Hello 我是方才,10人研发leader、4年团队管理&架构经验。
专注于分享成体系的编程知识、职场经验、个人成长历程等!
文末,方才送你一份优质的技术资料,记得领取哟!
今天给大家分享个,方才最近在生产环境遇到的一个bug:一个基于Redisson实现的分布式锁,因看门狗机制导致锁被不断延期,从而出现死锁的问题。
ps:这块代码已经一年没有动过了,最近突然频发,还是很有意思的,看完我相信对你一定有所启发。
大家可以看看这个分布式锁的工具类方法,思考下原因点:
@SneakyThrows
publicstatic <T> T lock(Callable<T> callable, LockConfig lockConfig) {
RLock lock = redissonClient.getFairLock(lockConfig.getKey());
boolean lockSuccess = false;
try {
lockSuccess = lock.tryLock(lockConfig.getWaitTime(), lockConfig.getWaitTimeUnit());
if (!lockSuccess) {
// 加锁失败, 调用失败回调
throw lockConfig.getExceptionSupplier().get();
}
// 执行业务方法
return callable.call();
} finally {
if (lock != null && lockSuccess && lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
关于分布式锁
在解决具体的问题之前,方才先介绍下分布式锁的设计思路是怎样的,方便后续理解分析问题和解决问题的思路:
分布式锁的核心需求:
互斥性:同一时刻仅有一个客户端持有锁。
避免死锁:锁需有超时或自动释放机制,防止客户端崩溃后锁无法释放。
高可用:锁服务需具备容错能力,多数节点存活即可运行。
同时知晓下需要注意的事项:
锁超时问题:要么使用看门狗,自动续期;要么预估业务最大耗时,设置合理超时(避免客户端崩溃,导致锁一直无法释放的问题)。
锁权限问题:确保仅锁持有者能释放,避免越权释放导致分布式锁失效;
简单说,分布式锁就是保障同一时刻,只能有一个客户端去做某件事,且执行完成后及时释放锁,其他所有额外操作就是为了保证这一目标。
看看生产的问题
接下来,我们来看看具体的生产环境问题:
根据用户反馈的现象,快速进行问题定位,判定是基于 Redisson 实现的分布式锁未正确释放的问题;
后台查询redis key的ttl,发现key的过期时间被不断定时更新为 30s(这就是 Redisson 的看门狗机制,每10s检查并延续分布式锁);
结合同时间段的应用异常日志,发现近期因为网络不稳定,导致redis的链接偶尔不可用。应用报错日志:

问题分析
结合应用报错和现象,方才推测是因为redis连接偶尔不可用,导致锁释放失败,但是看门狗的连接又是可用的,导致被无限续期。(是不是很离谱,但现实就是这么离谱,具体情况请往下看)
Redisson的看门狗机制
方才这里也简单补充下看门狗机制,tryLock的方法最终会执行org.redisson.RedissonLock#tryAcquireOnceAsync
:

具体的看门机制实现如下:
在下图步骤1中,获取分布式锁成功后,就会将 threadID放到内存的 过期时间续签map中:EXPIRATION_RENEWAL_MAP ;
然后就是每隔10s的定时递归:在下图的 步骤3,从内存 ConcurrentMap EXPIRATION_RENEWAL_MAP 中获取当前 【clientId+redisKey】对应的 threadId;
在步骤4,会去查询 redis,确定 分布式锁是否有效,并将分布式锁续签至 30s【如果redis没有这个key了,才会去 删除EXPIRATION_RENEWAL_MAP 记录的 threadId】;
也就是说,只要我的看门狗任务一直存在,且 redis key 也存在,那这个分布式锁就会被一直定时续期。

深入分析问题
根据问题现象,本质就是分布锁未正确释放,那就会有两种可能,一种是根本没有去释放锁,另一个种是释放失败。
我们再来看看释放锁的代码,进行一步分析:
finally {
if (lock != null && lockSuccess && lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
根据问题现象以及看门狗机制,结合 finally中的代码,方才有3个怀疑方向:
if判断 条件不满足,导致
lock.unlock()
未被执行;if判断中,代码报错,导致线程终止,从而导致
lock.unlock()
未被执行;lock.unlock()
执行了,但是执行过程报错,导致看门狗没有被终止;
排查过程
情况1
根据代码逻辑进行分析并结合 Redisson提供的API的语义,这个 if 判断逻辑没有问题:
要么一定为True,去执行 unlock方法;
要么就是这个锁已经释放了,或者就是这个锁不是当前线程的,不需要去释放锁。
情况2
情况2:if判断中,代码报错,导致线程终止,从而导致 lock.unlock()
未被执行。
方才通过阅读源码,确实在if的表达式中,lock.isLocked() && lock.isHeldByCurrentThread()
会去执行redis命令,结合应用的连接报错,是有可能在该处抛出连接不可用的异常,然后线程被终止,导致 lock.unlock()
未被执行的。

情况3
同时方才也担心 Redisson 的 lock.unlock()
方法也有情况2类似的问题,因为连接redis失败,导致看门狗的定时任务未被清理(其实这是方才最先怀疑的地方,不信任任何开源产品,之前遇到过nacos因为连接短暂不可用,导致注册发现失败的情况)。
想要确定问题,就需要进行代码分析,lock.unlock()
的最终实现是:org.redisson.RedissonBaseLock#unlockAsync0
,阅读代码发现是先操作的redis,再释放的看门狗;

所以方才最初也怀疑过是unlock
方法在操作redis失败时,导致看门狗未释放;
不过经过对 CompletionStage
的理解,以及本地通过防火墙模拟unlock
时与redis的链接不可用的测试,发现不管 CompletionStage<Boolean> future = unlockInnerAsync(threadId);
的执行结果是否成功,都会执行cancelExpirationRenewal(threadId);
释放看门狗( CompletionStage
这个是Redisson自己封装的)。
经过验证 Redisson 在实现 unlock
方法的时候,本身的健壮性还是很好的,考虑到了很极端的情况。
修正思路
通过上面的排查,明确问题就是if
表达式执行时,因为redis网络连接不可用,代码报错,导致线程终止,从而导致 lock.unlock()
未被执行。
那该如何修改呢?首先阅读该判断的逻辑:if (lock != null && lockSuccess && lock.isLocked() && lock.isHeldByCurrentThread())
。
这里想要做的操作是:
确保
lock
对象不为空,lock != null
这个合理,应保留;确保tryLock加锁成功,
lockSuccess
这个合理,应保留;通过
lock.isLocked()
检查锁是否是被锁状态,结合unlock
的实现,底层已经做了兜底,可删除;lock.isHeldByCurrentThread()
的作用是保证当前锁是被当前线程所持有的,避免把人家的锁给释放了,同上,unlock
已经做了兜底,可删除;
ps:参考分布锁要达到的目标,lock.isLocked()
和 lock.isHeldByCurrentThread()
本身的判定似乎是合理的,且必须的,但忽略了在分布环境下,我们需要考虑各种极端情况下,确保核心业务逻辑的正确运行和数据的最终一致性的问题。
在这个场景下,因为前置检查失败,导致未执行unlcok
操作,从而导致分布锁一直被续期,就未满足数据最终一致性的要求。
unlock
的实现
关于unlock
操作已经确保了不会越权释放的实现,具体看下unlock
的redis指令(ps:这段lua脚本的解释,大家可以直接丢给AI):
@Override
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
// remove stale threads
"while true do "
+ "local firstThreadId2 = redis.call('lindex', KEYS[2], 0);"
+ "if firstThreadId2 == false then "
+ "break;"
+ "end; "
+ "local timeout = tonumber(redis.call('zscore', KEYS[3], firstThreadId2));"
+ "if timeout <= tonumber(ARGV[4]) then "
+ "redis.call('zrem', KEYS[3], firstThreadId2); "
+ "redis.call('lpop', KEYS[2]); "
+ "else "
+ "break;"
+ "end; "
+ "end;"
+ "if (redis.call('exists', KEYS[1]) == 0) then " +
"local nextThreadId = redis.call('lindex', KEYS[2], 0); " +
"if nextThreadId ~= false then " +
"redis.call(ARGV[5], KEYS[4] .. ':' .. nextThreadId, ARGV[1]); " +
"end; " +
"return 1; " +
"end;" +
"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; " +
"end; " +
"redis.call('del', KEYS[1]); " +
"local nextThreadId = redis.call('lindex', KEYS[2], 0); " +
"if nextThreadId ~= false then " +
"redis.call(ARGV[5], KEYS[4] .. ':' .. nextThreadId, ARGV[1]); " +
"end; " +
"return 1; ",
Arrays.asList(getRawName(), threadsQueueName, timeoutSetName, getChannelName()),
LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId),
System.currentTimeMillis(), getSubscribeService().getPublishCommand());
}
结合Redisson实现分布式锁底层的存储结构,使用的hash类型,其中hash中的key组成规则是:clientId+threadId。
protected String getLockName(long threadId) {
return id + ":" + threadId;
}
// ID 是基于UUID生成
private final String id = UUID.randomUUID().toString();
Redisson在 redis key和 lua 脚本的实现设计上,就已经确保了unlock
操作不会越权释放其他客户端或其他线程持有的锁。

修正后的代码为
结合前面问题的分析,以及unlock
底层的实现,最终代码修正为:
finally {
if (lock != null && lockSuccess ) {
// 直接释放锁,redisson底层已经控制了线程越界问题
try {
lock.unlock();
} catch (IllegalMonitorStateException e) {
// 锁已被释放或不属于当前线程(可忽略)
}
}
}
最后
这个问题最后解决起来挺简单的,但分析问题、定位问题的过程,还是很有意思。而且本身这个故障也很极端,一般情况根本无法复现(同样的Redis集群,一个连接池中的连接,部分连接不可用)。
但这是一个比较经典的分布式问题,在做核心业务逻辑的实现时,我们需要严格的考虑到每一段涉及外部调用的代码,都有可能失败,此时逻辑需要形成闭环。
如果本文对你有所帮助,记得给方才点个爱心,也可以在评论区说说你曾经遇到过的坑。
交流群
相遇即是缘分,方才送你一份优质的资料(包括方才自己输出的ES、前端、Mysql系列的知识图谱,软考架构师资料,方才阅读过的优质书籍等等资料)。
也可备注加群,方才拉你进入优质的技术交流群(日常分享高质量的技术文章、优质的资料、实时资讯共享等)。
