目录
分布式锁方案一(不建议,但原理得懂):Redis锁setnx与业务代码处理
背景:
现有编码格式为业务常量+数字,每新增一条数据在基础上+1,比如:
文件类型1 编码为ZS01
文件类型1下文件1 编码为ZS0101
文件类型1下文件2 编码为ZS0102
文件类型2 编码为ZS02
文件类型2下文件1 编码为ZS0201
文件类型2下文件2 编码为ZS0202
解决方案:
使用mysql中count()函数与where条件,查询出条数充当最大值,再此基础上加1,生成编码,通过编码工具类实现格式统一,并使用redis分布式锁解决并发问题。
分布式锁方案一(不建议,但原理得懂):Redis锁setnx与业务代码处理
redis 的 setnx区别于普通set,他是 set key if not exist ,当一个key不存在的时候,可以设置成功。那么,我们就可以把 setnx 来设定某个key为一把锁,这个key存在的时候,则表示获得锁,那么请求无法操作共享资源,除非这个key不存在了,那就行。
第一次设置成功,第二次设置不成功,因为这个key没有释放,除非删除了,或者超时清除了,那么才可以。
从上面操作可以看得出来,这其实也是分布式锁的3个关键步骤,加锁设值,删除解锁,重试(死循环或者递归)
通过如下流程可以更好梳理思路:
雏形代码
产生问题一:锁释放问题
代码改造:锁添加过期时间
思考问题:
如果业务执行的过程抛出异常了,怎么办?锁会一直没释放。
如果当前运行这段代码的计算机节点突然停电了,代码正准备删除lock,这个时候咋办?锁也会一直存在。
提出的两个问题,其实我们要保证锁最终不管怎样都要释放,所以,我们可以为锁添加过期时间,如上图。
一旦后续发生故障,那么30秒后还是能释放锁。但是这个时候还是会有问题,程序正好运行到1.1还没来得及设置过期时间,拉电了,此时锁设置成功,但是没有设置过期时间,还是有问题,所以,要么全设置成功,原子性必须得保证。我们可以使用 setnx内置的,可以多加时间参数来设置。
产生问题二:锁被别的线程误删
代码改造:添加setnx锁请求标识防勿删
产生问题三:递归容易造成内存溢出
代码改造:递归改造while循环
目前所使用的递归方案,高并发时也容易造成内存溢出,那么其实可以改造一下,改为死循环即可只要获得锁失败,则返回去尝试获得锁即可
产生问题四:查询锁并且删除锁产生原子性问题
代码改造:Lua原子性操作
图中箭头处,当我们拿出锁后,并且判断也成功了,在这一刹那间,锁也可能正好失效吧。这个时候已经进入了判断内部了,所以会执行删除锁,但是这个时候因为锁恰好失效,所以其他请求就占有锁,那么自己在删除锁的时候,其实删除的是别人的锁,这样在极端的情况下其实也会出问题的。此时怎么办?
查询锁并且删除锁,这其实也是原子性操作,因为上一节课说了,这里也是可能会删除其他的锁的因为原子性保证不了。
所以接下来我们所需要做的,就是保证查询以及判断都是原子性的操作。这里就需要结合使用LUA脚本来解决这个问题
可以打开redis官网:https://redis.io/commands
解释:get命令获得key与参数比对,如果比对一致,则删除,否则返回0。这是一段脚本,是一个命令一起运行的,所以要比我们程序代码中的调用要来的更好,因为这是原子性操作。要么全成功,要么全失败。
在命令行可以通过eval命令来进行操作:
把上述脚本转换为一个字符串(大家可以直接复制)
// 使用LUA脚本执行删除key操作,为了保证原子性
String lockScript =
" if redis.call('get',KEYS[1]) == ARGV[1] "
+ " then "
+ " return redis.call('del',KEYS[1]) "
+ " else "
+ " return 0 "
+ " end "
;
在通过redis调用即可
产生问题五:业务还没执行完,锁就过期了
代码改造:setnx 锁自动续期
遗留问题思考:
我在这里设置了30秒,如果业务执行时间很长,需要35秒,这个时候还没等业务执行完毕就释放锁了,那么其他请求就会进来处理共享资源,那么锁其实就失效了,没起到作用了。而且在第个请求执行到第35秒的时候,会被第一个请求的del给删除锁,这个时候完全乱套了,各自没有删除自己的锁而是删的其他请求的锁,整个都乱了,怎么办?前面我们设置了超时时间,但是如果真的业务执行很耗时,超时了,那么我们应该给他自动续期啊开启(fork)一个子线程,定时检查,如果lock还在,则在超时时间重置,如此循环,直到业务完成后删除锁。(或者使用while死循环也行)
LUA脚本:
// if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('expire',KEYS[1],30) else return 0 end
String refreshScript =
" if redis.call('get',KEYS[1]) == ARGV[1] "
+ " then "
+ " return redis.call('expire',KEYS[1],30) "
+ " else "
+ " return 0 "
+ " end "
;
终极版:java代码实现
那么执行过程中,会经历几次续期,结束了,就释放timer。
@Transactional
@Override
public void modifyCompanyInfo3(ModifyCompanyInfoBO companyInfoBO, Integer num) throws Exception {
String distLock = "redis-lock";
String selfId = UUID.randomUUID().toString();
Integer expireTimes = 30;
while (redis.setnx(distLock, selfId, expireTimes)) {
// 如果加锁失败,则重试循环
System.out.println("setnx 锁生效中,一会重试~");
Thread.sleep(50);
}
// 一旦获得锁,则开启新的timer执行定期检查,做lock的自动续期
autoRefreshLockTimes(distLock, selfId, expireTimes);
try {
System.out.println("获得锁,执行业务~");
// 加锁成功,执行业务
Thread.sleep(40000);
this.doModify(companyInfoBO);
} finally {
// 业务执行完毕,释放锁
// String selfIdLock = redis.get(distLock);
// if ( StringUtils.isNotBlank(selfIdLock) && selfIdLock.equals(selfId)) {
// redis.del(distLock);
// }
// 使用LUA脚本执行删除key操作,为了保证原子性
String lockScript =
" if redis.call('get',KEYS[1]) == ARGV[1] "
+ " then "
+ " return redis.call('del',KEYS[1]) "
+ " else "
+ " return 0 "
+ " end "
;
long unLockResult = redis.execLuaScript(lockScript, distLock, selfId);
if (unLockResult == 1) {
lockTimer.cancel();
System.out.println("释放锁,并且取消timer~");
}
}
}
private Timer lockTimer = new Timer();
// 自动续期
private void autoRefreshLockTimes(String distLock, String selfId, Integer expireTimes) {
// if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('expire',KEYS[1],30) else return 0 end
String refreshScript =
" if redis.call('get',KEYS[1]) == ARGV[1] "
+ " then "
+ " return redis.call('expire',KEYS[1],30) "
+ " else "
+ " return 0 "
+ " end "
;
lockTimer.schedule(new TimerTask() {
@Override
public void run() {
System.out.println("自动续期,重置到30秒");
redis.execLuaScript(refreshScript, distLock, selfId);
}
},
expireTimes/3*1000,
expireTimes/3*1000);
}
private void doModify(ModifyCompanyInfoBO companyInfoBO) {
//业务代码
}
总结:
会出现的问题
这种方案能解决方案一的原子性问题,但是依然会存在很大的问题,如下所示:
1、时钟不同步:如果不同的节点的系统时钟不同步,可能导致锁的过期时间计算不准确。
解决方案:使用相对时间而非绝对时间,或者使用时钟同步工具确保系统时钟同步。
2、死锁:在某些情况下,可能出现死锁,例如由于网络问题导致锁的释放操作未能执行。
解决方案:使用带有超时和重试的锁获取和释放机制,确保在一定时间内能够正常操作。
3、锁过期与业务未完成:如果业务逻辑执行时间超过了设置的过期时间,锁可能在业务未完成时自动过期,导致其他客户端获取到锁。
解决方案:可以设置更长的过期时间,确保业务有足够的时间完成。或者在业务未完成时,通过更新锁的过期时间来延长锁的生命周期。
4、锁的争用:多个客户端同时尝试获取锁,可能导致锁的频繁争用。
解决方案:可以使用带有重试机制的获取锁操作,或者采用更复杂的锁实现,如 Redlock 算法。
5、锁的释放问题:客户端获取锁后发生异常或未能正常释放锁,可能导致其他客户端无法获取锁。
6、锁被别的线程误删:假设线程a执行完后,去释放锁。但是它不知道当前的锁可能是线程b持有的(线程a去释放锁时,有可能过期时间已经到了,此时线程b进来占有了锁)。那线程a就把线程b的锁释放掉了,但是线程b临界区业务代码可能都还没执行完。
分布式方案二:开源框架:Redisson
Redisson 概述
总结一下上面的解决问题的历程和问题,用SETNX+EXPIRE可以解决分布式锁的问题,但是这种方式不是原子性操作。因此,在提出的有关原子性操作解决方法,但是依然会出现几个问题,在会出现的问题中简单罗列了几种问题与解决方法,其中一个问题中有锁过期与业务未完成有一个系统的解决方案,即接下来介绍的Redison。
Redisson 是一个基于 Redis 的 Java 驱动库,提供了分布式、高性能的 Java 对象操作服务,这里只探讨分布式锁的原理:
只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程1还持有锁,那么就会不断的延长锁key的生存时间。因此,Redisson就是使用Redisson解决了锁过期释放,业务没执行完问题。
Watchdog 定期续期锁:
当客户端成功获取锁后,Redisson 启动一个 Watchdog 线程,该线程会定期(通常是锁过期时间的一半)检查锁是否过期,并在过期前对锁进行续期。
Watchdog 使用 Lua 脚本确保原子性:
为了确保 Watchdog 操作的原子性,Redisson 使用 Lua 脚本执行 Watchdog 操作。这样在 Watchdog 检查和续期锁的过程中,可以保证整个操作是原子的,防止出现竞争条件。
Watchdog 续期锁的过期时间:
Watchdog 线程会通过使用 PEXPIRE 或者 EXPIRE 命令来续期锁的过期时间。这样在业务未完成时,锁的过期时间会不断延长,直到业务完成释放锁。
Redisson 是 java 的 Redis 客户端之一,是 Redis 官网推荐的 java 语言实现分布式锁的项目。
Redisson 提供了一些 api 方便操作 Redis。因为本文主要以锁为主,所以接下来我们主要关注锁相关的类,以下是 Redisson 中提供的多样化的锁:
- 可重入锁(Reentrant Lock)
- 公平锁(Fair Lock)
- 联锁(MultiLock)
- 红锁(RedLock)
- 读写锁(ReadWriteLock)
- 信号量(Semaphore) 等等
总之,管你了解不了解,反正 Redisson 就是提供了一堆锁… 也是目前大部分公司使用 Redis 分布式锁最常用的一种方式。
本文中 Redisson 分布式锁的实现是基于 RLock 接口,而 RLock 锁接口实现源码主要是 RedissonLock 这个类,而源码中加锁、释放锁等操作都是使用 Lua 脚本来完成的,并且封装的非常完善,开箱即用。
接下来主要以 Redisson 实现 RLock 可重入锁为主。
源码地址:GitHub - niceyoo/redis-redlock: redis分布式锁之redlock应用篇
官网介绍
入门整合
和Jedis以及RedisTemplate-样,Redisson其实也是redis的一个客户端
Redisson里面封装了很多有用的api和功能实现,非常实用,当然也包含了分布式锁。Jedis这样的客户端仅仅只是把提供了客户端调用,很多功能其实需要自己去实现封装的。Redisson所提供的是实用redis最简单最便捷的方法,Redisson的宗旨也是让我们使用者关注业务本身,而不是要更关注redis,要把redis这块分离,使得我们的精力更加集中于业务上。
Redisson内部结合实用了LUA脚本实现了分布式锁,并且可以对其做到续约释放等各项功能,非常完善。当然也包含了gc里面的一些锁,JC里面的只能在本地实现,集群分布式下则失效,如果要使用则可以使用Redisson提供的工具来实现锁就行了。
上面的代码其实就是设计为可重入锁,不多整述,简单来讲,就是方法运行,可以多次使用同一把锁。或者说一个线程在不释放的情况下可以获得锁多次,不过在释放的时候也需要释放多次。(有兴趣课后建议去学习一下gc相关内容)
测试
apipost测试接口最终结果的顺序即可
Redisson 分布式锁测试
测试
1.拔电源测试会否解锁
2.自动续期测试(看门狗)
3.lock设置自定义时间,比如15秒,超时是否自动续期(无看门狗)
4. 测试可重入锁(用同一把锁):重入2次,释放2次