前言
在之前的文章谈谈基于Redis分布式锁(上)- 手写方案最后我们分析得出手写一个完美的分布式锁方案并不容易(比如可重入性),而且性能也没法得到保证。而Redisson可以解决上述的所有问题,但是还有些小缺陷,文章最后我们再做讨论
本章节我们将探索一下Redisson的底层现实原理,通过源码来分析一下Redisson的实现细节
Redisson简介
Redisson在基于NIO的Netty框架上,充分的利用了Redis键值数据库提供的一系列优势,在Java实用工具包中常用接口的基础上,为使用者提供了一系列具有分布式特性的常用工具类,支持 Redis 单实例、Redis 哨兵、Redis Cluster、Redis master-slave 等各种部署架构
redisson是目前redis分布式锁相对完美的实现,更多详情可以通过Redisson了解
Redisson简单使用
RLock lock = redisson.getLock("lockName");
try{
//可以设置超时时间
lock.lock();
//业务逻辑
} finally {
lock.unlock();
}
Redisson加解锁过程分析
详细流程图
通过一张图来看一看Redisson内部的锁操作流程,其内部实现主要用到3大技术栈(Lua脚本+Semaphore+异步线程),注:笔者使用的Redisson版本为3.12.1
加锁操作
Redisson使用Lua脚本方式将多个非原子命令封装在一起,一起发送给服务端,保证操作的原子性,加锁操作比较复杂,我们以一个例子开始来逐步分析,假设多个客户端同时竞争key为lockName上的锁资源
客户端1
RLock lock1 = redisson.getLock(“lockName”);
lock1.lock();
System.out.println("客户端1获锁成功!");
lock1.lock();
System.out.println("客户端1重复获锁成功!");
lock1.unlock();
lock1.unlock();
客户端2
RLock lock2 = redisson.getLock(“lockName”);
lock2.lock();
加锁源代码
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
long threadId = Thread.currentThread().getId();
Long ttl = tryAcquire(leaseTime, unit, threadId);
// ttl为空,则说明获锁成功
if (ttl == null) {
return;
}
// 使用redis->subscribe订阅channel,用于监听回调处理
RFuture<RedissonLockEntry> future = subscribe(threadId);
...
// 获锁失败,则使用while循环不断获取锁的剩余过期时间ttl,然后指定Park的时间为ttl,不断循环判断
try {
while (true) {
// 如果锁还存在,则返回的是当前锁的剩余过期时间ttl
ttl = tryAcquire(leaseTime, unit, threadId);
// 如果其它客户端释放了锁,当前客户端竞争锁成功
if (ttl == null) {
break;
}
// 当前客户端线程阻塞,时间为指定的ttl
if (ttl >= 0) {
//使用Semaphore->LockSupport.park()方法
future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
}
}
} finally {
unsubscribe(future, threadId);
}
}
加锁lua脚本
<T> RFuture<T> tryLockInnerAsync(long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
internalLockLeaseTime = unit.toMillis(leaseTime);
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, command,
//锁不存在,进行加锁操作,将锁资源保存在hash中
"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; " +
//锁存在且为同一线程重复加锁,将该线程的锁重入次数加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; " +
//其它客户端已经竞争到锁,返回当前锁的剩余过期时间
"return redis.call('pttl', KEYS[1]);",
Collections.<Object>singletonList(getName()), internalLockLeaseTime, getLockName(threadId));
}
解释一下Lua脚本中的几个参数
KEYS[1]加锁的key的名称,比如RLock lock = redisson.getLock(“lockName”);则KEYS[1]就是lockName
ARGV[1]表示锁的过期时间,如果未设置默认为30秒
ARGV[2]表示加锁的客户端ID,格式为:uuid + “:” + threadid,例如:
11bb52bc-a764-4649-8b46-a61513d7fe44:1
获锁过程
分支一:锁不存在
使用"exists lockName"命令判断锁是否存在,不存在则使用"hset KEYS[1] ARGV[2] 1"命令进行加锁操作,假设客户端1加锁成功,使用hash数据结构存储客户端1的锁资源,结构为:
"lockName":{
//客户端ID:重入次数
"11bb52bc-a764-4649-8b46-a61513d7fe44:1":1
}
接着使用"pexpire KEYS[1] ARGV[1]",即"pexpire lockName 30000"设置lockName锁的过期时间,然后返回null表示加锁成功!
分支二:锁存在且为同一客户端重复加锁
客户端在同一线程操作中是可以重复获得锁的,使用命令"hincrby KEYS[1] ARGV[2] 1"将同一客户端的可重入次数加1,并重新设置过期时间,返回null表示加锁成功!
客户端1重复加锁成功,此时hash结构如下:
"lockName":{
//客户端ID:重入次数加了1
"11bb52bc-a764-4649-8b46-a61513d7fe44:1":2
}
分支三:客户端锁竞争
在客户端获锁失败后,当前客户端会订阅(subscribe)名称为"redisson_lock__channel: {lockName}"的channel,用于监听回调处理,客户端释放锁时会在redisson_lock__channel:{lockName}的channel上发布(publish)UNLOCK_MESSAGE的解锁消息
如果此时另一个客户端2也尝试在lockName上加锁,exists判断lockName已存在且hash中lockName键已经存在客户端1的锁"11bb52bc-a764-4649-8b46-a61513d7fe44:1",所以客户端2不能加锁了,怎么办?
客户端线程会使用"pttl KEYS[1]"命令返回当前锁的剩余过期时间ttl,然后使用J.U.C框架中的Semaphore根据返回的ttl时间调用LockSupport.parkNanos(ttl)来阻塞自己,在指定的等待时间结束后,则继续尝试加锁,不断循环,直到成功为止
RedissonLock类中的lock()方法代码片段如下:
while (true) {
//尝试加锁
ttl = tryAcquire(leaseTime, unit, threadId);
//获取成功
if (ttl == null) {
break;
}
if (ttl >= 0) {
// 调用Semaphore的tryAcquire()方法
future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
}
Semaphore类中的tryACquire代码片段如下:
//nanosTimeout时间为客户端需要等待的时间ttl
LockSupport.parkNanos(this, nanosTimeout);
场景举例:A正在上厕所,B发现A已经在厕所了,于是B就问A什么时候可以出来,A说等10分钟就好了,这时B就使用手表开始计时等待(Semaphore),10分钟后发现A还在里面,于是B继续问A还要多久,A不好意思说再等我5分钟吧,B又开始计时等待。。。
WatchDog延期机制
为什么要使用WatchDog?
Redisson提供的获锁api中有一个leaseTime选项,该值为-1时表明获锁成功的客户端可以一直持有该锁,释放锁之前,其他客户端线程将一直等待下去。我们知道当在Redis中设置一个key时,往往需要指定expireTime,防止其长期占用内存空间。在种场景下,锁最终还是会过期,所以在key过期之前,必须提供一种机制(WatchDog)来保证key继续有效
Redisson分布式锁中WatchDog实现机制
客户端加锁(lock)成功后,会启用一个watch dog后台线程,使用netty时间轮HashedWheelTimer算法,每隔delay=10秒检查如果客户端还持有锁,则重新设置锁的过期时间为lockWatchdogTimeout=30秒(默认),其中delay = lockWatchdogTimeout/3
private <T> RFuture<Long> tryAcquireAsync(long leaseTime, TimeUnit unit, long threadId) {
//当leaseTime为-1时启用watchdog
if (leaseTime != -1) {
return tryLockInnerAsync(leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
}
RFuture<Long> ttlRemainingFuture = tryLockInnerAsync(commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout(), TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
// 客户端获锁成功,延期操作
if (ttlRemaining == null) {
scheduleExpirationRenewal(threadId);
}
});
return ttlRemainingFuture;
}
对于同一客户端重复获锁且成功时,Redisson是怎么保证WatchDog的延期操作只执行一次?答案是:本地缓存
private void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
//第2次以后再获取锁,不用再使用时间轮算法延期了
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {
//第1次获取成功时,进行延期操作
entry.addThreadId(threadId);
renewExpiration();
}
}
RedissonLock类中的renewExpiration()方法代码片段如下:
//延期操作
private void renewExpiration() {
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
//Lua脚本延期锁的过期时间
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
//延期成功
if (res) {
// 继续循环延期操作
renewExpiration();
}
});
}
//每隔10秒检查一次
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
}
调用renewExpirationAsync()方法设置锁的过期时间,Lua脚本如下:
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.<Object>singletonList(getName()),
internalLockLeaseTime, getLockName(threadId));
}
哪些获锁方法会使用WatchDog?
大家应该注意,不是所有的获锁成功操作都会开启WatchDog功能,还需要leaseTime为-1的条件成立时,才会启用WatchDog。
下面将罗列出Redisson提供的部分获锁操作:
定义RLock rlock = client.getLock(“lockName”);
获锁方法 | 是否开启WatchDog |
---|---|
rlock.lock() | 启用 |
rlock.tryLock() | 启用 |
tryLock(long waitTime, TimeUnit unit) | 启用 |
tryLock(long waitTime, long leaseTime != -1, TImeUnit unit) | 关闭 |
… | … |
注:leaseTime为-1则才会开启watchDao功能
释放锁操作
释放锁的操作相对简单,也比较容易理解,大概就四步:
删除key -> 设置过期时间 ->删除本地缓存 -> 发布解锁消息
解锁操作lua脚本
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return commandExecutor.evalWriteAsync(getName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
//不存在就直接返回null
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
//将当前客户端的锁重入次数-1
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
//如果当前客户端还持了锁,则重新设置过期时间
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
//客户端已经释放了锁,删除key,发布解锁消息
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; "+
"end; " +
"return nil;",
Arrays.<Object>asList(getName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
删除本地缓存代码
void cancelExpirationRenewal(Long threadId) {
ExpirationEntry task = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (task == null) {
return;
}
if (threadId != null) {
task.removeThreadId(threadId);
}
if (threadId == null || task.hasNoThreads()) {
Timeout timeout = task.getTimeout();
if (timeout != null) {
timeout.cancel();
}
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
}
}
其中删除本地缓存map是在异步线程中执行的,WatchDog对客户端的锁进行缓期操作后,将该客户端线程信息保存在本地缓存map中,保证同一客户端重复获锁成功时,锁延期操作只执行一次
总结
至此,Redisson加解锁的详细过程分析完毕!回到开篇,我们说Redisson还有些小缺陷,比如在Mast-Slave架构下,主从同步通常是异步的
在这种场景(主从结构)中存在明显的竞态:
1、客户端A从master获取到锁
2、在master将锁同步到slave之前,master宕掉了
3、slave节点被晋级为master节点
4、客户端B取得了同一个资源被客户端A已经获取到的另外一个,锁安全失效!
官方给出的解决方案是使用Redlock算法,如果读者想进一步了解更多关于Redlock的内容,请参考官网Redis之RedLock算法