Filter concepts by levelShowing all levels.

System Design · Section 93

Ticketing / Reservation Systems

Level
advanced
Read
14 min
Concepts
1

A reservation system exists to hold one invariant — a unit of inventory is sold at most once — under conditions specifically designed to break it: thousands of buyers competing for the same seat in the same second, a payment step that takes seconds or minutes and can fail, and clients that retry on timeout. The mechanism is a temporary hold. Selecting a seat does not sell it; it creates a hold row with an expiry, and a unique constraint on the held unit means two concurrent attempts cannot both succeed, so the loser is told the seat has gone immediately rather than after entering card details. Expiry is what makes abandonment safe, because the person who should release a hold is precisely the one who has closed the tab — and the expiry is compared at read time rather than delegated to a cleanup job, so availability is correct at every instant and the sweep becomes a storage optimisation rather than a correctness dependency. Payment is coupled to the hold rather than the reverse: the hold comes first, the charge runs against it carrying an idempotency key so a resubmission returns the original booking, and success converts the hold into a booking inside a transaction that re-checks the hold is still valid. That re-check is what closes the gap payment latency opens — a charge that outlives its hold must fail loudly and refund, because the alternative is two people arriving at one seat. Hold duration is therefore a product decision with real cost on both sides: too short and genuine buyers lose seats mid-checkout, too long and inventory sits unavailable during exactly the minutes it is most wanted. And for general-admission inventory the same shape applies with a conditional decrement that cannot fall below zero.

System Design overview

What is true here

  1. The invariant is enforced by a unique constraint on the held unit — an availability check describes a state that is stale the moment it is read.
  2. A hold makes a seat unavailable without selling it, so a failed or abandoned payment costs nothing but a short delay.
  3. Compare expires_at at read time; relying on a cleanup job makes availability depend on when that job last ran.
  4. Convert the hold inside a transaction that re-checks it, so a payment that outlived its hold refunds instead of overbooking.
  5. Idempotency keys make a retried purchase return the original booking rather than creating a second one.

What you will be able to do

  • Design a hold mechanism that answers a lost race immediately and releases itself on abandonment
  • Write an availability query that is correct without a cleanup job having run
  • Sequence payment and conversion so a slow charge cannot produce a double booking
  • Choose a hold duration from the payment latency distribution and state what each side of the trade costs

Holding inventory safely

Atomic holds, self-releasing expiry, idempotent purchases, and the re-check that couples payment to inventory.

Holds, expiry, payment coupling and never double-booking

coreadvanced

A reservation system exists to enforce one invariant — a unit of inventory is sold at most once — under conditions designed to break it: thousands of people wanting the same seat in the same second, a payment step that takes seconds or minutes and can fail, and clients that retry. The mechanism is a temporary hold. Selecting a seat does not sell it; it creates a hold row with an expiry, and that row is what makes the seat unavailable to everyone else while one buyer completes payment. The hold is enforced by a unique constraint on the unit, so two concurrent attempts cannot both create one — the loser is told the seat has gone, immediately, rather than after they have entered their card details. Expiry is what makes abandonment safe: a buyer who closes the tab does not remove their own hold, so the hold must release itself, which means a stored `expires_at` compared at read time rather than a cleanup job that decides when a seat is free. Payment is coupled to the hold rather than the other way round: the hold is created first, payment runs against it, and success converts the hold into a booking inside a transaction that re-checks the hold is still valid — because a payment that takes four minutes against a three-minute hold has to fail loudly, and refund, rather than quietly overbook. Idempotency covers the retries: a client that resubmits a purchase must get the original booking back, not a second one, which is the same idempotency-key mechanism a payment flow uses. And the whole design refuses to rely on any application-level check, because the invariant here is the product.

Think of it as

A cloakroom peg with a slip on it that says "reserved until 14:32". Anyone else who reaches for the peg sees the slip and moves on. If the person who put it there does not come back, nobody has to notice or tidy up — at 14:32 the slip stops meaning anything and the peg is free. The one rule that matters is that only one slip can ever be on a peg, and that rule is enforced by the peg having room for exactly one, not by everyone being careful.

sql
-- one hold per seat, enforced by the database
CREATE TABLE holds (
  seat_id    uuid PRIMARY KEY,     -- one row max
  buyer_id   uuid        NOT NULL,
  expires_at timestamptz NOT NULL
);

-- take a hold, or lose the race cleanly
INSERT INTO holds (seat_id, buyer_id, expires_at)
VALUES ($1, $2, now() + interval '8 minutes')
ON CONFLICT (seat_id) DO UPDATE
   SET buyer_id = $2, expires_at = now() + interval '8 minutes'
 WHERE holds.expires_at < now()     -- only if the
RETURNING seat_id;                  -- old hold has
-- 0 rows -> somebody else holds it  -- expired

What we're doing: Run one seat through a high-contention sale and watch each mechanism do its job.

seat-14c.txttext
Seat 14C. 3,400 people press "select" within
the same second when the sale opens.

  3,400 INSERT ... ON CONFLICT attempts
  1 succeeds. 3,399 get 0 rows back and are
  told "taken" immediately.

  Nobody waited on a lock. Nobody entered card
  details for a seat they could not have.

Buyer A now holds 14C until 10:08:00.

10:03  A submits payment.
       idempotency key: hold-14C-buyerA
