Hotel Booking (Booking.com style) — system design case. Search → Booking Saga (hold → pay → confirm) → Payment → Notifications. Demonstrates no-double-booking via hybrid pessimistic-lock-on-hold + saga compensation, Elasticsearch search vs Postgres FTS trade-offs, dynamic pricing, cancellation/refund flow, and concurrent reservation race resolution. Five FlowBuilder scenarios + 2 ADRs (inventory consistency, search engine choice) + capacity hints on every node.
Hotel booking хорошо проверяет, понимает ли инженер разницу между поиском и бронированием. Поиск должен быть быстрым, дешевым и допускающим небольшую устарелость. Бронирование должно быть строгим: нельзя продать последнюю комнату двум гостям, нельзя списать деньги без подтвержденного hold, нельзя оставить номер заблокированным навсегда после падения orchestrator-а.
Целевой масштаб для этого кейса: около 1.5M hotels, в среднем 50 rooms на hotel, inventory horizon 730 дней. Если хранить доступность по (hotel_id, room_type_id, date), получается десятки миллиардов строк inventory на два года вперед. Поиск может достигать 500M запросов в день, peak около 30K RPS, а успешных booking-ов гораздо меньше: примерно 1M/day, то есть write path существенно ниже read path. Эта асимметрия определяет архитектуру.
Кейс тренирует ::concept{slug="postgres-internals"}, ::concept{slug="idempotency"}, ::concept{slug="consistency-models"} и ::concept{slug="saga-orchestration"}. Он особенно полезен после базового ::concept{slug="acid-vs-base"}: availability в поисковом индексе может быть approximate, но финальная booking transaction должна быть ACID.
Думайте о hotel booking как о двух системах с разной правдой. Read path отвечает на вопрос: какие отели, вероятно, подходят пользователю по городу, датам, цене, amenities и рейтингу. Write path отвечает на другой вопрос: можно ли прямо сейчас удержать конкретный room type на конкретные dates и потом подтвердить его после оплаты. Ошибка возникает, когда search index начинают считать source of truth.
Второй mental model: бронирование это не один SQL INSERT, а saga hold -> pay -> confirm. Hold коротко и атомарно резервирует capacity. Payment может идти секунды и зависеть от внешнего PSP. Confirm переводит hold в booked. Если payment failed, saga делает compensation и отпускает hold. Если пользователь исчез, TTL/reaper освобождает hold позже.
Третий mental model: inventory обычно работает на уровне room type per date, а не физической комнаты. Для отеля важнее не “комната 503 свободна”, а “двухместный номер DBL свободен на 12-14 июня”. Физическую комнату можно назначить позже. Это уменьшает cardinality и делает overbooking контроль проще, но усложняет edge cases с connected rooms, accessible rooms и specific room requests.
Диаграмма разделяет Edge/API Gateway, Search read path, Booking write path и Async fan-out. Client идет через CDN/API Gateway. GET /search направляется в Search Service, который сначала проверяет Redis cache, затем Elasticsearch с geo-фильтрами, price filters, amenities и текстовым ranking. Elasticsearch хранит денормализованный hotel document и availability hint, обновляемый через CDC или индексатор.
POST /bookings идет в Booking Orchestrator. Он запрашивает финальную цену у Dynamic Pricing, вызывает Inventory Service для hold, затем Payment Service, затем confirm. Inventory Service работает с Postgres, sharded by hotel_id, где строки inventory имеют поля total, booked, held, price_minor, currency, version. Constraint booked + held <= total должен защищать от oversell даже при ошибке application logic.
Outbox публикует booking.confirmed, booking.cancelled, hold.expired в Kafka, откуда notifications, analytics, reviews и search indexer получают обновления. Это важно: письмо гостю, push hotel partner-у и обновление поискового availability не должны происходить внутри critical transaction бронирования.
Search + book + confirm показывает нормальный путь. Пользователь ищет отель, получает top 50 результатов с p95 меньше 500ms, выбирает room type, orchestrator создает hold на 15 минут, payment списывает сумму, затем inventory confirm уменьшает held и увеличивает booked. Урок: search может быть cache-heavy, но перед оплатой надо перечитать цену и доступность из source of truth.
Race scenario показывает двух пользователей, которые одновременно пытаются взять последний номер. Правильное решение: короткий pessimistic lock или atomic update в Postgres на строке (hotel_id, room_type_id, date). Первый transaction инкрементит held, второй после ожидания видит, что доступность стала нулевой, и получает 409 Conflict. Нельзя полагаться на проверку “сначала SELECT, потом UPDATE” без lock/constraint.
Payment failure показывает compensation. Hold уже создан, но PSP возвращает decline. Orchestrator должен немедленно release hold и вернуть 402, а не ждать TTL. Если orchestrator упал между hold и release, reaper найдет expired hold по hold_expires_at и освободит его. Это пример ::concept{slug="saga-orchestration"} без долгой distributed transaction.
Hold timeout учит проектировать abandoned checkout. Пользователь закрыл вкладку, телефон разрядился, 3DS завис. Через 15 минут hold становится expired. Reaper делает held--, пишет событие и может уведомить других пользователей. Cancellation/refund сценарий добавляет обратный путь: booking переводится в cancelled, payment refund обрабатывается идемпотентно, а inventory возвращается только если policy допускает resale.
Первый trade-off: Elasticsearch против Postgres full-text/PostGIS. Postgres может выдержать небольшой каталог, но 500M searches/day, geo radius, multi-language analyzers, facets, amenities и ranking делают специализированный search index прагматичнее. Цена: index lag и approximate availability. Поэтому ES отвечает за discovery, а Postgres за финальную доступность.
Второй trade-off: pessimistic lock на весь booking flow против короткого hold transaction плюс saga. Держать SELECT FOR UPDATE во время payment нельзя: внешняя задержка превратит БД в bottleneck. Короткий lock только на hold дает no-double-booking и низкую contention. Цена: нужны hold state, expiration, reaper и compensation.
Третий trade-off: inventory по room type против inventory по конкретной комнате. Room type проще масштабируется и лучше подходит OTA, но не решает specific room assignment. Concrete room model нужен для небольших PMS или luxury hotels с точным выбором номера. Часто используют гибрид: booking держит room type, property management system позже назначает physical room.
Четвертый trade-off: cache freshness против search latency. Агрессивный cache дает 70-85% hit ratio, но может показывать уже занятый номер. Хороший UX обязан объяснять это: “room just taken”, suggestions, re-search. Плохой UX берет оплату и потом сообщает, что номера нет.
Booking.com, Expedia и Airbnb разделяют search/discovery и transactional booking. У них разные доменные нюансы: hotel inventory часто идет от channel managers и PMS, Airbnb ближе к уникальным listings, а корпоративные travel systems добавляют policy approvals и negotiated rates. Но паттерн один: denormalized read model для поиска, строгая transaction для reservation.
В отельном мире есть внешние источники правды: property management systems, channel managers, GDS, direct hotel APIs. Они могут прислать обновление availability с задержкой или отклонить booking после предварительного hold. Поэтому production OTA часто держит partner-specific adapters, reconciliation с поставщиками и manual ops tools.
Платежи обычно делегируются отдельному payment case. Для hotel booking важно не смешивать payment ledger и reservation inventory. Booking знает payment_id, payment знает состояние денег, а refund/cancellation policy связывает их через saga.
Главный anti-pattern: считать Elasticsearch availability финальным. Если search index сказал “1 room left”, это только hint. Без transaction в inventory source of truth будет double-booking. Второй anti-pattern: long DB transaction на время payment. Это создает lock wait, connection pool starvation и cascading failures.
Третья ошибка: не иметь hold_expires_at и reaper. Тогда abandoned checkout оставляет номера недоступными до ручной чистки. Четвертая: не делать idempotency на POST /bookings. Пользователь может дважды нажать кнопку или mobile client retry-ит timeout, и без idempotency получится два hold-а или два платежа.
Пятая ошибка: хранить цену только в search result. Dynamic pricing, taxes, fees и currency conversion должны быть подтверждены перед hold/payment. Шестая: не моделировать multi-night availability атомарно. Номер должен быть доступен на каждую дату проживания; нельзя подтвердить half booking без явной split-stay логики.
Не нужна такая архитектура для маленького сайта одного отеля с десятками номеров. Там можно начать с Postgres, простого transaction lock, server-rendered search и hosted payment checkout. Elasticsearch, Kafka, sharding и saga orchestrator будут дороже пользы.
Не стоит использовать OTA-style room type inventory, если продукт продает строго назначенные места или ресурсы: театр, самолет, парковочное место, врачебный slot. Там модель ближе к ticket booking или appointment scheduling. Также не нужен 15-minute hold для instant booking без payment step, если поставщик гарантирует availability и оплата идет позже по invoice.
Если partner API является единственным source of truth и не дает hold/confirm semantics, нельзя обещать пользователю жесткую гарантию “номер ваш” до подтверждения от партнера. Архитектура должна явно показывать pending state.
::concept{slug="postgres-internals"} про row locks, indexes, isolation и constraints.::concept{slug="idempotency"} для безопасных retries booking/payment endpoints.::concept{slug="saga-orchestration"} для hold, payment, confirm, cancellation и refund.::concept{slug="consistency-models"} для разделения stale read model и strict write model.::concept{slug="transactional-outbox"} для событий booking lifecycle.