System Design #21: Design a Ticket Booking System

SYSTEM DESIGN #21 · INTERVIEW GUIDE

Design a Ticket Booking System

The moment Taylor Swift announces a tour, 5 million fans hit the Ticketmaster website simultaneously — all trying to buy the same 50,000 seats. Without careful concurrency control, two fans book the same seat, the system oversells, and your company is on the front page for all the wrong reasons. This Ticket Booking System solves the double-booking problem with distributed locking via Redlock (5-node Redis quorum) and Lua atomic seat reservation scripts that make check-and-book an uninterruptible operation. A Saga pattern coordinates the booking-payment-confirmation flow with full compensating transaction support for every failure scenario. A priority Waitlist built on Redis ZPOPMIN releases seats fairly when bookings expire or are cancelled. You will learn how to handle 100,000 concurrent users competing for limited inventory without a single double-sell.

RedlockRedis LuaSaga PatternTemporalStripe

💡

The Gist — What Problem Are We Solving?

Ensuring exactly one person gets each unique seat — even when thousands try simultaneously

Unlike a flash sale where any unit of stock will do, every seat in a venue is unique. Seat 14B Row F cannot be substituted. This makes the locking problem harder: you must guarantee exactly one person books each specific seat, even when thousands attempt the same seat simultaneously. The solution requires atomic operations, distributed locks, and careful saga orchestration — and must handle the Taylor Swift scenario: 14 million users competing for 70,000 tickets.

💬

Think of it as a distributed referee that ensures only one hand touches each seat, no matter how many hands reach for it at the same moment.

Functional Requirements

These are the capabilities the system must deliver — what users and operators can actually do with it.

🎭
Seat Selection

🎭Browse seat map with real-time availability; select specific seat

🔒
Seat Locking

🔒Hold selected seat exclusively for 10 minutes during checkout

💳
Booking & Payment

💳Complete purchase within hold window; confirm on payment success

📋
Waitlist

📋Join waitlist for sold-out events; FIFO notification on cancellation

🔔
Notifications

🔔Booking confirmation; reminder; waitlist offer with 5-min window

Non-Functional Requirements

These define how well the system must perform — the quality attributes that separate a toy from a production system.

🔒 No Double-Booking

🔒Zero tolerance — atomic check-and-lock; SELECT FOR UPDATE safety net

⏱️ Checkout Window

⏱️10-minute hold; visible countdown; optional 5-min extension

📈 Concurrency

📈10K+ simultaneous booking attempts per hot event

⚡ Lock Latency

⚡<1ms seat lock acquisition via Redis

📋 Waitlist

📋FIFO fairness; 5-minute acceptance window per offer

📊

Key Metrics — The Numbers That Define This System

The headline numbers to know cold — and be ready to explain how each one is achieved.

<1ms
lock acquisition
10 min
checkout hold
SELECT FOR UPDATE
DB safety net
FIFO
waitlist ordering
5 min
waitlist offer window
🏗️

System Architecture Diagram

Full data flow from source to serving. Each layer scales independently.

Ingestion
Ticket Booking Flow
Seat Map UI

Seat Lock API

Redis Redlock
10min TTL

Order Service
Postgres SERIALIZABLE

Processing
Payment Service

Confirm; Lock expiry

Kafka delayed event

Inventory release

Storage
Waitlist ZPOPMIN

Notify next user

🗺️

End-to-End User Journey

Trace a single request end-to-end — the story interviewers want you to tell fluently.

1
User browses seat map

— Seat availability served from Redis hash (seat_id → status). Available seats rendered clickable.

2
User selects seat 14B

— Lock API called: Redis Lua script: IF seat_14B == ‘available’ THEN SET seat_14B ‘locked:user_123’ EX 600 RETURN ‘ok’ ELSE RETURN ‘taken’

3
Checkout started

— Order created in Postgres with status=RESERVED. 10-minute countdown shown. Kafka delayed message published for T+10min.

4
Payment completes

— Payment service confirms charge. Saga: UPDATE seat status=’booked’; UPDATE order status=’CONFIRMED’; SELECT FOR UPDATE confirms no race condition.

5
Checkout abandoned

— T+10min: Kafka delayed event fires. Lua script releases lock. Kafka seat-available event. Waitlist ZPOPMIN → notify next user.

🔭

High-Level Design — Component Breakdown

Core components — each with a single, well-defined responsibility. The key architectural insight: each layer scales independently, and failure in one component is isolated from the rest.

1
Seat Map UI
2
Lock API
3
Order Service
4
Payment
5
TTL Manager
6
Waitlist
1 — Seat Map UI

Handles responsibilities for the Seat Map UI layer. Designed for independent horizontal scaling — additional instances added without architectural changes. Communicates asynchronously with adjacent components to maximise throughput and fault isolation.

2 — Lock API

Handles responsibilities for the Lock API layer. Designed for independent horizontal scaling — additional instances added without architectural changes. Communicates asynchronously with adjacent components to maximise throughput and fault isolation.

3 — Order Service

Handles responsibilities for the Order Service layer. Designed for independent horizontal scaling — additional instances added without architectural changes. Communicates asynchronously with adjacent components to maximise throughput and fault isolation.

4 — Payment

Handles responsibilities for the Payment layer. Designed for independent horizontal scaling — additional instances added without architectural changes. Communicates asynchronously with adjacent components to maximise throughput and fault isolation.

5 — TTL Manager

Handles responsibilities for the TTL Manager layer. Designed for independent horizontal scaling — additional instances added without architectural changes. Communicates asynchronously with adjacent components to maximise throughput and fault isolation.

6 — Waitlist

Handles responsibilities for the Waitlist layer. Designed for independent horizontal scaling — additional instances added without architectural changes. Communicates asynchronously with adjacent components to maximise throughput and fault isolation.

🔬

Low-Level Design — Deep Dives

Deep dives worth explaining in detail in any senior engineering interview. For each: know the data structure, the algorithm, the why, and the trade-off you made.

1 — Seat Map Service
Redis Hash · WebSocket Diff

Seat map stored as Redis hash: HSET seat_map:{event_id} {seat_id: status}. Status ∈ {available, reserved, sold}. Full seat map JSON cached at 5-second TTL. WebSocket push on status change: Kafka consumer publishes diff {seat_id, old_status, new_status} to Redis Pub/Sub channel seatmap:{event_id}. All connected clients subscribe and apply diffs locally. 100K concurrent viewers: 100K WebSocket connections, 2 gateway instances at 50K each.

# On status change
redis.hset(f’seat_map:{event_id}’, seat_id, ‘reserved’)
redis.publish(f’seatmap:{event_id}’, json.dumps({
‘seat_id’: seat_id, ‘status’: ‘reserved’
}))
# WebSocket gateway forwards to all subscribers

2 — Redlock Seat Reservation
5-node Quorum · 10s TTL

Redlock implementation: attempt SETNX lock:{event_id}:{seat_id} with 10s TTL on all 5 Redis nodes. If ≥3 nodes acknowledge within 5ms: lock acquired. Effective lock validity = TTL – acquire_time – clock_drift_factor = ~9.9s. Lua atomic inventory check runs under lock. Lock released immediately after DB order record created (typically <50ms total hold time). Lock acquisition failure (another buyer): return 409 Conflict; client retries with adjacent seat.

from redlock import Redlock
dlm = Redlock([{‘host’: f’redis-{i}’} for i in range(5)])

def reserve_seat(event_id, seat_id, user_id):
lock = dlm.lock(f’seat:{event_id}:{seat_id}’, 10000)
if not lock: raise SeatConflict()
try:
create_reservation(event_id, seat_id, user_id)
finally:
dlm.unlock(lock)

3 — Payment Saga
Stripe · Idempotency Keys

Booking Saga steps with idempotency: (1) Reserve seat (Redlock). (2) Create order in Postgres (idempotency_key = booking_id). (3) Charge payment (Stripe PaymentIntent with idempotency_key). (4) Confirm order + release lock. Stripe idempotency key ensures retried charge never double-charges. Compensation: if charge fails → ZRANGEBYLEX waitlist_queue → ZPOPMIN → notify next user. Saga state machine in Redis: booking:{id} → {step, status}.

def booking_saga(booking_id, seat_id, payment_method):
order = create_order(booking_id) # idempotent
try:
stripe.PaymentIntent.create(
amount=order.price_cents,
idempotency_key=f’booking_{booking_id}’
)
confirm_order(order)
except stripe.error.CardError:
release_seat(seat_id)
notify_next_waitlist(seat_id)

4 — Waitlist ZPOPMIN Queue
FIFO Fair · 10-min Window

Waitlist: Redis Sorted Set with score=join_timestamp (FIFO). On cancellation/expiry: ZPOPMIN waitlist:{event_id}:{seat_id} returns earliest waiter. Lua guard: atomically verify popped user_id matches notified user before granting window. 10-minute exclusive window: Redis key window:{event_id}:{seat_id} SETEX 600. Window expiry: Kafka delayed message triggers ZPOPMIN again. Fairness audit: all ZPOPMIN operations logged to ClickHouse for dispute resolution.

