پست

طراحی سیستم Flash Sale

طراحی سیستم فروش ناگهانی برای فروش موجودی محدود تحت تقاضای شدید بدون Overselling همراه با Waiting Room و عدالت در دسترسی

طراحی سیستم Flash Sale

درک مسئله

سیستم Flash Sale چیست؟ برخلاف بسیاری از مصاحبه‌های طراحی سیستم که در آن‌ها یک محصول کامل طراحی می‌شود، این مسئله روی یک بخش به‌خصوص چالش‌برانگیز از سیستم تجارت الکترونیک زوم می‌کند: فروش مقدار بسیار محدودی موجودی تحت تقاضای شدید.

برای ملموس شدن مسئله، تصور کنید Nike ساعت ظهر ۱۰٬۰۰۰ جفت از یک کفش مدل محدود را عرضه می‌کند. ممکن است ده‌ها میلیون کاربر ظرف چند ثانیه سعی کنند آن را بخرند.

کار ما طراحی سیستمی است که به آن کاربران اجازه می‌دهد برای موجودی در دسترس منصفانه رقابت کنند و خریدشان را بدون هیچ Overselling کامل کنند.

نیازمندی‌های عملکردی

نیازمندی‌های اصلی:

  • کاربران بتوانند آیتم Flash Sale را ببینند.
  • کاربران بتوانند در حالی که فروش فعال است، یک واحد موجود را برای مدت محدود تضمین/رزرو کنند.
  • کاربرانی که واحدی را تضمین کرده‌اند بتوانند پرداخت را کامل کنند و آن را بخرند.

نیازمندی دوم همان است که نامزدها بیشتر از همه از قلم می‌اندازند. وسوسه‌انگیز است که مستقیم از دیدن محصول به خرید آن بروید، ولی پرداخت از طریق شخص ثالث انجام می‌شود و چند ثانیه طول می‌کشد و نمی‌توانید موجودی را در طول آن مدت داخل یک تراکنش باز پایگاه داده نگه دارید. رزرو چیزی است که به شما اجازه می‌دهد فوراً یک واحد را از دسترس خارج کنید و پول را بعداً تسویه کنید. اگر آن را رد کنید، بیشتر مصاحبه‌کننده‌ها شما را به سمتش برمی‌گردانند، هرچند خودتان به آن رسیدن بهتر از این است که هدایت شوید.

خارج از محدوده:

  • کاربران بتوانند کاتالوگ گسترده‌تر محصول را مرور یا جستجو کنند.
  • کاربران بتوانند ارسال، مرجوعی، بازپرداخت یا تاریخچه‌ی سفارش را مدیریت کنند.
  • ادمین‌ها بتوانند Flash Sale بسازند، پیکربندی یا مدیریت کنند.

نیازمندی‌های غیرعملکردی

برخلاف نیازمندی‌های عملکردی، نیازمندی‌های غیرعملکردی کیفیت‌های سیستم را توصیف می‌کنند که برای کاربران مهم‌اند. معمولاً به شکل «سیستم باید بتواند…» بیان می‌شوند.

نیازمندی‌های اصلی:

  • سیستم باید بتواند از Overselling جلوگیری کند، حتی وقتی افراد زیادی در یک لحظه می‌خواهند همان واحد را ادعا کنند.
  • سیستم باید بتواند میلیون‌ها کاربری را که ظرف چند ثانیه بعد از شروع فروش می‌رسند مدیریت کند.
  • سیستم باید به کاربران فرصت منصفانه برای رقابت بر سر موجودی محدود بدهد.

خارج از محدوده:

  • سیستم باید از داده‌ی کاربر محافظت کند و به مقرراتی مثل GDPR پایبند باشد.
  • سیستم باید Fault Tolerant باشد.
  • سیستم باید تراکنش‌های امن برای خرید ارائه دهد.

روی تخته‌سفید ممکن است این‌طور باشد:

  • عملکردی: ۱) دیدن آیتم فروش، ۲) رزرو آیتم برای زمان محدود، ۳) خرید آیتم رزروشده.
  • غیرعملکردی: ۱) درستی تحت Contention، ۲) مقیاس‌پذیر برای ۱۰ میلیون کاربری که همه یک محصول را می‌خواهند، ۳) عدالت.

رویکرد برنامه‌ریزی

پیش از رفتن سراغ طراحی، کمی رویکردتان را برنامه‌ریزی کنید. خوشبختانه راهبرد ساده است اگر چارچوب تحویل را دنبال کنید و سیستم را به‌ترتیب، با برآورده کردن یک نیازمندی عملکردی در هر بار، بسازید.

این شما را متمرکز نگه می‌دارد و مانع می‌شود خیلی زود در مسائل مقیاس‌دهی جذاب‌تر گم شوید. وقتی نیازمندی‌های عملکردی را برآورده کردیم، از نیازمندی‌های غیرعملکردی برای هدایت Deep Dive ها استفاده می‌کنیم.

تعریف Entityهای اصلی

با آماده بودن نیازمندی‌ها، اولین چیزی که توصیه می‌کنیم تعریف Entityهای اصلی یا اسم‌های اصلی سیستم است. در این مرحله لازم نیست نگران هر ستون یا فیلد باشیم. فقط داده‌ای را شناسایی می‌کنیم که مرکز API های ما خواهد بود و در نهایت باید ماندگار شود.

با پیمودن نیازمندی‌های عملکردی به چهار Entity اصلی می‌رسیم:

  • Product: کفشی که در Flash Sale فروخته می‌شود. شامل جزئیات محصول، قیمت، زمان شروع و پایان فروش و تعداد واحدهای موجود است. یک جفت یک واحد از آن محصول است.
  • Reservation: ادعای موقت روی یک واحد موجودی در حالی که کاربر Checkout را کامل می‌کند. کاربر، محصول، زمان انقضا و وضعیت فعلی را ردیابی می‌کند.
  • Purchase: رکورد یک خرید موفق که کاربر، محصول، رزرو، مبلغ پرداخت و وضعیت پرداخت را به هم وصل می‌کند.
  • User: فردی که سعی می‌کند در Flash Sale شرکت کند و محصول را بخرد.

در مصاحبه‌ی واقعی، این می‌تواند فقط فهرست کوتاهی باشد. فقط مطمئن شوید پیش از ادامه با مصاحبه‌کننده درباره‌ی Entityها صحبت کرده‌اید تا هر دو در یک مسیر باشید.

API یا رابط سیستم

با تعریف Entityها می‌توانیم سراغ API ها برویم. نیازمندی‌های عملکردی را یکی‌یکی پیش می‌بریم و API خارجی لازم برای هرکدام را تعریف می‌کنیم.

نیازمندی اول این است که کاربران بتوانند آیتم Flash Sale را ببینند. برای این، Endpoint ساده‌ی GET لازم داریم که productId را بگیرد و محصول متناظر را برگرداند:

1
GET /products/:productId -> Product

Product برگشتی اطلاعات لازم برای رندر صفحه‌ی فروش را دارد، از جمله جزئیات محصول، قیمت، بازه‌ی فروش و تعداد واحدهای موجود.

