day39 Redisson可重入锁,锁重试,WatchDog与MultiLock

day39 Redisson可重入锁、WatchDog与MultiLock

一、为什么使用 Redisson

上一阶段实现的 SimpleRedisLock 已经能借助线程标识和 Lua 脚本避免误删,但仍有四个不足:

问题 影响 Redisson 的对应能力
不可重入 同一线程再次获取同一把锁会失败,嵌套调用可能无法继续 使用 Hash 记录持有线程与重入次数
不可重试 获取失败后立即返回,无法在限定时间内等待 tryLock 支持等待并重试
固定超时释放 业务未执行完,锁可能先过期 WatchDog 自动续期
主从一致性 锁尚未同步时主节点故障,故障转移后可能丢锁 MultiLock 同时锁定多个独立节点

Redisson 是基于 Redis 的 Java 客户端,提供 RLock 等分布式对象。这里使用它的目的不是替换 Redis,而是复用其已经封装好的分布式锁语义与复杂并发处理。

二、项目接入与基本使用

2.1 引入依赖

项目的 pom.xml 已引入 redisson:

1
2
3
4
5
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>4.7.0</version>
</dependency>

2.2 创建 RedissonClient

RedissonConfig 使用单节点模式连接本机 Redis。项目目前分别为 6379 和 6380 创建了两个客户端:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Configuration
public class RedissonConfig {

@Bean
public RedissonClient redissonClient1() {
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6379");
return Redisson.create(config);
}

@Bean
public RedissonClient redissonClient2() {
Config config = new Config();
config.useSingleServer().setAddress("redis://localhost:6380");
return Redisson.create(config);
}
}

redis:// 表示非 TLS 连接;如果 Redis 配有密码,还需要调用 setPassword。

2.3 在一人一单业务中使用 RLock

项目使用用户 ID 组成锁名,使同一用户的下单请求互斥,不同用户仍可并行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Long userId = UserHolder.getUser().getId();
RLock lock = redissonClient.getLock("lock:order:" + userId);
boolean success = lock.tryLock();
if (!success) {
return Result.fail("不允许重复下单!");
}

try {
IVoucherOrderService service =
(IVoucherOrderService) AopContext.currentProxy();
return service.createVoucherOrder(voucherId);
} finally {
lock.unlock();
}

基本调用顺序是:

1
2
3
4
5
6
7
RedissonClient.getLock(锁名称)
↓
RLock.tryLock() 尝试获取锁
↓
获取成功后执行业务
↓
finally 中 unlock() 释放锁

释放锁必须放在 finally 中,避免业务异常导致正常释放逻辑被跳过。只有成功获取锁后才应执行 unlock();项目当前的控制流符合这一点。

tryLock 的常用形式:

1
boolean acquired = lock.tryLock(1, 10, TimeUnit.SECONDS);
  • waitTime:最多等待多久,在此期间 Redisson 可以重试;
  • leaseTime:获取成功后锁的固定租约时间;
  • TimeUnit:时间单位。

如果显式传入 leaseTime,锁按固定租约到期;需要自动续期时,不应显式指定固定租约时间。

三、可重入锁原理

3.1 Hash 存储结构

Redisson 的可重入锁使用 Redis Hash 保存锁状态:

1
2
锁名称(Redis key)
└── 客户端标识:线程ID(field) → 重入次数(value)
  • Redis key 表示哪一把锁;
  • field 标识持有锁的客户端和线程;
  • value 是该线程的重入计数。

同一线程再次进入相同锁保护的代码时,不需要与自己竞争,只需增加计数。每次退出一层就减少计数,直到计数归零才真正删除锁。

3.2 获取锁流程

获取锁的判断与写入由 Lua 脚本原子执行:

  1. 锁 key 不存在:写入当前线程标识,计数设为 1,并设置过期时间,获取成功;
  2. 锁 key 已存在且包含当前线程标识:计数加 1,刷新过期时间,重入成功;
  3. 锁由其他线程持有:获取失败,返回锁的剩余存活时间。

核心 Lua 逻辑可概括为:

1
2
3
4
5
6
7
8
9
10
11
12
13
if redis.call('exists', KEYS[1]) == 0 then
redis.call('hset', KEYS[1], ARGV[2], 1)
redis.call('pexpire', KEYS[1], ARGV[1])
return nil
end

