Redlock: distributed lock across multiple Redis nodes and the controversy around it
1. Overview
When multiple instances of a service process the same resource, for example deducting inventory for an Order, you need a distributed lock. A lock on a single Redis node is simple, but if that node dies, the lock disappears. Redlock is an algorithm proposed by the author of Redis (antirez) to acquire a lock across multiple independent Redis nodes.
This article explains how Redlock works, how to implement it yourself using Jedis, and how to write tests with Testcontainers. The final section analyzes the debate between Martin Kleppmann and antirez. After reading, you’ll know when Redlock is sufficient and when to choose another tool.
2. Why a single Redis node is not enough
A basic lock on a single node requires only one command:
SET lock:order:42 "c3a1-uuid" NX PX 30000
NX means set only when the key does not exist. PX 30000 sets a TTL of 30 seconds so the lock expires automatically if the client dies. The value is a random token, used so that only the client owning the lock can delete it.
The issue lies in availability. If you use master–replica replication, here’s one scenario:
- Client A acquires lock on the master.
- The master dies before the key is replicated to the replica (Redis replication is asynchronous).
- The replica is promoted to be the new master, and the lock key is not present on it.
- Client B acquires the same lock.
Now both A and B hold the lock. Redlock was created to handle this scenario.
3. The Redlock algorithm
Redlock uses N independent Redis nodes with no replication or clustering between them. Typically N = 5. A client acquires a lock as follows:
- Record the start time.
- Send
SET key token NX PX ttlto all N nodes in sequence. Each request has a short timeout (tens of milliseconds) so that a dead node does not cause the client to hang. - Calculate the elapsed time. The lock is considered successfully acquired when both of the following conditions are met:
- acquired on at least
N/2 + 1nodes (quorum), - the elapsed time is less than the TTL.
- acquired on at least
- The actual validity time of the lock is
ttl - elapsed - drift, wheredriftcompensates for clock skew between nodes. - If it fails, the client sends an unlock command to all nodes, including nodes it thought it did not acquire the lock on.
The core idea is that two clients cannot both hold a majority of nodes at the same time.
4. Implementing Redlock with Jedis
4.1. Dependency
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>5.1.0</version>
</dependency>
4.2. The Redlock class
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.exceptions.JedisException;
import redis.clients.jedis.params.SetParams;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public class Redlock {
private static final String UNLOCK_SCRIPT = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""";
private static final double CLOCK_DRIFT_FACTOR = 0.01;
private final List<JedisPool> nodes;
private final int quorum;
public Redlock(List<JedisPool> nodes) {
this.nodes = nodes;
this.quorum = nodes.size() / 2 + 1;
}
public Optional<LockHandle> tryLock(String resource, long ttlMillis) {
String token = UUID.randomUUID().toString();
long start = System.nanoTime();
int acquired = 0;
for (JedisPool pool : nodes) {
if (lockInstance(pool, resource, token, ttlMillis)) {
acquired++;
}
}
long elapsed = (System.nanoTime() - start) / 1_000_000;
long drift = (long) (ttlMillis * CLOCK_DRIFT_FACTOR) + 2;
long validity = ttlMillis - elapsed - drift;
if (acquired >= quorum && validity > 0) {
return Optional.of(new LockHandle(resource, token, validity));
}
unlockAll(resource, token);
return Optional.empty();
}
public void unlock(LockHandle handle) {
unlockAll(handle.resource(), handle.token());
}
private boolean lockInstance(JedisPool pool, String resource, String token, long ttl) {
try (Jedis jedis = pool.getResource()) {
return "OK".equals(jedis.set(resource, token, SetParams.setParams().nx().px(ttl)));
} catch (JedisException e) {
return false; // node is dead or timeout: consider it not acquired
}
}
private void unlockAll(String resource, String token) {
for (JedisPool pool : nodes) {
try (Jedis jedis = pool.getResource()) {
jedis.eval(UNLOCK_SCRIPT, List.of(resource), List.of(token));
} catch (JedisException ignored) {
// if the node does not respond, the key will expire automatically according to TTL
}
}
}
public record LockHandle(String resource, String token, long validityMillis) {}
}
Key points:
quorum = N/2 + 1. With 3 nodes, quorum is 2. With 5 nodes, quorum is 3.lockInstanceswallows exceptions and returns false. A dead node only reduces the vote count; it does not break the entire operation.validityis the time you are actually allowed to consider yourself holding the lock. This time is always less than the TTL.- Unlock uses a Lua script to make the “check token then delete” operation atomic. If you GET then DEL with two separate commands, you might accidentally delete a lock that another client just acquired.
- On failure,
unlockAllsends to all nodes. The reason is that a SET command might have succeeded on the server but the response timed out on the client side.
4.3. Initializing nodes with short timeout
import redis.clients.jedis.JedisPoolConfig;
List<JedisPool> pools = List.of(
new JedisPool(new JedisPoolConfig(), "redis-1", 6379, 50),
new JedisPool(new JedisPoolConfig(), "redis-2", 6379, 50),
new JedisPool(new JedisPoolConfig(), "redis-3", 6379, 50));
Redlock redlock = new Redlock(pools);
The last parameter is a timeout of 50 ms. If you leave the default timeout at 2 seconds, a hanging node could consume nearly the entire TTL of the lock.
4.4. Using in InventoryService
public class InventoryService {
private final Redlock redlock;
private final InventoryRepository repository;
public InventoryService(Redlock redlock, InventoryRepository repository) {
this.redlock = redlock;
this.repository = repository;
}
public boolean reserve(String sku, int quantity) {
var handle = redlock.tryLock("lock:sku:" + sku, 10_000);
if (handle.isEmpty()) {
return false; // let the caller retry with backoff
}
try {
int stock = repository.findStock(sku);
if (stock < quantity) return false;
repository.updateStock(sku, stock - quantity);
return true;
} finally {
redlock.unlock(handle.get());
}
}
}
Unlock always goes in finally. The code inside the lock must complete within validityMillis. If the operation might take longer, you need a lock renewal mechanism (watchdog) or break the work into smaller parts.
5. Testing with Testcontainers
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.20.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
<version>3.26.3</version>
<scope>test</scope>
</dependency>
@Testcontainers
class RedlockTest {
@Container static GenericContainer<?> r1 = redis();
@Container static GenericContainer<?> r2 = redis();
@Container static GenericContainer<?> r3 = redis();
static GenericContainer<?> redis() {
return new GenericContainer<>("redis:7-alpine").withExposedPorts(6379);
}
static Redlock newClient() {
return new Redlock(Stream.of(r1, r2, r3)
.map(c -> new JedisPool(new JedisPoolConfig(),
c.getHost(), c.getMappedPort(6379), 200))
.toList());
}
@Test
void secondClientCannotAcquireWhileFirstHoldsLock() {
Redlock clientA = newClient();
Redlock clientB = newClient();
var lockA = clientA.tryLock("lock:order:42", 5_000);
var lockB = clientB.tryLock("lock:order:42", 5_000);
assertThat(lockA).isPresent();
assertThat(lockA.get().validityMillis()).isLessThan(5_000);
assertThat(lockB).isEmpty();
clientA.unlock(lockA.get());
assertThat(clientB.tryLock("lock:order:42", 5_000)).isPresent();
}
@Test
void stillAcquiresWhenOneNodeIsDown() {
Redlock client = newClient();
r3.stop();
assertThat(client.tryLock("lock:order:99", 5_000)).isPresent();
}
}
The first test checks mutual exclusion: B fails while A holds the lock and acquires it immediately after A unlocks. The second test shows that Redlock still works when 1 out of 3 nodes is down, because the remaining 2 nodes form a quorum. This test stops the container outright, so place it at the end or move it to a separate class.
6. What about Redisson?
In practice, few people implement Redlock themselves. Redisson used to provide RedissonRedLock, accepting multiple RLocks from independent clients.
Note: In recent Redisson versions,
RedissonRedLockandRedissonMultiLockhave been marked as deprecated. Redisson recommends using ordinaryRLock, which has a built-in watchdog for auto-renewal and can sync to replicas, orRFencedLockif you need a fencing token. Check your version before choosing an API.
The fact that the most popular library has also abandoned Redlock leads us to the debate section.
7. The Debate: Kleppmann vs antirez
7.1. Martin Kleppmann’s argument
Kleppmann, author of Designing Data-Intensive Applications, argues that Redlock is not safe for problems requiring correctness. There are two main reasons.
Process pause. Consider this scenario:
t=0 Client A acquires lock (TTL 10s)
t=1 A pauses due to GC stop-the-world for 15 seconds
t=10 Lock expires on all nodes
t=11 Client B acquires lock, writes data to DB
t=16 A wakes up, still thinks it holds the lock, overwrites B's data
No TTL-based lock algorithm can prevent this scenario. Checking validity right before writing doesn’t help either, because a pause can occur right after the check. The cause of a pause is not limited to GC. Page faults, VM migration, or packet delays on the network can all cause the same effect.
Dependence on clock. Redlock assumes that the clocks on the nodes run at approximately the same speed. If a node’s clock jumps, for example due to aggressive NTP adjustment or manual time changes, the key on that node might expire early. A specific scenario with 5 nodes:
A acquires lock on nodes 1, 2, 3
Clock on node 3 jumps forward, key on node 3 expires
B acquires lock on nodes 3, 4, 5
=> Both A and B hold a quorum
Kleppmann’s conclusion: for safety, you need a fencing token, a monotonically increasing number accompanying the lock. Redlock does not provide this because its token is a random string.
7.2. What a fencing token looks like
The idea is that storage itself rejects writes with old tokens:
public class FencedInventoryRepository {
private final JdbcTemplate jdbc;
public FencedInventoryRepository(JdbcTemplate jdbc) {
this.jdbc = jdbc;
}
public boolean updateStock(String sku, int newStock, long fencingToken) {
int rows = jdbc.update("""
UPDATE inventory SET stock = ?, last_token = ?
WHERE sku = ? AND last_token < ?
""", newStock, fencingToken, sku, fencingToken);
return rows == 1;
}
}
Applying to the GC scenario above: B writes with token 34, A wakes up and writes with token 33. A’s UPDATE affects 0 rows, so B’s data remains intact. The key point is that storage, not the lock, is what ensures correctness. The token must be generated from a consensus system like ZooKeeper (zxid) or etcd (revision).
7.3. antirez’s response
antirez responded with the following main points:
- If storage already supports compare-and-set checks on a token, you can use a random token to check, and in that case the lock is almost unnecessary.
- Redlock has a time check after acquiring the lock, so network delays during lock acquisition are detected. A pause after that step is indeed a problem all TTL-based locks share.
- The clock assumption is reasonable if you operate correctly: do not let NTP jump time suddenly (use slew mode) and do not adjust time manually.
So far, neither side has won absolutely. However, the community largely agrees with Kleppmann’s classification, presented in the next section.
8. When to use, when not to use
Ask yourself: what are the consequences if two clients hold the lock at the same time?
- Efficiency lock (avoiding duplicate work): sending duplicate report emails, running a cron job twice, recalculating cache. An occasional duplicate is harmless. In this case, a single Redis node with
SET NX PXis enough. Redlock costs 3–5 times the infrastructure with negligible improvement. - Correctness lock (mistakes corrupt data): transferring money, deducting inventory, issuing invoice numbers. Redlock is not sufficient. You should use etcd or ZooKeeper with a fencing token, or better yet, push the constraint down to the database using optimistic locking (
@Versionin JPA),SELECT ... FOR UPDATE, or unique constraints.
Note: Common mistakes when using Redlock:
- Using Redis Cluster or master–replica as the “nodes” of Redlock. Nodes must be completely independent.
- Leaving the connection timeout at its default, which is too long.
- Unlocking with
DELinstead of a Lua script that checks the token.- Enabling persistence without configuring
fsync=always, or not delaying node restart. A node restart and key loss can cause two clients to both reach quorum. antirez recommends that a restarted node wait for at least one maximum TTL before rejoining.
9. Conclusion
Redlock acquires a lock on a majority of N independent Redis nodes, with time validity calculations to compensate for network delays and clock skew. You’ve seen how to implement it with Jedis, how to unlock safely with Lua scripts, and how to test with Testcontainers.
Redlock still relies on TTL and clocks, so it cannot protect against process pauses or clock jumps. Therefore, for locks that merely avoid duplicate work, a single Redis node is sufficient. For locks protecting data integrity, you need a fencing token from a consensus system, or let the database enforce the constraint.