从零搭建基于SpringBoot的秒杀系统(七):高并发导致超卖问题分析处理

323 阅读5分钟

在没有高并发的环境下,做到现在已经算是一个比较完善的后端逻辑了,但是如果同时有1000个请求或者更多请求的时候,就会产生很多问题,包括秒杀最怕的超卖。想一下,秒杀活动本来就是不赚钱甚至是亏钱的活动,如果超卖了,发货就代表亏本,不发货直接影响信用。因此绝不能出现超卖的情况。

(一)现象展示

我们用apache jmeter进行压力测试,为了方便测试,先将人员登陆认证代码注释掉,注释config下的ShiroConfig。接着在Controller下的killController添加测试代码:

@RequestMapping(value = prefix+"/test/execute",method = RequestMethod.POST,consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public BaseResponse testexecute(@RequestBody @Validated KillDto killDto, BindingResult result, HttpSession httpSession){
    if (result.hasErrors()||killDto.getKillid()<0){
        return new BaseResponse(StatusCode.InvalidParam);
    }
    try {
        Boolean res=killService.KillItem(killDto.getKillid(),killDto.getUserid());
        if (!res){
            return new BaseResponse(StatusCode.Fail.getCode(),"商品已经抢购完或您已抢购过该商品");
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    BaseResponse baseResponse=new BaseResponse(StatusCode.Success);
    baseResponse.setData("抢购成功");
    return baseResponse;
}

同时清空item_kill_success表,将item_kill表中要卖的商品总数设置为5

接着配置apache jmeter,apache jmeter可以在官网下载,或者在我的公众号《Java鱼仔》中回复 秒杀系统 获取。点击apache jmeter/bin目里下的jmeter.bat即可运行。

下面这些配置的配置文件在上述回复中也可以获取到,或者在github.com/OliverLiy/S…的Tool文件夹下获取,下面简单讲一下重要参数

线程组中主要是三个线程的设置

Http请求中主要配置地址、端口、请求方法以及请求数据,我在这里取killid=1,userid从外部文件选择。

csv配置即上面用户存储的文件地址,主要注意路径以及变量名,这里使用userId,前面的用户就需要使用"userid":${userId}。

csv文件通过英文逗号分隔。

查看结果树是用来看处理请求的,HTTP信息头管理器增加信息头

配置完成后启动系统,切换到观察树,点击jmeter的运行按钮,即可看到1000个线程开始跑。观察item_kill表,total总数变成了-47,发生了超卖,同时item_kill_success中也出现了同一用户购买多件商品的情况。

(二)问题分析

产生超卖的原因我们可以从代码中分析出来,在KillServiceImpl中,单线程情况下下面这段语句来判断当前用户是否抢购过该商品没有问题,但是在多线程的情况下,如果第一个线程还未执行后面的抢购代码,第二个线程就进来了,就会导致两个线程都执行抢购代码,从而导致发生超卖。

在commonRecordKillSuccessInfo方法中会再做一次判断,因此实际产生的订单数量会比total总量减少的数量要少

(三)mysql层面优化

通过分析我们找到了超卖的原因,从mysql的层面上可以优化代码。在itemKillMapper中,在每句查询和修改sql语句中增加一条对total的判断,新增sql的V2版本:

@Select("select \n" +
        "a.*,\n" +
        "b.name as itemName,\n" +
        "(\n" +
        "\tcase when(now() BETWEEN a.start_time and a.end_time and a.total>0)\n" +
        "\t\tthen 1\n" +
        "\telse 0\n" +
        "\tend\n" +
        ")as cankill\n" +
        "from item_kill as a left join item as b\n" +
        "on a.item_id = b.id\n" +
        "where a.is_active=1 and a.id=#{id} and a.total>0;")
ItemKill selectByidV2(Integer killId);

@Update("update item_kill set total=total-1 where id=#{killId} and total>0")
int updateKillItemV2(Integer killId);

在KillServiceImpl中增加KillItemV2版本,与第一版的区别就在于mysql的优化

//mysql优化
public Boolean KillItemV2(Integer killId, Integer userId) throws Exception {
    Boolean result=false;
    //判断当前用户是否抢购过该商品
    if (itemKillSuccessMapper.countByKillUserId(killId,userId)<=0){
        //获取商品详情
        ItemKill itemKill=itemKillMapper.selectByidV2(killId);
        if (itemKill!=null&&itemKill.getCanKill()==1 && itemKill.getTotal()>0){
            int res=itemKillMapper.updateKillItemV2(killId);
            if (res>0){
                commonRecordKillSuccessInfo(itemKill,userId);
                result=true;
            }
        }
    }else {
        System.out.println("您已经抢购过该商品");
    }
    return result;
}

同时在KillService接口中把对应的V2版本加上去:

public interface KillService {
    Boolean KillItem(Integer killId,Integer userId) throws Exception;
    Boolean KillItemV2(Integer killId,Integer userId) throws Exception;
}

接着在KillController中,也增加一段代码用来测试V2版本

//测试mysql优化的版本
@RequestMapping(value = prefix+"/test/execute2",method = RequestMethod.POST,consumes = MediaType.APPLICATION_JSON_UTF8_VALUE)
@ResponseBody
public BaseResponse testexecute2(@RequestBody @Validated KillDto killDto, BindingResult result, HttpSession httpSession){
    if (result.hasErrors()||killDto.getKillid()<0){
        return new BaseResponse(StatusCode.InvalidParam);
    }
    try {
        Boolean res=killService.KillItemV2(killDto.getKillid(),killDto.getUserid());
        if (!res){
            return new BaseResponse(StatusCode.Fail.getCode(),"商品已经抢购完或您已抢购过该商品");
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
    BaseResponse baseResponse=new BaseResponse(StatusCode.Success);
    baseResponse.setData("抢购成功");
    return baseResponse;
}

(四)压力测试

接下来又可以使用jmeter进行测试了,首先清理一下数据库,item_kill表中id为1的项设置总量为5,清空item_kill_success表。

打开jmeter,将path改成/kill/test/execute2,点击运行项目。这一次1000个请求也没有出现超卖的情况:

但是点开订单表后,我们发现一个用户同时购买两次的问题还是存在

分析代码我们会发现,即使优化了mysql,如果两个请求同时进入下面的代码块,虽然不会导致超卖,但是依旧会导致一个用户购买多次的情况:

下面一章我们将对此再做优化。

到目前为止的代码均放在github.com/OliverLiy/S…

我搭建了一个微信公众号《Java鱼仔》,分享大量java知识点与学习经历,如果你对本项目有任何疑问,欢迎在公众号中联系我,我会尽自己所能为大家解答。