分布式锁几乎是后端面试和工程实践里出现频率最高的话题之一。但"会用 Redisson"和"真正理解它为什么安全"之间,隔着几个经典的坑:业务没执行完锁就过期、旧持有者误删新持有者的锁、持锁节点宕机后会不会死锁。
本文不堆概念,先用一个可直接运行、零外部依赖的工程把这些问题逐个复现出来(互斥、误删、token 校验、看门狗续期、崩溃释放),再对照真实 Redis/Redisson 的命令与源码级行为解释背后的原理。所有现象都附实测输出。
1. 为什么需要分布式锁
在单机里我们用 synchronized 或 ReentrantLock 保证临界区互斥,但它们只在单个 JVM 进程内有效。当服务多实例部署时,同一个操作可能落在不同节点上,JVM 锁互相不可见:
css
用户下单 ──> 网关 ──> 服务实例A(JVM 锁)
└─> 服务实例B(另一把 JVM 锁,互不相干)
于是需要一个所有实例共享、且保证操作原子性的协调者,Redis 是最常见的选择。但"用 Redis 实现一把正确的锁"比想象中讲究,下面逐层演进。
2. 第一版:SETNX 加锁,DEL 解锁(有坑)
最直觉的写法:
scss
// 加锁:不存在才成功
Boolean ok = redis.opsForValue().setIfAbsent(key, "1", Duration.ofSeconds(10));
if (!ok) throw new LockException("获取锁失败");
// 业务...
// 解锁
redis.delete(key);
SET key value NX PX ttl 是原子的,互斥没问题。但这版有两个致命缺陷:
- 解锁不校验持有者 :任何客户端都能
DEL掉这把锁; - value 用固定值 "1" :无法区分锁是谁的。
3. 复现:旧持有者如何"误删"新持有者的锁
经典故障时序:
css
A 加锁成功(TTL=300ms)
A 业务卡顿(GC / 慢查询 / 下游超时)超过 300ms,锁自动过期
B 加锁成功,拿到同一把锁
A 缓过来,执行 DEL ------ 把 B 的锁删掉了
C 此时也能加锁成功 ------ 互斥被彻底破坏
我在演示工程里用内存原语复现了这一过程,实测输出:
css
===== 场景2:无条件 DEL 误删别人的锁(错误实现) =====
A 拿到锁,TTL=300ms,业务超时睡 500ms
锁过期后 B 拿到锁:true
A 执行 DEL 后,锁是否还在:false(false=B 的锁被 A 误删!)
根因是:锁的所有权在 TTL 到期后已经发生了转移,但解锁动作没有检查"当前这把锁还是不是我的"。
4. 第二版:唯一 token + Lua 比对删除
修法是给每次加锁生成唯一 token(通常用 UUID,Redisson 里是 客户端实例ID:线程ID),解锁时用 Lua 保证"判断 + 删除"原子:
ini
String token = UUID.randomUUID().toString();
redis.opsForValue().setIfAbsent(key, token, Duration.ofSeconds(10));
// 解锁 Lua:只删自己的锁
String UNLOCK_LUA = """
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('del', KEYS[1])
else
return 0
end
""";
为什么必须用 Lua 而不是先 GET 再 DEL?因为这是两条命令,中间同样存在竞态:你 GET 比对成功、正准备 DEL 的瞬间锁可能刚好过期并被别人拿走。Lua 在 Redis 单线程内一次执行完,"比较 + 删除"不可分割。
实测同一时序下,安全实现不会误删:
arduino
===== 场景3:比对 token 再删(正确实现) =====
B 拿到锁:true
A 尝试释放锁是否成功:false(期望 false)
B 的锁是否仍在:true(期望 true)
演示工程里对应的原子原语(内存版用 ConcurrentHashMap.compute 模拟 Redis 单命令/Lua 的原子性):
ini
public boolean compareAndDelete(String key, String token) {
long now = System.currentTimeMillis();
boolean[] removed = {false};
store.compute(key, (k, old) -> {
if (old != null && old.expireAt() > now && old.token().equals(token)) {
removed[0] = true;
return null;
}
return old;
});
return removed[0];
}
5. 新的矛盾:TTL 设短了提前失效,设长了崩溃后等死
token 校验解决了"误删",但 TTL 的两难依然存在:
- TTL 设短:业务稍微慢一点锁就提前过期,并发安全被破坏;
- TTL 设长:持锁节点一旦宕机,其他节点要等很久才能拿到锁,可用性差。
固定 TTL 本质是在"安全性"和"可用性"之间做静态妥协。看门狗(Watchdog)机制就是用来打破这个两难的:锁先给一个租期,只要持锁客户端还活着,就后台自动续期;客户端死了续期停止,锁到期自动释放。
6. 看门狗复现:业务比租期长,锁不过期;进程崩了,锁会释放
演示工程里看门狗每 lease/3 时间执行一次"仅当 token 仍属于我才续期":
ini
ScheduledFuture<?> renewal = scheduler.scheduleAtFixedRate(() -> {
boolean renewed = redis.compareAndExpire(key, token, leaseMs);
// 续期也必须带 token 校验,不能无脑 PEXPIRE
}, period, period, TimeUnit.MILLISECONDS);
实测:初始租期只有 300ms,业务执行 1000ms,期间另一个线程反复抢锁:
less
===== 场景4:看门狗自动续期(业务 1000ms > 初始 TTL 300ms) =====
A 拿到锁并启动看门狗,开始执行业务...
[watchdog] 续期 成功 key=order:lock:1001:watchdog
...(每 100ms 续期一次,共 10 次)
业务执行期间 B 抢到锁次数 = 0(期望 0,锁被看门狗续命)
A 业务完成,主动释放锁
而当持锁者"崩溃"(续期任务停止、也不主动解锁),锁会在一个租期后自动释放,不会死锁:
arduino
===== 场景5:持锁者崩溃后锁自动释放(防死锁) =====
A 拿到锁后崩溃,停止续期
崩溃后 200ms,B 抢锁是否被挡住:true(期望 true,锁尚未过期)
崩溃后约 600ms(超过 TTL),B 拿到锁:true(期望 true)
这两条性质合起来正是分布式锁想要的:活着就一直持有,死了能自动让位。
7. Redisson 是怎么做的(源码级行为)
Redisson 的可重入锁 RedissonLock 与上面的演示一一对应,理解这几个关键点就够了:
锁结构不是普通 String,而是 Hash。 field 是 客户端UUID:线程ID,value 是重入次数,从而支持同一线程可重入:
yaml
key: myLock
field: 7a8c..:1 value: 1 // 同一线程再次 lock,value 递增为 2
加锁是一段 Lua :判断 key/field 是否存在,不存在则 hset 并 pexpire;存在且是本线程则重入计数 +1。
看门狗的触发条件很关键:
lock.lock()或tryLock(waitTime, -1, unit)不传 leaseTime 时,才启用看门狗;- 默认
lockWatchdogTimeout = 30s,每 1/3(即 10s)续期一次; - 续期也是 Lua:只有 field 仍是自己时才
pexpire回 30s。
一旦你显式传了 leaseTime,例如 tryLock(10, 30, SECONDS),看门狗就不生效,锁到期自动释放、不续期。这是非常容易踩的点------很多人以为用了 Redisson 就一定有自动续期,其实带固定超时的加锁是没有的。
解锁 Lua 做三件事:判断 field 属不属于自己、重入计数减一、归零后删除 key 并发布一条解锁消息(唤醒等待者重新抢)。
8. 可重入与等待:容易被忽略的细节
- 可重入:同一线程嵌套调用加锁方法不会自死锁,靠 Hash 计数;但释放次数要和加锁次数匹配。
- 等待与自旋:拿不到锁时 Redisson 不是疯狂自旋,而是订阅解锁消息 + 按剩余等待时间挂起,被唤醒后重试,降低 Redis 压力。
- 锁与事务的边界 :锁要在事务外 获取和释放(
lock → 开事务 → 提交 → unlock)。如果在@Transactional方法内加解锁,可能出现"锁已释放、事务还没提交",其他线程拿到锁读到旧数据。 - 锁只挡"用同一把锁的人" :所有相关路径必须用同一个 key、同一套加解锁逻辑;DB 唯一约束等兜底依然重要(这点和幂等是相通的)。
9. Redlock 与极端情况:别神化分布式锁
- 主从架构下,master 加锁成功后、key 还没同步到 replica 就宕机,新主上可能没有这把锁。Redlock 通过对多个独立节点"多数派加锁"来缓解,但它的争议(时钟漂移、GC 停顿、网络分区)一直存在,Martin Kleppmann 与 antirez 有过著名争论。
- 工程上的务实选择:绝大多数业务用单 Redis + 看门狗 + token 校验就够了;对强一致要求极高的资源(资金、库存),不要只依赖锁,要用数据库唯一约束、状态机或原子操作兜底。
- 锁是"尽量减少并发冲突",不是"证明业务绝对正确"。
10. 方案对比
| 实现 | 互斥 | 防误删 | 业务超 TTL | 崩溃释放 | 可重入 |
|---|---|---|---|---|---|
| SETNX + 固定值 + DEL | ✅ | ❌ | 锁提前失效 | 靠 TTL | ❌ |
| SETNX + token + Lua 解锁 | ✅ | ✅ | 锁提前失效 | 靠 TTL | ❌(可自行扩展) |
| 看门狗续期(Redisson 默认) | ✅ | ✅ | 自动续期 | 停续期后 TTL 释放 | ✅ |
| 看门狗 + DB 唯一约束兜底 | ✅ | ✅ | ✅ | ✅ | ✅ |
11. 可运行源码
工程基于 Spring Boot 3.2.5 / Java 17,零外部中间件,用 FakeRedis 模拟 Redis 原子原语,启动后自动跑完 5 个场景,放在仓库掘金专区:
- Gitee:gitee.com/Jackie51/we...
- 目录:
juejin/distributed-lock-deep-dive
arduino
mvn spring-boot:run
迁到生产时,把 FakeRedis 的三个方法替换为:setIfAbsent、解锁 Lua、续期 Lua(或直接用 Redisson 的 RLock),演示结论完全一致。
12. 小结
SETNX + DEL不校验持有者,会在锁过期转移后误删别人的锁;加唯一 token 并用 Lua"比对再删"解决。- 固定 TTL 在"提前失效"和"崩溃慢释放"之间两难,看门狗用自动续期 + 宕机停续期同时满足安全与可用。
- Redisson 只有在不显式指定 leaseTime 时才启用看门狗(默认 30s 租期、10s 续期);锁用 Hash 实现可重入。
- 注意锁在事务外、同 key 统一使用;强一致场景用 DB 约束兜底,Redlock 不是银弹。
如果这篇对你有帮助,欢迎点赞收藏。你们线上的分布式任务/库存扣减用的是 Redisson 还是自研 Lua?评论区聊聊踩过的坑。