手动实现Redis分布式锁存在的问题
- 锁被别人释放了怎么办?
- 定义的过期时间太长或太短,怎么定义?
- 锁的释放不是原子操作怎么处理?
小试牛刀
首先我们要定义锁的实现,也就是下面的getLock方法,使用的是RedisTemplate,并没有去定义工具类。
getLock方法获取锁
getLock方法使用的是redis的SETNX命令,但是由于SETNX命令无法进行原子性操作过期时间,所以我们在这里使用SET命令,从redis2.6.12版本开始,SET命令的行为可以通过一系列参数来修改:
- EX seconds : 将键的过期时间设置为 seconds 秒。 执行 SET key value EX seconds 的效果等同于执行 SETEX key seconds value。
- PX milliseconds : 将键的过期时间设置为 milliseconds 毫秒。 执行 SET key value PX milliseconds 的效果等同于执行 PSETEX key milliseconds value。
- NX : 只在键不存在时, 才对键进行设置操作。 执行SET key value NX 的效果等同于执行 SETNX key value 。
- XX : 只在键已经存在时, 才对键进行设置操作。
同时使用SET LOCK ID EX 30 NX命令的好处就是可以加上ID这个唯一标识。后续会讲到。
release方法释放锁
在获取锁的时候我们设置了key,也就是锁的名称,ID就是键值对的value。好处就是为了释放锁的时候出问题,举个例子:当线程A获取到锁进行业务逻辑的时候,线程B的误操作,直接释放了锁。为了解决类似的异常情况,我们将当前的线程ID和锁进行绑定。
设计这个方法的步骤和思想如下:
- 首先释放锁的时候需要传入当前线程信息,因为锁的value值是和线程id绑定的,同时需要传入锁的名称。
- 获取到当前锁的value值,与当前需要释放锁线程ID是否一致。
- 一致则可以释放,不一致不能释放。
问题它又来了,这里的get+del命令又是两条命令了,又得回到原子性的问题
- 客户端1执行get命令,判断锁是不是自己的
- 客户端2执行set命令,强制的获取到了锁
- 客户端1执行了del命令,删掉了客户端2的锁
所以我们使用redis可以执行lua脚本的特性,写了一个脚本来原子性执行,解决这个问题。具体的实现如下代码所示:
@Component
public class RedisInfo {
@Autowired
private RedisTemplate<Object, Object> redisTemplate;
/**
* Title:getLock <br>
* Description:获取锁的实现 <br>
* author:于琦海 <br>
* date:2022/10/25 10:58 <br>
* @param key 锁的名称
* @param time 预估的过期时间
* @param timeUnit 时间单位
* @param thread 当前操作获取锁的线程
* @return Boolean
*/
public Boolean getLock(String key, Long time, Thread thread, TimeUnit timeUnit) {
return redisTemplate.opsForValue().setIfAbsent(key, thread.getId(), time, timeUnit);
}
/**
* Title:release <br>
* Description:释放锁的原子实现 <br>
* author:于琦海 <br>
* date:2022/10/25 11:12 <br>
* @param key 锁的名称
* @param thread 当前操作释放锁的线程
* @return Long
*/
public Long release(String key, Thread thread) {
String lua = " if redis.call('GET',KEYS[1]) == ARGV[1] then return redis.call('DEL',KEYS[1]) else return 0 end";
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setResultType(Long.class);
script.setScriptText(lua);
return redisTemplate.execute(script, Arrays.asList(key), thread.getId());
}
/**
* Title:ttl <br>
* Description:查询key的剩余过期时间 <br>
* author:于琦海 <br>
* date:2022/10/25 14:13 <br>
* @param key 锁的名称
* @param timeUnit 时间单位
* @return Long
*/
public Long ttl(String key, TimeUnit timeUnit) {
return redisTemplate.getExpire(key, timeUnit);
}
/**
* Title:expire <br>
* Description:更新key的过期时间 <br>
* author:于琦海 <br>
* date:2022/10/25 14:33 <br>
* @param key 锁的名称
* @param time 预估的过期时间
* @param timeUnit 时间单位
* @return Boolean
*/
public Boolean expire(String key, long time, TimeUnit timeUnit) {
return redisTemplate.expire(key, time, timeUnit);
}
}
续期问题
假设我们设置锁的持有时间为30s,但是由于网络问题或者机器问题导致执行我的业务逻辑超过了30s,而此时锁已经失效了,这种情况就会有问题。那如果我设置为一个超大的时间,比如100s一定能执行完,此时锁的效率又会变得非常低。
根据这个情况,我们可以写一个守护线程来根据剩余时间来对锁进行续期,类似于Redisson的看门狗机制。
思路如下:
- 首先开启一个守护线程 。
- 获取当前锁的剩余过期时间。
- 如果锁的剩余时间小于等于我设置的锁过期时间的三分之一(我设置30s,除以3,也就是10s)。
- 对当前的锁续期。
@Service
public class RedisLockDemo {
@Autowired
private RedisInfo redisInfo;
/**
* Title:businessInfo <br>
* Description:这里面是业务逻辑伪代码 <br>
* author:于琦海 <br>
* date:2022/10/25 11:34 <br>
*/
public void businessInfo() {
// 获取锁
Boolean lock = redisInfo.getLock("qhyu", 30L, Thread.currentThread(), TimeUnit.SECONDS);
if (lock) {
try {
// 开启一个守护线程,去刷新过期时间,实现看门狗的机制
Runnable thread = () -> {
while (true) {
// 会不会
// 获取key的剩余过期时间
Long timeLess = redisInfo.ttl("qhyu", TimeUnit.SECONDS);
// 当时间小于等于10s的时候,续命
if (timeLess <= 10L) {
System.out.println("续命");
redisInfo.expire("qhyu", 30L, TimeUnit.SECONDS);
}
}
};
Thread thread1 = new Thread(thread);
thread1.setDaemon(true);
thread1.start();
int i = 0;
while (true) {
if (i < 50) {
i++;
// 执行我们的业务逻辑
System.out.println("这里是我的业务逻辑" + i);
Thread.sleep(10000L);
} else {
break;
}
}
} catch (Exception e) {
throw new RuntimeException(e);
} finally {
redisInfo.release("qhyu", Thread.currentThread());
}
}
}
}
测试
根据上面的代码,写了个controller掉用了一下,能达到自动续期的目的。
@RestController
@RequestMapping(("/qhyu/redis"))
public class RedisLockController {
@Autowired
private RedisLockDemo redisLockDemo;
@GetMapping("/test")
public void test(){
redisLockDemo.businessInfo();
}
}
在时钟正常的情况下,我们预计没执行两次业务逻辑,就会续命一次。以下的结果能证明这一点。
那么如果不是守护线程,直接开一个线程不行吗?肯定是不行的,因为当线程执行完成之后,新开的线程不会被销毁,如下所示,会一直去对锁进行续命,就会出现问题。