بعد، کاربران باید بتوانند برای مدت محدودی واحد موجود را تضمین کنند. یک Endpoint از نوع POST در اختیار می‌گذاریم که سعی می‌کند برای محصول داده‌شده رزرو بسازد:

1
POST /products/:productId/reservations -> Reservation

اگر موجودی باشد، سیستم رزرو را می‌سازد و به کلاینت برمی‌گرداند. Reservation برگشتی شامل reservationId، productId و Timestamp ای به نام expiresAt است، که همه‌ی چیزهایی است که کلاینت لازم دارد تا شمارش معکوس نشان دهد و بعد Endpoint خرید را صدا بزند. مهم است که این به معنای خرید واحد توسط کاربر نیست. فقط پنجره‌ی محدودی برای کامل کردن Checkout به او می‌دهد. اینکه چطور وقتی میلیون‌ها کاربر برای همان موجودی رقابت می‌کنند این رزروها را ایمن می‌سازیم، یکی از چالش‌های اصلی است که بعداً به آن می‌پردازیم.

در نهایت، کاربری با رزرو فعال باید بتواند خریدش را کامل کند:

1
POST /reservations/:reservationId/purchases -> Purchase

این Endpoint سعی می‌کند از کاربر پول بگیرد و رزرو او را به خرید کامل‌شده تبدیل کند. اگر رزرو منقضی شده یا پرداخت شکست بخورد، درخواست شکست می‌خورد و موجودی سرانجام می‌تواند در دسترس کاربر دیگری قرار گیرد.

در مصاحبه، این تقریباً همان مقدار جزئیاتی است که در این مرحله لازم دارید. مصاحبه‌کننده معمولاً می‌تواند فیلدهای درخواست و پاسخ را از Entityها و نیازمندی‌هایی که تعریف کرده‌ایم استنباط کند. هر وقت طراحی پیچیده‌تر شد، همیشه می‌توانیم API ها را تکامل دهیم.

طراحی سطح بالا

۱) کاربران بتوانند آیتم Flash Sale را ببینند

با اولین نیازمندی عملکردی شروع می‌کنیم. وقتی کاربر به صفحه‌ی Flash Sale می‌رود، باید جزئیات محصول و تعداد واحدهای باقی‌مانده را ببیند. این مسیر خواندن ساده‌ای است. کلاینت GET /products/:productId را صدا می‌زند، Backend محصول متناظر را در پایگاه داده پیدا می‌کند و به کلاینت برمی‌گرداند. فعلاً موجودی در دسترس را مستقیم روی ردیف محصول به شکل inventoryCount نگه می‌داریم.

چهار جزء اصلی دخیل‌اند:

  • Client: کاربران از طریق وب‌سایت یا اپ موبایل با Flash Sale تعامل می‌کنند. درخواست‌های کلاینت از طریق API Gateway به Backend می‌روند.
  • API Gateway: نقطه‌ی ورود Backend. درخواست‌ها را به سرویس مناسب هدایت می‌کند و می‌تواند دغدغه‌های مشترک مثل احراز هویت، Rate Limiting و لاگ را هم مدیریت کند.
  • Sale Service: سرویس اپلیکیشنی ما که API های Flash Sale را مدیریت می‌کند. برای این نیازمندی اول، درخواست‌های دیدن محصول را با گرفتن آن از پایگاه داده و برگرداندن به کلاینت مدیریت می‌کند.
  • Database: داده‌ی ماندگار ما را ذخیره می‌کند. از Postgres استفاده می‌کنیم تا بعداً برای اهداف درستی به تضمین‌های ACID تکیه کنیم. فعلاً فقط جدول Product را در نظر داریم که فیلدهایی مثل productId، inventoryCount، price، saleStartTime و saleEndTime دارد.

وقتی کاربر صفحه‌ی Flash Sale را باز می‌کند:

  1. کلاینت درخواست REST به نام GET /products/:productId می‌فرستد.
  2. API Gateway درخواست را به Sale Service می‌فرستد.
  3. Sale Service از پایگاه داده محصول متناظر را Query می‌کند.
  4. جزئیات محصول، از جمله inventoryCount فعلی، به کلاینت برمی‌گردد.

آسان!

۲) کاربران بتوانند در حین فعال بودن فروش، واحد موجود را برای زمان محدود تضمین/رزرو کنند

بعد باید به کاربر اجازه دهیم موقتاً یکی از واحدهای موجود را در حالی که Checkout را کامل می‌کند تضمین کند. برای پشتیبانی از این، جدول Reservation را به پایگاه داده اضافه می‌کنیم. هر رزرو userId، productId، وضعیت فعلی و Timestamp ای به نام expiresAt را ثبت می‌کند که به کاربر پنجره‌ی محدودی برای کامل کردن خرید می‌دهد.

وقتی کلاینت POST /products/:productId/reservations را صدا می‌زند، Sale Service بررسی می‌کند موجودی هنوز در دسترس است، رزرو می‌سازد و inventoryCount محصول را یکی کم می‌کند. جریان:

  1. کلاینت POST /products/:productId/reservations می‌فرستد.
  2. API Gateway درخواست را به Sale Service می‌فرستد.
  3. Sale Service تراکنشی باز می‌کند و بررسی می‌کند محصول در حال فروش است و موجودی باقی دارد.
  4. ردیف جدیدی در جدول Reservation با زمان انقضا، مثلاً ۱۰ دقیقه در آینده، درج می‌کند.
  5. inventoryCount محصول را یکی کم می‌کند و تراکنش را Commit می‌کند.
  6. رزرو به کلاینت برگردانده می‌شود و تا expiresAt به کاربر زمان کامل کردن خرید می‌دهد.

مراحل ۳ تا ۵ باید یک تراکنش واحد باشند. اگر بررسی و کاهش بتوانند از هم جدا شوند، دو کاربر هر دو آخرین واحد را موجود می‌خوانند و هر دو برای آن رزرو می‌گیرند، که همان Overselling است که نیازمندی‌های ما رد می‌کنند.

البته همه‌ی کسانی که واحدی را رزرو می‌کنند واقعاً نمی‌خرند. کاربر ممکن است تب را ببندد، پرداختش رد شود یا نظرش عوض شود. باید آن رزروها سرانجام منقضی شوند تا موجودی برای همیشه قفل نماند.

فعلاً این را با Cron Job ساده‌ای مدیریت می‌کنیم که دوره‌ای اجرا می‌شود. رزروهای فعالی را Query می‌کند که زمان expiresAtشان گذشته، آن‌ها را منقضی علامت می‌زند و inventoryCount محصول متناظر را زیاد می‌کند تا آن واحدها دوباره در دسترس شوند. مثل خود رزرو، آن جفت نوشتن در یک تراکنش انجام می‌شود. اگر رزروی را بدون برگرداندن واحدش منقضی کنیم، موجودی‌ای را از دست می‌دهیم که هیچ‌کس نمی‌تواند بخرد.

این یعنی واحد منقضی‌شده ممکن است بسته به تکرار اجرای Job مدت کوتاهی طول بکشد تا به موجودی برگردد، ولی برای طراحی اولیه‌ی ما اشکالی ندارد. در Deep Dive ها وقتی نگران مقیاس شدیم بهترش می‌کنیم.

