流量削峰三大技术
- 秒杀令牌
- 秒杀大闸
- 队列泄洪
引入削峰技术之前方案的缺点
- 秒杀下单接口会被脚本不停的刷新,所谓秒杀接口其实就是一个暴露在公网的 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 亿个用户都能得到抢占库存的先机;
- 可以使用秒杀大闸技术优化系统性能;