电商高并发秒杀5 流量削峰技术

前言

对应的交易优化技术使用了缓存校验+异步扣减库存的方式,使得秒杀下单的方式有了明显的提升。
即便查询优化,交易优化技术用到极致后,只要外部的流量超过了系统可承载的范围就有拖垮系统的风险。本章通过秒杀令牌,秒杀大闸,队列泄洪等流量削峰技术解决全站的流量高性能运行效率。

项目缺陷:
  • 秒杀下单接口会被脚本不停的刷新;
  • 秒杀验证逻辑和秒杀下单接口强关联,代码冗余度高;
  • 秒杀验证逻辑复杂,对交易系统产生无关联负载;

1. 秒杀令牌

1.1 原理

  • 秒杀接口需要依靠令牌才能进入,对应的秒杀下单接口需要新增一个入参,表示对应前端用户获得传入的一个令牌,只有令牌处于合法之后,才能进入对应的秒杀下单的逻辑;
  • 秒杀令牌由秒杀活动模块负责生成,交易系统仅仅验证令牌的可靠性,以此来判断对应的秒杀接口是否可以被这次http的request进入;
  • 秒杀活动模块对秒杀令牌生成全权处理,逻辑收口;
  • 秒杀下单前需要获得秒杀令牌才能开始秒杀;

1.2 代码实现

 /**
    * 每次下单生成秒杀令牌 替代原来令牌
    */
   @RequestMapping(value = "/generatetoken", method = {RequestMethod.POST}, consumes = {CONTENT_TYPE_FORMED})
   @ResponseBody
   public CommonReturnType generatetoken(@RequestParam(name = "itemId") Integer itemId,
                                         @RequestParam(name = "promoId") Integer promoId) throws BusinessException {
       //根据token获取用户信息
       String[] tokenArray = httpServletRequest.getParameterMap().get("token");
       if (ArrayUtils.isEmpty(tokenArray) || StringUtils.isEmpty(tokenArray[0]) || tokenArray[0].equals("null")) {
           throw new BusinessException(EmBusinessError.USER_NOT_LOGIN, "用户还未登陆,不能下单");
       }
       //获取用户的登陆信息
       UserModel userModel = (UserModel) redisTemplate.opsForValue().get(tokenArray[0]);
       if (userModel == null) {
           throw new BusinessException(EmBusinessError.USER_NOT_LOGIN, "用户还未登陆,不能下单");
       }
       /**
        * 以userid为维度防止token重复
        */
       //获取秒杀访问令牌
       String promoToken = promoService.generateSecondKillToken(promoId, itemId, userModel.getId());

       if (promoToken == null) {
           throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "生成令牌失败");
       }
       //返回对应的结果
       return CommonReturnType.create(promoToken);
   }



   //封装下单请求
   @RequestMapping(value = "/createorder", method = {RequestMethod.POST}, consumes = {CONTENT_TYPE_FORMED})
   @ResponseBody
   public CommonReturnType createOrder(@RequestParam(name = "itemId") Integer itemId,
                                       @RequestParam(name = "amount") Integer amount,
                                       @RequestParam(name = "promoId", required = false) Integer promoId,
                                       @RequestParam(name = "promoToken", required = false) String promoToken) throws BusinessException {


       //根据token获取用户信息
       String[] tokenArray = httpServletRequest.getParameterMap().get("token");
       if (ArrayUtils.isEmpty(tokenArray) || StringUtils.isEmpty(tokenArray[0]) || tokenArray[0].equals("null")) {
           throw new BusinessException(EmBusinessError.USER_NOT_LOGIN, "用户还未登陆,不能下单");
       }
       //获取用户的登陆信息
       UserModel userModel = (UserModel) redisTemplate.opsForValue().get(tokenArray[0]);
       if (userModel == null) {
           throw new BusinessException(EmBusinessError.USER_NOT_LOGIN, "用户还未登陆,不能下单");
       }
       //校验秒杀令牌是否正确
       if (promoId != null) {
           String inRedisPromoToken = (String) redisTemplate.opsForValue().get("promo_token_" + promoId + "_userid_" + userModel.getId() + "_itemid_" + itemId);
           if (inRedisPromoToken == null) {
               throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "秒杀令牌校验失败");
           }
           if (!org.apache.commons.lang3.StringUtils.equals(promoToken, inRedisPromoToken)) {
               throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "秒杀令牌校验失败");
           }
       }
