Redlock:多个Redis节点上的分布式锁及其争议
1. 概述
当一个服务的多个实例同时处理一个资源时,例如减少Order的库存,你需要一个分布式锁。在单个Redis节点上加锁很简单,但如果那个节点宕机,锁就消失了。Redlock是Redis的作者(antirez)提出的算法,用于在多个独立的Redis节点上获取锁。
本文介绍Redlock如何工作,如何用Jedis自己实现它,如何用Testcontainers编写测试。最后分析Martin Kleppmann和antirez之间的争议。读完后,你将了解何时Redlock足够用,何时应该选择其他工具。
2. 为什么单个Redis节点还不够
在单个节点上的基础锁只需要一条命令:
SET lock:order:42 "c3a1-uuid" NX PX 30000
NX意味着只有当key不存在时才set。PX 30000设置30秒的TTL,使得锁在客户端崩溃时自动过期。值是一个随机token,只有持有锁的客户端才能删除它。
问题在于可用性。如果你使用主从复制(master-replica),可能会出现以下情景:
- 客户端A在主节点上获取锁。
- 主节点在将key复制到从节点之前宕机(Redis复制是异步的)。
- 从节点被提升为新的主节点,上面没有锁key。
- 客户端B获得了相同的锁。
此时A和B同时持有锁。Redlock正是为了处理这种情景而诞生的。
3. Redlock算法
Redlock使用N个独立的Redis节点,这些节点之间没有复制关系和集群关系。通常N = 5。客户端按以下步骤获取锁:
- 记录开始时间。
- 依次向N个节点发送
SET key token NX PX ttl。每个请求都有很短的超时时间(几十ms),以防止宕机节点导致客户端挂起。 - 计算已经过去的时间。当满足以下两个条件时,锁被认为获取成功:
- 在至少
N/2 + 1个节点上成功(法定人数), - 已经过去的时间小于TTL。
- 在至少
- 锁的实际有效时间等于
ttl - elapsed - drift,其中drift补偿节点之间的时钟偏差。 - 如果失败,客户端向所有节点发送解锁命令,包括它认为没有成功获取的节点。
核心思想是两个客户端不能在同一时间同时控制多数节点。
4. 用Jedis实现Redlock
4.1. 依赖
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>5.1.0</version>
</dependency>
4.2. Redlock类
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; // 节点宕机或超时:视为未获取
}
}
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) {
// 节点未响应,key将按TTL自动过期
}
}
}
public record LockHandle(String resource, String token, long validityMillis) {}
}
关键要点:
quorum = N/2 + 1。对于3个节点,quorum是2。对于5个节点,quorum是3。lockInstance捕获异常并返回false。一个节点宕机只会减少获票数,不会破坏整个操作。validity是你被允许认为自己持有锁的时间。这个时间总是小于TTL。- 解锁使用Lua脚本使“比较token然后删除”操作以原子方式执行。如果你分别用
GET和DEL两条命令,可能会误删其他客户端刚刚获取的锁。 - 失败时,
unlockAll向所有节点发送请求。原因是SET命令可能已在服务器端成功,但响应在客户端超时。
4.3. 用短超时初始化节点
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);
最后一个参数是50ms的超时。如果你保留默认超时2秒,一个挂起的节点可能会“消耗”锁的大部分TTL。
4.4. 在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; // 供调用者重试并带回退
}
try {
int stock = repository.findStock(sku);
if (stock < quantity) return false;
repository.updateStock(sku, stock - quantity);
return true;
} finally {
redlock.unlock(handle.get());
}
}
}
解锁始终在finally中。锁定区域内的代码必须在validityMillis内完成。如果操作可能耗时长,你需要锁续期机制(watchdog)或分解任务。
5. 用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();
}
}
第一个测试检查互斥性:B在A持有锁时失败,在A解锁后立即成功。第二个测试显示Redlock在失去3个节点中的1个时仍然工作,因为剩下的2个节点足以形成法定人数。此测试停止了容器,所以你应该将其放在最后,或者分离到一个单独的类中。
6. 那Redisson呢?
实际上,很少有人自己编写Redlock。Redisson曾提供RedissonRedLock,可以接收来自独立客户端的多个RLock。
注意: 在最新的Redisson版本中,
RedissonRedLock和RedissonMultiLock已被标记为deprecated。Redisson建议使用普通的RLock,它有自动续期的watchdog且可以复制到从节点,或者如果你需要fencing token,使用RFencedLock。在选择API之前,请检查你使用的版本。
连最流行的库都放弃了Redlock,这引领我们进入争议部分。
7. 争议:Kleppmann vs antirez
7.1. Martin Kleppmann的论证
《设计数据密集型应用》的作者Kleppmann指出,Redlock对于需要正确性的问题并不安全。主要有两个原因。
进程暂停。 看看以下情景:
t=0 客户端A获取锁(TTL 10秒)
t=1 A被GC停止世界(stop-the-world)15秒
t=10 锁在所有节点上过期
t=11 客户端B获取锁,写入数据到DB
t=16 A苏醒,仍然认为自己持有锁,覆盖B的数据
没有基于TTL的锁算法能阻止这种情景。在写入前检查validity也无济于事,因为pause可能发生在检查之后。导致暂停的原因不仅是GC。页面错误、虚拟机迁移或网络数据包延迟也会造成类似的效果。
时钟依赖。 Redlock假设各节点的时钟以大致相同的速度运行。如果某个节点的时钟发生跳跃,例如由于NTP激进调整或管理员手动修改时间,该节点上的key可能会提前过期。以5个节点为例的具体情景:
A在节点1、2、3上获取锁
节点3的时钟向前跳跃,节点3上的key过期
B在节点3、4、5上获取锁
=> A和B都控制了法定人数
Kleppmann的结论:要保证安全,你需要fencing token,即一个随锁一起递增的数字。Redlock不提供这个,因为它的token是随机字符串。
7.2. Fencing token长什么样
思想是存储本身拒绝带有过期token的写入:
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;
}
}
应用到上面的GC情景:B用token 34写入,A苏醒后用token 33写入。A的UPDATE影响了0行,所以B的数据保持不变。关键是存储而不是锁才能保证正确性。Token必须来自有共识的系统,如ZooKeeper(zxid)或etcd(revision)。
7.3. antirez的回应
antirez用以下要点进行了回应:
- 如果存储已经支持比较并设置(compare-and-set)的token检查,你可以使用随机token本身进行检查,此时锁几乎不再必要。
- Redlock在获取锁之后有一个时间检查步骤,所以获取过程中的网络延迟会被检测到。在那之后发生的暂停确实是所有基于TTL的锁都会遭遇的问题。
- 如果你操作得当,对时钟的假设是合理的:不让NTP突然跳跃(使用slew模式)且不手动调整时间。
迄今为止,还没有一方“绝对获胜”。不过,大多数社区都同意Kleppmann的分类方法,这将在下一部分介绍。
8. 何时使用,何时不使用
问问自己:如果两个客户端同时持有锁会怎样?
- 效率锁(避免重复工作):重复发送报告邮件、重复运行cron任务、重复计算缓存。偶尔的重复无关紧要。这种情况下,一个带有
SET NX PX的Redis节点就够了。Redlock消耗3-5倍的基础设施,而改进微乎其微。 - 正确性锁(错误会破坏数据):转账、扣库存、发号发票。Redlock不够安全。你应该使用etcd或ZooKeeper配合fencing token,或者更好的做法是把约束推到数据库,使用乐观锁定(JPA中的
@Version)、SELECT ... FOR UPDATE或唯一约束。
注意: 使用Redlock时常见的错误:
- 用Redis Cluster或主从复制作为Redlock的“节点”。这些节点必须完全独立。
- 保留过长的默认连接超时。
- 用
DEL而不是检查token的Lua脚本来解锁。- 启用持久化但不配置
fsync=always,或者不延迟节点重启。一个节点重启并丢失key可能导致两个客户端都达到法定人数。antirez建议重启的节点等待至少最大TTL的时间后才重新参与。
9. 结论
Redlock在N个独立的Redis节点的多数上获取锁,并计算有效时间来补偿网络延迟和时钟偏差。你已经看到了如何用Jedis实现它,如何用Lua脚本安全地解锁,以及如何用Testcontainers进行测试。
Redlock仍然依赖TTL和时钟,所以无法防止进程暂停或时钟跳跃。因此,对于仅防止重复工作的锁,一个Redis节点就足够了。对于保护数据正确性的锁,你需要来自共识系统的fencing token,或者让数据库自己维持约束。