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