性能文章>单服务并发出票实践>

单服务并发出票实践原创

1年前
4694810

一、背景

 项目组上线集团客运站系统,该集团旗下六家客运站,每日出票量在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接口配置

1.png

2.2.2 基础线程组配置

2.png
 基础线程组的配置:50个线程,好比50个客运站售票员,启动这些线程的时间为1秒,循环1次。

三、编程测试

3.1 交给数据库

 票号存在mysql数据中表中,程序从数据库读取,再加1更新。

3.1.1 伪代码

3.png

3.1.2 测试结果

4.png
 当起始票号为1时,50线程并发下取得大量的重复票号。

3.2 悲观锁

 在上述代码的基础上,采用synchronized悲观锁,锁this对象。

3.2.1 伪代码

5.png

3.2.2 测试结果

6.png
 起始票号为0,50线程并发下,没有出现任一重复票号!但是,观察发现,因悲观锁串行现象,导致其中一个最慢的线程,花费了4.3秒才获取票号,用户体验感极差。

3.3 乐观锁

 修改代码,改用versionNumber版本号,避免ABA问题,且为了提高成功率采用重试机制(这里两种重试机制,一是限制次数重试;二是无限重试直到成功为止。)

3.3.1 伪代码 - 限制次数重试

7.png

3.3.2 update语句

8.png

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 测试效果

9.png
 起始票号为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 测试效果

10.png
 起始票号为0,50线程并发下,在1秒内所有线程获取到递增票号,不重复、速度快。

4.1.3 增加线程组配置

11.png
 起始票号为0,200个线程同时并发请求,在3秒内获取到1-200个不重复票号!!生产上的QPS经过cat监控发现,不超过20个,说明该模式已经达到生产要求。

4.1.4 注意事项

 采用了上述方案,则会带来新问题。1、出现了热点key,难免出现缓存击穿问题;2、redis与mysql的数据同步问题。
 对于第一个问题,设置该热点key永不过期;
 第二个问题,则需要定时同步redis数据至mysql数据库,做持久化管理;同时防止极端情况,单节点的redis挂掉(本项目redis无主从、无集群、无哨兵),则利用业务代码功能补足该缺陷。(最后的流程图做介绍)

4.2.1 伪代码

12.png

五、整体流程图

13.png

点赞收藏
站务精英

特长

请先登录,查看8条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步

为你推荐

JDBC PreparedStatement 字段值为null导致TBase带宽飙升的案例分析

JDBC PreparedStatement 字段值为null导致TBase带宽飙升的案例分析

随机一门技术分享之Netty

随机一门技术分享之Netty

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

MappedByteBuffer VS FileChannel:从内核层面对比两者的性能差异

10
8