Chapter 70: Design Ticket Booking System
Requirements
Functional Requirements
- Seat Selection: Users browse available seats and select specific seats (not just quantity)
- Prevent Double Booking: Same seat cannot be sold to multiple users (strict consistency)
- Temporary Hold: Selected seats are held for limited time (10 minutes) while user completes payment
- Payment Processing: Integrate with payment gateway, handle success/failure/timeout
- Booking Confirmation: Issue unique booking ID and ticket on successful payment
- Event Types: Support concerts, movies, sports, theater
Non-Functional Requirements
- High Concurrency: Handle 50,000+ concurrent users for hot events (Taylor Swift concert, Marvel premiere)
- Low Latency: Seat availability checks < 500ms, booking confirmation < 2 seconds
- Strict Consistency: No double booking ever (prefer availability over consistency only for reads)
- Fairness: First-come-first-served for seat selection
- Scalability: Support 1000+ concurrent events, millions of users
Scale Estimates
- Hot event: 50,000 concurrent users, 10,000 seats, sells out in 10 minutes
- Requests per second: 50,000 users * 5 requests (browse, select, hold, pay, confirm) / 600 seconds = ~400 req/sec peak
- Database writes: 10,000 bookings in 10 minutes = ~17 writes/sec (manageable)
High-Level Design
Seat Availability)] API1 & API2 & API3 --> BookingDB[(Booking DB
PostgreSQL
Master-Replica)] API1 & API2 & API3 --> InventoryDB[(Inventory DB
Seat Maps)] API1 & API2 & API3 --> Queue[Message Queue
Booking Events] API1 & API2 & API3 --> PaymentGW[Payment Gateway
Stripe/PayPal] Queue --> Worker1[Worker: Release Expired Holds] Queue --> Worker2[Worker: Send Confirmation Email] Queue --> Worker3[Worker: Analytics] BookingDB --> Replica1[(Read Replica 1)] BookingDB --> Replica2[(Read Replica 2)]
Core Components
1. API Servers
Stateless servers handling user requests. Horizontally scalable behind load balancer.
- GET /events - List events
- GET /events/:id/seats - Get seat map and availability
- POST /bookings/hold - Hold selected seats (10 min TTL)
- POST /bookings/confirm - Confirm booking after payment
- DELETE /bookings/release - Release held seats
2. Redis Cache
Caches seat availability for fast reads. Critical for handling 50K concurrent users checking seats.
- Key:
event:{event_id}:seats→ Bitmap of available seats - Key:
hold:{booking_id}→ Held seats with 10-minute TTL - Invalidate cache on every booking confirmation (write-through)
3. Booking Database (PostgreSQL)
Source of truth for bookings. Master-replica setup for read scaling.
- Master: All writes (hold, confirm, release)
- Replicas: Read queries (user booking history, event analytics)
- Critical: Use transactions with proper isolation for seat locking
4. Inventory Database
Stores event metadata and seat maps (layout, pricing, categories).
5. Message Queue (Kafka/RabbitMQ)
Asynchronous processing of booking events:
- Release expired holds (TTL-based cleanup)
- Send confirmation emails/SMS
- Update analytics and reporting
- Trigger external integrations (CRM, marketing)
6. Payment Gateway
Third-party service (Stripe, PayPal) for payment processing. Handles idempotency and retry logic.
Seat Locking Strategies
The Core Challenge
50,000 users trying to book 10,000 seats simultaneously. Multiple users may try to book Seat A5 at the same time. System must ensure exactly one succeeds.
Strategy 1: Pessimistic Locking (Recommended)
Approach: Lock the seat row in the database before checking availability. Use SELECT FOR UPDATE.
SQL Example
BEGIN TRANSACTION;
-- Lock the seat row (blocks other transactions)
SELECT * FROM seats
WHERE event_id = 123 AND seat_id = 'A5'
FOR UPDATE;
-- Check availability
IF seat.status = 'available' THEN
UPDATE seats
SET status = 'held',
held_by = 'user_456',
held_until = NOW() + INTERVAL '10 minutes'
WHERE event_id = 123 AND seat_id = 'A5';
COMMIT;
RETURN success;
ELSE
ROLLBACK;
RETURN seat_unavailable;
END IF;
Pros
- Guarantees consistency - no double booking
- Simple to implement and reason about
- Database handles locking and deadlock detection
Cons
- Locks block concurrent requests (reduced throughput)
- Risk of deadlocks if not careful with lock order
- Database becomes bottleneck under extreme load
Strategy 2: Optimistic Locking
Approach: No locks. Use version number or timestamp. Update only if version matches (CAS - Compare-And-Set).
SQL Example
-- Read seat with current version
SELECT * FROM seats
WHERE event_id = 123 AND seat_id = 'A5';
-- Returns: status='available', version=5
-- Try to update (only succeeds if version unchanged)
UPDATE seats
SET status = 'held',
held_by = 'user_456',
version = version + 1
WHERE event_id = 123
AND seat_id = 'A5'
AND version = 5
AND status = 'available';
-- If affected_rows = 0, someone else booked it
IF affected_rows = 1 THEN
RETURN success;
ELSE
RETURN seat_unavailable (retry or show error);
END IF;
Pros
- No locking - higher throughput
- No deadlocks
- Better for scenarios where conflicts are rare
Cons
- High contention leads to many retries (hot event with 50K users)
- Poor user experience (repeated failures)
- Application must handle retry logic
Strategy 3: Distributed Lock (Redis)
Approach: Acquire lock in Redis before updating database.
Implementation
// Try to acquire lock
lock_key = "lock:event:123:seat:A5"
acquired = redis.SET(lock_key, "user_456", NX, EX, 5) // 5 sec expiry
if acquired:
// Lock acquired - proceed with booking
if seat.status == 'available':
seat.status = 'held'
seat.save()
redis.DEL(lock_key)
return success
else:
redis.DEL(lock_key)
return seat_unavailable
else:
// Lock not acquired - seat being booked by someone else
return seat_busy (retry)
Pros
- Fast (in-memory operation)
- Reduces database contention
- Can use Redlock algorithm for distributed Redis cluster
Cons
- Additional dependency (Redis failure = outage)
- Race conditions if Redis and DB get out of sync
- Complexity: must handle lock expiry, release, and edge cases
Recommendation
For ticket booking: Pessimistic Locking
- Strict consistency is critical (no double booking tolerance)
- Write volume is manageable (17 writes/sec for hot event)
- Database locks are battle-tested and reliable
- Combine with Redis cache for read scalability
Booking Flow
User Journey
- Browse Events: User searches for events (concerts, movies) by location, date, category
- Select Event: User clicks on event to view details and seat map
- View Seat Map: Interactive seat map shows available (green), held (yellow), booked (gray)
- Select Seats: User clicks to select seats (max 10 seats per booking)
- Hold Seats: API holds seats for 10 minutes, starts countdown timer
- Enter Details: User enters contact info, selects payment method
- Payment: User completes payment (credit card, PayPal, etc.)
- Confirmation: System confirms booking, sends ticket via email/SMS
Sequence Diagram
Hold Expiry Handling
If user doesn't complete payment within 10 minutes:
- TTL Expiry: Redis key expires automatically
- Background Job: Worker polls database every minute for expired holds
- Release Seats:
UPDATE seats SET status = 'available', held_by = NULL, held_until = NULL WHERE status = 'held' AND held_until < NOW(); - Update Booking:
UPDATE bookings SET status = 'expired' WHERE status = 'pending' AND expires_at < NOW(); - Cache Invalidation: Update Redis to reflect newly available seats
Edge Cases
1. Payment Timeout
- Problem: Payment gateway takes > 10 minutes to respond
- Solution: Extend hold when payment initiated (add 5 minutes). If payment succeeds after hold expired, check if seat still held by same user.
2. Payment Success but DB Update Fails
- Problem: User charged but booking not confirmed (network failure, DB crash)
- Solution: Use idempotent payment (idempotency key = booking_id). Retry DB update. If fails repeatedly, manual reconciliation job matches unconfirmed payments to bookings.
3. User Closes Browser
- Problem: Seats held but user abandoned
- Solution: TTL handles this automatically. Seats released after 10 minutes.
4. Concurrent Hold and Release
- Problem: User A holds seat, timer expires, User B books it, User A completes payment
- Solution: Confirm endpoint checks
expires_atand seat status. Reject if expired or seat no longer held by user.
Inventory Management
Seat Map Data Model
Events Table
CREATE TABLE events (
event_id BIGSERIAL PRIMARY KEY,
name VARCHAR(255),
venue_id BIGINT,
event_type VARCHAR(50), -- concert, movie, sports
event_date TIMESTAMP,
total_seats INT,
available_seats INT,
created_at TIMESTAMP DEFAULT NOW()
);
Seats Table
CREATE TABLE seats (
seat_id VARCHAR(10),
event_id BIGINT,
section VARCHAR(50), -- Orchestra, Balcony, VIP
row VARCHAR(5),
number INT,
price DECIMAL(10,2),
status VARCHAR(20), -- available, held, booked
held_by BIGINT,
held_until TIMESTAMP,
booked_by BIGINT,
booked_at TIMESTAMP,
version INT DEFAULT 0, -- for optimistic locking
PRIMARY KEY (event_id, seat_id),
INDEX idx_status (event_id, status),
INDEX idx_held_until (held_until) WHERE status='held'
);
Bookings Table
CREATE TABLE bookings (
booking_id BIGSERIAL PRIMARY KEY,
event_id BIGINT,
user_id BIGINT,
seats TEXT, -- JSON array: ["A5", "A6"]
status VARCHAR(20), -- pending, confirmed, expired, cancelled
total_amount DECIMAL(10,2),
payment_id VARCHAR(100),
expires_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
confirmed_at TIMESTAMP,
INDEX idx_user (user_id, created_at),
INDEX idx_event (event_id, status),
INDEX idx_expires (expires_at) WHERE status='pending'
);
Availability Bitmap (Redis)
For fast reads, maintain a bitmap in Redis representing seat availability:
Key: event:123:availability
Value: Bitmap (bit 0 = seat A1, bit 1 = seat A2, ...)
0 = booked, 1 = available
// Check if seat A5 (bit 4) is available
GETBIT event:123:availability 4 → Returns 1 (available)
// Mark seat A5 as booked
SETBIT event:123:availability 4 0
Benefits
- Extremely fast: O(1) lookup per seat
- Compact: 10,000 seats = 1.25 KB
- Atomic operations: SETBIT is atomic
Limitations
- Doesn't store who holds the seat or expiry time
- Must sync with DB (write-through cache)
- Use DB as source of truth, Redis for performance
Seat Map Visualization
Frontend renders interactive seat map:
- API Response:
GET /events/123/seatsreturns seat layout + availability - Format: JSON with sections, rows, seat numbers, prices, availability
- Real-Time Updates: WebSocket or polling to refresh availability every 5-10 seconds
Payment Integration
Payment Flow
- Initiate Payment: User submits payment method (credit card, PayPal)
- Idempotency Key: Use
booking_idas idempotency key to prevent double charge - Call Payment Gateway: Send charge request to Stripe/PayPal with amount, idempotency key
- Handle Response: Success, failure, or timeout
- Update Booking: Confirm or cancel booking based on payment result
Idempotent Payment
Critical to prevent double charging if request is retried:
// Stripe example
stripe.charges.create({
amount: 20000, // $200.00
currency: 'usd',
source: 'tok_visa',
description: 'Booking #789',
idempotency_key: '789' // Same key = same charge
});
// If retried with same key:
// - If original succeeded: Returns original charge (no new charge)
// - If original failed: Creates new charge attempt
// - If original pending: Returns same pending charge
Timeout Handling
Problem: Payment gateway takes > 30 seconds to respond (network issue, gateway slow).
Solution
- Client Timeout: API server times out after 30 seconds
- Background Job: Enqueue payment verification task
- Async Verification: Worker polls payment gateway for status
- Update Booking: Once status known, update booking and notify user
- Extend Hold: Extend seat hold by 5 minutes when payment initiated
Payment Status Reconciliation
Handle edge cases where payment succeeds but confirmation fails:
Reconciliation Job (runs hourly)
// Find bookings in "pending" state with recent payment attempts
SELECT * FROM bookings
WHERE status = 'pending'
AND created_at > NOW() - INTERVAL '24 hours';
// For each booking, query payment gateway
payment_status = stripe.charges.retrieve(booking.payment_id);
if payment_status == 'succeeded':
// Payment succeeded but booking not confirmed
// Confirm booking now (idempotent)
confirm_booking(booking.id);
notify_user(booking.user_id);
else if payment_status == 'failed':
// Payment failed, expire booking
expire_booking(booking.id);
release_seats(booking.seats);
Refunds
Handle cancellations and refunds:
- User-Initiated: User cancels booking, request refund from gateway
- Event Cancellation: Batch refund all bookings for cancelled event
- Idempotency: Use
refund_idto prevent double refund - Seat Release: Mark seats as available again after refund confirmed
Scalability
Handling 50,000 Concurrent Users
1. Read Scalability
Problem: 50K users browsing seat maps, checking availability
- Redis Cache: Serve availability from Redis (bitmap), not DB. 50K reads/sec easily handled.
- CDN: Serve static seat map images/SVGs from CDN
- DB Read Replicas: Route read queries (booking history, event details) to replicas
- API Rate Limiting: Limit per-user request rate (e.g., 10 req/sec) to prevent abuse
2. Write Scalability
Problem: 10,000 bookings in 10 minutes = 17 writes/sec (manageable, but hot event may spike higher)
- Database Sharding: Shard by
event_id. Each event's seats live on one shard (no distributed transactions needed). - Connection Pooling: Maintain persistent DB connection pool to avoid connection overhead
- Write-Ahead Log (WAL): PostgreSQL's WAL provides high write throughput and durability
3. Event-Based Sharding
Partition data by event to isolate load:
Events 1-1000] Router --> Shard2[Shard 2
Events 1001-2000] Router --> Shard3[Shard 3
Events 2001-3000] Shard1 --> DB1[(DB 1)] Shard2 --> DB2[(DB 2)] Shard3 --> DB3[(DB 3)]
- Hash Function:
shard = event_id % num_shards - Benefit: Hot event doesn't affect other events (isolated blast radius)
- Trade-off: Cross-event queries require scatter-gather (acceptable for rare queries like "all events in venue")
4. Waiting Room Pattern (Hot Events)
For extremely popular events (Taylor Swift, World Cup final), use virtual waiting room:
- Queue Entrance: Users enter queue before event sale opens
- Rate Limiting: Admit users to booking system at controlled rate (e.g., 1000 users/minute)
- Position Display: Show user their queue position and estimated wait time
- Session Token: Once admitted, user gets time-limited token to access booking system
Implementation
// Queue management
ZADD waiting_room:event:123
// Check user position
ZRANK waiting_room:event:123 → Returns position
// Admit next batch (1000 users)
users = ZRANGE waiting_room:event:123 0 999
for user in users:
token = generate_token(user, expires_in=15min)
send_notification(user, token)
ZREM waiting_room:event:123 user
Benefits
- Prevents system overload (controls incoming traffic)
- Fair queue (first-come-first-served)
- Better UX than crashes or errors
- Used by Ticketmaster, Eventbrite for hot events
Scalability Metrics
| Metric | Target | Strategy |
|---|---|---|
| Read QPS | 50,000+ | Redis cache, CDN, read replicas |
| Write QPS | 100+ | Sharding by event_id, connection pooling |
| Concurrent Bookings | 1000+/sec | Pessimistic locking, DB tuning, sharding |
| Latency (p99) | < 500ms | Redis cache, optimized queries, indexes |
Monitoring
- Booking Success Rate: % of hold requests that result in confirmed bookings
- Payment Failure Rate: Track gateway timeouts and failures
- Seat Availability Lag: Time between booking confirmation and cache update
- Hold Expiry Rate: % of holds that expire without payment (indicates UX issues)
- Database Lock Wait Time: Track contention on seat table
Interview Summary
Key Talking Points
- Double Booking Prevention: Pessimistic locking with SELECT FOR UPDATE is the safe, proven approach
- Seat Hold with TTL: 10-minute hold with background job to release expired holds
- Redis for Reads: Bitmap for seat availability, cache for scalability
- Idempotent Payments: Use booking_id as idempotency key to prevent double charge
- Waiting Room: Virtual queue for hot events to control load
Common Follow-Up Questions
1. How do you handle a user trying to book more seats than available?
Answer: Validate availability before acquiring locks. If user selects 5 seats but only 3 available, return error immediately (optimistic check). Then acquire locks only for available seats (pessimistic confirmation).
2. What if payment gateway is down?
Answer: Graceful degradation - extend seat hold, show user error message with retry option, enqueue for background retry. If gateway down > 10 minutes, consider pausing new bookings and notifying users.
3. How do you prevent bots from hoarding seats?
Answer: CAPTCHA on hold request, rate limiting per IP/user, device fingerprinting, require verified account (email/phone) to hold seats, limit max seats per user per event.
4. How do you handle seat pricing tiers (VIP, regular, etc.)?
Answer: Add section and price columns to seats table. Filter/sort by price in UI. Same locking logic applies regardless of price tier.
5. What about movie theaters with multiple showtimes?
Answer: Each showtime is a separate event. Same seat number (e.g., A5) can be booked for 2pm show and 5pm show independently. Partition by event_id (which includes showtime).
6. How do you handle group bookings (bulk seats)?
Answer: Allow selecting multiple seats in single request. Acquire locks on all seats atomically (single transaction). If any seat unavailable, reject entire booking (all-or-nothing).
7. How do you ensure fairness (no wealthy users getting all seats)?
Answer: Waiting room with fair queue (FIFO), limit max seats per user, detect/block scalper bots, potentially lottery system for ultra-hot events (Hamilton did this).