文章目录
一. 测试工具
测试工具:JMeter 参考文档
5.6.3版本下载链接:下载链接
二. 超卖现象演示
以一等座票为例,设置有8张票,此时开启500个线程同时购票:
两秒内请求全部发送完成。此时查看数据库车票表:
显示为负数,超卖了55张!!
三. 原因分析
问题代码段:
// 查出余票记录,需要得到真实的库存
DailyTrainTicket dailyTrainTicket = dailyTrainTicketService.selectByUnique(date, trainCode, start, end);
LOG.info("查出余票记录:{}", dailyTrainTicket);
// 扣减余票数量,并判断余票是否足够
reduceTickets(req, dailyTrainTicket);
若某时刻余票为1,如果这时候有多个线程并发进入到这里,那么它们同时查数据库,均可查到有余票1,并且都能校验为余票足够,从而执行下面的购票逻辑
因此为了避免这样的问题产生,应该对这一段代码加锁,限制并发访问
四. 解决办法
方法一:加synchronized锁
1. 单个服务节点情况
在上述问题代码段产生的方法上加上synchronized
public synchronized void doConfirm(ConfirmOrderDoReq req) {
再次执行500个线程同时购票:
优点:可以看到,此时成功解决超卖问题,也没有重卖
不足:由于上锁,相当于是一个个的执行,会导致售票效率降低!响应较慢!(线程花了十多秒才结束)
2. 增加服务器节点,分布式环境synchronized失效演示
增加一个服务节点,模拟分布式环境,再次测试
可以看到,超卖了一张票。
因此,分布式环境下,synchronized会失效! 只能解决单机锁问题,不能解决多机锁问题
方法二:使用Redis分布式锁锁解决超卖问题
1. 添加Redis分布式锁
在问题代码段的方法中,添加Redis分布式锁:
public void doConfirm(ConfirmOrderDoReq req) {
String key = req.getDate() + "-" + req.getTrainCode();
// 设置超时时间为5秒
Boolean setIfAbsent = redisTemplate.opsForValue().setIfAbsent(key, key, 5, TimeUnit.SECONDS);
if (Boolean.TRUE.equals(setIfAbsent)) {
LOG.info("恭喜,抢到锁了!");
} else {
// 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试
LOG.info("很遗憾,没抢到锁!");
throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL);
}
try{
.
.
.
}finally{
LOG.info("购票流程结束,释放锁!lockKey:{}", lockKey);
redisTemplate.delete(lockKey);
LOG.info("购票流程结束,释放锁!");
}
}
将key设置为日期和车次的拼接,意为只有同一日期同一车次之间进行上锁
2. 结果
解决超卖问题,但有时还会剩票没卖完。因此redis分布式锁也会导致效率降低,同时,由于设置了timeout为5秒,若购票业务或者其他业务耗时超过5秒,则此时会提前释放锁,造成并发问题。
方法三:使用Redisson看门狗解决锁超时的问题
由于redis分布式锁存在业务结束时间比设置的超时时间长的问题,因此可以引入守护线程,在一定时间的时候,延长超时时间,同时守护线程会随着主线程终止而终止,也不会无限延长终止时间
1. 添加Redisson依赖
<!--至少3.18.0版本,才支持spring boot 3-->
<!--升级到3.20.0,否则打包生产会报错:Could not initialize class org.redisson.spring.data.connection.RedissonConnection-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.21.0</version>
</dependency>
2. 使用Redisson看门狗
修改上锁与解锁内容,使用Redission自带的方法实现看门狗:
public void doConfirm(ConfirmOrderDoReq req) {
// 获取分布式锁
String lockKey = RedisKeyPreEnum.CONFIRM_ORDER + "-" + DateUtil.formatDate(req.getDate()) + "-" + req.getTrainCode();
RLock lock = null;
try {
// 使用redisson,自带看门狗
lock = redissonClient.getLock(lockKey);
/**
waitTime – the maximum time to acquire the lock 等待获取锁时间(最大尝试获得锁的时间),超时返回false
leaseTime – lease time 锁时长,即n秒后自动释放锁
time unit – time unit 时间单位
*/
// boolean tryLock = lock.tryLock(30, 10, TimeUnit.SECONDS); // 不带看门狗
boolean tryLock = lock.tryLock(0, TimeUnit.SECONDS); // 带看门狗
if (tryLock) {
LOG.info("恭喜,抢到锁了!");
// 可以把下面这段放开,只用一个线程来测试,看看redisson的看门狗效果
// for (int i = 0; i < 30; i++) {
// Long expire = redisTemplate.opsForValue().getOperations().getExpire(lockKey);
// LOG.info("锁过期时间还有:{}", expire);
// Thread.sleep(1000);
// }
} else {
// 只是没抢到锁,并不知道票抢完了没,所以提示稍候再试
LOG.info("很遗憾,没抢到锁");
throw new BusinessException(BusinessExceptionEnum.CONFIRM_ORDER_LOCK_FAIL);
}
// 业务逻辑
.
.
.
}catch (InterruptedException e){
LOG.error("购票流程异常", e);
}finally {
LOG.info("购票流程结束,释放锁!");
if (null != lock && lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
}
3. 结果
设置多线程进行模拟抢票,实验多次后,发现第一次抢票始终是会还剩下一些票,多次抢票后票数为0,且不会出现超卖。
方法四:Redis红锁解决单机Redis宕机问题
以上带看门狗的锁存在以下问题:
如果Redis节点宕机挂了,同时又有一个线程拿到了某个锁,那么此时如果Redis节点自动切换,又一个线程进来就有可能拿到同一个锁,因为此时Redis中没有该锁的key,违反了锁的互斥性,就会出现一些问题。
此时就可以利用Redis红锁来解决单机Redis宕机问题
Redis红锁用于Redis多节点情况。通常设有奇数个节点的Redis服务,每个线程在进入上锁代码段时,只有拿到半数以上的Redis的锁,才算拿到锁。
例如:
有 A B C D E 五个节点的Redis服务
线程1:拿到ABC
线程2:拿到DE
则此时仅算线程1拿到锁,线程二没拿到锁