Distributed Locks with Redis: SET NX, Fencing and the Redlock Debate
The minimal correct lock and the four bugs hiding in simpler versions, fencing tokens for the expiry race, Redlock in two fair paragraphs, and the lock-free designs that beat locking entirely.
Distributed locks are the topic where I've watched the most confident engineers write the most subtle bugs — myself included. The API looks trivial (one Redis command!), the happy path works instantly, and the failure modes hide for months before charging a customer twice. So let's do this properly: the correct minimal lock, the four classic bugs, the Redlock controversy in two honest paragraphs, and — most importantly — when a lock is the wrong tool entirely.
The minimal correct lock
// Acquire: one atomic command. NX = only if absent, PX = auto-expiry.
$token = bin2hex(random_bytes(16)); // YOUR token — this matters
$ok = Redis::set("lock:invoice:{$id}", $token, 'NX', 'PX', 30_000);
if (! $ok) { return; /* someone else holds it */ }
try {
$this->generateInvoice($id);
} finally {
// Release: delete ONLY if it's still OUR lock — must be atomic, hence Lua
Redis::eval(<<<'LUA'
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
end
return 0
LUA, 1, "lock:invoice:{$id}", $token);
}
Every element is load-bearing, and each one's absence is a named bug:
- No expiry (PX) → process crashes mid-work → lock held forever → the feature silently stops for everyone. Deadlock, distributed edition.
- No unique token → your process stalls past the expiry, the lock lapses, worker B acquires it, then your stall ends and your plain
DELdeletes B's lock → now C joins B inside the "exclusive" section. The token + Lua check-and-delete closes this. - GET-then-DEL without Lua → same bug, smaller window. Atomicity or nothing.
- Expiry shorter than the work → the big one. Your 30-second lock, your 45-second GC pause or slow query — and for 15 seconds, two workers both believe they're alone. No Redis setting fixes this; it needs the next section.
(In Laravel, Cache::lock('invoice:42', 30)->block(5, fn () => ...) implements the token-and-atomic-release dance for you. Use it rather than hand-rolling — but knowing the dance is what lets you spot problem #4, which no library fixes.)
Fencing tokens: the fix for the expiry race
If the lock protects writes to some resource, make the resource itself reject stale writers. Issue a monotonically increasing number with each lock acquisition (an INCR alongside the SET), and have the protected system check it:
-- The write carries its fencing token; the row remembers the highest seen.
UPDATE invoices
SET state = 'generated', fence = :token
WHERE id = :id AND fence < :token; -- stale holder's write simply doesn't apply
Now the delayed worker from bug #4 wakes up, writes with token 33, and the row — already at 34 from the newer holder — ignores it. This is the honest answer to "how do I make the lock safe": you don't; you make the resource safe and demote the lock to an efficiency device that merely prevents duplicate work most of the time.
Redlock, in two fair paragraphs
Redlock is the algorithm for taking a lock across five independent Redis nodes (majority acquisition within a time budget), designed for when a single Redis failing over could hand the same lock to two holders — replication is async, so a freshly promoted replica may not know about your lock. If a single-instance lock's failover window genuinely threatens correctness, Redlock narrows it.
The famous critique (Kleinberg — sorry, Kleppmann) stands: Redlock's safety still rests on timing assumptions — bounded clock drift, bounded pauses — that distributed-systems theory says you can't fully trust, and a long GC pause defeats it exactly like bug #4. The pragmatic synthesis I run with: for efficiency locks, single-instance SET NX PX is plenty and Redlock is overkill; for correctness, neither is sufficient without fencing — so add fencing and then the simple lock is fine again. If you need a lock service with real consensus guarantees, that's ZooKeeper/etcd territory, and it's rarely worth it for application code.
The best lock is no lock
Half the locks I review in audits are workarounds for a design that fights itself. Before locking, check the cheaper aisle:
- Unique constraints. "Only one active subscription per user" is a partial unique index, not a lock. The database has done atomic mutual exclusion flawlessly since before we were born.
- Atomic conditional updates.
UPDATE jobs SET state='running' WHERE id=? AND state='pending'— affected-rows tells you if you won. This is how queue claim semantics work everywhere. - Idempotency. If doing it twice is harmless, contention doesn't need excluding — see idempotency keys.
- Single-writer designs. Route all writes for an entity through one queue partition/worker, and exclusion is topological, not negotiated.
My actual usage distribution after all these years: ~80% "skip the recompute if someone's already on it" efficiency locks (simple SET NX PX, generous TTL, shrug at rare duplicates), ~15% replaced by one of the lock-free patterns above, ~5% correctness-critical — and those get fencing plus a long design conversation. If your ratio looks inverted, the locks are probably load-bearing walls in a house that needed different blueprints.
Part of the Redis series — caching, the wider toolbox, production config. Locks behaving strangely in production? That's not a rhetorical question I ask; bring the incident.