
做后端这些年我没少在分布式锁上栽跟头。最诡异的一次是在库存扣减场景里代码明明用 SET key value NX EX 给订单加了30秒的分布式锁业务逻辑满打满算也跑不了5秒压测时日志里却出现了两个线程同时通过锁校验、同时执行扣减的现象。当时我第一反应是“Redis出故障了”监控一切正常之后又把加锁代码读了三遍最后才反应过来——不是Redis把锁弄丢了而是我们对“锁的过期时间”的理解一开始就跑偏了。这个题目来自一个我很喜欢的系列“十万个why”。今天把这个问题彻底拆开锁明明还没过期为什么另一个线程能抢进去这篇文章适合被分布式锁坑过的后端开发正在设计并发方案的同学还有准备面试想把分布式锁讲透的人。文章不会堆砌概念只讲我在真实代码里遇见过、并且后来成功防住的那些情况。1. 从一次“锁没到期却被抢”的线上事故说起1.1 事故现场典型的加锁代码长什么样那次事故的代码简化之后大概是这样的Boolean locked redisTemplate.opsForValue() .setIfAbsent(lock:order:20240001, 1, Duration.ofSeconds(30)); if (Boolean.TRUE.equals(locked)) { try { // 1. 查库存 // 2. 扣减库存 // 3. 创建订单 // 4. 发送MQ } finally { redisTemplate.delete(lock:order:20240001); } }这版代码哪怕不看日志光是读一遍就能揪出两个问题value 是固定字符串“1”所有线程长得一模一样释放锁的时候根本没有校验“拿锁的是不是我自己”删除锁又是 get 和 del 两条命令中间有空窗。这两点后面再细说更让我意外的是——即使这两点都改对了锁还是有可能在“有效期之内”被别的线程抢进去。压测日志里出现的时间线是这样的线程A在 14:00:00 持锁成功开始走扣减库存的逻辑线程B在同一秒尝试加锁失败进入自旋重试大约 30 秒后线程B忽然加锁成功而这个时候线程A的业务方法还没有执行完。用大白话说就是“我手里明明握着钥匙门怎么就被另一个人打开了”。1.2 现象的实质锁消失才是抢锁成功的先决条件先把问题翻译成技术语言。在 Redis 分布式锁的模型里“抢锁成功”意味着执行了SET key value NX EX这个原子命令并且返回了 OK如果锁还活着这条命令会因为 key 已存在而返回 nil线程会继续等待。所以“另一个线程能抢进去”只有一种可能临界区对应那把锁在 Redis 里已经不存在了或者从一开始就不存在。顺着这个思路现场的嫌疑就集中在三类情况锁被 Redis 主动删掉了——最常见的也就是过期时间到了锁被别的线程/进程“删”掉了——解锁逻辑没有校验持有者身份锁从一开始就没生效——两个线程用的 key 压根不一样。那次事故最终定位是第一类锁的过期时间是 30 秒而业务线程因为一次比较严重的 Young GC 停顿实际耗时超过了 30 秒。锁在第 30 秒过期随后任何访问都会把它判定为不存在线程B在第 31 秒加锁成功。所谓“锁明明还没过期”只是我们以为业务 5 秒内能跑完Redis 可不知道你的业务要跑多久它只知道这个 key 到点了该失效了。2. 过期时间保护的不是业务是“异常兜底”2.1 为什么锁必须带过期时间很多刚接触分布式锁的同学都会问能不能不加过期时间我执行完 finally 里删掉不行吗答案是不行。设想一个最简单的场景线程A成功加锁之后代码在 try 块里抛了一个非预期异常甚至整个进程直接宕机那么 finally 里的删除操作永远得不到执行。没有过期时间这把锁就会像一个没人认领的行李箱永远躺在 Redis 里后续所有线程都进不去临界区——这就是分布式锁的死锁问题。过期时间是分布式锁的“安全网”。它的本意是在异常情况下最多容忍这把锁被占这么久时间一到就强制回收。所以我的理解是它保护的是系统不被单个故障节点拖死而不是给业务执行时间做预算。把 TTL 当成“业务执行时限”是很多线上问题的起点。你以为 30 秒过期时间是“我预期业务 20 秒跑完留 10 秒余量”但 Redis 压根不管你业务怎么想它到点就废锁。2.2 SET NX EX 的三个隐藏语义Redis 从 2.6.12 开始支持把SETNX和EXPIRE合并成一条原子命令SET lock:order:20240001 uuid-xxx NX EX 30它有四个关键语义NX只有 key 不存在时才能设置成功这是互斥的基础EX 30key 的过期时间是 30 秒到期自动被回收整个命令是原子的不会出现“key 设置了但是过期时间没设置上”的中间状态set key value NX EX和旧的setnxexpire是两码事——后者一旦 expire 执行失败锁就会变成永久锁这是更早一代工程里很经典的坑。解锁一定不能用del key这种简单方式因为它没有校验 value。正确的是先用 GET 比较 value再用 DEL 删除——这两步又必须原子完成所以工程上统一用一段 Lua 脚本if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end这段脚本的含义是先看当前锁的 value 是不是我加锁时生成的 UUID是才删不是就什么都不做。这一条能挡掉相当一部分“锁被别人删了”的问题但它挡不了“锁自己过期被删”。2.3 业务执行时长与 TTL一场默认的赛跑Redis 对过期 key 的清理是惰性删除为主、定期删除为辅。放宽时间尺度锁一定会在第 30 秒附近失效。如果业务执行时长接近或超过 30 秒就会跟 TTL 赛跑。有一次我在一个老的工程里看到锁的 TTL 设置成了 5 秒而业务里还要调一个经常超时的下游 HTTP 接口接口平均耗时 3 秒偶发重试 3 次就是 9 秒TTL 5 秒必然被突破。这个问题的可怕之处在于它不是必现的。只有当 GC 停顿、网络抖动、下游慢调用这些“尾部延迟”叠加时才会冒出来。平时测试跑 100 次都不会出问题压测一上就原形毕露。所以“锁没到期却被抢”这个问题本质上是 TTL 设计哲学的问题过期时间到底该按“平均耗时”设还是按“最坏情况”设3. 五个真实根因锁到底是怎么“被抢”的3.1 根因一业务执行时间悄悄超过了TTL这是我在真实代码里见到过最多的一种。时间线整理如下时间事件T0线程A执行SET key v1 NX EX 30成功耗时开始计时T05s线程B尝试加锁失败进入等待重试T028s线程A业务还没结束卡在下游调用上T030skey 过期锁在 Redis 中失效T031s线程B重试成功进入临界区与还没退出的线程A同时执行发生在这个时候代码里往往还伴随着一个隐藏条件新的请求会判断库存是否充足如果两个线程同时扣了库存那库存数据直接就是错的。解决思路不是简单把 TTL 调到很大——那会把死锁的恢复时间也无限拉长。正确的做法下文会讲短 TTL 自动续期两者结合。3.2 根因二解锁时没校验身份把别人的锁删了这个根因的触发链路往往是这样的线程A加锁成功业务偏慢超过了 TTL锁已过期线程B加锁成功正在执行业务线程A执行终于走到 finally执行del key因为 value 没校验或者是固定字符串把线程B刚加上的锁删掉线程C趁虚而入加锁成功。到这一步临界区里已经有 B 和 C 两个线程在同时干活了A 反而像一个搅局者。更讽刺的是A 自己当初的任务其实已经做完了它只是“临走前”把别人家钥匙砸了。这种冲突最容易发生在“锁过期但没有续期机制业务又长”的系统里一旦 A 的删除动作晚于 B 的加锁连锁反应就开始了。解法只有一种加锁时 value 必须是全局唯一的UUID、雪花 ID、线程 ID 都行解锁用上一节那段 Lua 脚本先比后删原子执行。3.3 根因三锁key不一致争的不是同一把锁有一种“被抢”其实最冤因为另一个线程根本不是抢是光明正大进来的——它拿的是另一把锁。典型场景是我见过的一次代码评审一个团队把订单锁的 key 设计成lock:order:{orderId}同时下单入口有两处一处手动拼参数一处走了公共方法。由于公共方法在拼 key 之前对 orderId 做了一次 trim而手动入口没有 trim订单 ID 带不带空格导致 key 不一样。两个线程处理同一个订单却各自锁了各自的门。从日志看是“两个线程同时进了临界区”实际上它们俩谁都没拿错锁问题是锁的 key 设计让它们俩从来就没有互斥过。还有两个更容易踩的坑前缀不统一lock:order:和lock:orders:以及多环境/多实例连接的不是同一个 Redis 库。排查这种问题最有效的方法是使用 Redis-CLI 查看 key 的全量列表对比同一业务到底产生了多少种不同的锁 key。3.4 根因四主从切换期间锁同步丢失这是从“单实例可用性”往上走一层的问题。Redis 主从复制默认是异步的主库写入锁成功后就向客户端返回 OK这条写命令会异步发给从库。如果主库在从库还没收到这条命令时宕机哨兵或者集群会选举新的主库而新主库上没有这把锁。此时原本互相排斥的两个线程就可能各自创建同一把锁。这种场景最常见于 Redis Cluster 的故障转移窗口。它有几个显著特征发生时间正好卡在主从切换或集群故障转移前后锁 key 在切换前已经写入但切换后EXISTS查不到不是每次都触发取决于复制进度和切换时机。它的可怕之处在于不可复现。业界关于怎么解决一直有争论下文 RedLock 部分会展开说。这里先记住一个判断标准如果你对锁的强一致性要求很高就不应该让 Redis 单独承担全部责任。3.5 根因五Redis服务器时钟跳变让锁提前过期这个原因相对少见但真实存在。Redis 计算 key 过期时间用的是服务器本地时间。如果系统时间被 NTP 往前跳了比如回拨几秒甚至几十秒key 的剩余 TTL 会瞬间被压缩锁在业务还在执行时就被视为过期了。时钟往后跳则会走向另一个极端明明该删的锁没删TTL 反而变长了所有线程卡在临界区外排队。这个问题的根治方案是维护好服务器时间同步同时不要把分布式锁的 TTL 设置得过分敏感。很多人没意识到分布式锁不止依赖 Redis 的性能还依赖 Redis 服务器的时钟稳定性。在大规模部署的环境里NTP 并不是永远可靠的。4. 线上排查手册一步一步把“抢锁”现场还原出来4.1 先把日志埋对定位就成功了一半我排查这个问题的姿势第一次是在事故之后补日志补完才发现很多证据已经没了。后来我要求所有涉及分布式锁的关键路径至少必须打这么几行日志加锁请求发起key、value、当前线程名、时间戳加锁结果成功还是失败成功的话返回后的时间戳失败的话进入等待还是直接返回释放锁key、value、持有总时长、释放结果。有了这三行排起查来非常快。一个最简单的判断如果日志显示“线程A加锁成功”到“线程A释放锁”之间的时间差已经大于锁的 TTL那锁定根因一不需要再看 Redis 侧。反过来说如果持锁时间远小于 TTL 却仍然发生了并发那问题一多半出在 key 或者解锁身份校验上。4.2 Redis侧的现场证据怎么取如果应用日志对不上就要去 Redis 侧看。先回答三个问题这个锁 key 现在还在吗EXISTS一下还在的话TTL看剩余时间。这个 key 的 value 是谁GET一下看是否由当时的线程 UUID 生成。这个 key 在时间窗口内有没有被删除过有监控的前提下可以大概推断没有监控的话低峰期可以用MONITOR抓一小段时间观察有没有非 owner 的DEL命令。其中MONITOR在生产环境只能短暂使用它会把所有写命令实时打印出来比较耗性能。平时如果不是事故低峰期我不建议开太久够抓到一两次异常就够了。更推荐的做法是给 Redis 开 slowlog 和命令统计至少能反推删除命令的来源。4.3 一份可以直接抄的排查对照表把现象和对应根因放一起判断顺序就有了现象线索优先怀疑验证方式日志显示持锁时间超过 TTL根因一比较持锁时长与 TTL 设置解锁日志查看 value 不匹配但仍然执行了 del根因二检查解锁脚本和加锁时的 value 生成逻辑Redis 里同一业务产生了多个 key 后缀根因三用 SCAN 或 KEYS 查看锁 key 分布事故时间点附近有主从切换/故障转移记录根因四查看集群事件确认切换时间事故时间点附近有 NTP 时间调整根因五查看服务器 ntpd 日志有一次排查中我把这些线索排了一遍最后发现根因三是主因、根因二是次因两个线程各自走进了不同的门然后其中一把门又被误删了。排查这事严丝合缝的时间线比灵光一现的猜测可靠得多。5. 把“锁不丢”从运气变成制度工程化防御方案5.1 加锁解锁的三个原子性到这一步我们已经有了一份能落地的条例加锁命令必须是原子的SET key uuid NX EX ttl不能用先SETNX再EXPIRE的旧写法解锁命令必须是原子的Lua 脚本先比较 value 再删除释放锁的动作不能轻易放在业务正常路径之外一旦脱手就要想清楚“谁来续期、谁来清理”。代码层面一个相对可靠的最小实现长这样String key lock:order: orderId; String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(key, requestId, Duration.ofSeconds(30)); if (Boolean.TRUE.equals(locked)) { try { // 业务逻辑建议拆成独立方法 } finally { String script if redis.call(get,KEYS[1]) ARGV[1] then return redis.call(del,KEYS[1]) else return 0 end; redisTemplate.execute(new DefaultRedisScript(script, Long.class), List.of(key), requestId); } }很多人不理解为什么解锁非要用 Lua而不是先 GET 再 DEL。因为 GET 和 DEL 两条命令之间不原子T1 时刻比较完 value 发现确实是自己的T2 时刻锁被别人删了或过期了重建时DEL 就会删掉别人的锁。Lua 脚本在 Redis 里是单线程执行的中间没有间隙。5.2 续期机制看门狗的正确姿势既然业务时长不可控一个做法是给锁动态续期也就是“看门狗”思想。Redisson 的RLock内置了 WatchDog默认 leaseTime 是 30 秒每 10 秒检查一次只要当前线程还持有锁就自动把过期时间重置为 30 秒直到业务执行完或者线程中断。自己手写一个简单的续期调度器也不复杂需要注意几点只在“锁确实还在自己手里”的时候续期续期的命令仍然需要携带唯一 value业务结束后必须取消续期任务否则会在锁释放后继续做无谓的刷新续期的“崩溃保护”依然靠最终 TTL如果业务进程整个挂了调度器也停了锁最终会自然过期。现实中我用 Redisson 比较多是因为它的 WatchDog 把续期和释放都封装起来了省心。遇到那种需要自己控制 TTL 的项目我也会手写一个基于ScheduledExecutorService的续期线程。我要提醒的是续期不是万能药它只能解决“业务跑得比 TTL 久”这一种问题前面说的主从切换和时钟跳变它都防不住。5.3 分布式锁之外的最后防线幂等退一步讲分布式锁在分布式系统里只是一个“概率性”互斥工具。是 Redis 的错吗不是。Redis 本身就是 AP 模型它优先保证可用。所以防御思路不能只想着“把锁做得更牢”还要接受“锁可能丢”这个现实然后在业务层面留好兜底。在库存这类场景我常做的是三层数据库层面update t_stock set qty qty - ? where sku_id ? and qty ?让库存扣减走原子 SQL版本号/乐观锁update t_xxx set version version 1 where id ? and version ?防止覆盖业务幂等表消费端按 orderId 去重重复请求直接丢弃。分布式锁降低的是并发冲突的概率幂等与原子 SQL 兜住的是最坏情况。很多团队把分布式锁当成保险箱结果保险箱自己生锈了然后才发现门外连第二道锁都没有。5.4 RedLock要不要用我的最终选择关于主从切换丢锁Redis 官方给过一个 RedLock 方案向多个独立 Redis 实例轮流加锁超过半数成功才算加锁成功。但这套方案在业界有较大争议主要问题包括实例间没有同步时钟依赖系统时间可能失效主从分区时旧主节点可能未认证成功新的客户端也能拿到锁而且部署复杂、性能也差。我的取舍很简单如果项目对一致性要求极高且接受引入额外的中间件我会优先选 ZooKeeper 或 etcd 这类强一致协调中心锁的语义就清晰多了如果只是常规业务单实例 Redis 唯一 value Lua 解锁 看门狗续期已经够用。真正让我下决心不盲目追 RedLock 的是一次踩坑经历几个节点同时加锁结果最后一个实例响应慢了 20 秒其他线程已经执行完业务锁却还“锁着”这让业务端多等了好几秒。分布式锁的可靠性最终还是要结合业务场景来做取舍而不是把某个方案奉为银弹。经历过那次“锁没到期却被抢”的事故之后我在代码评审里对分布式锁的 value、TTL、释放时机格外敏感也习惯了用一句话检验手里的实现“如果这把锁在业务执行中丢了我的代码会不会出问题”锁是否过期从来不取决于你觉得它有没有过期而取决于 Redis 里的 TTL 计数器。另一个线程能抢进去原因从来不在“锁”这个字上而在于我们对锁的边界和兜底赋予了太高的默认值。后来我面试别人也喜欢问这道题能答出“业务时长超过 TTL 是主因、误删和 key 不一致是次因、主从切换是隐藏地雷”的人基本都有过真实踩坑的经历。