ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Redisson分布式锁实战:可重入、看门狗续期与锁超时调优

Redisson分布式锁实战:可重入、看门狗续期与锁超时调优

分布式锁这个话题在后台服务里出现的频率极高,几乎每个做过多实例部署的团队都会碰到。我最早接触这块是在一个订单履约系统里,当时两个节点同时处理同一笔退款,账目直接对不上,排查了大半天才定位到是并发写冲突。后来试过基于数据库唯一索引、基于缓存原子命令自己手写、基于临时有序节点等几种方案,兜兜转转最后在多数业务场景里落到了 Redisson 上。这篇就围绕Redisson 实现分布式锁这个主题,把我这些年踩过的坑、调过的参数、想明白的原理,尽量完整地摊开讲一遍。

先说清楚这篇适合谁看:如果你已经有多实例部署的服务,正在为“同一份资源被两个进程同时改”发愁,或者你已经在用 Redisson 但只是lock()一把梭、没深究过续期和可重入,那这篇会对你有用。如果你还没接触过分布式协调,也没关系,我会把加锁的本质、为什么单机锁不够用这些前置知识用生活化的方式讲明白,再往深里走。核心关键词会围绕分布式锁、可重入、看门狗续期、锁超时、红锁这几个点展开,读到后面你会发现它们其实是环环相扣的一整套设计。

1. 先把问题想透:为什么单机锁在分布式场景下会失效

1.1 从一把“本地锁”的局限说起

单机环境下的锁,本质是 JVM 内部的一块内存状态。你用synchronized或者ReentrantLock,锁的持有者、等待队列、重入计数全都存在当前进程的堆里。同一个进程内的多个线程来抢,大家看的是同一份状态,所以能互斥。可一旦服务扩成两个实例,A 实例的锁状态在 B 实例的内存里根本不存在,B 的线程压根不知道 A 已经锁住了,于是两边同时进临界区,互斥就破了。

打个生活化的比方:单机锁像是你家卫生间的门锁,家里人排队用没问题;分布式锁则像是公司茶水间唯一一台咖啡机的使用牌,牌子得放在一个所有人都能看到的地方,谁拿到牌子谁用,用完挂回去。这个“所有人都能看到的地方”,就是分布式锁的存储载体,通常是一块所有节点都能访问的共享存储。

所以分布式锁要解决的核心问题就三个:互斥(同一时刻只有一个持有者)、可见性(所有节点都能看到锁状态)、可靠性(持锁节点挂了锁不能永远不释放)。这三个词看着简单,真正难的是在网络分区、节点宕机、时钟漂移这些异常情况下还能尽量守住它们。

1.2 自研分布式锁常见的三个坑

在落到 Redisson 之前,很多团队会先自己基于缓存的原生命令手写一版,SET key value NX PX 30000这种写法网上一搜一大把。我早期也这么干过,结果踩了三个典型的坑,正好说明为什么后来要换成熟组件。

第一个坑是误删别人的锁。A 拿到锁后业务执行超时,锁自动过期了,B 紧接着拿到锁;这时 A 执行完去DEL key,把 B 的锁给删了。解决办法是 value 存一个唯一标识(比如 UUID 加线程 ID),删除前先比对。但“比对再删除”这两步不是原子的,中间还是可能插进别的操作,得靠 Lua 脚本把比对和删除打包成一个原子动作。

第二个坑是锁过期但业务没执行完。你设了 30 秒过期,结果某次数据库慢查询拖了 40 秒,锁提前释放,互斥失效。有人会说那就把过期时间设长点,设成 5 分钟。可一旦持锁节点真的宕机,锁要 5 分钟才释放,这期间所有请求全堵着,可用性又崩了。过期时间短了不安全、长了不灵活,这个矛盾靠固定值根本解不开,必须有“业务还活着就自动续期”的机制。

第三个坑是不可重入。同一个线程在方法 A 里加了锁,调用方法 B 时又加同一把锁,如果实现不支持重入,自己就把自己锁死了。业务代码里这种嵌套调用太常见了,所以可重入几乎是刚需。

这三个坑恰好对应了 Redisson 的三个核心能力:唯一标识加 Lua 原子释放、看门狗自动续期、基于 Hash 结构的可重入计数。理解了坑,再看它的设计就会觉得顺理成章。

1.3 Redisson 在方案选型中的位置