if redis.call('hexists', KEYS[1], ARGV[2]) == 1 then
redis.call('hincrby', KEYS[1], ARGV[2], 1)
redis.call('pexpire', KEYS[1], ARGV[1])
return nil
end

return redis.call('pttl', KEYS[1])

返回 nil 表示首次获取或重入成功;返回剩余 TTL 表示锁正由其他线程持有。

3.3 释放锁流程

释放时同样需要用 Lua 保证“检查归属、修改计数、删除或续期”是一个原子操作:

  1. 判断锁是否存在以及是否属于当前线程;
  2. 不属于当前线程时不允许释放;
  3. 属于当前线程时将重入计数减 1;
  4. 计数仍大于 0,说明只退出了内层调用,刷新过期时间;
  5. 计数归零,才删除 Redis key,并通知等待该锁的线程。

因此,获取多少次就需要释放多少次。内层调用提前删除 key 会破坏外层业务的互斥性,重入计数正是为了避免这个问题。

四、锁重试与 WatchDog

4.1 有限等待与重试

带 waitTime 的 tryLock 并不是无休止地轮询 Redis,其主要流程是:

  1. 执行 Lua 脚本尝试获取锁;
  2. 返回 null 时获取成功;否则得到当前锁的剩余 TTL;
  3. 扣除本次操作耗时,若剩余等待时间小于等于 0,则返回失败;
  4. 订阅锁释放通知,并在剩余时间内等待;
  5. 收到通知或等待一段时间后再次尝试获取;
  6. 获取成功则返回,等待时间耗尽则失败。

这种“通知 + 再尝试”的方式减少了纯忙等造成的无效请求。tryLock() 无参调用则是立即尝试,不提供最大等待时间。

4.2 WatchDog 自动续期

固定过期时间无法预知业务究竟执行多久。租约太短会在业务结束前丢锁,租约太长又会使客户端故障后的锁长时间不释放。WatchDog 用自动续期协调这两个问题:

1
2
3
4
5
6
7
未显式指定 leaseTime 并成功获取锁
↓
按内部默认租约设置过期时间(常见默认值为 30 秒)
↓
客户端仍持有锁时,定时检查并续期
↓
业务完成 unlock(),取消续期并释放锁

在本次学习对应的默认配置中,WatchDog 大约每 10 秒把锁续回 30 秒。续期仍会校验锁的线程标识,避免给别人的锁续期。

如果持锁客户端崩溃,续期任务也会停止,Redis 中的过期时间最终会清除锁,避免永久死锁。

注意:WatchDog 的“未设置过期时间”是指调用加锁方法时没有显式传入 leaseTime,不是 Redis key 没有 TTL。Redisson 仍会给锁设置内部租约并周期续期。

4.3 本次流程图

图中的关键判断是:首次 Lua 加锁成功后,如果未显式指定固定租约,才进入 WatchDog 续期;释放成功后停止续期任务。

五、MultiLock 与主从一致性风险

5.1 单个主从集群为什么可能丢锁

Redis 主从复制通常是异步的,可能出现以下时序:

1
2
3
4
5
6
7
8
9
线程 A 在主节点写入锁
↓
锁 key 尚未复制到从节点
↓
主节点宕机,从节点晋升为主节点
↓
新主节点上没有该锁
↓
线程 B 再次获取同名锁,互斥失效

这里的结果是锁记录丢失、其他线程可能同时进入临界区,而不是锁无法释放造成的死锁。

5.2 MultiLock 的组合方式

MultiLock 将多个独立 Redis 节点上的 RLock 组合为一把联锁。只有所有子锁都获取成功,整体才算成功;如果某个子锁获取失败或等待超时,需要释放本次已经获取的子锁。

最小用法如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
RLock lock1 = redissonClient1.getLock("lock:order:" + userId);
RLock lock2 = redissonClient2.getLock("lock:order:" + userId);
RLock multiLock = redissonClient1.getMultiLock(lock1, lock2);

boolean acquired = multiLock.tryLock();
if (!acquired) {
return Result.fail("不允许重复下单!");
}
try {
// 执行业务
} finally {
multiLock.unlock();
}

这是原理示例。当前项目虽然创建了两个 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,注入目标也需明确