分布式锁解决(幂等性校验、同一用户只能报名一次)
项目背景
刚学分布式锁的时候,看到网上基本上99%的人都是在围绕着订单业务来展开说的,基本上每 10 个人,里面有 9 个人都是拿分布式锁和订单业务挂钩,我也是不好说啥了,随着工作经验的丰富,在使用分布式锁上也有一点自己的经验,于是今天用大白话来总结下这块,业务不算复杂,但是却能让大家更好的去理解运用好分布式锁,之前再写一个招考的项目的时候,里面有个报名的功能,由于项目性质的特殊,这个项目是给所有报名那些事业单位人用的,报名接口一旦出现,重复报名、漏报这些情况出现。不是赔点损失就能解决的,可能会引发一系列法律责任到自己头上。不敢吊儿郎当,由于报名时间都是由商务部在官网提前公布出去的,一个事业编不知道有多少人想上岸,而报名的流量也基本都集中在,报名的第一天。也类似于秒杀。
分布式锁应用场景
就我现在这家公司而言,用分布式锁用的最多的场景如下
- 各种报名(防止重复提交、例如:分布式会议室,同一时间段会议室只能被预定成功一次、做幂等性校验等)
- 各种申报材料的幂等性校验
其实用法都大差不差。可能很多人一想到分布式锁脑子里就一堆什么商品秒杀、订单支付、扣减库存相关的逻辑。其实我感觉业务都是相通的。
分布式锁技术选型
本文我就挑一个我之前实现过的:(分布式会议室同一时间段会议室只能被预定成功一次)这个需求展开来说分布式锁使用上的一些注意点吧。至于技术实现上有 Jedis、RedisTemplate、Redisson, 不要犹豫直接上 Redisson 就好了,让你使用 setnx 命令的都是耍流氓,造轮子归造轮子,开发归开发出了事情背锅的还是你,不要拿自己的职业生涯开玩笑,尤其像我之前的那个招考报名需求,一不小心就要被请进局子喝茶。用现成的轮子就好了,至于原理后续我会专门出一篇博客讲 Redisson 原理。
先来看效果
模拟 100 个不同的用户,且每个用户模拟手滑都重复点击 5 次预约,顺时 500 并发,进行预约同一时间段同一会议室的会议,会议预约记录表中只有一个人能预约成功。
csv 文件,对应请求参数中的 uid
数据库会议预约记录生成结果如下,只有一个人预约到了会议。
分布式预约会议实现需考虑的点
本身业务并不复杂,逻辑就俩点
- 查会议室该时间段内是否已有会议预约记录
- 查到已有预约记录,则提示用户预约时间冲突,无预约记录则预约成功
但是当流量激增的情况下,需要考虑
- 如何实现一个用户只能预约一次该时间段的会议?简而言之就是 如何防止同一用户重复预约?
- 如何保证一个时间段内的会议,只能被一个用户预约成功? 简而言之就是大家更容易理解的超卖。感觉各种业务的场景都差不多 ,无非我这里会议库存是 1,订单业务的库存大于 1 而已。
分布式预约会议实现代码
最终实现代码如下,代码很简单,为了方便演示效果,代码已脱敏。接下来说说
超卖问题,如何进行防重,是怎么实现的,以及举一反三,大家关心的秒杀又是怎样去实现的
@Override
public int check(TabMeet tabMeet) {
return tabMeetMapper.check(tabMeet);
}
@Override
public Object meetAdd(TabMeet tabMeet) throws InterruptedException {
Assert.isTrue(StringUtils.isNotEmpty(tabMeet.getUid()), "登录过期");
Assert.isTrue(StringUtils.isNotEmpty(tabMeet.getRoomid()), "请选择需要预定的会议室");
Assert.isTrue(tabMeet.getEndtime().after(tabMeet.getStarttime()), "会议预约结束时间不能小于开始时间");
Assert.isTrue(new Date().before(tabMeet.getStarttime()), "会议预约时间不能小于当前时间");
Assert.isTrue(new Date().before(tabMeet.getEndtime()), "会议预约时间不能小于当前时间");
RLock userLock = redisson.getLock(tabMeet.getUid() + tabMeet.getRoomid());
RLock roomlock = redisson.getLock(tabMeet.getRoomid());
Assert.isTrue(userLock.tryLock(0, 5, TimeUnit.SECONDS), "系统正在处理中!请勿重复点击");//获取不到锁立即返回 false,加锁成功的线程,锁 5s 后自动释放
boolean roomLock = roomlock.tryLock(3, 3, TimeUnit.SECONDS);//3s 内都在自旋尝试获取锁,锁超过 3 秒钟自动释放
Assert.isTrue(roomLock, "预约人数过多,请稍后重试");
try {
tabMeetService.doMeetAdd(tabMeet);
} catch (Exception e) {
unLock(userLock);
unLock(roomlock);
throw e;
} finally {
unLock(userLock);
unLock(roomlock);
}
return true;
}
@Transactional(propagation = Propagation.REQUIRED,
rollbackFor = Exception.class)
public void doMeetAdd(TabMeet tabMeet) throws InterruptedException {
Assert.isTrue(this.check(tabMeet) < 1, "预约时间冲突!");
this.save(TabMeet.builder()
.uid(tabMeet.getUid())
.roomid(tabMeet.getRoomid())
.starttime(tabMeet.getStarttime())
.endtime(tabMeet.getEndtime())
.build());
}
//锁没被释放且是本线程的锁,才能被解锁
public void unLock(RLock lock) {
if (lock.isLocked() && lock.isHeldByCurrentThread()) lock.unlock();
}
防止同一用户并发多次预约会议解决
用户 id + 会议室 id 作为分布式锁的 key,当同一用户多次请求过来,只有加锁成功的那次请求能走下面的逻辑,其他请求直接返给用户提示(系统正在处理中!请勿重复点击)
RLock lock = redisson.getLock(tabMeet.getUid() + tabMeet.getRoomid());
或者利用Redis 原子性自增实现,和用分布式锁实现机制都一样,限制同一用户点击的频率,但是要合理评估业务执行时间,合理设置 key 超时时间
private boolean validateApi(TabMeet req) {
String key = req.getUid() + "_" + req.getRoomid();
if (incr(key, 2L) > 1) {
return false;
}
return true;
}
private long incr(String key, long expireTime) {
long next = new RedisAtomicLong(key, redisTemplate.getConnectionFactory()).incrementAndGet();
redisTemplate.expire(key, expireTime, TimeUnit.SECONDS);
return next;
}
防止会议室超卖
为了保证,查会议室某时间段内的预约记录、与生成会议室预约记录是原子性的。又加了一把分布式锁,key 是会议室 id,当并发预约会议的时候,由于查记录、生成记录是原子性的。因此只能有一个人能预约成功。其他人好不容易竞争到锁后,发现会议室该时间段内已经有别人的预约记录了,直接收到(预约时间冲突)的提示。从而整个需求就这样实现了。查记录、生成记录如果是非原子性的,当出现预约记录入库突然耗时增加的情况,那么会出现,多人查到该时间段内没有预约记录,都去生成预约记录进行入库,导致多人都预约成功的情况出现,严重BUG!
代码可能存在的问题
你以为这就完了吗,😢其实上面的代码我留了个心眼子,就是存在一个概率很小,但是确实又会发生的问题,不知道读者看出来没有。
当被加锁的业务代码很耗时, 假设业务代码要耗时5s,这个时候来了线程 1、线程 2 同时来竞争锁,由于此时配置的是,锁超过 3s 就会自动释放,当线程 1 拿到锁后,业务代码执行到第3秒的时候,锁已经被自动释放了,此时线程 2 紧接着在第4 s的,来到 doMeetAdd 方法里面,发现该时间段会议室可以被预约,第 5 s 的时候,线程 1 已经成功预约到会议了,第 9s 的时候线程 2 也预约到会议了,就会存在多人重复预约会议的情况出现。
业务代码耗时长,复现问题
于是我在 doMeetAdd 方法里面加了点耗时,加了个 Thread.sleep(1000 * 5);
接着进行压测一下(模拟了 100 用户,每个用户重复预约5 次,相当于 500 并发)
数据库出现了 2 条同一时间段内的预约记录
出现的原因如下
当被加锁的业务代码很耗时, 假设业务代码要耗时5s,这个时候来了线程 1、线程 2 同时来竞争锁,由于此时配置的是,锁超过 3s 就会自动释放,当线程 1 拿到锁后,业务代码执行到第3秒的时候,锁已经被自动释放了,此时线程 2 紧接着在第4 s的,来到 doMeetAdd 方法里面,发现该时间段会议室可以被预约,第 5 s 的时候,线程 1 已经成功预约到会议了,第 9s 的时候线程 2 也预约到会议了,就会存在多人重复预约会议的情况出现。
业务代码耗时很长(解决方案)
- 合理的设置锁过期自动释放时间,评估一下业务代码耗时情况,在这个基础上,增加几秒钟,即为锁过期自动释放时间
- 放弃使用 tryLock,改用 lock()方法加锁,各位自己根据自己业务来。用 Redisson 的 lock()方法加锁,当业务代码一直没执行完成,Redisson 会每过 10s 为锁续期,续到 30s, 如果没有显示的进行解锁,将会造成死锁。一旦死锁,将会导致该会议室所有时间段所有人都不可预约。但是有人可能很好奇,业务代码怎么可能会一直卡在那。
对于:什么场景下业务代码会一直卡在那,从而造成死锁。感兴趣的读者可以点击下方链接,里面的附页我列举了几个案例,大家可以去了解一下。
mybatis 、ThreadPoolExecutor 导致的 oom 源码角度分析以及解决方案
最终分布式会议室同一时间段只能被预约一次的代码如下,用的 用户 id+会议室 id 作为 key 防止用户重复点击,用的 会议室 id 作为 key 会议被多人预约。小小几行代码,大家需要注意的是, 需要 tabMeetService.doMeetAdd(tabMeet); 进行事务方法调用,而不是 this.doMeetAdd(tabMeet);。不然事务会失效,至于为什么会失效,请点击我主页,翻阅事务失效连载文章,里面有对失效场景的详细介绍,以及对应的视频讲解。
@Override
public int check(TabMeet tabMeet) {
return tabMeetMapper.check(tabMeet);
}
@Override
public Object meetAdd(TabMeet tabMeet) throws InterruptedException {
Assert.isTrue(StringUtils.isNotEmpty(tabMeet.getUid()), "登录过期");
Assert.isTrue(StringUtils.isNotEmpty(tabMeet.getRoomid()), "请选择需要预定的会议室");
Assert.isTrue(tabMeet.getEndtime().after(tabMeet.getStarttime()), "会议预约结束时间不能小于开始时间");
Assert.isTrue(new Date().before(tabMeet.getStarttime()), "会议预约时间不能小于当前时间");
Assert.isTrue(new Date().before(tabMeet.getEndtime()), "会议预约时间不能小于当前时间");
RLock userLock = redisson.getLock(tabMeet.getUid() + tabMeet.getRoomid());
RLock roomlock = redisson.getLock(tabMeet.getRoomid());
Assert.isTrue(userLock.tryLock(0, 5, TimeUnit.SECONDS), "系统正在处理中!请勿重复点击");//获取不到锁立即返回 false,加锁成功的线程,锁 5s 后自动释放
boolean roomLock = roomlock.tryLock(3, 3, TimeUnit.SECONDS);//3s 内都在自旋尝试获取锁,锁超过 3 秒钟自动释放
Assert.isTrue(roomLock, "预约人数过多,请稍后重试");
try {
tabMeetService.doMeetAdd(tabMeet);
} catch (Exception e) {
unLock(userLock);
unLock(roomlock);
throw e;
} finally {
unLock(userLock);
unLock(roomlock);
}
return true;
}
@Transactional(propagation = Propagation.REQUIRED,
rollbackFor = Exception.class)
public void doMeetAdd(TabMeet tabMeet) throws InterruptedException {
int ct = this.check(tabMeet);
log.info("用户:{},会议是否冲突:{}", tabMeet.getUid(), ct);
Assert.isTrue(ct < 1, "预约时间冲突!");
this.save(TabMeet.builder()
.uid(tabMeet.getUid())
.roomid(tabMeet.getRoomid())
.starttime(tabMeet.getStarttime())
.endtime(tabMeet.getEndtime())
.build());
}
//锁没被释放且是本线程的锁,才能被解锁
public void unLock(RLock lock) {
if (lock.isLocked() && lock.isHeldByCurrentThread()) lock.unlock();
}
合理运用 tryLock、lock
9s内自旋持续尝试去获取锁,一旦获取到锁立即返回 true。如果后续没有解锁操作,每隔 10s,自动锁续命到 30s
Assert.isTrue(userLock.tryLock(9, TimeUnit.SECONDS), "系统正在处理中!请勿重复点击");
利用 TimeTask 定时器,每隔 10 s 递归续命,对应 Redisson 源码如下
3s 内都在自旋尝试获取锁,锁超过 3 秒钟自动释放
boolean roomLock = roomlock.tryLock(3, 3, TimeUnit.SECONDS);
对应源码解读
举一反三(电商业务中的秒杀如何实现?限量抵扣卷)
我感觉业务上都大差不差的,需要注意一下俩点
- 防止同一用户多次抢购,同一优惠卷
- 查库存、减库存。这一步要保证原子性,防止优惠卷超卖
针对第一点(防止同一用户多次抢购,同一优惠卷),对优惠卷 id+用户 id 作为分布式锁的 key,做个限流就好了。
针对第二点(如何防止超卖?),分俩种情况来说,一种查库存走的是数据库,一种库存是放到 Redis 中。 库存放到 redis 中,查库存、减库存这一步,写个 lua 脚本保证是原子性即可 , 库存放到数据库中直接(假设是 Mysql),结合@Transactional+悲观锁实现即可,就是查库存使用悲观锁(for update)为优惠卷库存记录加上一把行锁。假设现在有优惠卷 id 为 1,线程 a 先来悲观锁查库存,后续执行减库存、生成订单逻辑,但是事务还没提交,此时 线程 b 并发过来抢优惠卷,也是悲观锁查库存,由于优惠卷 id 为1的这行数据被锁了。线程 b 悲观查库存会被一直阻塞。一直等线程 a,走完抢优惠卷的整个逻辑,行锁才会被释放,线程 b 才能走后续的流程,保证了整个流程的原子性。实现也很简单,需要注意一下锁释放时机、以及事务回滚。
往深了更细的讲,我还可以扯到Mysql 事务隔离级别(四大隔离级别)、Mysql 存储引擎,如果你是Mysql 用的是Isam 引擎,事务这一说都不存在了,因为 Isam 引擎都不支持事务。
编码细节
要注意锁的力度,放到事务方法外面,不然可能导致,用户a 预约成功会议,但是事务还没有提交,此时用户 b 再来预约会议,发现没有会议预约记录,导致用户 a、b 都预约成功!!!
此外要注意在使用 @Transactional 的时候,要通过 tabMeetService.doMeetAdd(tabMeet); 的方式调用事务方法,而不是 this.doMeetAdd(tabMeet);调用不然事务会失效
小咸鱼的技术窝
关注不迷路,分享更多技术干货B站、CSDN、微信公众号都是(小咸鱼的技术窝),更多详情在主页