日志里那句 IllegalMonitorStateException
12 月初,结算服务凌晨报警了几次,错误信息很眼熟:
java.lang.IllegalMonitorStateException: attempt to unlock lock, not locked by current thread
by node id: b7f3c1a2-9de4-4a51-8c07-2fd6e14ba903 thread-id: 218
at org.redisson.RedissonLock.lambda$unlockAsync$4(RedissonLock.java:616)
at org.redisson.RedissonLock.unlock(RedissonLock.java:594)
at com.xxx.settle.SettleTask.lambda$run$0(SettleTask.java:143)
按字面意思是:当前线程想释放一把不是自己加的锁。第一反应是代码写错了,但那段代码是标准的 lock() / try-finally / unlock()。既然代码看着没问题,那就只能去翻源码了。我们用的是 Redisson 3.16.4,Redis 6.2.6。
加锁:一段 40 行的 Lua
顺着 RedissonLock.tryLockInnerAsync 往下走,真正的加锁逻辑全在 Lua 脚本里。我把参数替换成人话贴出来:
-- KEYS[1] = 锁名,比如 "lock:settle:882341"
-- ARGV[1] = leaseTime,用 lock() 无参时是 -1
-- ARGV[2] = uuid + ":" + threadId,比如 "b7f3c1a2-...:218"
-- 第一段:锁不存在 → 直接加锁
if (redis.call('exists', KEYS[1]) == 0) then
redis.call('hincrby', KEYS[1], ARGV[2], 1); -- Hash 里 field=线程标识,value=重入次数
redis.call('pexpire', KEYS[1], ARGV[1]); -- 设置过期
return nil; -- 返回 nil 表示加锁成功
end;
-- 第二段:锁已存在,且是自己加的 → 重入
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('hincrby', KEYS[1], ARGV[2], 1); -- 计数 +1
redis.call('pexpire', KEYS[1], ARGV[1]); -- 顺便续期
return nil;
end;
-- 第三段:锁被别人占着 → 返回剩余存活时间(毫秒)
return redis.call('pttl', KEYS[1]);
三个点值得记:
- 数据结构是 Hash 而不是 String,因为要存重入次数。
HINCRBY返回递增后的值,重入次数就是它。 ARGV[2]里带了threadId,所以可重入是线程级的,不是实例级。同一个 JVM 两个线程竞争同一把锁,后者的hexists会是 0,得等。- 加锁失败返回的不是
false,而是剩余 TTL。这个数字后面会被用来做精准等待。
用 redis-cli 能看到锁长什么样:
127.0.0.1:6379> HGETALL lock:settle:882341
1) "b7f3c1a2-9de4-4a51-8c07-2fd6e14ba903:218"
2) "2"
127.0.0.1:6379> PTTL lock:settle:882341
(integer) 28413
watchdog:为什么 lock() 不带参数就不会死锁
上面那段 Lua 里 ARGV[1] 在 lock() 无参时是 -1,而 PEXPIRE key -1 的效果是立即过期。所以 Redisson 在加锁成功后立刻启动续期:
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
if (leaseTime != -1) {
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
// leaseTime == -1 时走 watchdog 分支
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(
commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(),
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId); // 加锁成功后启动 watchdog
}
});
return ttlRemainingFuture;
}
lockWatchdogTimeout 默认 30000 ms,续期逻辑在 renewExpiration() 里,是一个 10 秒执行一次的延时任务(internalLockLeaseTime / 3):
private void renewExpiration() {
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) {
// 续期 Lua:只续自己的 field,并重置为 30 秒
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (res) {
renewExpiration(); // 成功 → 递归再排一次
} else {
expirationRenewalMap.remove(getEntryName());
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
}
-- 续期 Lua
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
redis.call('pexpire', KEYS[1], ARGV[1]);
return 1;
end;
return 0;
有两个细节我以前理解错了:
第一,watchdog 是递归排任务,不是固定线程池定时跑。所以续期间隔严格是 10 秒,不会因为调度抖动变成 8 秒或 12 秒。也因为它是递归的,一次续期失败(比如网络闪断)整个链条就断了,锁会在 30 秒后自然过期。
第二,续期任务存在 expirationRenewalMap 里,key 是 entryName(连接管理器 id + 锁名),不是 threadId。这意味着重入 3 次只对应一个续期任务,不会叠加。
等锁:订阅 Channel 而不是死循环 sleep
lock() 拿不到锁时,Redisson 不会像 SETNX + 自旋 那样空转。它拿到 pttl 之后做了三步:
// RedissonLock.lock(long leaseTime, TimeUnit unit, boolean interruptibly)
while (true) {
Long ttl = tryAcquire(leaseTime, unit, threadId);
if (ttl == null) break; // 拿到锁
// 1. 订阅 redisson_lock__channel:{锁名}
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
subscribeFuture.await(...);
try {
while (true) {
ttl = tryAcquire(leaseTime, unit, threadId);
if (ttl == null) break;
// 2. 用 Semaphore 阻塞等 ttl 毫秒,超时或被唤醒都重试
if (ttl >= 0) {
getEntry(threadId).getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
getEntry(threadId).getLatch().acquire();
}
}
} finally {
unsubscribe(subscribeFuture, threadId); // 3. 取消订阅
}
}
释放锁的 Lua 最后会 PUBLISH 一条消息到 redisson_lock__channel:{锁名},订阅者的 Semaphore 被释放,立刻醒来抢锁。相比固定 100 ms 自旋,这方案把抢锁延迟压到了一次 Redis 发布订阅的往返(我们环境 0.6 ms 左右),而且 CPU 空闲。
解锁:为什么会出现 IllegalMonitorStateException
解锁 Lua 长这样:
if (redis.call('hexists', KEYS[1], ARGV[2]) == 0) then
return nil; -- 锁不是你的
end;
local counter = redis.call('hincrby', KEYS[1], ARGV[2], -1);
if (counter > 0) then
redis.call('pexpire', KEYS[1], ARGV[1]); -- 还在重入,只续期
return 0;
else
redis.call('del', KEYS[1]); -- 计数归零,真删
redis.call('publish', KEYS[2], ARGV[1]); -- 通知等待者
return 1;
end;
return nil;
返回 nil 时,RedissonLock.unlockAsync 里会抛那个 IllegalMonitorStateException。
回到我那条报警。看堆栈是 SettleTask.java:143,翻代码最后定位到:锁的过期时间我们是显式传的 lock(20, TimeUnit.SECONDS),但结算任务里有一步调外部对账接口,偶尔会超过 20 秒。锁在 20 秒时自动过期了(传了 leaseTime 就没 watchdog),另一个节点趁机加锁成功。等第一个任务跑完执行 unlock(),锁已经属于别人,于是报错,而且把别人的锁给解了。
这就是显式指定 leaseTime 最大的坑:它关掉了 watchdog。源码里那句 if (leaseTime != -1) 就是分界线。
我们的改法:
// 改之前
lock.lock(20, TimeUnit.SECONDS);
// 改之后:不传 leaseTime,让 watchdog 托管,并在 finally 里判状态
RLock lock = redissonClient.getLock("lock:settle:" + merchantId);
lock.lock();
try {
doSettle(merchantId);
} finally {
if (lock.isHeldByCurrentThread()) { // 关键:先判断再解锁
lock.unlock();
}
}
isHeldByCurrentThread() 内部也是一段 Lua,检查 hexists。它能彻底避免误解锁,代价是多一次 Redis 往返。
另外一个绕不开的问题:主从切换
Redisson 的锁是单 Redis 实例语义。如果主节点加锁成功但还没同步给从节点就挂了,从节点升主后锁就丢了。Redisson 提供了 RedissonRedLock,要求同时向 N 个独立主节点加锁,超过半数成功才算拿到:
RLock l1 = client1.getLock("lock:settle:" + id);
RLock l2 = client2.getLock("lock:settle:" + id);
RLock l3 = client3.getLock("lock:settle:" + id);
RedissonRedLock redLock = new RedissonRedLock(l1, l2, l3);
redLock.lock();
我没上红锁,原因是运维成本:三个独立 Redis 实例(不是集群的三个分片),故障恢复、时钟漂移、GC 停顿带来的 clock drift 问题都要自己扛。我们的结算是任务级幂等的(按 merchant_id + settle_date 有唯一索引兜底),丢锁的最坏结果是重复执行一次任务,而唯一索引会让第二次插入失败。所以宁可多一点重试,也不引入三套 Redis。
下篇预告
这篇先把《Redisson 分布式锁源码解析》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。