You’re in a system design interview. The interviewer says:
“Design a concert ticket ordering system. 100 million users. Peak load of 200,000 concurrent requests per second. Tickets are one per account holder. Payment goes through an external billing API limited to 25 requests per second per artist.
Oh — and there’s a loyalty card system for frequent concert-goers.”
Most candidates start drawing boxes immediately. The dangerous ones — the ones who fail — start with the database.
The right move: identify the hard problems first. In a ticket ordering system, there are exactly two:
Overselling — two users buy the last ticket simultaneously
Rate limiting — the billing API allows 25 req/sec per artist, but 200,000 users want in
Everything else is engineering execution. These two problems are architectural decisions that, if wrong, cannot be fixed later.
Before any box is drawn, clarify scope:
Functional requirements:
Users browse and purchase concert tickets (one per account)
Payment via external billing-api (POST /billing/pay)
Staff records physical attendance at concerts
Loyalty rewards: 5 concerts at same location → ¥1,000 credit, 7 concerts of same artist → ¥1,500 credit
Rewards paid via POST /billing/reward
Scale numbers:
Users: 100 million registered
Concert locations: 5,000
Artists: 150,000
Peak ticket ordering: 200,000 req/sec
Loyalty reads (peak): 100,000 req/sec
Loyalty writes (peak): 75,000 req/sec (Concert Day, 6x per year)
billing-api rate limit: 25 req/sec per artistId, 850ms p99 latency
Non-functional requirements:
No overselling under any load
Seat guarantee: once reserved, it stays yours during payment
Idempotent payment: retry never double-charges
Eventual ticket confirmation via push, not polling
Loyalty reads must be fast during Concert Day spikes
Capacity estimation:
Ticket ordering:
200,000 req/sec peak
Each request: ~2KB payload
Bandwidth: ~400 MB/s inbound at peak
Loyalty storage:
100M users × avg 20 attendance records = 2B rows
Each row: ~200 bytes
Total: ~400 GB (fits in Cassandra cluster)
billing-api constraint:
25 req/sec per artistId
Popular concert: 200,000 users competing
Queue depth at peak: 200,000 / 25 = 8,000 seconds of backlog
→ Must queue and notify async, never block HTTP
Every component has a specific reason:
Component Why this, not something else Redis Atomic DECR for seat counting — single-threaded, no race conditions Kafka Buffer against billing-api rate limit, replay on failure PostgreSQL ACID for ticket records — financial data needs transactions Cassandra Write-optimized for loyalty — 75K writes/sec at Concert Day WebSocket Push confirmation without polling — 200K open connections
Without distributed coordination:
This is the classic check-then-act race condition. No amount of database transactions fixes it without serializable isolation — which at 200K req/sec would create an unacceptable lock bottleneck.
Redis is single-threaded for command execution. DECR is guaranteed atomic — no two threads can execute it simultaneously. This makes it the perfect distributed counter:
Why Redis DECR works: Redis processes commands in a single thread via an event loop. When DECR runs, no other command runs simultaneously. The counter is your distributed mutex — whoever gets a non-negative result owns a seat. No database lock, no optimistic retry loop, no race condition.
Seat hold timeout:
DECR concert:123:available_tickets
SET concert:123:hold:{userId} "HELD" EX 600 ← 10-minute hold
If payment doesn’t complete in 10 minutes, the hold key expires and a background job restores the counter. The seat goes back on sale.
The billing API allows 25 requests per second per artistId. At peak, 200,000 users want to buy tickets for the same popular artist simultaneously. This is a 8,000-second backlog if handled naively.
User request
↓
Redis DECR (seat reserved instantly)
↓
Enqueue to Kafka: topic "payments.artist.{artistId}"
↓
HTTP 202 returned to user ("Seat reserved, payment pending")
WebSocket connection kept open
↓
Kafka consumer group (Billing Workers for artistId):
↓
Token bucket: max 25 tokens/sec per artistId
↓
Pop message → POST /billing/pay
↓
├── HTTP 200 → UPDATE ticket SET status='CONFIRMED'
│ → WebSocket push: "✓ Booking confirmed!"
│ → Publish to notification topic (email)
│
├── HTTP 500 → INCR Redis counter (release seat)
│ → WebSocket push: "Payment failed, try again"
│
└── HTTP 429 → Exponential backoff + re-enqueue
→ Do NOT release seat (billing-api is just busy)
Users shouldn’t poll for confirmation. At 200K concurrent users, polling would generate millions of unnecessary HTTP requests per second.
Instead, each user maintains a persistent WebSocket connection opened when they click “Buy”:
The WebSocket Server subscribes to Kafka topics ticket.confirmed and ticket.failed. When a billing worker publishes a result, the WebSocket server routes it to the correct user’s open connection by userId.
For scale, sticky sessions via consistent hashing — userId % N determines which WebSocket server owns that connection. This ensures the Kafka consumer knows exactly which server to publish to without broadcasting.

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.