🏗️ Movie Ticket Booking System — High-Level Design¶
Target Level: Senior/Staff Engineer | Focus: Concurrency, flash sales, payment, seat allocation
1. SYSTEM OVERVIEW¶
Purpose: Online movie ticket platform handling bookings, seat selection, payments, and discovery (like BookMyShow).
Scale: 10M MAU, 100K concurrent during flash sales (Avengers release), 50K simultaneous bookings
Users: Moviegoers, Theatre admins, Platform operators
Use Cases: Browse movies/theatres, Select seats, Book tickets, Cancel/refund, Search by city/date/genre
Constraints: No double-booking, <2s booking response, 99.95% uptime, payment idempotency
2. HIGH-LEVEL ARCHITECTURE¶
flowchart TB
U[Users] --> CDN["CDN<br/>movie & theatre pages"]
U --> GW["API gateway<br/>auth, WAF, per-user rate limits,<br/>waiting room on hot releases"]
GW --> SS["Search service (read)"]
GW --> BS["Booking service (write, stateless)"]
SS --> ES[(Elasticsearch)]
SS --> RC[("Redis<br/>seat-map cache, ~1–2 s stale")]
BS --> PG[("PostgreSQL primary<br/>show_seats = source of truth")]
BS --> PAY["Payment gateway<br/>(idempotent, auth/capture)"]
PAY -. webhook .-> BS
PG --> RR[(Read replicas)] --> SS
PG -. outbox / CDC .-> EV["Events: confirmations,<br/>refunds, cache invalidation"]
🎬 Animated Sequence Diagram¶
🎬 Animated Movie Ticket Booking Sequence — Search → Select → Reserve → Payment → Confirm. Click ▶ to play/pause. Created with Remotion.
2.5 CLASS DIAGRAM¶
classDiagram
class Movie {
<<dataclass>>
+movie_id: str
+title: str
+genre: Genre
+duration_minutes: int
+language: str
}
class Theatre {
<<dataclass>>
+theatre_id: str
+name: str
+city: City
}
class Screen {
<<dataclass>>
+screen_id: str
+name: str
+seats: tuple~Seat~
+with_rows(screen_id: str, name: str, rows: Dict, per_row: int) Screen
}
class SeatCategory {
<<enumeration>>
REGULAR
PREMIUM
VIP
}
class Seat {
<<dataclass>>
+seat_id: str
+row: str
+number: int
+category: SeatCategory
}
class Show {
+show_id: str
+movie: Movie
+theatre: Theatre
+screen: Screen
+start_time: datetime
+end_time: datetime
+base_prices: Dict~SeatCategory, Decimal~
-_seats: Dict~str, ShowSeat~
+seat(seat_id: str) ShowSeat
+available_seat_ids(now: datetime) List~str~
}
class SeatStatus {
<<enumeration>>
AVAILABLE
HELD
BOOKED
}
class ShowSeat {
<<dataclass>>
+seat: Seat
+status: SeatStatus
+booking_id: Optional~str~
+hold_expires_at: Optional~datetime~
+lock: Lock
+is_free(now: datetime) bool
+held_by(booking_id: str) bool
+hold(booking_id: str, expires_at: datetime) None
+book() None
+release() None
}
class BookingStatus {
<<enumeration>>
PENDING
CONFIRMED
CANCELLED
EXPIRED
}
class Booking {
<<dataclass>>
+booking_id: str
+user_id: str
+show: Show
+seats: List~ShowSeat~
+line_prices: Dict~str, Decimal~
+expires_at: datetime
+status: BookingStatus
+payment_id: Optional~str~
+total: Decimal
+seat_ids: List~str~
}
class PricingStrategy {
<<abstract>>
+price(base: Decimal, show: Show, seat: Seat)* Decimal
}
class StandardPricing
class PeakHourPricing {
+hours: range
+multiplier: Decimal
}
class WeekendPricing {
+multiplier: Decimal
}
class CompositePricing {
+strategies: tuple~PricingStrategy~
}
class PaymentGateway {
<<abstract>>
+charge(idempotency_key: str, amount: Decimal)* str
+refund(payment_id: str, amount: Decimal)* None
}
class BookingService {
-_gateway: PaymentGateway
-_pricing: PricingStrategy
-_clock: Clock
-_hold_ttl: timedelta
-_shows: Dict~str, Show~
-_bookings: Dict~str, Booking~
-_registry_lock: Lock
+add_show(show: Show) None
+search(city, movie_id, on, genre) List~Show~
+available_seats(show_id: str) List~str~
+hold_seats(show_id: str, user_id: str, seat_ids: List~str~) Booking
+pay_and_confirm(booking_id: str) Booking
+cancel(booking_id: str) Booking
+release_expired() int
}
Screen "1" *-- "*" Seat : layout
Seat --> SeatCategory
Show --> Movie
Show --> Theatre
Show --> Screen
Show "1" *-- "*" ShowSeat : per-show inventory
ShowSeat --> Seat
ShowSeat --> SeatStatus
Booking --> Show
Booking "1" o-- "1..10" ShowSeat : held or booked
Booking --> BookingStatus
BookingService "1" *-- "*" Show
BookingService "1" *-- "*" Booking
BookingService o-- PricingStrategy
BookingService o-- PaymentGateway
PricingStrategy <|-- StandardPricing
PricingStrategy <|-- PeakHourPricing
PricingStrategy <|-- WeekendPricing
PricingStrategy <|-- CompositePricing
CompositePricing o-- "*" PricingStrategy : applies in sequence
3. KEY COMPONENTS & INTERVIEW Q&A¶
Booking Service (Go/Python)¶
- Seat selection with a 10-minute hold (a lease on
show_seatsrows) - Stateless; the database row is the lock
- Admission control (waiting room) for flash sales
🔴 Interview Question: "How do you prevent double-booking during a flash sale?"
✅ Answer: One source of truth, plus layers that only shed load:
1. Correctness: a conditional update on show_seats (... WHERE status = 'AVAILABLE' OR hold expired), all-or-nothing per request, rows locked in seat-id order. Two requests can never both succeed for a seat, whatever happens above the database.
2. Seat state machine: AVAILABLE → HELD (10 min lease) → BOOKED, with lazy expiry in the same WHERE clause.
3. Load shedding: per-user rate limits, max seats per booking, a waiting room that admits users at the rate the booking path can serve. Optionally a per-seat Redis SET NX pre-filter so obviously taken seats never reach the DB.
4. Client: immediate "seats held" with a countdown timer; the seat map is a cached snapshot and may be slightly stale, which is fine because the hold is authoritative.
🔴 Interview Question: "How do you handle multiple bookings for a single seat during peak hours in a distributed system?"
✅ Answer:
App servers are stateless, so in-process locks don't help. The database decides (layer 1 or 2, pick one); layers 3 and 4 only reduce how much contention reaches it:
Layer 1 — Optimistic Locking (Application Level)¶
Each seat has a version field. The booking request includes the version read during seat selection:
UPDATE show_seats
SET status = 'HELD', version = version + 1, held_by = ?, held_until = ?
WHERE seat_id = ? AND show_id = ? AND version = ?
AND (status = 'AVAILABLE' OR (status = 'HELD' AND held_until <= NOW()))
Rows affected = 0, another request has already taken the seat — the booking is rejected. The user sees "Seat no longer available."
Layer 2 — Pessimistic Locking (Database)¶
For peak hours, escalate to pessimistic row-level locks:
BEGIN;
SELECT seat_id, status, held_until FROM show_seats
WHERE show_id = ? AND seat_id IN (?, ?, ?)
ORDER BY seat_id -- every transaction locks rows in the same order: no deadlocks
FOR UPDATE NOWAIT; -- fail fast instead of queueing behind another booking
-- In the app: every row AVAILABLE or HELD-and-expired? Otherwise ROLLBACK.
UPDATE show_seats SET status = 'HELD', held_by = ?, held_until = NOW() + INTERVAL '10 minutes'
WHERE show_id = ? AND seat_id IN (?, ?, ?);
COMMIT;
NOWAIT avoids queueing at the DB level: if a row is already locked, the request fails immediately rather than blocking. ORDER BY seat_id matters for multi-seat requests: two transactions locking {A1, A2} and {A2, A1} in different orders deadlock (Postgres detects it and aborts one, but that is a failed booking and wasted work).
Layer 3 — Redis (optional pre-filter, not the source of truth)¶
A per-seat lease in Redis can reject obviously taken seats before they reach the database:
# one key per seat; value = booking id so only the owner can release it
ok = all(redis.set(f"seat:{show_id}:{seat_id}", booking_id, nx=True, px=600_000) for seat_id in seats)
# (all-or-nothing needs a Lua script over hash-tagged keys, or releasing the ones acquired on failure)
RELEASE = """
if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) end
return 0
""" # compare-and-delete must be atomic; GET then DEL from the client can delete someone else's lease
held_by value on the row acts as a fencing token. A per-show mutex (SET show:123:lock … NX) is worse still: it serialises every booking for a show across the whole fleet even when they want different seats.
Layer 4 — Queue-Based Admission Control¶
During known peak hours (new release weekends), route all booking requests through a queuing layer:
User Request → Rate Limiter → SQS/Kafka Queue → Booking Workers → DB
↕
User polls for status via WebSocket
Seat Hold Timeout Strategy¶
| Step | Action | TTL |
|---|---|---|
| 1 | User selects seats → seats become HELD | 10 minutes |
| 2 | User starts payment → hold extended once (optional) | +5 minutes, hard cap |
| 3 | Payment confirmed while still held → seats become BOOKED | Permanent |
| 4 | Payment declined → hold kept so the user can retry | Until expiry |
| 5 | Hold lapses → seats free immediately (lazy check in every WHERE) |
— |
A background sweeper runs every 30 seconds. It is housekeeping, not correctness: expired holds are already treated as free by the booking query.
WITH lapsed AS (
UPDATE bookings SET status = 'EXPIRED'
WHERE status = 'PENDING' AND expires_at <= NOW()
RETURNING booking_ref
)
UPDATE show_seats SET status = 'AVAILABLE', held_by = NULL, held_until = NULL
WHERE status = 'HELD' AND held_by IN (SELECT booking_ref FROM lapsed); -- only seats still held by that booking
Search Service (Elasticsearch + Redis)¶
- Movie/theatre/shows indexed in Elasticsearch
- Popular searches cached in Redis (TTL: 1 minute)
- Geo-filtering by city and proximity
🔴 Interview Question: "How do you handle the thundering herd problem when a popular movie releases?"
✅ Answer:
1. CDN for static pages: Movie listing pages cached at edge (10 min TTL)
2. Stale cache while revalidate: Serve stale cached results while async refetch happens in background
3. Redis cache for seat availability: GET show:123:seat_map, rebuilt at most every 1–2 s per show, not on every booking. Staleness is safe because the hold is authoritative
4. Request coalescing: If 100 requests arrive for same query simultaneously, only 1 hits the backend; others wait on the first result
5. Rate limiting per user: 5 requests/second max during flash sales
Payment Service¶
- Idempotency key on every payment (
booking_id), and on every refund (refund:{booking_id}) - Prefer authorise, then capture after the seats are confirmed; on a failed confirm, void instead of refunding
- Results arrive via webhook, possibly more than once; the handler runs the same idempotent confirm
- Gateway failover (e.g. Stripe → Razorpay) only for new attempts, never for an attempt whose outcome is unknown: query its status first, or you risk charging twice
4. DATABASE OPERATIONS & SCHEMA¶
Entity-Relationship Model¶
Movie (1) ────→ Show (N) ────→ Screen (1) ────→ Theatre (1) ────→ City (1)
│ │
│ └───→ Seat (N)
│
└───→ Booking (N) ────→ User (1)
│
└───→ Payment (1)
Core SQL Schema¶
-- Theatres
CREATE TABLE theatres (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL,
city VARCHAR(100) NOT NULL,
address TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_theatres_city ON theatres(city);
-- Screens (auditoriums within a theatre)
CREATE TABLE screens (
id BIGSERIAL PRIMARY KEY,
theatre_id BIGINT NOT NULL REFERENCES theatres(id),
name VARCHAR(100) NOT NULL, -- "Screen 1", "IMAX", etc.
capacity INT NOT NULL
);
CREATE INDEX idx_screens_theatre ON screens(theatre_id);
-- Seats (physical seats in a screen)
CREATE TABLE seats (
id BIGSERIAL PRIMARY KEY,
screen_id BIGINT NOT NULL REFERENCES screens(id),
seat_row CHAR(1) NOT NULL, -- A, B, C, ...
seat_number INT NOT NULL, -- 1, 2, 3, ...
category VARCHAR(20) DEFAULT 'Regular', -- Regular, Premium, VIP
UNIQUE (screen_id, seat_row, seat_number)
);
CREATE INDEX idx_seats_screen ON seats(screen_id);
-- Movies
CREATE TABLE movies (
id BIGSERIAL PRIMARY KEY,
title VARCHAR(255) NOT NULL,
genre VARCHAR(50),
duration_min INT NOT NULL, -- In minutes
language VARCHAR(50),
rating DECIMAL(2,1) DEFAULT 0.0,
release_date DATE
);
-- Shows (individual screenings)
CREATE TABLE shows (
id BIGSERIAL PRIMARY KEY,
movie_id BIGINT NOT NULL REFERENCES movies(id),
screen_id BIGINT NOT NULL REFERENCES screens(id),
start_time TIMESTAMPTZ NOT NULL,
end_time TIMESTAMPTZ NOT NULL,
-- Base prices by category (denormalised for fast reads)
price_regular DECIMAL(10,2) DEFAULT 150.00,
price_premium DECIMAL(10,2) DEFAULT 250.00,
price_vip DECIMAL(10,2) DEFAULT 400.00,
-- UNIQUE (screen_id, start_time) would only stop identical start times.
-- Preventing overlaps needs an exclusion constraint (requires btree_gist):
EXCLUDE USING gist (screen_id WITH =, tstzrange(start_time, end_time) WITH &&)
);
CREATE INDEX idx_shows_movie ON shows(movie_id);
CREATE INDEX idx_shows_time ON shows(start_time);
CREATE INDEX idx_shows_screen_time ON shows(screen_id, start_time); -- city queries join screens → theatres(city)
-- Per-show seat inventory (to avoid N+1 queries)
CREATE TABLE show_seats (
id BIGSERIAL PRIMARY KEY,
show_id BIGINT NOT NULL REFERENCES shows(id),
seat_id BIGINT NOT NULL REFERENCES seats(id),
status VARCHAR(20) DEFAULT 'AVAILABLE', -- AVAILABLE, HELD, BOOKED
version INT DEFAULT 0, -- Optimistic locking
held_by VARCHAR(255), -- Booking holding the seat (acts as a fencing token)
held_until TIMESTAMPTZ, -- Hold expiry time
UNIQUE (show_id, seat_id)
);
-- Critical for booking performance
CREATE INDEX idx_show_seats_status ON show_seats(show_id, status)
WHERE status IN ('AVAILABLE', 'HELD');
-- Bookings
CREATE TABLE bookings (
id BIGSERIAL PRIMARY KEY,
booking_ref VARCHAR(20) UNIQUE NOT NULL, -- Human-readable: BK-XXXX
user_id BIGINT NOT NULL,
show_id BIGINT NOT NULL REFERENCES shows(id),
total_amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) DEFAULT 'PENDING', -- PENDING, CONFIRMED, CANCELLED, EXPIRED
expires_at TIMESTAMPTZ NOT NULL, -- end of the seat hold
created_at TIMESTAMPTZ DEFAULT NOW(),
confirmed_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ
);
CREATE INDEX idx_bookings_user ON bookings(user_id);
CREATE INDEX idx_bookings_show ON bookings(show_id, status);
-- Booking seats (junction table)
CREATE TABLE booking_seats (
id BIGSERIAL PRIMARY KEY,
booking_id BIGINT NOT NULL REFERENCES bookings(id),
seat_id BIGINT NOT NULL REFERENCES seats(id), -- with bookings.show_id identifies the show_seats row
category VARCHAR(20) NOT NULL,
price DECIMAL(10,2) NOT NULL, -- Snapshot of price at time of booking
UNIQUE (booking_id, seat_id)
);
-- Payments (idempotency key prevents double-charge)
CREATE TABLE payments (
id BIGSERIAL PRIMARY KEY,
booking_id BIGINT NOT NULL REFERENCES bookings(id),
amount DECIMAL(10,2) NOT NULL,
status VARCHAR(20) DEFAULT 'PENDING', -- PENDING, SUCCESS, FAILED, REFUNDED
idempotency_key VARCHAR(255) UNIQUE NOT NULL,
gateway VARCHAR(50), -- stripe, razorpay
gateway_txn_id VARCHAR(255),
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE INDEX idx_payments_booking ON payments(booking_id);
CREATE INDEX idx_payments_idempotency ON payments(idempotency_key);
Key Transaction Flows¶
Booking flow (atomic, all-or-nothing):
BEGIN;
-- 1. Lock the requested rows in a fixed order (no deadlocks between multi-seat requests)
SELECT seat_id FROM show_seats
WHERE show_id = $1 AND seat_id = ANY($2)
ORDER BY seat_id
FOR UPDATE;
-- 2. Hold them only if each is free or its hold has lapsed (lazy expiry)
UPDATE show_seats
SET status = 'HELD', version = version + 1,
held_by = $3, held_until = NOW() + INTERVAL '10 minutes'
WHERE show_id = $1 AND seat_id = ANY($2)
AND (status = 'AVAILABLE' OR (status = 'HELD' AND held_until <= NOW()));
-- 3. If rows updated < array_length($2): ROLLBACK → "seats taken" (nothing was held)
-- 4. Create the booking with snapshotted prices
INSERT INTO bookings (booking_ref, user_id, show_id, total_amount, status, expires_at)
VALUES ($3, $4, $1, $5, 'PENDING', NOW() + INTERVAL '10 minutes');
INSERT INTO booking_seats (booking_id, seat_id, category, price) VALUES (...);
COMMIT;
Payment confirmation flow (idempotent, re-checks ownership): the charge happens outside any transaction. Then:
BEGIN;
INSERT INTO payments (booking_id, amount, status, idempotency_key, gateway_txn_id)
VALUES ($1, $2, 'SUCCESS', $3, $4)
ON CONFLICT (idempotency_key) DO NOTHING; -- duplicate webhook / client retry
-- Confirm only if the hold is still ours and unexpired
UPDATE show_seats SET status = 'BOOKED'
WHERE held_by = $ref AND status = 'HELD' AND held_until > NOW();
-- rows updated == seat count?
-- yes → UPDATE bookings SET status = 'CONFIRMED', confirmed_at = NOW() WHERE id = $1 AND status = 'PENDING';
-- no → UPDATE bookings SET status = 'EXPIRED' ...; insert a refund request into the outbox
COMMIT;
held_by no longer matches, the update touches 0 rows, and the late payer is refunded (or the authorisation voided). The seat is never sold twice.
5. SCALABILITY FOR FLASH SALES¶
| Strategy | Implementation |
|---|---|
| Queue excess | Requests beyond capacity go to SQS; user gets estimated wait time |
| Rate limit per user | 1 booking attempt per 5 seconds |
| Separate read/write paths | Movie listing reads from replicas/cache; bookings go to write master |
| Auto-scaling | Booking workers auto-scale based on queue depth |
| A/B capacity testing | Regular load testing to know breaking point |
6. FAILURE MODES¶
| Failure | Effect | Mitigation |
|---|---|---|
| Payment succeeds after hold lapsed | Paid for a seat now held by someone else | Ownership + expiry re-check in the confirm UPDATE; refund / void |
| Duplicate webhook or client retry | Double confirm / double charge | Idempotency keys on payment and confirm; confirm is a no-op if already CONFIRMED |
| Gateway timeout (outcome unknown) | Can't tell if charged | Query the gateway by idempotency key before retrying or failing over |
| Sweeper down | Lapsed bookings stay PENDING in reports | No effect on availability (lazy expiry); alert on sweeper lag |
| App crash after commit, before refund call | Refund lost | Transactional outbox: refund request committed with the state change |
| Primary DB failover | Seconds of write unavailability | Synchronous replica for bookings (no lost confirmations); clients retry with the same idempotency key |
| Hold hoarding by bots | Inventory locked without purchase | Max seats per booking, max concurrent holds per user, CAPTCHA in the waiting room |
7. CAPACITY (back of the envelope)¶
- Inventory: ~10K screens × 5 shows/day × 7 days published × ~250 seats ≈ 90M
show_seatsrows live; ~100 bytes each ≈ 9 GB plus indexes. One Postgres primary holds it; partition by show date to keep the hot set small and drop old partitions cheaply. - Write rate, normal: 10M MAU, say 3M bookings/month ≈ 1.2/s average, maybe 50/s at Friday-evening peak. Trivial.
- Write rate, flash sale: 100K users in the first minute ≈ 1.7K hold attempts/s, concentrated on maybe 50 shows (~12K seats). Each attempt is one short transaction on a handful of rows, a few ms. The hot-row contention, not total QPS, is the limit, which is why admission control per show matters more than adding app servers.
- Reads: seat-map views run 10–100× holds; served from a cache refreshed every 1–2 s per show.
8. COST (Monthly)¶
| Component | Cost |
|---|---|
| Compute (booking + search) | $4,000 |
| PostgreSQL (Primary + Replicas) | $2,500 |
| Elasticsearch cluster | $1,500 |
| Redis Cache | $800 |
| CDN + Bandwidth | $1,000 |
| Total | $9,800 |