背景:
现有编码格式为业务常量+数字,每新增一条数据在基础上+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相关内容)
Redisson 支持单点模式、主从模式、哨兵模式、集群模式,这里以单点模式为例,redisson这个框架重度依赖了Lua脚本和Netty,代码很牛逼,各种Future及FutureListener的异步、同步操作转换。
测试
apipost测试接口最终结果的顺序即可
Redisson 分布式锁测试
测试
1.拔电源测试会否解锁
2.自动续期测试(看门狗)
3.lock设置自定义时间,比如15秒,超时是否自动续期(无看门狗)
4. 测试可重入锁(用同一把锁):重入2次,释放2次
扩展知识
常见分布式锁方案对比
分类 | 方案 | 实现原理 | 优点 | 缺点 |
基于数据库 | 基于mysql 表唯一索引 | 1.表增加唯一索引 2.加锁:执行insert语句,若报错,则表明加锁失败 3.解锁:执行delete语句 | 完全利用DB现有能力,实现简单 | 1.锁无超时自动失效机制,有死锁风险 2.不支持锁重入,不支持阻塞等待 3.操作数据库开销大,性能不高 |
基于MongoDB findAndModify原子操作 | 1.加锁:执行findAndModify原子命令查找document,若不存在则新增 2.解锁:删除document | 实现也很容易,较基于MySQL唯一索引的方案,性能要好很多 | 1.大部分公司数据库用MySQL,可能缺乏相应的MongoDB运维、开发人员 2.锁无超时自动失效机制 | |
基于分布式协调系统 | 基于ZooKeeper | 1.加锁:在/lock目录下创建临时有序节点,判断创建的节点序号是否最小。若是,则表示获取到锁;否,则则watch /lock目录下序号比自身小的前一个节点 2.解锁:删除节点 | 1.由zk保障系统高可用 2.Curator框架已原生支持系列分布式锁命令,使用简单 | 需单独维护一套zk集群,维保成本高 |
基于缓存 | 基于redis命令 | 1. 加锁:执行setnx,若成功再执行expire添加过期时间 2. 解锁:执行delete命令 | 实现简单,相比数据库和分布式系统的实现,该方案最轻,性能最好 | 1.setnx和expire分2步执行,非原子操作;若setnx执行成功,但expire执行失败,就可能出现死锁 2.delete命令存在误删除非当前线程持有的锁的可能 3.不支持阻塞等待、不可重入 |
基于redis Lua脚本能力 | 1. 加锁:执行SET lock_name random_value EX seconds NX 命令 2. 解锁:执行Lua脚本,释放锁时验证random_value -- ARGV[1]为random_value, KEYS[1]为lock_name if redis.call("get", KEYS[1]) == ARGV[1] then return redis.call("del",KEYS[1]) else return 0 end | 同上;实现逻辑上也更严谨,除了单点问题,生产环境采用用这种方案,问题也不大。 | 不支持锁重入,不支持阻塞等待 |
表格中对比了几种常见的方案,redis+lua基本可应付工作中分布式锁的需求。然而,当偶然看到redisson分布式锁实现方案(传送门),相比以上方案,redisson保持了简单易用、支持锁重入、支持阻塞等待、Lua脚本原子操作,不禁佩服作者精巧的构思和高超的编码能力。下面就来学习下redisson这个牛逼框架,是怎么实现的。
分布式锁需满足四个条件
首先,为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
- 互斥性。在任意时刻,只有一个客户端能持有锁。
- 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
- 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了,即不能误解锁。
- 具有容错性。只要大多数Redis节点正常运行,客户端就能够获取和释放锁。
redisson加锁&解锁Lua脚本
加锁、解锁Lua脚本是redisson分布式锁实现最重要的组成部分。首先不看代码,先研究下Lua脚本都是什么逻辑
1、加锁Lua脚本
- 脚本入参
参数 | 示例值 | 含义 |
KEY个数 | 1 | KEY个数 |
KEYS[1] | my_first_lock_name | 锁名 |
ARGV[1] | 60000 | 持有锁的有效时间:毫秒 |
ARGV[2] | 58c62432-bb74-4d14-8a00-9908cc8b828f:1 | 唯一标识:获取锁时set的唯一值,实现上为redisson客户端ID(UUID)+线程ID |
- 脚本内容
-
-- 若锁不存在:则新增锁,并设置锁重入计数为1、设置锁过期时间
-
if (redis.call('exists', KEYS[1]) == 0) then
-
redis.call('hset', KEYS[1], ARGV[2], 1);
-
redis.call('pexpire', KEYS[1], ARGV[1]);
-
return nil;
-
end;
-
-- 若锁存在,且唯一标识也匹配:则表明当前加锁请求为锁重入请求,故锁重入计数+1,并再次设置锁过期时间
-
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]);
- 脚本解读
Q:返回nil、返回剩余过期时间有什么目的?
A:当且仅当返回nil,才表示加锁成功;客户端需要感知加锁是否成功的结果
2、解锁Lua脚本
- 脚本入参
参数 | 示例值 | 含义 |
KEY个数 | 2 | KEY个数 |
KEYS[1] | my_first_lock_name | 锁名 |
KEYS[2] | redisson_lock__channel:{my_first_lock_name} | 解锁消息PubSub频道 |
ARGV[1] | 0 | redisson定义0表示解锁消息 |
ARGV[2] | 30000 | 设置锁的过期时间;默认值30秒 |
ARGV[3] | 58c62432-bb74-4d14-8a00-9908cc8b828f:1 | 唯一标识;同加锁流程 |
- 脚本内容
-
-- 若锁不存在:则直接广播解锁消息,并返回1
-
if (redis.call('exists', KEYS[1]) == 0) then
-
redis.call('publish', KEYS[2], ARGV[1]);
-
return 1;
-
end;
-
-- 若锁存在,但唯一标识不匹配:则表明锁被其他线程占用,当前线程不允许解锁其他线程持有的锁
-
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
-
return nil;
-
end;
-
-- 若锁存在,且唯一标识匹配:则先将锁重入计数减1
-
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
-
if (counter > 0) then
-
-- 锁重入计数减1后还大于0:表明当前线程持有的锁还有重入,不能进行锁删除操作,但可以友好地帮忙设置下过期时期
-
redis.call('pexpire', KEYS[1], ARGV[2]);
-
return 0;
-
else
-
-- 锁重入计数已为0:间接表明锁已释放了。直接删除掉锁,并广播解锁消息,去唤醒那些争抢过锁但还处于阻塞中的线程
-
redis.call('del', KEYS[1]);
-
redis.call('publish', KEYS[2], ARGV[1]);
-
return 1;
-
end;
-
return nil;
- 脚本解读
Q1:广播解锁消息有什么用?
A:是为了通知其他争抢锁阻塞住的线程,从阻塞中解除,并再次去争抢锁。
Q2:返回值0、1、nil有什么不一样?
A:当且仅当返回1,才表示当前请求真正触发了解锁Lua脚本;但客户端又并不关心解锁请求的返回值,好像没什么用?