关于削峰技术,常用场景例如秒杀。为什么要流量削峰?之所以叫秒杀,也就是第一秒的时候流量涌入的问题,瞬时流量变大可能对机器造成影响,因此我们需要把第一秒的流量平滑的过度掉,削弱峰值,把流量平滑的过渡到第二秒或者后面,让系统性能有平滑的提升。
对于我们现在没有做任何操作的时候,秒杀下单的接口会被脚本不停地刷。秒杀验证逻辑和秒杀下单接口强关联,代码冗余度高。秒杀验证逻辑复杂,对交易系统产生无关联负载。
因此我们引入了秒杀令牌原理:
秒杀接口需要依靠令牌才能进入。秒杀令牌由秒杀活动模块负责生成。
秒杀活动模块对秒杀令牌生成全权处理,逻辑收口。
秒杀下单前用户需要先获得令牌才能秒杀。
接下来实战一下:
把用户校验的全部写到一个接口中:
//生成秒杀令牌
@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 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.UNKNOWN_ERROR , "生成令牌失败");
}
return CommonReturnType.create(promoToken);
}
进一步进行判断,通过发token
@Override
public String generateSecondKillToken(Integer promoId , Integer itemId , Integer userId) {
//获取对应商品的秒杀活动信息
PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId);
//dataobject->model
PromoModel promoModel = convertFromDataObject(promoDO);
if(promoModel == null){
return null;
}
//判断当前时间是否秒杀活动即将开始或正在进行
if(promoModel.getStartDate().isAfterNow() || promoModel.getEndDate().isBeforeNow()){
return null;
}
//判断item信息
ItemModel itemModel =itemService.getItemByIdInCache(itemId);
if(itemModel == null){
return null;
}
//判断用户信息
UserModel userModel = userService.getUserByIdInCache(userId);
if(userModel == null){
return null;
}
String token = UUID.randomUUID().toString().replace("-" , "");
redisTemplate.opsForValue().set("promo_token_" + promoId + "_user_" + userId + "_item_" + itemId , token);
redisTemplate.expire("promo_token_" + promoId + "_user_" + userId + "_item_" + itemId , 5 , TimeUnit.MINUTES);
return token;
}
如此,前端在下单请求中带这个token,后端下单接口中校验这个token是否合法即可,这样就做到了校验和下单分离。但是现在还有个问题是只要用户请求,都会颁发token,所以我们接下来要做限制,也就是秒杀大闸:
秒杀大闸的原理:
依靠秒杀令牌的授权原理定制化发牌逻辑(控制令牌发放),那么我们就能做到类似大闸的功能。
我们可以根据秒杀商品初始库存颁发对应数量的令牌,控制大闸流量。
用户风控策略前置到秒杀令牌发放中。
库存售罄判断前置到秒杀令牌发放中。
这里就不编码了,实现起来很简单,就是在发布的时候,把允许生成令牌的数量放到redis中,每次生成令牌的时候就-1。
当然,这样还是有缺陷的,假如秒杀商品有10w个,那么对于系统浪涌流量涌入还是无法应对的,且这样的做法只是对于单库存而言,对于多库存多商品的令牌限制能力弱。
接下来解决一下这些问题,也就是队列泄洪功能。
队列泄洪原理,就是排队的策略,因为排队有时比并发更高效。依靠排队来限制并发流量,依靠排队和下游用塞窗口程度调整队列释放流量大小。
private ExecutorService executorService;
@PostConstruct
public void init(){
executorService = Executors.newFixedThreadPool(20);
}
//封装下单请求
@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 redisPromoToken = (String) redisTemplate.opsForValue().get("promo_token_" + promoId + "_user_" + userModel.getId() + "_item_" + itemId);
if (redisPromoToken == null || !StringUtils.equals(redisPromoToken , promoToken)){
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR , "秒杀令牌校验失败");
}
}
//同步调用线程池的submit方法
//用塞窗口为20的等待队列,用来队列化泄洪
Future<Object> future = executorService.submit(new Callable<Object>() {
@Override
public Object call() throws Exception {
//加入库存流水init状态
String stockLogId = itemService.initStockLog(itemId , amount);
//再去完成对应的下单事务型消息机制
boolean result = mqProducer.transactionAsyncReduceStock(userModel.getId() , promoId , itemId , amount , stockLogId);
if (!result){
throw new BusinessException(EmBusinessError.UNKNOWN_ERROR , "下单失败");
}
return null;
}
});
try {
future.get();
} catch (InterruptedException e) {
e.printStackTrace();
throw new BusinessException(EmBusinessError.UNKNOWN_ERROR , "下单失败");
} catch (ExecutionException e) {
e.printStackTrace();
throw new BusinessException(EmBusinessError.UNKNOWN_ERROR , "下单失败");
}
return CommonReturnType.create(null);
}
其实实现方式很简单,创建一个线程池,把下单放到线程中,block自己等待返回,然后再返回给前端。
以上是本地泄洪的策略,当然我们还有分布式的策略,将队列设置到外部redis中,由分布式队列来管理。这个后面再说。