Administrator
发布于 2021-12-11 / 6826 阅读
170

Redisson 分布式锁源码解析

日志里那句 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 分布式锁源码解析》里的坑列了,下一篇写我们当时是怎么在线上工程里真正落地的——包括那次让领导拍桌的故障复盘。

参考