提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
前言
完善秒杀项目,增加了新的功能秒杀大闸和队列泄洪。
继续上一个帖子:https://blog.csdn.net/qq_42961251/article/details/127443307?spm=1001.2014.3001.5502
项目链接🔗:https://gitee.com/llbnk/book_activity_platform/tree/master/
问题
在上一个帖子:https://blog.csdn.net/qq_42961251/article/details/127443307?spm=1001.2014.3001.5502
的例子中,我们还存在一些问题。
① 秒杀信息校验是在OrderController
中进行校验的,这样校验过程不但影响我们的交易速度,而且校验过程和下单过程是完全耦合的,这样不利于代码进一步扩展,固可以采用发放令牌的方式来解耦。
② 假如100w个请求在一秒钟同时过来,但库存只有100个,在redis还没有更新完售空标志位时,生成100w个令牌不是对系统资源的极大浪费,固应该采用设置令牌大闸的方式,保证只有生成多余几倍的库存数量令牌后,就不再生成令牌了。
③ 生成令牌和设置大闸的方式并不能解决浪涌流量涌入后系统无法应对的问题,假设库存有10w,在一秒钟之内有50w的请求下单,系统根本无法承受。所以需要我们自己实现本地队列泄洪,解决大量流量涌入。
一、秒杀令牌
在订单按钮按下的时候先请求生成一个秒杀令牌,只有在秒杀令牌申请成功之后才会进入我们之前写的OrderController中的createItem方法。
前端代码
detailitem.html
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://localhost:8888/order/generatetoken?token="+token,
data:{
"itemId": g_itemVO.id,
"promoId": g_itemVO.promoId
},
xhrFields:{withCredentials:true},
success:function(data){
if("success" == data.status){
var promoToken = data.data;
//下单
$.ajax({
type:"POST",
contentType:"application/x-www-form-urlencoded",
url:"http://localhost:8888/order/createOrder?token="+token,
data:{
"itemId": g_itemVO.id,
"amount": 1,
"promoId": g_itemVO.promoId,
"promoToken":promoToken
},
xhrFields:{withCredentials:true},
success:function(data){
if("success" == data.status){
alert("预约成功");
window.location.reload();
}else{
alert("预约失败,原因为"+data.data.errorMsg);
if(data.data.errorCode == 20003){
window.location.href = "login.html";
}
}
},
error:function(data){
alert("预约失败,原因为"+data.data.errorMsg);
}
});
}else{
alert("获取令牌失败,原因为"+data.data.errorMsg);
if(data.data.errorCode == 20003){
window.location.href = "login.html";
}
}
},
error:function(data){
alert("获取令牌失败,原因为"+data.data.errorMsg);
}
});
});
后端在OrderController层增加对应的generatetoken用于生成秒杀令牌,此逻辑调用了PromoService的generateSecondKillToken方法,此方法主要做三件事,第一件判断是否库存售空,第二件校验秒杀活动,商品,和用户。第三件将生成的秒杀令牌放入redis中,以便于后面的createItem方法调用。(此令牌包含了promoId、userId、itemId三个结合字段信息)
后端代码
OrderController
//生成秒杀令牌
@RequestMapping(value = "/order/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);
}
PromoServiceImpl
//生成秒杀令牌
@Override
public String generateSecondKillToken(Integer promoId, Integer itemId, Integer userId) {
//判断是否库存已售空,如果售空就不颁发令牌了
if(redisTemplate.hasKey("promo_item_stock_invalid_"+itemId)){
return null;
}
PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId);
//dataobject->model
PromoModel promoModel = convertFromEntity(promoDO);
//如果promoModel不存在直接生成空令牌
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表示以结束
//只有status为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;
}
//将生成的令牌放入redis中,5min有效期
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;
}
而在createItem方法中需要校验这个秒杀令牌,校验的方式就是将前端中的localStorage中的token和redis中的token做对比。
后端代码
OrderController
//订单创建
@PostMapping(value = "/order/createOrder", consumes = {CONTENT_TYPE_FORMED})
public CommonReturnType createItem(@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 {
//采用分布式登录会话机制检验token
UserModel userModel = checkTokenAndUserInfo();
/**简单用户下单令牌的操作,只有拿到令牌才能下单*/
checkSecondsKillTheToken(promoId,userModel,itemId,promoToken);
String stockLogId = itemService.initStockLog(itemId, amount);
//OrderModel orderModel = orderService.createOrder(userModel.getId(), itemId, promoId, amount);
//因为需要保证MQ中信息发送必须成功,所以采用rocketMQ事务
boolean result = mqProducer.transactionAsyncReduceStock(userModel.getId(), itemId,
promoId, amount,stockLogId);
if(!result){
throw new BusinessException(EmBusinessError.UNKNOW_ERROR,"下单失败");
}
return CommonReturnType.create(null);
}
/**简单用户下单令牌的操作,只有拿到令牌才能下单*/
private void checkSecondsKillTheToken(Integer promoId, UserModel userModel, Integer itemId, String promoToken) throws BusinessException {
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,"秒杀令牌校验失败");
}
}
}
private UserModel checkTokenAndUserInfo() throws BusinessException {
//采取新的放法 分布式会话将token放入redis中
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, "用户还未登录,不能下单");
}
return userModel;
}
二、令牌大闸
假如100w个请求在一秒钟同时过来,但库存只有100个,在redis还没有更新完售空标志位时,生成100w个令牌不是对系统资源的极大浪费,固应该采用设置令牌大闸的方式,保证只有生成多余几倍的库存数量令牌后,就不再生成令牌了。
本程序中设计的是5倍的库存数量。在PromoServiceImpl的generateSecondKillToken实现了大闸。
后端代码
PromoServiceImpl
/生成秒杀令牌
@Override
public String generateSecondKillToken(Integer promoId, Integer itemId, Integer userId) {
//判断是否库存已售空,如果售空就不颁发令牌了
if(redisTemplate.hasKey("promo_item_stock_invalid_"+itemId)){
return null;
}
PromoDO promoDO = promoDOMapper.selectByPrimaryKey(promoId);
//dataobject->model
PromoModel promoModel = convertFromEntity(promoDO);
//如果promoModel不存在直接生成空令牌
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表示以结束
//只有status为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;
}
//这个地方就是令牌大闸的闸门,我们预先准备了5倍库存的令牌
//在这里没生成一个新的令牌就将大闸数量-1直到不发放令牌为止
Long result = redisTemplate.opsForValue().increment("promo_door_count_" + promoId, -1);
if(result < 0){
return null;
}
//将生成的令牌放入redis中,5min有效期
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;
}
二、队列泄洪
生成令牌和设置大闸的方式并不能解决浪涌流量涌入后系统无法应对的问题,假设库存有10w,在一秒钟之内有50w的请求下单,系统根本无法承受。所以需要我们自己实现本地队列泄洪,解决大量流量涌入。
队列泄洪主要是基于排队思想来进行泄洪操作,假设有50w个请求过来,但是我最多可以处理1w个,对不起剩下49w个你都得给我去排队。
实现方式:开启一个线程池,设置系统能够处理请求的数量,在线程池的submit方法中去完成最核心的生成订单,更新redis,mysql的操作。配合future的get方法的阻塞特性。让整个请求处理处于同步状态下。
后端代码
OrderController
//订单创建
@PostMapping(value = "/order/createOrder", consumes = {CONTENT_TYPE_FORMED})
public CommonReturnType createItem(@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 {
//采用分布式登录会话机制检验token
UserModel userModel = checkTokenAndUserInfo();
/**简单用户下单令牌的操作,只有拿到令牌才能下单*/
checkSecondsKillTheToken(promoId,userModel,itemId,promoToken);
/*最核心方法,包括
1.由线程池组建的泄洪闸门
2.设置异步操作流水
3.利用rokcetMQ事务,保证redis,mysql的最终一致性*/
theCoreLogic(itemId,amount,userModel,promoId);
return CommonReturnType.create(null);
}
private void theCoreLogic(Integer itemId, Integer amount, UserModel userModel, Integer promoId) throws BusinessException {
//同步调用线程池的submit方法
//我们首先搞了一个20个大小的线程池
//这个线程池实现callable方法,其实就是搞了一个大小为20的等待队列用来队列化泄洪
//也就是说在一台服务器上只有20个请求能过来下单,剩下的请求全部要过来排队
/*其实就是这个线程池只有20各大小,如果当前请求数量大于20就要将多余的线程数量放入线程池的阻塞队列中进行排队处理*/
Future<Object> future = executorService.submit(new Callable<Object>() {
@Override
public Object call() throws Exception {
//为了根据checkLocalTransaction确定消息的状态,需要引入操作流水(操作型数据:log data)
//他的作用就是先插入一条异步流水,根据这个status用来追踪异步扣减库存这个消息。
//就是根据你这个流水的状态来确定MQ返回是成功还是失败还是不知道
String stockLogId = itemService.initStockLog(itemId, amount);
//OrderModel orderModel = orderService.createOrder(userModel.getId(), itemId, promoId, amount);
//因为需要保证MQ中信息发送必须成功,所以采用rocketMQ事务
boolean result = mqProducer.transactionAsyncReduceStock(userModel.getId(), itemId,
promoId, amount,stockLogId);
if(!result){
throw new BusinessException(EmBusinessError.UNKNOW_ERROR,"下单失败");
}
return null;
}
});
try {
future.get();
} catch (InterruptedException e) {
throw new BusinessException(EmBusinessError.UNKNOW_ERROR);
} catch (ExecutionException e) {
throw new BusinessException(EmBusinessError.UNKNOW_ERROR);
}
}
总结
这三个改进都不是很难,但是确实很常用。