۳) کاربرانی که واحدی را تضمین کرده‌اند بتوانند پرداخت را کامل کنند و آن را بخرند

در نهایت، کاربران دارای رزرو فعال باید بتوانند خریدشان را کامل کنند. جدول Purchase را برای ردیابی پرداخت اضافه می‌کنیم و از پردازشگر پرداخت خارجی مثل Stripe استفاده می‌کنیم تا لازم نباشد جزئیات حساس پرداخت را خودمان مدیریت کنیم.

وقتی کلاینت POST /reservations/:reservationId/purchases را صدا می‌زند، Sale Service اول بررسی می‌کند رزرو هنوز فعال است. بعد Purchase ای با وضعیت PENDING می‌سازد و از Stripe می‌خواهد برای خرید یک PaymentIntent بسازد. Stripe یک Client Secret برمی‌گرداند که به کلاینت پاس می‌دهیم.

پردازش پرداخت تا حد زیادی خارج از محدوده‌ی این مسئله است. اگر می‌خواهید جزئیات پشت PaymentIntentها، وضعیت پرداخت، Retry و یکپارچه‌سازی ایمن با پردازشگر خارجی مثل Stripe را بفهمید، تحلیل «طراحی سیستم پرداخت» را ببینید.

از آنجا، کلاینت مستقیم با Stripe ارتباط می‌گیرد تا جزئیات پرداخت کاربر را ارسال و پرداخت را تأیید کند. وقتی پرداخت موفق یا ناموفق شد، Stripe یک Webhook با نتیجه به Sale Service ما می‌فرستد.

Webhook درخواست HTTP است که یک سیستم هنگام رخ دادن رویدادی به سیستم دیگر می‌فرستد. اینجا به‌جای اینکه Sale Service مدام از Stripe بپرسد پرداخت موفق شده یا نه، Stripe وقتی پرداخت به وضعیت نهایی رسید یک Endpoint روی Backend ما را صدا می‌زند و وضعیت پرداخت را به ما می‌گوید.

جریان کامل:

  1. کلاینت POST /reservations/:reservationId/purchases را می‌فرستد.
  2. API Gateway درخواست را به Sale Service می‌فرستد.
  3. Sale Service بررسی می‌کند رزرو وجود دارد و منقضی نشده است.
  4. Purchase‌ای با status = PENDING می‌سازد.
  5. Sale Service یک Stripe PaymentIntent می‌سازد و Client Secret را به کلاینت برمی‌گرداند.
  6. کلاینت جزئیات پرداخت را مستقیم به Stripe می‌فرستد و پرداخت را تأیید می‌کند.
  7. Stripe نتیجه را با Webhook به Sale Service می‌فرستد.
  8. اگر پرداخت موفق شود، Sale Service Purchase را COMPLETE و Reservation را PURCHASED علامت می‌زند. اگر شکست بخورد، Purchase FAILED علامت می‌خورد و رزرو تا انقضا فعال می‌ماند و به کاربر فرصت Retry می‌دهد.

چیزی که آن جریان کنار می‌گذارد خود کاربر است. Stripe به کلاینت می‌گوید پرداخت انجام شد، ولی رکوردی که برای ما مهم است، یعنی Purchase با وضعیت COMPLETE و Reservation با وضعیت PURCHASED، فقط وقتی Webhook لحظه‌ای بعد برسد ثبت می‌شود. تا آن موقع کلاینت نمی‌داند کفش را گرفته یا نه. ساده‌ترین راه این است که کلاینت هر ثانیه یک‌بار GET /purchases/:purchaseId را Poll کند تا وضعیت نهایی شود. فقط کاربرانی که رزرو دارند Poll می‌کنند، که چند هزار نفرند، نه میلیون‌ها نفر در فروش، پس تقریباً هزینه‌ای ندارد. اگر ترجیح می‌دهید Push کنید، اتصال SSE که در آن چند ثانیه باز نگه داشته شود هم جواب می‌دهد.

الگو، Real-time Updates: گفتن اینکه خریدار کفش را گرفته یا نه نمونه‌ی کوچکی از الگوی به‌روزرسانی بلادرنگ است. مجموعه‌ی کلاینت‌های منتظر ریز است و انتظار چند ثانیه‌ای است، که در آن Polling ساده بر اتصال Push می‌چربد. SSE وقتی منطقی‌تر است که کلاینت‌های منتظر بیشتر باشند یا مدت بیشتری منتظر بمانند.

با این، هر سه نیازمندی عملکردی ما برآورده شده است. طراحی ساده‌ی سرتاسری داریم که به کاربران اجازه می‌دهد محصول را ببینند، موجودی محدود را رزرو کنند و خریدشان را کامل کنند.

البته این طراحی وقتی ده‌ها میلیون کاربر هم‌زمان برسند فرو می‌ریزد. اینجاست که نیازمندی‌های غیرعملکردی وارد می‌شوند.

Deep Dive های احتمالی

۱) چطور تحت Contention شدید Consistency را حفظ کنیم؟

طراحی سطح بالای ما موجودی باقی‌مانده را به‌صورت یک inventoryCount روی ردیف Product نگه می‌دارد. وقتی کاربر رزرو می‌سازد، باید آن شمارنده را بدون اینکه هرگز از صفر پایین‌تر برود کم کنیم.

پایگاه داده‌ی رابطه‌ای می‌تواند این را با Conditional Update ایمن اعمال کند:

1
2
3
UPDATE Product
SET inventoryCount = inventoryCount - 1
WHERE productId = ? AND inventoryCount > 0;

اگر Update یک ردیف را تغییر دهد، موجودی را تضمین کرده‌ایم. اگر صفر ردیف را تغییر دهد، محصول تمام شده است. می‌توانیم این Update و ساخت Reservation را در یک تراکنش انجام دهیم تا دو تغییر Atomic اتفاق بیفتند.

پس درستی مشکل نیست. Contention مشکل است.

دلیلش این است که در طول Flash Sale، بالقوه میلیون‌ها درخواست رزرو همه می‌خواهند همان ردیف Product را به‌روز کنند. پایگاه داده باید آن نوشتن‌ها را سریالی کند چون هر کاهش به مقداری که کاهش قبلی ساخته وابسته است.

فکر کنید در فروشگاه فیزیکی چطور این کار را می‌کردید. با ۱۰٬۰۰۰ جفت و صفی تا بیرون در، ده صندوق باز می‌کردید و به هر صندوق‌دار ۱۰۰۰ جفت می‌دادید. ده نفر هم‌زمان سرویس می‌گیرند. پایگاه داده ما نمی‌تواند این کار را بکند. هر رزرو همان inventoryCount را می‌خواند، یکی کم می‌کند و دوباره می‌نویسد، پس درخواست دوم تا Commit شدن اولی نمی‌تواند شروع کند. هر تعداد صندوق‌دار استخدام کنیم، هنوز فقط یک صندوق داریم.

