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%.
-
10:00:00 — the burst. 100,000 fans hit
POST /queueover ten seconds, ~10,000/s. Each is a singleZADDintoqueue:{event}scored by arrival timestamp. Every call succeeds in ~1 ms; none touches inventory. Fans immediately see honest positions fromZRANK— "you are 41,204." -
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 viaZPOPMIN, 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. -
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 leasehold:{event}:A1 EX 480 NX. The other ~3,999 affect zero rows and get an instant409 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. -
10:00:05–10:04:00 — draining. Seats flip
available → held → soldas 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 toavailable, 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 keepsactive ≈ 1.5 × remaining, so asremainingfalls from 10,000 toward 0, admission tapers. -
~10:04:00 — sold out. The 10,000th seat's
held → soldtransition sets the lastbooking_id, andavailable_counthits 0.targetbecomes 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 firstUPDATEcommits and increments to version 8, so the second'sWHERE version=7matches nothing and returns zero rows. Exactly one winner, no lock, loser gets an instant409. - 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 → soldonlyWHERE hold_id = :hold_id AND state = 1. If the reaper already flipped the seat back toavailable, the promote matches zero rows and checkout returns410 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_intentrecord 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 noavailableseats. 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_seatsand 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.