市面上的分布式锁方案大致分几类:基于关系型数据库(唯一索引或行锁)、基于缓存(原子命令加 Lua)、基于协调服务(临时有序节点)。数据库方案实现简单但性能一般,且锁释放依赖应用主动操作,宕机后容易残留;协调服务方案一致性较好但引入的组件重、运维成本高;缓存方案性能高、部署轻,是大多数互联网业务的首选。

Redisson 就是缓存方案里封装得比较完整的一个客户端。它把加锁、释放、续期、重入、等待唤醒这些细节都包进了RLock接口里,底层用 Lua 脚本保证原子性,用发布订阅做锁释放的通知,用后台线程做续期。选它的理由很实际:不用自己维护那一堆 Lua 脚本和边界条件,出问题的概率低。当然,封装得深也意味着你得知道它内部在干什么,否则参数配错了照样出事,这也是后面要重点拆的部分。

2. 核心机制拆解:Redisson 到底在背后做了什么

2.1 加锁的原子性与 Hash 结构设计

Redisson 的锁在缓存里不是存一个简单的字符串,而是一个 Hash 结构。key 是锁的名字,field 是“客户端唯一标识 + 线程 ID”,value 是重入次数。为什么这么设计?因为要同时满足两个需求:一是能区分“这把锁是谁持有的”,二是能记录“同一个持有者重入了几次”。

加锁时执行一段 Lua 脚本,逻辑大致是:先判断 key 是否存在,不存在就直接写入 field 和计数 1,并设置过期时间;存在的话判断 field 是不是自己的,是自己的就把计数加一,不是自己的就返回当前剩余过期时间,让调用方知道还要等多久。整段脚本在缓存服务端原子执行,避免了“判断”和“写入”之间被别的请求插队。

这里有个细节值得说:过期时间是在第一次加锁时设置的,重入时不会重置。也就是说重入只是把计数加一,锁的整体存活时间还是从第一次加锁算起。这个设计是有意为之,否则一个反复重入的业务可能让锁无限续命,掩盖了真正的执行超时问题。理解这一点,后面调看门狗参数时就不会困惑。

2.2 可重入计数与释放的配对逻辑

可重入的实现靠的就是那个计数。线程第一次加锁计数为 1,重入一次变 2,释放时每调一次unlock()计数减一,减到 0 才真正删除 key 并触发释放通知。这就要求加锁和释放必须严格配对,加了几次就得解几次。

我见过不少事故是lock()之后在某个分支里return了却没unlock(),计数永远减不到 0,锁只能等过期。所以实操上我一律建议用try/finally把unlock()包起来,哪怕你觉得这段代码不可能抛异常。另外要注意,unlock()如果在一个没持有锁的线程里调用,会直接抛异常,这其实是好事,能帮你尽早发现配对错误,别去 catch 掉它。

释放同样走 Lua 脚本:判断 field 是不是自己的,是就把计数减一,减完如果大于 0 就更新计数并续一下过期时间,等于 0 就删除 key 并发布一条释放消息。删除和发布消息打包在一个脚本里,保证“锁没了”和“通知等待者”这两件事不会脱节。

2.3 看门狗续期的触发条件与边界

看门狗是 Redisson 最容易被误解的机制。很多人以为只要用了 Redisson 就自动续期,其实不是。只有加锁时没有显式指定过期时间(也就是调用无参的lock()),看门狗才会启动。如果你写的是lock(30, TimeUnit.SECONDS),那就是固定 30 秒过期,不会续期,到点就释放,哪怕业务还在跑。

看门狗的默认续期周期是锁过期时间的三分之一,默认锁过期时间是 30 秒,所以大约每 10 秒续一次,每次把过期时间重置回 30 秒。这个“三分之一”的比例是有讲究的:续期太频繁会给缓存带来额外压力,续期间隔太长又可能在两次续期之间锁就过期了,三分之一是个兼顾两者的经验值。

但看门狗不是万能的。它依赖持锁进程还活着、还能和缓存通信。如果进程假死(比如长时间 Full GC)或者网络断了,续期线程跑不起来,锁照样会过期。所以看门狗解决的是“业务执行时间不确定”的问题,解决不了“持锁者失联”的问题,后者只能靠合理的过期时间兜底。这一点想不清楚,就容易在看门狗上寄托过多期望。