حتی با صدها Instance از Sale Service و پایگاه داده‌ی به‌شدت مقیاس‌داده‌شده، این تک‌ردیف نقطه‌ی داغ (Hot Spot) می‌شود. هر درخواست منتظر نوبتش برای قفل ردیف می‌ماند و هرچه تعدادشان بیشتر باشد انتظار طولانی‌تر می‌شود. افزودن سرور اپلیکیشن هم مسئله را حل نمی‌کند چون همه روی همان گلوگاه جمع می‌شوند. فقط به همان سرعتی می‌توانیم موجودی را پخش کنیم که پایگاه داده این Updateهای ترتیبی را پردازش می‌کند.

هر راه‌حلی که انتخاب کنیم باید این قید را حفظ کند که هرگز بیش از موجودی رزرو ندهیم، در حالی که Contention شدید روی آن موجودی را مدیریت می‌کند.

الگو، Dealing with Contention: Flash Sale خالص‌ترین شکل الگوی مدیریت Contention است. میلیون‌ها کاربر در یک لحظه همان ۱۰٬۰۰۰ واحد را می‌خواهند و سیستم باید مطمئن شود هر واحد دقیقاً یک بار داده می‌شود. این الگو تکنیک‌های ادعای Atomic استفاده‌شده اینجا را پوشش می‌دهد، از Conditional Update و قفل ردیف تا پخش کردن Contention روی ردیف‌های زیاد.

گزینه‌هایمان را ببینیم.

رویکرد اول، شمارنده‌ی Redis: یک راه مدیریت ردیف داغ Product این است که State رزرو پرتنش را به Redis ببریم. Redis داده‌اش را در حافظه نگه می‌دارد و نوشتن روی دیسک، قفل ردیف و حسابداری تراکنشی را که پایگاه داده‌ی رابطه‌ای در هر Update می‌پردازد حذف می‌کند، پس یک Instance از Redis می‌تواند روی یک کلید تعداد عملیات در ثانیه‌ای را پردازش کند که یک مرتبه‌ی بزرگی بیش از Postgres روی یک ردیف است. به‌جای ذخیره‌ی موجودی باقی‌مانده روی ردیف Product، برای هر Flash Sale یک شمارنده‌ی Redis نگه می‌داریم:

1
available:{productId} -> 10,000

وقتی درخواست رزرو می‌رسد، Sale Service بررسی می‌کند شمارنده بزرگ‌تر از صفر است و آن را کم می‌کند.

Redis برای این به‌خصوص مناسب است چون اجرای دستور روی Thread اجرایی اصلی‌اش سریالی است. هزاران تراکنش پایگاه داده بر سر قفل ردیف نمی‌جنگند. هر کاهش یکی پس از دیگری، Atomic و در حافظه اجرا می‌شود و بخش بحرانی را فوق‌العاده سریع می‌کند.

ولی یادتان باشد رزرو فقط کاهش نیست. اگر کاربر خریدش را کامل نکند، باید بدانیم رزروش کی منقضی می‌شود تا سرانجام آن واحد را به شمارنده برگردانیم. برای این می‌توانیم ساختار داده‌ی دوم در Redis نگه داریم: Sorted Set از رزروهای فعال. هر شناسه‌ی رزرو با Timestamp برابر expiresAt به‌عنوان Score اضافه می‌شود:

1
2
3
4
5
expirations:{productId}

reservationA -> 12:10:03
reservationB -> 12:10:07
reservationC -> 12:10:12

چون Sorted Setها بر اساس Score مرتب می‌شوند، Job پاکسازی موجود ما می‌تواند کارآمد رزروهایی را که زمان انقضایشان گذشته پیدا کند، حذف کند و شمارنده‌ی موجودی را زیاد کند.

همچنین می‌توانیم خود رزرو فعال را در Redis و با کلید reservationId ذخیره کنیم تا Sale Service وقتی کاربر سعی می‌کند بخرد بتواند آن را بازیابی کند.

در نهایت، کاهش شمارنده و ثبت رزرو نمی‌توانند عملیات جدا باشند. اگر موجودی را کم کنیم و پیش از ثبت رزرو Crash کنیم، یک واحد را گم کرده‌ایم. می‌توانیم بررسی، کاهش و نوشتن رزرو را با هم در Lua Script کوچکی اجرا کنیم که Redis Atomic اجرایش می‌کند.

چالش‌ها: Redis Throughput این مسیر بحرانی را به‌شدت بالا می‌برد ولی چند Trade-off مهم می‌آورد.

اول، State ما حالا بین دو سیستم تقسیم شده است. داده‌ی محصول و خرید هنوز در پایگاه داده‌ی رابطه‌ای زندگی می‌کند، در حالی که موجودی در دسترس و رزروهای فعال در Redis است. Redis دیگر فقط Cache نیست. در طول فروش بخشی از منبع حقیقت ماست. یعنی باید با دقت درباره‌ی Durability، بازیابی و نحوه‌ی تطبیق نهایی این State با پایگاه داده‌ی ماندگار فکر کنیم.

دوم، ما Serialization را واقعاً حذف نکرده‌ایم. هر رزرو برای این محصول هنوز باید همان شمارنده‌ی Redis را به‌روز کند. فقط Serialization را به جای بسیار سریع‌تری برده‌ایم. یک Instance از Redis می‌تواند بسته به بار کاری صدها هزار عملیات ساده در ثانیه را مدیریت کند، ولی هنوز سقف محدودی دارد. اگر اجازه دهیم میلیون‌ها کاربر هم‌زمان به این کلید بکوبند، سرانجام خود Redis گلوگاه می‌شود.

رویکرد دوم، ReservationUnit با SKIP LOCKED: به‌جای بردن شمارنده‌ی داغ به Redis، می‌توانیم مدل داده‌ی زیربنایی را اصلاح کنیم تا ردیف واحدی نباشد که همه‌ی درخواست‌های رزرو بر سر آن تصادم کنند.

به‌جای نمایش ۱۰٬۰۰۰ کفش موجود با:

1
inventoryCount = 10,000

Pool‌ای می‌سازیم که برای هر واحد قابل‌رزرو یک ردیف دارد:

1
2
3
4
5
6
7
8
ReservationUnit
productId | unitId
------------------
shoe-123  | 1
shoe-123  | 2
shoe-123  | 3
...
shoe-123  | 10000

حالا هر کفش موجود با ردیف مستقلاً قابل‌قفل مخصوص خودش نمایش داده می‌شود. وقتی کاربر سعی می‌کند یکی را رزرو کند، تراکنش ما فقط باید هر ردیف موجودی را از این Pool ادعا کند.

روش بدیهی برای این:

1
2
3
4
5
SELECT unitId
FROM ReservationUnit
WHERE productId = ?
LIMIT 1
FOR UPDATE;

FOR UPDATE به پایگاه داده می‌گوید ردیف انتخاب‌شده را تا پایان تراکنش قفل کند. این از ادعای یک واحد توسط دو تراکنش جلوگیری می‌کند.

اما هنوز مشکل هست. تصور کنید هزاران تراکنش این Query را هم‌زمان اجرا کنند. بسیاری ممکن است ابتدا همان اولین ردیف موجود را انتخاب کنند. یک تراکنش قفل را می‌گیرد و بقیه منتظر می‌مانند.

