在单机环境下,我们可以用语言内置的锁机制(比如Java的synchronized或ReentrantLock)来保护临界区资源。但一旦系统扩展到多实例部署,进程内的锁就失去了意义,因为两个进程各自持有自己的锁,根本无法互斥。这时分布式锁就成了刚需,而Redis凭借其高性能成为最常见的实现载体。不过,一个简单的SET NX命令真的足够安全吗?Redlock算法又是如何解决单节点锁的缺陷的?这正是本文要深入探讨的内容。

单节点Redis锁的缺陷与正确写法
很多人实现Redis锁的第一反应是先SETNX再加EXPIRE,这是典型的错误写法。因为这两条命令不是原子的,如果在SETNX成功后、EXPIRE执行前客户端崩溃,这把锁就永远不会过期,造成死锁。正确的做法是使用一条命令完成:
// 正确的加锁方式:一条原子命令
jedis.set("lock:key", "uuid-client-1", SetParams.setParams().nx().px(30000));
// 错误的方式:两条命令,中间可能崩溃导致死锁
// jedis.setnx("lock:key", "uuid-client-1");
// jedis.pexpire("lock:key", 30000);
解锁同样存在原子性问题。如果直接用DEL删除key,可能出现误删他人锁的情况:客户端A的锁过期后,客户端B获取了锁,此时A恢复执行并DEL了这把锁,等于把B的锁删掉了。因此解锁必须校验锁的持有者,而校验加删除需要用Lua脚本保证原子性:
// KEYS[1]为锁的key,ARGV[1]为客户端唯一标识
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
即便写法正确,单节点锁仍有致命弱点:如果Redis主节点在加锁后、数据尚未同步到从节点时宕机,从节点晋升为主节点后并不存在这把锁,另一个客户端可以再次加锁,互斥性被破坏。这就是主从切换带来的锁丢失问题,Redlock正是为了解决这个问题而诞生的。
Redlock算法的完整实现流程
Redlock的核心思想是用多个完全独立、互不复制的Redis实例取代主从架构,通常是5个实例。客户端需要在这5个实例上依次加锁,只有当超过半数(至少3个)实例加锁成功,且总耗时小于锁的有效期时,才认为加锁成功。之所以要求多数派,是因为即使有部分实例宕机,算法依然可以正常工作,这本质上借鉴了Quorum机制。
具体流程如下:客户端记录当前时间T1,依次向5个实例执行加锁操作,每个请求都设置一个很短的超时时间,避免在某个宕机实例上阻塞太久。全部操作完成后记录时间T2,加锁耗时为T2减T1。若成功锁定了至少N/2+1个实例,且耗时小于锁的有效期,则锁真正生效,实际有效时间为锁的初始有效期减去加锁耗时。如果加锁失败,客户端必须向所有实例发起解锁请求,包括那些没有加锁成功的实例,以清理残留的锁。
public String redlock(String key, String requestId, long expireMs) {
int success = 0;
long start = System.currentTimeMillis();
List<String> lockedNodes = new ArrayList<>();
for (Jedis jedis : jedisList) { // jedisList为5个独立实例的连接
try {
String result = jedis.set(key, requestId,
SetParams.setParams().nx().px(expireMs));
if ("OK".equals(result)) {
success++;
lockedNodes.add(jedis.toString());
}
} catch (Exception e) {
// 单个实例失败不影响整体流程,继续尝试下一个
}
}
long cost = System.currentTimeMillis() - start;
// 多数派且耗时未超过有效期,加锁成功
if (success >= (jedisList.size() / 2 + 1) && cost < expireMs) {
return requestId;
}
// 失败时必须清理所有已加锁的实例
unlockAll(key, requestId);
return null;
}
有一个容易被忽略的细节是时钟问题。Redlock的安全性依赖于各实例的系统时钟以大致相同的速率前进。如果某个实例的时钟发生跳变,可能导致锁提前过期或延迟过期,破坏算法的正确性。因此Antirez建议避免使用NTP的步进式时间同步,改用将时间变化平滑分散的方式,这一点在后文的争议部分会详细讨论。
使用Redisson实现Redlock的实战代码
手写Redlock容易出错,推荐直接使用Redisson框架,它对Redlock做了完善的封装。首先引入依赖,然后创建多个独立实例的RedissonClient,用RLock组合成RedissonRedLock。需要注意,Redisson从3.x版本开始逐步弱化RedissonRedLock,新版本建议使用RReadWriteLock或基于Raft协议的RServer,但在3.17之前的版本中它仍是可用的:
// 创建5个独立的Redisson客户端,对应5个无关联的Redis实例
Config config1 = new Config();
config1.useSingleServer().setAddress("redis://192.168.0.1:6379");
RedissonClient client1 = Redisson.create(config1);
// 其余4个实例按同样方式创建,略
RLock lock1 = client1.getLock("order:lock");
// 假设已创建lock2到lock5
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3, lock4, lock5);
boolean acquired = redLock.tryLock(0, 30, TimeUnit.SECONDS);
if (acquired) {
try {
// 执行业务逻辑,例如订单扣减库存
} finally {
redLock.unlock(); // 一定要在finally中释放锁
}
} else {
// 获取锁失败的处理逻辑
}
使用时有两个要点值得注意。第一,锁的过期时间要结合业务最长执行时间设置,如果业务可能超过有效期,应该启动看门狗机制让锁自动续期,Redisson在调用tryLock不指定leaseTime时默认会启用看门狗,默认每10秒续期到30秒。第二,解锁必须在finally块中执行,否则一旦业务抛出异常,锁要等到过期才能释放,期间其他客户端全部阻塞。
Redlock的安全性争议与选型建议
2016年,分布式系统领域的知名学者Martin Kleppmann发表文章质疑Redlock的安全性,引发了一场与Antirez的公开辩论。Martin的核心论点有两个。第一,分布式锁的正确性不应依赖时间假设。即使没有时钟跳变,客户端也可能因为GC停顿、进程暂停等原因在持有锁期间长时间无响应。设想客户端A获取锁后发生长达数秒的Full GC,锁在此期间过期,客户端B获取了同一把锁,随后A从GC中恢复,它并不知道自己曾经失联,继续执行临界区代码,此时两个客户端同时在操作共享资源,锁形同虚设。
第二,Martin指出如果系统对正确性要求极高,Redlock这种依赖多数派却没有任何持久化共识机制的方案并不牢靠;而如果要求没那么高,单Redis实例的锁加一个fencing token反而更简单高效。所谓fencing token,就是每次加锁时获取一个单调递增的序号,资源服务端拒绝携带旧序号的请求,这样即使客户端在锁过期后仍持有旧锁继续写入,也会被资源方识别并拒绝。
那么工程实践中该如何选择?给出三条参考建议:如果业务只是用来防止定时任务重复执行、防止缓存击穿这类对极端场景不敏感的场景,单Redis节点加看门狗续期就足够了,性能好、实现简单;如果需要严格的互斥性和容错性,应该考虑基于共识协议的方案,例如ZooKeeper或etcd的分布式锁,它们通过事务日志保证锁节点的持久性和顺序性,Martin和许多专家都认为这类方案比Redlock更可靠;如果团队技术栈以Redis为主、部署多实例成本可接受,且业务允许极小概率的锁失效,Redlock是可以接受的折中方案,同时尽量在资源侧增加幂等校验作为兜底。技术选型从来不是非黑即白,理解每种方案的失效模式,再结合业务对正确性的容忍程度做决策,才是成熟的工程判断。