秒杀系统 | 流量削峰技术 | 秒杀令牌

流量削峰三大技术

  • 秒杀令牌
  • 秒杀大闸
  • 队列泄洪

引入削峰技术之前方案的缺点

  • 秒杀下单接口会被脚本不停的刷新,所谓秒杀接口其实就是一个暴露在公网的 URL /order/create,如果用户知道自己的 token,要秒杀的商品的 id,很容易就能写个脚本不停的刷,这样会影响正常用户的下;即便在秒杀活动还没开始的时候,也存在被黄牛用户不停的刷的可能(有了秒杀令牌机制,在活动开始前,秒杀令牌是发不出去的,没有活动令牌,/order/create 就不会成功);
  • 秒杀验证逻辑和秒杀下单接口强关联,代码冗余度高,下单的逻辑和活动是否开始的逻辑是没有关联的,哪怕活动没有开始,仍然可以以普通商品的方式下单,即便活动开始了,校验活动是否开始的逻辑也不应该在下单接口的逻辑中,
  • 秒杀验证逻辑复杂,对交易系统产生无关联负载,交易接口要解决的是,生成对应的交易号,落单,并且扣减对应的库存;校验用户的合法状态,活动的状态,其实都不是下单接口要做的事情;

秒杀令牌原理

  • 秒杀接口需要依靠令牌才能进入,秒杀接口需要新增一个入参,表示前端用户获得的秒杀令牌,令牌合法之后,才能进入秒杀下单的逻辑;
  • 秒杀的令牌,由秒杀活动模块(PromoService)负责生成,和交易系统无关,交易系统只是验证令牌的可靠性,以此判断 HTTP 请求能否进入秒杀接口;
  • 秒杀活动模块(PromoService)对秒杀令牌的生成全权处理,逻辑收口,即秒杀活动模块全权负责秒杀令牌的生成周期以及生成方式;
  • 秒杀下单前,需要先获得秒杀令牌才能进行秒杀下单;

秒杀令牌实现

生成秒杀令牌
  • 校验活动的合法性,活动是否开始;
  • 校验用户信息和商品信息,并且在下单接口中,把这部分逻辑删除;
  • 生成秒杀令牌,并存入 Redis 中,针对一个活动、一个商品,每个用户只能获得一个令牌;如果用户多次下单,每次下单,该用户对应的秒杀令牌都会更新;
@Override
public String generateSecondKillToken(Integer promoId, Integer itemId, Integer userId) {
    // 0. 获取活动信息
    PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId);
    PromoModel promoModel = convertFromPromoDO(promoDO);
    if (promoModel == null) {
        return null;
    }
    if (promoModel.getStartTime().isAfterNow()) {
        promoModel.setStatus(1);
    } else if (promoModel.getEndTime().isBeforeNow()) {
        promoModel.setStatus(3);
    } else {
        promoModel.setStatus(2);
    }

    // 1. 判断活动是否正在进行
    if (promoModel.getStatus().intValue() != 2) {
        return null;
    }

    // 2. 校验商品信息和用户信息
    ItemModel itemModel = itemService.getItemByIdInCache(itemId);
    if (itemModel == null) {
        return null;
    }
    UserModel userModel = userService.getUserByIdInCache(userId);
    if (userModel == null) {
        return null;
    }

    // 3. 生成秒杀令牌,并存入 Redis 中
    String token = UUID.randomUUID().toString().replace("-", "");
    redisTemplate.opsForValue().set("promo_token_" + promoId + "_userid_" + userId + "_itemid_" + itemId, token);
    redisTemplate.expire("promo_token_" + promoId + "_userid_" + userId + "_itemid_" + itemId, 5, TimeUnit.MINUTES);

    return token;
}
增加生成秒杀令牌的接口
  • 每次调用下单接口前都要调用这个接口;
@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 {
    String token = httpServletRequest.getParameterMap().get("token")[0];
    if (StringUtils.isEmpty(token)) {
        throw new BusinessException(EmBusinessError.USER_NOT_LOGIN, "用户还未登录,不能下单");
    }
    UserModel userModel = (UserModel) redisTemplate.opsForValue().get(token);
    if (userModel == null) {
        throw new BusinessException(EmBusinessError.USER_NOT_LOGIN, "用户还未登录,不能下单");
    }

    // 生成秒杀令牌
    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 {

    // 校验用户是否登录
    String token = httpServletRequest.getParameterMap().get("token")[0];
    if (StringUtils.isEmpty(token)) {
        throw new BusinessException(EmBusinessError.USER_NOT_LOGIN, "用户还未登录,不能下单");
    }
    UserModel userModel = (UserModel) redisTemplate.opsForValue().get(token);
    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 (!StringUtils.equals(promoToken, inRedisPromoToken)) {
            throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "秒杀令牌校验失败");
        }
    }

    // 判断库存是否已经售罄,若对应的售罄 key 存在,则直接返回下单失败
    if (redisTemplate.hasKey("promo_item_stock_invalid_" + itemId)) {
        throw new BusinessException(EmBusinessError.STOCK_NOT_ENOUGH);
    }

    // 在 RocketMQ 的事务型消息中完成下单操作
    String stockLogId = itemService.initStockLog(itemId, amount);
    if(!mqProducer.transactionAsyncReduceStock(userModel.getId(), promoId, itemId, amount, stockLogId)) {
        throw new BusinessException(EmBusinessError.UNKNOWN_ERROR, "下单失败");
    }
    return CommonReturnType.create(null);
}

秒杀令牌的缺陷

  • 在活动刚开始的时候,比如有 1亿个用户下单,就会生成 1 亿个秒杀令牌;
  • 秒杀令牌的生成是耗性能的;
  • 即便 1 亿个用户都得到了秒杀令牌,也不是 1 亿个用户都能得到抢占库存的先机;
  • 可以使用秒杀大闸技术优化系统性能;

你可能感兴趣的:(秒杀系统 | 流量削峰技术 | 秒杀令牌)