Redis分布式锁失效场景全解析:原子性、过期时间与主从切换

发布时间:2026/10/6 19:06:58
Redis分布式锁失效场景全解析:原子性、过期时间与主从切换
1. 分布式锁失效问题整体拆解1.1 分布式锁到底在解决什么问题先捋一个最常见的使用场景电商秒杀商品库存只有100件后端服务部署了3个实例。如果每个实例各读一次数据库判断库存大于0再扣减那在高并发下库存很容易变成负数。这类问题本质上就是“多实例并发访问共享资源”这时候就需要一个跨进程、跨机器的互斥机制——也就是分布式锁。用Redis实现分布式锁是很多团队的第一选择。原因很简单Redis部署简单、性能高、主从和集群方案成熟而且本身就带原子操作。相比数据库悲观锁、ZooKeeper临时节点锁Redis锁在性能上有绝对优势。但它有一个明显的短板在极端场景下锁可能悄悄失效导致多个请求同时进入临界区。这个失效问题如果不搞清楚线上出事故只是时间问题。我在实际项目中遇到过好几次类似事故业务在高峰期出现重复下单、库存超卖表面上看代码逻辑没问题锁也加了但最终结果是“锁被绕过”了。排查到最后几乎都能归到分布式锁失效的几种典型原因上。这篇文章就当一次系统复盘把加锁、持锁、释放整条链路上可能失效的环节一个个拆开讲并且给出可落地的防控手段。1.2 “失效”到底是指什么状态先把“失效”的概念界定清楚。分布式锁失效指的是下面两类情况之一一类是死锁型失效锁永远无法被释放所有后续请求全部被卡住系统的吞吐量直接降为零。这种故障通常是最先暴露、最好定位的。另一类是误入型失效锁名存实亡多个线程同时拿到了锁都认为自己是唯一的持有者一起执行了临界区代码。这类故障更隐蔽因为它不报错只在数据结果里留下痕迹。从“加锁 → 持锁 → 释放”三段链路来看每段都有对应的失效场景。我把整个风险链条整理成了一张自检表当成排查时的索引用链路阶段失效场景可能原因后果加锁阶段锁压根没加上SETNX和EXPIRE拆分执行中途异常所有请求同时进入临界区加锁阶段锁被错误覆盖不同业务共用一个key锁形同虚设持锁阶段锁提前过期锁超时时间小于业务执行时间后续线程趁虚而入持锁阶段锁被进程STW拖过期GC停顿、网络阻塞导致持锁超时即使持有者还活着锁却已失效释放阶段误删他人锁没有校验value就删除key一个线程删掉另一个线程持有的锁集群环境锁随着主节点宕机丢失主从同步延迟从节点上位后没有锁记录两个线程同时持锁这张表基本上是分布式锁失效的全部“事故类型”。下面我按阶段逐一深入先把根因讲透再给出对应的解决方案。2. 加锁环节的失效原子性就是生命线2.1 SETNX和EXPIRE分家一次崩溃就死锁很多人在第一次实现Redis分布式锁的时候写出来的是这样的代码# Python示例错误写法 if redis.setnx(lock:order, 1): redis.expire(lock:order, 30) # 执行业务 do_business() redis.delete(lock:order)这段代码最大的问题在于setnx和expire是两条独立的命令。如果setnx执行成功之后进程突然崩溃、网络异常、或者Redis在写入EXPIRE之前宕机了那么这个锁key就永远存在没有过期时间后面所有请求都会在setnx处失败系统陷入死锁。这就像你出门前把门锁上了但把钥匙忘在屋里同时还把门焊死了——妥妥的灾难。我在生产环境里见过不止一次这种写法。它在前端低并发、测试环境可能跑几个月都不出问题因为崩溃需要凑巧发生但是一旦上线到大促场景Redis主从切换、应用重启、网络抖动频繁出现死锁概率就会急剧上升。2.2 正确加锁姿势一条SET命令搞定解决“非原子”的问题办法就是让加锁和设置过期时间在Redis端一次性完成。Redis从2.6.12版本开始SET命令本身就支持NX和EX/PX参数可以在一条命令里完成整个加锁操作SET lock:order 5e5f4a2e-8f3a-4b6d-9c2a-1d0f3a7b9c11 NX PX 30000这个命令的含义是只有当lock:order这个key不存在时才设置它的值为5e5f4a2e-8f3a-4b6d-9c2a-1d0f3a7b9c11同时设置30秒过期时间。如果key已经存在命令直接返回nil表示加锁失败。这里有几个细节值得强调NX是NOT EXISTS的缩写保证同一时刻只有一个线程能设置成功。PX跟着的是毫秒数也可以用EX指定秒数。一般建议用PX因为分布式锁的超时时间经常落在几秒到几十秒之间用毫秒可以更精细地控制。value值不能用固定的字符串比如1而要用一个全局唯一的标识比如UUID、雪花ID或者业务请求ID。这个唯一标识是后面安全释放锁的前提。如果value固定不变就会出现一个很危险的情况线程A拿到锁执行业务线程B在A释放之后拿到同一个锁用的还是同一个value如果A执行了释放操作它会把B的锁也删掉。这在后面的释放环节会详细展开。2.3 value的唯一性不只是规范是安全基石曾经有一位新同事问我“锁的value用什么值有关系吗反正锁就是用来占位的。” 关系非常大。value作为锁持有者的身份凭证必须满足两个条件全局唯一。同一把锁的key在任意时刻只能对应一个有效holder因此value绝不能是共享的常量。入锁时生成释放时校验。下面这个Lua脚本释放锁的第一步就是比对valueif redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这个脚本的含义是延迟到Redis服务端去判断“当前锁的value是否等于我持有时的value”匹配才允许删除否则不删。因为Lua脚本本身就是原子执行的比对和删除中间不会被其他命令插队。如果没有这一层校验释放锁的时候直接del一个不存在的key或者del一个已经被其他线程持有的key都会破坏互斥性。尤其是后者线程A的业务还在执行锁却被删掉线程B立刻拿到锁进来两个线程同时跑临界区数据就乱了。所以在加锁的时候就一定要为release预留好value凭证。我的习惯是用“服务名实例ID线程ID自增序号”拼接成一个唯一标识既保证唯一性也方便排查问题时直接看value就知道是哪个服务哪个线程在持锁。3. 持锁与释放环节的失效过期时间背后的时间博弈3.1 持有锁的时间窗口为什么不能随便拍脑袋设超时加锁成功只是第一步真正让锁失效的高发区在“持有”阶段。核心矛盾在于我们强制给锁设置了一个过期时间但业务处理时间不是一个固定值。如果锁过期了业务还没执行完锁就会提前释放。这个时候其他线程可以重新加锁进入临界区于是两个线程同时跑同一段逻辑。举个现实例子。用户支付回调处理逻辑中为了防止重复处理用Redis锁锁住了订单号超时时间设置成5秒。正常情况下这个回调处理在几百毫秒内就能结束5秒绰绰有余。但遇到下游接口响应慢、数据库连接池打满的情况回调逻辑可能会跑8秒甚至10秒。锁在第5秒就自动过期了另一个重试请求进来重新加锁成功开始处理同一笔订单。结果就是重复退款、重复发放权益这类的线上事故我听过很多。那过期时间到底怎么设我的经验分三步走第一步找出该临界区逻辑的历史最大耗时比如通过监控平台看P99、P99.9耗时。时间起点是获取锁之后终点是业务逻辑执行完毕。第二步在这个最大耗时基础上乘以1.5到2作为缓冲。假如P99耗时为2秒设置超时时间3到4秒是合理的。第三步留一道兜底如果用Redisson这类成熟库它内置了看门狗WatchDog机制能自动续期如果手写锁就需要在业务代码里手动延长或拆逻辑不要让太重的任务占着锁。不要抱有“设大一点就安全”的心态超时时间设得越长一旦持有锁的实例宕机其他线程等待恢复的时间就越久服务的可用性也会跟着受影响。所以超时时间是在“误入型失效”和“死锁型失效”之间做权衡。3.2 业务超时后的人工续期看门狗到底做了什么事很多刚接触Redis分布式锁的朋友会问既然业务执行时间可能超过锁过期时间那为什么不把锁设成永不超时答案大家都懂进程一旦崩溃永不过期的锁就没有任何机制去清理系统必然死锁。所以关键不是“不设过期”而是“动态续期”。Redisson的看门狗机制解决的就是这个矛盾。它默认的锁过期时间是30秒拿到锁之后后台会启动一个定时任务每隔10秒检查一次锁是否仍然被持有如果持有就自动把锁的过期时间续到30秒。只要业务还在跑锁就一直续着如果实例宕机定时任务随之消失锁最多30秒后自动过期不会死锁。这个设计非常优雅。实现的核心其实很简单就是用Lua脚本去“重新设置过期时间”-- 续期脚本如果当前锁的value匹配则重新设置过期时间 if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(pexpire, KEYS[1], ARGV[2]) else return 0 end如果你不想引入Redisson这个重量级依赖也可以自己实现一个轻量版的续期调度器。用ScheduledExecutorService或者在业务代码里每跑一段时间就执行一次上面的Lua脚本。但注意一点续期逻辑本身占用了系统线程如果锁的量级很大调度器可能会成为瓶颈所以不要过度Design。3.3 看门狗解决不了的失效STW与网络阻塞看门狗确实能续期但它有一个前提持有锁的进程必须还活着而且JVM的垃圾回收GC不能停顿太长时间。Java应用在Full GC时用户线程会进入Stop The World状态这段时间内所有业务代码都暂停运行。如果STW持续了超过锁的超时时间比如堆上对象太多Full GC用了40秒那看门狗线程也一样被冻结无法续期。等到GC结束应用恢复执行时锁已经过期很久了其他线程早就进了临界区。STW之外还有一类隐蔽的失效是“网络阻塞”。客户端到Redis之间的网络如果发生长时间抖动续期命令迟迟发不到Redis端锁同样会过期。这种情况比较被动因为应用自身看起来一切正常。针对这类“进程活着但锁已经失效”的场景工程上能做的几个动作调优GC参数尽量避免超长STW。比如启用G1垃圾回收器合理设置-XX:MaxGCPauseMillis。锁保护的逻辑尽量轻量不要在临界区里做远程调用、大文件读写、批量扫描数据库这类耗时不可控的操作。如果业务确实需要长时间独占资源就不要用Redis锁的默认超时而是结合数据库的乐观锁版本号做二次校验给Redis锁设置一道“数据层防线”。3.4 释放锁的Lua脚本最后一道防线的常见错误释放锁用Lua脚本这块很多人知道要“比对value再删除”但实际写代码时还是有三个常见的坑坑一用GET和DEL两条命令裸奔。先get拿到value在本地比对如果相同再执行del。2. 逻辑没问题但比对和删除之间不是原子的如果中间发生上下文切换另一个线程趁虚而入加锁成功你紧接着的del就把别人的锁删了。坑二把Lua脚本写串联但没有注意返回值的判断。脚本返回0的时候有些封装库会把它当作“执行失败”抛异常反而导致业务中断。正确处理应该是返回0表示“未持有锁或锁已过期”这是正常情况不需要额外处理。坑三释放锁时传错value。有的代码在加锁时生成了唯一value但释放时重新用UUID生成一个新值去比结果永远匹配不上锁一直卡到超时才释放。这三点在自测阶段都不容易暴露建议在开发阶段就写好单元测试模拟“线程A加锁、线程B尝试释放A的锁”这种对撞场景确保释放逻辑是原子的。4. 集群环境下的失效主从切换与锁丢失4.1 主从异步复制导致的锁凭空消失很多分布式锁的教程讲完“SET NX EX”和“Lua脚本”就结束了好像只要把这两点做到位锁就稳如泰山。但真实的互联网架构里Redis很少是单机部署一般至少是主从架构上规模之后还会用哨兵或集群。部署方式一变问题跟着就来了。Redis主从之间的数据同步默认是异步的。主节点写入数据后并不会等从节点确认完成再返回客户端。这意味着什么假设线程A在主节点上成功设置了锁key。主节点还没来得及把这个key同步给从节点极端情况下主节点突然宕机。哨兵组件马上执行故障转移把一个从节点提升为新的主节点。此时新的主节点上是没有那个锁key的。线程B来加锁一切顺利加上锁进入临界区。线程A也以为自己还持着锁于是两个线程同时执行临界区代码——锁失效了。没错这个失效并不需要网络故障、也不需要STW它只需要一次“刚刚好”的宕机时机。在真实的生产环境里这种窗口很小但高并发下任何小概率事件都可能被放大成事故。为了验证这个场景我在测试环境做过一次故障演练部署一主一从Redis人为延迟主从复制用redis-cli加上同步策略做限速然后模拟主节点宕机。实测结果证明只要复制没有完成新的主节点上确实查询不到锁key。故障释放窗口大约等于“主从复制完成”所需的时间。如果量级大这个窗口内的并发请求是可以突破锁的。4.2 Redlock算法多节点协商的锁到底靠不靠谱既然单节点为主从架构时锁可能丢失业界提出了一个很经典的方案Redlock红锁。思路不再依赖单点而是让客户端向多个独立的Redis节点请求加锁只有大多数节点N/21同意加锁才算真正持有锁。具体流程大概是客户端向全部N个Redis节点发送SET key value NX PX ttl命令。计算整个请求过程耗时如果总耗时已经大于锁的有效期比如设置了10秒但加锁过程用了8秒就直接判定失败释放所有节点上的锁。如果成功加锁的节点数大于N/2并且耗时小于有效期才算加锁成功。释放锁时客户端对所有节点都发送Lua脚本删除锁。Redlock的设计思想在分布式系统领域很有名但争议也不小。最大的争议在于时钟问题如果某个节点上的系统时钟发生了跳跃它计算的过期时间就会不准锁可能提前过期也可能突然失效。工程上我的态度是Redlock适合对一致性要求极高、且明确部署了多个互相独立的Redis实例的场景比如金融级的账务处理。但如果你已经用了主从加哨兵架构Redlock并不能帮你解决“主从切换丢锁”的问题因为Redlock要求的是互相独立、不进行数据同步的节点哨兵把主从两个实例当成一组副本跟Redlock的模型是不一样的。4.3 更现实的兜底方案主动降级与数据层屏障这里说点我的实际经验。分布式锁在某些极端场景下不可能实现绝对的“不失效”从CAP理论的角度看Redis锁本质上是偏向AP、牺牲了一点一致性的方案。如果你需要绝对可靠那就应该换ZooKeeper或者etcd它们通过ZAB和Raft协议保证一致性代价是吞吐量低一些。但在很多业务里Redis锁的偶发失效是可以接受的前提是你做好了“双重保险”。我惯用的兜底方案是三层防护第一层应用层加Redis锁把大部分并发请求挡在门外。第二层数据库层做幂等约束比如订单表加上业务唯一索引订单号操作类型即使两个请求同时进来数据库也只接受一条写入。第三层关键业务操作使用乐观锁更新时带上版本号或条件UPDATE ... WHERE stock 0保证即使逻辑并发执行最终的数据变更不会越界。有朋友可能会问既然数据库已经做了兜底那还需要Redis锁吗要的。数据库兜底只能把“坏的并发结果”挡住但防不住“重复处理”带来的下游副作用比如给用户发了两次短信、调用了两次第三方支付退款。Redis锁的核心价值是让后端的副作用只发生一次而不是让数据库不出错。5. 常见问题与排查技巧实录5.1 如何确认锁是不是真的失效了线上出了疑似锁失效的事故第一步不是急着改代码而是先把“锁有没有生效”这个事实搞清楚。我推荐一套组合排查手段第一查Redis里的锁key。用redis-cli连接Redis以后执行GET lock:order:10086 TTL lock:order:10086GET返回的是锁的值TTL返回剩余过期时间秒。如果key不存在说明锁已经释放或者从未加上如果TTL显示-1说明这个锁没有过期时间可能是老代码里遗留下来的问题如果TTL剩了很多秒说明持有者还在续期。第二用MONITOR命令观察实时命令流。在测试或者低峰期临时开启MONITOR观察锁key在每个时间点上的操作记录。重点看是否出现两个不同value的线程先后成功执行了SET ... NX。MONITOR注意MONITOR在高并发生产环境会显著增加Redis的性能开销一般不推荐线上长时间开启。需要做的时候选择业务低峰时段开个一两分钟就关掉。第三查应用日志。在加锁、释放、业务开始、业务结束这四个位置加上日志日志里必须带上锁的key和value。我做排查时见过最难受的日志就是只打了“加锁成功”却不知道是哪个实例、哪次请求加的锁。所以日志模板至少要长这样[lock-acquired] keylock:order:10086 valueuser-service-172.18.1.3-128-thread-42-1024 ttl30000ms [lock-released] keylock:order:10086 valueuser-service-172.18.1.3-128-thread-42-1024 cost3850ms5.2 缓存穿透、击穿、雪崩和分布式锁的关系“Redis缓存治理”相关搜索词经常和分布式锁一起出现。很多同学把“缓存击穿”和“分布式锁”放在一起理解但不一定清楚它们之间的边界。缓存穿透查询一个必然不存在的数据请求直接打到数据库。这种情况加锁没用应该用布隆过滤器或者缓存空值。缓存击穿某个热点key过期瞬间大量并发请求直接打到数据库。这种情况加锁是有效的用“线程去数据库重建缓存之前先抢锁抢不到的先等待”这种策略Redis锁能让数据库只被极少数的线程击打。缓存雪崩大量key同时过期数据库被打挂。这种情况应该让过期时间加入随机值避免整点同时失效分布式锁在这种场景下作用有限。我在做缓存击穿防护时常用的一种写法是“双重检查锁”# 思路大致如下伪代码 data cache.get(key) if data is None: if redis.setnx(lock_key, uuid, expire5): try: data db.query(key) cache.set(key, data, ttl) finally: release_lock(lock_key, uuid) else: # 没有抢到锁的线程先等一小段时间然后重新读缓存 time.sleep(50) return cache.get(key)这种写法在低并发下足够用但要注意抢锁失败时不要无限等待否则流量全部堆积在等待上同样会拖垮应用。一般建议等待一段固定时间后再次读缓存如果还没有才走兜底逻辑。5.3 实际项目中的三个典型故障记录我在过往项目中遇到并处理的三个分布式锁失效案例都比较有代表性分享出来供参考。案例一锁的过期时间设了5秒业务却跑了15秒。表现订单重复关闭、对账不平。根因订单关单逻辑中调用了第三方支付平台的查询接口这个接口在对方服务繁忙时最长耗时可达12秒加上本地数据库操作总共约15秒。锁第5秒就过期了重试线程立即拿到锁进入相同逻辑。修复方案将锁内逻辑中远程调用的部分移出临界区或者给远程调用单独设置较短的超时时间再配合Redisson看门狗续期双管齐下。案例二主从切换瞬间锁丢失导致重复发放优惠券。表现某活动发放优惠券出现超发。根因Redis为主从架构主节点写入锁后立即宕机新主节点上没有锁记录。修复方案业务侧增加了“基于数据库唯一索引的幂等表”优惠券发放记录表加唯一键并发重复操作时数据库拒绝第二条写入短期无法完全杜绝锁丢失但数据层的屏障保证了不超发。案例三释放锁的Lua脚本写错了value比对逻辑。表现偶发“明明释放了锁其他线程还是拿不到锁”持续等满30秒超时才能继续。根因释放时重新生成了UUID与加锁时存入的UUID不一致导致比对永远失败。修复方案将value的生成统一放到一个上下文对象里加锁时生成并存容器释放时从容器取不允许在释放函数内重新生成。这三个案例看起来很普通但它们暴露的是同一个教训分布式锁的失效往往不是单一原因造成的而是好几个环节同时出现了“将就”。我以前排查分布式锁问题一般会写一个Checklist按顺序检查锁key是否唯一加锁是否原子value是否有唯一标识释放是否用Lua校验超时时间是否覆盖业务最大耗时是否需要续期Redis是单机还是主从。每一个问题的答案都会改变系统的行为。5.4 常用工具和排查命令速查为了排查方便整理了一份常用命令速查表。建议收藏到自己的手册里场景命令说明查看锁是否存在EXISTS lock:order:10086返回1表示存在0表示不存在查看锁剩余时间TTL lock:order:10086返回-2表示key不存在-1表示永不过期查看锁持有者GET lock:order:10086结合日志确认是否当前线程持有手动删除锁需要谨慎DEL lock:order:10086会导致当前持锁者失效危险操作观察实时操作MONITOR低峰期临时开启观察锁key的写入删除查看慢查询日志SLOWLOG GET 10排查看门狗续期是否因超时被阻塞查看主从状态INFO REPLICATION主从复制是否健康从节点是否落后5.5 关于选型一道相对稳妥的判断题讲到这里很多读者最关心的问题可能是“那我到底该用自研锁还是Redisson要不要上Redlock”我的判断题是这样如果业务对锁的强一致有硬要求且你能忍受略低的吞吐量直接用ZooKeeper或etcd。如果选定了Redis优先用Redisson不要自己造轮子。Redisson不只是实现了一个加锁释放它还替你处理了续期、可重入、批量锁等大量边界场景。如果用了Redisson仍然遇到锁失效不要花太多时间在“如何让Redis锁永不失效”上那是死胡同。更应该做的是把数据层的唯一约束、幂等表、乐观锁补起来让Redis锁成为一个高性能的“前置过滤器”而不是唯一的保命符。这个思路是我踩过多次坑之后的结论。Redis分布式锁可以做到“99.9%的时间有效”但唯独那0.1%的极端情况往往出现在你最不希望它出错的时候。6. 结尾一次事故复盘后的个人建议最后分享一个我个人的工作习惯每次分布式锁相关的功能上线我一定会在压测环境里做两件小事。第一人为把锁内业务逻辑延迟——用一个开关控制sleep 8秒验证锁的过期时间是否兜得住第二在主从环境里手工执行一次主节点宕机观察业务是否在切换期间出现重复执行。这两个测试做完我心里就有底了。也许这种测试无法覆盖100%的故障场景但它至少能帮你把最典型的几条失效路径提前暴露出来。多测一次线上就能少一次事故。