前言
hi,大家好,我是大鱼七成饱(公众号同名)。
上周同事请教定时任务的方案,任务不能重复,时间不定,量不大,考虑了下,推荐Redission的Lock(带Watchdog机制那个)。忽然想起来,之前面试的时候也问到过,起码有三次关于 Redission WatchDog。感觉很重要,这次就梳理下,把原理总结清楚,保证下次问到,轻松吊打面试官。
首先是环境准备
xml
<dependencies>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.24.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<!-- 引入log4j2依赖 -->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-log4j2</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.26</version>
<scope>provided</scope>
</dependency>
</dependencies>
Redission版本是3.24.3,集成到Springboot 3.1.6 版本。
举个例子
为方便理解,先举个简单例子,保存订单,给这个操作加锁,示例代码如下
less
@PostMapping("addOrder")
public ResponseEntity<Object> addGoods(@RequestBody Order order) {
RLock rLock = redissonClient.getLock("order-lock" + order.getId());
boolean islock = false;
try {
islock = rLock.tryLock(10, TimeUnit.SECONDS);
if (!islock) {
return ResponseEntity.ok("请稍后再试。。。");
}
orderService.addOrder(order);
log.info("\n{} --> 获取到锁的睡眠40s", Thread.currentThread().getName());
Thread.sleep(40000);
log.info("\n{} --> 获取到锁的醒了", Thread.currentThread().getName());
return ResponseEntity.ok(order);
} catch (Exception e) {
log.info("\n{} --> 获取锁失败:{}", Thread.currentThread().getName(), e);
} finally {
if (islock && rLock.isHeldByCurrentThread()) {
rLock.unlock();
}
}
log.info("\n{} --> 获取锁失败", Thread.currentThread().getName());
return ResponseEntity.ok("服务暂时无法加载。。。");
}
redissonClient.getLock
就获取了一个带有Watch Dog 自动延时的锁,是不是特简单(有时候挺佩服大师的API设计)。
添加订单前,先根据订单id加锁,然后处理超时后,watch dog会自动将锁延时。处理完成后unlock下,释放锁。
原理图
会用了,那么执行原理是啥呢?有道是一 图 胜 千 言méi tú shūo ge dàn,下面上个图
有两个点需要注意:
- watchDog 只有在未显示指定加锁超时时间(leaseTime)时才会生效。
- lockWatchdogTimeout 设定的时间不要太小 ,比如设置的是 100毫秒,由于网络直接导致加锁完后,watchdog去延期时,这个key在redis中已经被删除了。
到这里了,我们看看源码
上锁
跟切西瓜一样,这刀先切哪里呢?既然 islock = rLock.tryLock(10, TimeUnit.SECONDS);
这句上锁的,那就从这往下找,会进入如下代码,这里是上锁:
续期
兄弟们,接着往下走啊,继续找看门狗,如下图。
scheduleExpirationRenewal,看名字是不是特别亲切,定时过期和续租,这个方法继续debug,找到这个方法:renewExpiration,顾名思义,更新过期时间。里面有个TimerTask,这就是我们要找的看门狗(到期自动续租的定时任务)
renewExpiration调用的renewExpirationAsync实现如下:
typescript
protected CompletionStage<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));
}
这就是续期的核心代码,使用lua执行的。getRawName是锁的名字(示例里的"order-lock" + order.getId())"),getLockName是线程+ UUID.randomUUID()拼接的,internalLockLeaseTime是续期的时间,如果key存在,则调用pexpire续期。这段代码就是源头活水,嘿嘿。
续期根源代码找到了,还要看看水流向哪里呀(怎么解锁的)。来都来了是吧,在继续看看
解锁
跟刚才一样,跟着unlock代码, unlock ->unlockAsync->unlockAsync0-> unlockInnerAsync
ini
protected RFuture<Boolean> unlockInnerAsync(long threadId, String requestId, int timeout) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"local val = redis.call('get', KEYS[3]); " +
"if val ~= false then " +
"return tonumber(val);" +
"end; " +
"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]); " +
"redis.call('set', KEYS[3], 0, 'px', ARGV[5]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call(ARGV[4], KEYS[2], ARGV[1]); " +
"redis.call('set', KEYS[3], 1, 'px', ARGV[5]); " +
"return 1; " +
"end; ",
Arrays.asList(getRawName(), getChannelName(), getUnlockLatchName(requestId)),
LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime,
getLockName(threadId), getSubscribeService().getPublishCommand(), timeout);
}
这里简单解释下执行流程
第一个if:判断加锁key存不存在,KEYS[2] 这里代表发布订阅消息的管道名称; 第二个if:判断KEY[1]的ARGV[3]是否存在,APGV[3] 代表线程id标识,如果不存在则返回null; 第三方if:如果存在将APGV[3]的值减一,如果counter的值相当于线程id标识经过运算后的值大于0,由于是可重入锁,该线程依旧可以获取到锁,重新设置ARGV[2] 锁过期时间,返回0; del操作是真正的解锁操作,并且通过publish命令发布消息,APGV[1] 代表消息内容,操作成功后返回1; 解锁失败返回nil,nil相当于Java中的null;
redis.call('set', KEYS[3], 1, 'px', ARGV[5]);是为啥呢?解决这个问题哈,github.com/redisson/re... attempt to unlock lock, not locked by current thread by node id。bug是16号提的,作者27号修的,感觉是临时修补。因为执行完后,有个方法删了这个锁,
ce.writeAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.DEL, getUnlockLatchName(id));为了不报错而已
一点思考
Watchdog每个锁都开启单独的Monitor,稍有不慎会生成大量线程,造成内存溢出。这种锁只限定简单场景(处理时间无法预估)使用。高并发场景我们能预估出时间,普通的锁就可以,设置超时长点就行。