....
   }


生成秒杀令牌

    /**
     * 生成秒杀用的令牌
     *
     * @param promoId
     * @param itemId
     * @param userId
     */
    @Override
    public String generateSecondKillToken(Integer promoId, Integer itemId, Integer userId) {
        //判断是否库存已售罄,若对应的售罄key存在,则直接返回下单失败
        if (redisTemplate.hasKey(PROMO_ITEM_STOCK_INVALID + itemId)) {
            return null;
        }
        PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId);

        //dataobject->model
        PromoModel promoModel = convertFromDataObject(promoDO);
        if (promoModel == null) {
            return null;
        }

        //判断当前时间是否秒杀活动即将开始或正在进行
        if (promoModel.getStartDate().isAfterNow()) {
            promoModel.setStatus(1);
        } else if (promoModel.getEndDate().isBeforeNow()) {
            promoModel.setStatus(3);
        } else {
            promoModel.setStatus(2);
        }
        //判断活动是否正在进行
        if (promoModel.getStatus().intValue() != 2) {
            return null;
        }

        //判断item信息是否存在
        ItemModel itemModel = itemService.getItemByIdInCache(itemId);
        if (itemModel == null) {
            return null;
        }
        //判断用户信息是否存在
        UserModel userModel = userService.getUserByIdInCache(userId);
        if (userModel == null) {
            return null;
        }

        /**
         * 如果已发放令牌数量大于活动商品的数量 * 系数,就不在发放秒杀令牌
         *
         * 获取秒杀大闸的count数量
         */

        long result = redisTemplate.opsForValue().increment(PROMO_DOOR_COUNT + promoId, -1);
        if (result < 0) {
            return null;
        }
        //生成token并且存入redis内并给一个5分钟的有效期
        String token = UUID.randomUUID().toString().replace("-", "");
        /**
         * 以USERid为维度防止token重复
         */
        redisTemplate.opsForValue().set("promo_token_" + promoId + "_userid_" + userId + "_itemid_" + itemId, token, 5, TimeUnit.MINUTES);
        return token;
    }
image.png
点击下单按钮,前端两次接口请求:
  • 1.创建与用户id、商品id、促销id关联的时效性token( 非用户token)&
  • 2.创建订单createOrder
秒杀令牌的缺陷
  • 在活动刚开始的时候,比如有 1000w个用户下单,就会生成 1000w个秒杀令牌;
    秒杀令牌的生成是耗资源与性能的;
  • 如果1000w个用户都得到秒杀令牌,那么一直关注活动的用户,在秒杀的用户没有抢占先机;

2. 秒杀大闸

为了解决秒杀令牌在活动一开始无限制生成,影响系统的性能,提出了秒杀大闸的解决方案;

2.1 原理

依靠秒杀令牌的授权原理定制化发牌逻辑,解决用户对应流量问题,做到大闸功能;
根据秒杀商品初始化库存颁发对应数量令牌,控制大闸流量;
用户风控策略前置到秒杀令牌发放中;
库存售罄判断前置到秒杀令牌发放中。

2.2 代码实现:

        /**
         * 将大闸的限制数字设到redis内
         */
        redisTemplate.opsForValue().set(PROMO_ITEM_STOCK +itemModel.getId(), itemModel.getStock());
        String promo_itemid = "promoid_" + promoId + "_itemid_" + itemModel.getId();

        redisTemplate.opsForValue().set(promo_itemid, 1, 10, TimeUnit.MINUTES);

        /**
         * 每隔一段时间重新设置令牌最大数量
         */
        String promo_itemid = "promoid_" + promoId + "_itemid_" + itemId;

        if (!redisTemplate.hasKey(promo_itemid)) {
            /**
             * 将大闸的限制数字设到redis内
             */
            redisTemplate.opsForValue().set(PROMO_DOOR_COUNT + promoId, itemModel.getStock().intValue() * 5);

            redisTemplate.opsForValue().set(promo_itemid, 1, 10, TimeUnit.MINUTES);
        }

        long result = redisTemplate.opsForValue().increment(PROMO_DOOR_COUNT + promoId, -1);
        if (result < 0) {
            return null;
        }

方案缺陷

浪涌流量涌入后系统无法应对
多库存多商品等令牌限制能力弱;

3. 队列泄洪

采用秒杀大闸之后,还是无法解决浪涌流量涌入后台系统,并且多库存多商品等令牌限制能力较弱;

3.1 技术来源
  • 排队有些时候比并发更高效(例如redis单线程模型,innodb mutex key等);
  • 依靠排队去限制并发流量;
  • 依靠排队和下游阻塞窗口程度调整队列释放流量大小;
  • 以支付宝银行网关队列为例,支付宝需要对接许多银行网关,当你的支付宝绑定多张银行卡,那么支付宝对于这些银行都有不同的支付渠道。在大促活动时,支付宝的网关会有上亿级别的流量,银行的网关扛不住,支付宝就会将支付请求队列放到自己的消息队列中,依靠银行网关承诺可以处理的TPS流量去泄洪;

阻塞队列就像“水库”一样,拦蓄上游的洪水,削减进入下游河道的洪峰流量,从而达到减免洪水灾害的目的;

3.2 代码实现
  private ExecutorService executorService;

    @PostConstruct
    public void init() {
        executorService = new ThreadPoolExecutor(20, 20,
                0L, TimeUnit.MILLISECONDS,
                new LinkedBlockingQueue<>(1024),new ThreadPoolExecutor.AbortPolicy());
    }
       /**
         * 同步调用线程池的submit方法
         */
        /**
         * 拥塞窗口为20的等待队列,用来队列化泄洪
         */
        Future<Object> future = executorService.submit(new Callable<Object>() {

            @Override
            public Object call() throws Exception {
                //加入库存流水init状态
                String stockLogId = itemService.initStockLog(itemId, amount);


                //再去完成对应的下单事务型消息机制
                if (!mqProducer.transactionAsyncReduceStock(userModel.getId(), itemId, promoId, amount, stockLogId)) {
                    throw new BusinessException(EmBusinessError.UNKNOWN_ERROR, "下单失败");
                }
                return null;
            }
        });

        try {
            future.get();
        } catch (InterruptedException e) {
            throw new BusinessException(EmBusinessError.UNKNOWN_ERROR);
        } catch (ExecutionException e) {
            throw new BusinessException(EmBusinessError.UNKNOWN_ERROR);
        }

3.3 本地、分布式实现队列方案比较
本地:将队列维护在本地内存中;
分布式:将队列设置到外部redis中

比如说我们有100台机器,假设每台机器设置20个队列,那我们的拥塞窗口就是2000,但是由于负载均衡的关系,很难保证每台机器都能够平均收到对应的createOrder的请求,那如果将这2000个排队请求放入redis中,每次让redis去实现以及去获取对应拥塞窗口设置的大小,这种就是分布式队列;

本地和分布式有利有弊:

分布式队列最严重的就是性能问题,发送任何一次请求都会引起call网络的消耗,并且要对Redis产生对应的负载,Redis本身也是集中式的,虽然有扩展的余地。单点问题就是若Redis挂了,整个队列机制就失效了。

本地队列的好处就是完全维护在内存当中的,因此其对应的没有网络请求的消耗 ,只要JVM不挂,应用是存活的,那本地队列的功能就不会失效。因此企业级开发应用还是推荐使用本地队列,本地队列的性能以及高可用性对应的应用性和广泛性。可以使用外部的分布式集中队列,当外部集中队列不可用时或者请求时间超时,可以采用降级的策略,切回本地的内存队列。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,847评论 4 362
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,208评论 1 292
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,587评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,942评论 0 205
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,332评论 3 287
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,587评论 1 218
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,853评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,568评论 0 198
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,273评论 1 242
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,542评论 2 246
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,033评论 1 260
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,373评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,031评论 3 236
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,073评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,830评论 0 195
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,628评论 2 274
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,537评论 2 269

推荐阅读更多精彩内容