Redis Distributed Locks: Fine for Efficiency, Not Enough for Correctness

A Redis lock lease being renewed by a watchdog while a paused client wakes with an expired claim

I keep seeing the same line in design docs and interview answers:

We take a Redis lock with SET ... NX PX, with an expiry so a crashed process cannot deadlock anyone, and Redisson’s watchdog renews the lease while the job runs.

That is a respectable answer. It shows you know how to operate a Redis lock.

But it stops one question short of the one that actually matters:

While you believe you still hold the lock, is it possible the lock already belongs to someone else?

It is. Of course it is. And that gap — not missing an EXPIRE, not forgetting the watchdog — is where Redis distributed locks get genuinely dangerous.

Get the basic lock right first

Most of what people call a “Redis distributed lock” is one conditional SET command. That is not yet a complete lock.

A minimally correct single-instance lock is acquired like this:

SET lock_key unique_value NX PX 30000

That one command does four things at once:

  • NX writes only if the key does not exist;
  • PX 30000 sets a 30-second lease;
  • unique_value identifies this acquisition and must be unique across all clients and all lock requests;
  • acquisition and expiry are set atomically, in a single round trip.

Do not SETNX first and call EXPIRE afterwards. If the process dies between the two commands, the lock may never expire.

Release is symmetric. You cannot just DEL lock_key.

Imagine client 1’s lease expired while it was paused, client 2 acquired the fresh lock, and then client 1 wakes up and runs DEL. It just deleted client 2’s lock.

So release must verify ownership and delete atomically. Redis 8.4 and later provide a native conditional delete:

DELEX lock_key IFEQ unique_value

On earlier Redis versions, use a Lua script:

if redis.call("get", KEYS[1]) == ARGV[1] then
    return redis.call("del", KEYS[1])
else
    return 0
end

Get all of this right and you have a basically usable Redis lock.

But only basically usable.

Expiry solves deadlock and creates a new problem

Why does the lock expire at all?

Because the holder might crash. If locks lived forever, one dead client could stall every other client indefinitely.

Expiry solves that liveness problem.

But the moment you allow automatic expiry, you inherit a safety problem:

The old holder has not stopped working, yet the lease is gone and a new holder has walked in.

Here is the classic timeline.

Client 1 takes a 30-second lease and starts computing. Then it hits a long JVM stop-the-world pause, a container freeze, a SIGSTOP, or a suspended VM — processes can be stopped in more ways than most code plans for. The whole process is frozen for more than 30 seconds.

Redis expires the lock on schedule. Client 2 acquires it and starts touching the same resource.

Then client 1 resumes. Its memory, call stack, and business context are all intact, so it continues right where it left off and issues a write to the database.

Now two clients are mutating the same data.

Notice what is subtle here: client 1 is no longer a legitimate lock holder, sure — but the database has no way to know that. It accepts the stale request like any other.

This is the stale client problem, and it is the heart of the matter.

A watchdog renews leases; it cannot stop time

When an RLock is acquired without an explicit leaseTime, the Redisson watchdog has a straightforward idea: while the client is alive, a background task periodically extends the lease. The default watchdog timeout is 30 seconds, tunable if you need to. If you supply an explicit leaseTime, the lock is released after that interval instead of being renewed by the watchdog.

It solves plenty of real cases: the job runs longer than expected, but the client is otherwise healthy.

The catch is that the watchdog is just more code inside the same client process.

When the whole process pauses, the watchdog pauses with it. When Redis or the network is unreachable for a while, renewals fail. So the watchdog reduces the odds of “my healthy job simply ran slow and lost the lease.” It does not prove:

If a client eventually resumes, it still holds the lock.

Can you just re-check the lock after resuming? Still not enough.

“Check the lock” and “write to the external database” are not one atomic transaction. The check can pass, the client can pause again, and then write with an expired identity anyway.

The lock server can tell you who holds the lock right now. It cannot reach across the network and stop a stale client writing to some other database.

The server can lose your lock too

Clients are not the only failure source. With ordinary primary-replica Redis, there is another important one.

  1. Client 1 acquires the lock on the primary.
  2. The lock has not yet been replicated to the replica — replication is asynchronous.
  3. The primary dies.
  4. The replica is promoted.
  5. Client 2 acquires the same lock on the new primary.

Again, two clients believe they acquired the lock successfully.

This is not a Redis bug. It is the inherent boundary of asynchronous replication.

Client libraries can wait for the lock to reach replicas, trading latency for less failover risk. But that addresses “did the lock get copied” — it does nothing about the previous section’s “expired holder kept working.” Those are two different failures. Don’t blur them together.

Does Redlock settle it?

To stop depending on one instance, or on primary-replica failover, Redis has the Redlock algorithm.

The idea: run several independent Redis primaries — five, typically — and acquire the same lock across them. But “got 3 out of 5, we’re fine” undersells it. A correct Redlock client must also:

  • acquire the majority in a time well below the lock TTL;
  • compute the remaining validity as TTL minus acquisition time minus a clock-drift margin;
  • attempt to release the lock on all instances — including those whose acquisition outcome is uncertain — when acquisition fails overall.

The independent primaries also need explicit crash-recovery semantics. If a node loses a lock during restart and rejoins immediately, a second client may be able to form a new majority while the first lock is still valid. Avoid that with persistence strong enough for the failure model, such as appendfsync always, or by keeping a restarted node out of service for longer than the maximum lock TTL.

Redlock’s safety is famously disputed.

