Спроектируй систему бронирования отелей
Бронирование отелей: проблема двойного бронирования, инвентарь как счётчик на тип-номера на ночь, холды с истечением, контроль конкуренции, намеренный овербукинг, поиск доступности и интеграция платежа на пути брони.
У отеля остался один люкс на Новый год. Два гостя, в разных городах, нажали «Забронировать» в одну секунду. Оба приложения запросили доступность — оба увидели «1 доступен» — оба показали страницу подтверждения, оба списали с карты, оба отправили подтверждение по почте. 31 декабря две семьи приехали в один номер. Ночной менеджер оплатил отель конкурента, погасил гнев обоих гостей бесплатным проживанием, и бренд получил репутационный удар, которого ни один SLA-дашборд не показал. Это проблема двойного бронирования (double-booking), и это та же гонка потерянного обновления, что и у кошелька — два чтения «1 доступен», оба декрементят — но с поворотом, делающим отели своим случаем: инвентарь — это на тип-номера на ночь, бронь охватывает диапазон ночей, и бизнес иногда намеренно продаст номер дважды. Сделать «доступен?» и «зарезервировать» одним атомарным решением, через диапазон дат, — вся проблема.
Требования
Система бронирования даёт гостям искать доступные номера на диапазон дат и бронировать их, ни разу не обещая один номер двум гостям, что оба приедут.
Функциональные. Искать доступность по отелю, типу номера и диапазону дат. Резервировать номер на диапазон дат. Держать (hold) инвентарь, пока гость вводит платёж, освобождая, если он бросит. Подтверждать при оплате, отменять и освобождать по запросу. Показывать гостю его брони. Давать отелям управлять инвентарём и ценами.
Нефункциональные. Никакого двойного бронирования физического инвентаря (ядровый инвариант), но высокая доступность поиска и бронирования (лежащий сайт брони теряет выручку каждую секунду). Read-heavy: поисков много больше броней, так что путь чтения должен быть быстрым и кэшируемым, а путь записи — строго корректным. Поддержать намеренный овербукинг как бизнес-рычаг (продать чуть больше ёмкости, чтобы покрыть неявки). Интегрировать платёж на пути брони с дисциплиной идемпотентности прошлых уроков.
Определяющая форма: это read-heavy система с маленькой, критичной к корректности записью. Поиск может быть eventually consistent и обслуживаться из кэшей/реплик; операция резерва должна быть строго консистентной и атомарной — и уникально, она оперирует инвентарём, разбитым по ночам и по типам номеров.
Оценка
отелей = ~1 000 000 листингов по миру
поисков = ~10 000 поисков/с (read-heavy, доминирующая нагрузка)
броней = ~100 броней/с в среднем, ~1 000/с на пике (крошечно против поиска)
read:write = ~100:1 (поиски против броней)
строк инвентаря = типы_номеров × ночи → счётчик на (тип_номера, дата)
TTL холда = ~10–15 минут, пока гость платитЧисла говорят две ясные вещи. Поиск доминирует (~100:1 чтений к записям), так что архитектура read-оптимизирована — доступность обслуживается из кэшей и read-реплик, денормализована для быстрых запросов по диапазону дат и терпит лёгкую устарелость. Броней мало, но они должны быть в точности корректны — при ~100–1 000/с путь записи не проблема пропускной способности; это проблема корректности на горячем ресурсе (популярный отель на популярную дату). Инвентарь естественно моделируется как счётчик (или строка) на тип-номера на ночь, потому что проживание с 3-го по 6-е потребляет 3-е, 4-е и 5-е независимо — и эта поночёвая гранулярность делает запись брони интересной.
Высокоуровневый дизайн
Ключевое озарение — разделение: read-оптимизированный путь доступности (кэши, read-реплики, денормализованный инвентарь) поглощает ~10К поисков/с и может быть чуть устаревшим, и строго консистентный путь резерва, делающий атомарный check-and-decrement. Холд резервирует номер, пока гость платит; задача истечения возвращает брошенные холды. Платёж сидит на шаге подтверждения с ключами идемпотентности. Двойное бронирование предотвращается целиком в сервисе резерва.
Глубокое погружение
Проблема двойного бронирования через диапазон дат
Вступление — снова гонка потерянного обновления, но с поночёвым инвентарём и диапазоном дат. Наивный поток — прочитать доступность; если доступно, записать бронь — имеет тот же фатальный зазор: два запроса читают «1 доступен» до того, как любой запишет. Починка — тот же принцип (сделать check-and-reserve атомарным), но применённый на каждую ночь через запрошенный диапазон.
Чистая модель: счётчик инвентаря на (тип_номера, дата), а бронь на ночи N1..N2 должна атомарно декрементить каждую ночь в диапазоне, успевая, только если все они доступны:
inventory(deluxe, 2025-12-31) = 1
Наивно (СЛОМАНО):
A: чит. 1 → ок B: чит. 1 → ок
A: запись брони B: запись брони → обе забронированы, счёт ушёл 1 → -1
Атомарно (на ночь, всё-или-ничего в одной транзакции):
UPDATE inventory SET booked = booked + 1
WHERE (room_type, date) IN (Dec31) AND booked < total -- условно
→ A успех (booked 0→1); тот же UPDATE B матчит 0 строк (booked = total) → отклонёнДва пути реализации, оба валидны: одна ACID-транзакция, что декрементит все поночёвые строки с условной проверкой (или SELECT … FOR UPDATE, чтобы их заблокировать), коммитясь, только если каждая ночь успешна; или атомарное условное обновление на ночь внутри одной транзакции. Всё-или-ничего через диапазон важно: 3-ночное проживание не должно забронировать ночи 1 и 2, затем провалиться на ночи 3, оставив бесполезную частичную бронь. Поскольку все ночи одного типа номера сидят вместе, держишь это на одном шарде (шард по отелю), так что оно остаётся локальной транзакцией — тот же трюк одношард-ACID, что и у кошелька.
▸Почему это работает
Почему моделировать инвентарь как поночёвый счётчик, а не отслеживать отдельные физические номера (номер 412 против 415)? Потому что гости почти никогда не бронируют конкретный номер — они бронируют тип номера на диапазон ночей, а отель назначает физический номер при заселении. Отслеживание конкретных номеров вынуждает решать задачу интервального планирования/назначения на каждом поиске («есть ли какой-то свободный номер на весь диапазон?»), что куда дороже и конфликтнее, чем инкремент счётчика. Со счётчиком на (тип_номера, ночь) доступность — тривиальное сравнение (booked < total), а бронь — условный инкремент на каждую ночь в диапазоне — быстро, дружелюбно к кэшу для чтений и атомарно для записей. Ты разрешаешь до физического номера лишь при заселении, когда назначение — локальное, офлайн-решение без давления конкуренции. Моделирование взаимозаменяемой единицы (тип-номера-ночь) вместо физической и делает оба пути, чтения и записи, выполнимыми.
Холды брони и истечение
Бронь не мгновенна — гостю нужны минуты на ввод платёжных деталей. Если декрементить инвентарь только после платежа, два гостя могут оба пройти проверку доступности и наперегонки платить; если декрементить до и гость бросит, номер заблокирован навсегда. Ответ — холд (hold): в начале checkout атомарно зарезервируй инвентарь (декременти счётчик) и создай холд с коротким TTL (Time To Live — временем жизни, 10–15 минут). Холд делает номер по-настоящему недоступным всем остальным во время checkout, так что окно двойного бронирования закрыто ещё до завершения платежа.
Если гость платит вовремя, холд конвертируется в подтверждённую бронь. Если бросает, механизм истечения возвращает инвентарь: либо фоновая задача истечения подметает истёкшие холды и переинкрементит счётчик, либо холды несут метку истечения, а запросы доступности игнорируют истёкшие (ленивое истечение). Это тот же паттерн TTL-и-освобождение, что у распределённой блокировки или записи кэша — резервируй оптимистично, ограничь резервирование во времени и автоматически верни его, чтобы брошенный checkout не застрял с инвентарём навсегда.
Овербукинг как намеренная политика
Вот поворот, отделяющий отели от строгого никакого-двойного-списания кошелька: отели овербукят нарочно. Исторически предсказуемая доля гостей не приходит или отменяет поздно, так что продажа ровно по ёмкости оставляет номера пустыми и теряет выручку. Поэтому бизнес намеренно продаёт, скажем, 102 номера против 100 физических, ставя, что ~2 не приедут. Это не баг, который надо заинженерить прочь — это политика, которую система должна поддержать: лимит инвентаря становится total × overbook_factor (или total + overbook_buffer), а не физическим счётом, настраиваемым на отель/дату из исторических ставок неявки.
Инженерное следствие двойное. Первое: инвариант меняется: «никогда не превышать физическую ёмкость» становится «никогда не превышать настроенный лимит овербукинга» — атомарный check-and-decrement сравнивает с лимитом политики, не с сырым счётом номеров. Второе: нужен путь walk/компенсации для редкого случая, когда овербукинг кусает (приехало больше гостей, чем номеров): перебронируй их в сопоставимый отель, оплати разницу. Senior-мысль — осознать, что корректность здесь определяется бизнес-политикой, не физикой — работа системы в том, чтобы обеспечить любой лимит, что задаёт политика, в точности и атомарно, пока овербукинг намеренно ставит этот лимит выше физического счёта.
▸Частая ошибка
Показательная ошибка — трактовать поиск доступности и гарантию брони как один уровень консистентности — обычно пытаясь сделать поиск строго консистентным, чтобы «число, что ты увидел, и есть число, что получишь». Это и не нужно, и вредно: поиск — это ~100× трафика, так что прогон его через строго консистентный путь записи разрушает масштабируемость, и он всё равно не гарантирует, что номер твой, потому что кто-то может забронировать в зазоре между твоим поиском и кликом. Верная модель принимает, что поиск рекомендателен и чуть устарел (из кэшей/реплик), и кладёт реальную гарантию на атомарный check-and-reserve. UI это отражает: «номеров мало» и финальное атомарное подтверждение, а не обещание, что результат поиска — это бронь. Смешивание оценки чтения с гарантией записи — то, как команды и переинженеривают путь чтения, и всё равно отгружают двойные брони.
Узкие места и компромиссы
Архитектура — намеренное разделение консистентности: read-heavy путь доступности меняет строгую свежесть на масштаб (кэши/реплики, eventually consistent, ~100× трафика), пока путь резерва не меняет ничего на корректности (строго консистентен, атомарный check-and-decrement). Конкуренция за горячий инвентарь — реальное узкое место — популярный отель на пиковую дату — это единственный конфликтный счётчик, ровно как горячий аккаунт кошелька; держишь его одношардовым ради локально-транзакционной атомарности и принимаешь, что одна горячая строка сериализуется, смягчая короткими холдами (так что окна конкуренции кратки) и, где бизнес разрешает, запасом, что даёт овербукинг. Холды меняют конверсию на защиту: слишком коротки — гости теряют номера посреди checkout, слишком длинны — брошенные холды морят инвентарь голодом — настраивай TTL. Овербукинг меняет опыт гостя на выручку — настроенная ставка на ставки неявки с ценой walk-пути, когда она неверна. И шардинг по отелю держит каждую бронь одношардовой транзакцией ценой того, что межотельные запросы (поиск) требуют отдельной денормализованной read-модели — именно поэтому поиск и резерв — разные системы.
Два гостя бронируют последний люкс на ту же ночь в пределах секунды; оба видели «1 доступен» и оба получили подтверждения. Что это, и какова верная починка для многоночной брони?
Твой сервис резерва обеспечивает «никогда не превышать физический счёт номеров», но бизнес хочет перепродавать, чтобы покрыть неявки. Как система должна трактовать овербукинг?
Чтобы закрыть окно двойного бронирования, пока гость вводит платёж, система ставит короткоживущий _______ на инвентарь — делая номер недоступным всем остальным во время checkout — а механизм истечения автоматически возвращает его, если гость бросает, так что незавершённая бронь не застрянет с номером навсегда.
- 01Что такое проблема двойного бронирования и как её починить для многоночного проживания?
- 02Зачем холды брони с истечением, и что это за паттерн?
- 03Как система должна трактовать овербукинг, и что меняется из-за него?
Бронирование отелей — это гонка двойного бронирования — снова потерянное обновление кошелька — но над инвентарём, что на тип-номера на ночь, где проживание охватывает диапазон, а бизнес иногда перепродаёт нарочно. Архитектура — намеренное разделение консистентности: read-оптимизированный путь доступности (кэши/реплики, ~100× трафика, лёгкая устарелость норм и рекомендательна) и строго консистентный путь резерва, чей атомарный check-and-decrement всё-или-ничего по каждой ночи в диапазоне — единственное, что предотвращает двойное бронирование — держимый одношардово по отелю, так что это одна локальная ACID-транзакция. Инвентарь моделируется как счётчик на (тип_номера, ночь), а не физические номера, потому что гости бронируют взаимозаменяемый диапазон-типа-номера, а физический номер назначается при заселении, что делает и чтения (тривиальное booked < total), и записи (условный инкремент) выполнимыми. Короткоживущий холд с TTL закрывает окно двойного бронирования во время checkout, а задача/ленивая проверка истечения возвращает брошенные холды — паттерн резервируй-с-TTL-и-освободи. Поворот — овербукинг: настроенная политика, что ставит лимит выше физической ёмкости, чтобы покрыть неявки, так что инвариант становится «никогда не превышать настроенный лимит» (обеспечиваемый столь же атомарно) с walk-путём для редкого переполнения. Постоянные компромиссы — разделение консистентности ради масштаба, конкуренция за горячий инвентарь на пиковые даты, TTL холда, балансирующий конверсию против защиты, и овербукинг, меняющий опыт гостя на выручку — все разрешаются вокруг одного правила: поиск может оценивать, но атомарный check-and-reserve — единственная гарантия. Теперь, когда встретишь любую задачу «забронировать слот» — концертные билеты, очередь к врачу, парковочное место — ты потянешься к той же форме: read-оптимизированный вид для поиска, атомарный условный декремент для фактического резервирования и холд с TTL, пока пользователь завершает оплату.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.