那天是月末对账日,财务同学在群里发来一张截图:同一个客户的同一笔手续费,被扣了两次。我第一反应是数据库唯一键没加,第二反应是“不可能,我们这个环节加了分布式锁”,第三反应是去翻日志。日志显示:两台机器,相差 11 秒,先后“成功获取锁”并执行了扣款逻辑。
“锁”这个东西,听起来像保险柜,用起来像便利贴——你以为贴上去别人就不会碰,其实它自己会掉。这篇是那次排查的完整记录,顺便把“锁不住”和“释放不了”这两类常见故障梳理一遍。
一、现象:锁明明加了,却像没加
这个环节的代码大概是这样(已脱敏改写):
public void deduct(String orderNo) {
String lockKey = "lock:deduct:" + orderNo;
// 老代码:自己用 SETNX 实现的锁,过期时间 10 秒
Boolean ok = redis.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);
if (!Boolean.TRUE.equals(ok)) {
return; // 没抢到,直接返回
}
try {
doDeduct(orderNo); // 调第三方扣款接口,平时 300ms,那天很慢
} finally {
redis.delete(lockKey); // 释放
}
}日志里的时间线是这样的:
10:03:00.112 [pod-a] lock acquired lock:deduct:20240930001
10:03:00.115 [pod-a] call payment gateway ...
10:03:10.113 (锁 TTL 到期,Redis 自动删除 key)
10:03:11.207 [pod-b] lock acquired lock:deduct:20240930001
10:03:11.210 [pod-b] call payment gateway ...
10:03:13.554 [pod-a] payment gateway returned, deduct done
10:03:13.556 [pod-a] unlock lock:deduct:20240930001 <-- 删的是 pod-b 的锁!
10:03:15.901 [pod-b] payment gateway returned, deduct done一目了然:第三方支付网关那天响应变慢(用了 13 秒),而锁只有 10 秒寿命。pod-a 还在处理,锁已经自己“过期下班”;pod-b 顺利拿到锁,开始第二次扣款;更离谱的是,pod-a 处理完毕后执行 delete,把 pod-b 的锁给删了——如果此时再来第三个请求 pod-c,它也能拿到锁。
二、排查路线图
分布式锁的故障,我把它归成两类:“锁不住”(多个持有者)和 “释放不了”(死锁、unlock 报错)。排查时先用 redis-cli 看现场,再对照代码。
三、动手:用 redis-cli 到现场看锁
第 1 步:锁 key 存在吗?值是什么?还剩多久?
怀疑锁有问题,第一件事就是直接去看锁本身。对着“正在出问题的订单号”查:
$ redis-cli -h 10.0.0.21 exists lock:deduct:20240930001
(integer) 0
$ redis-cli -h 10.0.0.21 get lock:deduct:20240930001
(nil)
$ redis-cli -h 10.0.0.21 ttl lock:deduct:20240930001
(integer) -2
$ redis-cli -h 10.0.0.21 pttl lock:deduct:20240930001
(integer) -2TTL 返回值一定要背下来:-2 = key 不存在;-1 = key 存在但没有过期时间(“永久锁”,一旦持有者挂了就是死锁);正数 = 剩余秒数。PTTL 是毫秒版本。这里 key 已经不存在,说明锁已经被释放(自然过期或被删)。
如果问题正在发生,你会看到这样的现象:业务还在处理,但锁 key 没了或者值变了。老代码里我们存的 value 是固定的 "1",根本无法判断“这把锁是谁的”,这正是误删他人锁的根源:
$ redis-cli -h 10.0.0.21 get lock:deduct:20240930002
"1"
$ redis-cli -h 10.0.0.21 ttl lock:deduct:20240930002
(integer) 6锁还有 6 秒,但你无法知道它属于哪个实例。好的锁 value 应该是随机 UUID + 线程 id,这样可以在排查时对上日志。
第 2 步:如果用的是 Redisson,锁长什么样
Redisson 的 RLock 在 Redis 里是一个 Hash,field 是 UUID:线程id,value 是重入次数:
$ redis-cli -h 10.0.0.21 type lock:deduct:20240930003
hash
$ redis-cli -h 10.0.0.21 hgetall lock:deduct:20240930003
1) "5f1c2a90-3c7e-4b7d-9d1a-7e2f0c11a8b4:87"
2) "1"
$ redis-cli -h 10.0.0.21 pttl lock:deduct:20240930003
(integer) 22413field 前半段是 Redisson 客户端实例的 UUID(每个 RedissonClient 启动时生成,可对应到具体 pod),后半段 87 是线程 ID,value 1 表示重入了 1 次。PTTL 是 22413ms,如果是没传 leaseTime 的锁,你会看到这个值在 30000 和 20000 之间反复摆动——因为看门狗大约每 lockWatchdogTimeout/3(约 10 秒)续期一次,把 TTL 重新拉回 30 秒。这个“TTL 反复回到 30s”的现象,就是判断看门狗是否在工作的最直观办法:
$ for i in 1 2 3 4 5 6; do redis-cli -h 10.0.0.21 pttl lock:deduct:20240930003; sleep 4; done
(integer) 29981
(integer) 26002
(integer) 22011
(integer) 29996 # 续期发生,回到 30s
(integer) 26010
(integer) 22004如果你看到 TTL 一路递减到 0、锁消失,说明这把锁没有续期——要么传了 leaseTime,要么看门狗根本没启动(比如自己写的 SETNX 锁)。
第 3 步:看看是谁的锁——CLIENT LIST 与代码对照
$ redis-cli -h 10.0.0.21 client list | grep -c redisson
24
$ redis-cli -h 10.0.0.21 info commandstats | egrep 'eval|evalsha|hset|pexpire'
cmdstat_evalsha:calls=8231044,usec=9012311,usec_per_call=1.09,rejected_calls=0,failed_calls=0Redisson 的加锁、续期、解锁全部通过 Lua 脚本(EVAL/EVALSHA)实现,保证原子性。evalsha 的调用次数可以侧面反映锁竞争的热度。
四、根因:四类“锁失效”一次讲清
1. 业务耗时超过锁的过期时间(本次根因)
锁的 TTL 是为了防止持有者宕机导致死锁而设的“保险”,代价就是:持有者活着但太慢时,锁会自己过期。Redis 官方分布式锁文档里说得很直白——锁的有效期同时是“自动释放时间”和“客户端完成操作的时间窗口”,互斥性只在这个窗口内成立。我们的 TTL 写死 10 秒,支付网关一慢就越界。
2. 释放时没校验身份,误删别人的锁(本次的放大器)
官方文档同样明确提醒:只用 DEL 释放不安全,可能删掉别人后来拿到的锁。正确做法是加锁时写入一个唯一随机值,释放时用脚本“值相等才删”(Redis 8.4 起也可用 DELEX key IFEQ value,之前用 Lua):
-- 加锁
SET lock:deduct:20240930001 <uuid> NX PX 30000
-- 解锁:Lua,原子地“比较再删除”
if redis.call("get", KEYS[1]) == ARGV[1] then
return redis.call("del", KEYS[1])
else
return 0
end3. Redisson 看门狗:只有“没指定 leaseTime”才会续期
用 Redisson 的好处是它自带续期。规则记住三条(来自官方文档与配置说明):
lock.lock()或lock.tryLock(waitTime, TimeUnit):没有 leaseTime,锁的初始 TTL 是lockWatchdogTimeout,默认 30 秒(Config.lockWatchdogTimeout,单位毫秒,默认 30 * 1000)。只要持有锁的 Redisson 实例还活着,看门狗就在后台定时续期(大约每 1/3 个 TTL 一次);lock.lock(10, TimeUnit.SECONDS)或lock.tryLock(3, 10, TimeUnit.SECONDS):指定了 leaseTime,看门狗不工作,10 秒到就释放,不管你业务有没有做完;- 进程挂了(或和 Redis 断连太久),续期停止,锁最多再撑 30 秒就自动释放——这是“防死锁”的设计。
RLock lock = redisson.getLock("lock:deduct:" + orderNo);
// ✅ 写法 A:不传 leaseTime,看门狗托管续期(业务耗时不确定时首选)
boolean ok = lock.tryLock(3, TimeUnit.SECONDS);
// ⚠️ 写法 B:传了 leaseTime,看门狗不生效;业务超过 30 秒就会“锁不住”
boolean ok2 = lock.tryLock(3, 30, TimeUnit.SECONDS);很多团队踩的坑正是这个:为了“防止死锁”,习惯性地传一个 leaseTime,结果把 Redisson 最有价值的续期功能关掉了。
4. unlock 时 IllegalMonitorStateException
这是另一类症状:业务日志里出现
java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread by node id: 5f1c2a90-3c7e-4b7d-9d1a-7e2f0c11a8b4 thread-id: 87
at org.redisson.RedissonBaseLock.lambda$unlockAsync$1(RedissonBaseLock.java:...)
at com.demo.pay.DeductService.deduct(DeductService.java:58)含义:你要解锁的这把锁,Redis 里不属于“当前线程”。Redisson 文档明确写了:只有锁的持有者线程才能解锁,否则抛这个异常。常见诱因有四个:
- 锁已经过期被释放(指定了 leaseTime 且业务超时;或看门狗续期失败,如 Redis 抖动/GC 长停顿)——此时 key 已不存在或已被别人持有;
- 跨线程解锁:
lock在线程 A,unlock在异步回调/线程池线程 B; - 没抢到锁也去 unlock:
tryLock返回 false 后依然进了 finally 执行unlock(); - 配置写错单位:
lockWatchdogTimeout单位是毫秒,如果误写成setLockWatchdogTimeout(30)(想表达 30 秒),锁的 TTL 只有 30 毫秒,几乎立刻过期,unlock 时必然抛异常——GitHub 上就有用户报过“设置 LockWatchdogTimeout 后 unlock 抛 IllegalMonitorStateException”的 issue(#2292)。
正确的加解锁骨架:
RLock lock = redisson.getLock("lock:deduct:" + orderNo);
boolean locked = false;
try {
locked = lock.tryLock(3, TimeUnit.SECONDS); // 不传 leaseTime → 看门狗续期
if (!locked) { return; }
doDeduct(orderNo);
} finally {
if (locked && lock.isHeldByCurrentThread()) { // 只有真正持有才解锁
lock.unlock();
}
}5. 主从切换丢锁与 Redlock 之争
即便上面全做对,还有一个“先天局限”:如果 Redis 是主从(含哨兵/Cluster)架构,锁只写在主库,主从复制是异步的。Redis 官方文档写得明白:客户端 A 在 master 上拿到锁,master 在把这个 key 同步给 replica 之前崩溃,replica 被提升为新 master,客户端 B 又能拿到同一把锁——互斥性被破坏。
官方为此提出了 Redlock 算法:N 个(如 5 个)互相独立的 master,客户端向多数派(≥3)加锁成功,且总耗时小于锁有效期才算成功。Redisson 提供了 RedissonRedLock 实现。但 Redlock 本身有争议:Martin Kleppmann 的文章认为,它依赖同步系统假设(网络延迟、进程停顿、时钟漂移都有界),而现实里 GC 停顿、包延迟、时钟跳变都会发生;并且它不产生单调递增的 fencing token,无法阻止“一个被暂停的持有者醒来后继续写”。他的结论是:只为“提高效率、避免重复干活”的锁,单 Redis 实例足够,别用 Redlock 增加复杂度;要求绝对正确性的场景,应使用有 fencing token 的一致性系统(如 ZooKeeper),或者在资源侧做校验。Redis 官方文档自己也在页尾保留了这场讨论并提示需要考虑 fencing token 和 TTL 使用非单调时钟的问题。
我的态度很务实:把锁当“性能优化”,不要把锁当“正确性保证”。真正的正确性,必须由数据库层兜底——这才是本次故障的根治思路。
五、止血与修复
止血(当晚)
- 用财务提供的清单,把重复扣款的订单挑出来,调用退款接口逐笔回退(先人工核对,再执行);
- 把锁的过期时间从 10 秒临时调到 60 秒,并关闭那个响应变慢的支付渠道的自动重试;
- 给扣款接口加一个临时的“订单号 + 状态”前置检查,遇到已扣款状态直接返回。
根治 1:换成 Redisson,并且别传 leaseTime
自己手写 SETNX 锁的代码全部替换成 Redisson 的 RLock(可重入、自带续期、Lua 保证原子性)。同时在 Redisson 配置中确认 lockWatchdogTimeout 没有被写错单位;如果确实想更短的“宕机后最长等待时间”,例如 10 秒,就写成 10000:
# redisson.yaml(示例)
singleServerConfig:
address: "redis://10.0.0.21:6379"
lockWatchdogTimeout: 30000 # 单位毫秒,默认 30000并为下游调用加超时(支付网关最长 5 秒),保证业务耗时有上界,锁的持有时间可控。
根治 2:数据库层兜底——幂等 + 状态机 + 唯一键
-- 扣款流水表:同一业务单号只允许一条“扣款”记录
ALTER TABLE fee_deduct_log ADD UNIQUE KEY uk_biz (biz_type, biz_no);
-- 状态机更新:只有 INIT -> SUCCESS 一种合法迁移,重复执行影响行数为 0
UPDATE fee_order SET status = 'DEDUCTED', deduct_time = NOW()
WHERE order_no = '20240930001' AND status = 'INIT';int rows = orderMapper.markDeducted(orderNo); // WHERE status='INIT'
if (rows == 0) {
log.warn("order {} already deducted, skip", orderNo);
return; // 即使锁失效,也不会二次扣款
}
try {
feeLogMapper.insert(new FeeLog(orderNo, "DEDUCT")); // 唯一键冲突则抛 DuplicateKeyException
} catch (DuplicateKeyException e) {
return;
}
paymentGateway.deduct(orderNo, orderNo); // 第二个参数:下游幂等号(requestId)这样,锁的作用是“大部分情况下别重复干活(省资源)”,即便锁失效,唯一键和状态机也保证结果正确。对应 Kleppmann 说的“资源侧校验”。
根治 3:监控与告警
- 业务侧:统计
tryLock失败率、持锁耗时分位数(P99),持锁时间接近 leaseTime/watchdog 周期就告警; - 日志里打印
lock key + 当前线程 + 加锁耗时 + 解锁结果,出现IllegalMonitorStateException单独告警; - Redis 侧:关注
keyspace中永久 key(TTL = -1)的锁前缀,防止“永久锁”残留:redis-cli --scan --pattern 'lock:*'抽样检查 TTL。
# 抽样检查有没有 TTL=-1 的“永久锁”
$ redis-cli -h 10.0.0.22 --scan --pattern 'lock:*' | head -200 \
| while read k; do echo "$(redis-cli -h 10.0.0.22 ttl "$k") $k"; done | awk '$1==-1'
-1 lock:legacy:sync-task一条历史遗留的永久锁被抓了出来,属于以前手写锁忘设过期时间的“古董”,顺手删掉并修了代码。
六、复盘
| 做法 | 为什么 |
|---|---|
加锁用 SET key uuid NX PX ttl,释放校验 value(Lua 或 DELEX) | 避免误删别人的锁,这是最基本的底线 |
| 业务耗时不确定:用 Redisson 且不传 leaseTime | 看门狗默认 30 秒 TTL,存活期间持续续期;传了就不续 |
| 下游调用必须有超时,锁内不做无界等待 | 持锁时间有上界,锁才有意义 |
finally 里先判断 isHeldByCurrentThread 再 unlock,且 lock/unlock 同线程 | 避免 IllegalMonitorStateException 覆盖真正的业务异常 |
lockWatchdogTimeout 单位是毫秒,别手滑写成 30 | 单位写错等于 30 毫秒过期,锁形同虚设 |
| 锁只是“防重复”的优化,正确性靠唯一键 + 状态机 + 下游幂等号 | 主从异步复制、GC 停顿、时钟跳变都可能让锁失效,Redlock 也无法完全解决 |
| 确需强一致:引入 fencing token 或用 ZooKeeper/etcd 类一致性系统 | Redlock 依赖时间假设,且不产生单调递增令牌 |
排查现场固定动作:type / hgetall / ttl / pttl 看锁,日志对时间线 | 五分钟就能判断是“过期了”“被删了”还是“没续期” |
一句话备忘:锁不住 = 锁过期(业务太慢 / 传了 leaseTime)或被误删;释放不了 = 不是持有者(过期、跨线程、没抢到就解锁)。redis-cli 看
ttl与 hash 的 field,Redisson 不传 leaseTime 才有看门狗,最终正确性交给数据库唯一键和状态机。
事后复盘会上,有同事问:“那我们干脆不用锁了行不行?”我说可以,但你得先说服 DBA 你的唯一键设计是认真的。锁像雨伞,能挡大多数雨,遇上台风还是得靠房子结实——而数据库,就是那栋房子。
参考资料
- Redis 官方文档:Distributed Locks with Redis——单实例加锁
SET NX PX、随机值 + 脚本安全解锁、主从异步复制导致的丢锁场景、Redlock 算法及 fencing token 的提示。 - Redis 官方文档:SET 命令——NX / PX / EX 选项,以及“值匹配才删除”的解锁脚本示例。
- Redisson Reference Guide:Locks and synchronizers——lockWatchdogTimeout 默认 30 秒、看门狗续期,以及指定 leaseTime 后的行为。
- Martin Kleppmann:How to do distributed locking——效率锁与正确性锁的区别、fencing token、Redlock 对时间假设的依赖。
- Redisson GitHub Issue #2292——设置 lockWatchdogTimeout 后 unlock 抛出 IllegalMonitorStateException 的真实案例。
一起聊聊