Martin Kleppmann’s critique is that Redlock leans on strong timing assumptions — node clocks advancing at roughly the same rate, network delay, process pauses, and clock drift all bounded relative to the lease. It also does not, on its own, produce a fencing token that downstream systems could use to recognize a stale client.

Salvatore Sanfilippo’s rebuttal is that every auto-expiring distributed lock faces the stale-holder problem; Redlock times the acquisition phase to rule out pathological delay there, and under the assumptions it states, it remains practically safe.

So resist both slogans:

“Five Redis nodes vote, therefore it’s bulletproof.”

and

“Redlock is disproven, never use it.”

The accurate version is:

Redlock is a fault-tolerant locking scheme built on leases and majority quorums, subject to timing, persistence, and crash-recovery assumptions. It is not a linearizable lock from a consensus system.

If your correctness depends on this lock, you have to understand those assumptions — “majority” alone is not an argument.

Fencing tokens: make the resource do the checking

If a lock failing once means one report gets computed twice, fine.

If the lock guards payments, shipments, device control, or anything irreversible, “fails extremely rarely” is not an acceptable answer.

What usually helps is not a longer TTL. It is teaching the final resource to recognize stale requests.

The classic mechanism is a fencing token. Every successful acquisition returns a strictly increasing token:

  • Client 1 acquires the lock with token 33.
  • Client 1 pauses; its lease expires.
  • Client 2 acquires the lock with token 34.
  • The database has already accepted writes bearing token 34.
  • Client 1 resumes and writes with token 33. The database refuses it.

The point is not that the token “is a number.” The point is:

The protected resource must remember the newest token it has accepted and atomically reject anything older.

If the database never checks the token, logging it a thousand times changes nothing.

Plain Redis SET NX PX and Redlock do not hand you a token with those semantics. Single-instance INCR produces increasing numbers, but keeping them from ever going backwards — across failovers, rollbacks, and independent multi-node setups — takes additional consistency work of its own.

Newer Redisson versions ship RFencedLock, which returns a fencing token on acquisition. It is not magic either: the downstream resource still has to actually check it.

ZooKeeper and etcd do not end the story

They do not.

ZooKeeper and etcd give you stronger consistency and clearer session semantics than a Redis primary-replica lock, and they are better fits for coordination work in general.

But they still cannot un-send a request that is already in flight to a database.

A ZooKeeper client that loses its session after a long pause is in exactly the same shape: a new holder has the lock, the old holder wakes up, and it can still talk to a database that has never heard of ZooKeeper sessions.

Stronger lock services improve the judgment of who the current holder should be. The last line of defense for business correctness still usually sits at the resource itself:

  • fencing tokens;
  • database transactions and row locks;
  • version numbers or conditional (CAS) updates;
  • unique constraints;
  • idempotency keys;
  • a single writer or a serialized queue.

Fencing tokens are the classic weapon against stale holders, but they are not the only answer to concurrency correctness.

Overselling should not hinge on one lock

Back to the interview line:

We used a Redis distributed lock to fix overselling.

The risk in that sentence is what it implies: that the Redis lock is the only thing keeping inventory correct.

The inventory invariant is better enforced by the system that actually stores the inventory. For example, a conditional update in the database:

UPDATE inventory
SET stock = stock - 1
WHERE sku_id = ?
  AND stock > 0;

Then check the affected row count to see whether the decrement happened, and layer transactions, unique constraints, and idempotent order IDs on top.

A Redis lock still earns its place here: it trims hot contention, avoids duplicated work, and keeps a flood of doomed requests from hammering the database.

It should not be the only reason stock never goes negative.

A mature high-concurrency design assumes the lock can fail, requests can duplicate, and messages can redeliver — and still lands in a state that satisfies the business invariant.

Efficiency or correctness?

So are Redis distributed locks usable? Of course they are.

The question to answer first is: is this lock for efficiency, or for correctness?

Efficiency

Cases like:

  • several machines not regenerating the same report;
  • one cache key not being rebuilt by ten concurrent misses;
  • a low-value background job not running twice;
  • shedding a burst of load before it reaches a downstream system.

If the lock fails once in a while, you burn some extra compute and the final result is still correct. For these, a single-instance Redis lock is light, fast, and genuinely good enough.

Correctness

If one lock failure can mean:

  • a double charge;
  • the same item sold to two people;
  • two controllers driving one device;
  • an unrecoverable file or state being overwritten;

then “did you enable the watchdog” is not the question. The questions are:

  • What is the deployment topology, and what are its failure semantics?
  • When the lock is lost, what stops the old client?
  • Can the final resource check a fencing token or a version number?
  • Do transactions, conditional updates, and unique constraints actually hold the invariant?
  • Under retries, timeouts, and redelivery, are the operations idempotent?

The answers to those matter far more than the TTL.

Closing

A Redis distributed lock is neither a toy nor a silver bullet. It is a coordination mechanism resting on leases, client behavior, deployment topology, and timing assumptions.

It can tell a group of well-behaved participants: “let this one work, for now.”

It cannot, by itself, order your database, object store, or physical device to never accept a stale holder’s request.

So the next time the topic comes up, don’t stop at SET NX PX, the Lua unlock, and the watchdog. The stronger answer is:

First decide whether the lock is an efficiency optimization or a correctness guarantee. For efficiency, a plain Redis lock is simple and effective. If losing the lock can corrupt money or data, I define the deployment and failure semantics explicitly, and make the final resource reject stale requests — via fencing tokens, version numbers, transactions, or conditional updates. ZooKeeper or etcd can give stronger coordination semantics, but they don’t replace downstream correctness checks either.

If you can explain that clearly, “deep experience with Redis distributed locks” stops being a résumé line and starts being true.

References