秒杀项目系列之九: 流量削峰(秒杀令牌+秒杀大闸限定令牌数+队列泄洪)

  1. 当前秒杀系统存在的问题
  • 秒杀下单接口会被脚本不停的刷
    用户只需要知道秒杀url,用户token、itemId、promoId,很容易通过http请求的方式不断刷新抢商品.虽然有秒杀开始时间的验证,但是还是会对服务器产生压力.
  • 秒杀验证逻辑和秒杀下单接口强关联,代码冗余度高
  • 秒杀验证逻辑复杂,对交易系统产生无关联负载
    对用户身份的验证和对秒杀活动信息的验证应该与交易系统无强耦合.
  1. 秒杀令牌的原理和使用方式
  • 秒杀令牌的原理

    • 秒杀接口需要依靠令牌才能进入
    • 秒杀的令牌由秒杀活动模块负责生成
    • 秒杀活动模块对秒杀令牌生成全权处理,逻辑收口
    • 秒杀下单前需要先获得秒杀令牌
  • 秒杀令牌的作用

    • 秒杀令牌只有在秒杀开始后才会生成,在有秒杀令牌后才能抢商品,所以就避免了提前通过token、itemId、promoId和url的脚本抢商品.
    • 验证代码放在生成令牌中,与下单代码隔离开,实现低耦合.
  • 秒杀令牌实现代码

    • OrderController.java

      // 生成秒杀令牌
      @PostMapping(value = "/generatetoken", consumes = {
               CONTENT_TYPE_FORMED})
      public CommonReturnType generatetoken(@RequestParam("itemId") Integer itemId,
                                            @RequestParam("promoId") Integer promoId) throws BusinessException {
               
          // 根据token获取用户信息
          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);
      }
      // 下单
      @PostMapping(value = "/createorder", consumes = {
               CONTENT_TYPE_FORMED})
      public CommonReturnType createOrder(@RequestParam("itemId") Integer itemId,
                                          @RequestParam(value = "promoId", required = false) Integer promoId,
                                          @RequestParam("amount") Integer amount,
                                          @RequestParam(value = "promoToken", required = false) String promoToken) throws BusinessException {
               
          // 使用token的方法获取用户信息
          String token = httpServletRequest.getParameterMap().get("token")[0];
          if(StringUtils.isEmpty(token)){
               
              throw new BusinessException(EmBusinessError.USER_NOT_EXIST, "用户未登陆,不能下单");
          }
          UserModel userModel = (UserModel)redisTemplate.opsForValue().get(token);
          if(userModel == null){
               
              throw new BusinessException(EmBusinessError.USER_NOT_EXIST, "用户未登陆,不能下单");
          }
          // 校验秒杀令牌是否正确
          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(inRedisPromoToken, promoToken)){
               
                  throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "秒杀令牌校验失败");
              }
          }
      	//  判断库存是否已售罄,若对应的售罄key存在,则直接返回下单失败
          if(redisTemplate.hasKey("promo_item_stock_invalid_" + itemId)){
               
              throw new BusinessException(EmBusinessError.STOCK_NOT_ENOUGH);
          }
      	//  初始化库存流水(id、itemid、amount、status存入数据库流水表)
          String stockLogId = itemService.initStockLog(itemId, amount);
      
      	//  完成对应的下单事务型消息机制
          boolean orderState = mqProducer.transactionAsyncReduceStockAndAddSales(userModel.getId(), itemId, promoId, amount, stockLogId);
      	// 下单失败
          if(!orderState){
               
              throw new BusinessException(EmBusinessError.UNKNOWN_ERROR, "下单失败");
          }
          return CommonReturnType.create(null);
      }
      
    • PromoServiceImpl.java

      @Override
      public String generateSecondKillToken(Integer promoId, Integer itemId, Integer userId) {
               
          // 校验是否有商品秒杀活动
          PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId);
          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);
          }
          // 1表示秒杀未开始,2表示进行中,3表示已结束。如果秒杀活动不正在进行中,则不生成秒杀令牌
          if(promoModel.getStatus() != 2){
               
              return null;
          }
          // 校验商品信息是否存在
          ItemModel itemModel = itemService.getItemByIdInCache(itemId);
          if(itemModel == null){
               
              return null;
          }
          // 校验用户信息是否存在
          UserModel userModel = userService.getUserByIdInCache(userId);
          if(userModel == null){
               
              return null;
          }
          // 生成秒杀令牌并存入redis缓存中,设置一个5分钟的有效期
          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;
      }
      
    • OrderServiceImpl.java

      @Override
      @Transactional
      public OrderModel createOrder(Integer userId, Integer itemId, Integer promoId, Integer amount, String stockLogId) throws BusinessException {
               
          // 用户信息、秒杀活动信息、商品信息等放在生成令牌处校验
          ItemModel itemModel = itemService.getItemByIdInCache(itemId);
          if(itemModel == null){
               
              throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "商品信息不存在");
          }
          if(amount <= 0 || amount > 99){
               
              throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "数量信息不正确");
          }
          // 2.落单减库存
          boolean result = itemService.decreaseStock(itemId, amount);
          if(!result){
               
              throw new BusinessException((EmBusinessError.STOCK_NOT_ENOUGH));
          }
      
          // 3.订单入库
          OrderModel orderModel = new OrderModel();
          orderModel.setItemId(itemId);
          orderModel.setUserId(userId);
          orderModel.setAmount(amount);
          if(promoId != null){
               
              orderModel.setItemPrice(itemModel.getPromoModel().getPromoItemPrice());
          }else{
               
              orderModel.setItemPrice(itemModel.getPrice());
          }
          orderModel.setPromoId(promoId);
          orderModel.setOrderPrice(orderModel.getItemPrice().multiply(BigDecimal.valueOf(amount)));
          // 生成交易流水号(订单号)
          orderModel.setId(generateOrderNo());
      
          OrderDO orderDO = convertFromOrderModel(orderModel);
          orderDOMapper.insertSelective(orderDO);
      
          // 4. 商品销量增加,先增加到缓存中,然后通过rocketmq事务消息机制发送消息
          itemService.increaseSales(itemId, amount);
      
          // 设置库存流水状态为成功
          StockLogDO stockLogDO = stockLogDOMapper.selectByPrimaryKey(stockLogId);
          if(stockLogDO == null){
               
              throw new BusinessException(EmBusinessError.UNKNOWN_ERROR);
          }
          // status为2表示扣减库存成功
          stockLogDO.setStatus(2);
          stockLogDOMapper.updateByPrimaryKeySelective(stockLogDO);
          // 5. 返回前端
          return orderModel;
      }
      
    • 前端getitem.html下单界面ajax代码

      // jQuery(document).ready()这个方法在dom载入就绪时对其进行操纵并调用执行它所绑定的函数。
      jQuery(document).ready(function(){
          $("#createorder").on("click", function () {
              var token = window.localStorage["token"];
              if(token == null){
                  alert("没有登陆,不能下单");
                  window.location.href="login.html";
                  return false;
              }
              $.ajax({
                  type:"POST",
                  contentType: "application/x-www-form-urlencoded",
                  url:"http://" + g_host + "/order/generatetoken?token=" + token,
                  data:{
                      "itemId":g_itemVO.id,
                      "promoId":g_itemVO.promoId
                  },
                  xhrFields:{withCredentials:true},
                  success:function (data) {
                      if(data.status == "success"){
                          var promoToken = data.data;
                          $.ajax({
                              type:"POST",
                              contentType: "application/x-www-form-urlencoded",
                              url:"http://" + g_host + "/order/createorder?token=" + token,
                              data:{
                                  "itemId":g_itemVO.id,
                                  "promoId":g_itemVO.promoId,
                                  "amount":1,
                                  "promoToken":promoToken
                              },
                              xhrFields:{withCredentials:true},
                              success:function (data) {
                                  if(data.status == "success"){
                                      alert("下单成功");
                                      window.location.reload();
                                  }else{
                                      alert("下单失败,原因为"+data.data.errMsg);
                                      if(data.data.errCode == 20003){
                                          window.location.href="login.html";
                                      }
                                  }
                              },
                              error:function (data) {
                                  alert("下单失败,原因为"+data.responseText);
                              }
                          });
                      }else{
                          alert("获取令牌失败,原因为"+data.data.errMsg);
                          if(data.data.errCode == 20003){
                              window.location.href="login.html";
                          }
                      }
                  },
                  error:function (data) {
                      alert("获取令牌失败,原因为"+data.responseText);
                  }
              });
          });
          initView();
      });
      
  • 目前存在的问题
    秒杀令牌只要活动一开始就可以无限制生成,影响系统性能.
    比如有100件商品,十万用户抢,每个用户点一下就生成一个秒杀令牌,只有100件商品,生成海量的令牌只会影响系统性能.

  1. 秒杀大闸的原理和使用方式
  • 秒杀大闸原理

    • 依靠秒杀令牌的授权原理定制化发牌逻辑,做到大闸功能
    • 根据秒杀商品初始化库存颁发对应数量令牌,控制大闸流量
    • 用户风控策略前置到秒杀令牌发放中(秒杀令牌已完成)
    • 库存售罄判断前置到秒杀令牌发送中
  • 秒杀大闸代码实现

    • PromoServiceImpl.java

      // 发布促销活动
      public void publishpromo(Integer promoId) {
               
          // 通过活动id获取活动
          PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId);
          if(promoDO.getItemId() == null || promoDO.getItemId() == 0){
               
              return;
          }
          ItemModel itemModel = itemService.getItemById(promoDO.getItemId());
          // 将库存同步到redis中
          redisTemplate.opsForValue().set("promo_item_stock_" + itemModel.getId(), itemModel.getStock());
          // 将销量同步到redis中
          redisTemplate.opsForValue().set("promo_item_sales_" + itemModel.getId(), itemModel.getSales());
          // 将秒杀大闸的限制数字设置到redis中,并设置大闸的限制数量为库存的5倍
          redisTemplate.opsForValue().set("promo_door_count_" + promoId, itemModel.getStock() * 5);
      }
      
    • PromoServiceImpl.java

      @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);
          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);
          }
          // 1表示秒杀未开始,2表示进行中,3表示已结束。如果秒杀活动不正在进行中,则不生成秒杀令牌
          if(promoModel.getStatus() != 2){
               
              return null;
          }
          // 校验商品信息是否存在
          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;
          }
          // 生成秒杀令牌并存入redis缓存中,设置一个5分钟的有效期
          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;
      }
      
  • 目前存在的问题

    • 浪涌流量涌入后系统无法应对
      当库存比较多的时候,以5倍或数倍的方式发放令牌还是会造成瞬间大量的请求涌入.
    • 多库存、多商品等令牌限制能力弱
      当前是针对少量库存和商品的处理,当多库存、多商品时,瞬间请求量仍然很大.
  1. 队列泄洪的原理和使用方式
  • 队列泄洪原理

    • 排队有时候比并发更高效
      例如redis单线程模型,innodb mutex key等.
    • 依靠排队去限制并发流量
    • 依靠排队和下游拥塞窗口程度调整队列释放流量大小
      比如支付宝银行网关队列.支付宝支持的并发数很多,但是支付操作是在对接的银行上实现的,银行支持不了那么高的并发操作,所以由支付宝调整释放流量的大小给银行处理,保证在银行支持的能力范围内.
  • 队列泄洪代码实现

    • OrderController.java

      private ExecutorService executorService;
      
      @PostConstruct
      public void init(){
               
          // newFixedThreadPool(): 创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待。
          // 开辟20个线程数的线程池,同一时间只能处理20个请求,其他的请求放在队列中等待,用来队列化泄洪
          executorService = Executors.newFixedThreadPool(20);
      }
      @PostMapping(value = "/createorder", consumes = {
               CONTENT_TYPE_FORMED})
      public CommonReturnType createOrder(@RequestParam("itemId") Integer itemId,
                                          @RequestParam(value = "promoId", required = false) Integer promoId,
                                          @RequestParam("amount") Integer amount,
                                          @RequestParam(value = "promoToken", required = false) String promoToken) throws BusinessException {
               
          // 使用token的方法获取用户信息
          String token = httpServletRequest.getParameterMap().get("token")[0];
          if(StringUtils.isEmpty(token)){
               
              throw new BusinessException(EmBusinessError.USER_NOT_EXIST, "用户未登陆,不能下单");
          }
          UserModel userModel = (UserModel)redisTemplate.opsForValue().get(token);
          if(userModel == null){
               
              throw new BusinessException(EmBusinessError.USER_NOT_EXIST, "用户未登陆,不能下单");
          }
          // 校验秒杀令牌是否正确
          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(inRedisPromoToken, promoToken)){
               
                  throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "秒杀令牌校验失败");
              }
          }
          // 同步调用线程池的submit方法
          // 当将一个Callable的对象传递给ExecutorService的submit方法,则该call方法自动在一个线程上执行,并且会返回执行结果Future对象。
          // 即每一个初始化库存流水操作、rocketmq事务型消息、下订单操作放在一个线程中执行,一共20个线程,则可以同时有20个这一系列操作,其他的放在队列中
          Future<Object> future = executorService.submit(new Callable<Object>() {
               
              @Override
              public Object call() throws Exception {
               
                  //  初始化库存流水(id、itemid、amount、status存入数据库流水表)
                  String stockLogId = itemService.initStockLog(itemId, amount);
                  // 完成对应的下单事务型消息机制
                  boolean orderState = mqProducer.transactionAsyncReduceStockAndAddSales(userModel.getId(), itemId, promoId, amount, stockLogId);
                  // 下单失败
                  if(!orderState){
               
                      throw new BusinessException(EmBusinessError.UNKNOWN_ERROR, "下单失败");
                  }
                  return null;
              }
          });
          try {
               
              // 返回null
              future.get();
          } catch (InterruptedException | ExecutionException e) {
               
              throw new BusinessException(EmBusinessError.UNKNOWN_ERROR);
          }
          return CommonReturnType.create(null);
      }
      
  1. 本地和分布式泄洪
  • 本地: 将队列维护在本地内存中
    • 优势:
      • 高性能: 没有到redis网络请求的消耗
      • 高可用性: 只要机器不宕机就能用
    • 缺点: 不能实现很好的负载均衡
  • 分布式: 将队列设置到外部redis中
    • 优势: 集群统一管理,能够实现很好的负载均衡
    • 缺点: 性能较低: 有到redis网络请求的消耗,性能比本地内存的方式低
    • 单点故障: 可能会造成整个redis队列中的请求都无法处理
  • 更好的方式: 使用外部集中式的分布式队列(比如redis分布式队列中),当该队列性能出现问题时,采用降级的方式切回到本地内存队列.

你可能感兴趣的:(秒杀项目系列,java,流量削峰,分布式,秒杀)