Redis分布式锁从原理到实践:避开SETNX、主从切换与Redlock的坑
1. 先想清楚这个锁到底要锁什么做后端几年Redis 分布式锁算是我见过讨论最多、也最容易翻车的中间件话题之一。很多团队一上来就写SETNX然后直接上线等到线上出现重复扣款、重复下单了才开始排查。其实问题的根子不在这条命令而在你有没有想清楚分布式锁到底在解决什么问题。1.1 单体应用里锁的样子在单机应用里多个线程同时操作同一个变量或资源时我们通常用synchronized或ReentrantLock。这类 JVM 内建的锁只能约束当前进程内的线程。一旦应用被拆成多个实例或者原本的多线程改成多个进程问题就变了同一时刻可能有两个进程同时执行同一段代码。A 实例和 B 实例各自内部的锁互相看不到synchronized无法跨进程互相排斥。分布式锁要解决的正是这种跨进程、跨实例的互斥问题。它不是替代单体锁而是把“在同一时刻只有一个客户端能持有临界区访问权”这个约束从单进程内提升到整个集群范围内。很多人容易在这里犯一个概念错误以为用了分布式锁就万事大吉。实际上分布式锁只能保证“大多数时候大家都不越界”。如果业务代码本身不考虑幂等、不对结果做校验锁被意外释放后依然可能出问题。所以第一步不是写代码而是判断你的业务到底需不需要分布式锁。1.2 分布式锁的基本条件分布式锁不管用什么存储实现基本都要满足几个条件互斥性任意时刻最多只有一个客户端持有锁。可重入性按需同一个客户端在持有锁时再次加锁应该能成功否则容易出现死锁。防死锁客户端崩溃后锁必须在有限时间内自动释放不能永久占用。安全性只有持有锁的客户端才能释放锁不能把别人持有的锁误删。高可用锁服务自身不能成为单点存储节点出问题后要能快速恢复。性能加锁、解锁操作必须足够快不能比业务本身的耗时还高几个数量级。这些听起来都是常识但实际踩坑的大多出现在“防死锁”和“安全性”这两条。防死锁需要设置过期时间安全需要校验持有者身份。两个需求叠加在一起就要求加锁、校验、释放这三个动作必须保持原子性而这正是 Redis 实现里最容易埋雷的地方。1.3 为什么偏要用 Redis分布式锁的实现方案很多常见的有基于数据库唯一约束、基于 ZooKeeper 临时节点、基于 etcd 的租约机制以及基于 Redis 的占位 key 方式。Redis 之所以在绝大多数业务里胜出原因是简单直接它性能极高加锁解锁一次通常不到一毫秒SET NX PX一条命令就能完成“占位过期”两个操作Redis 本身很多公司已经在用了不需要额外引入新组件。相对于关系数据库的行级锁Redis 对连接、并发和超时控制都更友好。相对于 ZooKeeper 或 etcdRedis 在运维成本和集群规模上的负担更小。但性能好的工具往往会把问题的严重性掩盖得越深。Redis 的分布式锁有几个与生俱来的缺陷锁过期导致并发穿透、主从切换导致锁丢失、GC 停顿导致锁误释放、Redlock 方案在业务可行性上存在争议。接下来我会先把正确的实现方式讲透再把这些缺陷一个不落地拆开看。2. 基于 Redis 的两种主流实现2.1 用 SET NX EX 一条命令加锁先看最简单也最正规的加锁命令注意这里不是用SETNX而是用弱化了的SET命令SET lock:order 550e8400-e29b-41d4-a716-446655440000 NX PX 30000含义很好理解只有当lock:order这个 key 不存在时才设置它value 用一段全局唯一的随机串PX 30000表示 30 秒后自动过期。为什么不用老旧的SETNX因为SETNX是先设置再单独设过期时间两步之间没有原子性。如果客户端在设置了 key 之后、设置过期时间之前崩溃这个锁就永远没人能释放形成死锁。value 为什么必须是随机串不能写死成1因为释放锁的时候要做归属校验。如果不校验可能会出现下面这种事故实例 A 持有锁业务执行超时锁自动过期实例 B 拿到锁开始干活A 业务终于跑完执行DEL lock:order把 B 持有的锁给删了。这个动作会导致 B 的临界区失去保护AB 两边同时操作同一份数据。解决办法是删除之前先确认 key 的 value 是不是自己加锁时写的那个随机串是自己的才删不是就别动。网上有些文章会把随机串写成 UUID这个没问题。但如果你准备做可重入锁建议在随机串里拼上线程标识例如UUID : 线程ID。这样同一个线程再次加锁时可以从 value 里识别出自己的身份方便做重入计数。2.2 用 Lua 保证解锁原子性判断 value 和删除 key 这两个动作必须放在同一个原子操作里。如果先GET再DEL中间一旦发生其他客户端抢锁又会删错。Redis 官方推荐的解锁方式是 Lua 脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end执行时传入EVAL ... 1 lock:order 550e8400-e29b-41d4-a716-446655440000Lua 脚本在整个执行期间Redis 不会穿插处理其他命令所以“取值、比较、删除”天然是一个不可分割的整体。这也是我在项目里要求所有团队成员必须遵守的规范加锁用SET NX PX解锁用 Lua 脚本禁止出现“先 GET 后 DEL”的写法。如果你用的是客户端封装库很多库内部已经做了这层处理。但理解底层脚本仍然非常重要因为排查问题时你会需要看懂别人的封装到底干了什么以及你自己手写的脚本是不是足够严谨。例如有人会把比较条件写成tonumber(KEYS[1])之类完全没有必要value 是随机字符串就比字符串。2.3 用客户端库处理续期前面那个 30 秒过期时间有一个明显的隐患如果业务执行时间超过 30 秒锁到期自动释放后面的线程就会趁机进来。部分成熟的 Redis 客户端库提出了“看门狗”方案比如 Java 生态里常见的 Redisson 封装。它的核心逻辑是加锁成功后如果业务还没结束客户端会启动一个后台定时任务在锁的过期时间快到时自动续期。默认租约 30 秒每 10 秒检查一次如果锁仍然被当前客户端持有就把过期时间重新拉回 30 秒。客户端如果崩溃定时任务也随之停止锁会在租约到期后自然释放。看门狗方案能解决“锁被普通过期时间打断”的问题但不要把续期当成万能保险。续期只能说明客户端进程还活着不说明业务线程没有被长时间停顿。如果业务线程发生 Full GC停顿了 40 秒看门狗线程仍然在续期锁照样会被续住。这时锁倒是没被别人抢走但持有锁的线程实际并没有在处理业务整个系统的吞吐和延迟会被这种虚假占用拖垮。更严重的情况是业务线程从 GC 中恢复后以为自己还持有锁继续执行写操作而锁其实已经在某个环节被释放了双方并发写入数据照样出问题。所以我的建议是客户端库的看门狗可以作为默认选项但业务上始终要保留幂等校验和版本校验作为最后防线。锁是互斥手段不是数据正确性的唯一保障。3. 绕不开的缺陷与风险3.1 锁过期导致的并发穿透这是 Redis 分布式锁最经典的缺陷。假设锁的过期时间设为 5 秒业务临界区正常运行需要 2 秒正常情况下没问题。可一旦业务所在实例发生长时 GC、数据库慢查询、网络抖动2 秒变成 8 秒锁在第 5 秒就释放了。另一个客户端 B 立刻拿到锁进入临界区。此时两个客户端同时运行同一段业务代码分布式锁形同虚设。有一种误以为把过期时间调大比如设成 30 分钟就能避免这种问题。这不叫解决问题只是把问题出现的时间延后同时带来了更严重的副作用如果客户端在持有锁过程中崩溃且没有看门狗续期锁要等到 30 分钟之后才释放业务会被卡死。更合理的思路是组合使用三层机制根据业务耗时的压测分布设置合理的租约时间而不是随便拍一个值。开启看门狗续期让存活线程能持续持有锁。在写数据时加入幂等主键、版本号或者时间戳防止旧线程把新数据覆盖成过期数据。第三点值得重点解释。锁过期后旧线程 A 最终还是会把业务执行完并落库。此时 B 可能已经把新数据写进去了。如果落库操作是“无脑 UPDATE”A 就会用自己读到的旧快照覆盖 B 的新写入。这种情况下锁解决不了问题必须依靠版本号或条件更新。简单说分布式锁负责“别让我俩同时跑”版本号负责“就算同事跑了也不能覆盖我的成果”。3.2 节点故障与主从切换问题Redis 分布式锁的第二个知名缺陷是主从切换时锁会丢。标准流程是客户端向 Redis 主节点写入锁 key主节点异步同步给从节点。假如客户端 A 刚在主节点写入锁主节点还没来得及把数据复制给从节点主节点就宕机了。哨兵或者集群选中一个从节点提升为新的主节点但这个新的主节点里没有 A 写入的锁 key。客户端 B 来加锁时新主节点认为锁不存在直接加锁成功。于是在一瞬间A 和 B 都认为自己持有锁。这只是最典型的一种情况实际中还会出现更隐蔽的并发窗口A 加锁成功后主节点网络分区、切换B 从新主节点拿到锁等 A 恢复过来它并不知道自己的锁已经在副本上失效继续执行写操作。很多团队碰到这种问题后第一反应是升级到 Redis Cluster以为集群模式能解决。真实情况是Cluster 的复制同样是异步的主节点切换时依然存在这个窗口。Cluster 解决的是容量和高可用不是一致性问题。针对这个缺陷能采取的补救措施不多一是对写入操作做最终一致性校验二是在极端重要的业务上不要使用 Redis而是改用具备强一致性语义的协调服务三是尽量不要把锁服务和一个普通的缓存 Redis 混用因为缓存场景的丢数据容忍度与锁场景完全不同。3.3 时钟跳跃和 GC 停顿Redis 的 key 过期依赖服务器本地时间。如果 Redis 节点的系统时间发生回拨那么原本应该过期的锁可能会晚几分钟才过期期间其他客户端一直拿不到锁。如果时间往前跳锁又会提前过期导致并发穿透。生产环境中 NTP 同步偶发的时间跳跃并不罕见尤其是虚拟化环境里。客户端这边的 GC 停顿则是另一个容易被甩锅给 Redis 的问题。假设 A 持有锁后线程进入 Full GC停顿 10 秒。锁的过期时间是 5 秒锁早就释放了B 线程进来拿到锁。等 A 的 GC 结束它开始继续执行临界区代码但它根本不知道自己的锁已经失效。这时候 A 和 B 同时写入数据。这类问题既不是锁过期设置得不对也不是 Redis 不稳定而是客户端进程的暂停超出了租约时间的控制范围。如果客户端所在 JVM 有长 GC 风险最好的办法是给每个写操作分配一个单调递增的 fencing token。加锁时从锁服务取一个递增序号后续的写请求必须携带这个序号。数据端数据库或消息队列记录最新处理过的序号只接受序号更大的请求。这样即使旧线程拿着过期锁回来写数据也会因为 token 太小而被拒绝。Redis 本身不太容易直接给你这个 token需要在锁服务外层自己维护。这也是 Redis 锁在面对“强一致写”时比较吃亏的原因。3.4 Redlock 的争议既然单个 Redis 节点有主从切换丢锁的问题有人提出用多个独立 Redis 节点组成分布式锁也就是 Redlock 思路客户端向全部 N 个节点依次加锁超过半数成功就算持锁成功释放时向全部节点释放。思路听起来很美好但社区里对它的可行性有过非常激烈的争论。反对的核心观点大致是Redlock 本质上依赖各个 Redis 节点的“相对独立”但现实中多个节点共享的是同一个操作系统时间域无法避免时钟跳跃对锁过期判断的影响比如网络分区发生时客户端可能在被分区的少数节点上成功获取锁此时整个系统已经没有多数派概念的支撑。另外 Redlock 的客户端复杂度很高出问题后人很难推理。很多团队以为自己用了 Redlock 就安全了实际上是给自己增加了一堆调试成本。我对 Redlock 的态度是如果你的业务需要“严格要求任何时候都不能有两个客户端同时持有锁”那么这个目标用任何基于 Redis 的方案都达不到。这种情况下应该上强一致性协调服务而不是继续在 Redis 上缝补。如果业务只是高并发下的防重、限流、缓存更新互斥那么单个 Redis 节点配合好的实现方式已经足够Redlock 的收益不大风险却不小。4. 实操中的参数计算与避坑4.1 超时时间设置别拍脑袋锁的过期时间 TTL 是一个非常敏感的参数。设小了业务稍微抖动就丢锁设大了客户端崩溃后要等很久才能恢复。合理的做法是压测出一组业务临界区的耗时数据再根据分位数计算。我常用的计算方法是先压测得到业务临界区执行时间的 TP99加上网络往返平均耗时再加上客户端调度和 GC 的容忍余量最后整体乘以一个安全系数。举个例子某下单接口的临界区压测 TP99 为 200msRedis RTT 平均 50msGC 余量预留 100ms加起来的基线是 350ms。安全系数取 5最终 TTL 可以定为 1750ms向上取整到 2 秒。注意 2 秒不是拍脑袋而是有压测依据的。如果开启看门狗续期TTL 还可以设置得保守一点甚至直接依赖客户端库默认的 30 秒值因为真正的续期逻辑会保护长时间任务。这里有一个容易被忽略的细节TTL 要从“客户端发出加锁命令”开始算而不是从“业务代码开始执行”开始算。如果加锁命令本身排队等了一段时间比如 Redis 连接池被占满锁的实际可用时间已经缩水。所以加锁时最好也设置一个合理的客户端等待超时连不上 Redis 就别无限等待。4.2 获取锁的重试策略在竞争激烈的场景里客户端加锁失败后不能干等着需要在有限时间内重试。重试策略至少要包含两点总超时时间和每次重试的退避间隔。总超时时间要和业务的可接受等待时间挂钩。比如用户请求最多等 3 秒那么 2.5 秒拿不到锁就可以直接返回失败或降级。每次重试间隔建议带随机抖动避免大量客户端在同一个时间点同时重试形成惊群。实践上会用伪随机退避固定步长 random(0~50ms)比如每次重试前等待 50ms再加上随机量总共重试几十次。另外要注意加锁失败退避期间需要再次检查自己是否真的没有持有锁。网络抖动可能导致其实已经加锁成功但响应丢失客户端误以为失败。这种场景下如果直接发起第二次加锁同一个客户端可能会同时持有两把锁。更稳妥的做法是加锁命令没有收到明确响应时先尝试用 Lua 脚本检查锁 value 是否属于自己确认没有持有才进入重试。4.3 常见问题速查与个人取舍我在实际项目里整理过一张速查表排查线上锁问题时基本靠它这里分享给你现象可能原因处理思路解锁时报错发现删的是别人的锁解锁前没校验持有者身份统一用 Lua 脚本做GET 比较 DEL业务还没跑完锁就自动释放TTL 设置过短或没开启续期压测得到 TTL 基线开启看门狗续期客户端崩溃后锁迟迟不释放TTL 设置过长设置合理的过期时间不要为了省事调成几十分钟主从切换后两个客户端同时持有锁Redis 异步复制新主节点丢失锁 key关键业务升级为强一致协调方案普通业务靠幂等兜底服务 JVM 发生 Full GC锁和业务脱节客户端停顿时间超过租约异步线程续期 写操作加版本号/幂等约束同一个线程重复加锁导致自己死锁基础锁不支持重入客户端库的可重入锁或用 hash 记录持有者与次数加锁成功但响应超时客户端误判失败网络分区或响应包丢失超时后查询锁 value 确认归属再决定是否重试最后说一点个人取舍。我现在面对新项目时默认不会一上来就搭 Redlock。先问清楚业务对“锁失效”的容忍度允许偶尔并发穿透的业务用单节点 Redis 看门狗 幂等校验就够完全不允许并发穿透的业务直接上强一致协调服务不要在 Redis 上强行做不可能实现的保证。这个判断做完后面能省去很多不必要的争论和排障时间。还有一个小反思锁的用途不只是互斥。有时候我们需要的只是“防重提交”这种场景完全可以不依赖分布式锁用数据库唯一约束或者消息队列 key 去重就解决了。引入分布式锁之前先想想这里是不是真的有多实例并发写入还是只是自己在给自己造复杂度。这个习惯比掌握任何一条 Redis 命令都重要。