Java秒杀系统(十二):JMeter压力测试重现秒杀场景中超卖等问题

作者: 修罗debug
版权声明:本文为博主原创文章,遵循 CC 4.0 by-sa 版权协议,转载请附上原文出处链接和本声明。



摘要:本篇博文是“Java秒杀系统实战系列文章”的第十二篇,本篇博文我们将借助压力测试工具Jmeter重现秒杀场景(高并发场景)下出现的各种典型的问题,其中最为经典的当属“商品库存超卖”的问题,在本文我们重现这种问题,并对问题进行分析!

内容:一个正规的、声称能承受高并发请求的系统的背后应该经历了一些不为人知的经历,这个秒杀系统也是如此,一般而言,这些经历都是比较残酷的,在本文中我们将重现出这样的经历!即采用压力测试工具Jmeter压测这个秒杀系统的“秒杀接口”!

在进入秒杀压测环节前,我们将之前的“接收前端用户的秒杀请求对应的控制器方法”复制一份,用于给JMeter压测使用,即在KillController中复制出一个新的“执行秒杀请求”的方法,其代码如下所示:

//商品秒杀核心业务逻辑-用于压力测试
@RequestMapping(value = prefix+"/execute/lock",method = RequestMethod.POST,consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public BaseResponse executeLock(@RequestBody @Validated KillDto dto, BindingResult result){
if (result.hasErrors() || dto.getKillId()<=0){
return new BaseResponse(StatusCode.InvalidParams);
}
BaseResponse response=new BaseResponse(StatusCode.Success);
try {
//不加分布式锁的前提
Boolean res=killService.killItem(dto.getKillId(),dto.getUserId());
if (!res){
return new BaseResponse(StatusCode.Fail.getCode(),"不加分布式锁-哈哈~商品已抢购完毕或者不在抢购时间段哦!");
}
}catch (Exception e){
response=new BaseResponse(StatusCode.Fail.getCode(),e.getMessage());
}
return response;
}

之后,我们便可以开心的进入玩耍环节。

(1) 双击JMeter的启动脚本jmeter.sh,进入JMeter的主界面,新建一个测试计划,然后在该测试计划下新建一个线程组(设定1秒并发1000个线程,后续还可以调整线程数),紧接着是新建HTTP请求项以及CSV数据文件的读取配置等等,如下图所示:


其中,userId参数用于模拟参与秒杀~抢购的用户,其取值将来源于上图中的“CSV数据文件设置”选项的文件,在这里Debug设定了10个用户,如下图所示:  


值得一提的,“HTTP消息头管理器”选项是必需的,用于指定提交的数据的数据格式,即Content-Type的取值为application/json(因为我们的后端接口设置的就是 consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)。

在开始之前,我们设定了killId=3的商品作为秒杀~抢购的对象,并在数据表中设定其“可抢购数量/库存”的值total为6,如下图所示:


(2) 万事俱备只欠东风,下面我们点击JMeter主界面的启动,即可发起“1秒内并发1000个线程”的请求,而这1000个线程对应的用户的Id,即userId将随机从上述的CSV文件中读取。在出现结果之前,我们先从理论的角度上进行分析:10个用户抢购库存只有6个的书籍,那么理论上结果应该是“库存变为0,被抢购完毕,然后在item_kill_success表中会有6条,而且也应该仅有6条秒杀成功的订单记录”! 然而,理论归理论,现实还是很残酷的!

(3) 点击JMeter的启动按钮,此时可以观察控制台的输出信息以及数据库表item_kill和item_kill_success,会发现一连串“惨不忍睹”的数据记录,如下图所示:


对于初次接触“高并发秒杀业务场景”的童鞋可能会感觉到惊讶,“明明经过Postman测试过了呀,为啥还会出现这种情况!”,有点百思不得其解!

然鹅呢,Debug想说的是“事出必有因”,而出现这种情况,单单抱怨是屁用都木有的,还得去源头进行分析,即从代码的层次进行分析!

(4) 我们再次来回顾一下所写的“秒杀接口”的核心逻辑,如下图所示:


A 当用户在前端界面疯狂的点击“抢购”按钮时,我们上面接口将会接收到“汹涌潮水般”的用户秒杀请求,首次秒杀,很多用户都是第一次秒杀该商品,故而A流程大部分用户都将通过考核;

B 同时,由于B流程的逻辑是判断是否可抢,而很明显,大家都是第一次来抢的,这个商品也没那么快被抢完,故而B流程大家也将通过考核;

C 到了C流程,就需要扣减库存了,由于库存的扣减在这里只是单纯的“减一”的操作,故而在C这个流程,很多人将可以成功减一;

D 最后大家势如破竹,赶紧到了D流程,D流程是用于“生成秒杀成功的订单”,记录用户秒杀过的商品的痕迹,同时也是为了服务于A流程;这个时候的D已经不做什么判断了(大家可以看到核心的判断其实在于A流程,这也就是问题出现的致命根源),大家就直接插入一条成功的记录了。

因此,最终就出现了“库存超卖”、“同一个用户可以抢到多次”等各种莫名其妙的问题;

通过上面的分析,其实Debug已经指出来了,问题产生的根源在于高并发的情况下D流程的处理并没有为A流程的处理赢得足够的时间,即“生成一条秒杀成功后的订单记录” 并没有及时的为 “判断用户是否已经秒杀过了~是否已经有对应的订单记录了” 的流程很好的服务!

那么在下面的篇章中,我们将从各个角度进行优化,包括数据库级别Sql的优化、代码逻辑的优化、分布式锁的引入等等(当然这些是从开发的层面来讲的,其实还有运维的层面也可以优化,比如Nginx的负载均衡、中间件的集群部署提高高可用等等)!

补充:

1、目前,这一秒杀系统的整体构建与代码实战已经全部完成了,该秒杀系统对应的视频教程的链接地址为:https://www.fightjava.com/web/index/course/detail/6,可以点击链接进行试看以及学习,实战期间有任何问题都可以留言或者与Debug联系、交流!

2、另外,Debug也开源了该秒杀系统对应的完整的源代码以及数据库,其地址可以来这里下载:https://gitee.com/steadyjack/SpringBoot-SecondKill 记得Fork跟Star啊!!!

3、最后,不要忘记了关注一下Debug的技术微信公众号: