秒杀项目实践(六)——流量削峰技术

PunkLu 2019年12月22日 126次浏览

流量削峰技术

秒杀可能会在一个瞬间涌入大量流量,降低系统性能,可以使用流量削峰的技术来削除掉一部分流量,保证系统性能的高可用性。

缺陷分析

  1. 秒杀下单接口会被脚本不停地刷
  2. 秒杀验证逻辑和秒杀下单接口强关联,代码冗余度高
  3. 秒杀验证逻辑复杂,对交易系统产生无关负载

解决方案-秒杀令牌

秒杀接口需要依靠令牌才能进入。

秒杀的令牌由秒杀活动模块负责生成

秒杀活动模块对秒杀令牌生成全权处理,逻辑收口

秒杀下单前需要先获得秒杀令牌。

秒杀令牌代码实现

在秒杀接口PromoService中新增方法:

 /**
     * 生成秒杀用的令牌
     * @param promoId
     * @param itemId
     * @param userId
     * @return
     */
    String generateSecondKillToken(Integer promoId,Integer itemId,Integer userId);

在其实现类PromoServiceImpl中增加实现:

@Override
    public String generateSecondKillToken(Integer promoId,Integer itemId,Integer userId) {
        // 查出对应的PromoModel
        // 获取对应商品的秒杀活动信息
        PromoDO promoDO = promoDOMapper.selectByItemId(promoId);
        // dataobject -> model
        // 若查询的秒杀商品不存在,则返回一个空的令牌
        PromoModel promoModel = convertFromDataObject(promoDO);
        if (promoModel == null){
            return null;
        }
        // 判断当前时间是否秒杀活动即将开始或正在进行
        DateTime now = new DateTime();
        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;
        }

        // 判断商品信息是否存在,不存在则返回null
        ItemModel itemModel = itemService.getItemByIdCache(itemId);
        if (itemModel == null){
            return null;
        }

        // 判断用户是否存在,不存在的化返回null
        UserModel userModel = userService.getUserByIdInCache(userId);
        if (userModel == null){
            return null;
        }

        // 生成秒杀token令牌
        String token = UUID.randomUUID().toString().replace("-","");
        // 将生成的秒杀令牌token保存到Redis中并设置过期时间为5分钟
        redisTemplate.opsForValue().set("promo_token_"+promoId,token);
        redisTemplate.expire("promo_token_"+promoId,5, TimeUnit.MINUTES);
        return token;
    }

在OrderController中增加生成秒杀令牌的方法:

/**
     * 生成秒杀令牌token
     * @param itemId
     * @param promoId
     * @return
     * @throws BusinessException
     */
    @RequestMapping(value = "/generatetoken",method = RequestMethod.POST,consumes = CONTENT_TYPE_FORMED)
    @ResponseBody
    public CommonReturnType createOrder(@RequestParam(name = "itemId")Integer itemId,
                                        @RequestParam(name = "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 = promoServive.generateSecondKillToken(promoId,itemId,userModel.getId());
        // 返回对应的结果
        if (promoToken == null){
            throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"生成令牌失败");
        }
        return CommonReturnType.create(promoToken);
    }

因为现在在生成秒杀令牌token的时候就校验过登录信息和用户信息,所以OrderServiceImpl中的createOrder方法中的以下两段代码就不再需要了:

// 因为在秒杀令牌中做过校验了,所以不再需要再次校验了,注释掉
        /*if (itemModel == null){
            throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"商品信息不存在");
        }

        // UserModel userModel = userService.getUserById(userId);
        // 优化为从Redis缓存中获取相应的用户信息
        UserModel userModel = userService.getUserByIdInCache(userId);
        if (userModel == null){
            throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"用户信息不存在");
        }*/

// 因为在秒杀令牌中做过校验了,所以不再需要再次校验了,注释掉
        // 校验秒杀活动信息
        /*if (promoId != null){
            // 校验对应活动是否存在这个适用商品
            if (promoId.intValue() != itemModel.getPromoModel().getId()){
                throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"秒杀活动信息不正确");
            }else if (itemModel.getPromoModel().getStatus().intValue() !=2){
                // 校验秒杀活动是否正在进行中
                throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR,"秒杀活动还未开始");
            }

        }*/

但是相应的,需要在OrderController中的createOrder方法里获取完用户登录信息后增加对秒杀令牌token的校验。如下:

// 校验秒杀令牌是否正确
        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,"秒杀令牌校验失败");
            }
        }

