Walkthrough28 min

Walkthrough: Ticket Booking

Inventory contention, seat locking with TTLs, idempotent payments, and handling flash-sale spikes.

"Design Ticketmaster" is the canonical strong-consistency question: thousands of people converge on the same few hundred seats the second a sale opens, and selling one seat twice is a hard failure. The design splits cleanly into a relaxed read side and a strict booking side — and saying that split out loud in the first five minutes is the single highest-value move.

Step 1: Requirements

Functional:

  1. Users can browse and search events.
  2. Users can view a venue's seat map with availability.
  3. Users can book specific seats and pay.

Non-functional — the consistency split, stated per feature:

  • Booking must be strongly consistent: no double-sold seats, ever.
  • Browsing can be eventually consistent: an event description or seat map that is seconds stale is fine.
  • Survive flash sales: 10M users arriving in the minute a hot on-sale opens.
  • Read:write ratio ~100:1 — cache the read side aggressively.

Step 2: Core entities

Event, Venue, Performer, Ticket (one row per seat, with a status: available → held → sold), Booking (user, seats, payment state).

Step 3: API

GET  /v1/events/:id                      -> event + seat map
GET  /v1/search?q=...                    -> events
POST /v1/bookings    { seat_ids[] }      -> { booking_id, hold_expires_at }
POST /v1/bookings/:id/confirm  (payment) -> { status }

Step 4: High-level design

Two paths through the system. The read path: Event and Search services over Postgres, with Redis caching hot events and a CDN for images — eventual consistency makes all this caching safe. Search graduates as scale demands: SQL LIKE (can't use an index — fine at tiny scale) → Postgres full-text (GIN index) → Elasticsearch (a dedicated search engine built on an inverted index), kept in sync via CDC (change data capture: streaming the DB's change log into the search index). Search being seconds stale is exactly the eventual consistency we budgeted.

The booking path: a Booking service over the same Postgres, calling an external PSP (payment service provider, e.g. Stripe — they handle cards; you never store card numbers). Booking is deliberately not cached.

Deep dive 1: The seat hold

Users expect a seat to be held while they pay (say, 10 minutes). Two mechanisms, in the good-to-great ladder:

Good — status column + expiry: UPDATE tickets SET status='held', held_until=now()+'10 min' WHERE id=? AND status='available'. The WHERE clause makes the claim atomic — two users race, one row updates, the loser sees 0 rows changed. A cron sweeps expired holds back to available. Correct, but the sweep adds lag and cron machinery.

Great — Redis lock with TTL: SET hold:seat:1234 <user_id> NX EX 600. Atomic claim and self-expiring hold in one command — no cron. The seat's truth (sold or not) still lives in Postgres; Redis holds only the transient hold. On confirm, the booking service verifies the hold is still yours, marks the ticket sold in a DB transaction, and releases the lock.

Then expect the probe: what if the TTL expires mid-payment? Be honest: the hold can lapse while the user is typing a card number. Options: extend the TTL when payment starts, and always re-verify the hold inside the final DB transaction — the transaction, not Redis, is the last line of defense. If the seat was lost, refund/void the payment and apologize. Never mark a seat sold on the strength of an expired hold.

Deep dive 2: Idempotent payment confirmation

Payment requests time out and get retried — by clients, by proxies. A retry must not double-charge or double-book. Send an idempotency key (a unique ID per logical attempt) with the PSP charge and store it with the booking: replays with the same key return the stored result instead of charging again. If payment fails or times out definitively, release the hold immediately so the seat returns to the pool.

Deep dive 3: Surviving the flash sale

10M users at on-sale time will flatten the seat map and booking DB. The standard answer is a virtual waiting queue: users land in a queue (their position tracked server-side), and the system admits them gradually — a few thousand at a time — into the actual booking flow, notified via SSE (server-sent events: a one-way push channel over plain HTTP). Everyone else sees a live queue position instead of an error page. This converts a stampede into a steady, survivable flow, and it's honest: the seats were never going to be enough anyway.

Seat-map liveness uses the same SSE channel: push seat-status changes to viewers of that event. One caution: cache seat availability only briefly (a second or two) if at all — it is the one read that must not be very stale.

Wrap-up

Recap against requirements: browsing and search are cached and eventually consistent (CDC-fed Elasticsearch); booking is strongly consistent via atomic holds re-verified in a final transaction; payments are idempotent under retries; and flash sales are absorbed by the waiting queue with SSE admission. With more time: dynamic pricing, fraud checks, and multi-venue sharding.