Skip to main content
System Design · lesson 9 of 11
Open the System Design page →
System Design advanced Lesson 9 of 11

Also part of: Redis

Distributed Locks: A Real Double-Booking Bug, Fixed With a Redis Lock

Two processes racing on the same booking slot really do double-book it. A Redis SET NX PX lock fixes it — a blind DEL unlock reintroduces the bug.

By SkillByExample · Published

system-design distributed-locks redis redlock docker

Two processes can each believe they’re allowed to act, because between checking and acting, nothing stopped a second process from checking too. A distributed lock is how machines that don’t share memory agree on who gets to go first.

The bug: double-booking a single slot

docker run -d --name redis-lesson -p 6379:6379 redis:8-alpine
docker exec redis-lesson redis-cli DEL room:2pm:bookings

Two processes race to book the same 1-seat slot — each checks the current booking count, and only proceeds if there’s room:

# book_no_lock.sh <bidder-name>
count=$(docker exec redis-lesson redis-cli LLEN room:2pm:bookings)
if [ "$count" -lt 1 ]; then
  sleep 0.3   # simulate real processing time — a payment call, a confirmation email
  docker exec redis-lesson redis-cli RPUSH room:2pm:bookings "$1" > /dev/null
  echo "$1: booked (saw count=$count)"
else
  echo "$1: rejected (saw count=$count)"
fi
$ ./book_no_lock.sh alice & ./book_no_lock.sh bob & wait
bob: booked (saw count=0)
alice: booked (saw count=0)
$ docker exec redis-lesson redis-cli LRANGE room:2pm:bookings 0 -1
bob
alice

Both processes call LLEN before either has written anything back, so both see count=0 and both conclude the slot is free. The sleep 0.3 — standing in for real work like charging a card — is what widens the race window enough to hit reliably; a 1-seat slot ends up with 2 bookings.

The fix: a real Redis-based lock

# book_with_lock.sh <bidder-name> <token>
acquired=$(docker exec redis-lesson redis-cli SET lock:room:2pm "$2" NX PX 2000)
if [ "$acquired" != "OK" ]; then
  echo "$1: could not acquire lock, rejected immediately"
  exit 0
fi
count=$(docker exec redis-lesson redis-cli LLEN room:2pm:bookings)
if [ "$count" -lt 1 ]; then
  sleep 0.3
  docker exec redis-lesson redis-cli RPUSH room:2pm:bookings "$1" > /dev/null
  echo "$1: booked (saw count=$count)"
else
  echo "$1: rejected (saw count=$count)"
fi
owner=$(docker exec redis-lesson redis-cli GET lock:room:2pm)
[ "$owner" == "$2" ] && docker exec redis-lesson redis-cli DEL lock:room:2pm > /dev/null
$ docker exec redis-lesson redis-cli DEL room:2pm:bookings lock:room:2pm
$ ./book_with_lock.sh alice token-alice & ./book_with_lock.sh bob token-bob & wait
bob: could not acquire lock, rejected immediately
alice: booked (saw count=0)
$ docker exec redis-lesson redis-cli LRANGE room:2pm:bookings 0 -1
alice

SET key value NX PX 2000 is Redis’s atomic “set only if this key doesn’t already exist, expiring in 2 seconds” — exactly one of alice/bob’s SET calls can win it, so whichever loses (bob, here) is rejected before it ever touches the booking count. The PX expiry matters independently of who wins: if the winning process crashed instead of finishing normally, the lock would still self-expire in 2 seconds rather than jamming the slot forever.

The real danger: unlocking without checking who you are

The unlock above checks owner == my token before deleting — that check isn’t decoration. Here’s what a blind DEL (no check) actually does:

echo "resetting demo key:"
docker exec redis-lesson redis-cli DEL lock:demo

# slow_client: acquires with a short 500ms TTL, then takes 1000ms of real work
echo "slow_client acquiring:"
docker exec redis-lesson redis-cli SET lock:demo slow-client-token PX 500 NX
sleep 1

# meanwhile, 700ms in, slow_client's lock has already expired —
# fast_client legitimately acquires it fresh:
echo "fast_client acquiring after expiry:"
docker exec redis-lesson redis-cli SET lock:demo fast-client-token PX 5000 NX

# slow_client now finishes and blindly deletes, with no idea it's no longer the owner:
echo "slow_client blindly unlocking (no token check):"
docker exec redis-lesson redis-cli DEL lock:demo

echo "lock owner after slow_client's blind DEL:"
docker exec redis-lesson redis-cli GET lock:demo
$ ./blind_unlock_demo.sh
resetting demo key:
0
slow_client acquiring:
OK
fast_client acquiring after expiry:
OK
slow_client blindly unlocking (no token check):
1
lock owner after slow_client's blind DEL:

