好,现在我们来到了分布式锁讨论的核心部分。如果还没读过前一篇,我强烈建议先读一遍,了解加锁和解锁的基本流程。后面我不仅会解释 Redlock 的原理,还会抛出很多与分布式系统相关的疑问。最好能跟着我的思路,在心里一起分析答案。
现在,我们来看看 Redis 创始人提出的 Redlock 方案,如何解决主从切换后锁失效的问题。
Redlock 方案基于两个前提:
不再部署**从库(replica)和哨兵(sentinel)实例,只部署主库(master)**实例
但需要部署多个主库实例,官方建议至少 5 个
换句话说,使用 Redlock 需要部署至少 5 个 Redis 实例,而且都是主节点。它们之间没有关系,各自是独立的实例。
注意:这不是部署 Redis Cluster,而是单纯部署 5 个独立的 Redis 实例。
Redlock 具体怎么用?
整体流程分 5 步:
客户端先获取"当前时间戳 T1"
客户端依次向这 5 个 Redis 实例发送加锁请求(用前面提到的 SET 命令),每个请求都设超时(毫秒级,且远小于锁的有效期)。如果某个实例加锁失败(网络超时、锁被占用等各种异常),立即尝试下一个实例
如果客户端在≥3 个(多数)Redis 实例上成功加锁,就再次获取"当前时间戳 T2"。如果 T2 - T1 < 锁的过期时间,认为加锁成功;否则算失败
加锁成功后,操作共享资源(例如修改 MySQL 某行或调用 API)
加锁失败,向"所有节点"发送释放锁请求(用前面提到的 Lua 脚本释放)
简单总结 4 个要点:
客户端在多个 Redis 实例上加锁
必须保证在大多数节点上加锁成功
在大多数节点上加锁的总时间必须小于锁的过期时间
释放锁时要向所有节点发送请求
可能第一次不太好理解,建议把上面文字多读几遍,加深理解。然后记住这 5 步,很重要。下面的讨论会基于这个流程,分析各种可能导致锁失效的假设场景。
搞懂 Redlock 流程后,我们来看看它为什么这么设计
1)为什么要多个实例加锁?
本质是为了容错。如果部分实例异常崩溃,只要剩余实例成功加锁,整体锁服务依然可用。
2)为什么多数加锁就算成功?
多个 Redis 实例一起,本质上构成了分布式系统。
在分布式系统中,“故障节点"总会存在。因此讨论分布式系统问题时,要考虑系统能容忍多少个故障节点,同时不影响整体系统的"正确性”。这是分布式系统的容错性问题。这类问题的结论是:如果只存在"宕机-停止"型故障,只要多数节点正常,整个系统仍能提供正确服务。
这个问题的模型就是常听的拜占庭将军问题,感兴趣可以看看算法推导。
3)为什么第 3 步成功加锁后,还要计算加锁总耗时?
因为操作多个节点,耗时肯定比单实例长。而且这是网络请求,网络情况复杂,可能有延迟、丢包、超时等,网络请求越多,异常概率越高。因此,即使成功在多数节点加锁,如果加锁总耗时"超过"了锁的过期时间,那部分实例上的锁可能已经过期,整体锁就没意义了。
4)为什么要在所有节点上释放锁?
在某个 Redis 节点加锁时,可能因"网络原因"失败。比如客户端在某个 Redis 实例上成功加锁,但读响应结果时因网络问题出现读取失败。这种情况下,锁其实已经在这个 Redis 实例上设置成功了。
因此释放锁时,无论当初在该节点加锁是否成功,都要在所有节点上释放,确保清理掉节点上任何残留的锁。
好了,理解了 Redlock 流程和相关原理后,看起来 Redlock 确实解决了 Redis 节点异常故障时锁失效的问题,保证了锁的"安全性"。
但真是这样吗?
Redlock 之争:谁对谁错?
Redis 创始人刚提出这个方案,立即遭到分布式系统领域一位著名专家的质疑。这位专家是Martin Kleppmann,英国剑桥大学分布式系统研究员,之前是从事大规模数据基础设施的软件工程师和创业者。他经常出席大会演讲、写博客和书,也是开源贡献者。
他迅速发表文章,挑战 Redlock 的算法模型,提出了自己对分布式锁该如何设计的看法。Redis 创始人 Salvatore Sanfilippo(Antirez)也不示弱,撰文反驳批评,并阐述了更多 Redlock 设计细节。这场交锋在当时互联网上引发了异常激烈的争论。
两位作者逻辑清晰,论据扎实,堪称领域专家的真刀真枪对决,分布式系统领域思想碰撞的盛宴。两人都是公认的分布式系统权威,却在同一问题上得出相反结论。怎么可能?下面我提取并梳理了他们文章中的核心论点。
以下内容为拓展阅读,信息量较大,可能会难消化,建议慢慢看。
分布式系统专家 Martin 对 Redlock 的挑战
Martin 提出了四条主要论点。
1)分布式锁用来干什么?
他说必须先明确目标。用分布式锁只有两个原因:
效率:单纯为了避免重复执行昂贵工作(如重量级计算)而实现互斥。即使锁偶尔失效,只是多发两条警告,烦人但不致命
正确性:需要锁来防止并发进程互相干扰。一旦锁失效,多个进程会同时操作同一份数据,导致关键数据损坏、永久不一致、数据丢失,岂不是相当于给病人重复用药
如果你的目标是前者——效率,单实例 Redis 就够了;偶尔因崩溃或主从切换导致的故障可容忍,Redlock 属于过度设计。但 Martin 认为如果目标是正确性,Redlock 依然不安全:锁可能失效,而你甚至察觉不到。
2)分布式系统中锁会出什么问题?
分布式系统是个不可控的野兽:会遭遇各种意外异常。Martin 把它们归为常见的 NPC trio:
** N **——网络延迟
** P **——进程停顿(如 GC)
** C **——时钟漂移
他用 GC 停顿说明危险:
客户端 1 在节点 A、B、C、D、E 上加锁成功
客户端 1 遭遇长时间 GC 停顿
所有 Redis 节点上的锁 TTL 过期
客户端 2 现在成功在 A、B、C、D、E 上加锁
客户端 1 的 GC 完成,仍认为自己持有锁
客户端 2 也认为自己持有锁 → 冲突
Martin 认为 GC 可能在任何时候发生,持续时间不可预测。
注:即使没有 GC 的语言,如果发生网络延迟或时钟漂移,也会遇到同样问题,Martin 只是用 GC 作为具体说明。
3)"所有时钟都是准确的"是不安全的假设
如果 Redis 节点上的时钟异常,Redlock 同样会失去互斥性:
客户端 1 在 A、B、C 上加锁成功,到 D、E 的包丢了
节点 C 的时钟跳变,导致它的 key 提前过期
客户端 2 现在成功在 C、D、E 上加锁,到 A、B 的包丢了
两个客户端都认为自己拥有锁 → 冲突
Martin 的观点:Redlock 严重依赖所有节点时钟保持同步,一个坏时钟就能毁掉算法。如果 C 崩溃重启而不是时钟跳变,结果也一样。他列举了时钟出错的日常原因:
系统管理员手动重置机器时间
NTP 大幅跳变而非缓慢调整
总之,Redlock 假设了同步系统模型,而分布式系统文献表明这在实际部署中不安全。在混乱的现实世界中不能依赖时钟,必须为异步现实设计。
4)用防护令牌(fencing token)保证正确性
Martin 提出不要依赖时间,而是用防护令牌:
锁服务授予锁时,同时返回一个单调递增的令牌
客户端向共享资源发出的每个请求都带上这个令牌
资源服务器拒绝任何令牌小于已见过最大令牌的请求,保证只有最新的锁持有者能成功
这样,无论 NPC 哪种异常发生,分布式锁的安全性都能得到保障,因为算法建立在异步模型上。相比之下,Redlock 无法提供类似防护令牌的功能,所以无法保证正确性。
Martin 进一步强调,设计良好的分布式锁遇到任何 NPC 故障时,允许错过截止时间,但绝不能返回错误答案。换句话说,牺牲的只能是活性(性能),正确性必须保持完好。
Martin 的结论:
Redlock 是个错配:对效率来说太重,对正确性来说又太弱
不合理的时钟假设:算法做了危险假设——认为不同机器的时钟完美同步。一旦违反,锁就会失效
无法保证正确性:因为 Redlock 无法提供类防护令牌机制,解决不了正确性问题。如果关心正确性,使用提供共识的系统,如 ZooKeeper
以上就是 Martin 质疑 Redlock 的论点,听起来很有根据。接下来看看 Redis 作者 Salvatore Sanfilippo(Antirez)如何回应。
Redis 作者 Antirez 的反驳
Antirez 的文章围绕三个核心点:
1)澄清时钟问题
Antirez 直击核心异议:时钟。
他认为 Redlock 不需要完美同步的时钟——只需要在已知误差范围内大致同步。比如想计时 5 秒,实际可能是 4.5 秒或 5.5 秒。只要累积误差小于锁自动释放窗口,算法就是安全的。这种容差在生产环境是现实的。
关于显式时钟修改,他反驳:
手动调时钟:别这么干就行;如果你愿意手写修改 Raft 日志,同样能破坏 Raft
时钟跳变:做好运维规范,比如用微小步进而非跳变,就能避免大幅跳变,实践中可实现
Antirez 先解决时钟问题,因为他其余的辩护都依赖这个前提。
2)回应网络延迟和 GC 问题
Antirez 反驳 Martin 的场景:网络延迟或长 GC 停顿破坏 Redlock。回顾 Martin 的顺序:
客户端 1 尝试在 A、B、C、D、E 上加锁
客户端 1 加锁成功后遭遇长 GC 停顿
所有 Redis 节点上的锁条目过期
客户端 2 成功在同一组节点上加锁
客户端 1 的 GC 完成,仍认为自己持有锁
客户端 2 也认为自己持有锁 → 冲突
Redis 作者反驳这个假设有缺陷——Redlock 能保证锁安全性。
如何做到?
回顾前面描述的 Redlock 五步流程,重述一下:
客户端获取当前时间戳 T1
向 5 个 Redis 实例发送加锁请求,每个设短超时。任一请求失败立即尝试下一个
如果在多数(≥3)实例上成功加锁,获取 T2。如果 T2 - T1 < 锁 TTL,算成功;否则算失败
成功则操作共享资源
失败则向所有节点发释放请求
关键是第 1-3 步。为什么第 3 步要重取 T2 并比较 T2 - T1 与锁 TTL?
作者强调:如果第 1-3 步期间发生网络延迟、GC 停顿或任何长时间停滞,第 3 步会通过 T2 - T1 检测到。如果耗时超过锁 TTL,就判定失败并释放所有锁。
进一步论证:如果延迟/GC 发生在第 3 步之后,即客户端已确认加锁并正在操作资源,那么锁可能在操作进行中过期。但这不是 Redlock 特有的缺陷,任何锁服务(包括 ZooKeeper)都有同样问题,超出了协议讨论范围。
例子:
客户端通过 Redlock 加锁(多数成功+耗时检查)
开始操作共享资源;发生长 GC/网络停顿
锁自动过期
客户端最终发起 MySQL 更新;锁可能已被别人持有
结论:
锁确认前,Redlock 在第 3 步检测任何长时间延迟
确认后,NPC(网络、停顿、时钟漂移)影响所有分布式锁;Redlock 和 ZooKeeper 都无法阻止
因此,在时钟正确的前提下,Redlock 是正确的。
3)对防护令牌机制的挑战
作者也挑战 Martin 提出的防护令牌方案,提出两点:
首先,这要求共享资源服务器拒绝"旧"令牌。比如更新 MySQL 时从锁服务获取单调递增令牌,附加到行更新中。这要求 MySQL(或事务层)强制隔离小令牌。
UPDATE table T SET val = $new_val, current_token = $token WHERE id = $id AND current_token < $token但如果操作不是针对 MySQL 呢?比如写磁盘文件或发 HTTP 请求,这种方法就无能为力了。这对被操作的资源服务器要求更高。换句话说,大多数需要操作的资源服务器并不具备这种互斥能力。
再者,如果资源服务器已经有"互斥"能力,那还要分布式锁干嘛?
因此 Redis 作者认为这个方案不成立。
其次,退一步说,即使假设 Redlock 不提供防护令牌能力,它仍然提供了一个随机值(即前面提到的 UUID)。用这个随机值,也能实现和防护令牌一样的效果。怎么做?
Redis 作者只提了一句可以实现类似功能,但没展开细节。根据我查到的资料,大概流程如下。如有错误欢迎讨论:
客户端用 Redlock 加锁
操作共享资源前,客户端用锁的 VALUE 标记共享资源
客户端处理业务逻辑,最后修改共享资源时检查标记是否一致,只有一致才修改(类似 Compare-And-Swap 的 CAS 思想)
仍以 MySQL 为例,可能是这样:
客户端用 Redlock 加锁
修改 MySQL 表某行前,先把锁的 VALUE 更新到该行的某个字段(假设叫 current_token)
客户端处理业务逻辑
修改 MySQL 该行数据时,用 VALUE 作为 WHERE 条件,确保 token 匹配才修改
UPDATE table T SET val = $new_val WHERE id = $id AND current_token = $redlock_value可见这依赖 MySQL 的事务机制,也达到了 Martin 提到的防护令牌效果。
不过讨论中仍有网友提出小问题:当两个客户端用这种方式先"标记"再"检查+修改"共享资源时,这两个客户端之间的操作顺序无法保证。而 Martin 提出的防护令牌——既然是单调递增的数字——资源服务器可以拒绝小令牌,从而保证操作的顺序性。
Redis 作者对此给出了不同解释,我觉得很合理。他解释说:**分布式锁的本质是"互斥"。只要能保证两个客户端并发操作时一个成功一个失败就够了,无需关心"顺序"。**在 Martin 的原文批评中,他一直强调这个顺序性问题的重要性。但 Redis 作者持不同看法。
总结一下 Redis 作者的结论:
作者认同对方关于"时钟跳变"对 Redlock 影响的观点,但认为时钟跳变可以避免,取决于基础设施和运维实践。
Redlock 在设计时考虑了 NPC 问题。如果 NPC 发生在 Redlock 流程的第 3 步之前,锁的正确性仍可保证;但如果发生在第 3 步之后,那不单是 Redlock 的问题,其他分布式锁服务同样面临这个问题。因此这类场景不在讨论范围内。
很有意思吧?
分布式系统中,一个看似简单的锁,竟会遇到这么多影响安全性的复杂场景。不知看完双方观点后,你觉得哪边更有说服力?
既然讲完了 Redis 分布式锁的争议,你可能也注意到 Martin 在文章中推荐用ZooKeeper实现分布式锁,声称更安全。但真的吗?
基于 ZooKeeper 的锁安全吗?
熟悉 ZooKeeper 的话,用它实现分布式锁的方式是:
客户端 1 和客户端 2 都尝试创建临时节点(ephemeral node),比如/lock
假设客户端 1 先到——成功获得锁,客户端 2 失败
客户端 1 操作共享资源
完成后客户端 1 删除/lock 节点,释放锁
可见不同于 Redis,ZooKeeper 不用你操心设置锁过期时间。它用临时节点保证:客户端 1 只要连接还在,就能一直持有锁。而且如果客户端 1 意外崩溃,临时节点会自动删除,确保即使故障也能释放锁。
听起来很美妙。不用担心过期,异常自动释放,很完美。
实则并不完全。
想想看,客户端 1 创建临时节点后,ZooKeeper 怎么保证该客户端继续持有锁?原因在于客户端 1 与 ZooKeeper 服务器保持 Session,这个 Session 依赖客户端定期发送"心跳"来维持连接。
如果 ZooKeeper 长时间没收到客户端心跳,就认为 Session 过期,于是临时节点也会被自动删除。同样基于这个问题,我们也讨论下 GC 如何影响 ZooKeeper 的锁:
客户端 1 成功创建临时节点/lock,获得锁
客户端 1 遭遇长时间垃圾回收(GC)
客户端 1 无法向 ZooKeeper 发送心跳,ZooKeeper 删除临时节点(即锁被释放)
客户端 2 成功创建临时节点/lock,获得锁
客户端 1 的 GC 结束,仍认为自己持有锁(产生冲突)
可见即使使用 ZooKeeper,在进程 GC 或网络异常延迟场景下也无法保证安全性。
这正是 Redis 作者在反驳文章中提到的:如果客户端已拿到锁但与锁服务失去连接(如因 GC),那不单 Redlock 有这个问題——其他锁服务同样有类似问题,ZooKeeper 也不例外。
因此可以得出结论:分布式锁在极端情况下未必安全。
如果业务数据高度敏感,使用分布式锁时必须注意这个问题,不能假定分布式锁 100%安全。现在总结用 ZooKeeper 实现分布式锁的优缺点:
ZooKeeper 的优势:
无需考虑锁过期时间
Watch 机制让获取锁失败的客户端能监听锁释放,实现乐观锁方式
ZooKeeper 的劣势:
性能不如 Redis
部署和运维成本高
存在客户端与 ZooKeeper 长时间断连导致锁释放的问题
我对分布式锁的理解
前面详细讨论了用 Redis Redlock 和 ZooKeeper 实现分布式锁在各种异常场景下的安全性问题。现在我想分享个人观点,仅供参考,欢迎理性讨论。
1)该不该用 Redlock?
前面分析过,Redlock 正常工作的前提是系统时钟准确。如果能保证这个前提,可以考虑使用。但在我看来,保证时钟准确不像听起来那么简单。
首先从硬件层面,时钟漂移是常见且不可避免的现象。CPU 温度、机器负载、芯片材质等因素都会导致时钟偏移。其次从工作经验看,我遇到过时钟出错或运维强制修改系统时钟的情况,这会影响系统正确性。所以人为失误也很难完全避免。
因此我对 Redlock 的看法是:能不用就尽量别用。
不仅如此,它的性能比单实例 Redis 差,部署成本也相对较高。我个人会优先考虑用Redis 主从+哨兵模式实现分布式锁。那怎么保证正确性?这就引出下一点。
2)如何正确使用分布式锁?
分析 Martin 的观点时,他提到的防护令牌(fencing token)思路很有启发。虽然这个方案局限性大,但在正确性至关重要的场景下是很好的思路。
因此我们可以结合两种方案:
上层用分布式锁实现"互斥"目标
虽然极端情况下锁可能失效,但它能在最高层拦截大部分并发请求,从而降低底层资源层压力。
但对需要绝对数据正确性的业务,必须在资源层实现"兜底"
设计思路可以参考 fencing token 方案。
两者结合,我认为对绝大多数业务场景已经能满足需求了。