In a ticket booking system, two users attempt to book the same seat at nearly the same time. How would you ensure the seat is sold only once?
How I frame it in the interview
I'd clarify whether we're talking about selecting a seat (hold) or final payment (sold). The fix is a seat state machine per (show_id, seat_id) with atomic transition — only one session can move AVAILABLE → HELD → SOLD for that seat.
Seat lifecycle
- AVAILABLE — on the map, can be selected
- HELD — temporarily locked for one user (e.g. 8–10 min) during checkout
- SOLD — payment confirmed, terminal state
Two users clicking the same seat hit holdSeat(). The database must make exactly one hold succeed. The loser gets an immediate "seat no longer available" and the UI refreshes the map.
Seat A12 (show #991) User A request User B request (5ms later)
status: AVAILABLE | |
| BEGIN TX BEGIN TX
| SELECT seat FOR UPDATE SELECT seat FOR UPDATE (blocks…)
| status == AVAILABLE ✓ |
| -> HELD (userA, TTL 10m) |
| COMMIT |
| | lock acquired
| | status == HELD by A
| | -> 409 SEAT_UNAVAILABLE
| | COMMIT
| payment window… pick another seatHow to enforce “only once” — pick one primary mechanism
1) Pessimistic lock (common in interviews): SELECT … FOR UPDATE on the seat row inside a transaction. First transaction reads AVAILABLE, writes HELD, commits. Second blocks, then reads HELD by another user → reject. Simple and correct for one DB.
2) Optimistic locking: version column or conditional UPDATE … WHERE status = 'AVAILABLE'. Zero rows updated = lost race. Better under low contention; same seat hot spot still serializes on one row.
3) Unique constraint safety net: UNIQUE(show_id, seat_id) on bookings where status IN ('HELD','SOLD') (partial index) — DB rejects double insert even if application logic has a bug.
// Pessimistic: one seat row, serialized access
@Transactional
public HoldResult holdSeat(Long showId, String seatId, Long userId) {
Seat seat = seatRepo.findByShowAndSeatForUpdate(showId, seatId);
if (seat.getStatus() == SOLD) {
return HoldResult.unavailable("Already sold");
}
if (seat.getStatus() == HELD && !seat.getHeldBy().equals(userId)) {
if (seat.getHoldExpiresAt().isAfter(Instant.now())) {
return HoldResult.unavailable("Held by another user");
}
seat.releaseHold(); // expired hold
}
seat.hold(userId, Duration.ofMinutes(10));
seatRepo.save(seat);
return HoldResult.ok(seat.getHoldToken());
}
// Optimistic alternative: version column
// UPDATE seats SET status='HELD', version=version+1, held_by=?
// WHERE show_id=? AND seat_id=? AND status='AVAILABLE' AND version=?
// -- 0 rows updated => someone else won the raceWalkthrough — A and B, 5ms apart
Both see seat A12 green on the map (cached UI). Both POST /hold. User A's txn locks the row first → A12 HELD for A. User B's txn sees HELD, not expired → 409. B's UI turns A12 grey. Only A proceeds to payment; on pay success A12 → SOLD. B never had a valid hold, so no double sale.
Weak answer to avoid
"Check if seat is free, then insert booking." Without lock or conditional update, both threads read AVAILABLE and both insert — classic check-then-act race. Also weak: only using in-memory lock on one app server when you run multiple instances.
Follow-up questions
User A holds the seat but abandons checkout — when can B book it?
TTL on hold (cron or lazy check on next hold attempt). When expired, HELD → AVAILABLE in same locked transaction before granting B's hold. Optional waitlist for that seat.
Payment succeeds but your service crashes before marking SOLD?
Idempotent payment + reconciliation: seat stays HELD or UNKNOWN until webhook/status API confirms. Reconciliation job pairs payment with seat. Same patterns as ambiguous payment timeout.
Seat map on frontend shows stale “available” — is that a backend bug?
Short-lived holds need live or near-live seat map — WebSocket/SSE broadcast on hold/sold, or poll every few seconds during selection. Backend is source of truth; UI is eventually consistent.
BookMyShow scale — one row lock per seat enough?
For a single hot seat, yes — one row serializes. For whole-show traffic, shard by show_id, connection pool tuning, and avoid locking entire show row. Hot drops may add queue before hold API (related to flash-sale pattern).
How is this different from the iPhone flash sale question?
Flash sale is fungible inventory (any of 5 phones) with a counter. Seat booking is unique resource per ID — you lock a specific row, not decrement a count. Same atomicity idea, different data model.