BOOK DEEP READ · System Design Interview Vol 2 · Ch 7
System Design Interview Vol 2 · Ch 7 · Alex Xu & Sahn Lam · 2022
You pick a room in a booking app and press "Reserve". In the second or two that follows, the system has to do something genuinely dangerous: confirm the room is still there, put it in your name, and not also sell that same room to the person pressing the same button right now. This chapter builds that system from scratch.
You probably assume the hard part is crowds. Do the arithmetic: five thousand hotels, a million rooms; even at 70% occupancy with an average three-night stay, that's only a bit over two hundred thousand bookings a day — about three per second. Three! Less traffic than a small forum's posts. The difficulty was never volume. It's that two of those three might be reaching for the same room, and getting it wrong costs real money.
Picture the paper register at the front desk. A guest calls, the clerk turns to March 5th, sees one room left, writes down a name. The trouble is that in the same second a colleague is looking at the same page, also seeing "one left", also writing a name. Two people paid; there is one room. That's overselling. What's true of a paper ledger is exactly true of two servers reading the same row.
Step one: change what you sell. A hotel doesn't sell "room 301" — it sells "king room · March 5th · how many left", a single cell of allowance. Like a cinema selling "how many seats remain for Saturday night" and sorting out where you sit at the door. Finding an empty room among tens of thousands turns into subtracting one from a number.
Step two: govern that cell. Two ways to live. Either you hold the page face-down while you change it and nobody else may look until you're done — safe, but a queue builds up; or you note which revision of the page you read, check on writing whether that revision still holds, and if someone cut in front of you, start over — faster most days, but during a scramble you redo it again and again.
Step three: cure the other kind of double. You fumbled and clicked "Submit" twice. The fix is to hand you a ticket number when you open the booking page, and let one ticket number be redeemed exactly once — like taking a numbered slip in a queue: shout twice, you still count as one person.
For an ordinary hotel the second way (check the revision) is plenty, and you can even let the database itself guard the rule "never book more than you have". Only a scramble — the night before a concert, a whole city chasing one hotel — earns that cell its own queue. The price you pay: now and then you'll reach the last step and read "just taken". The system would rather waste one of your clicks than sell one room twice.
A reservation system is hard not because of traffic (it's a few writes per second) but because of correctness over finite inventory: turn rooms into one cell of "room type × date", then guard that cell with "only one writer at a time" and "one ticket, one redemption".
Want the mechanisms, the notation and the diagrams? → Switch to the deep read
On the surface this chapter teaches you to design a booking system; what it actually teaches is that some systems are hard on correctness, not on scale. The estimate comes out at roughly three reservations per second, yet the system must survive concurrency on a single inventory row and must never sell an extra room because of one retry. The skeleton is four things: model sellable capacity as an inventory row per "room type × date", wrap "decrement inventory" and "insert reservation" in one database transaction, block duplicate submissions with an idempotency key, and block overselling with an optimistic lock or a database constraint. Only once the system is split into several services are you forced to reach for 2PC / Saga / TCC — tools that cost far more.
SELECT ... FOR UPDATE.Chapter 7 of Volume 2. The first six chapters (proximity service, maps, message queue, monitoring, ad aggregation) are uniformly throughput and streaming problems; this one turns for the first time to a transactional system with finite inventory — the book's dividing line between "can it hold up" and "does it add up". It leads into Ch 11 (payments) and Ch 12 (digital wallet). In the real world: the booking path at Booking.com, Airbnb or Expedia, and any system with finite inventory and money attached — ticketing, airline seats, flash sales, restaurant tables.
All of the difficulty concentrates into one instant: two people press "Reserve" on the last room at the same time. What if you don't solve it? An oversell isn't a line in an error log, it's a payout — the guest arrives, there's no room, and the hotel covers the difference to put them elsewhere (the industry calls it walking the guest), plus the reputational cost. But fearing oversells so much that you serialize all concurrency makes a popular hotel unbookable during a rush. This chapter walks the narrow path: never sell one room twice, and never lock the system up either.
The book's setting is a chain of 5,000 hotels, about 1 million rooms. At 70% occupancy and an average three-night stay, new reservations per day come to roughly 1,000,000 × 0.7 ÷ 3 ≈ 230,000; spread over 86,400 seconds that's about 3 QPS of writes. Walking the funnel backwards (far more people browse detail pages than actually pay), reads land in the hundreds of QPS. How many inventory rows? 5,000 hotels × ~20 room types × 730 days ≈ 73 million rows, each holding a few integers — a few GB, which one relational database holds comfortably.
The conclusion is counterintuitive: this chapter needs no sharding, no NoSQL, no queue to absorb spikes. The estimate isn't here to prove the design can cope; it's here to talk you out of over-engineering — so all the attention goes back to those 3 writes.
This is the chapter's foundation. The data model needs two tables: reservation (idempotency key, hotel, room type, check-in / check-out dates, count, status) and room_type_inventory (hotel_id, room_type_id, date, total_inventory, total_reserved) — one row per hotel, per room type, per day. Asking "is anything free from 3/5 to 3/8" becomes checking whether those three rows all have headroom; booking becomes adding 1 to total_reserved on each, plus one reservation row.
Why model it this way? ① It copies the business — hotels already sell by type and assign numbers at check-in, so the model tracks reality and needs the fewest later changes. ② It turns search into arithmetic — "find an empty room among tens of thousands" degenerates into "add or subtract one integer on one row", which gives concurrency control somewhere concrete to live. ③ It turns "several nights" into "several rows" — three nights is three row updates in one transaction, and if any single night is full the whole booking fails, which is exactly the right semantics. The cost lives in that multiplication: rows = hotels × types × days, so you must define a sellable window (say two years out) and roll rows forward and clean them up on a schedule.
They both look like "booked twice" and their causes are nothing alike — this is the chapter's most valuable distinction, and the place interviews trip people up.
First kind: one user submitting twice (a retry after a timeout, a fumbled double-click). The cure is idempotency: generate a reservation id as the idempotency key the moment the user opens the booking page, carry it on the request, and put a unique index on that key in the reservation table. The second submission collides with the constraint, so no new row is created and the first result is returned. The easy mistake is timing: the key must be fixed before the moment a retry could happen — mint a fresh key on every click and your idempotency is fake.
Second kind: two users reaching for the same cell. Both requests read "1 left", both conclude there's room, both write "reserved +1" — a read-modify-write race. An idempotency key is no help whatsoever here: these are two separate, individually legitimate requests. Only concurrency control saves you.
Pessimistic locking. SELECT ... FOR UPDATE locks the inventory row until the transaction commits; latecomers queue. Correct and easy to explain; the cost is that concurrency on that row is fully serialized, everyone waits while the lock is held, and a long transaction drags in timeouts and deadlocks.
Optimistic locking. Add a version integer to the row: read it, then write with UPDATE ... SET total_reserved = ?, version = version + 1 WHERE version = <the value read>. Zero rows affected means somebody got there first, so re-read and retry. Near-zero overhead under low contention; under high contention it degenerates into a retry storm.
Database constraint. Let the row police itself, e.g. CHECK (total_reserved <= total_inventory) — or <= total_inventory * 1.1 to leave room for overbooking. The least code, and out-of-bounds writes are rejected by the database itself; the cost is that the rule hides in the schema rather than evolving with your code, support differs across databases, and the application still has to handle the "rejected" path.
At this chapter's 3 QPS the authors pick optimistic locking or a database constraint — pessimistic locking here is the most expensive tool for the smallest nail. One piece of discipline is non-negotiable: decrementing inventory and writing the reservation must sit in the same transaction. Split them, crash in between, and you are left with either "inventory taken but no reservation" (a room that vanishes) or "a reservation with no inventory taken" (an oversell).
Loosen the constraint from "no more than capacity" to "no more than capacity × a factor" (the book uses 110%) and the system will sell more rooms than exist. Why? Cancellations and no-shows are the norm, and selling strictly to inventory leaves rooms running empty. The cost is that unredeemable overbooking means walking the guest elsewhere and eating the difference. So that factor isn't a technical parameter; it's a business decision trading historical cancellation rates against payout costs.
As soon as the reservation service and the inventory service each own a database, "decrement inventory + write reservation" crosses a process boundary and ACID stops being free. Three roads: 2PC has a coordinator run a two-phase vote — strong consistency, but locks are held throughout and a coordinator crash leaves transactions in doubt; Saga splits the work into a chain of local steps and runs compensations in reverse order on failure — no locks, scales well, but only eventual consistency, and compensation is not rollback: refunds may carry fees and cancellations may carry penalties, so compensations have business semantics of their own; TCC does a Try that reserves the inventory, then a Confirm or a Cancel — shorter lock windows than 2PC, at the cost of three code paths per service.
The chapter's stance is admirably restrained: don't split if you don't have to. With a few GB of data and 3 QPS of writes, anything one transaction in one database can settle is not worth trading for a better-looking architecture diagram.
Table 1 · Three ways to guard the inventory row
| Pessimistic lock | Optimistic lock | DB constraint | |
|---|---|---|---|
| Mechanism | SELECT ... FOR UPDATE locks the row | write only if version is unchanged, else retry | CHECK rejects out-of-bounds writes |
| Low contention (3 QPS) | works, but you pay for locks | almost free | least code |
| Hot row (a rush) | serialized queue, latency spikes, deadlocks | retry storm, success rate drops | rejection rate climbs, app still retries |
| When to choose | high contention that must succeed first time | this chapter's default: few writes, rare conflicts | simple rule, let the database backstop you |
Table 2 · Where "decrement inventory + write reservation" lives
| Approach | Consistency | Locks held | Cost | Fits |
|---|---|---|---|---|
| One DB, one txn | strong (ACID) | milliseconds | data must share a DB; service borders constrained | this chapter: a few GB, 3 QPS |
| 2PC | strong | both phases | blocking, coordinator single point, high latency | few participants, short txns, strong needs |
| Saga | eventual | none | you write the compensations, with business meaning | long flows (book → pay → ticket) |
| TCC | eventual (near-lock while reserved) | Try → Confirm | three code paths per service + edge cases | "hold it now, pay later" inventory |
What about hot rows? A non-problem most days that erupts the night before a concert, when a whole city chases one hotel. Three practical answers: bucket the cell (split 10 rooms into 5 cells of 2, spreading concurrency over 5 rows, at the cost of "rooms exist but in another bucket"); give the cell its own queue, trading a retry storm for a predictable wait; and keep reads away — let list pages read caches and replicas, treat the number they see as a hint, and re-check against the primary only at the moment of booking.
This skeleton — an inventory row, one transaction, an idempotency key — is close to the universal answer for finite inventory: ticketing (a price tier for one show), airlines (flight × cabin × date), e-commerce checkout (SKU stock), restaurant and desk booking. Swap the nouns and it ports. In practice: SELECT ... FOR UPDATE plus a unique index on MySQL or PostgreSQL is the most common implementation; DynamoDB uses conditional writes and a transactional API, with a client token on transactional writes for idempotency; Spanner uses pessimistic locks plus automatic client retries; Alibaba and Ant turned TCC and Saga into a framework (Seata) and open-sourced it.
In interviews the value of this question is that it's one of the few that tests transactions, isolation and idempotency for real. An interviewer will almost certainly push on three things: what happens when two people book the last room, what happens when a user clicks twice, and how many transactions you put the inventory decrement and the reservation write into. Answer those three and the chapter has paid for itself.
1. This chapter is hard on correctness, not scale — about 3 QPS of writes, 73M inventory rows in a few GB, and one relational database is enough.
2. The core abstraction: you don't sell a room number but one cell of "room type × date" (total_inventory and total_reserved), one row per night.
3. A multi-night booking is +1 on several inventory rows plus one reservation, and it must happen in a single transaction, or you leak vanished rooms or oversells.
4. Treat the two duplicates separately: a re-sent request → idempotency key + unique index; two users on one cell → concurrency control. Neither substitutes for the other.
5. Pick one guard: pessimistic locks (correct but serializing), optimistic locks (the default here, a retry storm under contention), or a database constraint (least code).
6. Overbooking (say 110%) is a business decision — cancellation rates traded against payout costs, not a technical parameter.
7. Splitting services means buying consistency: 2PC blocks while holding locks, Saga is only eventual and compensation is not rollback, TCC needs three code paths per service. Don't split if you don't have to.
8. Hot rows are the real enemy: bucket, queue, and push reads onto caches and replicas — where the count is only ever a hint.