fast_client’s brand-new lock — with 5 real seconds left on its own TTL — is gone. slow_client never checked whether it still owned the lock before deleting; it just deleted the key, which by that point belonged to someone else entirely. The token check in the earlier fix exists specifically to prevent this: running the same scenario with GET-before-DELETE correctly finds fast-client-token where it expects slow-client-token, and skips the delete.

Making the check-then-delete fully atomic

Checking with GET and then deleting is still two separate round-trips, with a real (if narrow) gap between them. A Lua script run via EVAL closes that gap by making the whole check-and-delete one atomic server-side operation:

docker exec redis-lesson redis-cli SET lock:demo my-token PX 5000 NX
docker exec redis-lesson redis-cli EVAL \
  "if redis.call('GET', KEYS[1]) == ARGV[1] then return redis.call('DEL', KEYS[1]) else return 0 end" \
  1 lock:demo my-token
$ (correct token)
1
$ (wrong token, run against a freshly re-set lock:demo)
0

1 means the script’s DEL actually ran (the token matched); 0 means it didn’t touch the key at all. Real production Redis lock libraries (Redlock’s own reference implementation included) use exactly this pattern rather than a plain DEL.

Practice

1. What happens if the lock's TTL is shorter than the critical section it's protecting — say PX 100 with a 300ms sleep inside?

The lock expires while the first holder is still “working,” so a second process can legitimately acquire it and start its own critical section before the first one finishes. Both processes end up believing they hold an exclusive lock at the same time — the exact failure this lesson’s unlock-safety example demonstrates, except now it happens during the critical section instead of just at unlock time. The fix is picking a TTL comfortably longer than the slowest realistic critical section, or using a “lock extension” heartbeat that renews the TTL while work is still in progress (what Redlock client libraries call auto-extend).

2. Two processes both call `SET lock:x token NX PX 1000` at the literal same instant. Can they both get `OK`?

No — Redis processes commands one at a time on a single thread, so even truly simultaneous network arrivals get serialized into some real order once they reach the server. Exactly one SET ... NX can ever return OK for a given key while it doesn’t already exist; the other necessarily arrives second (from Redis’s point of view) and gets nil. This is what makes SET NX usable as a lock primitive at all — the mutual exclusion comes from Redis’s single-threaded command execution, not from anything the clients do.

3. Why is `PX` (expiry in the same command as the lock acquisition) safer than acquiring the lock and setting a separate `EXPIRE` afterward?

Between the SET and a separate EXPIRE call, the process could crash — leaving a lock with no expiry at all, which now blocks everyone forever. Setting NX and PX as part of the same SET command means the lock is born with its expiry already attached; there’s no window where it exists without one.

4. This lesson's example uses one Redis instance. What does Redlock (5 independent instances) add that a single instance can't give you?

A single instance is one thing that can go down and take every lock with it, or briefly (during a failover to a replica) let two clients both think they hold the same lock. Redlock has a client try to acquire the same lock on 5 independent instances and only considers itself successful if a majority (3 of 5) agree — a single instance failing or briefly disagreeing doesn’t change the majority outcome, the same quorum idea a Raft-style leader election uses to survive individual node failures.

Frequently Asked Questions

Why not just use Redis's own atomic commands like INCR instead of a lock?
A single atomic command works when the whole operation is one command — INCR alone would in fact solve a plain counter race with no lock needed. A distributed lock earns its place when the protected logic is more than one command: check current bookings, decide against business rules, then write — the kind of multi-step 'critical section' this lesson's booking example needs, which no single Redis primitive can express atomically on its own without either a lock or a Lua script.
Is a single-Redis-instance lock actually safe?
It's safe against races between well-behaved clients, but it's a known single point of failure — if that one Redis instance goes down, every lock disappears with it, and if it's a primary-replica pair, a failover can briefly let two clients both believe they hold the same lock (this is the core of the real, public disagreement between Redis's own Redlock algorithm and Martin Kleppmann's 'How to do distributed locking' critique of it). Production systems that truly can't tolerate that briefly run Redlock across 5+ independent Redis instances, not one.
Why does the unlock need to check a token instead of just deleting the key?
Because the lock can expire on its own (that's the whole point of the PX TTL — a crashed holder must not lock everyone else out forever). If the original holder is just slow rather than crashed, its lock can expire and get legitimately re-acquired by someone else before the slow holder finally gets around to unlocking — a blind DEL at that point deletes the new holder's lock, not the slow holder's own (already-expired) one. This lesson demonstrates that exact bug.
Why use a Lua script for the safe unlock instead of GET-then-DEL in two commands?
GET-then-DEL from the client is two round-trips with a gap between them — another client's SET could land in that gap, and the DEL would still delete it even after checking the token, just one step later. EVAL runs the whole check-and-delete as one atomic operation on the server, so there's no gap for anything else to land in.