Redis分布式锁过期怎么办?看门狗续期与幂等兜底实战解析 写这篇的时候我先说个真实感受Redis 分布式锁这个问题看着只涉及一个“过期时间”参数真正掉坑里的人才知道这里是分布式系统里最典型的“你以为你在控制其实你根本没控制”的翻车现场。库存扣减、订单防重、任务调度随便哪个场景撞上“锁过期但业务没跑完”轻则数据不一致重则线上告警刷屏。我自己就经历过一次凌晨两点被叫起来查了半天发现是锁过期后两个线程同时进了临界区场景极其经典。所以这篇文章不打算讲 Redisson 用法那种入门内容而是把这个高频面试题背后的完整链路拆开从事故现场到看门狗续期再到自研续期的实现、锁粒度治理和幂等兜底全部捋一遍。不管你是刚接触分布式锁的新手还是已经在生产环境踩过坑的工程师这里面的细节应该都对你有用。1. 先看事故现场锁过期到并发失控的完整链条1.1 一段看似严谨的代码是怎么出事的先看一段很常见的实现很多人觉得自己已经考虑得很全面了加了锁、设了过期时间、释放前还校验了 value 是不是自己的简直滴水不漏String lockKey stock:1001:lock; String requestId UUID.randomUUID().toString(); Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, 5, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { // 扣减库存查询库存 - 计算剩余 - 更新数据库 doDeductStock(); } finally { String val redisTemplate.opsForValue().get(lockKey); if (requestId.equals(val)) { redisTemplate.delete(lockKey); } } }表面上看锁有过期时间5 秒自动释放避免死锁释放前先比对 value防止误删别人的锁。这套写法在很多教程里已经算“标准答案”了但线上跑起来就不是那么回事。假设 doDeductStock 里有一段第三方接口调用耗时 8 秒。前 5 秒一切正常锁在 Redis 里好好待着。到第 5 秒锁因为 TTL 到期自动消失。此时线程 B 过来抢锁发现锁不存在成功 set 成功也进入了扣减库存的逻辑。第 8 秒线程 A 终于执行完了进入 finally 块从 Redis 里取回的 value 是线程 B 的 requestId和 A 自己的不一致所以 A 没有删除 B 的锁。代码逻辑确实没有误删但麻烦已经造成了线程 A 和线程 B 在第 5 秒到第 8 秒之间已经同时在执行扣减库存的代码。很多人把注意力放在“释放锁时校验 value防止删掉别人的锁”上其实真正致命的不是“删错锁”而是“锁过期后两个线程同时进入了临界区”。误删锁只是让问题变得更明显但即使你没有误删并发访问的伤害也已经落地了。1.2 为什么过期时间总是很难定准这个问题的根源在于业务执行时间是动态的而锁过期时间是写死的两者之间根本不存在可靠的匹配关系。你可能会想那把过期时间设大一点不就行了比如直接设 30 秒、60 秒。这个思路有两个问题第一业务耗时不可控。一个接口平时 1 秒返回赶上数据库慢查询、第三方接口超时重试、Full GC 停顿可能就变成 10 秒、30 秒。你把过期时间设为 60 秒总有一天会遇到超过 60 秒的业务到时候问题照旧。第二过期时间设置过长兜底退路会断掉。分布式锁设置过期时间本来是为了防止持锁进程崩溃导致锁永久不释放。如果业务进程在拿到锁之后直接宕机或者被 kill锁就会一直躺在 Redis 里直到 TTL 到期。过期时间设得越长这个“死锁”持续的时间就越久后面所有请求都会卡在抢锁这一步。所以我个人的判断是分布式锁的过期时间本质上是用一个静态阈值去约束动态执行过程这条路天然就有缺陷。真正合理的解法不是把阈值调大而是让“锁的过期时间”跟着“业务执行进度”一起动也就是业界最常见的“看门狗”自动续期机制。2. 解法一Redisson 看门狗让锁跟着业务走2.1 看门狗到底在做什么看门狗Watchdog这个叫法很形象你可以把它理解成一个默默守在旁边的警卫盯着的不是有没有人偷东西而是这把锁的“租约还剩多久”。Redisson 的看门狗机制核心就一句话当锁没有指定 leaseTime 时默认给锁设置 30 秒过期时间同时启动一个后台定时任务每 10 秒检查一次锁是否还在。只要锁还在就把过期时间重新设置为 30 秒相当于不断“续租”。这里有两个关键细节需要理解。第一续期不是无脑刷新 TTL。Redisson 在续期时会用 Lua 脚本先校验锁的 value 是否还是当前线程持有的标识如果锁已经易主value 不对就不会再续期。这一点很重要因为如果锁已经被别人抢走了你还去续期那就等于变相延长别人的锁会造成更严重的并发问题。第二看门狗只有在业务线程存活时才会持续续期。如果业务代码执行完毕finally 里调用了 unlock锁被主动释放看门狗任务也会随之取消。如果业务进程直接崩了看门狗线程也会跟着死掉锁会在剩余的 TTL 之后自动过期不会造成永久死锁。用 Redisson 的实现非常简单RLock lock redissonClient.getLock(stock:1001:lock); lock.lock(); try { // 业务逻辑随便跑多久 doDeductStock(); } finally { if (lock.isHeldByCurrentThread()) { lock.unlock(); } }使用 lock() 不传参数就会触发看门狗。这段代码里doDeductStock 哪怕跑 5 分钟只要线程还活着锁就会一直续期不会被 Redis 强行释放。这也就解决了前面那个“业务没跑完锁就没了”的核心痛点。2.2 一个很容易被忽略的 API 陷阱Redisson 的 lock() 方法有几个重载如果你调用的是带 leaseTime 的版本比如lock.lock(10, TimeUnit.SECONDS);这种情况下Redisson 会认为你明确指定了锁的过期时间不会再启动看门狗。10 秒一到锁就自动释放不管业务有没有跑完。这个设计其实是有道理的业务方主动传入过期时间说明业务方对执行时长有较强的掌控能力或者就是想限制锁的最长持有时间防止异步任务挂起时锁一直不释放。但实际使用中最容易翻车的点就在这里有的人在代码里想用 Redisson但传了 leaseTime以为看门狗还在工作结果锁超时释放业务却还没跑完就又回到了最原始的“锁过期”问题。所以建议是需要看门狗就调用 lock() 或者 lock(long waitTime, TimeUnit unit) 这种不带 leaseTime 的重载如果你的业务确实需要限定锁的最长持有时间那就明确接受“锁到点就释放”这个行为并且在业务层面做好兜底。看门狗的性能开销也是一个值得说的点。每 10 秒一次续期其实就是一个 Lua 脚本的 Redis 调用开销非常小。一个 Redis 实例每秒处理几万次请求都很轻松所以完全不用担心看门狗会影响性能真正要注意的反而是业务线程和看门狗线程的生命周期管理。3. 解法二自研轻量续期不引入 Redisson 也能稳3.1 续期任务的整体设计与核心代码有些团队没引入 RedissonRedis 操作是通过自封装的 RedisTemplate 或者别的客户端包一层做的。这时候为了一个看门狗就去引一个大框架改动面太大不值得。自研一个轻量的续期机制其实也不难核心思路就是开一个定时任务在锁过期之前主动重置 TTL。我直接给一段可以落地的 Java 示例基于 RedisTemplatepublic boolean executeWithRenewal(String lockKey, Runnable business) { String requestId UUID.randomUUID().toString(); long expireSeconds 30L; long renewIntervalSeconds 10L; Boolean locked redisTemplate.opsForValue() .setIfAbsent(lockKey, requestId, expireSeconds, TimeUnit.SECONDS); if (!Boolean.TRUE.equals(locked)) { return false; } AtomicBoolean finished new AtomicBoolean(false); ScheduledExecutorService scheduler Executors.newSingleThreadScheduledExecutor(); scheduler.scheduleAtFixedRate(() - { if (finished.get()) { return; } String currentVal redisTemplate.opsForValue().get(lockKey); if (requestId.equals(currentVal)) { redisTemplate.expire(lockKey, expireSeconds, TimeUnit.SECONDS); } }, renewIntervalSeconds, renewIntervalSeconds, TimeUnit.SECONDS); try { business.run(); return true; } finally { finished.set(true); scheduler.shutdownNow(); String finalVal redisTemplate.opsForValue().get(lockKey); if (requestId.equals(finalVal)) { redisTemplate.delete(lockKey); } } }这里有几个参数可以按业务情况调整。我习惯把初始过期时间设为 30 秒续期周期设为 10 秒也就是每 10 秒检查一次把锁的生命周期又拉回到 30 秒。为什么要留出 20 秒的余量因为如果某次续期因为网络抖动或者线程调度延迟失败了锁不会立刻过期还有至少两个续期周期的缓冲。极端情况下续期任务连续失败两次锁会在第 30 秒左右自然过期也不至于无限期持有。3.2 自研续期的几个容易踩的坑第一坑续期任务必须和业务线程生命周期强绑定。上面的代码里finally 块先置 finished 为 true再关闭 scheduler最后释放锁。顺序不能乱如果先删锁再关闭定时任务中间可能会有一次续期操作在删锁后执行把一把已经不存在的锁重新设置上过期时间那就成了幽灵锁后续线程永远抢不到锁。我建议的顺序是先停掉续期动作再释放锁两者之间用原子变量做开关。第二坑续期时一定要校验 value。续期操作本质上也是操作 Redis 里的那个 key如果锁已经被别的线程持有了你还在给它续期那就等于在帮别人延长锁会让并发问题更严重。所以续期前用 requestId 做一次校验表面上看是多一次 GET 请求实际上是把安全性补齐了。第三坑ScheduledExecutorService 线程池要复用不要在每次加锁时都新建。上面的示例中每次调用都会创建单线程调度器这是为了让代码可读性更好。实际生产环境里应该把 scheduler 定义成静态成员或者 Spring Bean否则高并发场景下频繁创建线程池代价很高。另外续期任务本身也可能因为业务线程阻塞太久而堆积。scheduleAtFixedRate 的语义是如果上一个任务还没执行完下一个任务会等待这就会导致续期节奏被打乱。我见过一个极端情况业务线程在锁内做了一次很慢的 RPC 调用导致续期任务前面的任务排队等到它真正执行续期时锁已经过期了。这种问题靠自研代码很难完全规避本质上还是要把核心执行时长降下来这一点后面会展开讲。4. 解法三缩小临界区才是更根本的治理方向4.1 把耗时的重活从锁里挪出去不管是看门狗还是自研续期本质上都是在“补漏”并没有改变一个事实锁保护的临界区里包含了太多不该有的操作。与其想方设法延长锁的生命周期不如先审视一下锁里面到底放了什么东西。我见过很多分布式锁性能问题的根源是锁内同时做了这些事查数据库余额、调外部风控接口、计算折扣、更新库存表。从头到尾一遍网络耗时加数据库耗时随便就上秒级。这种设计的问题在于锁不仅保护了资源状态还绑架了业务流程的执行权导致一个线程持锁时间过长后面所有请求都在抢锁等待。合理的做法是锁内只做状态判断和标记把耗时操作放到锁外或者改成异步执行。改造前lock.lock(); try { // 查询余额 BigDecimal balance queryBalance(accountId); // 调用外部风控接口耗时可能 3 秒 boolean pass riskControl(accountId); if (pass balance.compareTo(amount) 0) { deduct(accountId, amount); } } finally { lock.unlock(); }改造后lock.lock(); try { // 锁内只做快速预占检查并标记状态耗时控制在毫秒级 boolean canDeduct tryMarkDeduct(accountId, amount); if (canDeduct) { // 真正的外部调用和扣款放到异步线程或消息队列里 asyncDeductExecutor.submit(() - { boolean pass riskControl(accountId); if (pass) { deduct(accountId, amount); } else { rollbackMark(accountId, amount); } }); } } finally { lock.unlock(); }这种改造的好处很明显锁的持有时长从秒级降到毫秒级锁过期概率大幅降低即使用了续期机制压力也小很多。4.2 锁粒度拆分与异步串行化的实际经验除了缩小临界区还有一个经常被忽略的点锁的粒度。两个交易的锁如果完全没有交集却使用了同一个全局锁不仅性能差还会让锁冲突概率变高。举个典型的例子扣减库存时锁 key 可以设计为 stock:{skuId}:lock而不是 stock:global:lock。用户维度的防重提交锁 key 可以用 user:{userId}:submit:lock。锁粒度越细并发能力越强同时锁内持有时长也会因为冲突减少而变得更稳定。另外很多场景其实不需要分布式锁而是可以用“串行化”来解决。比如订单创建后需要异步处理可以丢进消息队列由单消费者线程处理天然的串行执行就没有并发冲突。或者用数据库本身的唯一约束在表上建一个唯一索引两个并发请求同时插入数据库层面只会成功一个。这些方案比分布式锁更可靠因为不需要担心锁过期、锁丢失、时钟跳跃这些分布式锁的固有问题。我个人的经验是分布式锁在技术上要有但只是第一道防线不是唯一防线。把架构设计成“锁内只做快速状态流转锁外异步处理耗时业务数据库兜底防重”才是线上最稳的组合。5. 兜底方案就算并发进来了数据也不能乱5.1 释放锁必须用 Lua 脚本才能保证原子性前面说了一堆续期和粒度治理但不管怎么做没有任何方案能保证锁绝对不会被突破。网络分区、主从切换、进程长时间 Full GC都可能在极端情况下让两个线程同时拿到同一个锁。这时候最后的保护是业务数据本身不能被破坏。先解决释放锁时的经典问题为什么我强调释放前要校验 value但还是有人会踩坑因为校验和删除不是原子操作。你 GET 一次拿到值比对通过准备 DELETE就在这个间隙锁的 TTL 刚好到期另一个线程设置成功你的 DELETE 把别人的锁删了。所以严谨的写法必须用 Lua 脚本让“比对 value 删除 key”在 Redis 端一个原子操作完成if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 endRedisTemplate 调用方式大致是这样DefaultRedisScriptLong releaseScript new DefaultRedisScript(); releaseScript.setScriptText( if redis.call(get, KEYS[1]) ARGV[1] then return redis.call(del, KEYS[1]) else return 0 end ); releaseScript.setResultType(Long.class); Long result redisTemplate.execute( releaseScript, Collections.singletonList(lockKey), requestId );这段脚本的意义在于无论怎么 GET、DELETE都不会出现“删错锁”的中间态。Redisson 内部释放锁用的也是类似思路不是简单的一个 DELETE 命令。5.2 幂等设计锁失效后的最后防线如果说 Lua 脚本解决的是“误删锁”那业务幂等解决的是“两个线程真的同时进来了怎么办”。这是最后一道防线也是最不容妥协的一道。以一个最简单的扣库存场景为例即使分布式锁被突破只要数据库层面做了唯一约束或者表结构里带有“状态判断”第二个请求进来时会因为状态不匹配而失败数据就不会被重复扣减。我常用的做法是加一张操作流水表或者叫防重表核心字段包括业务主键、操作类型、流水号。在写流水表时给业务主键加唯一索引。两个线程同时执行只有一个能成功插入流水另一个插入冲突直接失败业务数据也不会被破坏。设计状态机也是个好办法。比如订单状态从“待支付”到“已支付”更新时带上状态条件int updated orderMapper.updateState( orderId, oldState, // 预期的当前状态比如待支付 newState // 目标状态已支付 ); // updated 1 才表示更新成功 if (updated 1) { // 执行后续动作 }这种乐观锁方案不依赖 Redis纯靠数据库的原子更新来保证只有一个线程能完成状态流转。它和分布式锁不是替代关系而是互补关系分布式锁尽量阻止并发进入幂等和状态机确保即使进入也不会造成破坏。6. 面试官到底想考什么以及怎么答才算有水平6.1 透过问题看本质“Redis 分布式锁过期了还没处理完怎么办”这个问题在面试中特别高频几乎成了分布式相关的必考题。但我觉得面试官并不是真的想听你背诵 Redisson 的看门狗机制而是想通过这个问题考察两件事第一你是否理解分布式锁在真实业务中的边界。很多候选人能说出来有看门狗能说出来续期但再追问一句“锁会完全可靠吗”就答不上来了。其实完整的回答应该包含三个层次自动续期解决“锁不够用”的问题业务代码治理解决“锁被用得不好”的问题幂等兜底解决“锁被突破后数据不坏”的问题。第二你是否具备实战踩坑的敏感度。如果只是照本宣科说 Redisson 的默认参数说明没经历过线上问题。但如果你能讲出“锁内不做 RPC”“释放锁必须用 Lua 脚本”“业务要设计幂等机制”这些实战细节面试官就会觉得你真的处理过这类问题。6.2 常见追问的参考回答方向看门狗默认过期时间和续期周期是多少Redisson 默认锁过期时间 30 秒续期周期是 10 秒。每次续期会把锁过期时间重新设置为 30 秒。为什么不直接把过期时间调得很大因为过期时间既是“业务执行时长上限”也是“进程崩溃后的死锁时间上限”。调得太大业务是够用了但一旦持锁进程宕机锁要很久才释放会对后续请求造成长时间阻塞。如果 Redis 是主从部署锁在主节点上主节点刚写入锁就宕机了从节点还没同步锁丢了怎么办这时可以使用 RedLock 算法向多个 Redis 节点申请锁超过半数节点成功才算持有。但 RedLock 本身也存在争议比如遇到 GC 停顿或时钟跳跃时依然有不可靠的可能。所以我在实际项目中更倾向用数据库唯一约束或业务幂等做最终兜底而不是过分依赖分布式锁。这个回答既能体现你对 RedLock 有了解又能体现你更看重工程上的可落地性。自研看门狗时如何避免锁被无限续期两点续期时校验 value 是否是当前线程的业务线程结束后立即停止续期任务。同时给续期任务一个最大执行次数或者超时上限防止异常情况下任务一直跑。看门狗线程和业务线程的关系是什么看门狗依赖业务线程存活。业务线程执行完毕锁会被释放看门狗后续的续期动作也会停止。如果业务线程阻塞但不退出看门狗会一直续期所以锁内不能有长时间阻塞的操作最好用异步方式把耗时操作移出去。面试中把这些点答出来再配上自己真实的线上排查经历基本上就能让对方相信你确实是在实战中积累的经验。7. 我自己的一点实战心得最后聊点个人体会。刚开始接触 Redis 分布式锁的时候我花了很多时间研究续期实现把看门狗参数调来调去后来发现线上真正稳定运行的系统并不只是靠一个完美的续期方案。有一次排查一个库存超卖问题最终定位到的是一个锁内的第三方接口耗时超过锁过期时间当时第一反应是延长锁过期时间后来冷静下来把接口调用挪出锁锁内执行时间从 4 秒变成了 50 毫秒问题彻底消失。所以我现在遇到“锁不够用”的问题会先问自己三件事临界区里有没有不该放的耗时操作锁粒度能不能更细业务数据有没有幂等兜底这三件事做完续期方案反而变成了一个锦上添花的补充。如果你也在被这个问题困扰建议从这三个方向依次排查大概率能找到比“调大过期时间”更靠谱的出路。