Java秒杀系统设计
系统大概的整体架构
秒杀一般都是在高并发的场景下出现,这时候就需要考虑一些原则性的问题:
1.高并发
高并发是指在某一瞬间,一个接口突然收到爆发式请求,不做很好的限流保护,可能会造成系统的崩溃。
1.1生成订单代码
@Override
public void generateOrderByMq(GenerateOrderDto generateOrderDto) {
log.info("------生成订单开始进行流量削峰-----");
MqMessage<GenerateOrderMqDto> generateOrderDtoMqMessage = new MqMessage<>();
GenerateOrderMqDto generateOrderMqDto = new GenerateOrderMqDto();
generateOrderMqDto.setUserId(SecurityUtils.getUserId());
generateOrderMqDto.setDeliveryType(generateOrderDto.getDeliveryType());
generateOrderMqDto.setIsShopCar(generateOrderDto.getIsShopCar());
generateOrderMqDto.setOneOrderDto(generateOrderDto.getOneOrderDto());
generateOrderDtoMqMessage.setData(generateOrderMqDto);
/*这里用mq的顺序队列 防止并发下库存出现混乱问题*/
Boolean isSend = generateOrderProducer.sendOrderly(generateOrderDtoMqMessage);
}
1.2顺序消费生成者代码
package com.hjt.rocketmq.producer;
import com.hjt.constant.RocketMqConstant;
import com.hjt.message.MqMessage;
import com.hjt.myOrder.dto.GenerateOrderMqDto;
import org.apache.rocketmq.client.producer.SendResult;
import org.apache.rocketmq.client.producer.SendStatus;
import org.apache.rocketmq.spring.core.RocketMQTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.Objects;
/**
* @author :hjt
* @date : 2022/9/29
*/
@Component
public class GenerateOrderProducer {
@Autowired
private RocketMQTemplate rocketMQTemplate;
public Boolean sendOrderly(MqMessage<GenerateOrderMqDto> mqMessage) {
// 有序消费
SendResult sendResult = rocketMQTemplate.syncSendOrderly(RocketMqConstant.RQ_PRODUCER_GENERATE_ORDER_TOPIC, mqMessage, RocketMqConstant.RQ_PRODUCER_GENERATE_ORDER_KEY);
SendStatus sendStatus = sendResult.getSendStatus();
if (Objects.equals(sendStatus, SendStatus.SEND_OK)) {
return true;
}
return false;
}
}
1.3顺序队列消费者代码
package com.hjt.rocketmq.consumer;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.json.JSONUtil;
import com.hjt.constant.RocketMqConstant;
import com.hjt.myOrder.dto.GenerateOrderMqDto;
import com.hjt.myOrder.service.impl.OrderServiceImpl;
import com.hjt.util.OrderPreventMqConsumerUtil;
import org.apache.rocketmq.common.message.Message;
import org.apache.rocketmq.common.message.MessageExt;
import org.apache.rocketmq.spring.annotation.ConsumeMode;
import org.apache.rocketmq.spring.annotation.RocketMQMessageListener;
import org.apache.rocketmq.spring.core.RocketMQListener;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
/**
* @author :hjt
* @date : 2022/9/29
*/
@Component
@RocketMQMessageListener(topic = RocketMqConstant.RQ_PRODUCER_GENERATE_ORDER_TOPIC,
consumerGroup = RocketMqConstant.RQ_CONSUMER_GENERATE_ORDER_CONSUMER_GROUP, consumeMode = ConsumeMode.ORDERLY
)
public class GenerateOrderConsumer implements RocketMQListener<Message> {
private static final Logger log = LoggerFactory.getLogger(GenerateOrderConsumer.class);
@Autowired
private OrderPreventMqConsumerUtil orderPreventMqConsumerUtil;
@Autowired
private OrderServiceImpl orderServiceImpl;
@Override
public void onMessage(Message message) {
//防止消息被重复消费
MessageExt messageExt = (MessageExt) message;
String msgId = messageExt.getMsgId();
log.info("msgId:" + msgId);
//判断是否消费过
boolean consumerMQ = orderPreventMqConsumerUtil.isConsumerMQ(msgId);
if(consumerMQ){
log.error("topic: " + RocketMqConstant.RQ_TOPIC_DELAYED_ORDER + " msgId: " + msgId + " 已被消费");
return;
}
log.info("--------开始进行生成订单处理-----");
String strBody = orderPreventMqConsumerUtil.exchangeStr(message.getBody());
log.info("strBody: {}", strBody);
//json转换
GenerateOrderMqDto mqDto = JSONUtil.toBean(strBody, GenerateOrderMqDto.class);
if(ObjectUtil.isNull(mqDto)){
log.error("-----生成订单实体类为空-----");
return;
}
/*这里是生成订单的业务逻辑*/
Boolean isSuccess = orderServiceImpl.generateOrder(mqDto);
if(!isSuccess){
log.error("====生成订单信息失败===");
}
/*标记消息未已消费*/
orderPreventMqConsumerUtil.addConsumerMQ(msgId);
}
}
1.4生成订单代码
@Override
public Boolean generateOrder(GenerateOrderMqDto generateOrderMqDto) {
//初始化返回类型
List<ProductDto> products = new ArrayList<>();
Long countAllPro = 0L;
//计算总商品金额
BigDecimal totalMoney = BigDecimal.ZERO;
//商品id,多的以,分割
String countAllProIds = "";
List<OneOrderDto> oneOrderDto = generateOrderMqDto.getOneOrderDto();
if (CollectionUtils.isEmpty(oneOrderDto)) {
log.error("订单信息不能为空");
return false;
}
//先根据雪花算法生成订单id
Long orderId = snowFlake.nextId();
//遍历计算每个商品对应的金额或者库存等等。。。
for (int i = 0; i < oneOrderDto.size(); i++) {
OneOrderDto oneOrder = oneOrderDto.get(i);
//初始化商品
Product product = null;
//先查下该商品是否有库存
LambdaQueryWrapper<Product> lambdaProduct = new LambdaQueryWrapper<>();
lambdaProduct.eq(Product::getIsDelete, 0);
lambdaProduct.eq(Product::getId, oneOrder.getProId());
product = productMapper.selectOne(lambdaProduct);
//商品已下架
if (ObjectUtil.isNull(product)) {
log.error("订单模块,商品id:" + oneOrder.getProId() + "已下架");
return false;
}
//判断商品是否有库存
if (product.getStock() <= 0) {
log.error("订单模块,商品id:" + oneOrder.getProId() + "没有库存,请重新选择");
return false;
}
/**对该商品进行加锁*/
if (!versionLockUtil.versionLockReduceStock(product, lambdaProduct, oneOrder.getCount())) {
log.error("订单模块,商品id:" + oneOrder.getProId() + "版本号机制获取库存失败,请稍微再试");
return false;
}
//计算商品总金额
countAllPro = countAllPro + oneOrder.getCount();
//金额计算
BigDecimal proCount = new BigDecimal(oneOrder.getCount());
BigDecimal totalOnePro = product.getPrice().multiply(proCount);
totalMoney = totalMoney.add(totalOnePro);
//统计商品id
countAllProIds = countAllProIds + String.valueOf(oneOrder.getProId()) + ",";
//初始化
ProductDto productDto = new ProductDto();
productDto.setId(oneOrder.getProId());
productDto.setStock(oneOrder.getCount());
productDto.setPrice(product.getPrice());
productDto.setProTitle(product.getProTitle());
productDto.setOrderId(orderId);
productDto.setUserId(SecurityUtils.getUserId());
products.add(productDto);
/*插入订单商品信息表*/
OrderProductInfo orderProductInfo = new OrderProductInfo();
orderProductInfo.setUserId(SecurityUtils.getUserId());
orderProductInfo.setOrderId(orderId);
orderProductInfo.setCount(oneOrder.getCount());
orderProductInfo.setProductId(oneOrder.getProId());
orderProductInfoMapper.insert(orderProductInfo);
}
Order oneOrder = new Order();
oneOrder.setOrderId(orderId);
//TODO 防止重复具体后续再实现
//用rocketmq处理延迟付款
MqMessage<List<ProductDto>> mqMessage = new MqMessage<>();
mqMessage.setData(products);
// 超时 没付款 回退库存
delayedOrderProducer.producerDelayedOrderSendOne(orderId, mqMessage);
//初始化订单信息
oneOrder.setCreateTime(LocalDateTime.now());
oneOrder.setUserId(generateOrderMqDto.getUserId());
oneOrder.setDeliveryType(generateOrderMqDto.getDeliveryType());
//金额计算
oneOrder.setTotal(totalMoney);
oneOrder.setAllCount(countAllPro);
oneOrder.setProId(countAllProIds);
//未支付
oneOrder.setIsPayed(false);
oneOrder.setDeleteStatus(0);
oneOrder.setVersion(0);
orderMapper.insert(oneOrder);
log.info("--------------订单信息初始化成功");
/**异步修改商品库存*/
this.asyncUpdateStockProducer(generateOrderMqDto.getOneOrderDto());
return true;
}
需要注意的问题:
-
mq队列
不可以自定义抛出异常
,例如:throw new BaseException(AuthException.AUTH_ERROR_MOBILE_EXIST);如果这样,mq会一直重试mq(重试16次,该消息会被重试16次,业务逻辑处理不好的话,会造成16次重复消费,导致业务上出现逻辑问题。)博主认为正确的做法应该是 记录错误的信息,然后就return返回了,后续再单独对这些错误的信息进行业务处理。 -
mq防止被重复消费,这里博主用的是新建一张表,用一张表的主键去判断是否被消费过。(其实也可以用redis中set数据类型防止重复消费)
ps:博主用上述代码,用JMeter测压测过1s1000000个请求生成订单也没问题。库存正常减少,包括生成订单后没付款也自己加回库存。
2.超卖解决方案
加乐观锁(为了效率问题抛弃悲观锁,且悲观锁可能会造成死锁问题)
乐观锁代码
/***
* 版本号机制 更新库存(减库存)
* @param product
* @param lambdaProduct
* @param count 商品库存
*/
public Boolean versionLockReduceStock(Product product, LambdaQueryWrapper<Product> lambdaProduct, Long count) {
/*乐观锁 版本号机制*/
/*用redis存版本号*/
Long version = getVersion(product.getId());
Product newProduct = productMapper.selectOne(lambdaProduct);
Long newVersion = getVersion(newProduct.getId());
/*redis获取库存*/
Long stock = 0L;
stock = getStock(product.getId());
/*版本号一致就更新*/
if (version.equals(newVersion)) {
log.info("-----版本号一致,商品id为:" + product.getId() + "--- 版本号为:" + version);
if (stock < count) {
log.error("====当前商品没有足够的库存=====");
return false;
}
stock = stock - count;
log.info("当前库存为:" + newProduct);
product.setStock(stock);
redisUtil.set(RedisConstants.MODULES_ORDER_GENERATE_VERSION_KEY + String.valueOf(newProduct.getId()), newVersion + 1);
/*redis修改库存*/
if (redisUtil.set(RedisConstants.MODULES_ORDER_GENERATE_STOCK_KEY + newProduct.getId(), stock)) {
return true;
} else {
return false;
}
}
/*版本号不一致*/
else {
log.error("-----版本号不一致,商品id为:" + product.getId() + "--- 版本号为:" + version);
/**睡眠2s 有3次机会*/
for (int i = 0; i < 3; i++) {
log.error("第:" + 1 + "次机会重试");
ThreadUtil.safeSleep(2 * 1000);
Product newestProduct = productMapper.selectOne(lambdaProduct);
this.versionLockReduceStock(newestProduct, lambdaProduct, count);
}
}
return false;
}
乐观锁建议是Version版本可以用redis来控制,用mysql中表的字段的话还需要update,如果请求多的时候,更新的语句其实本质上也是加锁,会造成程序的性能不高。
当然也可以用redisson的分布式锁,看门狗机制还是挺香的。
3.恶意请求(用Sentinel做限流)
用的是Sentinel进行请求保护
Sentinel 以流量为切入点,从流量控制、熔断降级、系统负载保护等多个维度保护服务的稳定性。
建议nacos要和Sentinel结合,防止出现配置丢失
(ps:Sentinel如何安装使用请看我另外一篇博客)
/***
* 秒杀流量削峰,避免1s收到很多请求
* 这里用Sentinel防止生成订单被冲击
*/
@ApiOperation(value = "秒杀流量削峰,避免1s收到很多请求,生成订单(包含多个订单)")
@RequestMapping(method = RequestMethod.POST,value = "/generateOrderByMq")
@Log(title = "秒杀流量削峰,避免1s收到很多请求,生成订单(包含多个订单)")
@SentinelResource(value = "generateOrderByMq", blockHandler = "handleException",blockHandlerClass = OrderSentinelHandler.class)
public AjaxResult generateOrderByMq(@Valid @RequestBody GenerateOrderDto generateOrderDto) {
orderService.generateOrderByMq(generateOrderDto);
return AjaxResult.success();
}
主要代码
@SentinelResource(value = "generateOrderByMq", blockHandler = "handleException",blockHandlerClass = OrderSentinelHandler.class)
/**
* @author :hjt
* @date : 2022/10/10
* Sentinel 自定义异常处理类
* 异常状态码 8000-9000
*/
public class OrderSentinelHandler {
public static AjaxResult handleException(BlockException exception){
return new AjaxResult(8001,"生成订单请求次数过多,请稍后再试");
}
}
这里设置
QPS=并发量/平均响应时间
200只是个粗略的数值,具体可以根据你的业务进行调整。
4.链接暴露问题
链接加密(博主采用的是RSA加密算法)
具体加密算法可以看我这篇博客
Java加密算法
这是请求订单的接口(加密后的参数这是前端直接传给我们的,这里我们模拟的是前端加密的场景)
Java代码例子
//加密
@Encrypt
@PostMapping("/encryption")
public GenerateOrderDto encryption(@Valid @RequestBody GenerateOrderDto generateOrderDto){
GenerateOrderDto generateOrderDto1 = new GenerateOrderDto();
// BeanUtil.copyProperties(generateOrderDto,generateOrderDto1);
long time = System.currentTimeMillis();
generateOrderDto.setTimestamp(time);
return generateOrderDto;
}
//解密
@Decrypt
@PostMapping("/decryption")
public AjaxResult Decryption(@RequestBody GenerateOrderDto generateOrderDto){
return AjaxResult.success(generateOrderDto.toString());
}
友情提示:
详情代码可以看下面我的个人项目地址 。https://github.com/hongjiatao/spring-boot-anyDemo
后端拿到参数进行解密
需要注意的是,再次请求该解密的接口,会报错。
这里博主还设置了一个参数只能被解密一次,也就是说前端传过来的加密参数只能用一次,这样做的好处就是即使黑客人员拿到我们加密后的参数,也无法大批量的去请求订单接口,有效的防止订单接口被黑客利用工具直接请求。
5.自定义注解+AOP+Redis 防止重复提交
- 在需要防止重复提交的接口的方法,加上注解。
- 发送请求写接口携带 Token
- 请求的路径+ Token 拼接程 key+UUID(由前端生成返回给我们)
- key默认存在一天的时间。
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package com.hjt.aspect;
import com.hjt.annotation.NoRepeatSubmit;
import com.hjt.myException.BaseException;
import com.hjt.util.RedisUtil;
import com.hjt.util.RedissonUtil;
import com.hjt.util.ServletUtils;
import com.hjt.utils.StringUtils;
import java.util.UUID;
import javax.servlet.http.HttpServletRequest;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.Assert;
@Aspect
@Component
public class RepeatSubmitAspect {
private static final Logger log = LoggerFactory.getLogger(RepeatSubmitAspect.class);
@Autowired
private RedissonUtil redissonUtil;
@Autowired
private RedisUtil redisUtil;
public RepeatSubmitAspect() {
}
@Pointcut("@annotation(noRepeatSubmit)")
public void pointCut(NoRepeatSubmit noRepeatSubmit) {
}
@Around("pointCut(noRepeatSubmit)")
public Object around(ProceedingJoinPoint pjp, NoRepeatSubmit noRepeatSubmit) throws Throwable {
int lockSeconds = noRepeatSubmit.lockTime();
HttpServletRequest request = ServletUtils.getRequest();
Assert.notNull(request, "request can not null");
String bearerToken = request.getHeader("Authorization");
String requestUUID = request.getHeader("requestUUID");
if (StringUtils.isBlank(requestUUID)) {
throw new BaseException("防止重复提交", "602", "requestUUID为空");
} else {
String path = request.getServletPath();
String resultKey = path + ":" + bearerToken + ":" + requestUUID;
if (this.redisUtil.hasKey(resultKey)) {
throw new BaseException("防止重复提交", "603", "出现重复提交");
} else {
this.redisUtil.set(resultKey, 1, (long)lockSeconds);
log.info(" resultKey = [{}], requestUUID = [{}]", resultKey, requestUUID);
Object result = pjp.proceed();
return result;
}
}
}
private String getKey(String token, String path) {
return token + path;
}
private String getClientId() {
return UUID.randomUUID().toString();
}
}
//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//
package com.hjt.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface NoRepeatSubmit {
int lockTime() default 86400;
}
只需要加上这个注解 即可 @NoRepeatSubmit 由前端传UUID给后端。
/***
* 秒杀流量削峰,避免1s收到很多请求
* 这里用Sentinel防止生成订单被冲击
*/
@ApiOperation(value = "秒杀流量削峰,避免1s收到很多请求,生成订单(包含多个订单)")
@RequestMapping(method = RequestMethod.POST,value = "/generateOrderByMq")
@Log(title = "秒杀流量削峰,避免1s收到很多请求,生成订单(包含多个订单)")
@SentinelResource(value = "generateOrderByMq", blockHandler = "handleException",blockHandlerClass = OrderSentinelHandler.class)
@NoRepeatSubmit
public AjaxResult generateOrderByMq(@Valid @RequestBody GenerateOrderDto generateOrderDto) {
orderService.generateOrderByMq(generateOrderDto);
return AjaxResult.success();
}
TODO // 2022/10/24写
6.数据库问题怎么防止被击穿。
后续补上。
个人搭建项目代码地址:
https://github.com/hongjiatao/spring-boot-anyDemo
欢迎收藏点赞三连。谢谢!有问题可以留言博主会24小时内无偿回复。