Redisson 看门狗机制详解:分布式锁如何自动续期防止提前过期
一、看门狗要解决什么问题
1.1 锁超时的两难困境
给分布式锁设置超时时间是为了防止死锁(持有者崩溃后锁能自动释放)。但超时时间设多少是个难题:
| 设置 | 风险 |
|---|---|
| 设太短(如5秒) | 业务还没执行完,锁就过期了,其他线程趁虚而入 |
| 设太长(如10分钟) | 持有者崩溃后,其他线程要等10分钟才能获取锁 |
举个例子:
你设置锁超时30秒,正常情况下业务2秒就完成了。但某天数据库慢查询导致业务执行了35秒:
线程A 线程B
│ │
├── 加锁(超时30秒) │
├── 开始执行业务... │
│ (数据库慢查询中...) │
│ │
├── 第30秒:锁自动过期! │
│ ├── 加锁成功(锁已过期)
│ (还在执行中...) ├── 开始操作同一数据
│ │
├── 第35秒:业务完成 │
├── 释放锁 → 释放的是线程B的锁! │
│ │
结果:两个线程同时操作了同一数据,数据不一致
1.2 看门狗的解决思路
不设固定的超时时间,而是让一个后台线程(看门狗)定期检查锁是否还在使用,如果还在使用就自动延长过期时间。
就像你在图书馆占了一个座位,规定2小时不用就收回。看门狗就是你的朋友,每隔1小时帮你去前台说"他还在用",这样座位就不会被收回。直到你真的离开了,朋友也不再帮你续期,座位自然就释放了。
注:
博客:
https://blog.csdn.net/badao_liumang_qizhi
二、看门狗的工作原理
2.1 核心流程
加锁成功(设置初始过期时间30秒)
│
├── 启动看门狗定时任务(每10秒执行一次)
│
│ ┌─── 第10秒:检查锁是否还被持有 → 是 → 续期到30秒
│ │
│ ├─── 第20秒:检查锁是否还被持有 → 是 → 续期到30秒
│ │
│ ├─── 第30秒:检查锁是否还被持有 → 是 → 续期到30秒
│ │
│ ├─── 第40秒:检查锁是否还被持有 → 是 → 续期到30秒
│ │
│ └─── ...(持续续期,直到主动释放锁)
│
▼
主动释放锁 → 停止看门狗 → 锁正常过期
2.2 关键参数
| 参数 | Redisson 默认值 | 含义 |
|---|---|---|
| lockWatchdogTimeout | 30秒 | 锁的初始过期时间,也是每次续期的时长 |
| 续期间隔 | 过期时间的 1/3(即10秒) | 每隔多久检查一次是否需要续期 |
2.3 看门狗何时停止
| 场景 | 看门狗行为 |
|---|---|
主动调用 unlock() |
立即停止续期,删除锁 |
| 持有锁的线程执行完毕 | 停止续期,锁到期后自动释放 |
| 持有锁的 JVM 进程崩溃 | 看门狗随进程一起消失,锁到期后自动释放 |
| 网络断开(与 Redis 断连) | 续期失败,锁到期后自动释放 |
三、Redisson 中的使用方式
3.1 启用看门狗(不指定 leaseTime)
java
RLock lock = redissonClient.getLock("my-lock");
// 只指定等待时间,不指定 leaseTime → 自动启用看门狗
lock.tryLock(10, TimeUnit.SECONDS);
try {
// 业务逻辑,无论执行多久,锁都不会过期
doSlowBusinessLogic();
} finally {
lock.unlock(); // 主动释放,看门狗停止
}
3.2 禁用看门狗(指定 leaseTime)
java
RLock lock = redissonClient.getLock("my-lock");
// 指定 leaseTime=30秒 → 不启用看门狗,30秒后强制过期
lock.tryLock(10, 30, TimeUnit.SECONDS);
try {
// 如果业务超过30秒,锁会自动释放!
doBusinessLogic();
} finally {
lock.unlock();
}
3.3 对比
| 方式 | 代码 | 看门狗 | 适用场景 |
|---|---|---|---|
| 自动续期 | tryLock(waitTime) |
启用 | 业务耗时不确定 |
| 固定超时 | tryLock(waitTime, leaseTime) |
不启用 | 业务耗时可预估 |
四、看门狗的内部实现(简化版)
4.1 伪代码实现
java
/**
* 看门狗机制的简化实现.
*/
public class WatchdogLock {
private final RedisTemplate<String, String> redisTemplate;
private final String lockKey;
private final String holderId;
private final long watchdogTimeout; // 默认30秒
private ScheduledFuture<?> renewalTask; // 续期定时任务
private final ScheduledExecutorService scheduler;
/**
* 加锁并启动看门狗.
*/
public boolean tryLock(long waitTime, TimeUnit unit) {
// 尝试加锁,设置初始过期时间
Boolean success = redisTemplate.opsForValue()
.setIfAbsent(lockKey, holderId,
watchdogTimeout, TimeUnit.MILLISECONDS);
if (Boolean.TRUE.equals(success)) {
// 加锁成功,启动看门狗
startWatchdog();
return true;
}
return false;
}
/**
* 启动看门狗定时任务.
* 每隔 watchdogTimeout/3 的时间检查并续期.
*/
private void startWatchdog() {
long renewInterval = watchdogTimeout / 3; // 10秒
renewalTask = scheduler.scheduleAtFixedRate(() -> {
// 检查锁是否还是自己持有的
String currentHolder =
redisTemplate.opsForValue().get(lockKey);
if (holderId.equals(currentHolder)) {
// 续期:重新设置过期时间
redisTemplate.expire(lockKey,
watchdogTimeout, TimeUnit.MILLISECONDS);
} else {
// 锁已经不是自己的了,停止续期
stopWatchdog();
}
}, renewInterval, renewInterval, TimeUnit.MILLISECONDS);
}
/**
* 释放锁并停止看门狗.
*/
public void unlock() {
// 停止看门狗
stopWatchdog();
// 释放锁(验证持有者)
String currentHolder =
redisTemplate.opsForValue().get(lockKey);
if (holderId.equals(currentHolder)) {
redisTemplate.delete(lockKey);
}
}
/**
* 停止看门狗定时任务.
*/
private void stopWatchdog() {
if (renewalTask != null && !renewalTask.isCancelled()) {
renewalTask.cancel(false);
}
}
}
4.2 续期的 Redis 命令
看门狗每次续期实际执行的是:
# 检查锁是否还是自己持有的,如果是则续期
# 使用 Lua 脚本保证原子性
EVAL "
if redis.call('get', KEYS[1]) == ARGV[1] then
return redis.call('pexpire', KEYS[1], ARGV[2])
else
return 0
end
" 1 "my-lock" "holder-id-123" "30000"
用 Lua 脚本是为了保证"检查持有者"和"续期"这两步是原子操作,不会被其他命令打断。
五、看门狗的时序图
5.1 正常场景:业务执行时间超过锁的初始过期时间
时间轴(秒) 0 10 20 30 35 40
│ │ │ │ │ │
加锁(30s) ├─────────────────────────────┤ 没有看门狗的话这里锁就过期了
│ │ │ │ │ │
看门狗续期 │ ├→续期 │ │ │
│ │ ├→续期 │ │
│ │ │ ├→续期 │
│ │ │ │ │ │
业务执行 ├─────────────────────────┤ │
│ │ │ │ │ │
unlock() │ │ │ │ ├→释放锁,停止看门狗
│ │ │ │ │ │
结果:业务执行了35秒,但锁始终有效,没有被其他线程抢走
5.2 异常场景:JVM 崩溃
时间轴(秒) 0 10 15 20 30 40
│ │ │ │ │ │
加锁(30s) ├─────────────────────────────┤
│ │ │ │ │ │
看门狗续期 │ ├→续期 │ │ │
│ │ │ │ │ │
JVM崩溃 │ │ ✗ │ │ │ ← 第15秒进程崩溃
│ │ │ │ │
│ │ │ │ │ 看门狗随进程消失
│ │ │ │ │ 最后一次续期在第10秒
│ │ │ │ │ 锁将在第40秒过期(10+30)
│ │ │ │ │
其他线程 │ │ │ │ ├→ 第40秒:锁过期,可以获取
结果:进程崩溃后最多等30秒(一个续期周期),锁自动释放
六、什么时候用看门狗,什么时候不用
6.1 推荐使用看门狗的场景
| 场景 | 原因 |
|---|---|
| 业务耗时不确定(如调用外部接口) | 无法预估超时时间 |
| 涉及大量数据库操作 | 慢查询可能导致耗时波动 |
| 后台异步任务 | 处理时间可能很长 |
6.2 不推荐使用看门狗的场景
| 场景 | 原因 | 建议 |
|---|---|---|
| 秒杀/抢购 | 需要快速失败,不应长时间持有锁 | 固定短超时 |
| 简单的状态更新 | 耗时可预估(毫秒级) | 固定超时即可 |
| 对锁释放时间有严格要求 | 看门狗续期可能导致锁持有过久 | 固定超时 |
6.3 代码选择
java
// 场景一:后台订单处理(耗时不确定)→ 用看门狗
lock.tryLock(30, TimeUnit.SECONDS); // 不指定 leaseTime
// 场景二:秒杀扣库存(必须快速完成)→ 不用看门狗
lock.tryLock(5, 10, TimeUnit.SECONDS); // 指定 leaseTime=10秒
七、看门狗的注意事项
7.1 必须主动调用 unlock()
看门狗会一直续期,如果你忘记调用 unlock(),锁会被无限续期,永远不释放(直到 JVM 退出)。
java
// 错误:忘记 unlock,锁永远不释放
public void badMethod() {
RLock lock = redissonClient.getLock("key");
lock.lock(); // 看门狗启动
doSomething(); // 如果这里抛异常...
// unlock 永远不会被调用!看门狗持续续期!
}
// 正确:用 try-finally 保证释放
public void goodMethod() {
RLock lock = redissonClient.getLock("key");
lock.lock();
try {
doSomething();
} finally {
lock.unlock(); // 无论是否异常都释放
}
}
7.2 看门狗是线程级别的
看门狗绑定的是"加锁的线程"。如果加锁线程结束了但没有调用 unlock,看门狗的行为取决于实现:
- Redisson:看门狗任务在 Netty 的事件循环中运行,即使加锁线程结束,只要 JVM 还在,看门狗仍会续期
- 所以一定要确保 unlock 被调用
7.3 集群环境下的可靠性
看门狗续期依赖与 Redis 的网络连接。如果网络断开:
正常:看门狗 → Redis(续期成功)
断网:看门狗 → X → Redis(续期失败)
→ 锁在上次续期后的30秒内过期
→ 其他实例可以获取锁
这是一种"安全失败"的设计:宁可让锁过期(可能短暂重复处理),也不让锁永远不释放(死锁)。
八、完整示例:带看门狗的文件导入任务
java
/**
* 文件导入服务.
* 导入大文件可能耗时几分钟,使用看门狗防止锁提前过期.
*/
@Service
public class FileImportServiceImpl implements FileImportService {
@Resource
private RedissonClient redissonClient;
@Resource
private FileRecordRepository fileRecordRepository;
/**
* 导入文件(耗时不确定,可能几秒也可能几分钟).
*/
public void importFile(Integer fileId) {
// 按文件ID加锁,防止同一文件被重复导入
String lockKey = "file-import-" + fileId;
RLock lock = redissonClient.getLock(lockKey);
try {
// 只指定等待时间,不指定 leaseTime → 启用看门狗
boolean acquired = lock.tryLock(10, TimeUnit.SECONDS);
if (!acquired) {
log.warn("文件正在被其他实例导入, fileId:{}", fileId);
return;
}
// 看门狗已启动,无论导入多久锁都不会过期
log.info("开始导入文件, fileId:{}", fileId);
// 模拟耗时操作:逐行读取并处理
FileRecord fileRecord = fileRecordRepository
.findById(fileId).orElseThrow();
List<String> lines = readFileLines(fileRecord.getFilePath());
for (int i = 0; i < lines.size(); i++) {
processLine(lines.get(i));
// 每处理1000行打印进度
if (i % 1000 == 0) {
log.info("导入进度: {}/{}, fileId:{}",
i, lines.size(), fileId);
}
}
// 更新状态
fileRecord.setStatus("COMPLETED");
fileRecordRepository.save(fileRecord);
log.info("文件导入完成, fileId:{}, 总行数:{}",
fileId, lines.size());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
log.warn("文件导入被中断, fileId:{}", fileId);
} catch (Exception e) {
log.error("文件导入异常, fileId:{}", fileId, e);
} finally {
// 释放锁,看门狗停止
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
}
执行过程:
第0秒: 加锁成功,看门狗启动(每10秒续期一次)
第10秒: 看门狗续期 → 锁过期时间重置为30秒后
第20秒: 看门狗续期 → 锁过期时间重置为30秒后
...
第180秒:文件导入完成(处理了3分钟)
第180秒:调用 unlock() → 锁释放,看门狗停止
整个过程中锁始终有效,不会被其他实例抢走
九、总结
| 问题 | 答案 |
|---|---|
| 看门狗是什么? | 一个后台定时任务,定期给锁续期 |
| 为什么需要看门狗? | 解决"锁超时时间难以确定"的问题 |
| 什么时候启用? | 调用 tryLock 时不指定 leaseTime |
| 续期频率是多少? | 默认每 10 秒一次(过期时间的 1/3) |
| 进程崩溃后锁会一直存在吗? | 不会,看门狗随进程消失,锁在最后一次续期后30秒过期 |
| 忘记 unlock 会怎样? | 锁被无限续期,永远不释放(直到 JVM 退出) |
| 和固定超时的区别? | 看门狗适合耗时不确定的场景,固定超时适合耗时可预估的场景 |