Redisson看门狗竟引发死锁?一次生产环境死锁事故复盘

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();
            }
        }
    }

关于分布式锁 

在解决具体的问题之前,方才先介绍下分布式锁的设计思路是怎样的,方便后续理解分析问题和解决问题的思路:

分布式锁的核心需求:

  1. 互斥性:同一时刻仅有一个客户端持有锁。

  2. 避免死锁:锁需有超时或自动释放机制,防止客户端崩溃后锁无法释放。

  3. 高可用:锁服务需具备容错能力,多数节点存活即可运行。

同时知晓下需要注意的事项:

  1. 锁超时问题:要么使用看门狗,自动续期;要么预估业务最大耗时,设置合理超时(避免客户端崩溃,导致锁一直无法释放的问题)。

  2. 锁权限问题:确保仅锁持有者能释放,避免越权释放导致分布式锁失效;

简单说,分布式锁就是保障同一时刻,只能有一个客户端去做某件事,且执行完成后及时释放锁,其他所有额外操作就是为了保证这一目标。

看看生产的问题 

接下来,我们来看看具体的生产环境问题:

  1. 根据用户反馈的现象,快速进行问题定位,判定是基于 Redisson 实现的分布式锁未正确释放的问题;

  2. 后台查询redis key的ttl,发现key的过期时间被不断定时更新为 30s(这就是 Redisson 的看门狗机制,每10s检查并延续分布式锁);

  3. 结合同时间段的应用异常日志,发现近期因为网络不稳定,导致redis的链接偶尔不可用。应用报错日志:

image-20250324165810001

问题分析 

结合应用报错和现象,方才推测是因为redis连接偶尔不可用,导致锁释放失败,但是看门狗的连接又是可用的,导致被无限续期。(是不是很离谱,但现实就是这么离谱,具体情况请往下看)

Redisson的看门狗机制

方才这里也简单补充下看门狗机制,tryLock的方法最终会执行org.redisson.RedissonLock#tryAcquireOnceAsync

image-20250324171757331

具体的看门机制实现如下:

  1. 在下图步骤1中,获取分布式锁成功后,就会将 threadID放到内存的 过期时间续签map中:EXPIRATION_RENEWAL_MAP ;

  2. 然后就是每隔10s的定时递归:在下图的 步骤3,从内存 ConcurrentMap  EXPIRATION_RENEWAL_MAP 中获取当前 【clientId+redisKey】对应的 threadId;

  3. 在步骤4,会去查询 redis,确定 分布式锁是否有效,并将分布式锁续签至 30s【如果redis没有这个key了,才会去 删除EXPIRATION_RENEWAL_MAP  记录的 threadId】;

  4. 也就是说,只要我的看门狗任务一直存在,且 redis key 也存在,那这个分布式锁就会被一直定时续期。

image-20250324173157586

深入分析问题

根据问题现象,本质就是分布锁未正确释放,那就会有两种可能,一种是根本没有去释放锁,另一个种是释放失败。

我们再来看看释放锁的代码,进行一步分析:

finally {
            if (lock != null && lockSuccess && lock.isLocked() && lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }

根据问题现象以及看门狗机制,结合 finally中的代码,方才有3个怀疑方向

  1. if判断 条件不满足,导致  lock.unlock()未被执行;

  2. if判断中,代码报错,导致线程终止,从而导致  lock.unlock()未被执行;

  3. lock.unlock()执行了,但是执行过程报错,导致看门狗没有被终止;

排查过程 

情况1

根据代码逻辑进行分析并结合 Redisson提供的API的语义,这个 if 判断逻辑没有问题:

  1. 要么一定为True,去执行 unlock方法;

  2. 要么就是这个锁已经释放了,或者就是这个锁不是当前线程的,不需要去释放锁。

情况2

情况2:if判断中,代码报错,导致线程终止,从而导致  lock.unlock()未被执行。

方才通过阅读源码,确实在if的表达式中,lock.isLocked() && lock.isHeldByCurrentThread() 会去执行redis命令,结合应用的连接报错,是有可能在该处抛出连接不可用的异常,然后线程被终止,导致   lock.unlock()未被执行的。

image-20250321182804067

情况3

同时方才也担心 Redisson 的   lock.unlock() 方法也有情况2类似的问题,因为连接redis失败,导致看门狗的定时任务未被清理(其实这是方才最先怀疑的地方,不信任任何开源产品,之前遇到过nacos因为连接短暂不可用,导致注册发现失败的情况)。

想要确定问题,就需要进行代码分析,lock.unlock()的最终实现是:org.redisson.RedissonBaseLock#unlockAsync0,阅读代码发现是先操作的redis,再释放的看门狗;

image-20250321182203692

所以方才最初也怀疑过是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())

这里想要做的操作是:

  1. 确保 lock对象不为空,lock != null这个合理,应保留;

  2. 确保tryLock加锁成功,lockSuccess 这个合理,应保留;

  3. 通过lock.isLocked() 检查锁是否是被锁状态,结合unlock的实现,底层已经做了兜底,可删除;image-20250321183203170

  4. 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操作不会越权释放其他客户端或其他线程持有的锁。

image-20250321183859501

修正后的代码为

结合前面问题的分析,以及unlock底层的实现,最终代码修正为:

finally {
            if (lock != null && lockSuccess ) {
                // 直接释放锁,redisson底层已经控制了线程越界问题
                try {
                    lock.unlock();
                } catch (IllegalMonitorStateException e) {
                    // 锁已被释放或不属于当前线程(可忽略)
                }
            }
        }

最后 

这个问题最后解决起来挺简单的,但分析问题、定位问题的过程,还是很有意思。而且本身这个故障也很极端,一般情况根本无法复现(同样的Redis集群,一个连接池中的连接,部分连接不可用)。

但这是一个比较经典的分布式问题,在做核心业务逻辑的实现时,我们需要严格的考虑到每一段涉及外部调用的代码,都有可能失败,此时逻辑需要形成闭环。

如果本文对你有所帮助,记得给方才点个爱心,也可以在评论区说说你曾经遇到过的坑。


交流群 

相遇即是缘分,方才送你一份优质的资料(包括方才自己输出的ES、前端、Mysql系列的知识图谱,软考架构师资料,方才阅读过的优质书籍等等资料)

也可备注加群,方才拉你进入优质的技术交流群(日常分享高质量的技术文章、优质的资料、实时资讯共享等)

技术资料
技术资料
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值