day39 Redisson可重入锁、WatchDog与MultiLock
一、为什么使用 Redisson
上一阶段实现的 SimpleRedisLock 已经能借助线程标识和 Lua 脚本避免误删,但仍有四个不足:
| 问题 | 影响 | Redisson 的对应能力 |
|---|---|---|
| 不可重入 | 同一线程再次获取同一把锁会失败,嵌套调用可能无法继续 | 使用 Hash 记录持有线程与重入次数 |
| 不可重试 | 获取失败后立即返回,无法在限定时间内等待 | tryLock 支持等待并重试 |
| 固定超时释放 | 业务未执行完,锁可能先过期 | WatchDog 自动续期 |
| 主从一致性 | 锁尚未同步时主节点故障,故障转移后可能丢锁 | MultiLock 同时锁定多个独立节点 |
Redisson 是基于 Redis 的 Java 客户端,提供 RLock 等分布式对象。这里使用它的目的不是替换 Redis,而是复用其已经封装好的分布式锁语义与复杂并发处理。
二、项目接入与基本使用
2.1 引入依赖
项目的 pom.xml 已引入 redisson:
1 | <dependency> |
2.2 创建 RedissonClient
RedissonConfig 使用单节点模式连接本机 Redis。项目目前分别为 6379 和 6380 创建了两个客户端:
1 |
|
redis:// 表示非 TLS 连接;如果 Redis 配有密码,还需要调用 setPassword。
2.3 在一人一单业务中使用 RLock
项目使用用户 ID 组成锁名,使同一用户的下单请求互斥,不同用户仍可并行:
1 | Long userId = UserHolder.getUser().getId(); |
基本调用顺序是:
1 | RedissonClient.getLock(锁名称) |
释放锁必须放在 finally 中,避免业务异常导致正常释放逻辑被跳过。只有成功获取锁后才应执行 unlock();项目当前的控制流符合这一点。
tryLock 的常用形式:
1 | boolean acquired = lock.tryLock(1, 10, TimeUnit.SECONDS); |
waitTime:最多等待多久,在此期间 Redisson 可以重试;leaseTime:获取成功后锁的固定租约时间;TimeUnit:时间单位。
如果显式传入 leaseTime,锁按固定租约到期;需要自动续期时,不应显式指定固定租约时间。
三、可重入锁原理
3.1 Hash 存储结构
Redisson 的可重入锁使用 Redis Hash 保存锁状态:
1 | 锁名称(Redis key) |
- Redis key 表示哪一把锁;
- field 标识持有锁的客户端和线程;
- value 是该线程的重入计数。
同一线程再次进入相同锁保护的代码时,不需要与自己竞争,只需增加计数。每次退出一层就减少计数,直到计数归零才真正删除锁。
3.2 获取锁流程
获取锁的判断与写入由 Lua 脚本原子执行:
- 锁 key 不存在:写入当前线程标识,计数设为 1,并设置过期时间,获取成功;
- 锁 key 已存在且包含当前线程标识:计数加 1,刷新过期时间,重入成功;
- 锁由其他线程持有:获取失败,返回锁的剩余存活时间。
核心 Lua 逻辑可概括为:
1 | if redis.call('exists', KEYS[1]) == 0 then |
返回 nil 表示首次获取或重入成功;返回剩余 TTL 表示锁正由其他线程持有。
3.3 释放锁流程
释放时同样需要用 Lua 保证“检查归属、修改计数、删除或续期”是一个原子操作:
- 判断锁是否存在以及是否属于当前线程;
- 不属于当前线程时不允许释放;
- 属于当前线程时将重入计数减 1;
- 计数仍大于 0,说明只退出了内层调用,刷新过期时间;
- 计数归零,才删除 Redis key,并通知等待该锁的线程。
因此,获取多少次就需要释放多少次。内层调用提前删除 key 会破坏外层业务的互斥性,重入计数正是为了避免这个问题。
四、锁重试与 WatchDog
4.1 有限等待与重试
带 waitTime 的 tryLock 并不是无休止地轮询 Redis,其主要流程是:
- 执行 Lua 脚本尝试获取锁;
- 返回
null时获取成功;否则得到当前锁的剩余 TTL; - 扣除本次操作耗时,若剩余等待时间小于等于 0,则返回失败;
- 订阅锁释放通知,并在剩余时间内等待;
- 收到通知或等待一段时间后再次尝试获取;
- 获取成功则返回,等待时间耗尽则失败。
这种“通知 + 再尝试”的方式减少了纯忙等造成的无效请求。tryLock() 无参调用则是立即尝试,不提供最大等待时间。
4.2 WatchDog 自动续期
固定过期时间无法预知业务究竟执行多久。租约太短会在业务结束前丢锁,租约太长又会使客户端故障后的锁长时间不释放。WatchDog 用自动续期协调这两个问题:
1 | 未显式指定 leaseTime 并成功获取锁 |
在本次学习对应的默认配置中,WatchDog 大约每 10 秒把锁续回 30 秒。续期仍会校验锁的线程标识,避免给别人的锁续期。
如果持锁客户端崩溃,续期任务也会停止,Redis 中的过期时间最终会清除锁,避免永久死锁。
注意:WatchDog 的“未设置过期时间”是指调用加锁方法时没有显式传入
leaseTime,不是 Redis key 没有 TTL。Redisson 仍会给锁设置内部租约并周期续期。
4.3 本次流程图

