
做后端开发的这几年我踩过最深的坑就是分布式锁。不是说加锁本身难而是线上故障复盘的时候发现“锁失效”背后藏着一整套看似合理、实则脆弱的逻辑链Redis 分布式锁用得好是并发防线用不好就是一纸空文。库存超卖、订单重复、缓存击穿每一类事故背后几乎都有它的影子。这篇文章是从“为什么明明加了锁业务还是出错”这个问题出发把 Redis 分布式锁失效的路径一条条捋清楚。我会从底层命令讲到主从复制、从内存模型讲到业务超时再到排查手段和故障复盘。不管你是刚接触 Redis 的初级工程师还是已经在生产环境里维护微服务的中级后端这篇内容都能帮你少走几个月的弯路。1. 分布式锁的底层逻辑从 SetNX 说起1.1 加锁的正确姿势不是 SetNX 一个命令早期做分布式锁很多老代码喜欢用SETNX key value先尝试设置一个 key如果 key 不存在就返回 1表示加锁成功如果 key 已经存在就返回 0表示别家已经持有锁。伪代码写出来特别简单Long result jedis.setnx(lock:order:1001, clientA); if (result 1L) { // 获得锁执行业务 }问题很快就暴露了SETNX只负责设置不负责过期。如果持有锁的服务在执行业务时突然崩溃、断网、被强制 kill这个 key 会永远留在 Redis 里其他服务再也拿不到锁。这叫作“锁永远不释放”比锁失效更让运维头疼。后来大家开始用EXPIRE配合SETNX拼接加锁逻辑jedis.setnx(lock:order:1001, clientA); jedis.expire(lock:order:1001, 30);这个方案依然有致命缺陷两步操作并非原子。如果SETNX成功之后、EXPIRE执行之前进程宕机或网络闪断锁又变成一把没有过期时间的死锁。正确的做法是使用 Redis 2.6.12 之后提供的原子命令SET lock:order:1001 clientA NX PX 30000NX表示只有 key 不存在时才设置PX 30000表示锁的自动过期时间是 30 秒。一个命令同时完成“设置值”和“过期时间”从根上消除了两步操作之间的间隙。我见过不止一个项目因为沿用“先 setnx 再 expire”的旧写法出现锁永不释放的线上事故。所以这里第一条经验就是加锁只用一条原子命令不要拆成多步。1.2 解锁为什么必须用 Lua 脚本解锁看起来比加锁简单拿到了锁业务执行完把 key 删掉就结束了。但直接DEL也埋着雷。考虑这个时间线客户端 A 拿到了锁锁的过期时间是 10 秒。A 的业务超时跑了 15 秒锁在 10 秒时已经自动过期。客户端 B 拿到锁开始处理同一份业务。又过了 5 秒A 终于执行完回头直接DEL lock:order:1001这把锁实际上是 B 的锁A 把自己的删锁操作架在了 B 的锁上。B 的业务还没跑完锁却没了第三个客户端 C 又进来最终三份业务同时执行。问题的根源在于解锁操作没有校验“这把锁是不是自己的”。正确的解锁代码必须保证两步——校验持有者身份、删除 key——是原子操作。Redis 官方推荐用 Lua 脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end脚手架代码里KEYS[1]传锁的 keyARGV[1]传加锁时写入的唯一标识。只有当 Redis 里存的 value 等于当前客户端持有的 value 时才执行删除否则不能删。为什么非要用 Lua因为 Redis 执行 Lua 脚本是原子的不会插入其他指令。如果你先用GET判断、再用DEL删除中间就有可能混进别的客户端的操作判断和删除之间出现空隙锁就可能在那一瞬间被误删。实际操作中这段 Lua 脚本最好做成常量避免每次解锁都重复拼接。结合 Jedis 或 Lettuce调用eval方法即可。这里有个细节不同语言客户端对 Lua 脚本返回值的处理不一样有的把0和1转成布尔有的转成长整型写代码时要先确认底层 API 类型否则可能出现“解锁成功但返回失败”的虚假告警。1.3 加锁时的唯一标识别只存一个线程名很多分布式锁的教程都建议SET的 value 用一个全局唯一的客户端标识通常是UUID 线程 ID。为什么不能只存一个固定的字符串比如lock因为解锁时要校验身份如果所有客户端都存同一个值那么任何一个客户端都能解掉别人的锁误删锁的隐患就完全敞开了。我在实际项目里一般这样生成标识String lockValue UUID.randomUUID().toString() : Thread.currentThread().getId();用UUID确保多实例之间不重复再拼上线程 ID这样同一个 JVM 里的不同线程也能区分开。如果项目里已经引入了链路追踪可以把traceId拼接进去排查问题时看到 Redis 里的 value就能直接定位到是哪条业务链路上加的锁极大降低定位成本。这里顺便提醒一下value 不要存放用户 ID、订单 ID 这类业务语义过强的数据。锁的 value 应该只作为持有人身份标识业务数据放到业务字段里。否则多个业务共用一套锁逻辑时value 冲突会引发误删。2. 锁失效的致命场景Expire 时间与业务超时2.1 Redis 锁为什么会“自动过期”Redis 分布式锁依赖 key 的过期时间来保证可靠性这是一把双刃剑。没有过期时间服务器宕机锁就死掉设了过期时间业务执行一旦超过过期时间锁就会在业务还没结束时被 Redis 悄悄删除。这不是 Redis 的 bug而是分布式环境下最常见的设计取舍用过期时间换可用性必然牺牲部分可靠性。假设你设置了锁超时时间为 5 秒业务里调用了第三方接口这个接口在极端情况下的耗时达到 8 秒。锁在第 5 秒消失第二个服务实例在 6 秒时拿到同一把锁此时第一个实例的业务还没执行完两个服务同时操作同一份资源原有的串行化保护彻底失效。这种故障有个典型特征平时压测测试不出来只有在高延迟、重试、网络抖动时才会冒出来。所以这类问题在压测环境里往往被归类为“测试环境一切正常生产环境偶发异常”。2.2 正确确定锁超时时间的经验公式锁的过期时间不能拍脑袋我的惯用做法分三步第一步对目标业务做压测或者日志统计拿到正常执行耗时的 P99 分位值。比如订单支付回调需要查数据库、调用会员服务、推送消息平均耗时 120msP99 可能到 350ms。第二步加上一个缓冲系数通常取 2 到 3 倍。这里不能只加固定毫秒数因为耗时越长波动幅度越大。比如 P99 是 350ms设置 1000ms 至少能覆盖大部分情况。第三步考虑外部依赖的极端情况。如果业务里涉及 HTTP 调用超时时间可能是 2 秒那么锁的超时时间至少要比“最慢外部依赖超时时间”还要大。否则外部依赖毛刺稍微一上来锁就会被误释放。一个可供参考的配置# 这份配置来自一个订单回调业务 lock: time-out-ms: 5000 # 业务 P99 耗时约 900ms最大外部依赖超时 3000ms # 留 2000ms 给 GC、网络抖动等系统扰动如果实在估不准宁可在锁粒度上做拆分也别把锁超时时间无限放大。锁时间过长意味着锁故障恢复变慢一旦持有线程卡死整个资源被卡住的时间会拉得很长。2.3 看门狗机制自动续期靠不靠谱为了解决“业务没跑完锁却被释放”的问题Redisson 提出了看门狗Watchdog机制。简单说客户端在加锁时不指定固定过期时间而是由看门狗自动续期默认锁的 leaseTime 是 30 秒看门狗会每隔三分之一 leaseTime默认 10 秒检查一次锁是否仍在持有如果仍在持有就把过期时间重置为 30 秒。RLock lock redissonClient.getLock(lock:order:1001); boolean locked lock.tryLock(0, -1, TimeUnit.MILLISECONDS); if (locked) { try { doBusiness(); } finally { lock.unlock(); } }上面的第二个参数传-1表示不指定 leaseTime走看门狗续期逻辑。听着很完美但有几个坑必须提。第一看门狗续命是“持有期间持续续期”如果持有线程执行的是无限循环或一直阻塞那 Redis 里这把锁会一直被续期相当于无期限持有其他线程永远等不到锁。第二看门狗依赖客户端与 Redis 之间的连通性如果发生网络分区客户端无法继续续期那么锁还是会过期此时看门狗检查失败锁就会自动释放反而回到了“业务没完锁却没了”的旧问题上。所以看门狗不是万能药它只是把“锁超时时间拍脑袋”变成“锁超时时间动态调”降低人为配置失误的概率并不改变分布式锁失效的本质风险。我个人的落地经验是能明确估算业务耗时的场景直接用固定过期时间不必开看门狗业务链路复杂、耗时不可控的场景比如调用多个外部服务再考虑 Redisson 的看门狗方案但要配合业务超时中断机制避免无限续期。3. 主从切换与异步复制带来的锁失效3.1 异步复制下主节点宕机会丢锁Redis 最常见的高可用方案是主从架构加哨兵或者直接上集群。主节点负责写数据从节点通过异步复制同步数据。问题就出在“异步”这两个字上。当主节点刚写入锁 key还来不及把数据同步给从节点时主节点突然宕机。哨兵检测到主节点不可用触发故障转移把一个从节点提升为新主节点。但新主节点上没有那把锁。此时本来该被锁串行化的资源突然就处于“无人加锁”状态其他客户端可以畅通无阻地拿到新锁。画个时间线会更直观客户端 A 向主节点 M 写入锁 key设置成功。主节点 M 尚未把数据同步到从节点 S。主节点 M 所在机器断电宕机。哨兵提升 S 为新主节点。客户端 B 向新主节点 S 写入同一把锁 key成功。A 和 B 同时持锁并发执行业务。这不是理论推演而是生产环境真实发生过的主从锁失效案例。只要用 Redis 主从架构这个风险就天然存在因为它源于复制机制本身。3.2 主从复制延迟对锁可靠性的影响就算主节点没宕机主从复制延迟同样会放大锁失效的概率。主节点写入锁后数据是异步发送给从节点的。如果业务并发高、主节点网络繁忙、或者复制积压缓冲区被冲掉从节点的数据落后主节点几十毫秒甚至更久。在故障转移的那一瞬间延迟越大新主节点丢失锁 key 的概率越高。想减少这种失效核心是降低复制延迟。常见手段包括把主从部署在同一个机房、用万兆网络、监控master_repl_offset和slave_repl_offset的差距。但请注意这些手段只是降低概率并不能彻底消除。Redis 官方其实为这个问题提供了一个思路主节点写入锁后用特定命令等待从节点确认。但这种方法会阻塞主节点写操作性能代价非常高生产环境很少直接用。因此绝大多数团队的选择是接受这个理论风险用业务层的幂等性来兜底或者改用带强一致语义的分布式协调组件。3.3 时钟跳跃为什么会让锁突然失效Redis 的过期时间依赖服务器本地时间。如果部署 Redis 的服务器发生系统时间跳变比如 NTP 校时后面一大截那么原本 30 秒过期的锁可能瞬间就被认为过期了。很多人会忽略这点因为日常开发机器的时钟一般比较准。但生产环境的物理机和容器时间漂移并不罕见。云厂商提供的 NTP 服务默认开启每次校时通常是小步调整不会造成明显跳变。可一旦 NTP 服务异常、或者手动调整了系统时间锁的过期计算就会失真。从工程实践看有两条缓解措施。第一Redis 服务器开启和 NTP 同步并且把校时策略设为平滑步进避免瞬间大跳变。第二不要把所有可靠性押在“过期时间绝对精确”上而是把锁的续期和业务幂等结合让即使锁提前释放业务自身也能通过唯一索引、状态机、版本号等手段兜底。时钟跳跃这个坑普通业务可能几年碰不上一次但一旦碰上就是大规模并发错乱。我在一个金融类项目里就遇到过 NTP 跳变引发的锁集体过期最后排查了几小时结论竟然是系统时间回拨那次经历让我再也不敢忽略这个问题。3.4 RedLock 算法到底能不能救主从失效主从架构锁失效的一种经典方案是 Redis 官方提出的 RedLock。算法的核心思想是不要只往一个 Redis 实例上加锁而是往 N 个相互独立的 Redis 节点上加锁只有获得超过半数节点的锁才算加锁成功。假设有 5 个独立 Redis 节点 1. 向 5 个节点依次执行 SET key value NX PX 30000 2. 如果成功加锁的节点数 3认为持有分布式锁 3. 计算加锁总耗时如果总耗时超过锁过期时间视为加锁失败 4. 释放锁时向所有节点都发送解锁脚本RedLock 的价值在于它不依赖单一节点的可靠性即使某个节点宕机、或者某个节点的锁丢失只要还有多数节点持有锁分布式锁整体依然有效。但 RedLock 不是银弹。它有三个绕不开的问题第一如果某个节点收到加锁请求时已经落后客户端向这个节点加锁要等待网络超时可能无法在锁过期时间内完成多数节点加锁。第二RedLock 依然依赖各节点的本地时钟时钟跳变问题照样存在。第三某些分布式系统领域的专家对 RedLock 提出过尖锐质疑认为在“暂停”或“网络分区”面前它无法提供绝对可靠的互斥保证。实际项目里如果要选型我的建议偏保守如果系统规模不大对锁可靠性要求极高又想用 Redis可以考虑 RedLock但必须接受它更慢、更复杂如果锁的可靠性要求极高超出 Redis 能力范围直接引入 ZooKeeper 或 etcd 这类强一致性组件更合适不必在 Redis 上死磕。4. 线程安全与重入问题锁失效的另一面4.1 可重入锁同一个线程重复加锁会怎样分布式锁的“可重入性”是指同一个客户端线程在已经持有锁的情况下还能再次获取同一把锁。这个需求在业务嵌套场景里非常常见比如方法 A 加了锁方法 A 内部又调用方法 B方法 B 也加了同一把锁。如果锁本身不支持重入方法 B 就会获取锁失败进而报错或死锁。为什么要单独讲这个因为很多基础版 Redis 分布式锁没有实现可重入一旦业务出现嵌套调用锁直接“失效”——这里的失效不是锁丢了而是锁把自己给阻塞了。实现可重入锁的常见做法是用 Redis 的 Hash 结构记录持有者与重入次数。# 加锁时如果 fieldclientA 不存在初始化次数为 1 HSET lock:order:1001 clientA 1 # 重入时次数加 1 HINCRBY lock:order:1001 clientA 1 # 解锁时次数减 1 HINCRBY lock:order:1001 clientA -1 # 当次数减到 0删除整个 key实际编码时判断持有者身份、增加次数这些操作也必须放到 Lua 脚本里保证原子性。Redisson 的RLock默认就支持可重入它内部就是用 Hash 结构实现的。自己手写一套时别选 String 结构否则重入场景无法记录次数。4.2 误删锁锁超时与业务执行的“错位更新”前面章节提到过误删锁的经典案例这里再深入一层。有些团队会把“解锁前判断 value 是否一致”这个步骤省略掉理由是“就我们自己系统在用锁不可能会误删”。直到某个深夜A 服务持锁超时B 服务拿到锁A 服务最后照样DELB 业务的锁被删C 服务又冲进来。那一刻省掉的一行校验代码换来的是一个被扣成负数的库存。用表格对比一下不同解锁方式的可靠性解锁方式是否校验持有者是否原子生产环境建议直接 DEL否是绝对不要用GET 判断后 DEL是否不可靠中间有间隙Lua 脚本校验后 DEL是是生产环境标准做法我在团队里给这份代码做了强约束所有手写 Redis 锁的解锁代码必须使用 Lua 脚本代码评审看到GET和DEL直接打回。这个规则虽然严格但效果显著从那以后误删锁的事故再没出现过。4.3 获取锁失败后的等待自旋重试的代价另外一个容易被人忽略的线程安全问题是获取锁失败之后客户端如何等待重试。无脑自旋while(true)轮流加锁在 Redis 连接池有限的情况下会耗尽连接甚至打爆 Redis。比较合理的做法是带最大等待时间的自旋long startTime System.currentTimeMillis(); while (System.currentTimeMillis() - startTime 3000) { if (tryLock()) { return true; } Thread.sleep(20); } return false;这里的核心是两点自旋间隔不能太短否则会给 Redis 造成极大压力最大等待时间必须存在否则客户端可能永远拿不到锁却永远不退出。自旋间隔的选择和业务量有关。高并发场景下20ms的间隔会带来每秒 50 次加锁尝试需要确认 Redis 和连接池扛得住。低并发场景则可以放宽到50ms或100ms。此外等待超时后的处理也很重要。拿到锁失败后可以选择快速失败返回错误也可以选择降级处理。在秒杀、库存这类场景里快速失败往往比无限等待更合理因为用户等不起系统也等不起。5. 常见问题与排查技巧实录5.1 Lettuce 命令超时RedisCommandTimeoutException 的排查思路很多 Spring Boot 项目用的是 Lettuce 客户端生产日志里经常出现类似这样的异常io.lettuce.core.RedisCommandTimeoutException: Redis command timed out这个异常和分布式锁有什么关系在我遇到过的问题里加锁和解锁命令往往是高并发下最容易超时的操作。命令超时会引起两种连锁反应一是加锁命令超时导致业务直接报错用户操作失败二是解锁命令超时导致锁没有及时释放引发后续请求全部阻塞。排查这个异常先看三个位置第一Redis 服务器本身是否 CPU 过高、慢查询过多。用redis-cli执行SLOWLOG GET 50看最近是否有大量耗时命令。如果 Redis 的SET、DEL、EVAL都慢说明 Redis 端瓶颈明显。第二Lettuce 客户端连接池配置。Spring Boot 2.x 默认 Lettuce 连接池大小是 8高并发下连接池排队会导致大量命令等待。此时要把连接池配置调大但不能无脑调大过大的连接池会反过来拖垮 Redis 连接处理器。第三大 key 或热 key 问题。如果同一个锁 key 被成千上万线程争抢自旋加锁命令大量堆积Redis 处理能力到上限自然超时。这种场景不该只调连接池要把锁粒度拆小、减少争抢。以前排查过一个线上问题分布式锁加的 key 太粗所有订单都在同一把大锁上排队高峰期订单量一涨Redis 单节点 QPS 瞬间爆表加锁命令大面积超时。后来把锁 key 从lock:order改成lock:order:{orderId}超时问题立刻消失。5.2 锁自动释放后如何判断是否有并发穿透锁自动释放后业务可能没有感知但系统行为会透露出信号。常见现象是同一张订单的扣款日志出现两个不同线程的 traceId数据库唯一索引偶尔报重复或者某个字段的最终值出现“后写覆盖先写”。判断是否有锁穿透最快的方法是加一行监控在加锁成功后记录 Redis 里锁的 value 对应哪个请求 ID解锁成功后也记录一下。如果日志里出现“同一个资源两个请求都宣称加锁成功”就可以判定锁失效。再进一步可以在业务关键表上增加类似update_time和version之类的乐观锁字段当出现并发写时数据库会通过版本号拒绝其中一个更新防止脏数据落库。这种兜底策略才是生产环境真正能救命的最后一道防线。5.3 经典故障复盘库存扣减超卖的完整链条用一个我做过的真实复盘案例把失败链条串起来。系统是电商库存服务使用 Redis 分布式锁防止超卖。故障现象某个热卖 SKU 在流量高峰出现一次库存负值客户投诉下单成功后无法发货。通过日志和 Redis 慢日志复盘后链条是这样的加锁代码设置锁超时 3 秒但扣库存前需要调用风控服务风控服务偶发超时重试整体耗时超过 3 秒。第一台服务实例持锁超过 3 秒锁被 Redis 自动删除。第二台服务实例在 3 秒后成功加锁开始执行同样的扣库存。第一台实例的风控调用最终返回成功它继续往下走最终执行了扣库存。两个实例同时扣库存库存字段被写入负值。修复分了三步走第一步把风控调用挪出加锁段在加锁前预校验第二步锁超时时间从 3 秒调整为 10 秒并接入看门狗续期第三步数据库库存扣减加上stock n的乐观锁条件防止最终落库超卖。这个案例最有价值的启示是单纯修 Redis 锁是不够的一定要给业务落库最终防线。分布式锁解决的是并发执行的问题但并发发生后是否能被数据层兜住是系统整体可靠性的一部分。5.4 分布式锁排查速查表把常见失效原因、现象和排查方向整理成一份速查表方便后续直接对照失效原因典型现象排查入口修复建议锁超时时间设置过短业务执行超时锁被自动释放对比业务耗时与锁过期时间压测取 P99设置 2-3 倍缓冲加锁不是原子命令进程崩溃后锁残留看 Redis 中锁 key 的 TTL改用 SET key value NX PX解锁不校验持有者别人的锁被误删查看日志中锁 value 是否匹配使用 Lua 脚本校验后删主从复制延迟丢锁故障转移后并发进入检查主从复制延迟和切换事件考虑 RedLock 或强一致性组件时钟跳变锁提前批量过期查看 Redis 服务器 NTP 记录NTP 平滑校时禁止手改时间Lettuce 连接池耗尽命令大面积超时检查连接池活跃数和等待数调大连接池拆分锁粒度业务无幂等兜底并发写产生脏数据检查数据库唯一约束和版本号增加乐观锁或业务幂等这张表我直接放在团队 wiki 里每次遇到分布式锁相关的故障先让值班同学照着表排查一轮能省下大量定位时间。最后的几点真实体会分布式锁失效这个问题我前前后后踩了三四年最大的体会是它不是一个孤立的技术点而是把 Redis 原理、系统架构、业务设计全部串起来的综合工程问题。单点 Redis 的锁性能最好但可靠性只能靠业务兜底主从架构的锁可用性更高但复制延迟天然存在RedLock 和强一致性组件更可靠但复杂度和性能代价要重新评估。如果你现在的系统里只是简单用 Redis 锁我的建议是先对照这份速查表把自己的代码过一遍重点看三件事加锁是不是一条原子命令、解锁是不是 Lua 脚本、加锁时是不是写了唯一标识。这三件事只要有一件做得不对锁总有一天会在你最想不到的时刻失效。把这三件事修好再谈看门狗、再谈 RedLock才不会在错误的根基上盖楼。另一个很实际的经验是给分布式锁加上完善的监控和日志排查成本会降一个量级。每次加锁和解锁都打一条带 traceId 的日志锁的 value 里带上请求 ID这样出问题时可以按 traceId 快速把加锁、业务执行、解锁整条链路串起来。很多看似玄学的并发问题最后都不是玄学只是日志不够完整。分布式锁没有“绝对可靠”的标准答案只有“在特定业务场景下足够可靠”的工程取舍。理解失效的原因比记住某个框架用法重要得多。