看门狗机制:从锁过期到自动续期的工程实践——Redisson分布式锁的生命线

大家好,我是程序员小策。

凌晨两点,线上告警炸了。

你打开监控一看:库存被扣成了负数。日志里两条订单几乎同时通过了扣库存逻辑------明明加了分布式锁,怎么还是锁不住?

原因很简单------锁过期了。你的业务执行了 15 秒,但锁的 TTL 只设了 10 秒。锁提前释放,第二个请求趁虚而入,两个请求同时扣了库存。

这就是分布式锁最经典的翻车场景:业务没跑完,锁先跑了。

问题定义:锁过期 ≠ 业务完成

分布式锁的基本套路你肯定知道:SETNX 抢锁,设个过期时间,用完释放。看起来天衣无缝。

但问题在于:你设的过期时间,凭什么刚好等于业务执行时间?

设短了------业务没跑完锁就释放了,别的线程趁虚而入,锁形同虚设。设长了------业务挂了锁不释放,别人等到天荒地老。

那设个"差不多"的值呢?比如 30 秒?也不行。平时 30 秒够用,一旦遇到慢 SQL、GC 停顿、网络抖动,业务执行时间可能飙到 40 秒。你永远无法预知业务到底要跑多久。

这就是核心矛盾:锁的过期时间是静态的,但业务的执行时间是动态的。

核心概念:看门狗

看门狗(Watchdog)是 Redisson 提供的自动续期机制------在锁的持有期间,后台线程每隔一段时间自动延长锁的过期时间,确保业务没跑完锁不会过期。

打个比方:你玩联机游戏,服务器要求客户端每隔 5 秒发一次心跳。收到了心跳,服务器就知道"这个玩家还在线",继续保留他的房间。一旦心跳断了,服务器判定玩家掉线,把房间回收。

看门狗就是这个心跳机制。Redisson 持有锁的线程每隔 10 秒(lockWatchdogTimeout / 3)向 Redis 发一次续期请求,相当于告诉 Redis:"我还活着,锁别过期。"如果线程挂了或者卡死了,心跳自然就断了,锁到期自动释放。

翻译回技术语言:

游戏心跳 看门狗
客户端发心跳 后台线程发续期命令
5 秒一次 10 秒一次(lockWatchdogTimeout / 3)
服务器保留房间 Redis 延长锁 TTL
心跳断开 → 回收房间 续期停止 → 锁自动过期

实现:看门狗到底怎么跑的

先看一段最基本的使用方式:

java 复制代码
import org.redisson.api.RLock;
import org.redisson.api.RedissonClient;
import org.springframework.stereotype.Service;

@Service
public class OrderService {

    private final RedissonClient redisson;

    public OrderService(RedissonClient redisson) {
        this.redisson = redisson;
    }

    public void deductStock(String productId) {
        RLock lock = redisson.getLock("lock:stock:" + productId);
        try {
            // 不传 leaseTime,看门狗自动生效
            lock.tryLock(0, -1, java.util.concurrent.TimeUnit.SECONDS);
            
            // 业务逻辑:扣减库存
            doDeduct(productId);
            
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        } finally {
            if (lock.isHeldByCurrentThread()) {
                lock.unlock();
            }
        }
    }

    private void doDeduct(String productId) {
        // 扣库存逻辑...
    }
}

关键点:lock.tryLock(0, -1, TimeUnit.SECONDS) 第二个参数传了 -1。

这个 -1 就是"不指定 leaseTime",Redisson 检测到 leaseTime = -1 时,就会启动看门狗。如果你传了一个具体的值(比如 30 秒),看门狗就不会启动------Redisson 认为你自己管过期时间。

看门狗的续期逻辑在 Redisson 源码的 RedissonLock.RenewalScheduler 中,核心流程如下:

java 复制代码
// Redisson 源码简化版
private void scheduleRenewal(long threadId) {
    RenewalTask task = new RenewalTask(threadId);
    // 延迟 lockWatchdogTimeout / 3 后执行续期
    // 默认 lockWatchdogTimeout = 30 秒,所以每 10 秒续期一次
    timeout = task.schedule(lockWatchdogTimeout / 3, TimeUnit.MILLISECONDS);
}

class RenewalTask implements Runnable {
    @Override
    public void run() {
        // 1. 异步执行 PEXPIRE 命令,重置锁的过期时间
        boolean success = renewExpiration(threadId);
        if (success) {
            // 2. 续期成功,递归调度下一次续期
            scheduleRenewal(threadId);
        }
        // 3. 续期失败(锁已经不属于当前线程),停止续期
    }
}

整个续期过程是递归调度的:续期成功 → 调度下一次 → 续期成功 → 再调度......直到 unlock() 被调用或者续期失败。

续期命令用的是 Lua 脚本,保证了原子性:

lua 复制代码
-- Redisson 续期 Lua 脚本简化版
if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then
    -- 锁还属于当前线程,续期
    redis.call('pexpire', KEYS[1], ARGV[1])
    return 1
end
return 0

先检查锁的持有者是不是自己,是才续期。这防止了一个线程给另一个线程的锁续期。

边界与陷阱

看起来很完美了对吧?但有几个坑你得知道。

陷阱一:unlock 必须放在 finally 里,且要判断 isHeldByCurrentThread。

