Distributed Locking Explained: Redis Redlock, ZooKeeper, and Why It’s Tricky
-
Last Updated: October 18, 2026
-
By: javahandson
-
Series

Learn distributed locking with Redis SET NX, Redlock, ZooKeeper ephemeral znodes, GC pause risks, and fencing tokens for safe concurrency.
Imagine two servers trying to do the same job at the same time. One deducts money from an account. The other does the exact same deduction a split second later, because it never knew the first one was running. The customer gets charged twice. This is the kind of mess that distributed locking is meant to prevent.
On a single machine, this problem is easy. Your language gives you a lock, a mutex, or a synchronized block, and the operating system makes sure only one thread enters a critical section at a time. But the moment your system runs on many machines, that cozy guarantee disappears. There is no shared memory across servers. There is no single operating system holding everyone back. Each node thinks it is alone in the world.
So we reach for a lock that lives outside all the servers, in a shared place every node can see. Usually that shared place is Redis or ZooKeeper. The idea sounds simple. In practice, distributed locking is one of those topics that looks easy on the whiteboard and quietly breaks in production. Networks pause. Processes freeze. Clocks drift. Any one of these can hand the same lock to two servers who both believe they are the only holder.
In this article we will build the concept up slowly. First, why a normal lock does not work across machines. Then two popular ways to build a distributed lock, using Redis and using ZooKeeper. After that, the famous debate about whether Redis locks are truly safe, and finally the one trick, fencing tokens, that makes locks actually correct. By the end you will understand not just how to build one, but why so many of them are subtly wrong.
Let us start with what you already know. Inside one program, a lock is a small flag in memory. A thread flips it to “taken”, does its work, and flips it back to “free”. Other threads wait their turn. The operating system coordinates everything, and it all works because every thread shares the same memory and the same clock.
Now split that program across five servers behind a load balancer. Each server runs its own copy. Each has its own memory. A flag set in one server’s memory means nothing to the other four. They cannot even see it. So a plain in-memory lock protects a thread from its siblings on the same box, and does absolutely nothing about the four other boxes doing the same work.
Picture a scheduled job that sends invoice emails. You deploy three instances of your service for safety. At midnight, all three wake up and run the same job. Without coordination, every customer gets three identical invoices. Embarrassing, and in a payments system, dangerous.
What you actually want is this: all three may wake up, but only one should win the right to run the job. The other two should notice someone else is working and step aside. That shared “right to run” is the lock, and because it must be visible to every instance, it has to live somewhere all of them can reach.
Before picking a tool, it helps to write down what a good lock must give us. These are the properties people quietly assume and then forget to check.
That last point is where almost every tricky bug lives. Holding a lock is easy. Being sure you still hold it, at the exact moment you use it, is the hard part.
Redis is a natural first choice. It is fast, it is in memory, and almost every backend already has it around for caching. The basic idea is to represent the lock as a single key. If the key exists, the lock is taken. If it does not, the lock is free. Whoever manages to create the key first becomes the holder.
A beginner’s first instinct is to check if the key exists, and if not, set it. Something like “if the lock is free, grab it”. But that is two separate steps, and between those two steps another client can slip in. Both check, both see it free, both grab it. Two holders. The lock is broken before we even started.
The fix is to make grabbing the lock a single atomic operation. Redis gives us exactly that with SET … NX. The NX flag means “only set this key if it does not already exist”. One command, no gap for anyone to sneak through.
There is a second problem to handle. What if the client that holds the lock crashes before releasing it? The key would sit there forever, and nobody could ever get the lock again. That is a deadlock. To avoid it, we attach an expiry time to the key. If the holder dies, the key expires on its own after a few seconds, and the lock frees up automatically.
Acquiring a lock safely in Redis with SET NX PX
# NX = set only if the key does not exist (atomic grab) # PX = expire after 30000 ms, so a dead holder can't block forever # The value is a random, unique token for THIS client only. SET lock:invoice-job "c9f3-7b21-uuid" NX PX 30000 # Reply is OK -> you got the lock # Reply is nil -> someone else holds it, back off and retry
Notice the value is a random token unique to this client. That detail matters more than it looks, and we will see why in a second.
Releasing seems trivial: just delete the key. But there is a sneaky bug here. Suppose client A grabs the lock with a 30 second timeout. Client A then freezes, maybe a long garbage collection pause. The 30 seconds pass, the key expires, and the lock is now free. Client B grabs it. Then client A wakes up, finishes its work, and deletes the key, which now belongs to B. Client A just released a lock it no longer owned, and a third client can now grab it while B is still working.
This is why the value is a unique token. On release, the client must check that the token stored in Redis still matches its own. Only then should it delete. And that check-and-delete must be atomic too, so we run it as a tiny Lua script.
Safe release: delete only if the token is still mine (Lua, runs atomically)
-- KEYS[1] is the lock key, ARGV[1] is my unique token
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0 -- not my lock anymore, do not touch it
end| Interview Insight: A very common question is “what’s wrong with just SETNX and DEL for a Redis lock?” The strong answer hits three points: SETNX alone has no expiry so a crashed holder deadlocks everyone; a plain DEL can delete a lock that already expired and now belongs to someone else; and the fix is a unique per-client token plus an atomic check-and-delete in Lua. If you can explain the expired-lock-stealing scenario, you are ahead of most candidates. |
So far we have used one Redis server. That one server is now a single point of failure. If it goes down, nobody can acquire or release locks, and your whole system stalls. The obvious fix is to add a replica. But replication in Redis is asynchronous, and that opens a nasty hole.
Here is the failure. Client A acquires the lock on the master. The master has not yet copied that key to the replica. Right then, the master crashes. The replica gets promoted to master. But the new master never heard about A’s lock, so as far as it knows, the lock is free. Client B asks for it and gets it. Now A and B both hold the same lock. Mutual exclusion is gone, and nothing in the system even noticed.
To fix the single-master weakness, the creator of Redis proposed an algorithm called Redlock. The idea is to stop trusting any single Redis server. Instead, run several completely independent Redis masters, with no replication between them, and require a client to win the lock on a majority of them.
The classic setup uses five independent Redis nodes, usually on different machines. A client that wants the lock does the following.
The majority rule is what gives Redlock its resilience. Because the nodes are independent, one or two can die and a client can still win on three. And since two different clients cannot both win a majority of the same five nodes at the same time, you get mutual exclusion without any replication at all.
Redlock does not just count node votes. It also watches the clock, and that is deliberate. Acquiring across five nodes takes real time. If it took so long that the lock is about to expire anyway, holding it is pointless. So the usable lock time is the original expiry minus the time already spent acquiring, minus a small safety margin for clock drift between machines.
This timing dependency is elegant, and it is also exactly where critics started asking hard questions. Because once an algorithm’s correctness depends on time and timeouts, you have to ask what happens when time itself misbehaves.
ZooKeeper comes at the problem from a very different direction. Redis is a fast cache that we bent into a lock. ZooKeeper was built from day one as a coordination service for distributed systems, and locking is one of the things it was designed to do well. It is slower than Redis, but it trades that speed for much stronger guarantees.
ZooKeeper stores data in a tree of small nodes called znodes, a bit like files in folders. Two special kinds of znode make locking clean.
Put the two together and you get an ephemeral sequential znode. It disappears if its owner dies, and it carries a number that records the order in which it was created. That combination is the heart of a ZooKeeper lock.
The standard ZooKeeper lock works like a well-behaved queue. Everyone lines up, and the person at the front holds the lock.
The lock folder as a queue of ephemeral sequential znodes
/locks/invoice-job/
lock-0003 <-- smallest number, this client HOLDS the lock
lock-0004 <-- watches lock-0003, waiting
lock-0005 <-- watches lock-0004, waiting
# When lock-0003 is deleted (work done OR client crashed),
# ZooKeeper notifies lock-0004 only. It becomes smallest -> its turn.You might wonder why each client watches just the one znode ahead of it, instead of watching the whole folder. The reason is a classic scaling trick. If every waiting client watched the lock holder, then the moment the holder released, ZooKeeper would have to wake up every single waiter at once. They would all rush to recheck, and all but one would go back to sleep. This stampede is called the herd effect, and it hammers the server for no reason.
By watching only its immediate predecessor, each client is woken exactly once, and only when it is genuinely its turn. The line advances one step at a time, calmly. It is a small design choice that makes a huge difference under load, and interviewers love to ask about it.
| Interview Insight: Expect the question “how does a ZooKeeper lock avoid the herd effect?” The answer: each client creates an ephemeral sequential znode and watches only the znode with the next-lowest number, not the holder and not the whole directory. So a release wakes exactly one waiting client instead of all of them. Mentioning ephemeral znodes for automatic cleanup on crash, and sequential numbers for fair ordering, rounds out a top answer. |
Now we reach the heart of the matter. Both approaches above look solid. Yet distributed locking has a famous reputation for being unsafe, and the reason is not bad code. It is that the world underneath the lock refuses to behave. Three things in particular ruin the party: process pauses, network delays, and clock drift.
Here is the scenario that started a well-known debate in the systems community. A client acquires a lock with a 30 second lease. It begins its work. Then, right in the middle, the process freezes. Maybe a long garbage collection pause in a Java service, maybe the machine was overloaded, maybe it got paused by the hypervisor. Whatever the cause, the client is frozen and has no idea.
While it is frozen, the 30 second lease expires. The lock service, quite correctly, gives the lock to another client. That second client starts working on the same resource. Then the first client unfreezes. It does not know any time has passed. It believes it still holds the lock, and it marches on to write to the resource. Now two clients write at once. The lock did its job perfectly and the system still broke.
The key lesson is uncomfortable. A lease that expires does not stop a frozen client. The client only finds out it lost the lock if it checks, and a frozen client checks nothing. No amount of clever locking on the Redis or ZooKeeper side can fix this alone, because the problem is on the client that went to sleep holding a knife.
The same shape of bug appears in other costumes. A network delay can hold up a client’s “I still have the lock” message so long that the lock expires in the meantime. A clock that jumps forward, because of a bad time sync, can make a lock expire earlier than anyone expected. Redlock in particular leans on timing, so if the clocks on the Redis machines drift apart, its safety reasoning weakens.
This is exactly what sparked the Redlock debate. A well-known researcher argued that Redlock relies too heavily on well-behaved clocks and timing, and that under real pauses and drift it cannot guarantee mutual exclusion for correctness-critical work. The author of Redis replied that in practice it is safe enough for many uses and that the timing assumptions are reasonable. Both sides actually agreed on one thing: if you need true correctness, the lock by itself is not enough. You need something more.

