End-to-end design of a hotel Central Reservation System (CRS) — the booking flow, avoiding double booking, inventory allocation and reservation holds, booking concurrency control (optimistic, pessimistic, conditional updates), distributed locks and a lock service, and scaling to 10k TPS and a million bookings a day — with data models, SQL and Java sketches.
Published September 25, 2026
These questions come from hospitality and travel interviews (CRS, OTAs, pricing, channel managers), but the patterns apply to any booking domain: flights, tickets, e-commerce stock.
Answer with the design framework:
The hard part is almost always "don't sell the same room twice, while staying fast".
Learn it in depth → The 6-Step Design Framework
Short answer: The typical flow:
BookingConfirmed event (through the outbox).Client → API Gateway → Booking Service ──► Inventory Service (DB, source of truth)
│ ▲
├──► Pricing Service (cache)
├──► Payment Service (idempotent)
└──► Outbox → Kafka → Notifications / OTA sync / PMS / Analytics
Search Service ← (CDC / events) ← Inventory + Rates (read model, eventually consistent)
Key points to cover:
Learn it in depth → Hotel Reservation System
Short answer: Make the inventory decrement atomic and conditional, in the database that is the source of truth. Don't use "check then update" in application code.
Option 1: a conditional update (the simplest, and very effective). The inventory is counted per room type per night:
-- room_inventory(hotel_id, room_type_id, stay_date, total, sold, held, version)
UPDATE room_inventory
SET held = held + :qty
WHERE hotel_id = :h AND room_type_id = :rt
AND stay_date BETWEEN :checkIn AND :lastNight
AND total - sold - held >= :qty;
-- success only if updated rows == number of nights (all in one transaction)
If the row count is less than the number of nights, roll back: at least one night is sold out.
Option 2: optimistic locking (a @Version column): read the row, check it, then update WHERE version = ?. Retry on conflict. Good when contention is low to moderate.
Option 3: pessimistic locking (SELECT … FOR UPDATE, in JPA @Lock(PESSIMISTIC_WRITE)): lock the inventory rows in a consistent order (by date) to avoid deadlocks. Good for high contention on a few rows (a popular hotel on New Year's Eve), but keep the transactions very short.
Option 4: specific rooms (seat-style inventory): assign physical rooms with a unique constraint (room_id, stay_date) in a room_night table. The database rejects the duplicate insert.
Also important:
@Transactional
public Hold hold(HoldRequest r) {
int nights = r.nights();
int updated = inventoryRepo.incrementHeld(r.hotelId(), r.roomTypeId(),
r.checkIn(), r.checkOut().minusDays(1), r.qty());
if (updated != nights) throw new SoldOutException(); // rolls back the whole transaction
return holdRepo.save(Hold.of(r, Instant.now().plus(Duration.ofMinutes(15))));
}
Common trap: a distributed lock (Redis) alone is not a safe way to prevent double booking. Locks can expire during GC pauses or network delays, so two holders can act at once. The database constraint or conditional update must still be the final guard. Use locks only to reduce contention, or with fencing tokens.
Short answer:
HELD → CONFIRMED → CHECKED_IN → CHECKED_OUT or CANCELLED / EXPIRED / NO_SHOW, with each transition updating held and sold atomically.FOR UPDATE SKIP LOCKED, or delayed messages) releases them.InventoryChanged) feed the channel manager / OTA sync and the search cache.Short answer:
SELECT … FOR UPDATE, or PostgreSQL advisory locks. Simple and consistent;SET lock:key <token> NX PX 30000, with release by a Lua script that checks the token. Fast, but only as reliable as your Redis setup; Redisson adds watchdog renewal;LockRegistry.// Redis lock with owner token (conceptual)
String token = UUID.randomUUID().toString();
Boolean ok = redis.opsForValue().setIfAbsent("lock:hotel:42", token, Duration.ofSeconds(30));
if (Boolean.TRUE.equals(ok)) {
try { doWork(); }
finally { redis.execute(UNLOCK_IF_OWNER_LUA, List.of("lock:hotel:42"), token); }
}
Common trap: the Redlock algorithm (multi-node Redis locking) is debated. It relies on timing assumptions, and without fencing tokens a paused client can still act after its lease expired. For correctness-critical work, prefer consensus systems (etcd, ZooKeeper) with fencing, or database constraints.
Learn it in depth → Design a Leader Election Algorithm
Short answer:
hotel:roomType:date;For a redesign of a legacy CRS: use the strangler-fig pattern: start by extracting the read path (search) behind an API gateway, then rates, then inventory and booking, with dual-writes or CDC and reconciliation during the migration.
Learn it in depth → Back-of-Envelope Estimation
Short answer:
Short answer: Use a saga with idempotent steps and compensation:
PAYMENT_AUTHORISED state;Reconciliation jobs compare the payment provider records with bookings daily. Never leave money and inventory out of sync silently.
Q: Why count inventory per night instead of per stay? A: Stays overlap in arbitrary ways; per-night counters make availability a simple minimum over the nights, and the booking an atomic decrement of each night.
Q: How long should a hold last? A: Long enough to complete payment (typically 10–15 minutes), short enough not to block real sales. Tune it from funnel data, and release holds promptly on abandonment.
Q: What's a fencing token? A: A monotonically increasing number issued with each lock grant. The protected resource remembers the highest token it has seen and rejects requests with a lower one, so a stale lock holder (after a GC pause) can't corrupt data.
Q: Why use SKIP LOCKED for expiry jobs?
A: Several job instances can each claim different expired holds without blocking each other or processing the same row twice.