【本篇文章基于redisson-3.17.6版本源码进行分析】
Redisson的GitHub地址:
https://github.com/redisson/redisson/
我们使用git clone将源码克隆下来,编译一下,就可以阅读源码了。
编写一个单元测试,测试可重入锁的加锁逻辑:
@Test
public void testRenenLock() {
Config config = new Config();
config.useSingleServer().setAddress("redis://127.0.0.1:6379");
RedissonClient redissonClient = Redisson.create(config);
RLock rLock = redissonClient.getLock("lock");
rLock.lock();
// ......
rLock.unlock();
}
这里我们使用没有指定超时时间的lock()加锁方法,同时RLock还提供了可以指定超时时间的加锁方法:
// 锁将在指定的leaseTime时间间隔后自动释放
void lock(long leaseTime, TimeUnit unit);
下面我们进入RedissonLock#lock()方法:
@Override
public void lock() {
try {
// 默认加锁,什么参数也没有传递。但是这里会设置 leaseTime = -1。leaseTime的含义是加锁的时间
lock(-1, null, false);
} catch (InterruptedException e) {
throw new IllegalStateException();
}
}
我们看到,如果没有指定超时时间,默认将会把leaseTime的值设置为-1:
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
// 当前线程ID
long threadId = Thread.currentThread().getId();
// tryAcquire()尝试获取锁
Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
// lock acquired
// ttl为空表示获取锁成功,则直接返回
if (ttl == null) {
return;
}
// 未能获取到锁的其它线程
// 争抢同一把锁的其它线程,订阅channel消息
// 发布-订阅消息:当释放锁的时候,会通过publish发布一条消息,通知其它等待这个锁的线程,我已经释放锁了,你们可以过来获取了。
CompletableFuture<RedissonLockEntry> future = subscribe(threadId);
pubSub.timeout(future);
RedissonLockEntry entry;
if (interruptibly) {
entry = commandExecutor.getInterrupted(future);
} else {
entry = commandExecutor.get(future);
}
try {
// 加锁失败的话,循环重试
while (true) {
ttl = tryAcquire(-1, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) {
break;
}
// waiting for message
if (ttl >= 0) {
try {
entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
if (interruptibly) {
throw e;
}
entry.getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
} else {
if (interruptibly) {
entry.getLatch().acquire();
} else {
entry.getLatch().acquireUninterruptibly();
}
}
}
} finally {
unsubscribe(entry, threadId);
}
// get(lockAsync(leaseTime, unit));
}
tryAcquire()方法就是可重入锁加锁的关键方法:
private Long tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
// tryAcquireAsync(): 异步加锁
return get(tryAcquireAsync(waitTime, leaseTime, unit, threadId));
}
tryAcquire()方法内部又是通过调用tryAcquireAsync()来异步加锁的:
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture<Long> ttlRemainingFuture;
if (leaseTime > 0) {
// 指定了超时时间
ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {
// 未指定超时时间 internalLockLeaseTime默认就是看门狗的超时时间:30秒
// waitTime: -1
ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}
CompletionStage<Long> f = ttlRemainingFuture.thenApply(ttlRemaining -> {
// lock acquired
// ttlRemaining为null, 其实就是加锁的LUA脚本中返回的nil,表示获取锁成功
if (ttlRemaining == null) {
if (leaseTime > 0) { // 如果设置了超时时间,则更新internalLockLeaseTime为指定的超时时间,并且不会启动看门狗
internalLockLeaseTime = unit.toMillis(leaseTime);
} else {
// 自动续期实现,看门狗机制入口
/**
* 1. 只有用户未指定锁的超时时间,看门狗才生效;如果我们指定了锁超时时间,则看门狗不会启动;
* 2. 获取锁成功的线程才启动看门狗
*
* lock.lock(); 开启看门狗
* lock.lock(5000, TimeUnit.SECONDS); 不开启看门狗
*/
scheduleExpirationRenewal(threadId);
}
}
return ttlRemaining;
});
return new CompletableFutureWrapper<>(f);
}
在本例中,我们没有指定超时时间,所以走的else分支的tryLockInnerAsync()方法,几个参数说明一下:
- waitTime:-1;
- internalLockLeaseTime:如果没有指定超时时间的话,使用默认时间 30000 毫秒,在RedissonLock构造方法中指定的,获取的是看门狗超时时间;
- TimeUnit.MILLISECONDS:单位毫秒;
- threadId:当前线程id;
- RedisCommands.EVAL_LONG:eval
tryLockInnerAsync()加锁逻辑:
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
/**
* 执行LUA脚本进行加锁,可以保证多个redis指令的原子性,包括可重入的处理,底层使用redis的hash数据结构
* 例如执行getLock("lock"),redis中哈希表的数据如下:
* a、redis key: lock
* b、hash key: 7006e075-6fe4-420f-8f69-801be27d45d2:1
* c、hash value: 1
*
* LUA脚本分为三部分:
* 1、加锁
* 判断锁是否存在(使用exists指令判断redis key是否存在),如果不存在,说明当前没人获取这把锁,可以直接获取(hincrby 命令创建一个新的哈希表,并指定hash的key为当前线程),并设置锁的超时时间(使用pexpire指令)。返回nil, 表示获取锁成功
* 2、重入
* 判断当前锁是不是自己的锁,如果是的话,则将锁的重入次数+1(hincrby命令更新哈希表的field value),并重置锁的超时时间(使用pexpire指令)。返回nil, 表示获取锁成功
* 3、返回
* 返回锁剩余的过期时间,单位:毫秒
*
* 参数说明:
* KEYS[1]: Collections.singletonList(getRawName()) ==> 我们指定的分布式锁的key
* ARGV[1]: unit.toMillis(leaseTime) ==> 锁的超时时间
* ARGV[2]: getLockName(threadId) ==> 每个进程的唯一ID + ":" + threadId
*
* getRawName(): 我们指定的分布式锁的key
* getLockName(threadId): 【每个进程的唯一ID + ":" + threadId】,主要用于可重入分布式锁的判断
*/
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', 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]);",
Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}
从上面的代码可以看出,Redisson内部实际上就是通过一段LUA脚本来进行加锁的。下面仔细分析一下LUA脚本:
// 通过exists指令判断加锁的key是否存在,如果不存在,说明还没人加锁,可以直接进行加锁
"if (redis.call('exists', KEYS[1]) == 0) then " +
// 通过hincrby指令往redis中插入一个哈希结构的数据,redis key=加锁key hash key=uuid+当前线程ID hash value=锁的重入次数
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
// 通过pexpire设置锁的过期时间
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
// 返回nil, 表示加锁成功
"return nil; " +
"end; " +
// 如下是可重入锁的逻辑
// 通过hexists指令判断当前的锁是不是自己的,只有是自己的锁,才支持可重入
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
// 通过hincrby指令更新哈希结构的数据,将hash value对应的可重入次数加一
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
// 通过pexpire重新设置锁的过期时间
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
// 返回nil, 表示加锁成功
"return nil; " +
"end; " +
// 如果当前有人获取了锁,并且这个锁不是自己的,那么将会执行pttl指令,返回当前锁剩余的过期时间
"return redis.call('pttl', KEYS[1]);"
LUA脚本的参数信息说明:
- KEYS[1]: Collections.singletonList(getRawName()) ==> 我们指定的分布式锁的key
- ARGV[1]: unit.toMillis(leaseTime) ==> 锁的超时时间
- ARGV[2]: getLockName(threadId) ==> 每个进程的唯一ID + ":" + 当前线程ID
因为微服务集群部署的时候,线程ID有可能重复,所以需要拼接上每个服务实例的唯一标识,也就是进程唯一ID去保证唯一,我们可以在应用启动的时候,使用uuid生成一个常量,作为每个服务实例的唯一标识。当然也可以使用其它方式去生成,只需要保证每个进程唯一即可。
加锁的逻辑基本上都在LUA脚本里面了,可以看到,当执行LUA脚本返回nil的时候,表示锁获取成功,否则返回的是锁剩余的过期时间,即其他线程需要等待的时间。
加锁后Redis内的数据格式如下:
即:
- redis key = lock,这个lock就是我们前面通过redissonClient.getLock("lock")指定的;
- hash key = 43689446-d9c1-4371-a32b-f9c320f8d803:1,即uuid + ":" + 当前线程ID;
- hash value = 1,表示当前锁的重入次数为1;
以上主要介绍了 Redisson 可重入锁的加锁、锁重入逻辑,核心都在那一段LUA脚本。