حالت تاریک
فصل ۷ — سیستم رزرو هتل (Hotel Reservation System)
خلاصهٔ تحلیلی — این متن چکیدهٔ مفاهیم فصل به زبان فارسی است، نه ترجمهٔ واژهبهواژه.
طراحی سیستم رزرو برای زنجیرهای مثل Marriott؛ تکنیکهای فصل مستقیماً به Airbnb، رزرو پرواز و بلیت سینما هم قابل انتقال است.
گام ۱ — نیازمندیها
- ۵٬۰۰۰ هتل و ۱ میلیون اتاق؛ پرداخت کامل هنگام رزرو؛ لغو رزرو مجاز؛ ۱۰٪ overbooking (فروش بیش از ظرفیت به امید لغوها)؛ قیمت اتاق روزانه و پویا.
- دامنه: صفحهٔ هتل/اتاق، رزرو، پنل ادمین، overbooking — جستوجوی اتاق خارج از محدوده است.
- غیرکارکردی: همزمانی بالا (چند مشتری همزمان سراغ یک اتاق در ایام پیک) و تأخیر متوسط (چند ثانیه برای پردازش رزرو پذیرفتنی است).
- برآورد: با اشغال ۷۰٪ و اقامت ۳ روزه ≈ ۲۴۰ هزار رزرو در روز ≈ فقط ~۳ TPS؛ با قیف ۱۰٪ تبدیل در هر گام: صفحهٔ جزئیات ~۳۰۰ QPS، صفحهٔ رزرو ~۳۰ QPS.
گام ۲ — طراحی سطح بالا
API
CRUD استاندارد REST برای هتلها، اتاقها و رزروها. در POST /v1/reservations فیلد reservationID نقش کلید idempotency برای جلوگیری از رزرو دوباره را دارد.
انتخاب دیتابیس — رابطهای
سه دلیل: بار read-heavy است (بازدید چند مرتبه بیشتر از رزرو است و NoSQL ها برای write بهینهاند)؛ ACID برای رزرو حیاتی است (بدون آن جلوگیری از موجودی منفی، شارژ دوباره و رزرو دوباره دشوار است و کد برنامه پیچیده میشود)؛ و ساختار داده (هتل/اتاق/رزرو) روشن و پایدار و کاملاً رابطهای است.
نکتهٔ کلیدی مدل داده
طرح اولیه (رزرو روی room_id) برای Airbnb درست است اما برای هتل غلط: مشتری نوع اتاق را رزرو میکند، نه اتاق مشخص را؛ شمارهٔ اتاق هنگام check-in تعیین میشود. وضعیت رزرو یک ماشین حالت ساده است: pending ← paid/canceled/rejected و paid ← refunded.
معماری میکروسرویس
کاربر/ادمین ← CDN (استاتیکها) ← API gateway عمومی (و API داخلی پشت VPN برای کارکنان) ← سرویسها: Hotel (دادهٔ تقریباً ایستا، cacheable)، Rate (قیمت روزانه)، Reservation (رزرو + موجودی)، Payment و Hotel Management. ارتباط بین سرویسها معمولاً با gRPC.
گام ۳ — بررسی عمیق
مدل دادهٔ بهبودیافته
roomID در API به roomTypeID تبدیل میشود و جدول محوری room_type_inventory اضافه میشود با کلید مرکب (hotel_id, room_type_id, date) و ستونهای total_inventory (منهای اتاقهای در تعمیر) و total_reserved. یک ردیف به ازای هر روز، کوئری بازهٔ تاریخ را ساده میکند؛ ردیفهای دو سال آینده با job روزانه از پیش پر میشوند. حجم: ۵٬۰۰۰ × ۲۰ نوع اتاق × ۷۳۰ روز ≈ ۷۳ میلیون ردیف — در یک دیتابیس جا میشود؛ replication برای HA.
بررسی موجودیت رزرو: انتخاب ردیفهای بازهٔ تاریخ و برای هر روز شرط total_reserved + N <= total_inventory؛ پیادهسازی overbooking فقط تغییر شرط به <= 110% * total_inventory است.
اگر داده بزرگ شد: آرشیو رزروهای گذشته به cold storage و شاردینگ با hotel_id (چون تقریباً همهٔ کوئریها اول هتل را انتخاب میکنند).
مسئلهٔ همزمانی — قلب فصل
سناریو ۱: دوبار کلیک همان کاربر. غیرفعالکردن دکمه سمت کلاینت کافی نیست (جاوااسکریپت قابل دور زدن است). راهحل: API idempotent — reservation_id که قبل از پرداخت تولید شده، کلید اصلی جدول رزرو است؛ درج دوم با نقض unique constraint شکست میخورد.
سناریو ۲: دو کاربر و یک اتاق آخر. با سطح ایزولاسیون غیر serializable، هر دو تراکنش شرط موجودی را True میبینند (تغییرات یکی برای دیگری تا commit نامرئی است) و هر دو رزرو میکنند. سه راهحل:
| روش | سازوکار | مزایا | معایب |
|---|---|---|---|
| قفل بدبینانه | SELECT ... FOR UPDATE؛ تراکنش دوم منتظر میماند | ساده؛ مناسب contention سنگین | خطر deadlock؛ قفل طولانی مقیاسپذیری را میکشد |
| قفل خوشبینانه | ستون version؛ نوشتن فقط اگر version جلو نرفته باشد | بدون قفل دیتابیس؛ سریع در contention کم | در contention بالا موج retry و تجربهٔ بد |
| قید دیتابیس | CHECK(total_inventory - total_reserved >= 0) | سادهترین پیادهسازی | شکستهای گیجکننده در contention بالا؛ قید version-control نمیشود؛ همهٔ دیتابیسها پشتیبانی نمیکنند |
چون QPS رزرو پایین است، قفل خوشبینانه یا قید دیتابیس توصیه میشود؛ قفل بدبینانه نه.
مقیاسپذیری (سناریوی booking.com — ۱۰۰۰ برابر QPS)
- سرویسها stateless اند و راحت مقیاس میگیرند؛ گلوگاه دیتابیس است ← شاردینگ با hotel_id (مثلاً ۳۰ هزار QPS بین ۱۶ شارد ≈ ~۱٬۹۰۰ به ازای هر شارد).
- کش موجودی (Redis): فقط دادهٔ حال و آینده معناست ← TTL و LRU طبیعیاند. کلید
hotelID_roomTypeID_date، مقدار تعداد اتاق خالی. کوئریهای موجودی به کش میروند و اکثر درخواستهای ناموفق پشت کش میمانند؛ دیتابیس اول بهروزرسانی میشود و تغییر ناهمگام (کد برنامه یا CDC با Debezium) به کش میرسد. - نکتهٔ ظریف: ناسازگاری کش-دیتابیس مهم نیست به شرطی که دیتابیس اعتبارسنجی نهایی را انجام دهد — کش میگوید اتاق هست، دیتابیس رد میکند، کاربر خطای «اتاق تمام شد» میبیند. دیتابیس source of truth میماند.
سازگاری داده بین میکروسرویسها
طراحی ما عمداً هیبریدی است: رزرو و موجودی در همان سرویس و همان دیتابیس رابطهای، تا ACID مسائل همزمانی را حل کند. در میکروسرویس خالص (هر سرویس دیتابیس خودش) یک عملیات منطقاً اتمیک چند سرویس را در بر میگیرد و transaction واحد ممکن نیست؛ راهحلها: 2PC (اتمیک اما blocking و کند) و Saga (زنجیرهٔ تراکنشهای محلی + تراکنشهای جبرانی؛ سازگاری نهایی). داوری معمار: این پیچیدگی اینجا ارزشش را ندارد.
نکات کلیدی فصل
- «چه چیزی واقعاً رزرو میشود؟» (نوع اتاق، نه اتاق) — فهم دامنه، اسکیما را تعیین میکند و اشتباه در آن کل طراحی را خراب میکند.
- دفاع دو لایه در برابر رزرو دوباره: کلید idempotency برای دوبار کلیک، و کنترل همزمانی (خوشبینانه/قید) برای رقابت کاربران.
- انتخاب مکانیزم قفل تابع میزان contention است، نه سلیقه.
- کشِ جلوی دیتابیس وقتی امن است که دیتابیس حرف آخر را بزند.
- عملگرایی در برابر خلوص میکروسرویس: دادههایی که با هم تراکنش میخواهند، کنار هم بمانند.
یادداشتهای مترجم
- idempotency، overbooking، optimistic/pessimistic locking و Saga در متن لاتین/فارسی ترکیبی آمدهاند.