关注我的公众号:【编程朝花夕拾】,可获取首发内容。

01 引言
在分布式的场景下,分布式锁是经常遇到的。而Redis的分布式锁用的较为频繁。但是Redis的分布式锁是怎么实现的呢?
在我们公司的业务代码中经常会出现这样的代码:

或者

这样的代码用来当分布式锁或者处理幂等性等问题。这样有没有什么问题呢?
我想应该不止我们公司有人写成那样的代码吧!因为我们公司的并发不大,反正遇到的问题并不多。我们一起来探索一下吧!
02 分布式锁
分布式锁大家已经耳熟能详了,其核心特性无非就那么几个:
- 互斥性:同一时刻只有一个客户端能持有锁
- 安全性:锁只能由持有者释放,防止误删
- 容错性:在部分节点故障时仍能正常工作
- 避免死锁:必须有超时机制,防止客户端崩溃导致锁无法释放
我们根据以上特性分析一下上面代码中setnx+expire和set可能会出现什么样的问题。
2.1 setnx + expire
我们还原一下伪代码:
java
let redisService = xxxxx;
try{
if (1 == redisService.setnx(key, value)){
redisService.expire(key, expire);
// 后续业务....
}
}finally {
redisService.delete(key);
}
setnx固然可以是保持互斥性,但是和expire方法是两个独立的方法。所以如果expire方法异常,就会导致setnx设为永久有效。虽然有redisService.delete兜底,但是如果出现服务器宕机,此处的代码几无法执行,而从造成死锁。
如果执行业务逻辑的时候由于网络抖动,分布式锁已经过期,业务还在阻塞中。这时第二个请求相同的Key就可以加锁了,业务逻辑还没有执行完,结果第一个方法的恢复正常执行了delete(key)的操作,就会误删掉第二个请求的key,也就是释放了第二个请求的锁。
还有就是由于业务复杂导致分布式锁过期,业务还没有执行完,就会自动释放锁。导致加锁失败。
简单总结问题:
setnx和expire没有原子性操作- 释放锁没有校验是不是自己的锁,可能会误删锁。
- 锁没有续约
2.2 set
set的方法是setnx + expire的结合,属于原子性操作。我们来看看伪代码:
java
let redisService = xxxxx;
try{
if ("OK" == redisService.setnx(key, value, "NX", "EX", 600)){
// 后续业务....
}
}finally {
redisService.delete(key);
}
这个方法比上面的好一点,保证了原子性。但是其他两个问题依然存在:
- 释放锁没有校验是不是自己的锁,可能会误删锁。
- 锁没有续约
2.3 优化
误删锁的问题容易解决,只需要在删除的时候,判断存入的Value是自己的Value即可。
java
String value = "xxxx";
if (value.equals(redisService.get(key)) {
redisService.delete(key);
}
锁的续约如何解决呢?我们耳熟能详的看门狗模式。简单来说就是启用一个线程,隔一段时间就去看当前客户端的Key有没有过期,快过期的时候,重新设置时间。
伪代码如下:
java
new Timer().schedule(new TimerTask() {
@Override
public void run() {
if (redisService.expire(key) <= expire) {
// 续约
redisService.expire(key, expire2);
}
}
}, 10);
思路也是比较简单。但是难点就在续约的时间,检查锁是否过期的间隔。我们将通过几个开源案例了解一下具体的实现。
03 Lock4j

3.1 简介
Lock4j是一个分布式锁组件,其提供了多种不同的支持以满足不同性能和环境的需求,立志打造一个简单但富有内涵的分布式锁组件。其特性就是简单易用,功能强大,扩展性强,支持redission,redisTemplate,zookeeper。可混用,支持扩展。
该开源项目来自苞米豆社区,你可能没听过。但是Mybatis-Plus你一定听过,它的作者和Lock4j的作者是同一人。
3.2 RedisTemplate
StringRedisTemplate和RedisTemplate是SpringBoot自带的客户端。我们看看Lock4J是如何实现分布式锁的。
实现类:com.baomidou.lock.executor.RedisTemplateLockExecutor

从源码可以看出,对于分布式锁的加锁、释放锁以及续约,为了保证原子性,都是用了Lua脚本。
加锁

通过ForkJoin线程池启动线程,加锁后根据参数执行是否续约。然后等待加锁的结果,并返回加锁的结果。
释放锁

释放锁就比较简单了。根据Lua脚本,先查询对比,然后根据结果删除对应的key。
续约

同样是先获取判断,然后在续约。都是通过Lua脚本处理的。但是要这里要说的是,通过new Timer()启动了一个调度任务,每到过期时间的三分之一,就会触发续约逻辑。
问题
我们会发现,在续约的时候启动的线程资源并没有被回收。如果加锁的线程很多,就会有很多任务在空跑。消耗了资源。小编在Gitee也返现了有人提出这个问题,并尝试优化:
3.3 Redisson
Redisson同样是一款经典的Redis客户端。该客户端自己实现分布式锁,并启用了看门狗模式。
加锁

直接通过RedissonClient客户端获取锁并尝试加锁,返回加锁结果。
释放锁

判断线程是否持有,有的话就释放锁。
续约

几乎没有代码。因为Redisson本身自带看门狗机制。我们追一下Redisson的源码。当执行lock()->tryAcquire()->tryAcquireAsync()->scheduleExpirationRenewal()->renewExpiration()

①我们可以看到依然通过TimerTask这样的调度任务。
②不续约的话就释放资源:

zookeeper的分布式锁我们暂且讨论,因为不是基于Redis的。
04 小结
分布式锁不是想象的那么简单,但也不是那么复杂。使用Redis分布式锁,直接Redssion客户端,使用完善的功能。使用Redis的Set或者setnx,可以满足日常并发不大的场景,设置合理的过期时间,就能够满足大部分的场景了。