为什么会存在流量削峰?
主要是包含两个方面的能力,在之前经历了查询优化技术、交易优化技术使用了缓存校验 + 异步扣减库存的方式使得秒杀下单的性能有了明显提升。但是,再这样的一些技术当中,仍然不得不面临一些问题,也就是浪涌洪峰流量的涌入问题,秒杀瞬间的流量很大,我么需要将涌入的巨大流量平滑的过渡掉。因此,流量削峰技术应运而生
目标
掌握秒杀令牌的原理和使用方式
掌握秒杀大闸的原理和使用方式
掌握队列泄洪的原理和使用方式
目前秒杀系统的缺陷
秒杀下单接口会被脚本不停的刷新:秒杀的下单接口就是一个公网的 URL 地址,对应这样的一个 json 请求的地址,只要用户知道自己的 token 和 商品 ID 和 秒杀商品 ID,就可以写一段脚本通过不断发送 HTTP 请求的方式 post 到后端,这样就会影响正常用户的下单购买并且会增加系统压力
秒杀验证逻辑和秒杀下单接口强关联,代码冗余度高
秒杀验证逻辑复杂,对交易系统产性无关联负载:交易要解决的生成对应的交易号、落单、扣减对应的库存,校验活动状态和用户合法性并非是交易逻辑索要承担的事情,因此引入秒杀令牌
秒杀令牌
秒杀接口需要依靠令牌才能进入:秒杀接口需要新增一个入参表示前端用户获得传入的一个令牌才能进入秒杀下单逻辑
秒杀的令牌由秒杀活动模块负责生成:交易系统仅负责验证令牌可靠性
@Override
public String generateSecondKillToken(Integer promoId, Integer itemId, Integer userId) {
//获取对应商品的秒杀活动信息
PromoDO promoDO = promoDOMapper.selectByItemId(promoId);
//dataobject->model
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);
}
// 判断活动是否正在进行
if (promoModel.getStatus().intValue() != 2) {
return null;
}
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, token);
redisTemplate.expire("promo_token_" + promoId, 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 {
//根据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);
}
所以下单接口中这些验证可以不要了,在生成令牌的时候做了
问题:秒杀令牌只要活动一开始就无限制生成,影响系统性能
秒杀大闸
依靠秒杀令牌的授权原理定制化发牌逻辑,做到大闸功能
根据秒杀商品初始库存颁发对应数量令牌,控制大闸流量
用户风控策略前置到秒杀令牌发放中
库存售罄判断前置到秒杀令牌发放中
@Override
public void publishPromo(Integer promoId) {
//通过活动id获取活动
PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId);
if(promoDO.getItemId() == null || promoDO.getItemId().intValue() == 0){
return;
}
ItemModel itemModel = itemService.getItemById(promoDO.getItemId());
//将库存同步到redis内
redisTemplate.opsForValue().set("promo_item_stock_"+itemModel.getId(), itemModel.getStock());
//将大闸的限制数字设到redis内
redisTemplate.opsForValue().set("promo_door_count_"+promoId,itemModel.getStock().intValue() * 5);
}
在 generateSecondKillToken 方法中添加
//获取秒杀大闸的count数量
long result = redisTemplate.opsForValue().increment("promo_door_count_"+promoId,-1);
if(result < 0){
return null;
}
缺点:多库存、多商品等令牌限制能力弱,浪涌流量涌入后系统无法应对
队列泄洪
排队有些时候比并发更高效(例如redis单线程模型,innodb mutex key等)
依靠排队去限制并发流量
依靠排队和下游拥塞窗口程度调整队列释放流量大小
@Controller("order")
@RequestMapping("/order")
@CrossOrigin(origins = {"*"},allowCredentials = "true")
public class OrderController extends BaseController {
@Autowired
private OrderService orderService;
@Autowired
private HttpServletRequest httpServletRequest;
@Autowired
private RedisTemplate redisTemplate;
@Autowired
private MqProducer mqProducer;
@Autowired
private ItemService itemService;
@Autowired
private PromoService promoService;
private ExecutorService executorService;
@PostConstruct
public void init(){
executorService = Executors.newFixedThreadPool(20);
}
//生成秒杀令牌
@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.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(!org.apache.commons.lang3.StringUtils.equals(promoToken,inRedisPromoToken)){
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);
//再去完成对应的下单事务型消息机制
if(!mqProducer.transactionAsyncReduceStock(userModel.getId(),itemId,promoId,amount,stockLogId)){
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);
}
return CommonReturnType.create(null);
}
}