记一次秒杀场景实现逻辑

2022-08-10 14:36:43

业务需求:

  • 每天晚8点放出200份奖品,活动共三天。
  • 要求用户填写手机号,卡号(会员卡号),姓名后开抢。
  • 要求手机号和卡号不能重复。
  • 抢到后填写收货地址提交到后台。
  • 软件是在我们同事的代码上面二次修改的,增加了cdn和卡号唯一的限制(之前只有手机号唯一)。

系统设计

  • 最外层采用cdn对静态资源进行缓存,减少服务器压力。
  • 系统使用redis缓存提交的用户信息。
  • 在用户跳转到地址提交界面,从redis取出资料,用户补充完整信息后保存到数据库。

cdn配置

我这里使用的是腾讯云的cdn,配置策略如下。另外有需要的话也可以在cdn上面配置IP访问限频,通过对单IP单节点QPS限制,可防御部分CC攻击以及刷单的情况。

软件部分

  • 这里是基于ruoyi的框架开发的。域名绑定使用的是apache反向代理,因为服务器上面还有别的php的一些程序。
  • 首先是将库存保存到静态变量里面,方便全局使用。
    public static boolean IS_IF_QJ = false;//是否开抢
    public static int jpsl=0;//今日剩余份数
    public static int jpslmax=0;//今日总份数
    public static String qjpc="20201224";//抢券批次,因为后续还会有活动,只需要改批次即可。

    public static List<String> list = new ArrayList<>();//保存提交的卡号。
  • 采用定时器初始化静态变量
  • 我这边使用的是比较老的版本,只能传一个参数String参数进来,新的版本可以传多个不同类型参数进来。我的思路就是通过后台配置定时任务的参数来初始化奖品数量。
  • 定时器的配置大家也可以按自己的方式写即可
    //开始抢券 传入份数
    public void ryQjKs(String jpsl) {
        GlobalUtils.IS_IF_QJ = true;
        GlobalUtils.jpsl=Integer.parseInt(jpsl);
        GlobalUtils.jpslmax=GlobalUtils.jpsl;
        GlobalUtils.list.clear();
    }
    //结束抢券
     public void stopqj() {
        GlobalUtils.IS_IF_QJ = false;
    }

接下来就是重点,抢券逻辑。

  public AjaxResult addSave(Ghqj ghqj, ModelMap mmap) {
        AjaxResult ajaxResult = new AjaxResult();

        if (GlobalUtils.IS_IF_QJ) {
            //信息校验部分
            if(){
                ajaxResult.put("msg", "请输入正确的卡号。");
                ajaxResult.put("code", 500);
                return ajaxResult;
            }
            //从redis查询该用户是否已中奖
            //先通过手机号查,再通过卡号查
            Ghqj gs = redisService.getCacheObject(GlobalUtils.qjpc+"p:" + ghqj.getPhone());
            if(gs==null){
                gs=redisService.getCacheObject(GlobalUtils.qjpc+"c:" + ghqj.getCar());
            }
            //如果用户是已中奖的,则直接返回对应信息,前端做处理,比如前端跳补充信息界面
            if (gs != null) {
                ajaxResult.put("phone", ghqj.getPhone());
                ajaxResult.put("msg", "恭喜获得" + ghqj.getJpname());
                ajaxResult.put("code", 200);
                return ajaxResult;
            }
            //调用扣取库存的代码
            //这里加了一个悲观锁,防止出现多线程问题。
            /*
           synchronized(this){
                if (GlobalUtils.jpsl>0&&GlobalUtils.list.size()<GlobalUtils.jpslmax) {
                    GlobalUtils.list.add(ghqj.getCar());
                    GlobalUtils.jpsl -= 1;
                } else {
                    ajaxResult.put("msg", "抱歉今日已抢完,请下次再来。");
                    ajaxResult.put("code", 500);
                    return ajaxResult;
                }
            }*/
            //悲观锁的另一种写法,上方和这里的代码选一份即可。
            if(!koukucun(ghqj)){
                ajaxResult.put("msg", "抱歉今日已抢完,请下次再来。");
                ajaxResult.put("code", 500);
                return ajaxResult;
            }
            //将中奖信息保存reids

           
            ghqj.setQjtime(new Date());
            redisService.setCacheObject(GlobalUtils.qjpc+"p:" + ghqj.getPhone(), ghqj);
            redisService.setCacheObject(GlobalUtils.qjpc+"c:"  + ghqj.getCar(), ghqj);
            ajaxResult.put("phone", ghqj.getPhone());
            ajaxResult.put("msg", "恭喜获得xx一份");
            ajaxResult.put("code", 200);
            return ajaxResult;


        } else {
            System.out.println("列数量:" + GlobalUtils.list.size());
            if(!GlobalUtils.IS_IF_QJ){
                ajaxResult.put("msg", "今日待开抢。");
                ajaxResult.put("code", 400);
                return ajaxResult;

            }else{
                ajaxResult.put("msg", "今日已抢完。");
                ajaxResult.put("code", 400);
                return ajaxResult;

            }

        }


    }
    //扣库存逻辑

    public synchronized boolean koukucun(Ghqj ghqj){
        //按理是只需要判断一次就可以了的,我这里还是多判断了一下。
        if (GlobalUtils.jpsl>0&&GlobalUtils.list.size()<GlobalUtils.jpslmax) {
            GlobalUtils.jpsl -= 1;
            GlobalUtils.list.add(ghqj.getCar());
            return true;
        } else {

            return false;
        }
    }

 补充位置信息
