1. 引言
此前的文章中,我们详细介绍了基于 redis 的分布式事务锁的实现:
厉害了,原来分布式锁有这么多坑
我们看到,分布式锁是如何一步步改进方案最终实现其高可用的。
但就“高可用”来说,似乎仍然有所欠缺,那就是如果他所依赖的 redis 是单点的,如果发生故障,则整个业务的分布式锁都将无法使用,即便是我们将单点的 redis 升级为 redis 主从模式或集群,对于固定的 key 来说,master 节点仍然是独立存在的,由于存在着主从同步的时间间隔,如果在这期间 master 节点发生故障,slaver 节点被选举为 master 节点,那么,master 节点上存储的分布式锁信息可能就会丢失,从而造成竞争条件。
那么,如何避免这种情况呢?
redis 官方给出了基于多个 redis 集群部署的高可用分布式锁解决方案 — RedLock,本文我们就来详细介绍一下。
2. RedLock 的加解锁过程
基于上述理论,我们知道,RedLock 是在多个 Redis 集群上部署的一种分布式锁的实现方式,他有效避免了单点问题。
假设我们有 N 个 Redis 服务或集群,RedLock 的加锁过程就如下所示:
- client 获取当前毫秒级时间戳,并设置超时时间 TTL
- 依次向 N 个 Redis 服务发出请求,用能够保证全局唯一的 value 申请锁 key
- 如果从 N/2 1 个 redis 服务中都获取锁成功,那么,本次分布式锁的获取被视为成功,否则视为获取锁失败。
- 如果获取锁失败,或执行达到 TTL,则向所有 Redis 服务都发出解锁请求。
3. Java 实现 — redisson
java 语言中,redisson 包实现了对 redlock 的封装,主要是通过 redis client 与 lua 脚本实现的,之所以使用 lua 脚本,是为了实现加解锁校验与执行的事务性。
此前主页君对 redis 结合 lua 实现严格的事务有过一篇文章来介绍,可以参考:
Redis 事务与 Redis Lua 脚本的编写
3.1 唯一 ID 的生成
分布式事务锁中,为了能够让作为中心节点的存储节点获悉锁的持有者,从而避免锁被非持有者误解锁,每个发起请求的 client 节点都必须具有全局唯一的 id。
通常我们是使用 UUID 来作为这个唯一 id,redisson 也是这样实现的,在此基础上,redisson 还加入了 threadid 避免了多个线程反复获取 UUID 的性能损耗:
代码语言:javascript复制protected final UUID id = UUID.randomUUID();
String getLockName(long threadId) {
return id ":" threadId;
}
3.2 加锁逻辑
redisson 加锁的核心代码非常容易理解,通过传入 TTL 与唯一 id,实现一段时间的加锁请求。
下面是可重入锁的实现逻辑:
代码语言:javascript复制<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
// 获取锁时向5个redis实例发送的命令
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
// 校验分布式锁的KEY是否已存在,如果不存在,那么执行hset命令(hset REDLOCK_KEY uuid threadId 1),并通过pexpire设置失效时间(也是锁的租约时间)
"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; "
// 如果分布式锁的KEY已存在,则校验唯一 id,如果唯一 id 匹配,表示是当前线程持有的锁,那么重入次数加1,并且设置失效时间
"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; "
// 获取分布式锁的KEY的失效时间毫秒数
"return redis.call('pttl', KEYS[1]);",
// KEYS[1] 对应分布式锁的 key;ARGV[1] 对应 TTL;ARGV[2] 对应唯一 id
Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
3.3 释放锁逻辑
代码语言:javascript复制protected RFuture<Boolean> unlockInnerAsync(long threadId) {
// 向5个redis实例都执行如下命令
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
// 如果分布式锁 KEY 不存在,那么向 channel 发布一条消息
"if (redis.call('exists', KEYS[1]) == 0) then "
"redis.call('publish', KEYS[2], ARGV[1]); "
"return 1; "
"end;"
// 如果分布式锁存在,但是唯一 id 不匹配,表示锁已经被占用
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then "
"return nil;"
"end; "
// 如果就是当前线程占有分布式锁,那么将重入次数减 1
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); "
// 重入次数减1后的值如果大于0,表示分布式锁有重入过,那么只设置失效时间,不删除
"if (counter > 0) then "
"redis.call('pexpire', KEYS[1], ARGV[2]); "
"return 0; "
"else "
// 重入次数减1后的值如果为0,则删除锁,并发布解锁消息
"redis.call('del', KEYS[1]); "
"redis.call('publish', KEYS[2], ARGV[1]); "
"return 1; "
"end; "
"return nil;",
// KEYS[1] 表示锁的 key,KEYS[2] 表示 channel name,ARGV[1] 表示解锁消息,ARGV[2] 表示 TTL,ARGV[3] 表示唯一 id
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.unlockMessage, internalLockLeaseTime, getLockName(threadId));
}
4. redisson RedLock 的使用
redisson 实现的分布式锁的使用非常简单:
代码语言:javascript复制Config config = new Config();
config.useSentinelServers().addSentinelAddress("127.0.0.1:6369","127.0.0.1:6379", "127.0.0.1:6389")
.setMasterName("masterName")
.setPassword("password").setDatabase(0);
RedissonClient redissonClient = Redisson.create(config);
RLock redLock = redissonClient.getLock("REDLOCK_KEY");
boolean isLock;
try {
isLock = redLock.tryLock(500, 10000, TimeUnit.MILLISECONDS);
if (isLock) {
//TODO if get lock success, do something;
}
} catch (Exception e) {
} finally {
redLock.unlock();
}
可以看到,由于 redisson 包的实现中,通过 lua 脚本校验了解锁时的 client 身份,所以我们无需再在 finally 中去判断是否加锁成功,也无需做额外的身份校验,可以说已经达到开箱即用的程度了。
5. redisson 的高级功能
5.1 异常情况的处理
分布式事务锁最常见的一个问题就是如果已经获取到锁的 client 在 TTL 时间内没有完成竞争资源的处理,而此时锁会被自动释放,造成竞争条件的发生。
这种情况如果让 client 端设置定时任务自动延长锁的占用时间,会造成 client 端逻辑的复杂和冗余。
redisson 在实现的过程中,自然也考虑到了这一问题,redisson 提供了一个“看门狗”的可选特性,并且增加了 lockWatchdogTimeout 配置参数,看门狗线程会自动在 lockWatchdogTimeout 超时后顺延锁的占用时间,从而避免上述问题的发生。
但是,由于看门狗作为独立线程存在,对于性能有所影响,如果并非是处理高度竞争且处理时长不固定的特殊资源,那么并不建议启用 redisson 的看门狗特性。
5.2 多个锁联合使用 — 联锁
既然 redisson 通过多个 redis 节点实现了 RedLock,那么,如果一个业务同时需要占用若干资源,是否可以将多个锁联合使用呢?答案也是可以的。
基于 Redis 的分布式 RedissonMultiLock 对象将多个 RLock 对象分组,并将它们作为一个锁处理。每个 RLock 对象可能属于不同的 Redisson 实例。
在这种复杂场景下,上述“看门狗”特性建议一定要启用,因为任何一个锁状态的崩溃都有可能会造成其他所有锁的持续挂起或资源被意外抢占。
下面是 redisson 联锁的一个示例:
代码语言:javascript复制public void multiLock(Integer expireTime,TimeUnit timeUnit,String ...lockKey){
RLock [] rLocks = new RLock[lockKey.length];
for(int i = 0,length = lockKey.length; i < length ;i ){
RLock lock = redissonClient.getLock(lockKey[i]);
rLocks[i] = lock;
}
RedissonMultiLock multiLock = new RedissonMultiLock(rLocks);
multiLock.lock(expireTime,timeUnit);
logger.info("【Redisson lock】success to acquire multiLock for [ " lockKey " ],expire time:" expireTime timeUnit);
}