前端修改getitem.html中的原有下单ajax请求函数为:

$.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,
								"amount":1,
								"promoId":g_itemVO.promoId,
								"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);
				}
			});

就是在创建订单前先调用生成秒杀令牌token的接口获取秒杀令牌token并作为参数传递到秒杀接口中。

重启项目,完成秒杀下单流程,可以发现,当下单时会先向后台请求token,并在下单时将token一起传递给后台下单接口。

秒杀大闸

之前的秒杀令牌实现中,秒杀令牌只要活动一开始就无限制生成,影响系统性能,且最终肯定只有极少数人可以秒杀成功,其他的令牌都是无实际意义的。

秒杀大闸原理

依靠秒杀令牌的授权原理定制化发牌逻辑,做到大闸功能。

根据秒杀商品初始库存颁发对应数量令牌,控制大闸流量。

库存售罄判断前置到秒杀令牌发放中

秒杀大闸代码实现

在之前的PromoServiceImpl中的publishPromo方法即发布模式活动方法最后增加将大闸的限制数字设到redis内的功能:

// 将大闸的限制数字设到redis内,把大闸限制数字设置为秒杀商品数量的5倍
        redisTemplate.opsForValue().set("promo_door_count_"+promoId,itemModel.getStock().intValue() * 5);

然后在PromoServiceImpl类中之前的生成令牌方法generateSecondKillToken方法内最终生成令牌并放入redis之前增加以下获取秒杀大闸的count数量以及校验的代码:

// 获取秒杀大闸的count数量
        long result = redisTemplate.opsForValue().increment("promo_door_count_" + promoId,-1);
        if (result < 0){
            return null;
        }

即如果之前设定好的大闸数量已经全部用完,则不再生成秒杀令牌,无法进行秒杀,可以降低服务器的压力。

接下来可以把之前OrderController里的createOrder下单方法中的以下判断商品库存是否已售罄的逻辑拿到PromoServiceImpl中的generateSecondKillToken生成令牌方法中的最前面并改成若已售罄,则返回null:

// 先从Redis检查是否已售罄
        boolean result = redisTemplate.hasKey("promo_item_stock_invalid_"+itemId);
        if (result){
            throw new BusinessException(EmBusinessError.STOCK_NOT_ENOUGH);
        }

队列泄洪

队列泄洪原理

经过引入秒杀令牌和大闸之后,有效地分离了校验和下单的逻辑并减少了生成令牌的次数,提高了性能。但是如果秒杀商品的库存较大,如10万个,按照之前生成5倍令牌数的逻辑,也会生成50万个令牌。为了解决这种办法,可以使用队列泄洪优化。

队列泄洪虽然是排队,但是也可以比并发更有效。例如redis单线程模型等。

依靠排队和下游拥塞窗口程度调整队列释放流量大小。

队列泄洪实现

在OrderController中添加以下代码:

private ExecutorService executorService;

    @PostConstruct
    public void init(){
        // 创建一个20个可工作线程的线程池
        executorService = Executors.newFixedThreadPool(20);

    }

并将OrderController中原有的下单方法createOrder中的以下代码:

// 变成使用MQ事务型消息
                // 先加入库存流水init状态
                String stockLogId = itemService.initStockLog(itemId,amount);
                // 再去完成对应的下单事务型消息机制
                boolean orderResult = mqProducer.transactionAsyncReduceStock(userModel.getId(),itemId,promoId,amount,stockLogId);
                if (!orderResult){
                    throw new BusinessException(EmBusinessError.UNKNOWN_ERROR,"下单失败");
                }

改成用上面创建好的线程池执行的方式:

// 同步调用线程池的submit方法
// 拥塞窗口为20的等待队列,用来队列化泄洪
        Future<Object> future = executorService.submit(new Callable<Object>() {
            @Override
            public Object call() throws Exception {
                // 变成使用MQ事务型消息
                // 先加入库存流水init状态
                String stockLogId = itemService.initStockLog(itemId,amount);
                // 再去完成对应的下单事务型消息机制
                boolean orderResult = mqProducer.transactionAsyncReduceStock(userModel.getId(),itemId,promoId,amount,stockLogId);
                if (!orderResult){
                    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);
        }

分布式的队列泄洪

另一个可选的的队列泄洪方式是放在如Redis内,完成分布式集中化地限流。但是并不一定用Redis完成队列泄洪就比本地使用线程池更好,因为还有网络开销以及Redis服务不可用等特殊的不可用情况。