分布式锁这个话题在面试和实际项目里出现的频率高得离谱,但大多数人停留在“SETNX 加个过期时间”的层面,真到线上出了锁失效、误删、续期失败的问题,往往一脸茫然。Redission 作为 Redis 官方推荐生态里最常被拿来解决分布式锁的组件,源码里藏了大量工程化的细节——看懂了它,你不仅能回答“为什么不用 SETNX 自己写”,还能在锁竞争激烈、业务执行时间不可控的场景下知道该怎么调参、怎么兜底。这篇内容适合已经用过 Redis、想搞清楚 Redission 锁底层实现的中高级开发者,也适合正在准备分布式相关面试、需要把锁的来龙去脉讲清楚的同学。我会从设计思路、核心源码、实操配置到踩坑排查,把这块内容完整拆一遍。
1. 分布式锁的整体设计与 Redission 的选型逻辑
1.1 为什么手写 SETNX 方案在生产环境站不住脚
很多人第一次实现分布式锁,都是这么写的:SET key value NX PX 30000,加锁成功就执行业务,执行完DEL key。这个方案在 demo 里跑得通,但放到生产环境,问题一个接一个冒出来。
第一个问题是误删。假设 A 线程加锁成功,设置了 30 秒过期,结果业务执行了 35 秒,锁在 30 秒时自动释放了,B 线程趁虚而入拿到锁。这时候 A 线程执行完业务,执行DEL key,把 B 的锁给删了。解决办法是 value 存一个唯一标识(比如 UUID + 线程 ID),删除前先比对。但比对和删除这两步不是原子的,得用 Lua 脚本包起来。
第二个问题是续期。业务执行时间根本不可控,你设 30 秒,可能某个慢查询就超了。锁提前释放,互斥性就没了。要解决就得有个“看门狗”在后台定期给锁续命,这个逻辑自己写又是一套定时任务加状态管理。
第三个问题是可重入。同一个线程在业务里嵌套调用另一个需要加锁的方法,如果锁不可重入,自己就把自己锁死了。要实现可重入,得在 value 里维护重入计数,加锁时判断是不是当前线程持有,是就计数加一,解锁时计数减一,减到零才真正删除。
第四个问题是阻塞等待。拿不到锁的时候,业务是直接失败还是等一会儿?等的话等多久?轮询间隔多少?这些策略自己实现又是一堆代码。
把这四件事凑齐,你写出来的东西基本就是半个 Redission 了。所以选型上的结论很直接:分布式锁不是一个 API 调用,而是一套状态机加容错策略,自己从零写,坑远比想象的多。Redission 把这些工程问题都封装好了,而且经过了大量生产验证,这是它值得用的核心理由。
1.2 Redission 锁的核心设计取舍
Redission 的锁实现有几个关键设计决策,理解这些决策背后的权衡,比死记 API 有用得多。
基于 Hash 结构存储锁状态。锁的 key 对应的不是一个简单的字符串,而是一个 Hash:field 是“客户端唯一标识 + 线程 ID”,value 是重入次数。这个设计一举解决了可重入和持有者识别两个问题。加锁时判断 field 是否存在,存在就HINCRBY加一;解锁时减一,减到零才删 key。相比用 String 存 UUID 再额外维护计数,Hash 结构把状态收敛在一个 key 里,原子性更好保证。
所有关键操作走 Lua 脚本。加锁、解锁、续期,每一个都是多步操作的组合,必须原子执行。Redission 把这些逻辑写成 Lua 脚本发给 Redis 执行,避免了“判断—修改”之间的竞态。这一点是它比手写方案可靠的根本原因之一。
看门狗机制做自动续期。加锁成功后,如果没显式指定 leaseTime,Redission 会启动一个后台任务,每隔lockWatchdogTimeout / 3的时间给锁续期到lockWatchdogTimeout。默认lockWatchdogTimeout是 30 秒,也就是每 10 秒续一次。这个设计的前提是:只要客户端还活着,锁就不应该过期。反过来,如果客户端进程挂了,续期任务自然停止,锁在 30 秒后自动释放,不会造成死锁。
基于 Pub/Sub 的等待唤醒。拿不到锁的线程不是傻傻轮询,而是订阅一个频道,等锁释放时收到通知再去抢。这比固定间隔轮询节省了大量无效请求,响应也更及时。
这几个设计放在一起,构成了一个“能用、好用、不容易出事”的锁。下面我们逐个拆源码。
2. 加锁流程的源码级拆解
2.1 从 lock() 到 Lua 脚本的调用链路
调用redissonClient.getLock("order:123")拿到的是一个RLock对象,实际类型是RedissonLock。调lock()方法,最终会走到lockInterruptibly(-1, null),核心逻辑在tryAcquire里。
先看tryAcquire的骨架(基于常见版本的实现结构,不同小版本细节略有差异):
private boolean tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) { long time = unit.toMillis(waitTime); long current = System.currentTimeMillis(); Long ttl = tryAcquire(leaseTime, unit, threadId); // 加锁成功 if (ttl == null) { return true; } // 计算剩余等待时间,超时返回 false time -= System.currentTimeMillis() - current; if (time <= 0) { acquireFailed(waitTime, unit, threadId); return false; } // 订阅锁释放消息,等待唤醒 CompletableFuture<RedissonLockEntry> future = subscribe(threadId); // ... 循环重试 }这里有个细节值得注意:tryAcquire返回的ttl为null表示加锁成功,返回具体数值表示锁已被占用、剩余存活时间是多少。用null而不是0或-1表示成功,是为了区分“锁不存在”和“锁存在但 TTL 恰好为 0”这两种情况,虽然实际中 TTL 为 0 的锁几乎不会出现,但这种边界处理体现了源码的严谨。
拿到ttl后,如果大于 0,说明锁被别人占着,此时计算剩余等待时间。如果waitTime是-1(即lock()无参调用,表示一直等),time会是一个很大的负数,但代码里对-1有特殊处理,不会直接返回失败。接着订阅释放消息,进入循环:先尝试tryAcquire,失败就通过Semaphore阻塞等待信号量,收到释放通知后release信号量唤醒,再重试。
2.2 加锁 Lua 脚本逐行解析
真正干活的是这段 Lua 脚本,Redission 里叫tryLockInnerAsync:
if (redis.call('exists', KEYS[1]) == 0) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('hincrby', KEYS[1], ARGV[2], 1); redis.call('pexpire', KEYS[1], ARGV[1]); return nil; end; return redis.call('pttl', KEYS[1]);逐段看。第一段:key 不存在,说明没人持锁,直接hincrby把 field 计数设为 1,然后pexpire设置过期时间,返回nil表示成功。第二段:key 存在且当前线程的 field 也存在,说明是重入,计数加一,刷新过期时间,返回nil。第三段:key 存在但不是当前线程持有,返回剩余 TTL,让调用方知道要等多久。
几个关键参数:KEYS[1]是锁的 key,ARGV[1]是 leaseTime(毫秒),ARGV[2]是“客户端 ID + 线程 ID”拼成的 field 名。客户端 ID 是 Redission 启动时生成的 UUID,保证不同客户端不冲突;线程 ID 保证同一客户端内不同线程能区分。
注意:重入时也会刷新过期时间(
pexpire),这意味着只要重入一次,锁的存活时间就重置了。这个行为在业务上是合理的——重入说明还在执行,锁应该继续有效。但如果你在锁内做了长时间的重入嵌套,要留意总时长别超过预期。
2.3 看门狗续期的触发条件与实现
看门狗不是无条件启动的。看tryAcquire里这段:
if (leaseTime != -1) { // 显式指定了 leaseTime,不启动看门狗 return tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG); } // leaseTime 为 -1,走看门狗逻辑 RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime, ...); ttlRemainingFuture.onComplete((ttlRemaining, e) -> { if (e != null) return; if (ttlRemaining == null) { // 加锁成功,启动续期任务 scheduleExpirationRenewal(threadId); } });结论很明确:只有调用lock()不传 leaseTime 时,看门狗才生效。如果你调lock(10, TimeUnit.SECONDS)显式指定了 10 秒,那锁到 10 秒就释放,不管业务有没有执行完。这是个高频踩坑点,很多人以为指定了时间看门狗还会兜底,其实不会。
scheduleExpirationRenewal内部维护了一个ConcurrentHashMap<String, ExpirationEntry>,key 是“锁名 + 线程 ID”,value 里记录了重入计数和续期任务的 Future。续期任务用HashedWheelTimer调度,每隔internalLockLeaseTime / 3(默认 10 秒)执行一次,执行内容是调用renewExpirationAsync的 Lua 脚本:
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then redis.call('pexpire', KEYS[1], ARGV[1]); return 1; end; return 0;脚本先判断当前线程是否还持有锁,持有才续期,返回 1;不持有返回 0,续期任务就此终止。这个判断很重要——如果锁已经被释放(比如业务异常导致解锁),续期任务不能盲目续命,否则会续出一个“幽灵锁”。
3. 解锁与等待唤醒的完整链路
3.1 解锁 Lua 脚本与重入计数递减
解锁走unlockInnerAsync:
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then return nil; end; local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); if (counter > 0) then redis.call('pexpire', KEYS[1], ARGV[2]); return 0; else redis.call('del', KEYS[1]); redis.call('publish', KEYS[2], ARGV[1]); return 1; end; return nil;逻辑分三步。第一步,判断当前线程是否持有锁,不持有直接返回nil,表示解锁失败(通常是重复解锁或锁已过期)。第二步,计数减一,如果还大于 0,说明是重入场景,刷新过期时间,返回 0 表示“还没完全释放”。第三步,计数减到 0,删除 key,并通过publish向KEYS[2]频道发一条释放消息,返回 1。
这里KEYS[2]是redisson_lock__channel:{锁名},ARGV[1]是解锁消息的内容(通常是 0)。发布消息这一步是等待唤醒机制的关键,下面细说。
注意:解锁脚本里判断持有者用的是
hexists,如果锁已经因为超时被删了,hexists返回 0,脚本返回nil,Redission 会抛IllegalMonitorStateException。这个异常在业务里经常被忽略,但它其实是个重要信号——说明你的锁在业务执行期间已经失效了,互斥性被破坏,需要排查是不是业务超时或看门狗没生效。
3.2 基于 Pub/Sub 的等待唤醒机制
拿不到锁的线程怎么等?Redission 用的是信号量加订阅的组合。subscribe(threadId)会订阅redisson_lock__channel:{锁名}频道,订阅回调里拿到消息后,从entry里取出Semaphore并release一个许可。等待的线程在Semaphore.acquire()上阻塞,收到许可后唤醒,重新尝试加锁。
这个机制相比轮询的优势很明显:轮询是“我每隔 100ms 问一次锁好了没”,不管锁有没有释放都在发请求;订阅是“锁释放了通知我”,没有无效请求。在锁竞争激烈的场景下,这个差异对 Redis 的压力影响很大。
但订阅机制也有代价。每个等待的线程都要维持一个订阅连接,如果等待线程特别多(比如上千个),Redis 的订阅连接数会飙升。而且订阅消息是广播的,锁释放时所有等待者都会被唤醒去抢,形成“惊群效应”。Redission 对此的处理是:唤醒后不是直接加锁,而是重新走tryAcquire,抢不到继续等。所以惊群存在,但不会造成错误,只是多了一些无效的加锁尝试。
3.3 锁释放后的重试循环
完整的等待逻辑在tryAcquire的循环里:
while (true) { ttl = tryAcquire(leaseTime, unit, threadId); if (ttl == null) { return true; } if (time <= 0) { acquireFailed(...); return false; } // 等待信号量,最多等 ttl 或剩余等待时间 if (ttl >= 0 && ttl < time) { entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS); } else { entry.getLatch().tryAcquire(time, TimeUnit.MILLISECONDS); } // 重新计算剩余时间 time -= ...; }注意tryAcquire的等待时长取ttl和time的较小值。为什么?如果锁的 TTL 是 5 秒,而你还愿意等 10 秒,那等 5 秒就够了——5 秒后锁大概率释放,没必要等满 10 秒。这个细节体现了源码对等待效率的优化。
4. 实操配置与参数调优
4.1 三种加锁方式的适用场景
Redission 的RLock提供了几种加锁方式,选错了会直接影响可靠性:
| 方法 | leaseTime | 看门狗 | 适用场景 | 风险 |
|---|---|---|---|---|
lock() | -1(默认30秒) | 生效 | 业务时长不可控 | 客户端假死时锁最长存活30秒 |
lock(leaseTime, unit) | 指定值 | 不生效 | 业务时长明确且短 | 业务超时锁提前释放 |
tryLock(waitTime, leaseTime, unit) | 指定值 | 不生效 | 需要限时等待且时长可控 | 同上,且拿不到锁直接返回false |
我的建议是:业务时长不可控就用lock()让看门狗兜底;业务时长明确且远小于 leaseTime 才用带参版本。最忌讳的是既指定了较短的 leaseTime,业务又可能超时,这等于自己埋雷。
4.2 lockWatchdogTimeout 的调整与计算
lockWatchdogTimeout默认 30 秒,续期间隔是它的三分之一,即 10 秒。这个值怎么定?核心约束是:续期间隔必须显著小于锁的存活时间,否则一次续期失败(比如网络抖动)就可能导致锁过期。
假设网络抖动导致一次续期延迟了 2 秒,续期间隔 10 秒,锁存活 30 秒,那么即使延迟 2 秒,下一次续期在 12 秒时到达,锁还有 18 秒存活,安全。但如果把lockWatchdogTimeout调到 3 秒,续期间隔变成 1 秒,一次 2 秒的抖动就足以让锁过期。
所以调整原则是:业务对锁失效的容忍度越低,这个值应该越大,给续期留足容错空间。但也不能无限大,否则客户端崩溃后锁的释放会延迟很久。一般 30 秒到 60 秒是比较稳妥的区间。配置方式:
Config config = new Config(); config.useSingleServer().setAddress("redis://127.0.0.1:6379"); config.setLockWatchdogTimeout(30000); // 单位毫秒 RedissonClient client = Redisson.create(config);4.3 公平锁与联锁的取舍
除了普通锁,Redission 还提供getFairLock()和getMultiLock()。公平锁保证先到先得,底层用 Redis 的 List 维护等待队列,代价是每次加锁解锁多了队列操作,吞吐量下降。联锁用于多个独立 Redis 节点,要求所有节点都加锁成功才算成功,适合对可靠性要求极高、能接受性能损耗的场景。
大多数业务用普通getLock()就够了。公平锁只在“必须严格按顺序执行”的场景才有必要,比如某些排队处理逻辑。联锁则要评估:你的 Redis 是不是多主架构?单主架构下联锁没有意义,反而增加复杂度。
5. 常见问题与排查实录
5.1 锁提前失效的排查路径
线上最典型的问题是“锁明明加了,怎么还是并发冲突了”。排查顺序建议这样走:
先确认加锁方式。如果是lock(leaseTime, unit),检查 leaseTime 是否小于业务最长执行时间。这个最容易被忽略,因为代码里写的是“10 秒”,但业务里一个远程调用就可能 15 秒。
再确认看门狗是否生效。看门狗依赖客户端存活,如果客户端所在机器 GC 停顿严重(比如一次 Full GC 停了 20 秒),续期任务也会停,锁可能在 GC 期间过期。这种情况要看 GC 日志,确认有没有长时间停顿。
最后确认 Redis 本身。如果 Redis 主从切换,锁可能丢失——主节点加锁成功但还没同步到从节点就挂了,从节点升主后锁就没了。这是 Redis 分布式锁的固有局限,Redission 也解决不了,只能靠联锁或业务层幂等兜底。
5.2 解锁抛 IllegalMonitorStateException 的原因
这个异常的含义是“当前线程不持有锁却试图解锁”。常见原因有三个:
一是锁已过期。业务执行时间超过 leaseTime,锁自动释放,解锁时hexists返回 0,抛异常。二是重复解锁。同一个锁unlock()调了两次,第二次必然抛。三是跨线程解锁。A 线程加的锁,B 线程去解,field 对不上,抛异常。
排查时先看异常堆栈里是哪个业务方法,再看这个方法有没有可能超时,最后检查 unlock 的调用次数。我的经验是,这个异常不要吞掉,它往往是并发问题的第一现场,吞了之后真正的数据不一致会更难查。
5.3 高并发下的锁竞争优化
锁竞争激烈时,几个优化方向:
缩短持锁时间。锁内只做必要的临界区操作,把日志、远程调用、复杂计算挪到锁外。这是收益最大的一条。
减小锁粒度。按业务 ID 分片加锁,比如订单锁用order:{orderId},而不是全局一把锁。
合理设置等待时间。tryLock的 waitTime 不要设太大,等太久不如快速失败走降级。设 3 到 5 秒比较常见。
考虑读写分离场景。如果读多写少,可以用RReadWriteLock,读锁共享、写锁互斥,比纯互斥锁吞吐高。
5.4 常见问题速查表
| 现象 | 可能原因 | 排查动作 | 解决方向 |
|---|---|---|---|
| 并发冲突 | leaseTime 小于业务时长 | 核对加锁参数与业务耗时 | 改用 lock() 或加大 leaseTime |
| 解锁抛异常 | 锁已过期或重复解锁 | 看堆栈定位业务方法 | 检查超时、检查 unlock 次数 |
| 锁不释放 | 客户端崩溃且看门狗未停 | 查客户端存活状态 | 依赖 TTL 自动释放,确认 TTL 合理 |
| 等待线程过多 | 订阅连接堆积 | 看 Redis 连接数与订阅数 | 减小 waitTime,快速失败降级 |
| 主从切换丢锁 | 异步复制未同步 | 查主从切换记录 | 业务幂等兜底或评估联锁 |
6. 我在实际项目中的几点体会
看门狗这块,我踩过最深的坑是“以为指定了 leaseTime 还有兜底”。早期有个批量处理任务,加锁写了lock(30, TimeUnit.SECONDS),结果单批数据量大时处理要 40 多秒,锁提前释放,两个节点同时处理同一批数据,产生了重复记录。后来改成lock()让看门狗续期,问题消失。这个教训让我养成了一个习惯:加锁前先估算业务 P99 耗时,再决定用哪种加锁方式。
另一个体会是关于解锁异常的。有段时间日志里频繁出现IllegalMonitorStateException,一开始团队选择 catch 掉不处理,结果后来发现是某个接口的锁 leaseTime 设得太短,锁失效期间数据被并发修改了。把异常吞掉等于把报警器关了,问题只会更隐蔽。现在我们的规范是:这个异常必须打日志并上报监控,作为并发风险的信号。
最后分享一个参数上的小技巧。lockWatchdogTimeout不要用默认值一把梭,要根据业务的最长执行时间来反推。我的经验公式是:lockWatchdogTimeout至少设为业务 P99 耗时的 3 倍,这样即使业务偶发慢查询,续期也有足够余量。比如业务 P99 是 8 秒,那 30 秒的默认值够用;如果 P99 到了 20 秒,就该考虑调到 60 秒了。调完之后记得压测验证,别拍脑袋定值。