CMD Guide
HomeSystem DesignSystem Design Problems

Designing Ticketmaster

This is the introductory version. For the staff-depth treatment — mechanism, failure modes, and the trade-off layer — study the deep companion: Designing Ticketmaster — Seat Reservation Without Double-Booking, Traced

A movie-ticket booking system prevents two people from buying the same seat by making the "who wins this seat" decision exactly once, atomically: each contested seat lives as one row that a short serialized transaction locks at read time (SELECT ... FOR UPDATE), while the transient five-minute hold and the fairness queue live outside that transaction in per-show ordered maps. Everything else in the product — browsing cities, movies, showtimes, seat maps — is a read-scaling problem you solve with caches and a CDN. The entire design converges on one hotspot: the write path for a popular show, where thousands of requests fight over a few thousand seat rows in the same few seconds.

Requirements

Functional. List cities → movies in a city → cinemas and showtimes for a movie → seat map for a show. A user selects up to 10 seats and gets a hold for 5 minutes to pay. If seats are taken but may free up (other users' holds expiring), the user may wait, and waiting users are served first-come-first-serve. Bookings are all-or-nothing — no partial orders.

Non-functional. High concurrency on the same seat, handled fairly; ACID for the money path; scalable and available through on-sale traffic spikes.

Capacity estimate (why the write path is the whole game)

Assume 3B page views/month and 10M tickets sold/month. Page views average ≈ 3B / 2.6M s ≈ 1,150 reads/s, spiking maybe 10× on a hot on-sale — easily absorbed by cache + CDN + read replicas. Ticket sales average only ≈ 4 writes/s — trivially small on paper, but they are not uniform: a single blockbuster on-sale funnels a huge fraction of those writes into one show's few thousand seat rows within seconds. So capacity is comfortable; contention is the problem.

Storage: 500 cities × 10 cinemas × 2000 seats × 2 shows × (50+50) bytes ≈ 2 GB/day, ≈ 3.6 TB over five years — small enough that the DB is not the bottleneck; the lock contention on hot rows is.

APIs

SearchMovies(api_dev_key, keyword, city, lat_long, radius,
             start_datetime, end_datetime, postal_code,
             results_per_page, sorting_order)
  -> [ { MovieID, ShowID, Title, StartTime, EndTime,
         Seats: [ {Type, Price, Status} ] } ]   // cacheable, read-only

ReserveSeats(api_dev_key, session_id, show_id, seat_ids[])
  -> "Reservation Successful"
   | "Reservation Failed - Show Full"
   | "Reservation Failed - Retry"   // seats currently held by others

session_id is the handle the server uses to expire the hold if payment does not complete. Search is read-only and cache-friendly; ReserveSeats is the one call that touches the contended write path.

Data model

The relationships that matter: a Movie and a Hall each have many Shows; a Show has many Show_Seat rows (one per physical seat for that show, pre-created with Status ∈ {0 free, 1 reserved, 2 booked}) and many Bookings; a User has many Bookings. The key modelling choice is that seat availability is per-show state on Show_Seat, not on the physical seat — that row is the single object every concurrent request contends for.

diagram
diagram

High-level architecture and how a show is routed

Web servers hold user sessions; application servers do reservation logic and talk to an ACID SQL store (primary + secondary) and a cache. The key structural decision: all in-memory state for a given show — its active reservations and its waiting queue — must live on one owner. We route by consistent hashing on ShowID, so every request for Show 99 lands on the same app server, which is the only place its ordered maps exist. That is what makes fair FCFS ordering and O(1) hold-expiry possible without cross-node coordination on the hot path.

diagram
diagram

The booking mechanism: two ordered maps

The server keeps two per-show structures, each a Hashtable<ShowID, LinkedHashMap<…>>. A LinkedHashMap is chosen because it gives two properties at once for free:

Clients hold a long-poll open against WaitingUsersService; when seats free up the server completes that request to push the user back to the seat map. Whenever a reservation completes or expires, ActiveReservationsService signals WaitingUsersService so it can wake the head — if the freed seat count now meets that user's requested count. Note the trade-off this buys: strict FCFS can idle freed seats behind a large head request (head wants 5, only 3 freed, and the #2 waiter who wants exactly 2 stays asleep). The alternative — a bounded look-ahead that scans the first k waiters for a fit — recovers that utilization at the cost of strict fairness (small parties keep leapfrogging big ones).

diagram
diagram

Concurrency: making the decision once

The correctness core is a short transaction that takes an exclusive lock on the specific seat rows at read time, so a competing transaction blocks at its own SELECT instead of racing to the UPDATE:

BEGIN;

-- reserve seats 54,55,56 of ShowID=99; lock in ascending id order (deadlock-safe)
SELECT ShowSeatID
  FROM Show_Seat
 WHERE ShowID = 99
   AND ShowSeatID IN (54, 55, 56)
   AND Status = 0                 -- 0 = free
 ORDER BY ShowSeatID
   FOR UPDATE;                    -- EXCLUSIVE row locks acquired NOW

-- app check: got exactly 3 rows?
--   yes -> proceed        no -> ROLLBACK, return "retry / wait"

UPDATE Show_Seat SET Status = 1   -- 1 = Reserved
 WHERE ShowSeatID IN (54, 55, 56);

INSERT INTO Booking (ShowID, UserID, Status, ExpiresAt)
VALUES (99, 4711, 1, CURRENT_TIMESTAMP + INTERVAL '5' MINUTE);

COMMIT;   -- lock held for milliseconds, NOT the 5-minute payment window

Why the naïve version is wrong. An earlier framing said "within a transaction, if we read rows we get a write lock on them." A plain SELECT does not. Under SERIALIZABLE in a lock-based engine (e.g. SQL Server) a plain read takes shared/range locks — two transactions can both read Status=0, then both attempt the UPDATE, deadlock on the lock upgrade, and the engine aborts one. Under PostgreSQL's SSI, a plain read takes no row locks at all; it detects the write-write conflict at commit and raises a serialization failure. Both are safe only via abort-and-retry, which collapses throughput and fairness on a hot row. SELECT ... FOR UPDATE takes the exclusive lock up front, so the loser blocks and then observes the truth (Status=1) — a clean fail, no retry storm. Because the seat rows already exist, you don't even need SERIALIZABLE; FOR UPDATE under READ COMMITTED is sufficient and cheaper (no range-lock overhead). Once the transaction commits, ActiveReservationsService starts tracking the hold in memory.

Worked trace: two users race for seats 54–56 of Show 99

Both Alice and Bob want the same three seats; the 5-minute TTL then expires Alice's unpaid hold and promotes Bob from the waiting queue.

TimeTxn A (Alice)Txn B (Bob)Seats 54–56Maps / outcome
14:00:00.000BEGIN; SELECT … FOR UPDATE → 3 rows, Status=0; X-locks heldfree, locked by A
14:00:00.002(updating)BEGIN; same SELECT … FOR UPDATE → blocks on A's lockslockedBob parked on lock
14:00:00.050UPDATE Status=1; INSERT Booking(ExpiresAt=14:05:00); COMMIT → locks freedstill blockedStatus=1 (Reserved)ActiveReservations[99] head = B-8801
14:00:00.051doneSELECT unblocks → 0 rows with Status=0 → ROLLBACK; API returns "retry / wait"ReservedBob appended to WaitingUsers[99] (head)
14:05:00.000no paymentTTL sweep: head expiry ≤ now → Booking=Expired, Status=0, pop; signal waiting service
14:05:00.010long-poll wakes: 3 free ≥ 3 wanted → sent to seat mapfreeBob promoted from waiting head

No moment exists where both hold the seats: the lock serializes the decision, the hold lives as a row (not an open lock), and fairness comes from the queue ordering, not from luck.

Reservation expiration and crash recovery

Expiry. The client shows a countdown that can drift from the server clock. Add a server-side buffer of ~5 seconds so the server never expires a hold before the client's timer hits zero — otherwise a user who clicks "Pay" at 4:59 could have the seat yanked mid-purchase.

Settlement is a CAS, not an assumption. The grace buffer only shrinks that race window — it cannot close it: the TTL sweep (Booking→Expired, seat Status→0) and a completing payment can still interleave. So payment completion must itself be a conditional update, in one transaction: UPDATE Booking SET Status = Booked WHERE BookingID = ? AND Status = Reserved AND ExpiresAt > now(), flipping the seat rows to Status = 2 in the same transaction. If it reports 0 rows, the sweep won — void/refund the charge and tell the user; never mark the booking booked. Because sweep and settle both write the same Booking row with guarded updates, the database serializes them and exactly one wins — the same decide-once-atomically discipline as the seat grab itself. "Paid but expired" becomes a compensable event, never a double-owned seat.

Crash recovery. ActiveReservationsService is a cache over durable truth: after a crash, rebuild each show's map by reading Booking rows with Status=Reserved and their ExpiresAt. WaitingUsersService is not persisted, so a crash silently drops everyone waiting — mitigate with a primary-secondary (hot standby) replica, or accept that waiters simply re-poll. Run the SQL store itself primary-secondary for durability.

Partitioning

Partition the database by ShowID, not MovieID: a blockbuster's shows would otherwise all land on one shard and hot-spot it, whereas ShowID spreads a movie's shows across shards. Use consistent hashing on ShowID to assign the app servers that own a show's maps (say 3 replicas per show). On expiry, the owning server updates the DB, removes the map entry, notifies the expired user, and broadcasts to the WaitingUsersService replicas for that show so the longest waiter is woken. On a successful full booking, it tells those replicas to expire any waiter whose requested count now exceeds the seats left.

Pitfalls

When to use this design — and when not

This design is application-managed timed holds + pessimistic row locking at commit. It is the right fit when contention is moderate-to-high but not extreme, you need a fair FCFS waitlist, timed holds so a human can finish paying, an interactive seat map, and strict ACID on money. Reach for it for cinemas, mid-size venues, and most "reserve then pay" flows.

Choose THIS when you need fair timed holds + an interactive seat map at moderate contention with ACID money; prefer queue-based serialization when one show's peak write rate on its seat rows outstrips what pessimistic locking can push through, and prefer optimistic concurrency when contention is genuinely rare.

Takeaways


Re-authored and deepened for this guide. Problem framing from Grokking the System Design Interview ("Design Ticketmaster"). Concurrency corrected against the PostgreSQL documentation on transaction isolation and Serializable Snapshot Isolation, the Microsoft SQL Server transaction-isolation-levels reference, and Martin Kleppmann, Designing Data-Intensive Applications, ch. 7 (two-phase locking, predicate/range locks, SSI, and SELECT ... FOR UPDATE). The traced companion page 018 uses the same FOR UPDATE pattern.

Operability and drill ladder

Ops signals: alert on the held-seat ratio (reserved ÷ total for a show), the TTL-expiry sweep lag (how far behind "now" the head of ActiveReservations is running), and — the one that should sit at ≈0 — payment successes arriving after the server-side hold already expired (each one is caught and voided by the settle CAS's 0-rows branch), which flags a clock-skew or grace-buffer regression.

Drill ladder

  1. L1: Write the FOR UPDATE seat claim SQL with ORDER BY.
  2. L2: Why is the hold not an open transaction?
  3. L3: Deadlock two multi-seat carts — prevent how?
  4. L4: Redis SETNX hold vs SQL truth — failure mode on Redis failover.
  5. L5: Flash-sale alternative architecture in 5 bullets.
🤖 Don't fully get this? Learn it with Claude

Stuck on Designing Ticketmaster? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Designing Ticketmaster** (System Design) and want to truly understand it. Explain Designing Ticketmaster from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Designing Ticketmaster** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Designing Ticketmaster** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Designing Ticketmaster** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes