طراحی پلتفرم حراج آنلاین مانند eBay
بررسی گامبهگام طراحی سیستم حراج آنلاین شامل ایجاد حراج، ثبت پیشنهاد با سازگاری قوی، دوام پیشنهادها با صف پیام، نمایش بلادرنگ بالاترین پیشنهاد و مقیاسدهی برای 10 میلیون حراج همزمان
درک مسئله
سرویس حراج آنلاین به کاربران اجازه میدهد کالاهایی را برای فروش فهرست کنند، در حالی که دیگران با ثبت پیشنهادهای (Bid) بالاتر و بالاتر برای خرید آنها رقابت میکنند تا حراج تمام شود و بالاترین پیشنهاد، کالا را ببرد.
مثل همه تحلیلهای رایج ما، این مسئله را گامبهگام و با راهنمایی چارچوب طراحی سیستم حل میکنیم. توجه کنید که اینجا از آنچه در مصاحبه لازم است یا ممکن است به آن برسید، وارد جزئیات بیشتری میشوم، اما فکر میکنم این جزئیات اضافه برای یادگیری مفاهیم و عمیقتر کردن درک مفید است.
نیازمندیهای عملکردی
نیازمندیهای اصلی:
- کاربر بتواند کالایی را با قیمت شروع و تاریخ پایان برای حراج ثبت کند.
- کاربر بتواند برای یک کالا پیشنهاد بدهد. پیشنهاد فقط وقتی پذیرفته میشود که از بالاترین پیشنهاد فعلی بیشتر باشد.
- کاربر بتواند یک حراج را ببیند، از جمله بالاترین پیشنهاد فعلی.
موارد خارج از محدوده:
- جستوجوی کالاها
- فیلتر کردن کالاها بر اساس دستهبندی
- مرتبسازی کالاها بر اساس قیمت
- مشاهده تاریخچه حراج یک کالا
نیازمندیهای غیرعملکردی
قبل از ورود به نیازمندیهای غیرعملکردی، از مصاحبهکننده درباره مقیاس مورد انتظار سیستم بپرسید. درک زودهنگام نیازمندیهای مقیاس به تصمیمهای کلیدی معماری در سراسر طراحی کمک میکند.
نیازمندیهای اصلی:
- سیستم باید برای پیشنهادها Strong Consistency (سازگاری قوی) داشته باشد تا همه کاربران بالاترین قیمت پیشنهادی را ببینند.
- سیستم باید تحملپذیر در برابر خطا و بادوام باشد. نباید هیچ پیشنهادی را از دست بدهیم.
- سیستم باید بالاترین قیمت پیشنهادی فعلی را بلادرنگ نمایش بدهد تا کاربران بدانند با چه چیزی رقابت میکنند.
- سیستم باید مقیاسی داشته باشد که از 10 میلیون حراج همزمان پشتیبانی کند.
موارد خارج از محدوده:
- مشاهدهپذیری (Observability) و مانیتورینگ مناسب
- امنیت سیستم و حفاظت از داده کاربر
- تست کامل و استقرار آسان (خطوط CI/CD)
آمادهسازی
تعریف موجودیتهای اصلی
با شناسایی موجودیتهای اصلی سیستم شروع میکنیم. فعلاً جزئیات را کم نگه میداریم و بعداً روی فیلدهای مشخص تمرکز میکنیم. تعریف این موجودیتهای کلیدی به ساختاردهی فکر ما برای طراحی API کمک میکند.
برای برآورده کردن نیازمندیهای عملکردی کلیدی، به این موجودیتها نیاز داریم:
- Auction (حراج): یک حراج برای یک کالا. شامل اطلاعاتی مثل قیمت شروع، تاریخ پایان و کالای در حال حراج.
- Item (کالا): کالایی که حراج میشود. شامل اطلاعاتی مثل نام، توضیحات و تصویر.
- Bid (پیشنهاد): پیشنهادی که در یک حراج داده میشود. شامل اطلاعاتی مثل مبلغ پیشنهاد، کاربر پیشنهاددهنده و حراجی که پیشنهاد در آن داده شده.
- User (کاربر): کاربر سیستم که حراج شروع میکند یا در حراج پیشنهاد میدهد.
اگرچه میتوانیم جزئیات کالا را مستقیم داخل موجودیت حراج قرار بدهیم، نرمالسازی آنها به موجودیتهای جدا مزایایی دارد:
- کالاها را میتوان در چند حراج دوباره استفاده کرد (مثلاً اگر فروشنده بخواهد کالای نفروخته را دوباره فهرست کند).
- جزئیات کالا را میتوان مستقل از جزئیات حراج بهروز کرد.
- میتوانیم بهراحتی ویژگیهای مخصوص کالا مثل دستهبندی یا جستوجو را اضافه کنیم.
در مصاحبه هر دو گزینه برای من خوب است. نکته کلیدی این است که استدلال خود را توضیح بدهید.
API یا رابط سیستم
قبل از ورود به طراحی سطح بالا، API را تعریف میکنیم، چون قراردادی بین کلاینت و سیستم میسازد. نیازمندیهای عملکردی را یکییکی مرور میکنیم و API های لازم را تعریف میکنیم.
برای ساخت حراج به endpoint ای از نوع POST نیاز داریم که جزئیات حراج را بگیرد و حراج ساختهشده را برگرداند:
1
2
3
4
5
6
7
POST /auctions -> Auction & Item
{
item: Item,
startDate: Date,
endDate: Date,
startingPrice: number,
}
برای ثبت پیشنهاد به endpoint ای از نوع POST نیاز داریم که جزئیات پیشنهاد را بگیرد و پیشنهاد ساختهشده را برگرداند:
1
2
3
4
POST /auctions/:auctionId/bids -> Bid
{
Bid
}
برای مشاهده حراجها به endpoint ای از نوع GET نیاز داریم که auctionId بگیرد و جزئیات حراج و کالا را برگرداند:
1
GET /auctions/:auctionId -> Auction & Item
طراحی سطح بالا
1) ثبت کالا برای حراج با قیمت شروع و تاریخ پایان
اول از همه، کاربران به راهی برای شروع حراج جدید نیاز دارند. این کار را با POST به endpoint /auctions همراه جزئیات حراج، از جمله اطلاعات کالایی که میفروشند، انجام میدهند.
با چیدن اجزای اصلی برای ارتباط کلاینت و میکروسرویسها شروع میکنیم. اولین سرویس ما یعنی Auction Service را اضافه میکنیم که به پایگاه دادهای متصل است که داده حراج و کالا را که در موجودیتهای اصلی بالا گفتیم ذخیره میکند. این سرویس خواندن و مشاهده حراجها را هم انجام میدهد.
- Client (مشتری): کاربران از طریق وبسایت یا اپلیکیشن با سیستم کار میکنند. همه درخواستهای کلاینت از طریق API Gateway به باطن میروند.
- API Gateway (دروازه API): درخواستهای کلاینت را مدیریت میکند، از جمله احراز هویت، Rate Limiting (محدودسازی نرخ درخواست) و مهمتر از همه، مسیریابی به سرویس مناسب.
- Auction Service: در این مرحله فقط یک لایه نازک روی پایگاه داده است. جزئیات حراج را از درخواست میگیرد، اعتبارسنجی میکند و در پایگاه داده ذخیره میکند.
- پایگاه داده: جدولهای حراجها و کالاها را ذخیره میکند.
این بخش طراحی فقط یک اپلیکیشن ساده CRUD است. اما بیایید درباره جریان داده صریح باشیم و دقیقاً بگوییم وقتی کاربر حراج جدید ثبت میکند چه اتفاقی میافتد:
- کلاینت یک
POSTهمراه جزئیات حراج به/auctionsمیفرستد. - API Gateway درخواست را به Auction Service میرساند.
- Auction Service درخواست را اعتبارسنجی میکند و داده حراج و کالا را در پایگاه داده ذخیره میکند.
- پایگاه داده داده حراج و کالا را در جدولها ذخیره میکند.
توجه کنید که اینجا چیزی درباره تصاویر نگفتم. این عمدی بود. جالبترین بخش مسئله نیست و نمیخواهم وقت را برایش هدر بدهم. در واقع تصاویر را در Blob Storage (ذخیرهسازی باینری حجیم) ذخیره میکنیم و در جدول کالاها با URL به آنها ارجاع میدهیم. میتوانید این را در مصاحبه بگویید تا درباره تمرکز بحث با مصاحبهکننده همراستا شوید.
2) ثبت پیشنهاد
پیشنهاد دادن جالبترین بخش این مسئله است و جایی که بیشترین زمان مصاحبه را صرف آن میکنیم. از سطح بالا شروع میکنیم و بعد در بررسی عمیق، جزئیات مقیاس و سازگاری را بررسی میکنیم.
برای مدیریت قابلیت پیشنهاد، یک Bidding Service اختصاصی و جدا از Auction Service معرفی میکنیم. این سرویس جدید:
- اعتبار پیشنهادهای دریافتی را بررسی میکند (مثلاً بررسی میکند مبلغ پیشنهاد از حداکثر فعلی بیشتر باشد).
- حراج را با بالاترین پیشنهاد جدید بهروز میکند.
- تاریخچه پیشنهادها را در پایگاه داده ذخیره میکند.
- طرفهای مربوط را از بهروزرسانی پیشنهادها باخبر میکند.
چند دلیل کلیدی برای جدا کردن پیشنهاددهی به سرویس مستقل داریم:
- مقیاسدهی مستقل: حجم ترافیک پیشنهادها معمولاً بسیار بیشتر از ساخت حراج است. انتظار داریم تعداد پیشنهادها 100 برابر حراجها باشد. سرویس جدا اجازه میدهد زیرساخت پیشنهاددهی را مستقل مقیاس بدهیم.
- جداسازی دغدغهها: پیشنهاددهی منطق کسبوکاری پیچیده خودش را دارد، از جمله اعتبارسنجی، Race Condition ها و بهروزرسانیهای همزمان. جدا نگه داشتن آن به تمیز ماندن مرزهای سرویس کمک میکند.
- بهینهسازی عملکرد: میتوانیم Bidding Service را مخصوصاً برای عملیات نوشتن با Throughput بالا بهینه کنیم، در حالی که Auction Service را میتوان برای بارکاری خواندنمحور بهینه کرد.
در سادهترین حالت، وقتی پیشنهادی ثبت میشود این اتفاقها میافتد:
- کلاینت یک
POSTهمراه جزئیات پیشنهاد به/auctions/:auctionId/bidsمیفرستد. - API Gateway درخواست را به Bidding Service میرساند.
- Bidding Service از پایگاه داده بالاترین پیشنهاد فعلی این حراج را میگیرد. پیشنهاد را در پایگاه داده با وضعیت «پذیرفتهشده» (اگر از بالاترین پیشنهاد فعلی بیشتر باشد) یا «ردشده» (اگر نه) ذخیره میکند. سپس وضعیت پیشنهاد را به کلاینت برمیگرداند.
- پیشنهادها در یک جدول جدید
Bidsذخیره میشوند که باauctionIdبه حراج وصل میشود.
وقتی این سؤال را میپرسم، بسیاری از داوطلبان پیشنهاد میکنند بهجای نگهداری جدول پیشنهادها، فقط یک فیلد maxBidPrice روی شیء حراج ذخیره شود. اگرچه سادهتر است، یک اصل اساسی یکپارچگی داده را نقض میکند: از تخریب داده تاریخی پرهیز کنید.
بازنویسی حداکثر پیشنهاد در هر بهروزرسانی یعنی از دست رفتن دائمی اطلاعات مهم. این کار ممیزی (Audit) فرایند پیشنهاددهی، بررسی اختلافها یا تحلیل الگوهای رفتاری را غیرممکن میکند. ناگزیر کاربری شکایت میکند که پیشنهادش ثبت نشده و باید برنده حراج میشد. بدون ردپای ممیزی کامل، راهی برای اثبات اشتباه او ندارید.
3) مشاهده حراج، از جمله بالاترین پیشنهاد فعلی
کاربران به دو دلیل باید بتوانند حراج را ببینند:
- میخواهند درباره کالا اطلاعات بگیرند، اگر تصمیم گرفتند به خریدش علاقهمندند.
- میخواهند پیشنهاد بدهند، پس باید بالاترین پیشنهاد فعلی را که باید ببرند بدانند.
این دو شبیه به هماند، اما نیازمندیهای متفاوتی دارند. اولی یک عملیات فقطخواندنی است. دومی سازگاری بلادرنگ میخواهد.
عمق این بحث را در بررسیهای عمیق اضافه میکنیم، اما بیایید روش پایهای را بگوییم که تضمین میکند بالاترین پیشنهاد فعلی دستکم بهطور معقول بهروز است.
وقتی کاربر برای اولین بار حراج را باز میکند، یک GET به /auctions/:auctionId میفرستد که حراج و جزئیات کالا را برای نمایش در صفحه برمیگرداند. عالی است.
اتفاق بعدی جالبتر است. اگر هرگز حداکثر قیمت پیشنهادی را تازه نکنیم، کاربر بر اساس مبلغ کهنه پیشنهاد میدهد و وقتی به او گفته میشود پیشنهادش پذیرفته نشده، گیج (و ناامید) میشود. بهویژه در حراجی با فعالیت زیاد این مشکل است. برای حل آن میتوانیم ساده هر چند ثانیه یکبار آخرین قیمت پیشنهادی را Poll کنیم. اگرچه ناقص است، دستکم درجهای از سازگاری تضمین میکند و احتمال اینکه به کاربر در لحظه بگوییم پیشنهادش پذیرفته نشده را کم میکند.
بررسیهای عمیق
با طراحی سطح بالا، وقت رفتن به عمق است. اینکه چقدر گفتوگو را اینجا هدایت میکنید، به سطح ارشدیت شما بستگی دارد. چند بررسی عمیق را مرور میکنیم که دوست دارم هنگام پرسیدن این سؤال پوشش بدهم، ولی یادتان باشد کامل نیستند.
1) تضمین Strong Consistency برای پیشنهادها
اطمینان از سازگاری پیشنهادها حیاتیترین جنبه طراحی سیستم حراج آنلاین ماست. با یک مثال ببینیم چرا همگامسازی درست ضروری است.
الگو: مدیریت رقابت (Dealing with Contention). حراجهای آنلاین نمونه کلاسیک الگوی مدیریت Contention هستند. وقتی چند کاربر همزمان در یک حراج پیشنهاد میدهند، با Race Condition روبهرو میشویم و باید مطمئن شویم فقط یک پیشنهاد برنده میشود. این هماهنگی دقیق میخواهد، با تکنیکهایی مثل Optimistic Concurrency Control، قفل ردیف (Row Locking) یا استراتژیهای کش برای مدیریت رقابت روی یک منبع.
مثال:
- بالاترین پیشنهاد فعلی 10 دلار است.
- کاربر A تصمیم میگیرد 100 دلار پیشنهاد بدهد.
- کاربر B کمی بعد تصمیم میگیرد 20 دلار پیشنهاد بدهد.
بدون کنترل همزمانی مناسب، توالی عملیات میتواند اینطور پیش برود:
- خواندن کاربر A: بالاترین پیشنهاد فعلی را که 10 دلار است میخواند.
- نوشتن کاربر A: پیشنهاد 100 دلاری را در پایگاه داده مینویسد. سیستم میپذیرد، چون 100 دلار از 10 دلار بیشتر است.
- خواندن کاربر B: بالاترین پیشنهاد فعلی را میخواند. بهخاطر تأخیر در انتشار داده یا مشکلات سازگاری خواندن، هنوز 10 دلار را میبیند، نه 100 دلار.
- نوشتن کاربر B: پیشنهاد 20 دلاری را در پایگاه داده مینویسد. سیستم میپذیرد، چون 20 دلار از 10 دلار (مقدار کهنهای که خوانده) بیشتر است.
نتیجه: هر دو پیشنهاد پذیرفته میشوند و حراج حالا دو کاربر دارد که فکر میکنند بالاترین پیشنهاد را دارند:
- پیشنهاد 100 دلاری کاربر A (بالاترین پیشنهاد واقعی)
- پیشنهاد 20 دلاری کاربر B (که باید رد میشد)
این ناسازگاری رخ میدهد، چون پیشنهاد 20 دلاری کاربر B بر اساس اطلاعات کهنه پذیرفته شد. در حالت ایدهآل، پیشنهاد کاربر B باید با بالاترین پیشنهاد بهروز، یعنی 100 دلار، مقایسه میشد و رد میشد.
پاسخی برای این سؤال هست که ادعا میکند Strong Consistency در واقع ضروری نیست. این استدلال را گاهی از داوطلبان Staff میبینم. استدلالشان این است که پذیرفتن هر دو پیشنهاد فرقی نمیکند. فقط باید سمت کلاینت را اصلاح کنیم و بعداً به کاربر B بگوییم پیشنهاد دیگری بالاتر از او بوده و دیگر برنده نیست. واقعیت این است که مهم نیست پیشنهاد کاربر A یا کاربر B اول وارد شده (مگر اینکه مبلغ برابر باشد). نتیجه نهایی یکسان است. کاربر A باید برنده حراج باشد.
این استدلال معتبر است و به رندر دقیق سمت کلاینت و فرایندی نیاز دارد که قبل از اطلاع دادن نتیجه نهایی به کاربران، صبر کند Eventual Consistency حل شود. هرچند بحث جالبی است، تا حد زیادی از پیچیدگی مسئله طفره میرود، بنابراین بیشتر مصاحبهکنندهها هنوز از شما میخواهند برای Strong Consistency حل کنید.
خوب، مسئله را فهمیدیم، اما چطور حلش کنیم؟
رویکرد بد: قفل ردیف با SELECT FOR UPDATE روی همه پیشنهادها
یک رویکرد اولیه ممکن است استفاده از قفل ردیف همراه کوئری گرفتن حداکثر پیشنهاد فعلی باشد. وقتی کاربر میخواهد پیشنهاد بدهد، باید مطمئن شویم هیچ پیشنهاد دیگری برای همان حراج همزمان پردازش نمیشود تا Race Condition رخ ندهد. اینطور پیاده میشود:
- شروع تراکنش: برای حفظ اتمی بودن، تراکنش پایگاه داده را شروع کنید.
- قفل همه ردیفهای پیشنهاد آن حراج با SELECT … FOR UPDATE: این کار همه پیشنهادهای موجود برای حراج را قفل میکند و تا پایان تراکنش فعلی، تراکنشهای دیگر نمیتوانند آنها را تغییر بدهند.
- کوئری حداکثر پیشنهاد فعلی از جدول پیشنهادها: بالاترین پیشنهاد فعلی حراج را بگیرید.
- مقایسه پیشنهاد جدید با آن: بررسی کنید مبلغ پیشنهاد دریافتی از حداکثر پیشنهاد فعلی بیشتر است یا نه.
- نوشتن پیشنهاد جدید در صورت پذیرش: پیشنهاد جدید را در جدول پیشنهادها درج کنید.
- Commit تراکنش: تراکنش را نهایی کنید تا تغییرات ماندگار شود.
کوئری SQL میتواند اینطور باشد:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
BEGIN;
WITH current_max AS (
SELECT MAX(amount) AS max_bid
FROM bids
WHERE auction_id = :auction_id
FOR UPDATE
)
INSERT INTO bids (auction_id, user_id, amount, bid_time)
SELECT :auction_id, :user_id, :amount, NOW()
FROM current_max
WHERE :amount > COALESCE(max_bid, 0)
RETURNING id;
COMMIT;
چالشها: اگرچه این رویکرد تضمین میکند هیچ دو پیشنهادی برای یک حراج همزمان پردازش نشود، اشکالات مهمی دارد:
- مشکلات عملکرد و مقیاسپذیری: قفل کردن همه ردیفهای پیشنهاد برای یک حراج، پردازش پیشنهادها را سریالی میکند و گلوگاه بزرگی میسازد. با زیاد شدن تعداد حراجها و پیشنهادهای همزمان، رقابت روی قفلها بیشتر میشود و به تأخیر بالاتر و تجربه کاربری بد میرسد. حتی ممکن است پایگاه داده زیر بار سنگین به قفل در سطح جدول تبدیل شود و همه عملیات روی جدول پیشنهادها مسدود شود.
- تجربه کاربری ضعیف: تأخیرهای ناشی از رقابت روی قفل و پردازش سریالی باعث زمان پاسخ کند یا Timeout هنگام ثبت پیشنهاد میشود. این در محیط رقابتی پیشنهاددهی که کاربران انتظار پاسخ بلادرنگ و عملکرد ثابت دارند، پذیرفتنی نیست.
بهطور کلی، این یک «نه» بزرگ است. میخواهید از قفل ردیف کم و بهینه استفاده کنید تا مطمئن شوید الف) تا حد ممکن ردیف کمی را قفل میکنید و ب) ردیفها را مدت کوتاهی قفل میکنید. اینجا هیچکدام از این دو اصل رعایت نمیشود.
رویکرد خوب: کش کردن حداکثر پیشنهاد در Redis
نکته بعدی که بیشتر داوطلبان به آن فکر میکنند، کش کردن حداکثر قیمت پیشنهادی است. متوجه میشوند مشکل اصلی رویکرد بالا این است که قفل را روی تعداد زیادی ردیف برای مدتی نگه میداریم، چون هر بار برای محاسبه حداکثر پیشنهاد باید جدول پیشنهادها را کوئری کنیم.
برای جلوگیری از این مشکل، میتوانیم حداکثر قیمت پیشنهادی را در یک ذخیرهساز درونحافظهای مثل Redis نگه داریم. حالا جریان داده هنگام ثبت پیشنهاد کاملاً فرق میکند:
- خواندن از کش: حداکثر قیمت پیشنهادی حراج مورد نظر را از Redis بخوانید.
- بهروزرسانی کش: اگر پیشنهاد جدید بالاتر است، کش را با حداکثر پیشنهاد جدید بهروز کنید.
- نوشتن پیشنهاد: پیشنهاد را با وضعیت پذیرفته یا رد شده در پایگاه داده بنویسید.
چالشها: یک مشکل را حل کردیم، اما مشکل دیگری ساختیم. حالا مسئله سازگاری را از پایگاه داده به سازگاری بین پایگاه داده و کش منتقل کردهایم. برای درست کار کردن به چند چیز نیاز داریم:
- خواندن و نوشتن کش باید اتمی باشد تا مثل سناریوی اولیه Race Condition نداشته باشیم. خوشبختانه این آسان است، چون Redis تکرشتهای (Single-threaded) است و عملیات اتمی را پشتیبانی میکند. میتوانیم از تراکنشهای Redis یا اسکریپتنویسی Lua استفاده کنیم تا دنباله خواندن، تغییر و نوشتن بهصورت یک عملیات اجرا شود:
1
2
3
4
5
6
7
8
9
10
-- Lua script to atomically compare and set max bid
local current_max_bid = tonumber(redis.call('GET', auction_key) or '0')
local proposed_bid_amount = tonumber(proposed_bid)
if proposed_bid_amount > current_max_bid then
redis.call('SET', auction_key, proposed_bid_amount)
return true
else
return false
end
- باید مطمئن شویم کش بهشدت با پایگاه داده سازگار است. در غیر این صورت ممکن است در وضعیتی بیفتیم که کش میگوید حداکثر پیشنهاد چیزی است، اما آن پیشنهاد (بهخاطر شکست یا هر مشکل دیگر) در پایگاه داده ما نیست. برای حل این مشکل چند گزینه داریم:
- از تراکنشهای توزیعشده (Two-phase Commit) استفاده کنید تا اتمی بودن در Redis و پایگاه داده تضمین شود. این پیچیدگی و سربار عملکردی قابل توجهی اضافه میکند.
- Redis را در طول حراج منبع حقیقت بدانید و ناهمگام در پایگاه داده بنویسید. این کار سازگاری را با عملکرد معاوضه میکند.
- از Optimistic Concurrency با منطق Retry استفاده کنید: اول کش را اتمی بهروز کنید، بعد در پایگاه داده بنویسید. اگر نوشتن پایگاه داده شکست خورد، بهروزرسانی کش را Rollback کنید.
مهمتر از همه، اگر در مصاحبهای دیدید که تراکنش توزیعشده لازم است، بررسی کنید آیا میتوانید سیستمتان را طوری بازطراحی کنید که از آن دوری کنید.
رویکرد عالی: نگه داشتن حداکثر پیشنهاد در پایگاه داده و OCC
مشکل قفل کردن ردیفهای زیاد برای مدت طولانی را با انتقال حداکثر پیشنهاد به کش حل کردیم، اما مشکل تازه سازگاری بین کش و پایگاه داده پیش آمد. با ذخیره حداکثر پیشنهاد در همان پایگاه داده میتوانیم هر دو مشکل را حل کنیم. عملاً از جدول حراج بهعنوان کش استفاده میکنیم.
جریان ما حالا این است:
- ردیف حراج را برای حراج مورد نظر قفل کنید (فقط یک ردیف).
- حداکثر قیمت پیشنهادی حراج را از جدول حراج بخوانید.
- پیشنهاد را با وضعیت پذیرفته یا رد شده در پایگاه داده بنویسید.
- اگر پیشنهاد جدید بالاتر است، حداکثر پیشنهاد را در جدول حراج بهروز کنید.
- تراکنش را Commit کنید.
حالا فقط یک ردیف و برای مدت کوتاه قفل میکنیم. اگر بخواهیم کاملاً از قفل بدبینانه (Pessimistic Locking) دوری کنیم، میتوانیم از Optimistic Concurrency Control (OCC) استفاده کنیم.
OCC برای سیستم ما ایدهآل است، چون تداخل پیشنهادها نسبتاً نادر است (بیشتر پیشنهادها در یک حراج همزمان نیستند). روش کار این است:
- ردیف حراج را بخوانید و حداکثر پیشنهاد فعلی را بگیرید (این همان چیزی است که در OCC «نسخه» مینامیم).
- تأیید کنید پیشنهاد جدید از حداکثر پیشنهاد بالاتر است.
- سعی کنید ردیف حراج را بهروز کنید، ولی فقط اگر حداکثر پیشنهاد تغییر نکرده باشد:
1
2
3
UPDATE auctions
SET max_bid = :new_bid
WHERE id = :auction_id AND max_bid = :original_max_bid
- اگر بهروزرسانی موفق بود، رکورد پیشنهاد را بنویسید. اگر موفق نبود، از مرحله 1 دوباره تلاش کنید.
این رویکرد کاملاً از قفل جلوگیری میکند و سازگاری را حفظ میکند، به قیمت Retry های گاهبهگاه در صورت بروز تداخل.
2) مقاوم و بادوام بودن سیستم
از دست دادن پیشنهاد غیرقابل قبول است. تصور کنید به کاربری بگویید حراجی را باخته، چون پیشنهاد برندهاش «گم شده». این برای اعتماد به پلتفرم فاجعهبار است. باید دوام (Durability) را تضمین کنیم و مطمئن شویم همه پیشنهادها ثبت و پردازش میشوند، حتی در صورت خرابی سیستم.
بهترین رویکرد اینجا معرفی یک Message Queue (صف پیام) بادوام و دریافت پیشنهادها در اولین فرصت است. این چند مزیت دارد:
- ذخیرهسازی بادوام: وقتی پیشنهادی میرسد، فوراً آن را در Message Queue مینویسیم. حتی اگر کل Bidding Service از کار بیفتد، پیشنهاد امن ذخیره میشود و گم نمیشود. مثل نوشتن اسم خودتان در لیست انتظار رستوران است. حتی اگر میزبان استراحت کند، جای شما در صف محفوظ است.
- بافر در برابر جهش بار: تصور کنید حراجی محبوب وارد دقایق پایانی میشود. ممکن است ناگهان هزاران پیشنهاد در ثانیه برسد، خیلی بیشتر از توان Bidding Service. بدون Message Queue باید یکی از این کارها را بکنیم: رها کردن پیشنهادها (غیرقابل قبول)، از کار افتادن زیر بار (آن هم غیرقابل قبول) یا تأمین بیش از حد سرورها (گران). با Message Queue، این جهشها قابل مدیریت میشوند. صف مثل بافر عمل میکند و پیشنهادها را موقتاً نگه میدارد تا Bidding Service بتواند پردازششان کند. مثل یک اتاق انتظار بینهایت بزرگ برای پیشنهادهاست.
- ترتیب تضمینشده: Message Queue ها (بهویژه Kafka) میتوانند تضمین کنند پیامها به همان ترتیبی که رسیدهاند پردازش شوند. این برای انصاف مهم است. اگر دو کاربر مبلغ یکسان پیشنهاد بدهند، میخواهیم مطمئن شویم اولین پیشنهاد برنده میشود. صف این ترتیب را رایگان به ما میدهد.
برای پیادهسازی، Kafka را بهعنوان Message Queue انتخاب میکنیم. اگرچه راهحلهای دیگری مثل RabbitMQ یا AWS SQS هم جواب میدهند، Kafka برای نیازهای ما مناسب است، چون:
- Throughput بالا: Kafka میتواند میلیونها پیام در ثانیه را مدیریت کند، که برای دورههای پرحجم پیشنهاددهی عالی است.
- دوام: Kafka همه پیامها را روی دیسک نگه میدارد و میتوان آن را برای Replication پیکربندی کرد، پس تضمین میشود هیچ پیشنهادی را از دست نمیدهیم.
- پارتیشنبندی: میتوانیم صف را بر اساس شناسه حراج پارتیشنبندی کنیم تا همه پیشنهادهای یک حراج به ترتیب پردازش شوند، و در عین حال پردازش موازی پیشنهادهای حراجهای مختلف ممکن باشد.
جریان کار این است:
- کاربر پیشنهاد را از طریق API ما میفرستد.
- API Gateway آن را به یک Producer میرساند که فوراً پیشنهاد را در Kafka مینویسد.
- Kafka نوشتن را تأیید میکند و میتوانیم به کاربر بگوییم پیشنهادش دریافت شده است.
- Bidding Service پیام Kafka را با سرعت خودش مصرف میکند.
- اگر پیشنهاد معتبر باشد، در پایگاه داده نوشته میشود.
- اگر Bidding Service در هر نقطه شکست بخورد، پیشنهاد در Kafka میماند و میتوان دوباره Retry کرد.
اگرچه صف پیام مقداری Latency اضافه میکند (معمولاً 2 تا 10 میلیثانیه در شرایط عادی)، این مبادله برای دوام معمولاً میارزد. الگوهای متعددی هم برای حفظ تجربه کاربری پاسخگو هنگام استفاده از صفها وجود دارد، اگرچه هرکدام مبادلات سازگاری و تأخیر متفاوتی دارند. از ترس عمیق شدن بیش از حد، اینجا واردش نمیشوم. اما بعداً درباره پخش پیشنهاد (Bid Broadcasting) صحبت میکنم که مشکل اعلان ناهمگام را حل میکند.
3) نمایش بلادرنگ بالاترین پیشنهاد فعلی
به نیازمندی عملکردی «کاربر بتواند حراج را ببیند، از جمله بالاترین پیشنهاد فعلی» برمیگردیم. راهحل فعلی از Polling استفاده میکند که چند مشکل کلیدی دارد:
- خیلی کند: بالاترین قیمت پیشنهادی را هر چند ثانیه بهروز میکنیم، اما برای حراج داغ ممکن است به اندازه کافی سریع نباشد.
- ناکارآمد: هر کلاینت در هر درخواست به پایگاه داده میزند و در اکثریت قریببهاتفاق موارد، حداکثر پیشنهاد تغییر نکرده است. این هدررفت است.
الگو: بهروزرسانی بلادرنگ (Real-time Updates). بهروز نگه داشتن کلاینتها بهصورت بلادرنگ درباره بالاترین پیشنهاد فعلی، نمونه عالی استفاده از الگوی بهروزرسانیهای بلادرنگ است.
حالا باید راهحل را گسترش بدهیم تا نیازمندی غیرعملکردی «سیستم بالاترین پیشنهاد فعلی را بلادرنگ نمایش بدهد» را برآورده کنیم.
رویکرد خوب: Long Polling
Long Polling با باز نگه داشتن اتصال به سرور تا زمانی که داده جدید در دسترس شود یا Timeout رخ بدهد، بهبود چشمگیری نسبت به Polling معمولی است. وقتی کلاینت میخواهد قیمت پیشنهادی فعلی حراج را زیر نظر بگیرد، اتصالی برقرار میکند که تا تغییر حداکثر قیمت پیشنهادی یا رسیدن به Timeout (معمولاً 30 تا 60 ثانیه) باز میماند.
سرور بهجای پاسخ فوری، درخواست را باز نگه میدارد. وقتی پیشنهاد جدیدی پذیرفته میشود، سرور به همه درخواستهای منتظر با حداکثر پیشنهاد بهروز پاسخ میدهد. سپس کلاینتها فوراً درخواست Long Polling جدیدی شروع میکنند و اتصال تقریباً پیوستهای نگه میدارند. پیادهسازی سمت کلاینت معمولاً چیزی شبیه این است:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
async function pollMaxBid(auctionId) {
try {
const response = await fetch(`/api/auctions/${auctionId}/max-bid`, {
timeout: 30000 // 30 second timeout
});
if (response.ok) {
const { maxBid } = await response.json();
updateUI(maxBid);
}
} catch (error) {
// Handle timeout or error
}
// Immediately start the next long poll
pollMaxBid(auctionId);
}
در سمت سرور، نگاشتی از درخواستهای منتظر برای هر حراج نگه میداریم. وقتی پیشنهاد جدیدی پذیرفته میشود، به همه درخواستهای منتظر آن حراج با حداکثر پیشنهاد جدید پاسخ میدهیم.
چالشها: اگرچه Long Polling بهتر از Polling معمولی است، هنوز محدودیت دارد. سرور باید برای همه کلاینتهایی که حراج را تماشا میکنند اتصال باز نگه دارد، که هنگام کار با حراجهای محبوب منابع زیادی مصرف میکند. علاوه بر این، اگر کلاینتهای زیادی یک حراج را تماشا کنند، ممکن است با مشکل Thundering Herd (هجوم گلهای) روبهرو شویم، یعنی همه کلاینتها بعد از دریافت بهروزرسانی همزمان سعی میکنند دوباره وصل شوند.
مکانیزم Timeout هم تأخیر کوچکی میآورد: اگر پیشنهادی درست بعد از پایان Long Poll قبلی کلاینت ثبت شود، کلاینت تا کامل شدن درخواست بعدی، بهروزرسانی را نمیبیند. این بین مصرف منابع (Timeout کوتاهتر یعنی درخواست بیشتر) و Latency (Timeout طولانیتر یعنی احتمال تأخیر بهروزرسانی) تعادل برقرار میکند.
در نهایت، مقیاسدهی چالش میشود، چون هر سرور باید مجموعه اتصالهای باز خودش را نگه دارد. اگر بخواهیم با اضافه کردن سرور افقی مقیاس بدهیم، به زیرساخت اضافه مثل Redis یا Message Queue برای هماهنگ کردن بهروزرسانیهای پیشنهاد بین همه سرورها نیاز داریم. این پیچیدگی و نقاط شکست احتمالی به سیستم اضافه میکند. وقتی درباره مقیاسدهی صحبت کنیم، بیشتر به آن میپردازیم.
رویکرد عالی: Server-Sent Events (SSE)
Server-Sent Events راهحل ظریفتری برای بهروزرسانی بلادرنگ پیشنهادها ارائه میکند. SSE یک کانال یکطرفه از سرور به کلاینت میسازد و به سرور اجازه میدهد هر وقت بهروزرسانی رخ داد، بدون نیاز به Polling یا نگه داشتن چند اتصال از سمت کلاینت، آن را بفرستد.
وقتی کاربر حراجی را میبیند، مرورگرش یک اتصال SSE برقرار میکند. سپس سرور میتواند هر وقت حداکثر پیشنهاد جدید تغییر کرد، آن را از طریق این اتصال بفرستد. این تجربه واقعاً بلادرنگ میسازد و از Polling معمولی و Long Polling کارآمدتر است. پیادهسازی سمت کلاینت بسیار ساده است:
1
2
3
4
5
6
const eventSource = new EventSource(`/api/auctions/${auctionId}/bid-stream`);
eventSource.onmessage = (event) => {
const { maxBid } = JSON.parse(event.data);
updateUI(maxBid);
};
در سمت سرور، مجموعهای از اتصالهای فعال SSE را برای هر حراج نگه میداریم. وقتی پیشنهاد جدیدی پذیرفته میشود، حداکثر پیشنهاد بهروز را به همه کلاینتهای متصل آن حراج Push میکنیم. پیادهسازی سرور میتواند چیزی شبیه این باشد:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class AuctionEventManager {
private connections: Map<string, Set<Response>> = new Map();
addConnection(auctionId: string, connection: Response) {
if (!this.connections.has(auctionId)) {
this.connections.set(auctionId, new Set());
}
this.connections.get(auctionId).add(connection);
}
broadcastNewBid(auctionId: string, maxBid: number) {
const connections = this.connections.get(auctionId);
if (connections) {
const event = `data: ${JSON.stringify({ maxBid })}\n\n`;
connections.forEach(response => response.write(event));
}
}
}
میتوانید اینجا از WebSocket هم استفاده کنید، اما SSE مسلماً مناسبتر است، چون ارتباط یکطرفه است و SSE سبکتر و سادهتر پیاده میشود.
چالشها: چالش اصلی، مقیاسدهی بهروزرسانیهای بلادرنگ در چند سرور است. با رشد پایگاه کاربران به چند سرور برای مدیریت همه اتصالهای SSE نیاز داریم. اما این مشکل هماهنگی میسازد: وقتی پیشنهاد جدیدی به سرور A میرسد، ممکن است کاربرانی همان حراج را تماشا کنند که به سرور B وصلاند. بدون زیرساخت اضافه، سرور A راهی برای اطلاع دادن به آن کاربران از پیشنهاد جدید ندارد، چون فقط از اتصالهای خودش خبر دارد.
مثلاً تصور کنید کاربر 1 و کاربر 2 هر دو حراج X را تماشا میکنند، اما کاربر 1 به سرور A و کاربر 2 به سرور B وصل است. اگر کاربر 3 پیشنهاد بدهد و به سرور A برسد، فقط کاربر 1 بهروزرسانی میگیرد. کاربر 2 با داده کهنه میماند، چون سرور B از پیشنهاد جدید بیخبر است. در ادامه، هنگام صحبت از مقیاسدهی، راهحل این مشکل را میگوییم.
4) پشتیبانی از 10 میلیون حراج همزمان
وقتی نوبت بحث مقیاس میرسد، معمولاً میخواهید برای هر سؤال طراحی سیستم فرایند مشابهی را دنبال کنید. از چپ به راست، هر جزء طراحی را ارزیابی کنید و این سؤالها را بپرسید:
- منابع لازم در اوج چیست؟ ذخیرهسازی، محاسبه و پهنای باند را در نظر بگیرید.
- آیا طراحی فعلی نیاز اوج را برآورده میکند؟
- اگر نه، چطور میتوانیم آن جزء را برای برآورده کردن نیاز جدید مقیاس بدهیم؟
با چند فرض عملیاتی اولیه شروع میکنیم. 10 میلیون حراج همزمان داریم و هر حراج 100 پیشنهاد دارد. یعنی 1 میلیارد پیشنهاد در روز. 1 میلیارد پیشنهاد تقسیم بر 100 هزار ثانیه (گردشده یک روز) میشود 10 هزار پیشنهاد در ثانیه.
Message Queue. با صف پیام برای مدیریت پیشنهادها شروع میکنیم. با 10 هزار درخواست در ثانیه و سختافزار مناسب، میتواند بدون مشکل این کار را انجام بدهد. پس حتی لازم نیست صف را پارتیشنبندی کنیم.
Bidding Service. در مرحله بعد، هم Bidding Service (مصرفکننده Message Queue) و هم Auction Service را بررسی میکنیم. مثل تقریباً همه سرویسهای بدون حالت (Stateless)، میتوانیم با اضافه کردن سرور بیشتر آنها را افقی مقیاس بدهیم. با فعال کردن Auto Scaling میتوانیم همیشه تعداد مناسب سرور را بر اساس مصرف حافظه یا CPU برای مدیریت بار فعلی داشته باشیم.
پایگاه داده. لایه ماندگاری را بررسی میکنیم. با ذخیرهسازی شروع میکنیم. میتوانیم عدد گرد بگیریم و بگوییم هر حراج 1 کیلوبایت است و هر پیشنهاد 500 بایت. اگر حراج متوسط یک هفته اجرا شود، 10 میلیون ضربدر 52 یعنی 520 میلیون حراج در سال داریم. یعنی 520 میلیون ضربدر (1 کیلوبایت + 0.5 کیلوبایت ضربدر 100 پیشنهاد برای هر حراج)، که حدود 25 ترابایت ذخیرهسازی در سال میشود.
این مقدار قطعاً قابل مدیریت است و چیز عجیبی نیست. SSD های مدرن میتوانند بیش از 100 ترابایت ذخیرهسازی را تحمل کنند. با اینکه میخواهیم برای چند Replica داغ احتیاط کنیم، به این زودیها فضای ذخیرهسازی تمام نمیشود. عاقلانه است که Shard کنیم، اما محدودیت جدیتر توان نوشتن است.
10 هزار نوشتن در ثانیه در توان یک نمونه Postgres با پیکربندی خوب است. اگر بخواهیم راهحل فعلی برای مدیریت سازگاری را حفظ کنیم، باید پایگاه داده را بر اساس شناسه حراج Shard کنیم تا بار نوشتن را تحمل کنیم. نگران تجمیع Scatter-gather نیستیم، چون همه خواندن و نوشتنهای یک حراج روی یک Shard است.
SSE. در نهایت گفتیم راهحل SSE ما برای پخش پیشنهادهای جدید به کاربران خوب مقیاس نمیگیرد. برای یادآوری: مشکل این است که وقتی پیشنهاد جدید میرسد، باید آن را برای همه کلاینتهایی که آن حراج را تماشا میکنند پخش کنیم. اگر 10 میلیون حراج و 100 میلیون کاربر داشته باشیم، ممکن است 100 میلیون اتصال داشته باشیم. این تعداد اتصال زیادی است و روی یک سرور جا نمیشود. بنابراین به راهی برای هماهنگ کردن سرورهای Bidding Service با هم نیاز داریم.
راهحل Pub/Sub (الگوی انتشار/اشتراک) است. چه از فناوریهایی مثل Redis Pub/Sub یا Kafka استفاده کنیم و چه راهحل سفارشی، باید پیشنهاد جدید را برای همه سرورهای مربوط پخش کنیم تا بتوانند بهروزرسانی را به کلاینتهای متصل خودشان بفرستند. روش کار ساده است: وقتی سرور A پیشنهاد جدیدی میگیرد، آن را در سیستم Pub/Sub منتشر میکند. همه سرورهای Bidding Service دیگر در همان Topic یا کانال مشترکاند و پیام را میگیرند. اگر پیشنهاد مربوط به حراجی باشد که یکی از اتصالهای کلاینت فعلی آنها مشترک آن است، داده پیشنهاد جدید را روی آن اتصال میفرستند.
اگر میخواهید بیشتر درباره Pub/Sub بدانید، تحلیل «نظرات زنده Facebook» (FB Live Comments) را ببینید که در آنجا با جزئیات بیشتری به آن میپردازم.
بررسیهای عمیق اضافی
جهتها و بسطهای احتمالی زیادی برای این سؤال هست. اینها چند مورد از محبوبترینهاست:
- زمان پایان پویای حراج: چطور میتوانید حراج را بهصورت پویا تمام کنید تا زمانی که یک ساعت از آخرین پیشنهاد بگذرد، حراج به پایان برسد؟ برای این، راهحل ساده و غیردقیقی هست که احتمالاً به اندازه کافی خوب است: با هر پیشنهاد جدید، زمان پایان حراج را در جدول حراج بهروز میکنید. سپس یک Cron Job میتواند دورهای اجرا شود تا دنبال حراجهایی بگردد که تمام شدهاند. اگر مصاحبهکننده چیز دقیقتری بخواهد، میتوانید هر پیشنهاد جدید را به صفی مثل SQS با Visibility Timeout یکساعته اضافه کنید. یک Worker بعد از یک ساعت هر آیتم را پردازش میکند و بررسی میکند آن پیشنهاد هنوز برنده است یا نه. اگر هست، حراج را ببندید.
- خرید: در پایان حراج برای برنده ایمیل بفرستید و از او بخواهید هزینه کالا را پرداخت کند. اگر ظرف N دقیقه یا ساعت یا روز پرداخت نکرد، به بالاترین پیشنهاددهنده بعدی بروید.
- تاریخچه پیشنهادها: چطور به کاربر اجازه میدهید تاریخچه پیشنهادهای یک حراج را بلادرنگ ببیند؟ این در اصل همان مسئله بهروزرسانی بلادرنگ پیشنهادهاست، پس میتوانید به راهحل آن مراجعه کنید.
انتظارات در هر سطح
سطح Mid-level
این سؤال برای داوطلب Mid-level چالشبرانگیز است و صادقانه بگویم، معمولاً آن را در مصاحبههای Mid-level نمیپرسم. با این حال، دنبال داوطلبی هستم که بتواند طراحی سطح بالا بسازد و بعد بهطور معقول به سؤالهای سازگاری، مقیاسدهی و سایر نیازمندیهای غیرعملکردی پاسخ بدهد. احتمالاً من گفتوگو را به آنجا هدایت میکنم، اما توانایی داوطلب را در حل مسئله، فکر کردن در لحظه و ارائه راهحلهای معقول، با وجود نداشتن تجربه عملی، میسنجم. مثلاً ممکن است راهحل ذخیرهسازی را بدون درک جنبههای منفی تراکنشهای توزیعشده پیشنهاد کنند.
سطح Senior
برای داوطلبان Senior انتظار دارم بفهمند سازگاری و پخش بلادرنگ در پیشنهاددهی مسئله اصلی است و گفتوگو را به سمت راهحلی هدایت کنند که این شرایط را برآورده میکند. ممکن است همهجا وارد جزئیات نشوند، اما باید بتوانند به اندازه کافی به راهحلی برای مسئله سازگاری برسند و توضیح بدهند بهروزرسانی پیشنهادها چطور به کلاینت میرسد. اگرچه ممکن است زمان کافی برای بحث درباره مقیاس نداشته باشند، انتظار دارم بسیاری از مشکلاتی را که سیستمشان خودش معرفی کرده، تشخیص بدهند.
سطح Staff
برای مهندسان Staff انتظار دارم تسلطشان را بر چالشهای اصلی پیرامون سازگاری و بهروزرسانیهای همزمان نشان بدهند و در عین حال ملاحظات پیچیده اضافه را هم فعالانه مطرح کنند. داوطلب قوی ممکن است بدون درخواست، درباره چگونگی پایان دادن حراجها و چالشهای منحصربهفرد سیستمهای توزیعشده بحث کند. او توضیح میدهد که اگرچه زمانهای پایان ثابت ساده به نظر میرسند، پیادهسازی پایانهای پویا (که حراج پس از پیشنهادهای دیرهنگام ادامه پیدا میکند) به هماهنگی دقیق برای مدیریت اختلاف ساعت (Clock Skew)، تلاشهای همزمان برای بستن حراج و پیشنهادهای معتبری که با تأخیر میرسند نیاز دارد. ممکن است یک سرویس زمانبندی اختصاصی با ابزارهایی مثل Apache Airflow یا راهحل سفارشی مبتنی بر صف برای مدیریت قابل اعتماد تکمیل حراج پیشنهاد کند.
این نوع بررسی عمیق خودجوش در مسائل مجاور، چه تکمیل حراج باشد، چه جلوگیری از تقلب، چه مدیریت خرابی سیستم، وسعت فنی و تفکر رهبری مورد انتظار در سطح Staff را نشان میدهد. در نهایت، مهمترین چیز این است که گفتوگو را رهبری کنند، عمیق شوند و دقت و تخصص فنی نشان بدهند.