Bookings and row locks
After this you can answer: How do two people never get the last seat at the same moment?
Picture it
Section titled “Picture it”sequenceDiagram participant A as Booking A participant P as Postgres participant B as Booking B A->>P: FOR UPDATE: lock B->>P: FOR UPDATE: waits A->>P: take seat, COMMIT P-->>B: lock, sees 0 seats B->>P: ROLLBACK, 409How it works
Section titled “How it works”- Lock the row.
FOR UPDATElocks only this event’s row until COMMIT or ROLLBACK. Bookings for other events, and plain reads, never wait. - Check on the locked row. Event exists (else 404), not started (else 400), enough seats (else 409). Nobody can change the row between the check and the write.
- Write relative to the row.
seats_left = seats_left - quantity, computed by Postgres. With the CHECKseats_left >= 0, even a missing lock could not oversell. - All or nothing. Seats, booking and outbox row commit together. Any failure rolls all of them back.
In MiniTickets
Section titled “In MiniTickets”The transaction is apps/api/src/queries/bookings.ts → createBookingTx.
The race demo (pnpm demo:booking-race) sends 20 buyers at 5 seats. Measured locally: 5 bookings,
15 answers of 409, 0 seats left, every time. With --unsafe (no lock, the app computes the number),
20 bookings were made for 5 seats: 15 oversold.
The cost is the hot row: all bookings for one popular event queue on one row, a few milliseconds each, and each waiting booking holds one of the pool’s 10 connections. That is why the sale-day gate in Valkey rejects most requests before they reach this lock.
Play the sale-day flow on the map →See it in AWS
Section titled “See it in AWS”RDS → minitickets-dev-db → Monitoring: DatabaseConnections and CPU rise on a busy sale. The lock
itself is inside Postgres.
aws logs tail /minitickets/dev/api --since 10m --filter-pattern "Not enough seats"
Break it
Section titled “Break it”| Symptom | Cause | Where to look |
|---|---|---|
| More bookings than seats | The lock was removed and the app wrote its own number | The query; run the race demo |
| Bookings slow down on a sale | Hot row: every booking queues on one row | Pool waits; the inventory gate |
| 409 although seats seem left | Someone else committed first; the page was stale | Working as designed |
Say it in an interview
Section titled “Say it in an interview”Follow-ups
Section titled “Follow-ups”Could a single conditional UPDATE do the same?
Yes: UPDATE … SET seats_left = seats_left - n WHERE id = $1 AND seats_left >= n. It is just as
safe and one statement shorter. But when it updates 0 rows you do not know why (unknown, started or
sold out) without a second query, which may already be out of date.
Do reads wait for the lock?
No. Postgres keeps the old version of the row for readers (MVCC), so events.list never waits for a
booking.
How would you scale a hugely popular event?
Split the seats over several rows, or put requests in a queue and confirm them in order. Today the Valkey gate rejects most requests early, and Postgres stays the final authority.
Advanced: Why the checks live in the query layer
A service never holds a transaction, but the checks must run on the locked row inside it. So the three checks sit in the query, a deliberate exception to the layer rule. Errors from them roll the transaction back and pass up as 404, 400 or 409. A violation of the CHECK constraint is translated to the same 409.
Checkpoint
Section titled “Checkpoint”Answer these without scrolling up. If one feels hard, reread that section.
- What is the lost-update race, in one sentence?
- What exactly does
FOR UPDATElock, and for how long? - Why write
seats_left - quantityinstead of a number computed by the app? - What commits together in the booking transaction?
- What is a hot row, and what protects it on a sale day?
Redraw it from memory: the sequence: A locks, B waits, A commits, B sees 0 and gets 409
Try this: run pnpm demo:booking-race on the local stack and count the 200 and 409 answers
Read this book online: https://book.theversehub.in/