نکته‌ی اضافه‌ی Postgres هم هست. تحت READ COMMITTED، اگر تراکنشی که قفل را دارد آن واحد را پیش از اینکه تراکنش منتظر آن را بگیرد حذف کند، منتظر ممکن است هیچ ردیفی برنگرداند. با LIMIT 1، این می‌تواند باعث شود درخواست تمام‌شده به نظر برسد حتی اگر واحدهای رزرو دیگری هنوز موجودند.

اینجا SKIP LOCKED مفید می‌شود:

1
2
3
4
5
SELECT unitId
FROM ReservationUnit
WHERE productId = ?
LIMIT 1
FOR UPDATE SKIP LOCKED;

به‌جای انتظار برای ردیفی که تراکنش دیگری قفلش کرده، SKIP LOCKED به پایگاه داده می‌گوید آن را رد کند و واحد موجود دیگری را جستجو کند.

پس اگر تراکنش A واحد 37 را قفل کند، تراکنش B منتظر واحد 37 نمی‌ماند. آن را رد می‌کند و واحد 38 را قفل می‌کند. تراکنش C ممکن است واحد 39 را بگیرد و همین‌طور ادامه.

عملاً یک شمارنده‌ی موجودی شدیداً مورد Contention را به هزاران قطعه‌ی مستقل موجودی تبدیل کرده‌ایم که می‌توانند موازی ادعا شوند.

SKIP LOCKED واژگان Postgres است و MySQL هم همان را به کار می‌برد. اما اگر کلیدواژه روی نوک زبانتان نیست، اشکالی ندارد. وقتی هر واحد ردیف قابل‌ادعای خودش را دارد، هر پایگاه داده‌ای که بتواند ردیف‌ها را مستقل قفل کند جواب می‌دهد.

وقتی واحدی را ادعا کردیم، بقیه‌ی کار داخل همان تراکنش کوتاه پایگاه داده انجام می‌شود:

  1. یک ReservationUnit را با FOR UPDATE SKIP LOCKED انتخاب کن.
  2. اگر ردیفی برنگشت، محصول تمام شده است.
  3. ReservationUnit ادعاشده را حذف کن.
  4. Reservation متناظر را با Timestamp ای به نام expiresAt درج کن.
  5. تراکنش را Commit کن.

چون ادعای موجودی و رزرو هر دو در همان پایگاه داده ذخیره می‌شوند، Atomic Commit می‌شوند. نمی‌توانیم موجودی را مصرف کنیم بدون ساخت رزرو، یا رزرو بسازیم بدون مصرف موجودی.

جریان انقضای موجود ما هم هنوز کار می‌کند. وقتی رزرو منقضی می‌شود، Job پاکسازی آن را منقضی علامت می‌زند و ردیفی را به Pool مربوط به ReservationUnit برمی‌گرداند. در Deep Dive بعدی آن جریان را بهتر می‌کنیم.

این همان تکنیک پایه‌ای است که Shopify برای سیستم رزرو موجودی‌اش به کار برد. به‌جای وادار کردن هر Checkout به به‌روزرسانی یک ردیف مقدار، Shopify Contention را روی Pool ای از ردیف‌های رزرو مستقلاً قابل‌قفل پخش کرد.

چالش‌ها: Trade-off بزرگ این است که یک شمارنده‌ی فشرده را با ردیف‌های پایگاه داده‌ی زیاد معاوضه می‌کنیم. به‌جای ذخیره‌ی inventoryCount = 10,000، Flash Sale ۱۰٬۰۰۰ جفتی ما حالا تا ۱۰٬۰۰۰ ردیف ReservationUnit نیاز دارد.

برای مسئله‌ی ما این کاملاً معقول است. ولی در مقیاس پلتفرمی مثل Shopify، که میلیون‌ها محصول ممکن است هرکدام موجودی بزرگی داشته باشند، ساختن ردیف برای هر واحد فیزیکی هدر دادن است. یک راه حل این است که فقط Pool محدودی از ردیف‌های قابل‌رزرو نگه دارید و وقتی Pool کم می‌شود پرش کنید، به‌جای اینکه کل موجودی را از قبل Materialize کنید.

همچنین هنوز محدودیتی در میزان کار هم‌زمانی هست که پایگاه داده‌ی رابطه‌ای ما می‌تواند تحمل کند. گلوگاه تک‌ردیف داغ را حذف کرده‌ایم، ولی هنوز نباید به ده‌ها میلیون کاربر اجازه دهیم هم‌زمان تراکنش پایگاه داده باز کنند. این را بعداً با معرفی Admission Control مدیریت می‌کنیم.

مزیت بزرگ نسبت به Redis این است که موجودی و رزروهای ما داخل یک پایگاه داده‌ی تراکنشی می‌مانند. پردازش موازی رزرو می‌گیریم، بدون معرفی منبع حقیقت دوم برای مهم‌ترین قید Consistency خودمان.

بین این دو رویکرد، با طراحی ReservationUnit جلو می‌رویم. Redis نقطه‌ی Serialization بسیار سریع‌تری می‌دهد ولی هنوز Hot Key برایمان می‌گذارد و منبع حقیقت ما را بین دو سیستم تقسیم می‌کند. با پخش موجودی روی ردیف‌های مستقلاً قابل‌قفل، می‌توانیم رزروها را هم‌زمان پردازش کنیم و ادعای موجودی و رزرو را در همان تراکنش پایگاه داده نگه داریم.

۲) چطور رزروهای منقضی‌شده را کارآمد آزاد کنیم؟

حالا که می‌توانیم رزروها را تحت Contention سنگین ایمن بدهیم، باید سمت دیگر چرخه‌ی عمر را مدیریت کنیم و وقتی رزروها منقضی می‌شوند موجودی را برگردانیم.

در طراحی سطح بالا، این را با Cron Job ساده مدیریت کردیم. هر چند ثانیه، رزروهای فعالی را که expiresAt آن‌ها در گذشته بود Query می‌کرد، آن‌ها را EXPIRED علامت می‌زد و واحدی را به Pool مربوط به ReservationUnit برمی‌گرداند.

اگر تحلیل‌های دیگر ما را خوانده باشید، احتمالاً مشکل این رویکرد را می‌بینید. رزرو ممکن است در 12:10:01 منقضی شود، ولی اگر Job پاکسازی ما تا 12:10:10 اجرا نشود، آن واحد ۹ ثانیه‌ی دیگر در دسترس نیست. در این بازه، ممکن است به کاربر گفته شود محصول تمام شده، با اینکه موجودی از قبل منقضی شده و باید برای خرید در دسترس باشد. در Flash Sale، که هر واحد مهم است، باید موجودی را همان لحظه‌ی انقضای رزرو در دسترس کنیم.

گزینه‌ها برای انجام این کار را بسنجیم.

رویکرد اول، Delayed Job با صف تأخیری: راه سرراست برای برگرداندن موجودی نزدیک‌تر به زمان دقیق انقضا این است که هنگام ساخت هر رزرو یک Delayed Job زمان‌بندی کنیم.

برای پنجره‌ی رزرو ۱۰ دقیقه‌ای ما می‌توانیم از صف تأخیری مثل Amazon SQS استفاده کنیم. وقتی رزرو را در 12:00:00 می‌سازیم، پیام ExpireReservation(reservationId) را هم با ۱۰ دقیقه تأخیر منتشر می‌کنیم. SQS پیام را تا حدود 12:10:00 پنهان نگه می‌دارد و بعد برای پردازش توسط Worker در دسترس می‌شود.

