news 2026/10/5 7:14:44

Redis分布式锁过期怎么办?看门狗续期与幂等兜底实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
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 续期任务的整体设计与核心代码

有些团队没引入 Redisson,Redis 操作是通过自封装的 RedisTemplate 或者别的客户端包一层做的。这时候为了一个看门狗就去引一个大框架,改动面太大,不值得。自研一个轻量的续期机制,其实也不难,核心思路就是:开一个定时任务,在锁过期之前主动重置 TTL。

我直接给一段可以落地的 Java 示例,基于 RedisTemplate:

public 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 end

RedisTemplate 调用方式大致是这样:

DefaultRedisScript<Long> 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 毫秒,问题彻底消失。

所以我现在遇到“锁不够用”的问题,会先问自己三件事:临界区里有没有不该放的耗时操作?锁粒度能不能更细?业务数据有没有幂等兜底?这三件事做完,续期方案反而变成了一个锦上添花的补充。如果你也在被这个问题困扰,建议从这三个方向依次排查,大概率能找到比“调大过期时间”更靠谱的出路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/5 7:14:39

空号检测接口对接避坑指南:从鉴权签名到线上故障排查

做短信营销、用户运营或者呼叫中心的朋友&#xff0c;对“空号检测接口”应该都不陌生。这东西从功能上看很简单——传一个手机号&#xff0c;接口返回一个状态&#xff1a;实号、空号、停机。但真正做技术对接的时候&#xff0c;从鉴权到签名、从超时到误判、从扣费对账到回调…

作者头像 李华
网站建设 2026/10/5 7:14:07

YOLOv11移动端部署实战:剪枝、量化与推理优化全解析

简介&#xff1a;这是一套面向YOLOv11目标检测开发者的移动端部署与轻量化实战文档&#xff0c;适合希望在手机、嵌入式开发板上高效运行模型的算法工程师与学习者。文档从深度学习模型轻量化背景切入&#xff0c;系统讲解模型剪枝、量化、知识蒸馏与轻量化网络结构设计&#x…

作者头像 李华
网站建设 2026/10/5 7:14:04

python不是内部或外部命令?彻底搞定PATH环境变量配置

“python 不是内部或外部命令”这句话&#xff0c;每年都能拦下一大批刚入门的同学。你明明照着教程把安装包下载好、双击运行、一路点 Next&#xff0c;可窗口一关&#xff0c;打开命令行敲一个python&#xff0c;系统却像不认识它一样。问题多半不在安装过程&#xff0c;而在…

作者头像 李华
网站建设 2026/10/5 7:13:42

DeepSeek平台15天实战:API调用、上下文管理与本地部署全攻略

简介&#xff1a;面向AI技术初学者及办公、科研、自媒体、学生等群体的DeepSeek 15天指导手册&#xff0c;系统拆解从账号注册、基础对话到文档解析、代码生成、自动化流程搭建的进阶路径&#xff0c;覆盖学术论文辅助、新媒体运营、学习规划、跨语言翻译等高频场景&#xff0c…

作者头像 李华
网站建设 2026/10/5 7:13:42

SpringBoot2+Vue3+MyBatis-Plus物流管理系统实战开发指南

1. 从项目全景入手&#xff1a;先看懂这套物流系统到底在做什么很多人一听到物流管理系统&#xff0c;第一反应就是“这不就是一套带增删改查的进销存吗”。说实话&#xff0c;我一开始也这么想过&#xff0c;但真正把SpringBoot2、Vue3、MyBatis-Plus、MySQL8.0这一整套组合搭…

作者头像 李华
网站建设 2026/10/5 7:13:34

Ubuntu 下 LabVIEW 完整安装指南:VIPM 配置与依赖库管理

如果你手里有一台装了 Ubuntu 的机器&#xff0c;又需要在里面跑 LabVIEW 做数据采集、自动化测试或者视觉检测&#xff0c;第一反应多半是去官网下载 Windows 安装包&#xff0c;然后发现官网给的是 rpm、sh&#xff0c;甚至没有现成的 Linux 版。我前阵子在一台 Ubuntu 20.04…

作者头像 李华