The missing piece is a fencing token. The idea is beautifully simple. Every time the lock is granted, the lock service also hands out a number that always goes up. First holder gets token 33, next holder gets 34, and so on. The token never decreases and is never reused.
The client then carries this token with every write it makes to the protected resource. And here is the trick: the resource itself remembers the highest token it has seen, and rejects any write that carries a smaller one. So even if a frozen old client wakes up and tries to write with its stale token 33, the resource has already accepted token 34 from the new holder and simply refuses the old write.
The resource rejects stale writes using fencing tokens
Client A acquires lock -> gets token 33 Client A freezes (long GC pause)... Lock expires. Client B acquires lock -> gets token 34 Client B writes with token 34 -> resource stores lastToken = 34 (accepted) Client A wakes up, still thinks it holds the lock Client A writes with token 33 -> resource sees 33 < 34 -> REJECTED Result: the stale write is fenced off. Only one real update wins.
This is why fencing tokens are such a big deal. They move the final safety check from the lock service to the resource being protected. The lock can be wrong. A client can be confused about whether it still holds the lock. None of that matters, because the resource makes the final call and only ever moves forward. The old write literally cannot land.
ZooKeeper makes fencing tokens almost free. Remember the sequential number on each znode? That number only ever increases, so it works directly as a fencing token. This is one more reason ZooKeeper is favored for correctness-critical coordination. A plain Redis lock does not give you an ordered token out of the box, which is part of the Redlock criticism. You can build one, but you have to do the work yourself.
| Interview Insight: If you are asked “how do you make a distributed lock actually safe against GC pauses?”, the one-word answer is fencing. Explain that the lock service issues a monotonically increasing token on each grant, the client sends it with every write, and the protected resource rejects any token lower than the highest it has seen. Add that ZooKeeper’s sequential znode number is a ready-made fencing token, and you have given the complete, senior-level answer. |
Neither tool is simply better. They sit at different points on the speed-versus-safety line, and the right choice depends on what breaks if the lock fails. A good way to decide is to ask one blunt question: if two clients held this lock at once for a moment, would it be annoying, or would it be a disaster?
If the honest answer is “annoying but survivable”, a Redis lock is usually fine and much faster. If the answer is “a disaster, money moves or data corrupts”, lean toward ZooKeeper with fencing, or add fencing tokens on top of whatever you use.
| Aspect | Redis (SET NX / Redlock) | ZooKeeper |
|---|---|---|
| Primary purpose | Cache, bent into a lock | Built for coordination |
| Speed | Very fast | Slower, more overhead |
| Crash cleanup | Key expiry (tune the TTL) | Ephemeral znode, automatic |
| Ordering / fencing token | Not built in, do it yourself | Sequential number, free |
| Herd effect handling | Manual retry and backoff | Watch predecessor only |
| Best when | Speed matters, a rare double-hold is tolerable | Correctness matters more than latency |
In real systems many teams start with a simple Redis lock because it is already there and it is quick. That is a reasonable default for jobs like “make sure only one worker runs this cron”. They move to ZooKeeper, or bolt on fencing tokens, only once they have a resource where a double write would be truly costly. There is no shame in starting simple, as long as you know the limits of what you started with.
Most distributed locking bugs come from a short list of mistakes that keep repeating across teams. Knowing them saves you from learning each one the hard way in production.
Interviewers rarely want you to recite an algorithm. They want to see that you know a lock can lie to you. So when the topic comes up, show the full arc. Start simple, admit the weakness, then reach for the real fix.
A strong path sounds like this. Begin with a single SET NX PX lock and explain the token-and-Lua release. Point out the single-point-of-failure problem and mention Redlock’s majority approach as the answer. Then raise the GC pause scenario yourself, before the interviewer does, and explain that timing-based locks cannot fully guarantee correctness. Finish with fencing tokens and note that ZooKeeper’s sequential znodes give them for free. Walking that road, from naive to safe, signals real experience far better than naming tools ever could.
A: Distributed locking is a way to make sure that only one process, across many servers, can access a shared resource at a time. Because servers do not share memory, the lock lives in a shared service like Redis or ZooKeeper that every node can reach.
A: SETNX alone has no expiry, so a crashed holder deadlocks everyone forever. A plain DEL can also delete a lock that already expired and now belongs to another client. The safe version uses SET NX PX with a unique token, and releases with an atomic check-and-delete Lua script.
A: Redlock runs several independent Redis masters (usually five) with no replication between them. A client must acquire the lock on a majority of nodes within the lock’s validity time. The majority rule removes the single point of failure that a single Redis master has.
A: Process pauses, network delays, and clock drift can all make a client believe it still holds a lock after the lease has actually expired. A frozen client never checks, so it can write to a resource that a new holder is already using. The lock works perfectly and the system still breaks.
A: A fencing token is a number that increases every time the lock is granted. The client sends it with every write, and the protected resource rejects any token lower than the highest it has seen. This blocks a stale write from a frozen old client, which is the real fix for lock safety. ZooKeeper’s sequential znode number works as a fencing token for free.
A: Use Redis when speed matters and a rare double-hold is tolerable, such as making sure only one worker runs a cron job. Use ZooKeeper when correctness matters more than latency, because it gives automatic crash cleanup and a built-in fencing token. For correctness-critical work, add fencing tokens regardless of which one you pick.
Distributed locking is one of those areas where the simple version works in the demo and fails at 3 a.m. under load. The concepts are not hard, but the failure modes are quiet, and that is what makes the topic tricky. Keep a few ideas with you into any design.
Master this progression, from a naive key to a fenced write, and you will understand distributed locking better than most engineers who have shipped it. More importantly, you will know when a simple lock is good enough, and when you must reach for something stronger. That judgment is the real skill.