وقتی Worker پیام را می‌گیرد، نمی‌تواند کورکورانه موجودی را برگرداند. ممکن است کاربر درست پیش از زمان انقضا خریدش را کامل کرده باشد، یا همان پیام بیش از یک بار تحویل شده باشد. در عوض، Worker تراکنش کوتاه پایگاه داده‌ای را انجام می‌دهد که:

  1. بررسی می‌کند رزرو هنوز ACTIVE است و expiresAt <= now.
  2. رزرو را EXPIRED علامت می‌زند.
  3. واحدی را به Pool مربوط به ReservationUnit برمی‌گرداند.
  4. تراکنش را Commit می‌کند.

اگر رزرو از قبل PURCHASED یا EXPIRED علامت خورده باشد، کاری باقی نمانده است.

زمان‌بندهای ماندگار دیگر هم می‌توانند اینجا کار کنند، مثل Timer در Temporal. مهم این است که Job انقضا به‌صورت ماندگار بیرون از Sale Service زمان‌بندی شود. اگر Instance از Sale Service که رزرو را ساخته Crash کند یا Restart شود، نمی‌خواهیم Timer انقضا همراهش ناپدید شود.

چالش‌ها: بزرگ‌ترین چالش مقیاس است. Flash Sale بزرگ می‌تواند در بازه‌ی کوتاهی هزاران یا میلیون‌ها رزرو بسازد، یعنی هزاران یا میلیون‌ها پیام تأخیری هم می‌سازیم که تقریباً هم‌زمان واجد شرایط پردازش می‌شوند.

همچنین تضمین نداریم Job دقیقاً در لحظه‌ی انقضای رزرو اجرا شود. صف‌ها ممکن است عقب بیفتند، Workerها ممکن است شکست بخورند و پیام‌ها ممکن است Retry شوند.

رویکرد دوم، بازیابی بر حسب تقاضا (On-demand Reclamation): می‌توانیم با تغییر زمان بازپس‌گیری موجودی، از زمان‌بندی کار انقضا کاملاً اجتناب کنیم.

مسئله‌ی واقعی را یادتان بیاید. برایمان مهم نیست واحد منقضی‌شده دقیقاً در 12:10:01 فیزیکی به Pool مربوط به ReservationUnit برگردد. مهم این است که خریداری که بعد می‌رسد، چه در 12:10:02 و چه دیرتر، به اشتباه نشنود محصول تمام شده است.

پس به‌جای برگرداندن موجودی منقضی به Pool هنگام فعال شدن Timer، می‌توانیم آن را وقتی خریداران واقعاً نیاز دارند بر حسب تقاضا بازپس بگیریم.

مسیر عادی رزرو تغییر نمی‌کند. اول سعی می‌کنیم با FOR UPDATE SKIP LOCKED یک ReservationUnit موجود را ادعا کنیم و تا وقتی واحد در Pool مانده، همین کافی است.

حالت جالب وقتی است که واحد موجودی پیدا نشود. پیش از برگرداندن «تمام شد»، بررسی می‌کنیم آیا بعضی رزروهای موجود از قبل منقضی شده‌اند. اگر شده‌اند، آن‌ها را دسته‌ای بازپس می‌گیریم:

  1. مثل همیشه سعی کن ReservationUnit موجود را ادعا کنی.
  2. اگر هیچ‌کدام موجود نبود، دسته‌ای از رزروهای ACTIVE را پیدا کن که expiresAt <= now.
  3. آن رزروها را EXPIRED علامت بزن.
  4. یکی از واحدهای بازپس‌گرفته‌شده را برای این درخواست نگه دار و Reservation آن را بساز.
  5. unitId های باقی‌مانده را به Pool مربوط به ReservationUnit برگردان.
  6. تراکنش را Commit کن.

درخواستی که دسته را بازپس می‌گیرد باید یکی از آن واحدها را نگه دارد. وگرنه هر ۵۰ تا را به Pool آزاد می‌کند و بعد با بقیه برای یکی رقابت می‌کند.

مثلاً اگر Pool خالی باشد ولی ۵۰ رزرو منقضی شده باشد، یک مرحله‌ی بازپس‌گیری هر ۵۰ را بازپس می‌گیرد، یکی را برای خریداری که فعالش کرد نگه می‌دارد و ۴۹ تای دیگر را به Pool برمی‌گرداند. بعد خریداران منتظر آن واحدها را با همان جریان SKIP LOCKED که قبلاً طراحی کردیم ادعا می‌کنند.

اما اگر هزاران خریدار هم‌زمان به Pool خالی بخورند چه؟ نمی‌خواهیم همه‌شان رزروهای منقضی را Query کنند و سعی کنند Pool را پر کنند.

در عوض فقط اجازه می‌دهیم برای هر محصول، هر بار یک درخواست سعی کند رزروهای منقضی را بازپس بگیرد. اولین درخواستی که Pool را خالی می‌یابد، قفل کوتاه‌مدت مخصوص آن محصول را می‌گیرد و دسته‌ای از رزروهای منقضی را بازپس می‌گیرد. این‌طور درخواست‌های دیگر همان کار را انجام نمی‌دهند. فقط کمی منتظر می‌مانند یا Query رزرو عادی را Retry می‌کنند.

جریان حالا اینطور است:

  1. خریداران زیادی می‌فهمند Pool مربوط به ReservationUnit خالی است.
  2. یک درخواست قفل بازپس‌گیری آن productId را می‌گیرد.
  3. آن درخواست یک واحد را برای خودش نگه می‌دارد و بقیه را به Pool برمی‌گرداند.
  4. قفل را آزاد می‌کند.
  5. درخواست‌های منتظر Retry می‌کنند و با SKIP LOCKED روی آن ردیف‌های تازه در دسترس پخش می‌شوند.

در Postgres می‌توانیم این را با Advisory Lock محدود به تراکنش و کلیدی بر اساس productId پیاده کنیم. مکانیزم دقیق قفل‌گذاری خیلی مهم نیست. مهم این است که فقط یک درخواست کار نسبتاً گران بازپس‌گیری را انجام دهد، در حالی که همه‌ی بقیه روی مسیر رزرو عادی می‌مانند.

چالش‌ها: Trade-off این است که درخواست رزرو گاه‌به‌گاه حالا کار بیشتری باید انجام دهد. اگر Pool موجود خالی باشد، ممکن است یک درخواست پیش از ادامه‌ی خریداران لازم باشد دسته‌ای از رزروهای منقضی را بازپس بگیرد.

اما مسیر رایج سریع می‌ماند. بیشتر خریداران فقط واحد موجود را ادعا می‌کنند و بازپس‌گیری فقط وقتی رخ می‌دهد که Pool واقعاً نیاز دارد. همچنین از ساختن میلیون‌ها Job انقضای جداگانه اجتناب می‌کنیم و دیگر به فرایند پس‌زمینه‌ای که دقیقاً در لحظه‌ی درست اجرا شود وابسته نیستیم.

۳) چطور میلیون‌ها کاربر را که هم‌زمان می‌رسند مدیریت کنیم؟

