单服务并发出票实践原创
一、背景
项目组上线集团客运站系统,该集团旗下六家客运站,每日出票量在1万~2万张左右(疫情期间票量下滑)。前期系统只能部署在一台单服务器上,应用程序与数据库(mysql)同服。
客运站出票业务有两大特点,一是出票渠道分散(人口窗口、自助设备、线上各分销渠道均支持出票);二是出票时间集中,全天出票量集中在上午几个小时之间。同时集团管理员对票号规则作出限制:从00000001开始自增。(有的客运站是以税票单证为票号or虚拟含一定意义的随机票号)。
1.1 分析
票号从00000001递增,首先需要做递增值的持久化,在mysql数据库表中做存储;其次不能重复,不能出现两张或多张票号一样的车票;最后凭经验,客运站出票速度一定要快!!(售票员出票和银行柜员点钱速度差不多(•◡•))。
二、准备环境
2.1 准备测试工具
工具 | 属性 |
---|---|
服务器(普通办公电脑) | Intel® Core™ i5-4460 CPU @ 3.20GHz 、 DDR4 @ 32G 2400Mhz 、HDD 机械硬盘 1T |
压测工具 | Apache JMeter |
2.2 jmeter配置
2.2.1 http接口配置
2.2.2 基础线程组配置
基础线程组的配置:50个线程,好比50个客运站售票员,启动这些线程的时间为1秒,循环1次。
三、编程测试
3.1 交给数据库
票号存在mysql数据中表中,程序从数据库读取,再加1更新。
3.1.1 伪代码
3.1.2 测试结果
当起始票号为1时,50线程并发下取得大量的重复票号。
3.2 悲观锁
在上述代码的基础上,采用synchronized悲观锁,锁this对象。
3.2.1 伪代码
3.2.2 测试结果
起始票号为0,50线程并发下,没有出现任一重复票号!但是,观察发现,因悲观锁串行现象,导致其中一个最慢的线程,花费了4.3秒才获取票号,用户体验感极差。
3.3 乐观锁
修改代码,改用versionNumber版本号,避免ABA问题,且为了提高成功率采用重试机制(这里两种重试机制,一是限制次数重试;二是无限重试直到成功为止。)
3.3.1 伪代码 - 限制次数重试
3.3.2 update语句
3.3.3 伪代码 - 无限重试,直至全部线程成功
/**
* 递增(票号) - 版本号 - 无限重试直至成功
* @param
* @author cll
* @date 2022/6/16
* @return 递增数字
* */
@Override
public String testVersionNumber() throws Exception{
while (true){
//乐观锁重试机制,每次重试前随机随眠 1-100 毫秒
Thread.sleep((int)(Math.random()*100 + 1));
VirtualNumberGenerateDto newDto = getNumByMysqlDB();
AtomicInteger newNum = new AtomicInteger(Integer.valueOf(newDto.getNum()) + 1);
if(1 == virtualNumberGenerateExMapper.updateNumByIdAndVersionNum(newNum.toString(),
newDto.getId(),newDto.getVersionNum())){
System.out.println(
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS")
.format(new Date())+
" 线程"+Thread.currentThread()
.getName()+"=======>num:"+newNum.toString());
return newNum.toString();
}
}
}
3.3.4 测试效果
起始票号为0,50线程并发下,没有出现任一重复票号!但是版本号受限于数据库性能,重试多次方可成功。(测试过程中,最长的一次线程需要10s才能获取票号)
1、每次只有一个线程能更新成功;
2、mysql数据存在磁盘,会存在IO开销;
3.3.5 问题思考
注意,代码缺少Transactional,使用数据库默认的"可重复读"事务隔离级别,乐观锁重试机制是否受Transactional影响?
四、实践方案
单服务应用下既要解决ABA问题,又要提高成功率。还是想到了redis,其数据存储于内存上,具备多路复用技术,使用单线程的increment递增方法。
4.1.1 伪代码
/**
* 测试redis单线程获取虚拟票号
* @author: cll
* @date : 2022/6/16
* @Document 利用redis速度快的特点,多线程并发获取虚拟票号,且递增
* 注意:需要定期将redis数据,持久化到mysql数据库表中
* @return
* */
@Override
public String testTicketRedis() {
VirtualNumberGenerateDto oldNum = getNumByMysqlDB();
double ticketSum = jedisTemplate.opsForValue().increment(oldNum.getSerial() + ":ticketnum", 1);
if (ticketSum >= 0) {
System.out.println(
new SimpleDateFormat("yyyy-MM-dd HH:mm:ss:SSS")
.format(new Date())+
" 线程"+Thread.currentThread()
.getName()+"=======>num:"+String.valueOf(ticketSum));
return String.valueOf(ticketSum);
} else {
throw new ServiceException(ResultUtils.createFailResult(Module.TICKET, MessageConstant.GET_VIRTUAL_NUMBER_FAIL));
}
}
4.1.2 测试效果
起始票号为0,50线程并发下,在1秒内所有线程获取到递增票号,不重复、速度快。
4.1.3 增加线程组配置
起始票号为0,200个线程同时并发请求,在3秒内获取到1-200个不重复票号!!生产上的QPS经过cat监控发现,不超过20个,说明该模式已经达到生产要求。
4.1.4 注意事项
采用了上述方案,则会带来新问题。1、出现了热点key,难免出现缓存击穿问题;2、redis与mysql的数据同步问题。
对于第一个问题,设置该热点key永不过期;
第二个问题,则需要定时同步redis数据至mysql数据库,做持久化管理;同时防止极端情况,单节点的redis挂掉(本项目redis无主从、无集群、无哨兵),则利用业务代码功能补足该缺陷。(最后的流程图做介绍)