The problem: two buyers, one seat
A concert has one seat left: the bookings database says seats_left = 1. Ana and Ben press Buy at the same moment, and their requests reach two app servers. Each reads seats_left, and if it's above 0, writes it minus 1 and sends a ticket:
step server does seats_left
1 Server 1 reads seats_left: 1 1
2 Server 2 reads seats_left: 1 1
3 Server 1 writes 0, Ana's ticket 0
4 Server 2 writes 0, Ben's ticket 0
Each server checked 1 > 0 before the other wrote, so both sold the seat. The database ends at 0, not −1: nothing looks wrong. This is a race condition: the result depends on the timing of two requests. The four moves can run in 6 orders, and 4 of them sell the seat twice; only the 2 where one server finishes before the other reads are safe.
The bug is the gap between checking and writing. A transaction alone doesn't close it: at the default isolation level of most databases, two transactions can both read 1 and both write 0. Every fix below makes the check and the write one step that no other request can cut into.
Fix 1: lock the row (pessimistic locking)
In SQL, SELECT … FOR UPDATE inside a transaction locks the rows it reads until the transaction commits, and anyone else who asks to lock them waits. Both servers run it, so Server 2's read waits while Server 1 writes 0, commits and sends Ana her ticket. The commit releases the lock; Server 2 reads 0 and tells Ben the show is sold out.
Locking first, because you expect a clash, is pessimistic locking. Its costs: everyone else waits, so locked work must stay short (milliseconds, never a person typing). And locks on two rows can deadlock: if Ana's server locks seat 14C and then asks for 14D while Ben's server holds 14D and asks for 14C, each waits for the other, and neither can go on. That's a deadlock. The fix is to lock rows in one agreed order, say lowest seat number first: the second server waits holding nothing, and no circle can form.
Fix 2: check a version (optimistic concurrency)
The second fix locks nothing. The row keeps a version number that every write raises by 1, and a write goes through only if the version is still the one the server read:
UPDATE concerts SET seats_left = 0, version = 8
WHERE id = 42 AND version = 7;
Both servers read 1 left at version 7. Server 1's write changes 1 row: Ana's ticket. Server 2's identical write finds version 8 and changes 0 rows, so it starts over, reads 0 and tells Ben it's sold out. Hoping for no clash and checking at the write is optimistic concurrency. Nobody waits, so it fits when clashes are rare: a wiki page two people rarely edit at once, a concert's description. On the rare clash, show the person the newer version.
When clashes are common, it falls apart: if 1,000 buyers read the same version of a flash sale's last 10 items, one write wins and 999 retry, round after round: about 10,000 writes to sell 10 items.
Fix 3: one conditional update
The third fix hands the check to the database, in one statement:
UPDATE concerts SET seats_left = seats_left - 1 WHERE id = 42 AND seats_left > 0;
The database runs updates to one row one after another, each checking the WHERE on the row as it is then. Server 1's update makes 1 into 0; Server 2's finds 0 and changes nothing. That isn't an error, so each server checks how many rows its update changed: 1 means a ticket, 0 means sold out. Reading seats_left afterwards doesn't work, since both servers would read 0. No lock, no version, no retries: when the rule fits in one statement (a seat left, an item in stock), this is usually the simplest fix.
Holds that expire
With a seat map, each seat is a row of its own: free, held or sold. Ana picks seat 14C at 8:00 and may need 10 minutes to type her card. Locking the row that long would tie up a database connection, make everyone who wants that seat wait, and leave it locked if she closes the tab.
Instead, mark the seat held with an expiry, held_until = now + 10 minutes. A seat is free when nobody holds it or its hold has run out, so it frees itself, with no job to run. Taking a hold is itself a conditional update, so two buyers can't hold one seat:
UPDATE seats SET state = 'held', held_by = 'Cy', held_until = now + 10 minutes
WHERE id = '14C' AND (state = 'free' OR (state = 'held' AND held_until <= now));
Paying sells the seat only if the hold is still the buyer's and still running:
time event
8:00 Ana holds 14C until 8:10
8:02 Ben holds 14D until 8:12
8:04 Cy tries 14C: held, so 0 rows change; she holds 14B until 8:14
8:06 Ben pays: 14D is sold
8:11 14C is free again; 14B is still held for Cy
If Ana tries to pay at 8:12, her hold has run out, so the payment is refused and she picks again.
Locks across services
When the work spans services, there's no one row to lock. A common design is a lock key in a fast in-memory store such as Redis, set only if nobody holds it, with an expiry (say 30 seconds) so a crashed holder can't block everyone.
The catch is a holder that pauses. Server 1 takes the lock and freezes for 40 seconds; at 30 its lock expires, and Server 2 takes it and writes. At 40 seconds Server 1 wakes up and sends its old write. A longer expiry doesn't help (a pause can outlast any expiry), and checking the lock just before writing leaves the same gap. The fix is a fencing token: each lock comes with a number one higher (33 for Server 1, 34 for Server 2), and the database refuses a write whose number is older than one it has seen.
Where it shows up in design interviews
Any design where two people want the same thing: seats, hotel rooms, flash sales, ride matching. Name where two requests can race, which fix you'd use, and why. A strong answer for seats: "Taking a seat is one conditional update, only if it's free or its hold ran out, and we check that a row changed. Holds last 10 minutes, and paying checks the hold is still ours." Isolation levels can wait until the interviewer asks (the Transactions and Isolation lesson).
Common interview questions
What's the difference between optimistic and pessimistic locking?
Pessimistic locking locks the row before deciding, and others wait; it suits frequent clashes. Optimistic locking checks a version at the write and retries on a clash; it suits rare clashes.
How do you stop two users from booking the same seat?
Make the check and the write one step: a conditional update that takes the seat only if it's free, plus a check of the rows it changed. For checkout, hold the seat with an expiry instead of locking it while the buyer pays.
What is a deadlock, and how do you avoid one?
Two transactions each hold a lock the other needs: neither can go on. Lock rows in one agreed order and keep transactions short; databases also detect deadlocks and cancel one transaction.
Why isn't a lock with an expiry enough on its own?
A holder can pause past the expiry, and then two servers both believe they hold the lock. A fencing token lets the database refuse the older holder's write.
Summary
- The race: both servers read 1 before either wrote: two tickets.
- Row lock (pessimistic): others wait; lock rows in one order to avoid deadlocks.
- Version check (optimistic): nobody waits, a clash means a retry; for rare clashes.
- Conditional update: one statement checks and writes; check the rows changed.
- Holds that expire: keep a seat 10 minutes while its buyer pays; it frees itself.
- Across services: a lock with an expiry, plus a fencing token.