Skip to content

02. Ticketing System — Low-Level Design

~20 min read · Part 3 of 4 (Overview → HLD → LLD → Q&A)

The HLD named the three rails. This file opens the two that carry the design's weight — the reservation engine that acquires seats by compare-and-swap over a self-expiring lease, and the waiting room that meters admission — and pins down the schemas, the algorithm, and the concurrency corners where a double-sell actually sneaks in.

Data models

The system of record for a seat is one row per seat, sharded by event. The durable relational row is the arbiter of the final sold state; the fast, high-churn hold layer lives in Redis.

CREATE TABLE seat (
    event_id     BIGINT      NOT NULL,
    seat_id      VARCHAR(16) NOT NULL,       -- 'A1', 'B4', section-encoded
    state        SMALLINT    NOT NULL,       -- 0=available, 1=held, 2=sold
    version      BIGINT      NOT NULL,       -- optimistic-concurrency guard
    hold_id      VARCHAR(32) NULL,           -- current holder, null if free/sold
    booking_id   VARCHAR(32) NULL,           -- set exactly once, at sale
    price_cents  INT         NOT NULL,
    PRIMARY KEY (event_id, seat_id)
);

CREATE INDEX idx_event_state ON seat (event_id, state);   -- "how many available"

Three choices matter. First, (event_id, seat_id) is the primary key, so every hot operation is a point update by that key and the data shards cleanly by event — one on-sale's contention stays on one shard. Second, version exists to make the CAS safe: an update only commits if the version it read still matches, which is how thousands of racing attempts on A1 collapse to exactly one winner without a lock. Third, booking_id is nullable and written exactly once — the transition to sold sets it under a uniqueness guard, giving a durable, auditable "this seat belongs to this booking" that survives any cache loss.

The booking side enforces the ultimate backstop:

CREATE TABLE booking (
    booking_id      VARCHAR(32) PRIMARY KEY,
    event_id        BIGINT      NOT NULL,
    seat_id         VARCHAR(16) NOT NULL,
    user_id         BIGINT      NOT NULL,
    idempotency_key VARCHAR(64) NOT NULL,
    payment_id      VARCHAR(32) NOT NULL,
    created_at      TIMESTAMP   NOT NULL,
    UNIQUE (event_id, seat_id),               -- HARD no-double-sell backstop
    UNIQUE (idempotency_key)                  -- retried checkout → one booking
);

The UNIQUE (event_id, seat_id) constraint is the line that cannot be crossed: even if every layer above malfunctions, the database physically refuses to record two bookings for one seat. The UNIQUE (idempotency_key) makes a retried or double-clicked checkout resolve to the same booking instead of a second charge.

The hold lease lives in Redis, not SQL, because it is time-boxed and high-churn:

KEY   hold:{event_id}:{seat_id}   VALUE {hold_id, user_id}   EX 480   NX
KEY   holdset:{hold_id}           SET of "{event_id}:{seat_id}"        # seats in one hold

SET ... EX 480 NX is an atomic acquire-if-absent with an 8-minute (480 s) expiry — a lease that self-releases if checkout never completes. The holdset groups the seats a single hold covers so a checkout or release can act on all of them together.

The queue is a Redis sorted set scored by arrival time:

ZADD  queue:{event_id}  {arrival_ts}  {queue_token}     # join, O(log n)
ZRANK queue:{event_id}  {queue_token}                    # "you are #41204"
ZPOPMIN queue:{event_id}  {batch_size}                   # admit the front

Component internals

Component 1 — Reservation engine (CAS acquire over a lease)

Responsibility: flip a seat available → held for exactly one caller under massive contention, back it with a self-expiring lease, and later promote it to sold or release it — never allowing two live claims on one seat.

The design separates two concerns that people wrongly fuse: acquiring the seat (must be atomic and instant) and holding it while the fan pays (must be time-boxed and revocable). Acquisition is a CAS; the hold is a lease.

class ReservationService:
    def hold(event_id, seat_ids, user_id, session_token) -> Hold | Conflict
    def release(hold_id) -> None                    # early release (back button)
    def promote(hold_id, booking_id) -> None         # hold → sold, at checkout
    def reap_expired() -> None                       # release lapsed leases

The hold acquisition, per seat, is a single conditional write guarded by version — the whole contention story lives in these lines:

UPDATE seat
   SET state = 1,                     -- held
       hold_id = :hold_id,
       version = version + 1
 WHERE event_id = :event_id
   AND seat_id  = :seat_id
   AND state    = 0                   -- available
   AND version  = :read_version;      -- optimistic guard
-- Rows affected = 1 → we won this seat.  Rows affected = 0 → someone else did.

If the update affects one row, the caller owns the seat and the service writes the Redis lease. If it affects zero rows, the seat was already taken between read and write, and this seat goes into conflict_seats for an instant 409. No lock is ever held; the loser is told immediately to pick again rather than waiting behind anyone.

Multi-seat holds are atomic as a group. A fan holding A1 and A2 must get both or neither. Attempt the CAS on each seat; if any fails, roll back the ones that succeeded (flip them back to available, delete their leases) and return the conflict. This keeps the fan from paying for a partial selection.

Component 2 — Waiting room (fair admission and backpressure)

Responsibility: accept the entire join burst cheaply, order fans by arrival, and admit them into the active shopping pool only as fast as the reservation engine can serve — keeping active shoppers near 1.5× remaining seats.

class WaitingRoom:
    def join(event_id, user) -> (queue_token, position)     # ZADD, always succeeds
    def position(event_id, queue_token) -> int              # ZRANK
    def admit_batch(event_id) -> [session_token]            # ZPOPMIN, rate-limited

Admission runs as a control loop, once per second per event:

def admit_batch(event_id):
    remaining   = inventory.available_count(event_id)      # e.g. 6,200 left
    active       = sessions.active_count(event_id)          # e.g. 9,000 shopping
    target       = min(1.5 * remaining, MAX_ACTIVE)         # cap the pool
    slots        = max(0, target - active)                  # how many to let in
    tokens       = queue.zpopmin(event_id, count=slots)     # take the earliest arrivals
    for t in tokens:
        yield sessions.issue(event_id, t, ttl=SESSION_TTL)  # session token, own expiry

The loop is self-correcting: as seats sell and remaining falls, target falls with it, so admission slows and finally stops when remaining hits zero — at which point queued fans are admitted straight to a sold-out response instead of a dead seat map.

Core algorithm — the on-sale, stepped through the scenario

Run the threaded scenario: 10,000 seats, 100,000 fans, on-sale at 10:00:00, hold TTL 8 minutes, payment conversion ~70%.

  1. 10:00:00 — the burst. 100,000 fans hit POST /queue over ten seconds, ~10,000/s. Each is a single ZADD into queue:{event} scored by arrival timestamp. Every call succeeds in ~1 ms; none touches inventory. Fans immediately see honest positions from ZRANK — "you are 41,204."

  2. 10:00:01 — first admission. With 10,000 seats available and 0 active, target = min(1.5 × 10,000, MAX) = 15,000, so the first loop admits the earliest ~15,000 arrivals via ZPOPMIN, each getting a session token. The other 85,000 keep polling their position. The reservation engine now faces ~15,000 shoppers, not 100,000 — the burst has been converted into a bounded pool.

  3. 10:00:01–10:00:05 — the opening grab. The admitted wave loads the seat map and grabs. The front-row seats concentrate contention: A1 draws ~4,000 hold attempts in the first second. All 4,000 run the version-guarded UPDATE ... WHERE state=0 AND version=v. Exactly one affects a row; it wins A1 and writes the lease hold:{event}:A1 EX 480 NX. The other ~3,999 affect zero rows and get an instant 409 conflict_seats:[A1] — they reselect. Aggregate hold-attempt rate peaks around 2,000/s, well within one shard. No lock convoy forms because no attempt ever waits.

  4. 10:00:05–10:04:00 — draining. Seats flip available → held → sold as fans check out. Roughly 70% of holds promote to bookings; the ~30% that lapse hit their 480 s TTL and the reaper flips them back to available, so they re-enter the pool for the next admitted batch. This is why total hold attempts (~25,000) exceed the 10,000 seats while sold seats never exceed 10,000: churned seats are re-sold, never double-sold. The admission loop keeps active ≈ 1.5 × remaining, so as remaining falls from 10,000 toward 0, admission tapers.

  5. ~10:04:00 — sold out. The 10,000th seat's held → sold transition sets the last booking_id, and available_count hits 0. target becomes 0; admission stops. Every fan still queued — fan 40,000, fan 90,000 — is now admitted directly to a sold-out response served from the inventory counter, not a seat map they would fruitlessly scan. Fan 40,000 never sees a seat they cannot have. The queue drains as a clean tail.