2.4 等待唤醒:发布订阅如何减少空转

当锁被占用时,等待的线程怎么办?最简单的做法是自旋,隔一小段时间重试一次。但自旋在锁竞争激烈时会疯狂打缓存,既浪费资源又增加延迟。Redisson 用的是发布订阅:抢锁失败后,当前线程订阅这个锁的释放频道,然后阻塞等待;持锁者释放时发布消息,等待者被唤醒再去抢。

这个机制的好处是等待期间不消耗 CPU 和网络。但它也有个边界:订阅关系是基于连接的,如果等待期间连接断了,唤醒消息可能收不到。Redisson 对此做了兜底,等待是有超时时间的,超时后会重新走一遍抢锁流程,不会无限挂死。理解这个机制,你就明白为什么锁释放后等待者不是“立刻”拿到锁,而是有个被唤醒再竞争的过程,高并发下这个微小延迟是正常的。

3. 上手实操:从依赖引入到一把可靠的锁

3.1 环境准备与客户端配置

先把依赖引进来。以 Maven 为例,加一个 Redisson 的 starter,版本建议选和你的缓存服务版本兼容的稳定版,别盲目追最新。引入后要做的是配置客户端,这一步很多人直接抄个单机地址就完事,其实有几个参数值得认真设。

singleServerConfig: address: "redis://127.0.0.1:6379" connectionMinimumIdleSize: 8 connectionPoolSize: 32 timeout: 3000 retryAttempts: 3 retryInterval: 1500

connectionPoolSize要根据你的并发量估,锁操作本身很快,但连接不够时请求会排队,表现为“莫名其妙变慢”。timeout是命令超时,设太短会在缓存抖动时频繁抛异常,设太长又会让故障感知变迟钝,3000 毫秒是个常见的起点。retryAttempts和retryInterval控制重试,注意这里的重试是针对连接层面的,和业务层的抢锁重试不是一回事,别混淆。

配置客户端时还要想清楚用单机、主从还是集群模式。单机最简单但没高可用;主从在主节点宕机、从节点晋升的瞬间,如果锁还没同步过去,可能出现两个持有者,这是异步复制的固有风险;集群模式把锁分散到不同分片,单分片故障影响面小一些。选哪种取决于你对一致性和可用性的权衡,没有银弹。

3.2 三种加锁方式的取舍

Redisson 的RLock提供了几种加锁入口,用哪种直接决定了出问题时的表现。

第一种是lock(),无参调用,阻塞直到拿到锁,拿不到就一直等,同时启动看门狗续期。适合“必须拿到锁才能继续、且业务执行时间不确定”的场景。风险是如果锁被一个失联的节点长期占着(虽然会过期,但过期前一直等),调用线程会一直阻塞,可能拖垮线程池。

第二种是lock(leaseTime, unit),指定过期时间,不续期。适合“业务执行时间可控、能明确估出上限”的场景。好处是即使持锁者挂了,锁最多占用 leaseTime 就释放,不会长期阻塞。代价是你得对业务耗时心里有数,估短了会提前释放。

第三种是tryLock(waitTime, leaseTime, unit),带等待时间和过期时间,拿不到锁等 waitTime 就返回 false,不无限阻塞。这是我在生产里用得最多的一种,因为它把“等多久”和“持多久”都显式化了,行为可预期。