为了防止出现恶意调用的情况,这里应该再去redis查一下是否有该用户的资料,判断用户是否是中奖的用户。示例代码未提供。另外这里只是保存逻辑,主要的核心还是抢券的秒杀场景,别的功能逻辑都可以按自己的思路实现。

public AjaxResult getInfo(Ghqj ghqj) {
        Ghqj ghqj1 = ghqjService.selectGhqjByPhone(ghqj.getPhone());
        if (ghqj1 == null) {
            ghqj.setQjtime(new Date());
            return toAjax(ghqjService.insertGhqj(ghqj));
        } else {
            AjaxResult ajaxResult = new AjaxResult();
            ajaxResult.put("msg", "您已提交过了!");
            ajaxResult.put("code", 400);
            return ajaxResult;
        }
    }

核心的逻辑主要就是使用了redis做数据缓存,另外使用了一个悲观锁。
如果对悲观锁和乐观锁分不清的话,我这边有一个比较好记的方法。

悲观锁代表非常的悲观,则想到我的代码可能每一次都会遇到多线程问题,所以直接加锁让线程一个一个的执行。
悲观锁举例:张三拿到数据a的值为1,并想将a的值+2,但是在修改的过程中李四也想来修改将a的值修改+1,则李四在取a的值之前就需要等待张三把数据处理完了才能再处理。
则张三把数据修改为3,李四再取出3再加一,最后就是4.
乐观锁代表非常的乐观,认为自己的代码每一次都不会遇到多线程问题,所以还是让代码是多线程执行的,只是每一次执行完需要校验原来的数据是否已被改变。
乐观锁简单举例:张三拿到数据a的值为1,并想将a的值+2,但是在修改的过程中李四也想来修改将a的值修改+1,假如李四先修改完,则a的值变为了2。然后张三这时候修改为,但是张三发现a的值不是开始的1了,所以就修改失败或者重新开始修改操作,若重新修改则重新取出a的值为2,再加上2。

压力测试

代码测试这边使用的是jmeter,下载后可以直接把工具语言设置为中文。
另外在压力测试的时候可能会影响公司的网络甚至导致短暂断网(1-2分钟),所以我这边后面测试是手机开热点进行压测的。
压力测试主要就是向数据接口提交自己的数据,我这边测试的是每秒万次请求,参数使用的是变量。工具使用教程另行百度。

工具下载可百度或者:https://download.csdn.net/download/qq_41780372/13985261

我这边在测试的时候,最开始是开始任务的时候,浏览器访问网站发现无法访问,压测完又可以访问了,我还以为是网站被打死了。
后来感觉是本地网络限制了,于是我找来了另一台手机(连的公司wifi,自己电脑连的自己手机热点),打开秒杀网站,在我电脑开启任务的瞬间手机去刷新。

奖品直接被秒空了,也没有任何卡顿的现象,非常的丝滑。
打开redis查看奖品被秒的数量,也是正确的(自己也测试了多次的),感觉是没什么问题了。

如果性能已达上限还未达到自己场景的需求,最优的秒杀优化方法,我感觉应该是库存拆分。(比如有100个库存,则将这100个库存拆分到10个服务里面,每个服务给10个库存,采用负载均衡随机分配)
当然系统还有许多可以优化的地方,比如我有200个奖品,只放一定量的请求进来,比如1000个,而我这里是全部放进来了的,总之我的需求是达到了。
线上效果:200个名额,3000用户左右,1秒秒光。
另外代码中有一些冗余代码,当时是为了保险起见一直没有删除,另外对性能的影响个人感觉也不高就没有做处理。

=====================================================================
20210308  bug修复
=====================================================================
1.定时器修改
将定时器的是否开抢放到最后一排,否则可能出现线程安全问题。
开抢之前jpslmax没有重置为0导致的,115行代码执行了之后,再执行101,102,再执行116就有可能有这个问题。如果程序启动之前有测试就会导致jpslmax在活动开始前不为0.因为之前压测都是活动启动好了再压测的所有没有找到这个问题。

2.redis读写操作为在同一锁中
可能出现的问题:
如果是不同的用户之间,不会有问题,但是如果同一用户手速过快,就会出现问题。
比如张三在抢的时候,一个现场正在走扣库存的逻辑,还未写入,另一个现场可能就走到了判断中奖的逻辑,这时候还未写入所以判断未中奖所以也会往下走,这就会导致扣库存的时候同一用户扣多次,最终导致秒杀的商品数量低于放出的商品数量(还好不是超出)
解决方案:在扣库存的锁中,加入判断是否已中奖的逻辑,并且把写入redis的操作也放入锁里面。

redis读判断是否已中奖

锁{扣库存}

reids写入

3.个人后期优化想法:

1.库存拆分:将200份奖品分为10*20个额度,然后随机走不同的方法,这样就可以同时走10个锁的方法,加快程序响应速度。
2.用户限制:限制同一用户1秒只允许访问一次接口

另外代码中有问题的地方还希望大佬能够多多提出自己的意见!



  • 作者:小宝&amp;
  • 原文链接:https://blog.csdn.net/qq_41780372/article/details/111942391
    更新时间:2022-08-10 14:36:43