Two users bought the last ticket at the same time
Two users bought the last ticket at the same time. Both received confirmation. The system sold what it didn’t have.
This isn’t a logic bug. It’s a race condition. And it happens in production every day.
The classic problem
def buy_ticket(ticket_id, user_id):
ticket = db.query("SELECT * FROM tickets WHERE id = ?", ticket_id)
if ticket.quantity > 0:
db.execute("UPDATE tickets SET quantity = quantity - 1 WHERE id = ?", ticket_id)
db.execute("INSERT INTO orders ...")
return "Purchase confirmed"
return "Sold out"
Looks correct. It isn’t.
If two requests arrive at the same time, both read quantity = 1, both pass the if, both decrement. Stock goes to -1 and two customers receive confirmation.
The SELECT and UPDATE are not atomic. Between one and the other, another process can step in.
Solution 1 — Pessimistic lock
def buy_ticket(ticket_id, user_id):
with db.transaction():
ticket = db.query(
"SELECT * FROM tickets WHERE id = ? FOR UPDATE",
ticket_id
)
if ticket.quantity > 0:
db.execute("UPDATE tickets SET quantity = quantity - 1 WHERE id = ?", ticket_id)
db.execute("INSERT INTO orders ...")
return "Purchase confirmed"
return "Sold out"
FOR UPDATE tells the database: “I’m reading this to modify it — nobody touches it until I’m done.”
Solves consistency. But under high concurrency — Black Friday, a concert that just opened sales — you create a queue at the database. Hundreds of requests waiting for the lock to release.
Solution 2 — Optimistic lock
UPDATE tickets
SET quantity = quantity - 1, version = version + 1
WHERE id = ? AND version = ? AND quantity > 0
No lock. No waiting. If another process modified first, the WHERE version = ? doesn’t match, no rows are affected — you detect the conflict and retry.
Better for high concurrency where conflicts are rare.
Solution 3 — Redis reservation + TTL
The first two solve consistency. But neither solves user experience.
What happens when the ticket is the last one and the user is filling out the payment form? Do you lock the stock while they type their credit card number?
The industry answer is a temporary reservation:
def reserve_ticket(ticket_id, user_id):
key = f"reservation:{ticket_id}"
# SET NX = only sets if key doesn't exist (atomic)
reserved = redis.set(key, user_id, nx=True, ex=600)
if not reserved:
return "Ticket reserved by another user"
return "Reserved. You have 10 minutes to complete your purchase."
def confirm_purchase(ticket_id, user_id):
key = f"reservation:{ticket_id}"
owner = redis.get(key)
if owner != user_id:
return "Reservation expired or invalid"
with db.transaction():
db.execute("UPDATE tickets SET quantity = quantity - 1 WHERE id = ?", ticket_id)
db.execute("INSERT INTO orders ...")
redis.delete(key)
return "Purchase confirmed"
SET NX is atomic by nature — Redis is single-threaded. Only one process can set the key. The second one finds it taken and returns immediately, without touching the database.
If the user abandons the cart, the 10-minute TTL expires and the ticket is automatically released. No cleanup job, no cron, no expired_at column in the table.
Three approaches, three trade-offs
| Pessimistic Lock | Optimistic Lock | Redis + TTL | |
|---|---|---|---|
| Consistency | ✅ Strong | ✅ Strong | ✅ Strong |
| High concurrency | ⚠️ Contention | ✅ Good | ✅ Good |
| UX (reservation window) | ❌ No reservation | ❌ No reservation | ✅ Real reservation |
| Complexity | Low | Medium | Medium |
In practice: ticketing systems, e-commerce with limited stock, and appointment booking use Redis. Financial systems with critical consistency use database locks. Using both together isn’t overkill — it’s the standard.
Race conditions aren’t a beginner’s bug. It’s a problem that only shows up when the system scales and starts receiving real load.
The code works perfectly on its own. The problem is when it runs in parallel with itself.