后果:如果业务抛异常,unlock 没执行,看门狗会一直续期,锁永远不释放------这比锁过期更可怕。

解法:unlock 前判断 lock.isHeldByCurrentThread(),防止释放别人的锁。

陷阱二:传了 leaseTime 就没有看门狗。

后果:lock.lock(10, TimeUnit.SECONDS) 这样写,看门狗不会启动。10 秒后锁强制过期,不管业务跑没跑完。

解法:需要看门狗就别传 leaseTime,或者传 -1。

陷阱三:lockWatchdogTimeout 是全局配置,改了影响所有锁。

后果:把 lockWatchdogTimeout 从 30 秒改成 10 秒,续期间隔变成 3.3 秒。如果某个业务偶尔 GC 停顿 5 秒,锁就过期了。

解法:默认 30 秒别乱改。如果某个锁确实需要更短的过期时间,单独传 leaseTime。

高级考量:多节点与主从切换

看门狗在单节点 Redis 下工作得很好。但生产环境通常是主从架构。

问题:主节点加锁成功,数据还没同步到从节点,主节点挂了。 从节点升为主节点后,锁信息丢失。另一个线程在新主上也能加锁成功------两个线程同时持有锁。

看门狗解决不了这个问题。它只负责续期,不负责主从一致性。

Redisson 提供了 RedLock 算法来应对:向 N 个独立 Redis 节点加锁,超过半数成功才算加锁成功。但 RedLock 本身争议很大------Martin Kleppmann 写了一篇著名的文章质疑它,Salvatore(Redis 作者)又写了一篇反驳。

实践中更常见的做法是:接受极低概率的锁丢失,在业务层做幂等兜底。 比如扣库存前先查一下是否已经扣过,用唯一订单号做去重。这比 RedLock 简单得多,也更可靠。

对比表格

方案 核心思路 优点 缺点 适用场景
SETNX + 固定 TTL 加锁时设过期时间 简单 业务没跑完锁就过期 执行时间确定的短任务
SETNX + 看门狗 后台线程自动续期 锁不过期,安全 线程挂了需要等 TTL 才释放 执行时间不确定的长任务
SETNX + 手动续期 业务代码自己续期 灵活可控 忘了续期就锁过期,代码复杂 极少数需要精确控制的场景
RedLock 多节点加锁,半数成功 主从切换安全 性能差、争议大 对一致性要求极高的场景

一句话总结:大部分场景用看门狗就够了。只有在"锁丢失不可接受"且"业务无法做幂等"的极端场景下,才考虑 RedLock。

面试追问

追问 1:看门狗的续期间隔为什么是 lockWatchdogTimeout / 3?

→ 回答方向:留出足够的容错空间。如果续期请求因为网络抖动失败了,还有 2/3 的时间(20 秒)可以重试。如果间隔设成 lockWatchdogTimeout 本身,一次续期失败锁就过期了。

追问 2:如果持有锁的线程发生 Full GC,看门狗还能续期吗?

→ 回答方向:不能。Full GC 期间所有线程暂停(Stop-The-World),看门狗线程也被冻结,无法发送续期请求。如果 GC 时间超过 lockWatchdogTimeout,锁会过期。这就是为什么 lockWatchdogTimeout 默认 30 秒------给 GC 留出足够的缓冲。

追问 3:可重入锁的续期是怎么处理的?

→ 回答方向:Redisson 的锁是可重入的,用 Hash 结构存储(field 是线程标识,value 是重入次数)。看门狗续期时只看 field 是否存在,不管重入次数。只要锁还属于当前线程,就续期。unlock 时重入次数减到 0 才真正释放锁并停止续期。

追问 4:看门狗续期失败会怎样?

→ 回答方向:续期失败(锁已被释放或属于别的线程),看门狗立即停止递归调度,不再续期。锁会在剩余 TTL 到期后自动释放。这是正确的行为------续期失败说明锁已经不是你的了,不应该继续霸占。

总结

看门狗解决的核心问题是:锁的过期时间是静态的,但业务的执行时间是动态的。

读完这篇你应该能:解释为什么 SETNX + 固定 TTL 不够用、说出看门狗的续期间隔和默认超时时间、在代码中正确使用 Redisson 的看门狗(不传 leaseTime)、在面试中区分看门狗和 RedLock 解决的是不同层面的问题。

相关推荐
水木流年追梦5 小时前
大模型入门-大模型分布式训练2
开发语言·分布式·python·算法·正则表达式·prompt
松☆5 小时前
torchtitan-npu:7B大模型在8卡NPU上的分布式训练实录
分布式
ZPC82107 小时前
DGX Spark 200G 跟 100G 设备的通讯协议
大数据·分布式·spark
水木流年追梦8 小时前
大模型入门-大模型分布式训练1
开发语言·分布式·python·算法·正则表达式·prompt
ULIi096kr10 小时前
Redis 分布式锁进阶第七十二篇
数据库·redis·分布式
云祺vinchin10 小时前
云祺&南大通用:打造分布式数据库建设与灾备方案
数据库·分布式·数据安全
bn9jBl64810 小时前
Redis 分布式锁进阶第七十七篇
数据库·redis·分布式
ULIi096kr10 小时前
Redis 分布式锁进阶第七十一篇
数据库·redis·分布式
phltxy11 小时前
RabbitMQ 发送方确认与重试机制
分布式·rabbitmq·ruby