Redis Redisson 分布式锁:主节点宕机与锁丢失,红锁算法及Watchdog源码级改造
大家好,今天我们来深入探讨 Redis Redisson 分布式锁,以及它在主节点宕机情况下的锁丢失问题。我们将重点分析 Redisson 锁的原理,探讨 RedLock (红锁) 算法,并针对 Redisson 的 Watchdog 自动续期机制进行源码级的改造,以提升分布式锁的可靠性。
Redisson 分布式锁原理与缺陷
Redisson 提供了基于 Redis 的分布式锁实现,它利用了 Redis 的原子操作和过期机制来实现锁的互斥。其核心原理如下:
- 加锁: 使用
SETNX命令(如果 key 不存在则设置 key 的值)尝试在 Redis 中设置一个特定的 key,这个 key 代表锁。如果设置成功,表示获取锁。 - 设置过期时间: 为了防止死锁,Redisson 会为这个 key 设置一个过期时间(
expire)。即使持有锁的客户端崩溃,锁也会在一定时间后自动释放。 - 释放锁: 使用
DEL命令删除 key 来释放锁。
Redisson 锁的核心代码片段:
public RFuture<Boolean> tryLockAsync(long leaseTime, TimeUnit unit) {
return tryLockAsync(leaseTime, unit, false);
}
private <T> RFuture<T> tryLockAsync(long leaseTime, TimeUnit unit, boolean interruptibly) {
// ...
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hset', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"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 1; " +
"end; " +
"return 0;",
Collections.singletonList(getName()), unit.toMillis(leaseTime), id);
// ...
}
这里使用 Lua 脚本保证了原子性:检查 key 是否存在,如果不存在则设置 key 的值和过期时间。
缺陷:主节点宕机导致锁丢失
在 Redis 的主从复制架构中,如果 Redisson 锁的 key 被设置在主节点上,而主节点在将这个 key 同步到从节点之前宕机,那么当从节点被提升为新的主节点后,这个锁 key 就会丢失。这意味着不同的客户端可以同时获取到锁,导致并发问题。 这就是典型的 脑裂 问题。
考虑以下时序:
- 客户端 A 在主节点成功获取锁。
- 主节点还未将锁信息同步到从节点。
- 主节点宕机。
- 从节点晋升为主节点,但没有客户端 A 的锁信息。
- 客户端 B 尝试获取锁,在新的主节点上成功获取。
- 客户端 A 和客户端 B 同时持有锁,导致并发问题。
RedLock (红锁) 算法
为了解决主节点宕机带来的锁丢失问题,Redis 的作者 antirez 提出了 RedLock 算法。RedLock 算法的核心思想是:在多个独立的 Redis 实例上尝试获取锁,只有当超过半数的实例成功获取锁,才认为获取锁成功。
RedLock 算法步骤如下:
- 获取当前时间戳。
- 尝试在 N 个独立的 Redis 实例上获取锁。 每个实例使用相同的 key 和 value(通常是客户端 ID),并设置一个相对较短的过期时间。
- 计算获取锁的总耗时。
- 只有当超过半数 (N/2 + 1) 的 Redis 实例成功获取锁,并且获取锁的总耗时小于锁的有效时间,才认为获取锁成功。
- 如果获取锁成功,则将锁的有效时间设置为剩余的有效时间(锁的原始有效时间 – 获取锁的总耗时)。 这将确保锁在所有实例上都有一个合理的有效时间。
- 如果获取锁失败,则释放所有 Redis 实例上的锁。
RedLock 的优点:
- 更高的容错性: 即使部分 Redis 实例宕机,只要超过半数的实例可用,锁仍然可以正常工作。
- 避免脑裂: 由于锁存在于多个实例上,即使发生脑裂,也只有一部分实例会丢失锁,而其他实例仍然持有锁,从而降低并发冲突的风险。
RedLock 的缺点:
- 复杂度较高: 需要维护多个 Redis 实例,并且需要处理获取锁和释放锁的复杂逻辑。
- 性能较低: 需要在多个 Redis 实例上进行操作,增加了延迟。
- 理论上存在风险: 虽然 RedLock 算法在很大程度上提高了分布式锁的可靠性,但仍然存在理论上的风险,例如时钟漂移等。
Redisson 也提供了 RedLock 的实现:
RLock lock1 = redisson.getLock("lock1");
RLock lock2 = redisson.getLock("lock2");
RLock lock3 = redisson.getLock("lock3");
RedissonRedLock lock = new RedissonRedLock(lock1, lock2, lock3);
try {
// 尝试加锁,最多等待100毫秒,leaseTime 10秒自动解锁
boolean res = lock.tryLock(100, 10, TimeUnit.SECONDS);
if (res) {
try {
//TODO do something
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
//TODO
}
Redisson Watchdog 自动续期机制
Redisson 默认情况下会开启 Watchdog 自动续期机制。 当一个客户端成功获取锁后,Redisson 会启动一个 Watchdog 线程,定期(默认是锁过期时间的 1/3)检查客户端是否仍然持有锁。如果客户端仍然持有锁,Watchdog 线程会延长锁的过期时间。
Watchdog 机制可以防止客户端在持有锁的过程中,由于网络延迟或 GC 等原因导致锁过期,从而避免锁的意外释放。
Redisson Watchdog 自动续期机制的核心代码:
private void scheduleExpirationRenewal() {
if (expirationRenewalSupport) {
renewExpiration();
this.expirationRenewalMap.put(Thread.currentThread(),
TimeoutScheduler.newTimeout(timeout -> {
expirationRenewalMap.remove(Thread.currentThread());
scheduleExpirationRenewal();
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS));
}
}
private void renewExpiration() {
internalLockLeaseTime = commandExecutor.evalWrite(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_LONG,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.singletonList(getName()),
internalLockLeaseTime, getLockName(id));
}
scheduleExpirationRenewal 方法会定时执行 renewExpiration 方法, renewExpiration 方法使用 Lua 脚本原子性地检查客户端是否仍然持有锁,如果持有则延长锁的过期时间。
Watchdog 机制在主节点宕机场景下的问题
虽然 Watchdog 机制可以防止锁的意外释放,但在主节点宕机场景下,它也存在问题:
- 续期操作丢失: 如果 Watchdog 线程在主节点宕机之前执行了续期操作,但续期操作还未同步到从节点,那么当从节点被提升为新的主节点后,这个续期操作就会丢失。
- 导致锁提前释放: 因为主节点宕机后,之前设定的过期时间仍然有效,而续期操作丢失,所以锁会在原始过期时间到达后被自动释放。
Watchdog 源码级改造:增强主节点宕机后的锁可靠性
为了解决 Watchdog 机制在主节点宕机场景下的问题,我们可以对 Redisson 的 Watchdog 机制进行源码级的改造,主要思路是:
- 在续期操作中携带续期时间戳: 在每次续期操作时,除了延长锁的过期时间,还需要将当前的续期时间戳写入到 Redis 中。
- 在从节点晋升为主节点后,检查锁的续期时间戳: 当从节点晋升为主节点后,需要检查锁的续期时间戳。如果续期时间戳距离当前时间超过一个阈值(例如,锁的原始过期时间),则认为续期操作丢失,需要重新设置锁的过期时间。
改造步骤:
- 修改
renewExpiration方法:
private void renewExpiration() {
long currentTime = System.currentTimeMillis();
internalLockLeaseTime = commandExecutor.evalWrite(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_LONG,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"redis.call('hset', KEYS[1], 'renewalTimestamp', ARGV[3]); " + // 添加续期时间戳
"return 1; " +
"end; " +
"return 0;",
Collections.singletonList(getName()),
internalLockLeaseTime, getLockName(id), currentTime);
}
这里,我们在 Lua 脚本中添加了 redis.call('hset', KEYS[1], 'renewalTimestamp', ARGV[3]); ,用于将当前的续期时间戳写入到 Redis 中。
- 添加从节点晋升为主节点后的检查逻辑:
这一步需要在 Redis Sentinel 或者 Redis Cluster 的客户端中进行处理。 当检测到从节点晋升为主节点后,需要遍历所有的 Redisson 锁,并执行以下检查:
public void checkRenewalTimestamp(String lockName, long leaseTime) {
Long renewalTimestamp = (Long) redisTemplate.opsForHash().get(lockName, "renewalTimestamp");
if (renewalTimestamp != null) {
long currentTime = System.currentTimeMillis();
if (currentTime - renewalTimestamp > leaseTime) {
// 续期操作丢失,重新设置锁的过期时间
redisTemplate.expire(lockName, leaseTime, TimeUnit.MILLISECONDS);
log.warn("Lock renewal timestamp expired, resetting lock expiration: {}", lockName);
}
}
}
这段代码首先从 Redis 中获取锁的续期时间戳,然后计算当前时间与续期时间戳的差值。如果差值大于锁的原始过期时间,则认为续期操作丢失,需要重新设置锁的过期时间。
代码解释:
redisTemplate: 这里假设你使用的是 Spring Data Redis,可以使用RedisTemplate对象来操作 Redis。lockName: 锁的名称。leaseTime: 锁的原始过期时间。redisTemplate.opsForHash().get(lockName, "renewalTimestamp"): 从 Redis 中获取锁的续期时间戳。redisTemplate.expire(lockName, leaseTime, TimeUnit.MILLISECONDS): 重新设置锁的过期时间。log.warn(...): 记录警告日志。
注意事项:
- 时钟同步: 确保 Redis 实例之间的时钟保持同步,以避免时钟漂移导致的问题。可以使用 NTP 等协议进行时钟同步。
- 阈值设置:
currentTime - renewalTimestamp > leaseTime中的leaseTime可以根据实际情况进行调整。 可以考虑将这个阈值设置为略大于锁的原始过期时间,以避免由于网络延迟等原因导致的误判。 - 性能影响: 从节点晋升为主节点后的检查逻辑可能会对性能产生一定的影响,需要根据实际情况进行评估和优化。
Redisson 锁选择建议
在实际应用中,如何选择合适的 Redisson 锁?
| 锁类型 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 单 Redis 锁 | 简单易用,性能高。 | 存在主节点宕机导致锁丢失的风险。 | 对锁的可靠性要求不高,且 Redis 主从切换概率较低的场景。 |
| RedLock (红锁) | 提高了容错性,可以避免主节点宕机导致的锁丢失。 | 复杂度较高,性能较低,理论上仍然存在风险。 | 对锁的可靠性要求较高,且可以接受一定的性能损失的场景。 |
| 改造后的 Watchdog | 在单 Redis 锁的基础上,通过续期时间戳检查,增强了主节点宕机后的锁可靠性。 | 需要进行源码级改造,复杂度略有提高。 | 对锁的可靠性有一定要求,但又不想引入 RedLock 的复杂性和性能损失的场景。 |
总结:
- 如果对锁的可靠性要求不高,可以使用单 Redis 锁。
- 如果对锁的可靠性要求很高,可以使用 RedLock。
- 如果对锁的可靠性有一定要求,但又不想引入 RedLock 的复杂性和性能损失,可以考虑对 Redisson 的 Watchdog 机制进行源码级的改造。
总结: 提升Redis锁可靠性
Redisson 分布式锁在 Redis 主节点宕机时可能存在锁丢失问题。RedLock 算法通过在多个 Redis 实例上加锁来提高可靠性,但复杂度较高。通过改造 Redisson 的 Watchdog 机制,在续期操作中携带续期时间戳,并在从节点晋升为主节点后进行检查,可以增强主节点宕机后的锁可靠性。 根据实际场景选择合适的锁类型,并在必要时进行源码级改造,是保证分布式锁可靠性的关键。