— Lua: atomic pop-and-grant
local user = redis.call(‘ZPOPMIN’, KEYS[1], 1)
if #user == 0 then return nil end
redis.call(‘SETEX’, KEYS[2], 600, user[1]) — 10min window
return user[1] — user_id

⚖️

Trade-offs & Decision Log

Every senior interview comes down to these decisions. Know the exact trade-off, the reasoning, and the specific numbers that justify each choice.

⚖️ Redlock vs Single Redis Instance Locking


Redlock (5-node quorum) ✅ Chosen
  • Tolerates 2 Redis node failures — majority quorum still holds lock
  • Lock valid only if acquired on ≥3/5 nodes within timeout
  • Prevents phantom locks from network partitions
  • 10× higher latency than single-instance lock (5 network round-trips)

Single Redis SETNX
  • Sub-millisecond lock acquisition
  • Single Redis failure = all locks lost = oversell possible
  • Clock drift can cause split-brain lock ownership
  • Simpler to implement and debug

💡

Decision: Redlock for seat reservation (high-value, must not double-sell); single Redis SETNX for session tokens and rate limiting

⚖️ Saga vs Two-Phase Commit for Booking Flow


Saga Pattern (choreography) ✅ Chosen
  • No distributed lock held across services — higher throughput
  • Each step has explicit compensating transaction (release seat, void payment)
  • Kafka event log provides full audit trail
  • Eventual consistency — brief window where payment charged but seat not confirmed

Two-Phase Commit (2PC)
  • ACID guarantee across all services
  • Coordinator holds distributed locks during prepare phase
  • Lock contention under load — one slow service blocks all bookings
  • Single coordinator failure = all in-flight bookings stall

💡

Decision: Saga choreography via Kafka; idempotency keys prevent double-charges; compensating transactions tested for all failure modes

🎯

Interview Questions — Answered
The exact questions interviewers ask — with production-grade answers

Q1
How does Redlock prevent phantom locks from clock drift?

Redlock’s clock drift vulnerability (the Antirez vs. Martin Kleppmann debate): Redlock assumes clocks across Redis nodes differ by at most clock_drift_factor × lock_validity_time. With NTP synchronisation, drift is typically <10ms over a 10-second lock TTL — acceptable. For safety: lock validity time = TTL - (time_to_acquire_lock + clock_drift). With 5 nodes, quorum requires 3/5 acknowledging the lock; network partition where 2 nodes are unreachable does not break safety — only availability. The implementation sets a conservative drift_factor=0.01 (1%) — for a 10s TTL, the effective lock is valid for 9.9s. Critical: always call unlock() after the lock is used, even if the operation failed.

Q2
How does the waitlist release seats fairly when a booking expires?

ZPOPMIN-based waitlist dequeue: (1) User joins waitlist: ZADD waitlist:{event_id} timestamp user_id. (2) When a booking expires (Kafka-delayed TTL message fires): ZPOPMIN waitlist:{event_id} 1 returns the earliest-joined user. (3) That user receives a 10-minute exclusive window notification via WebSocket/push. (4) If user completes booking: removed from waitlist, inventory stays decremented. (5) If user’s 10-minute window expires: ZPOPMIN again for next user. (6) Concurrent expiry prevention: Lua script checks that ZPOPMIN user_id matches the user being notified before granting inventory access — prevents two concurrent expiries from granting the same seat to two users.

Q3
How are seat maps kept consistent when thousands of users view them simultaneously?

Seat map serving strategy: (1) Redis hash per event: {seat_id: status} where status ∈ {available, reserved, sold}. (2) Cache the full seat map JSON in Redis with TTL=5 seconds. (3) WebSocket push on status changes: when seat status changes (reserved or sold), a Kafka consumer pushes a diff update to all connected seat map viewers via Redis Pub/Sub. Clients apply diff updates to their local seat map without full refresh. (4) Reconnect: on WebSocket reconnect, client requests full seat map snapshot from Redis cache. (5) Scale: 100K concurrent seat map viewers = 100K WebSocket connections. Gateway instances handle 50K connections each — 2 gateway instances per popular event.

System Design Series · Every Tuesday & Thursday

Level up your system design interviews

Each post covers Gist, Functional & Non-Functional Requirements, Key Metrics, System Diagram, User Journey, HLD, LLD, and Trade-offs & FAQs.

Subscribe to never miss a post →


Categories: System Design

Tags: , , , , , ,

Leave a Reply

Discover more from Cloud Wizard Inc.

Subscribe now to keep reading and get access to the full archive.

Continue reading