10:03  A's browser times out; A resubmits.
       Same key -> the original charge is
       returned. One charge, not two.

10:07:40  payment succeeds.
10:07:41  BEGIN
            SELECT * FROM holds
             WHERE seat_id = '14C'
               AND buyer_id = A
               AND expires_at > now()
               FOR UPDATE;            -- 1 row
            INSERT INTO bookings ...  -- unique
            DELETE FROM holds ...     -- on seat
          COMMIT
       Booked.

Alternative ending: payment succeeds at 10:08:20.
       The same SELECT returns 0 rows.
       The transaction rolls back, the payment is
       refunded, and the buyer is told the hold
       expired -- which is unpleasant, and far
       better than two people arriving at 14C.
5
The unique constraint turns a contention problem into a fast, clean answer for 3,399 people. Any design that instead queues them on a lock makes all 3,399 wait to be told the same thing.
16
The idempotency key is scoped to the hold rather than to the attempt, so a resubmission is recognised as the same purchase. This is the same mechanism the payment section describes, applied to the operation that couples payment to inventory.
21
Re-checking the hold inside the converting transaction, with a row lock, is what closes the gap that payment duration opens. Checking before the payment call proves nothing about the state after it.
31
This branch is the reason the hold duration is a product decision. It must be long enough that a normal payment finishes comfortably inside it, because the alternative to refusing here is selling one seat twice.

Why this works: Each mechanism handles a failure the others cannot: the constraint handles concurrency, the expiry handles abandonment, the idempotency key handles retries, and the re-check inside the converting transaction handles the gap that payment latency opens. Removing any one of them produces a specific, reproducible way to sell the same seat twice.

Checking availability, then booking

Wrong

python
if seat_is_available(seat_id):   # read
    charge(buyer)                # ...and a long
    create_booking(seat_id)      # gap...
                                 # ...then write

Better

python
hold = take_hold(seat_id, buyer)   # atomic, or
if not hold:                       # it fails now
    return "taken"
charge(buyer, idem_key=hold.id)
convert_hold_to_booking(hold)      # re-checks the
                                   # hold in the
                                   # same txn

What you see: Double bookings that cluster at the moment a popular sale opens and are impossible to reproduce afterwards, because they need two requests to fall inside the same window — a window the payment step stretches from milliseconds to minutes.

Why: The availability check describes a state that stops being true the instant it is read, and the payment call in the middle widens the gap by orders of magnitude. Taking an atomic hold first collapses the check and the claim into one operation, and re-checking at conversion covers the remaining time.

Two buyers, one seat, and a payment that outlives its hold
Buyer A
Buyer B
Booking service
Database
Payment provider
  1. 1. select seat 14C
  2. 2. INSERT hold (expires in 8m)
  3. 3. hold created
  4. 4. select seat 14C
  5. 5. INSERT hold → unique violationB is told immediately, before entering card details
  6. 6. charge A (idempotency key)
  7. 7. succeeded, 9 minutes later
  8. 8. convert hold → booking, re-check hold
  9. 9. hold expired — refuse, refund A
  1. Buyer A → Booking service: select seat 14C
  2. Booking service → Database: INSERT hold (expires in 8m)
  3. Database → Booking service: hold created
  4. Buyer B → Booking service: select seat 14C
  5. Booking service → Database: INSERT hold → unique violation (B is told immediately, before entering card details)
  6. Booking service → Payment provider: charge A (idempotency key)
  7. Payment provider → Booking service: succeeded, 9 minutes later
  8. Booking service → Database: convert hold → booking, re-check hold
  9. Database → Booking service: hold expired — refuse, refund A

The four states a seat moves through

The four states a seat moves through
StateSet byReleased byVisible as
availableDefaultSelectable
heldA buyer selecting itExpiry, cancellation, or conversionUnavailable to others
bookedPayment success, converting the holdRefund / cancellation flowSold
releasedHold expirySelectable again

Six requirements, and the mechanism for each

Six requirements, and the mechanism for each
RequirementMechanismWhy not something else
Concurrency controlUnique constraint on the held unit, plus a conditional insertA read-then-write availability check has a race between its two statements
Temporary holdsA hold row with `expires_at`Marking the seat sold before payment overbooks on every abandonment
Expiration`expires_at` compared at read time; a sweep is an optimisationA cleanup job alone makes availability depend on when the job last ran
IdempotencyIdempotency key on the purchase requestWithout it, a retried purchase is a second booking and a second charge
Payment couplingConvert inside a transaction that re-checks the holdChecking the hold before payment leaves a gap the payment duration widens
No double bookingAll of the above, with the database refusing violationsAny application-level guarantee fails under concurrency it did not anticipate

Remember: The invariant is "sold at most once", and it is enforced by a unique constraint on the held unit rather than by any availability check. Selection takes an atomic hold with an `expires_at`, so a lost race is answered immediately and an abandoned checkout releases itself without anyone noticing. Compare the expiry at read time so a sweep is an optimisation, not a correctness dependency. Carry an idempotency key so a retried purchase returns the original booking. And convert the hold inside a transaction that re-checks it, because a payment that outlives its hold must fail and refund rather than overbook.

See also: choosing enforcement mechanisms · optimistic vs pessimistic · concurrency control mechanisms · payment correctness building blocks · idempotency keys for post requests · prefer simpler mechanisms

Advertisement