一、前言
在跨进程的前提下访问某个共享资源时,需要使用到分布式锁来保证同一时间只有一个进程能够操作共享资源。
这个时候,锁对象需要从单个JVM内存中迁移到某个多进程共用的中间件上,例如MySQL、Redis或ZK上。
我们常常选择Redis来实现分布式锁,这里面有很多的坑,详情可以参考我的这篇文章我用了上万字,走了一遍Redis实现分布式锁的坎坷之路,从单机到主从再到多实例,原来会发生这么多的问题
Redisson是一个可以在java项目中使用的Redis客户端,其屏蔽了原子性、可重入、锁续期的诸多细节,内部实现各种各样的锁。
例如可重入锁、公平锁、MultiLock与Red Lock与读写锁等,今天主要分析可重入锁与锁续期的源码。
二、准备工作
引入redisson的依赖包:
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.16.6</version>
</dependency>
使用单节点配置:
redisson:
singleServerConfig:
address: 127:0:0:1:6379
使用以下命令来启动一个redis容器:
docker run --name redis -p 6379:6379 -d redis
测试代码:
@Resource
RedissonClient redissonClient;
public void lock() {
RLock lock = redissonClient.getLock("SunAlwaysOnline");
lock.lock();
try {
//模拟业务耗时
Thread.sleep(60000);
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
三、可重入锁分析
getLock方法,构造了一个RedissonLock对象
public RLock getLock(String name) {
return new RedissonLock(commandExecutor, name);
}
RedissonLock构造方法:
public RedissonLock(CommandAsyncExecutor commandExecutor, String name) {
super(commandExecutor, name);
//命令执行器
this.commandExecutor = commandExecutor;
//初次生成锁时,指定的过期时间,默认是30秒,用于避免程序宕机而导致锁无法释放
this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout();
//用于发布订阅
this.pubSub = commandExecutor.getConnectionManager().getSubscribeService().getLockPubSub();
}
进入到lock方法中:
public void lock() {
try {
lock(-1, null, false);
} catch (InterruptedException e) {
throw new IllegalStateException();
}
}
调用的是本类中的lock方法,leaseTime=-1,代表没有指定过期时间。
private void lock(long leaseTime, TimeUnit unit, boolean interruptibly) throws InterruptedException {
//获取线程id
long threadId = Thread.currentThread().getId();
//如果能获取到锁,则剩余存活时间ttl为空
Long ttl = tryAcquire(-1, leaseTime, unit, threadId);
if (ttl == null) {
return;
}
//如果获取不到锁,则订阅该线程释放锁的消息
RFuture<RedissonLockEntry> future = subscribe(threadId);
if (interruptibly) {
//可中断式同步订阅
commandExecutor.syncSubscriptionInterrupted(future);
} else {
//由于传入的是false,因此会走该方法
commandExecutor.syncSubscription(future);
}
try {
while (true) {
//第一次获取锁失败后,后续进行自旋
ttl = tryAcquire(-1, leaseTime, unit, threadId);
if (ttl == null) {
//获取到则结束死循环
break;
}
//阻塞等待释放锁的消息
if (ttl >= 0) {
try {
//在ttl时间内阻塞获取锁,内部是靠Semaphore来实现阻塞的
future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} catch (InterruptedException e) {
if (interruptibly) {
throw e;
}
future.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
}
} else {
//ttl小于0,继续阻塞等待
if (interruptibly) {
future.getNow().getLatch().acquire();
} else {
future.getNow().getLatch().acquireUninterruptibly();
}
}
}
} finally {
//此时已经获取到锁,或者出现中断异常,则取消订阅
unsubscribe(future, threadId);
}
}
进入核心的tryAcquire方法:
private Long tryAcquire(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
//get会进行同步等待,类似Future.get
return get(tryAcquireAsync(waitTime, leaseTime, unit, threadId));
}
tryAcquireAsync方法:
private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture<Long> ttlRemainingFuture;
if (leaseTime != -1) {
//指定锁的过期时间
ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {
//由于leaseTime=-1,因此走该方法去异步获取锁
ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}
//ttlRemainingFuture有结果后,会执行该方法,内部借用semaphore来实现
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) {
return;
}
//ttlRemaining为null,代表获取到锁
if (ttlRemaining == null) {
if (leaseTime != -1) {
internalLockLeaseTime = unit.toMillis(leaseTime);
} else {
//进行锁的续期
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}
tryLockInnerAsync方法内部:
<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
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));
}
这里使用具有原子性的lua脚本并通过Netty进行网络传输,详细看下这段脚本
(这里插一句,为什么lua脚本具有原子性?因为在执行该脚本时,会排斥其他lua脚本和命令,不过lua脚本无法保证事务性。)
方法参数
- waitTime,值为-1,代表未指定过期时间
- leaseTime,值为30秒
- unit,时间单位,毫秒
- threadId,线程ID
- command,redis命令类型,此处是eval
脚本参数
- KEYS[1],getRawName(),锁的key,这里就是SunAlwaysOnline
- ARGV[1],unit.toMillis(leaseTime),锁的过期时间,这里是30*1000毫秒
- ARGV[2],getLockName(threadId),UUID+线程id
脚本含义
//如果key不存在
if (redis.call('exists', KEYS[1]) == 0) then
//创建一个hash结构,该key的字段值被初始化为1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
//设置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
//返回null
return nil;
end;
//如果key存在,且当前线程持有该锁
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
//字段值加1,对同一线程的加锁统计,以此来实现可重入
redis.call('hincrby', KEYS[1], ARGV[2], 1);
//刷新过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
//返回null
return nil;
end;
//锁被其他线程占用,返回剩余过期时间
return redis.call('pttl', KEYS[1]);
加锁后,可以使用hgetall SunAlwaysOnline查看申请到的锁
127.0.0.1:6379> hgetall SunAlwaysOnline
1) "2f160d0b-3112-4c78-a1b3-2d7f123ce216:78"
2) "1"
在以SunAlwaysOnline为key哈希结构中,2f160d0b-3112-4c78-a1b3-2d7f123ce216:78是其中的一个字段,其值为1.
2f160d0b-3112-4c78-a1b3-2d7f123ce216是UUID,78为线程id。使用UUID+threadId的格式,可以来区分不同机器上出现相同线程id的情况。
继续看解锁的逻辑:
public void unlock() {
try {
get(unlockAsync(Thread.currentThread().getId()));
} catch (RedisException e) {
if (e.getCause() instanceof IllegalMonitorStateException) {
throw (IllegalMonitorStateException) e.getCause();
} else {
throw e;
}
}
}
调用的是RedissonBaseLock类中的unlockAsync方法:
public RFuture<Void> unlockAsync(long threadId) {
RPromise<Void> result = new RedissonPromise<>();
//异步解锁
RFuture<Boolean> future = unlockInnerAsync(threadId);
//解锁完成后的回调
future.onComplete((opStatus, e) -> {
//取消对锁的续期
cancelExpirationRenewal(threadId);
if (e != null) {
result.tryFailure(e);
return;
}
if (opStatus == null) {
//该线程尝试去释放别的线程加的锁,因此抛出异常
IllegalMonitorStateException cause = new IllegalMonitorStateException("attempt to unlock lock, not locked by current thread by node id: "
+ id + " thread-id: " + threadId);
result.tryFailure(cause);
return;
}
result.trySuccess(null);
});
return result;
}
进入到核心的unlockInnerAsync方法中:
protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;",
Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}
依然是一段lua脚本,在这里解释下:
脚本参数
- KEYS[1],getRawName(),锁的key,即SunAlwaysOnline
- KEYS[2],getChannelName(),channel名称,内容为redisson_lock__channel:{SunAlwaysOnline}
- ARGV[1],LockPubSub.UNLOCK_MESSAGE,解锁的消息,值为0
- ARGV[2],internalLockLeaseTime,默认30秒
- ARGV[3],getLockName(threadId),UUID+线程id
脚本含义
//如果解锁线程和加锁线程不是一个线程时,直接返回null
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
//使用hincrby使得字段值减1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
//如果剩余解锁次数大于0
if (counter > 0) then
//刷新过期时间,返回0
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
//剩余次数为0,可以直接释放锁
redis.call('del', KEYS[1]);
//往指定channel中发布锁被释放的消息,并返回1
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
return nil
Redisson通过创建一个hash结构,其中有一个字段为UUID+线程ID,字段值表示锁的重入次数,以此来实现可重入锁。
如果没有指定锁的过期时间,那么锁续期是怎么做的呢?
四、锁续期分析
上文我们在分析可重入锁加锁时,其tryAcquireAsync方法中就表明了锁续期的入口,即scheduleExpirationRenewal方法。
在未指定锁的过期时间时,才会进行对锁的续期。
protected void scheduleExpirationRenewal(long threadId) {
//续期对象,记录线程的重入次数,同时还会把续期任务存入timeout中
ExpirationEntry entry = new ExpirationEntry();
//getEntryName为UUID+key,存入缓存,下次续期直接使用
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
//进行线程的可重入
oldEntry.addThreadId(threadId);
} else {
//第一次加锁
entry.addThreadId(threadId);
try {
//开启续期
renewExpiration();
} finally {
if (Thread.currentThread().isInterrupted()) {
//如果当前线程被中断,取消当前线程对锁的续期。
//在续期对象中,如果没有可用于续期的线程id,则取消整个续期任务
cancelExpirationRenewal(threadId);
}
}
}
}
这里面其实只调用了renewExpiration方法:
private void renewExpiration() {
//获取续期对象
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
//启动一个延时任务,内部依靠的是Netty的时间轮算法
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
//获取不到,说明已经结束续期
if (ent == null) {
return;
}
//获取第一个可用的线程id
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
//异步使用lua脚本进行续期
RFuture<Boolean> future = renewExpirationAsync(threadId);
//执行完成后,触发该回调
future.onComplete((res, e) -> {
if (e != null) {
//续期出现异常后,则取消之后的所有续期,并终止该任务
log.error("Can't update lock " + getRawName() + " expiration", e);
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
return;
}
if (res) {
//续期成功,递归调用本方法进行下次续期
renewExpiration();
} else {
//说明锁已经被主动释放,取消后续的所有续期
cancelExpirationRenewal(null);
}
});
}
//internalLockLeaseTime 默认是30秒,因此这里每10秒进行一次续期
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);
//在续期对象中保存该任务
ee.setTimeout(task);
}
在续期成功的情况下,会不断进行递归调用,从而开启下一轮任务。
当然不可能出现无限递归的情况,每次递归前都会先从缓存中获取续期对象。
如果续期对象不存在,或内部不存在任何可用的线程id,以及续期失败后,都会直接结束续期任务。
接着看renewExpirationAsync方法:
protected RFuture<Boolean> renewExpirationAsync(long threadId) {
return evalWriteAsync(getRawName(), 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.singletonList(getRawName()),
internalLockLeaseTime, getLockName(threadId));
}
如果key存在且字段值是指定线程的情况下,刷新锁的过期时间,返回true。否则,返回false。
当然,这里的锁续期也叫做“Watch Dog”,即看门狗。下次面试官问到看门狗,别傻乎乎地不知道了。
那么看门狗这种奇怪的名字,到底是怎么来的呢?
源于internalLockLeaseTime字段,它是这样赋值的:
this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout();
五、后语
Redisson是使用hash结构来实现分布式锁的,将UUID+线程ID作为其中一个字段,重入次数作为value。
在某个线程获取锁失败后,会订阅锁的释放消息,接着进行自旋的阻塞式获取锁。
内部封装了lua脚本,来实现命令的原子性,可以避免以下问题:
- 创建锁与设置过期时间的原子性,防止创建完锁,客户端宕机,导致锁永远无法释放
- 检验锁与删除锁的原子性,防止检验通过后,直接删除其他客户端刚申请的锁
Redisson的看门狗机制,有效地解决了通过经验指定过期时间导致锁提前被释放的难题。
最后通过一副图,来让大家对整体有个大致的印象