RLock lock = redissonClient.getLock("order:refund:" + orderId); boolean acquired = false; try { acquired = lock.tryLock(3, 10, TimeUnit.SECONDS); if (!acquired) { // 没抢到锁,走降级或直接返回 return Result.busy(); } // 临界区业务 doRefund(orderId); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { if (acquired && lock.isHeldByCurrentThread()) { lock.unlock(); } }

这段代码有两个关键点:isHeldByCurrentThread()判断当前线程是否真的持有锁,避免在没持有时调unlock()抛异常;acquired标志确保只有抢到锁才释放。别小看这两行,线上很多“锁释放异常”的告警就是漏了它们。

3.3 锁粒度与 key 设计

锁的粒度直接决定并发度。锁太粗,比如用一个大 key 锁住所有订单操作,那所有请求串行,吞吐直接崩;锁太细,比如每个字段一把锁,又会让业务逻辑复杂到难以维护。我的经验是按业务聚合根来定粒度,订单退款就锁单个订单 ID,库存扣减就锁单个商品 ID,这样同一资源的操作互斥,不同资源并行。

key 的命名要有统一规范,建议用“业务域:资源类型:资源 ID”的格式,比如order:refund:12345。统一前缀的好处是排查时能快速定位,也方便做监控和清理。千万别用会动态变化、带随机数的 key,那样锁根本起不到互斥作用,还查不出问题。

还有一个容易忽略的点:锁的 key 和业务数据的 key 不要混用同一个命名空间。曾经有个项目把锁 key 和缓存数据 key 用了相同前缀,结果清理缓存时把锁也删了,互斥瞬间失效。这种问题排查起来极其痛苦,提前规范命名就能避免。

3.4 释放锁的正确姿势

释放锁看着简单,其实讲究不少。前面说了要用try/finally加isHeldByCurrentThread()判断,这里再补两个细节。

一是不要在 finally 里吞异常。unlock()本身可能因为连接问题抛异常,如果你 catch 了不处理,锁可能没真正释放,只能等过期。合理的做法是记录日志并告警,让运维知道有锁释放失败,而不是静默吞掉。

二是释放要及时。临界区代码执行完立刻释放,别把日志打印、远程调用这些耗时操作放在锁里面。我见过把一次 HTTP 调用放在锁内的,结果锁持有时间从毫秒级涨到秒级,并发能力断崖式下跌。原则是:锁内只放必须互斥的最小逻辑,其他都挪到锁外。

4. 参数调优与异常场景:那些文档里不会写的经验

4.1 过期时间到底怎么估

过期时间设多少,是分布式锁里最需要动脑子的参数。设太短,业务没跑完锁就没了,互斥失效;设太长,持锁者挂了之后锁长期不释放,其他请求全堵着。我的估算方法是:先测出临界区业务在 P99 分位下的耗时,再乘以 2 到 3 的安全系数。

举个例子,某次退款业务 P99 耗时 800 毫秒,那 leaseTime 设 3 秒左右比较稳妥,既覆盖了绝大多数情况,又不会在异常时占用太久。如果你用了看门狗(无参lock()),那过期时间其实是“续期周期”的基准,默认 30 秒够用,但如果你把默认值改小了,要确保续期周期仍然大于业务单次执行时间,否则续期还没到锁就过期了。

这里有个反直觉的点:不是所有场景都适合看门狗。如果你的业务耗时稳定且短,用固定 leaseTime 反而更可控,因为锁的释放时间是可预期的,不会出现“业务卡住导致锁一直被续期”的情况。看门狗适合的是“耗时波动大、难以预估上限”的场景,比如涉及外部依赖调用的批处理。

4.2 锁等待超时与线程池的联动

tryLock的 waitTime 设多少,要和你的线程池配置一起考虑。假设线程池有 50 个线程,waitTime 设 10 秒,那在最坏情况下,50 个线程可能同时阻塞 10 秒等锁,这期间线程池没有余力处理其他请求,可能引发级联超时。

我的做法是waitTime 设短一些,比如 1 到 3 秒,抢不到就快速失败走降级。降级可以是返回“系统繁忙请重试”,也可以是把请求丢进队列稍后处理,具体看业务。快速失败的好处是保护线程池,避免一个热点资源的锁竞争拖垮整个服务。这个思路和“超时时间要短、失败要快”的通用原则是一致的。

另外要监控抢锁失败率这个指标。如果某个锁的失败率持续偏高,说明要么锁粒度太粗、要么临界区执行太慢、要么就是有死锁或锁泄漏。这个指标比单纯的 QPS 更能反映锁的健康度,建议接入监控大盘。

4.3 主从切换下的锁安全边界

前面提过,主从模式下锁存在一个理论上的不安全窗口:主节点写入锁后还没同步到从节点就宕机了,从节点晋升后不知道这把锁存在,另一个客户端就能拿到同一把锁。这个窗口在异步复制下是客观存在的,Redisson 的红锁(RedLock)算法就是为缓解这个问题设计的——它要求同时在多个独立的缓存节点上加锁,超过半数成功才算拿到锁。

但红锁也不是银弹。它对时钟、网络延迟敏感,实现和运维复杂度都高,而且在极端网络分区下仍有争议。我的实际取舍是:如果业务对锁的安全性要求极高(比如涉及资金),不要只依赖缓存锁,要在数据层再加一道乐观锁或唯一约束做最终兜底。缓存锁负责挡住绝大多数并发,数据层的约束负责兜住极端情况,两层配合才稳。单纯指望某一种锁方案做到绝对安全,是不现实的。

4.4 常见问题速查表

把这些年遇到的高频问题整理成一张表,方便对照排查。

现象可能原因排查方向处理建议
锁释放时报“未持有锁”异常加锁释放不配对,或跨线程释放检查是否在异步线程里 unlock用 try/finally 配对,加 isHeldByCurrentThread 判断
业务没执行完锁就没了用了固定 leaseTime 且估短了看日志里锁过期时间与业务耗时改用看门狗或调大 leaseTime
锁一直不释放持锁进程假死,看门狗续期线程卡住查进程 GC 日志、线程栈设合理 leaseTime 兜底,加锁泄漏监控
抢锁失败率异常高锁粒度过粗或临界区太慢看锁 key 分布和临界区耗时细化锁粒度,缩短临界区
主从切换后出现双持有者异步复制窗口查切换时间点与锁操作时间数据层加乐观锁兜底,或评估红锁
等待锁的线程大量阻塞waitTime 设太长看线程池活跃数与阻塞数缩短 waitTime,快速失败降级

这张表不是穷举,但覆盖了八成以上的线上问题。遇到新问题,先对照现象找方向,再去翻 Redisson 的日志和缓存里的锁结构,通常能定位。

5. 把锁用对:几个容易被忽视的工程习惯

5.1 锁的监控与可观测性

锁是“看不见的并发控制”,出了问题往往表现为业务数据错乱,而不是明显的报错,所以监控尤其重要。我一般会关注几个指标:加锁成功率、平均持锁时长、锁等待队列长度、续期次数。加锁成功率骤降说明竞争加剧或有锁泄漏;持锁时长异常增长说明临界区变慢;续期次数异常说明有业务长时间占锁。

实现上可以在RLock外面包一层,记录加锁前后时间戳和结果,上报到监控系统。别嫌麻烦,等真出问题时,这些数据能帮你把排查时间从几小时缩短到几分钟。我经历过一次数据错乱,最后就是靠持锁时长曲线发现某类请求的锁持有时间突然从几十毫秒涨到十几秒,顺藤摸瓜找到了一个慢查询。

5.2 临界区代码的编写纪律

锁用对了,临界区写错了照样出事。几条纪律值得贴在工位上:临界区内不做远程调用、不打印大日志、不睡眠等待、不嵌套获取其他锁。远程调用会让持锁时间不可控,大日志会拖慢执行,睡眠纯属浪费锁资源,嵌套获取多把锁则是死锁的温床。

如果业务确实需要在一个逻辑里操作多个资源,尽量按固定顺序获取锁,比如都按资源 ID 升序加锁,这样能避免两个线程交叉持锁导致的死锁。这个技巧在多资源操作的场景里非常实用,成本低效果好。

5.3 什么时候不该用分布式锁

最后说个反向的经验:不是所有并发问题都该用分布式锁解决。如果能把操作设计成幂等的,很多场景根本不需要锁;如果能用数据库的乐观锁版本号或唯一约束解决,也不必引入缓存锁;如果并发量很低,用数据库行锁可能更简单可靠。

分布式锁是有成本的:多一次网络往返、多一个故障点、多一份运维负担。我见过为了一个每天只跑几次的定时任务加分布式锁的,其实用数据库的唯一约束或者调度框架自带的分片就能解决。先问“能不能不用锁”,再问“用哪种锁”,这个顺序能帮你省掉很多不必要的复杂度。

真正需要分布式锁的,是那些“必须严格互斥、且互斥逻辑跨进程”的场景,比如同一订单的并发退款、同一库存的并发扣减、同一资源的并发初始化。这些场景下,Redisson 提供的这套机制确实能省不少事,前提是你理解了它的边界,知道它在什么情况下会失效,并做好了兜底。

我个人在实际操作中的体会是,分布式锁的难点从来不在“怎么加锁”,而在“想清楚锁的边界和失效后的兜底”。Redisson 把加锁这件事封装得很好,但续期、过期、主从切换这些边界条件,还是得靠使用者自己判断和配置。把参数估准、把监控做好、把数据层的兜底加上,这套组合拳打下来,锁才算真正用稳了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进