ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

RedLock分布式锁原理与工程实践:多数派机制、时钟陷阱与最佳参数

RedLock分布式锁原理与工程实践:多数派机制、时钟陷阱与最佳参数 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, PXtimeout) 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 ListRedisClient 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才谈得上可用。
返回列表