The invariant held throughout: at every instant, each seat had at most one live claim, because each available → held transition was a single atomic CAS and each sale was guarded by a unique index. Contention never became incorrectness; it only became 409s.

Sequence diagram — two fans race for seat A1 at 10:00:01

Fan A      Fan B      Reservation      Seat row (A1)     Redis lease      Booking DB
  │ hold A1  │            │                 │                │               │
  ├──────────┼───────────▶│  read A1        │                │               │
  │          │  hold A1    │  (state=0,v=7)  │                │               │
  │          ├────────────▶│  read A1        │                │               │
  │          │            │  (state=0,v=7)  │                │               │
  │          │            ├─ UPDATE ... v=7 ▶│  1 row → WIN   │               │
  │          │            ├─ SET NX EX 480 ─┼────────────────▶│ lease held    │
  │◀─────────┼─ 201 hold ─┤                 │                │               │
  │          │            ├─ UPDATE ... v=7 ▶│  0 rows (v=8!) │               │
  │          │◀─ 409 A1 ──┤  conflict        │                │               │
  │          │  (reselect A2 → wins)         │                │               │
  │  checkout A1 (+idem key)                 │                │               │
  ├──────────┼───────────▶│  promote        │                │  INSERT       │
  │          │            ├─ hold→sold, set booking_id ──────┼──────────────▶│ UNIQUE(A1) ok
  │◀─────────┼─ 201 ──────┤                 │                │               │

Both fans read version 7. Fan A's CAS commits and bumps A1 to version 8; Fan B's CAS, still asserting version 7, matches zero rows and loses cleanly. Fan B is told to reselect, not made to wait. At checkout the UNIQUE (event_id, seat_id) index is the final guarantee the sale is singular.

Concurrency and edge cases

  • Two holds race for one seat: resolved by the version-guarded CAS. Both read state=0, version=7; the first UPDATE commits and increments to version 8, so the second's WHERE version=7 matches nothing and returns zero rows. Exactly one winner, no lock, loser gets an instant 409.
  • Abandoned hold: the Redis lease carries an 8-minute TTL and the durable row is reset by the reaper when the lease is gone. A fan who closes the tab cannot strand a seat — the seat self-heals back to available. Over-release is the safe failure direction; over-sell is not.
  • Reaper races a late checkout: a checkout arriving just as the hold expires must not sell a released seat. Checkout re-asserts the hold: it promotes held → sold only WHERE hold_id = :hold_id AND state = 1. If the reaper already flipped the seat back to available, the promote matches zero rows and checkout returns 410 hold expired — the fan is told to try again rather than being sold a seat someone else may now hold.
  • Double-click / retried checkout: the UNIQUE (idempotency_key) on booking collapses duplicate checkouts into one booking; the second insert conflicts and returns the existing booking rather than a second charge or a second seat.
  • Payment succeeds but the booking write fails: the dangerous split-brain. Guard it by charging inside a flow that writes a payment_intent record first, and reconcile: if a payment settled with no booking, either complete the booking (seats still held) or refund (seats released). The idempotency key ties the payment to at most one booking so reconciliation is deterministic.
  • Multi-seat partial success: holding A1+A2 where A1 wins and A2 loses must not leave the fan holding a lone A1 they did not want as a pair. The service rolls back the won seats (flip back to available, delete leases) and returns the conflict, so a group hold is all-or-nothing.
  • Session token outlives inventory: an admitted fan who dawdles until sold-out gets a 409/sold-out on their hold attempt, since the CAS finds no available seats. Admission does not guarantee a seat; only a successful CAS does. This is deliberate — guaranteeing a seat at admission would mean reserving inventory for shoppers who may never buy.
  • Hot-seat starvation under retry storms: a losing fan hammering A1 wastes attempts on a seat that is gone. The client is told the specific conflict_seats and nudged toward best-available, and the reservation service can return the current state so the UI grays out A1 immediately — turning retries into reselection rather than a self-inflicted hot loop.