Chapter 70: Design Ticket Booking System

Requirements

Functional Requirements

Non-Functional Requirements

Scale Estimates

Interview Tip: Clarify whether "prevent double booking" means zero tolerance (strict consistency) or occasional overselling is acceptable (eventual consistency + compensation). For concerts/movies, zero tolerance is required. This drives locking strategy.

High-Level Design

graph TB User[User] --> LB[Load Balancer] LB --> API1[API Server 1] LB --> API2[API Server 2] LB --> API3[API Server 3] API1 & API2 & API3 --> Cache[(Redis Cache
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.

2. Redis Cache

Caches seat availability for fast reads. Critical for handling 50K concurrent users checking seats.

3. Booking Database (PostgreSQL)

Source of truth for bookings. Master-replica setup for read scaling.

4. Inventory Database

Stores event metadata and seat maps (layout, pricing, categories).

5. Message Queue (Kafka/RabbitMQ)

Asynchronous processing of booking events:

6. Payment Gateway

Third-party service (Stripe, PayPal) for payment processing. Handles idempotency and retry logic.

Interview Tip: Emphasize that the hard part is not storing bookings, but preventing concurrent users from booking the same seat. The solution is in the seat locking strategy (next section).

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

Cons

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

Cons

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

Cons

Recommendation

For ticket booking: Pessimistic Locking

Interview Tip: Walk through all three strategies and explain trade-offs. Pessimistic locking is the safe default. Mention optimistic locking as an alternative but highlight the retry problem for hot events. Distributed locks add complexity but can be used in hybrid approach.

Booking Flow

User Journey

  1. Browse Events: User searches for events (concerts, movies) by location, date, category
  2. Select Event: User clicks on event to view details and seat map
  3. View Seat Map: Interactive seat map shows available (green), held (yellow), booked (gray)
  4. Select Seats: User clicks to select seats (max 10 seats per booking)
  5. Hold Seats: API holds seats for 10 minutes, starts countdown timer
  6. Enter Details: User enters contact info, selects payment method
  7. Payment: User completes payment (credit card, PayPal, etc.)
  8. Confirmation: System confirms booking, sends ticket via email/SMS

Sequence Diagram

sequenceDiagram participant User participant API participant Redis participant DB participant PaymentGW participant Queue User->>API: GET /events/123/seats API->>Redis: Get cached availability Redis-->>API: Bitmap of available seats API-->>User: Seat map (A5 available) User->>API: POST /bookings/hold {seats: [A5, A6]} API->>DB: BEGIN TRANSACTION API->>DB: SELECT * FROM seats WHERE id IN (A5,A6) FOR UPDATE DB-->>API: Rows locked, status=available API->>DB: UPDATE seats SET status='held', held_until=NOW()+10min API->>DB: INSERT INTO bookings (status='pending') API->>DB: COMMIT DB-->>API: Success API->>Redis: Update cache, set TTL API->>Queue: Publish hold_created event API-->>User: booking_id=789, expires_at=10:10 AM Note over User,API: User has 10 minutes to pay User->>API: POST /bookings/789/confirm {payment_method} API->>PaymentGW: Charge $200 (idempotency_key=789) PaymentGW-->>API: Success, charge_id=xyz API->>DB: BEGIN TRANSACTION API->>DB: SELECT * FROM bookings WHERE id=789 FOR UPDATE DB-->>API: status='pending', not expired API->>DB: UPDATE bookings SET status='confirmed', payment_id='xyz' API->>DB: UPDATE seats SET status='booked', booked_by='user_456' API->>DB: COMMIT DB-->>API: Success API->>Redis: Invalidate cache API->>Queue: Publish booking_confirmed event API-->>User: Booking confirmed! ticket_id=789 Queue->>User: Send confirmation email with ticket

Hold Expiry Handling

If user doesn't complete payment within 10 minutes:

  1. TTL Expiry: Redis key expires automatically
  2. Background Job: Worker polls database every minute for expired holds
  3. Release Seats:
    UPDATE seats
    SET status = 'available',
        held_by = NULL,
        held_until = NULL
    WHERE status = 'held'
      AND held_until < NOW();
  4. Update Booking:
    UPDATE bookings
    SET status = 'expired'
    WHERE status = 'pending'
      AND expires_at < NOW();
  5. Cache Invalidation: Update Redis to reflect newly available seats

Edge Cases

1. Payment Timeout

2. Payment Success but DB Update Fails

3. User Closes Browser

4. Concurrent Hold and Release

Interview Tip: Draw the sequence diagram on the whiteboard. Walk through the happy path first, then discuss failure scenarios. Emphasize idempotency (payment with idempotency key) and atomicity (DB transactions).

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

Limitations

Seat Map Visualization

Frontend renders interactive seat map:

Interview Tip: Mention the bitmap optimization proactively - it shows you're thinking about performance at scale. Emphasize that DB is source of truth, Redis is cache.

Payment Integration

Payment Flow

  1. Initiate Payment: User submits payment method (credit card, PayPal)
  2. Idempotency Key: Use booking_id as idempotency key to prevent double charge
  3. Call Payment Gateway: Send charge request to Stripe/PayPal with amount, idempotency key
  4. Handle Response: Success, failure, or timeout
  5. 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

  1. Client Timeout: API server times out after 30 seconds
  2. Background Job: Enqueue payment verification task
  3. Async Verification: Worker polls payment gateway for status
  4. Update Booking: Once status known, update booking and notify user
  5. 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:

Interview Tip: Emphasize idempotency and timeout handling. These are common interview follow-ups. Mention reconciliation jobs to show you understand distributed systems challenges.

Scalability

Handling 50,000 Concurrent Users

1. Read Scalability

Problem: 50K users browsing seat maps, checking availability

2. Write Scalability

Problem: 10,000 bookings in 10 minutes = 17 writes/sec (manageable, but hot event may spike higher)

3. Event-Based Sharding

Partition data by event to isolate load:

graph TB Router[Request Router] --> Shard1[Shard 1
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)]

4. Waiting Room Pattern (Hot Events)

For extremely popular events (Taylor Swift, World Cup final), use virtual waiting room:

  1. Queue Entrance: Users enter queue before event sale opens
  2. Rate Limiting: Admit users to booking system at controlled rate (e.g., 1000 users/minute)
  3. Position Display: Show user their queue position and estimated wait time
  4. 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

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

Interview Tip: The waiting room pattern is a great way to show advanced thinking. It's used in production by major ticketing platforms. Mention it proactively to impress the interviewer.

Interview Summary

Key Talking Points

  1. Double Booking Prevention: Pessimistic locking with SELECT FOR UPDATE is the safe, proven approach
  2. Seat Hold with TTL: 10-minute hold with background job to release expired holds
  3. Redis for Reads: Bitmap for seat availability, cache for scalability
  4. Idempotent Payments: Use booking_id as idempotency key to prevent double charge
  5. 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).