图中的关键判断是:首次 Lua 加锁成功后,如果未显式指定固定租约,才进入 WatchDog 续期;释放成功后停止续期任务。
五、MultiLock 与主从一致性风险
5.1 单个主从集群为什么可能丢锁
Redis 主从复制通常是异步的,可能出现以下时序:
1 | 线程 A 在主节点写入锁 |
这里的结果是锁记录丢失、其他线程可能同时进入临界区,而不是锁无法释放造成的死锁。
5.2 MultiLock 的组合方式
MultiLock 将多个独立 Redis 节点上的 RLock 组合为一把联锁。只有所有子锁都获取成功,整体才算成功;如果某个子锁获取失败或等待超时,需要释放本次已经获取的子锁。
最小用法如下:
1 | RLock lock1 = redissonClient1.getLock("lock:order:" + userId); |
这是原理示例。当前项目虽然创建了两个 RedissonClient,但业务代码仍只调用单个客户端的 getLock,尚未完成上述 MultiLock 组合。
5.3 代价与适用边界
MultiLock 通过“所有节点都成功”避免仅因单个节点丢失锁数据就重新取得完整锁,但可用性会降低:
- 任一节点不可用,都可能导致整把锁获取失败;
- 加锁与释放需要访问多个节点,网络和操作开销增加;
- 它适合更重视锁安全、能够接受节点故障时暂时拒绝业务的场景。
因此它是在一致性与可用性之间做取舍,并不是无代价地解决所有分布式锁问题。
六、今日问题与注意事项
6.1 两个 RedissonClient 的注入歧义
- 现象:
RedissonConfig同时声明redissonClient1、redissonClient2,业务字段只写了@Autowired private RedissonClient redissonClient;。 - 原因:容器中有多个同类型 Bean,单纯按类型无法唯一确定注入目标。
- 解决方向:单锁业务明确使用
@Qualifier("redissonClient1"),或者用@Primary指定默认客户端;MultiLock 业务则分别注入两个客户端并组合各自的RLock。 - 提醒:仅仅配置多个客户端并不会自动获得 MultiLock 语义。
6.2 显式租约与 WatchDog 不要混淆
tryLock(waitTime, leaseTime, unit):按传入的固定leaseTime自动释放,通常不会为这次锁启用 WatchDog 自动续期;- 不显式传入
leaseTime:成功获取后由 WatchDog 按内部租约自动续期; tryLock():立即尝试获取,但成功后仍可使用 WatchDog;代码注释中的“默认时间 30 秒”不等同于 30 秒后无条件释放。
今日总结
| 模块 | 核心要点 |
|---|---|
| Redisson 接入 | 通过 RedissonClient 获取 RLock,在 finally 中释放 |
| 可重入 | Hash 的 field 标识线程,value 记录重入次数,计数归零才删除锁 |
| 原子性 | 获取、重入、释放等复合操作通过 Lua 脚本完成 |
| 锁重试 | 带等待时间的 tryLock 结合释放通知,在剩余时间内再次尝试 |
| WatchDog | 未显式指定固定租约时周期续期,释放或客户端失效后停止 |
| MultiLock | 所有独立节点都加锁成功才算成功,增强安全性但降低可用性 |
| 项目现状 | 已使用单个 RLock;两个客户端尚未组合为 MultiLock,注入目标也需明确 |