در این مرحله گلوگاه ردیف داغ را حذف کرده‌ایم و جریان رزرو ما تحت Contention ایمن است. اما پایگاه داده هنوز سقف فیزیکی دارد.

فرض کنید تست بار نشان دهد پایگاه داده ما می‌تواند با ایمنی حدود ۱۰٬۰۰۰ تراکنش رزرو در ثانیه را پردازش کند. اگر به صدها هزار یا میلیون‌ها کاربر اجازه دهیم هم‌زمان رزرو را امتحان کنند، درخواست‌ها هنوز سریع‌تر از توان پردازش پایگاه داده می‌رسند. روی هم جمع می‌شوند، Latency بالا می‌رود و سرانجام درخواست‌ها شروع به Timeout می‌کنند یا سیستم را غرق می‌کنند.

Query هوشمند دیگری وجود ندارد که این را حل کند. به سقف ظرفیت خود زیرساخت رسیده‌ایم. حتی اگر در Deep Dive قبلی Redis را انتخاب کرده بودیم و آن سقف را خیلی بالاتر برده بودیم، باز هم نرخ حداکثری هست که سیستم رزرو می‌تواند با ایمنی تحمل کند.

پس به‌جای تلاش برای پذیرش ترافیک نامحدود در مسیر رزرو، باید به بالادست برویم و کنترل کنیم کاربران با چه سرعتی به آن برسند.

الگو، Scaling Writes: با حذف ردیف داغ، چیزی که می‌ماند مسئله‌ی Scaling Writes است. درخواست‌های رزرو سریع‌تر از توان Commit هر پایگاه داده‌ی واحد می‌رسند، و الگوی Scaling Writes ابزارهای آن را پوشش می‌دهد، از پارتیشن کردن نوشتن‌ها روی ردیف‌های زیاد (کاری که الان با ReservationUnit کردیم) تا جذب جهش‌ها با صف و حذف بار (Load Shedding)، که کار Waiting Room پایین است.

ببینیم چطور می‌توانیم Admission Control معرفی کنیم.

رویکرد اول، Rate Limit در API Gateway: ساده‌ترین راه محافظت از پایگاه داده این است که درخواست‌های رزرو را در API Gateway محدود نرخ کنیم. اگر تست بار بگوید سیستم رزرو می‌تواند با ایمنی حدود ۱۰٬۰۰۰ درخواست در ثانیه را پردازش کند، می‌توانیم Gateway را طوری پیکربندی کنیم که تقریباً همان نرخ را عبور دهد و هر چیز بیشتر را با 429 Too Many Requests رد کند.

خام است، ولی تضمین می‌کند پایگاه داده هرگز بیش از آنچه با ایمنی می‌تواند تحمل کند ترافیک نمی‌گیرد.

چالش‌ها: مشکل این است که Backend را با انداختن درد روی کاربر محافظت کرده‌ایم.

در طول Flash Sale، میلیون‌ها کاربر ممکن است هم‌زمان به Endpoint رزرو بخورند. بیشترشان 429 می‌گیرند و فوراً Retry می‌کنند. آن Retryها جهش دیگری می‌سازند که دوباره محدود نرخ می‌شود و سریع به Thundering Herd از کلاینت‌هایی می‌رسیم که مدام Gateway را می‌کوبند.

رویکرد دوم، Virtual Waiting Room: به‌جای رد کردن کاربران وقتی سیستم رزرو به ظرفیت می‌رسد، می‌توانیم آن‌ها را در بالادست در Waiting Room مجازی نگه داریم و با نرخی که Backend می‌تواند با ایمنی تحمل کند وارد کنیم.

وقتی Flash Sale شروع می‌شود، کاربران اول وارد Waiting Room می‌شوند، نه مستقیم به Endpoint رزرو. Waiting Room Service جدید هر کاربر را در Redis ثبت می‌کند و شناسه‌ای به او برمی‌گرداند که هنگام انتظار برای بررسی وضعیتش استفاده کند.

از سرویس جدا استفاده می‌کنیم چون Waiting Room ممکن است ترافیکی چند مرتبه‌ی بزرگی بیشتر از Sale Service ببیند، به‌خصوص وقتی میلیون‌ها کلاینت برای وضعیت Poll می‌کنند. این اجازه می‌دهد دو بار کاری را مستقل مقیاس بدهیم.

در عین حال، Admission Controller پیوسته کاربران را با نرخ کنترل‌شده عبور می‌دهد. اگر تست بار بگوید سیستم رزرو می‌تواند با ایمنی ۱۰٬۰۰۰ تراکنش در ثانیه پردازش کند، ممکن است حدود ۸٬۰۰۰ کاربر در ثانیه را وارد کنیم تا فضای کافی بماند.

جریان:

  1. کاربر وارد Waiting Room می‌شود.
  2. جایگاهش در Waiting Room در Redis ذخیره می‌شود.
  3. کلاینت در حین انتظار به‌صورت دوره‌ای وضعیتش را بررسی می‌کند.
  4. Admission Controller کاربران را با نرخی که سیستم رزرو می‌تواند با ایمنی تحمل کند وارد می‌کند.
  5. کاربر وقتی پذیرفته شد، توکن پذیرش امضاشده‌ی کوتاه‌مدتی دریافت می‌کند.
  6. کلاینت هنگام صدا زدن POST /products/:productId/reservations آن توکن را همراه می‌فرستد.
  7. API Gateway پیش از اجازه‌ی رسیدن درخواست به Sale Service توکن را اعتبارسنجی می‌کند.

توکن امضاشده تضمین می‌کند کاربران نتوانند Waiting Room را دور بزنند و مستقیم Endpoint رزرو را صدا بزنند. می‌توانیم فیلدهایی مثل userId، productId و زمان انقضا را در توکن بگذاریم و آن را با رازی که Backend ما می‌داند امضا کنیم. API Gateway می‌تواند امضا را بدون جستجوی پایگاه داده‌ی دیگری تأیید کند.

وقتی کاربر پذیرفته شد، هیچ‌چیز در جریان رزرو ما عوض نمی‌شود. همان درخواست همگام (Synchronous) را که از قبل طراحی کردیم می‌دهند و بلافاصله می‌فهمند واحدی را تضمین کرده‌اند یا نه.

این ویژگی‌ای را به ما می‌دهد که دنبالش هستیم. میلیون‌ها کاربر می‌توانند منتظر باشند، ولی در هر ثانیه فقط تعداد کنترل‌شده‌ای اجازه دارند تراکنش گران رزرو را انجام دهند.

اگر یک Instance از Redis نتواند به‌راحتی State مربوط به Waiting Room و ترافیک Polling را تحمل کند، می‌توانیم Waiting Room را بین چند Instance از Redis Shard کنیم. State اینجا سبک است و می‌تواند مستقل از خود سیستم رزرو مقیاس بگیرد.

چالش‌ها: چالش اصلی انتخاب نرخ پذیرش درست است. کاربران را خیلی سریع وارد کنید و سیستم رزرو را غرق می‌کنید. خیلی آهسته وارد کنید و ظرفیت را بلااستفاده می‌گذارید در حالی که کاربران بی‌دلیل منتظرند.

