RedLock这套分布式锁方案,从2015年提出来到现在,一直是分布式系统里讨论热度最高的算法之一。有人叫它红锁,有人说它没必要,更多人是在生产环境里用出了各种问题才回头翻论文。这篇文章不聊虚的,直接拆RedLock的实现原理,讲清楚每一段设计背后的意图,再说说实际落地时那些文档里不会写的东西。
先说结论:RedLock本质上是把单点Redis的分布式锁扩展成了多节点独立锁的组合判断,用空间换可用性,用“多数派”换安全性争议。它解决的是单实例锁在主从故障切换时会丢锁的问题,但同时也引入了一堆需要你仔细权衡的前提条件。下面从Why开始讲。
1. 为什么需要RedLock:单节点Redis锁到底有什么坑
1.1 单实例SET NX EX的经典用法回顾
在RedLock出现之前,最常规的Redis分布式锁就是单节点操作。加锁就是用一条命令搞定:
SET lock_key my_token NX EX 30000这条命令的含义是:只有当lock_key不存在时才写入,同时设置30秒过期时间。原子性由Redis单命令保证,所以不会出现“先判断再写入”的竞态。解锁时需要Lua脚本,核心逻辑是对比token,一致才删除:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end这个方案在很长一段时间里是主流做法,简单、快、容易理解。但它有一个非常致命的前提:Redis实例必须是可用的,且数据不能丢。
1.2 主从切换丢锁的真实场景
假设你的Redis用了主从架构,客户端A在主节点上加锁成功,写入了一条lock_key。如果这时候主节点突然宕机,哨兵或集群会自动把从节点提升为主节点。问题来了,从节点的数据同步是异步的,A写入的锁数据可能还没同步到从节点。新主节点上没有这条锁记录,客户端B就能加锁成功。
此时A和B都认为自己拿到了锁,分布式互斥被彻底破坏。这在真实生产环境里不是罕见事件,主备切换时丢数据的窗口虽然短,但锁涉及的往往是订单、支付、库存这类不能出错的资源,出一次就是事故。
有人会说,那用WAIT命令强制同步保证数据不丢行不行?WAIT确实能让写入结果同步到从节点再返回,但主节点本身宕机时,客户端发起的请求可能还没到达主节点,同样存在丢锁可能。而且WAIT会显著增加延迟,在高并发场景下不太现实。
单实例Redis锁的脆弱性,本质上是对“单个数据副本”的过度信任。RedLock的思路就是不再信任单个节点,而是让锁同时写入多个独立的Redis节点,只要多数节点成功,就认为锁被拿到了。
2. RedLock算法流程逐段拆解
2.1 五节点模型的由来
RedLock的标准建议是5个独立的Redis节点。为什么是5?因为它能容忍最多2个节点故障。少于3个节点成功就无法组成多数派,锁就获取失败。相比之下,3节点方案只能容忍1个节点故障,6节点也是容忍2个,但没有显著收益还会增加延迟。奇数在多数派算法里通常比偶数更高效,因为偶数节点在脑裂时可能出现“平局”需要额外处理。
这5个节点必须互相独立,不能有主从关系、不能共享复制、不能部署在同一台物理机。如果在同一个机器上跑5个进程,那和单点没有任何区别。
2.2 获取锁的完整步骤
RedLock获取锁的核心流程是这样的:
第一步,客户端获取当前毫秒级时间戳,记为T1。
第二步,依次向5个节点发送加锁命令,命令格式和单节点一样:
SET lock_key unique_token NX PX 50000注意这里的过期时间要留出足够的余量,通常建议是锁自动释放时间至少是正常业务耗时的5到10倍。unique_token是一个全局唯一的随机字符串,用于解锁时验证身份。
第三步,客户端计算整个过程用掉了多少时间。获取所有节点响应后,用当前时间减去T1,得到总耗时。如果总耗时小于锁的有效时间,且至少3个节点返回成功,就判定加锁成功。
第四步,如果加锁成功,真正持有锁的剩余时间 = 锁有效时间 - 耗时。
计算方式如下:
有效锁时间 = 预设过期时间 - (当前时间 - 开始加锁时间)假设预设过期时间是50000毫秒,加锁过程用了500毫秒,那真正能用的锁时间是49500毫秒。业务代码必须在49500毫秒内执行完并释放锁,否则锁会被自动释放。
第五步,如果加锁失败(成功节点数不足3个,或者过程耗时超过了锁有效时间),客户端需要主动向所有节点发送解锁命令,清理掉可能已经写入的锁记录。这一步很多人会忽略,直接失败就退出,结果残留的锁记录要等自动过期才能清掉,白白增加下一次加锁的等待时间。
整个流程的伪代码如下:
function acquireLock(lockKey, token, timeout, nodes): startTime = now() successCount = 0 for each node in nodes: try: result = node.SET(lockKey, token, NX, PX=timeout) if result == "OK": successCount++ except: continue elapsed = now() - startTime if successCount >= len(nodes) / 2 + 1 and elapsed < timeout: return true, timeout - elapsed else: releaseLock(lockKey, token, nodes) return false, 02.3 为什么必须用独立token
锁的值必须是一个全局唯一且无法预测的token,不能用一个固定的常量。原因很简单:解锁时要先校验token再删除,如果所有客户端都用同一个值,A客户端就能删除B客户端的锁。
更隐蔽的一个问题是,如果Java客户端用了UUID,一定要确保多个实例之间的UUID不会重复。理论上UUID碰撞概率极低,但为了避免极端情况,我见过有人用“UUID + 机器IP + 线程ID + 自增序号”组合生成,代价不大,图个安心。
全局唯一token的校验还有一个作用:防止误删别人的锁。比如客户端A加锁成功,但业务执行时间超过了锁的有效期,锁被自动释放。客户端B拿到锁开始干活。A干完活回来执行解锁,如果解锁不校验token,就会直接把B的锁删掉,导致B在无锁保护状态下继续操作。用token校验,A删除时发现锁值不是自己的,直接放弃删除,B不受影响。
2.4 释放锁为什么必须是Lua脚本
释放锁的Lua脚本在前面已经贴过了,这里补充解释为什么这个脚本的内容是必须的:
if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del", KEYS[1]) else return 0 end对比GET和DEL必须放在同一个原子操作里执行,否则会出现竞态。如果不使用Lua脚本,先GET判断,再DEL删除,两步之间存在时间窗口。在这个窗口内锁可能已经过期,被另一个客户端拿到,当前客户端再执行DEL就会删掉别人的锁。
使用Lua脚本后,Redis会保证脚本内所有命令的原子性,不会插入其他命令的执行。释放锁的时间和加锁时设置的过期时间无关,只要token匹配就立即删除。
2.5 重试策略的合理设计
RedLock在获取失败后如何处理,原论文只提了一句“建议客户端等待随机时间后重试”,但真正落地时必须想清楚细节。
首先,客户端不应该以过高的频率向所有节点发送加锁请求。每次加锁都是至少3个节点的网络交互,全失败时还要再发一轮解锁。如果业务并发很高,失败后的重试风暴甚至可能把Redis压垮。
我习惯的做法是:第一次加锁失败后,等待一个随机时间,范围在50毫秒到200毫秒之间。这个随机值是为了避免多个客户端同时重试,形成同步的请求拥堵。重试次数建议控制在3到5次以内,超过次数直接返回失败,而不是无限循环。
另外要做到”快速失败“。如果客户端在加锁过程中发现某个节点连接超时,不要长时间等待,直接记录为失败,立即尝试下一个节点。因为整个加锁的耗时非常关键,超时会吃掉锁的有效时间。举个例子,预设锁时间是50秒,第一个节点连接超时耗了30秒,即使后面4个节点全部成功,整个过程耗时41秒,已经接近锁有效期,此时判定加锁成功也是不可靠的。正确的做法是给每个节点单独设置短超时,比如50毫秒或100毫秒,整体加锁最坏情况也就是几百毫秒。
3. 时钟与GC问题:RedLock最主要的争议点
3.1 为什么说它依赖时钟
RedLock判断锁是否有效时,用了一条隐式的时间假设:所有Redis节点的时钟是基本一致的,或者至少不会有明显偏差。
回到获取锁的流程:客户端成功写入3个节点后,计算总耗时。这里用到的当前时间来自客户端本地时钟。而节点上锁的过期时间,依靠的是Redis服务器自身的时钟计算。客户端判断“耗时是否小于锁有效时间”时,实际上在拿客户端时钟和节点时钟做比较。
如果某个Redis节点的时钟往前跳了,比如通过NTP同步导致时钟步进,节点上的锁会提前过期。客户端A持有锁的时间还在业务有效期内,但锁已经从节点上消失了。此时客户端B加锁成功,两个客户端同时操作共享资源,锁的安全性被破坏。
反过来,如果节点时钟向后跳,锁的过期时间会被延长。虽然不会直接导致两个客户端同时持有锁,但会带来一个隐患:持有锁的客户端异常崩溃后,它的锁迟迟不释放,其他客户端需要等待更长的时间才能加锁成功。
RedLock原论文中明确写了算法正确性依赖于各个节点的时钟没有大的跳跃,但这在真实生产环境中并不容易保证。尤其是使用云主机时,时间同步机制不可控,Docker容器的时钟漂移也是老问题。
3.2 多个结论,不要迷信单一方案
业内对RedLock的批评主要集中在两个方向:
一个是时钟依赖。Martin Fowler在一篇经典回复中提到,RedLock是“基于时间戳的分布式算法”,它不能像ZooKeeper那样依赖单调递增的zxid来保证锁的公平性和安全性,而是把所有安全建立在”时间不会乱跳“这个前提上。
另一个是GC停顿问题。客户端A加锁成功后,业务代码还没执行,JVM发生了一次长时间GC停掉,比如50秒,锁的有效期只有30秒。锁在GC期间自动过期了,客户端B成功加锁并开始操作同一份资源。A的GC结束后继续执行业务,两个客户端就都在跑。这种情况不管用了几个Redis节点都无法避免,因为它发生在客户端进程内部。
这不是说RedLock就完全不能用,而是说任何基于Redis的锁,本质上都是“尽力而为”的互斥方案。它能挡住大部分并发冲突,但对极端场景(时钟跳跃、长GC停顿)需要业务侧做额外的兜底,比如数据库乐观锁、幂等性设计、版本号控制之类的多重保障。
3.3 与ZooKeeper锁的对比取舍
如果有人问ZooKeeper的锁和RedLock谁更可靠,通常我会说:如果你想用分布式锁保护已经存在的资源,ZooKeeper的整体模型更有利于一致性;但如果你追求的是性能和可用性,RedLock更合适。
ZooKeeper锁的核心是创建一个临时顺序节点,客户端按序号排队获取锁。临时节点会和会话绑定,会话超时则节点自动删除,锁自动释放。ZooKeeper集群内部通过ZAB协议保证数据一致,写请求必须多数派确认后返回,所以不会出现RedLock那种“主从切换丢锁”的问题。
但ZooKeeper锁的代价也很明显:延迟更高,每秒写入吞吐量远低于Redis,创建和删除节点都要走一次多数派协商。对高并发、对性能敏感的短任务,ZooKeeper方案往往会成为瓶颈。
RedLock适合的场景是:
- 锁持有时间短
- 业务本身对极小概率的双执行有容忍兜底
- 重视性能,已有Redis节点不想额外引入ZooKeeper集群
ZooKeeper锁适合的场景是:
- 锁是核心依赖,必须严格互斥
- 业务方愿意接受更高的延迟
- 已经有ZooKeeper基础设施
工程上没有银弹,别在选型阶段就把所有精力花在争论算法安全性上,更重要的是评估你的业务场景里,锁失效后的影响到底有多大,以及有没有补偿机制。
4. 实操中的参数选择与代码骨架
4.1 锁有效时间怎么定
锁的有效时间,也就是PX参数,是RedLock里最容易被拍脑袋的参数之一。定短了,业务没跑完锁就过期;定长了,客户端崩溃后其他客户端要等很久。
我的经验是:先统计业务正常执行时间的P99值,然后用这个值乘以5到10倍作为锁过期时间。比如业务99%的情况下在2秒内完成,锁过期时间设为10到20秒。这背后的逻辑是:锁过期只是最后一道保险,正常情况下业务应该主动释放锁。但如果业务发生超时、网络卡顿、GC停顿,锁还要能给其他客户端让路。
定得太长的一个反例是:有人把锁过期时间直接设成5分钟,结果业务代码里有个远程调用不稳定,偶尔卡住三分多钟,其他客户端在高峰期等到崩溃。这其实是把锁过期时间当成了变相的请求超时来用,完全丧失了互斥的意义。
4.2 网络超时参数必须单独设置
前面提到加锁过程中要设置较短的Per-Node超时时间,这里单独展开说。
如果没有给Redis客户端配置超时时间,默认情况下很多客户端会等待很久,比如默认socketTimeout设置为0(无限等待)。假设5个节点中有一个节点因为网络分区不可达,加锁命令在这个节点上一直阻塞,整个加锁流程就会被卡死。
我实际踩过这个坑:有一个节点短暂内存过高,命令处理变慢,加锁请求等了将近10秒才返回。当时锁的过期时间设的是15秒,另外4个节点早就返回成功了,就是这个慢节点拖了后腿,最后算下来总耗时接近锁的有效期。代码里再一判断耗时小于锁有效时间,勉强通过,但实际能用的锁时间已经所剩无几。
正确的参数清单如下:
单节点加锁超时: 100ms 单节点连接超时: 50ms 重试等待时间: 50~200ms随机 整体最大重试次数: 3~5次4.3 一个可运行的Java骨架
实现RedLock不一定要引入Redisson,核心逻辑用原生Redis客户端也能写出来。下面给一个简化版的骨架,用Lettuce或Jedis都能适配。
public class RedLockClient { private static final int NODE_COUNT = 5; private static final int LOCK_TIMEOUT_MS = 30000; private static final int CONNECT_TIMEOUT_MS = 100; private List<RedisClient> nodes; public boolean tryLock(String key, String token, long ttlMs) { long start = System.currentTimeMillis(); int successCount = 0; for (RedisClient node : nodes) { try { String result = node.set(key, token, SetOption.SET_IF_ABSENT, SetOption.SET_WITH_EXPIRE_TIME, ttlMs); if ("OK".equals(result)) { successCount++; } } catch (Exception e) { // 记录节点异常,继续尝试下一个 } } long cost = System.currentTimeMillis() - start; if (successCount >= NODE_COUNT / 2 + 1 && cost < ttlMs) { return true; } // 失败主动清理所有节点上的锁 releaseLock(key, token); return false; } public void releaseLock(String key, String token) { for (RedisClient node : nodes) { try { node.eval("if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end", key, token); } catch (Exception e) { // 忽略解锁异常 } } } }这个骨架只展示了最核心的逻辑,生产环境至少要加两部分:
一是把每次锁的状态输出到日志和监控。获取锁成功、失败的次数、每次获取耗时、每个节点的响应时间,都要记录下来。之前有一位读者跟我反馈,他们上RedLock后有个诡异现象——锁经常获取失败,后来通过监控发现其中一个Redis节点在高峰期响应时间不稳定,平均300毫秒,拖垮了整个加锁过程。如果没监控,这类问题排查起来极其痛苦。
二是把锁的获取和释放纳入统一的封装,最好做成一个模板方法。因为业务代码容易出现“获取锁成功后,finally块里忘了释放”的问题。封装成模板后,释放锁的逻辑固定执行,降低人为出错概率。
public void withLock(String key, long ttlMs, Runnable action) { String token = UUID.randomUUID().toString(); boolean locked = tryLock(key, token, ttlMs); if (!locked) { throw new LockAcquireException("acquire lock failed"); } try { action.run(); } finally { releaseLock(key, token); } }4.4 Redisson的看门狗机制能不能救
Redisson是Java生态里最流行的Redis客户端,它提供了现成的分布式锁实现,并带一个叫WatchDog的看门狗机制。默认情况下,Redisson的锁过期时间被设为30秒,同时每10秒会为持有中的锁续期一次,保证业务没执行完时锁不会自动过期。
看门狗机制看起来很美,但它不能解决RedLock的核心问题。它的续期是作用于单个Redis节点上的锁,如果这个节点故障,锁照样丢。Redisson也支持RedLock,称为RedissonRedLock,但它内部的看门狗只能续期所有节点上已经获取的锁,如果有节点宕机导致多数派失效,锁安全性依然无法保证。
所以不要把看门狗当成兜底方案。锁过期时间还是应该按照业务执行时间的P99加安全余量来设置,而不是依赖看门狗无限续期。看门狗只在你忘记主动释放锁时提供一个慢速保护,它替代不了合理的业务设计。
5. 常见问题与排查技巧实录
5.1 加锁全部成功但业务仍冲突
现象:RedLock返回true,两个客户端同时进入临界区,业务出现并发问题。
排查步骤:
第一步,先确认5个节点是否都返回了成功的SET结果。有一种情况是某个节点上本来就有残留的锁,比如上一次客户端崩溃前写入的锁没自动清理干净,新客户端在写入时应该得到nil结果,但旧锁过期时间较长,新客户端判定失败。如果代码中把节点失败异常也算作成功,就可能出现假成功。
第二步,检查锁内的token是否为同一个值。如果每次加锁都生成同一个token,两个客户端实际持有完全相同身份的锁,那么释放锁时的校验就形同虚设。
第三步,确认时钟是否一致。五节点中的某个节点时钟跳变,可能导致锁提前过期。把五节点和客户端的时钟都拉出来对比,偏差超过几百毫秒就要处理。
5.2 解锁时报错或解锁超时
现象:业务执行完成,调用释放锁,但日志显示某些节点的DEL命令超时或报错。
原因通常是:释放锁的Lua脚本在个别节点上执行时间过长,或节点本身已经不可用。这时候不要立刻宣布解锁失败,应尝试重试一次。如果重试仍失败,记录日志并继续,因为锁的过期时间总会兜底,不会造成永久死锁。
另一个容易忽略的错误是:释放锁时传错了token,导致Lua脚本的匹配失败。如果只有一个节点匹配失败,还可能是节点数据不一致;如果所有节点都失败,基本可以断定token确实不一致,得回查业务调用链路里有没有产生新的token覆盖了原token。
5.3 锁获取失败的占比过高
现象:业务高峰期,大量客户端拿不到锁,接口报错率上升。
优先检查各节点的load。如果某个节点的CPU或内存偏高,SET命令响应时间变长,会直接影响整体加锁耗时,进而导致耗时大于锁的有效期而判定失败。其次是检查客户端到节点之间的网络延迟,尤其在跨机房部署时,网络往返时间本身就有几十毫秒。如果加锁总耗时300毫秒,而锁有效期只有200毫秒,加锁必失败。这种问题本质上不是RedLock有问题,而是节点部署架构有问题,应该把Redis节点就近部署到客户端附近。
5.4 数据清除不完全,残留锁堆积
现象:Redis内存里出现大量未被清理的lock_key,占用了内存空间。
根本原因是加锁失败后,客户端没有执行释放锁的清理步骤,或者释放锁时只对成功节点执行了解锁,漏掉了失败节点上已经写入的锁记录。
解决方法是,在加锁失败的分支里,不管成功了几台,都要遍历所有节点发解锁命令。用上面给的伪代码可以看出来,释放锁的循环一定是遍历全部节点,而不是记着一开始成功的节点列表。这块代码写的时候要小心,别把节点集合缩小了。
6. 工程上的最终建议
RedLock是一个能用的算法,但它不是一个开了开关就能保证不出问题的方案。它的正确性依赖一套条件:节点独立、时钟稳定、网络可控、客户端不会长时间STW。这些条件在很多时候是满足的,但一旦有例外,你就需要业务侧的兜底。
我的个人经验是,不要把RedLock当成分布式安全的最后一道防线。用它来挡正常的并发冲突,效果很好,性能和实现复杂度都能接受。但如果你的业务场景是扣库存、转账、秒杀这种绝对不能双执行的,建议再叠一层数据库侧的约束,比如唯一索引、乐观锁版本号、或者状态机的状态流转检查。Redis分布式锁只解决问路谁能进临界区的调度问题,解决不了临界区本身的数据校验问题。
还有一点,生产环境的RedLock一定要放监控。锁获取成功率、耗时分布、节点响应时间、锁释放异常次数,这些指标比锁本身的算法讨论重要一百倍。没有监控的分布式锁,出了问题你连从哪查起都不知道。
最后分享一个小技巧。RedLock的5个节点可以做成懒加载模式,第一次请求时创建连接池,而不是在应用启动时全部连好。这样能减少对启动速度的影响。另外在运维执行节点更换或扩缩容时,新增的节点要先压测响应时间,确认不会成为整个加锁流程的短板。
RedLock的应用者往往关注它的多数派设计,觉得3/5这个门槛保障了安全。但从真实经验看,真正让RedLock在工程中失效的,多数不是多数派计算本身的逻辑,而是时钟不一致、客户端超时设置不当、业务代码未处理锁过期这些看起来不起眼的小问题。把这些控制住了,RedLock才谈得上可用。