Skip to content

Bookings and row locks

2 min read · main part only; folded sections are optional

After this you can answer: How do two people never get the last seat at the same moment?

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, 409
  1. Lock the row. FOR UPDATE locks only this event’s row until COMMIT or ROLLBACK. Bookings for other events, and plain reads, never wait.
  2. 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.
  3. Write relative to the row. seats_left = seats_left - quantity, computed by Postgres. With the CHECK seats_left >= 0, even a missing lock could not oversell.
  4. All or nothing. Seats, booking and outbox row commit together. Any failure rolls all of them back.

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 →

RDS → minitickets-dev-db → Monitoring: DatabaseConnections and CPU rise on a busy sale. The lock itself is inside Postgres.

SymptomCauseWhere to look
More bookings than seatsThe lock was removed and the app wrote its own numberThe query; run the race demo
Bookings slow down on a saleHot row: every booking queues on one rowPool waits; the inventory gate
409 although seats seem leftSomeone else committed first; the page was staleWorking as designed
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.

Answer these without scrolling up. If one feels hard, reread that section.

  1. What is the lost-update race, in one sentence?
  2. What exactly does FOR UPDATE lock, and for how long?
  3. Why write seats_left - quantity instead of a number computed by the app?
  4. What commits together in the booking transaction?
  5. 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/