همچنین مسئولیت جدید مهمی معرفی کرده‌ایم. Waiting Room حالا تصمیم می‌گیرد چه کسی و به چه ترتیبی به موجودی کمیاب می‌رسد.

مسئله‌ی مقیاس‌پذیری را حل کرده‌ایم، ولی بلافاصله آخرین سؤال ما مطرح می‌شود: چطور آن ترتیب را منصفانه کنیم؟

۴) چطور دسترسی به موجودی محدود را منصفانه کنیم؟

Waiting Room ما مسئله‌ی مقیاس‌پذیری را با کنترل سرعت رسیدن کاربران به سیستم رزرو حل می‌کند. اما لحظه‌ای که شروع می‌کنیم تصمیم بگیریم چه کسی اول پذیرفته شود، مسئول منصفانه بودن آن ترتیب هم می‌شویم.

رویکرد ساده، FIFO محض است. هر که اول وارد Waiting Room شود اول پذیرفته می‌شود. مشکل این است که برای فروشی با زمان شروع مشخص، این چند میلی‌ثانیه‌ی اول را به رقابت بر سر Latency شبکه و اتوماسیون تبدیل می‌کند، به‌جای اینکه به همه‌ی کسانی که به‌موقع حاضر شده‌اند شانس معقولی بدهد.

در عوض، می‌توانیم Waiting Room را پیش از شروع فروش باز کنیم ولی همه را تا ظهر آنجا نگه داریم. در ظهر، همه‌ی کسانی را که از قبل منتظرند به‌صورت تصادفی مرتب می‌کنیم. کسی که ساعت 11:55 وارد شده نسبت به کسی که 11:59:59 وارد شده هیچ مزیتی ندارد. وقتی فروش شروع شد، هر کس تازه برسد به ته صف می‌پیوندد، با ترتیب عادی FIFO.

این تعادل مفیدی می‌دهد. حاضر بودن هنگام شروع فروش مهم است، ولی رقابت برای اولین درخواستی که از در عبور می‌کند مهم نیست.

همچنین باید جلوی کاربران را بگیریم که چند جای صف را بگیرند. Waiting Room باید حساب احراز هویت‌شده بخواهد و برای هر فروش فقط یک ورودی فعال صف به ازای هر کاربر اجازه دهد. همچنین می‌توانیم CAPTCHA یا چالش ضد-ربات مشابهی را جلوی صف بگذاریم تا ورود انبوه خودکار سخت‌تر شود.

توکن پذیرش امضاشده‌ای که قبلاً معرفی کردیم تضمین می‌کند کاربران پس از تثبیت ترتیب نتوانند جلو بزنند. فقط کاربرانی که صریحاً توسط Waiting Room Service پذیرفته شده‌اند توکن معتبری می‌گیرند که اجازه‌ی رسیدن به Endpoint رزرو را می‌دهد.

در نهایت، اگر State مربوط به Waiting Room ما بین چند Instance از Redis Shard شده باشد، ترتیب هنوز باید سراسری باشد. نمی‌خواهیم هر Shard مستقل تصمیم بگیرد چه کسی بعدی است. در عوض، به هر کاربر مقدار ترتیبی می‌دهیم که اجازه می‌دهد Admission Controller ترتیب تصادفی پیش از فروش را حفظ کند و بعد FIFO را برای همه‌ی کسانی که پس از شروع فروش می‌رسند.

طراحی نهایی

با کنار هم گذاشتن همه‌چیز، طراحی نهایی ما چنین جریانی دارد: کاربر وارد Waiting Room Service می‌شود که State او را در Redis (که می‌تواند Shard شود) نگه می‌دارد. Admission Controller کاربران را با نرخ کنترل‌شده می‌پذیرد و توکن امضاشده می‌دهد. کلاینت با توکن، POST /products/:productId/reservations را صدا می‌زند. API Gateway توکن را تأیید می‌کند و درخواست را به Sale Service می‌فرستد. Sale Service در تراکنش کوتاه Postgres یک ReservationUnit را با FOR UPDATE SKIP LOCKED ادعا می‌کند، آن را حذف و Reservation را درج می‌کند. رزروهای منقضی‌شده هنگام خالی شدن Pool به‌صورت دسته‌ای و با قفل هر محصول بازپس گرفته می‌شوند. برای خرید، Sale Service Purchase می‌سازد، Stripe PaymentIntent می‌سازد و نتیجه را از Webhook می‌گیرد، و کلاینت وضعیت خرید را Poll می‌کند.

انتظار در هر سطح

ممکن است عمق جالب زیادی در این مسئله باشد و در مصاحبه‌ی حدود ۳۵ دقیقه‌ای احتمالاً همه‌ی جزئیات را پوشش نمی‌دهید. اما آنچه مهم است بسته به سطحی که برایش مصاحبه می‌دهید فرق می‌کند. (مقاله‌ی «انتظار در هر سطح» نویسنده هم سه بُعد عرض، عمق و ابتکار عمل را با همین منطق تعریف می‌کند و می‌گوید هرچه سطح بالاتر برود، عرض کمتر و عمق و ابتکار عمل بیشتر انتظار می‌رود [web:1].)

Mid-level: نامزد قوی سطح Mid باید طراحی سرتاسری کاملی برای دیدن محصول، رزرو موجودی و کامل کردن خرید بدون Overselling تحویل دهد. باید بفهمد بررسی و کاهش موجودی باید Atomic باشد، ولی منطقی است که مصاحبه‌کننده او را به سمت مسائل عمیق‌تر مثل Contention ردیف داغ، Admission Control یا انقضای رزرو هدایت کند. Job پاکسازی ساده کافی است، در حالی که رسیدن فعالانه به SKIP LOCKED یا Waiting Room امتیاز اضافه است.

Senior: نامزد قوی سطح Senior باید سریع از طراحی پایه رد شود و فعالانه مسائلی را که Flash Sale را دشوار می‌کنند شناسایی کند. باید inventoryCount داغ را تشخیص دهد، درباره‌ی رویکردهایی مثل Redis در برابر FOR UPDATE SKIP LOCKED استدلال کند، رزروهای منقضی را تمیز مدیریت کند و بفهمد Data Store هنوز سقف Throughput محدود دارد. اگر فشار بیاورند، باید Admission Control معرفی کند، ترجیحاً Waiting Room مجازی، و بتواند Trade-offهای پشت انتخاب‌هایش را توضیح دهد.

Staff و بالاتر: نامزد قوی سطح Staff+ باید تقریباً همه‌ی Deep Diveها را بدون پرسش پیش ببرد. باید قید موجودی را تحت Contention شدید حفظ کند، درباره‌ی ظرفیت Backend کمّی استدلال کند، مسیرهای رزرو و بازپس‌گیری را بدون معرفی گلوگاه‌های جدید طراحی کند و از Waiting Room برای نگه داشتن مسیر بحرانی در محدودیتش استفاده کند. همچنین باید فعالانه عدالت را مطرح کند، از جمله تصادفی‌سازی پیش از فروش، FIFO بعد از شروع فروش، ورودی‌های تکراری، مقاومت در برابر ربات و حفظ ترتیب هنگام مقیاس‌گرفتن Waiting Room.