预约活动(秒杀)项目中如何高效的保证下单交易成功?保证redis,mysql的最终一致性?
前言
`在秒杀项目中,秒杀下单的过程中都需要经历四步,分别为:
1.校验下单状态,商品是否存在,用户是否合法,购买数量是否正确
2.落单减库存,支付减库存
3.订单入库
4.返回前端
这个也就是秒杀项目中的优化核心。下面来讲解是如何进行秒杀过程优化的。
参考工程连接地址:https://gitee.com/llbnk/book_activity_platform
提示:以下是本篇文章正文内容,下面案例可供参考
一、redis引入
1.redis实现对用户账户缓存和商品缓存
可以看到最开始的项目,没有redis的引入,是需要进行多次查询数据库操作。
可以看到在校验环节查询了三次表一次Item表,一次userInfo表,一次promo活动信息表。
第二扣库存环节扣减了stock表的库存。所以需要操作两次stock表一次查询一次。
第三订单入库相当于插入order表这么一次mysql操作。
一共6次操作。
代码
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) throws BusinessException {
//判断是否登录
Boolean isLogin = (Boolean) httpServletRequest.getSession().getAttribute("IS_LOGIN");
if(isLogin == null || !isLogin){
throw new BusinessException(EmBusinessError.USER_NOT_LOGIN, "请登录后再进行预约");
}
//获取用户的登录信息
UserModel userModel = (UserModel) httpServletRequest.getSession().getAttribute("LOGIN_USER");
if(redisTemplate.hasKey("promo_item_stock_invalid_"+itemId)){
throw new BusinessException(EmBusinessError.STOCK_NOT_ENOUGH);
}
OrderModel orderModel = orderService.createOrder(userModel.getId(), itemId, promoId, amount);
return CommonReturnType.create(null);
}
OrderServiceImpl
//promoId非空表示已预约活动价格下单(也就是秒杀商品Id)
public OrderModel createOrder(Integer userId, Integer itemId, Integer promoId, Integer amount) throws BusinessException {
//1.校验下单状态,商品是否存在,用户是否合法,购买数量是否正确
ItemModel itemModel = itemService.getItemById(itemId);
if (itemModel == null) {
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "商品信息不存在");
}
UserModel userModel = userService.getUserById(userId);
if (userModel == null) {
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "用户信息不存在");
}
if (amount <= 0 || amount > 99) {
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "数量信息不正确");
}
//校验活动信息
if (promoId != null) {
//校验对应活动是否存在这个适用商品
if (!promoId.equals(itemModel.getPromoModel().getId())) {
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "活动信息不正确");
} else if (itemModel.getPromoModel().getStatus() != 2) {
//活动是否正在进行中
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "活动还未开始");
}
}
//2.落单减库存,支付减库存
boolean result = itemService.decreaseStock(itemId, amount);
if (!result) {
throw new BusinessException(EmBusinessError.STOCK_NOT_ENOUGH);
}
//3.订单入库
OrderModel orderModel = new OrderModel();
orderModel.setItemId(itemId);
orderModel.setUserId(userId);
orderModel.setAmount(amount);
orderModel.setItemPrice(itemModel.getPrice());
//orderModel.setOrderPrice(itemModel.getPrice().multiply(new BigDecimal(amount)));
//orderModel.setOrderPrice(itemModel.getPrice().multiply(new BigDecimal(amount)));
if (promoId != null) {
//商品价格取特价
orderModel.setItemPrice(itemModel.getPromoModel().getPromoItemPrice());
} else {
orderModel.setItemPrice(itemModel.getPrice());
}
orderModel.setPromoId(promoId);
orderModel.setOrderPrice(orderModel.getItemPrice().multiply(new BigDecimal(amount)));
//生成交易流水号
orderModel.setId(generateOrderNo());
OrderDO orderDO = convertFromOrderModel(orderModel);
orderDOMapper.insertSelective(orderDO);
//加上商品的销量
itemService.increaseSales(itemId, amount);
//4.返回前端
return orderModel;
}
如果由redis预先缓存user信息,和item信息相当于在第一步校验下单的过程省去了两次查表,这就极大的提升了下单的速度。
OrderServiceImpl
@Transactional
promoId非空表示已预约活动价格下单
public OrderModel createOrder(Integer userId, Integer itemId, Integer promoId,
Integer amount,String stockLogId) throws BusinessException {
//1.校验下单状态,商品是否存在,用户是否合法,购买数量是否正确
verifyingUserInformation(itemId,userId,amount,promoId);
//2.落单减库存,支付减库存
orderDecreaseStock(itemId, amount);
//3.订单入库
OrderModel orderModel = orderintoMysql(itemId, userId, amount, promoId);
//4.返回前端
return orderModel;
}
/**负责createOrder中的用户校验工作*/
private void verifyingUserInformation(Integer itemId, Integer userId,
Integer amount,Integer promoId) throws BusinessException {
//这一步是没有缓存的直接查mysql ItemModel itemModel = itemService.getItemById(itemId);
//这一步是走缓存的
ItemModel itemModel = itemService.getItemByIdInCache(itemId);
if (itemModel == null) {
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "商品信息不存在");
}
/**这个地方如果采用token登录模式这个地方是不用放进redis中进行验证user信息的,他这个采用的是oss模型所以需要
* 将用户信息缓存在redis中提高效率 UserModel userModel = userService.getUserById(userId);*/
UserModel userModel = userService.getUserByIdInCache(userId);
if (userModel == null) {
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "用户信息不存在");
}
if (amount <= 0 || amount > 99) {
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "数量信息不正确");
}
//校验活动信息
if (promoId != null) {
//校验对应活动是否存在这个适用商品
if (!promoId.equals(itemModel.getPromoModel().getId())) {
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "活动信息不正确");
} else if (itemModel.getPromoModel().getStatus() != 2) {
//活动是否正在进行中
throw new BusinessException(EmBusinessError.PARAMETER_VALIDATION_ERROR, "活动还未开始");
}
}
}
ItemServiceImpl
/**用于商品活动缓存设置,因为生成订单需要两部分,第一部分需要验证商品,第二部分要验证用户*/
@Override
public ItemModel getItemByIdInCache(Integer id) {
ItemModel itemModel = (ItemModel) redisTemplate.opsForValue().get("item_validate_" + id);
if (itemModel == null) {
itemModel = this.getItemById(id);
redisTemplate.opsForValue().set("item_validate_" + id, itemModel);
redisTemplate.expire("item_validate_" + id, 10, TimeUnit.MINUTES);
}
return itemModel;
}
UserServiceImpl
@Override
public UserModel getUserByIdInCache(Integer id) {
UserModel userModel = (UserModel) redisTemplate.opsForValue().get("user_validate_"+id);
if(userModel == null){
userModel = this.getUserById(id);
redisTemplate.opsForValue().set("user_validate_"+id,userModel);
redisTemplate.expire("user_validate_"+id,10, TimeUnit.MINUTES);
}
return userModel;
}
2.redis实现扣减库存优化
如果每次下单都需要去数据库中去减库存,这样又1w条请求来,都要查询去更改同一件商品的stock数量,会很慢。因为在更改数据的时候会触发行锁。这样其他线程请求就需要去等当前线程更改完数据库才能进行更改,效率太低了。
代码
ItemStockDOMapper.xml
<update id="decreaseStock">
<!--
这个update语句的意思是通过itemid来进行更新stock而输入的数是amount
这个amount必须小于库存数量,而且在这个地方是做减法
-->
update item_stock
set stock = stock - #{amount}
<!--每次操作都会在这增加行锁-->
where item_id = #{itemId} and stock >= #{amount}
</update>
如果我们直接用redis来进行更下单扣减库存操作呢?
1、redis先从数据库中取出库存数量
2、有下单请求时,每次来的请求都去扣减redis中的数量(因为redis是内存级别的效率非常高)。
3、最终直到redis中的库存减到0,停止售卖次商品。
OrderServiceImpl
/**负责createOrder中的减库存操作*/
private void orderDecreaseStock(Integer itemId, Integer amount) throws BusinessException {
boolean result = itemService.decreaseStock(itemId, amount);
if (!result) {
throw new BusinessException(EmBusinessError.STOCK_NOT_ENOUGH);
}
}
ItemServiceImpl
@Override
@Transactional
public boolean decreaseStock(Integer itemId, Integer amount) {
Long result = redisTemplate.opsForValue()
.increment("promo_item_stock_" + itemId, amount.intValue() * -1);
if (result >= 0) { // > 变 >= 再分化
return true;
}else {
//库存为负,此次交易失败,补回redis中库存
redisTemplate.opsForValue().increment("promo_item_stock_" + itemId, amount.intValue());
return false;
}
}
好处:因为redis是内存级别的效率非常高
坏处:redis和数据库中的数据不同步,如果redis宕机会导致用户购买商品的数量信息全部丢失。简单来说就是缓存中的数据未能和数据库中的数据产生任何一致性,这种方法是不可取的
二、RocketMQ引入
1.思考为什么要引入MQ
因为要同步redis和Mysql中的数据同步。
而MQ的左右就是在扣除redis中的stock之后,发送消息给Mysql让数据库扣减库存,异步更新Mysql保证redis和Mysql的最终一致性。
关于rocketMQ的一般使用和简单概念,可看我这篇博客:https://blog.csdn.net/qq_42961251/article/details/127100154?spm=1001.2014.3001.5502
但是每当引入了一个新的结构,系统变得复杂了,此时就需要考虑这个下单过程中会出现那些问题,采取什么样的结构。
2.下单采取的结构
第一种结构采用同步异步更新数据库
RocketMQ采用同步方式发送更新数据库消息,必定等到broker成功发送消息给consumer才能算订单成功,返回给前端。
第二种结构采用的是前端轮询加后端假异步
RocketMQ采用异步发送方式,并不需要等待消息是否发送成功,但是前端不能直接返回给用户,说下单成功,前端采用长轮询方式不断的访问后端是都更新数据库成功了,只有后端都成功才会发给前端消息,所以也称之为假异步。
本项目中我们采用的是第一种结构
2.MQ遇到的问题
MQ发送更新数据库扣减库存的消息,应该在校验环节和订单生成环节都完成之后,在进行MQ的发送。因为spring中的@Transactional
只能回滚Mysql并不能回滚MQ发送的消息。如果MQ先发送消息,之后订单orderintoMysql
方法出错了,此时就会白白的在Mysql中扣除一个库存导致,订单少买
如何解决这个问题呢?
使用RocketMQ事务机制。
3.RocketMQ事务机制
RocketMQ事务之正常事务过程
可以采用RokcetMQ中的事务发送,将校验和生成订单放在producer的本地事务来做,只有本地事务成功了,才会发送这条消息给Consumer,本地事务失败了,MQ就不会发送消息给broker,整个订单就会回滚。
RocketMQ事务之正常补偿过程
假设在创建订单createOrder这个过程发生了很长的时间,创建订单在数据库压力比较大的情况下可能用了10s多。这样MQ就发现本地事务一直没有说提交成功还是提交失败回滚,处于一个UNKNOW的状态于是就需要checkLocalTransaction
方法回调判断是否下单是否是成功的。
我们需要创建一个新的数据表流水日志表stockLog
表。
当前stockLog
中的status决定当前订单的状态,2代表成功,3代表回滚,1代表不知道。
每次当订单生成之后改动当前这条流水的status状态,决定是否补偿结果。
代码
MqConsumer
@Component
public class MqConsumer {
private DefaultMQPushConsumer consumer;
@Value("${mq.nameserver.addr}")
private String nameAddr;
@Value("${mq.topicname}")
private String topicName;
@Resource
private ItemStockDOMapper itemStockDOMapper;
@PostConstruct
public void init() throws MQClientException {
consumer = new DefaultMQPushConsumer("stock_consumer_group");
consumer.setNamesrvAddr(nameAddr);
//订阅所有topicName的消息
consumer.subscribe(topicName,"*");
//consumer处理过程
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(
List<MessageExt> msgs, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
//实现库存真正到数据库内扣减的逻辑
Message msg = msgs.get(0);
String jsonString = new String(msg.getBody());
Map<String, Object> map = JSON.parseObject(jsonString, Map.class);
Integer itemId = (Integer) map.get("itemId");
Integer amount = (Integer) map.get("amount");
itemStockDOMapper.decreaseStock(itemId,amount);
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
consumer.start();
}
}
MqProducer
@Component
public class MqProducer {
private DefaultMQProducer producer;
//为了保证MQ发送一定成功所以需要使用rocketmq中的事务
private TransactionMQProducer transactionMQProducer;
@Value("${mq.nameserver.addr}")
private String nameAddr;
@Value("${mq.topicname}")
private String topicName;
@Resource
private OrderService orderService;
@Resource
private StockLogDOMapper stockLogDOMapper;
@PostConstruct
public void init() throws MQClientException {
//做mq producer的初始化
producer = new DefaultMQProducer("producer_group");
producer.setNamesrvAddr(nameAddr);
producer.start();
transactionMQProducer = new TransactionMQProducer("transaction_producer_group");
transactionMQProducer.setNamesrvAddr(nameAddr);
transactionMQProducer.start();
transactionMQProducer.setTransactionListener(new TransactionListener() {
@Override
public LocalTransactionState executeLocalTransaction(Message message, Object arg) {
//真正要做的事情,就是创建订单 这步就是MQ事务中本地事务部分
Integer itemId = (Integer) ((Map)arg).get("itemId");
Integer promoId = (Integer) ((Map)arg).get("promoId");
Integer userId = (Integer) ((Map)arg).get("userId");
Integer amount = (Integer) ((Map)arg).get("amount");
String stockLogId = (String) ((Map)arg).get("stockLogId");
try {
orderService.createOrder(userId,itemId,promoId,amount,stockLogId);
} catch (BusinessException e) {
e.printStackTrace();
//设置对应的stockLog为回滚状态
StockLogDO stockLogDO = stockLogDOMapper.selectByPrimaryKey(stockLogId);
stockLogDO.setStatus(3);
stockLogDOMapper.updateByPrimaryKeySelective(stockLogDO);
return LocalTransactionState.ROLLBACK_MESSAGE;
}
return LocalTransactionState.COMMIT_MESSAGE;
}
/**
* 假设在创建订单createOrder这个过程发生了很长的时间,创建订单在数据库压力比较大的情况下可能用了
* 10s多。这样MQ就发现本地事务一直没有说提交成功还是提交失败回滚,处于一个UNKNOW的状态
* 于是就需要checkLocalTransaction回调判断是否下单是否是成功的
*/
@Override
public LocalTransactionState checkLocalTransaction(MessageExt msg) {
//根据是否扣减库存成功,来判断要返回COMMIT,ROLLBACK还是继续UNKNOWN
String jsonString = new String(msg.getBody());
Map<String,Object> map = JSON.parseObject(jsonString, Map.class);
Integer itemId = (Integer) map.get("itemId");
Integer amount = (Integer) map.get("amount");
String stockLogId = (String) map.get("stockLogId");
StockLogDO stockLogDO = stockLogDOMapper.selectByPrimaryKey(stockLogId);
if(stockLogDO == null){
return LocalTransactionState.UNKNOW;
}
if(stockLogDO.getStatus().intValue() == 2){
return LocalTransactionState.COMMIT_MESSAGE;
}else if(stockLogDO.getStatus().intValue() == 1){
return LocalTransactionState.UNKNOW;
}
return LocalTransactionState.ROLLBACK_MESSAGE;
}
});
}
//事务同步扣减库存方法
//事务怎么体现的?只要数据库中的数据提交了,对应的一个消息是必定发送成功的,数据库内消息回滚了,那么消息必定不发送。
public boolean transactionAsyncReduceStock(Integer userId,Integer itemId
,Integer promoId, Integer amount,String stockLogId){
HashMap<String, Object> bodyMap = new HashMap<>();
bodyMap.put("itemId",itemId);
bodyMap.put("amount",amount);
bodyMap.put("stockLogId",stockLogId);
HashMap<String, Object> argsMap = new HashMap<>();
argsMap.put("itemId",itemId);
argsMap.put("amount",amount);
argsMap.put("userId",userId);
argsMap.put("promoId",promoId);
argsMap.put("stockLogId",stockLogId);
Message message = new Message(topicName,"increase",
JSON.toJSON(bodyMap).toString().getBytes(Charset.forName("UTF-8")));
TransactionSendResult sendResult = null;
try {
//sendMessageInTransaction第二个参数argsMap就是后面会被本地事务中的executeLocalTransaction方法接收到
sendResult = transactionMQProducer.sendMessageInTransaction(message,argsMap);
} catch (MQClientException e) {
e.printStackTrace();
return false;
}
if(sendResult.getLocalTransactionState() == LocalTransactionState.ROLLBACK_MESSAGE){
return false;
}else if(sendResult.getLocalTransactionState() == LocalTransactionState.COMMIT_MESSAGE){
return true;
}else{
return false;
}
}
}
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) throws BusinessException {
//判断是否登录
Boolean isLogin = (Boolean) httpServletRequest.getSession().getAttribute("IS_LOGIN");
if(isLogin == null || !isLogin){
throw new BusinessException(EmBusinessError.USER_NOT_LOGIN, "请登录后再进行预约");
}
//获取用户的登录信息
UserModel userModel = (UserModel) httpServletRequest.getSession().getAttribute("LOGIN_USER");
boolean result = mqProducer.transactionAsyncReduceStock(userModel.getId(), itemId,
promoId, amount,stockLogId);
if(!result){
throw new BusinessException(EmBusinessError.UNKNOW_ERROR,"下单失败");
}
return CommonReturnType.create(null);
}
OrderServiceImpl
@Transactional
promoId非空表示已预约活动价格下单
public OrderModel createOrder(Integer userId, Integer itemId, Integer promoId,
Integer amount,String stockLogId) throws BusinessException {
//1.校验下单状态,商品是否存在,用户是否合法,购买数量是否正确
verifyingUserInformation(itemId,userId,amount,promoId);
//2.落单减库存,支付减库存
orderDecreaseStock(itemId, amount);
//自己添加,应对订单出现错误的时候,回滚第二步redis扣减的订单,防止噪声少买
//注意必须放在订单前面订单失败才能回滚
toAsynIncreaseRedisStock(itemId,amount);
//3.订单入库
OrderModel orderModel = orderintoMysql(itemId, userId, amount, promoId);
//4.因为采用了MQ事务发送机制,而这个订单成功的时间是不确定的,使用MQ事务有事务补偿机制
//所以在询问本地事务的时候返回是需要通过流水stock_log的status来判断的,当来到这里的时候说明订单都已经完成了
//所以此时我们应该将stock_log中的status设置为成功状态
//(1)问题又来了,当前不是又插入数据库了岂不是并发性又下降了?
//这种插入数据库是根据stockLogId进行插入的每一笔订单都有自己的stockLogId,而会在stockLogId上添加行锁
//这个行锁不存在并发关系,而在stock表中大家订单都需要取更改库存这个操作就是并发的,所以相当于将并发改数据库
//变成了非并发更改流水表中的字段
//(2)问题又来了,假如说我们当前商品只有100个,但我们有1w个人点击了在OrderController中
//设置插入数据库,此时就会造成生成了1w个流水信息,产生了极大的浪费
stockLogToSuccess(stockLogId);
//5.返回前端
return orderModel;
}
private void stockLogToSuccess(String stockLogId) throws BusinessException {
StockLogDO stockLogDO = stockLogDOMapper.selectByPrimaryKey(stockLogId);
if(stockLogDO == null){
throw new BusinessException(EmBusinessError.UNKNOW_ERROR);
}
//如果能拿到这个订单表的字段就将status更改为2
stockLogDO.setStatus(2);
stockLogDOMapper.updateByPrimaryKeySelective(stockLogDO);
}
4.RocketMQ的ACK机制
目前已经解决了,producer和broker之间的发送消息事务问题。保证了订单生成成功后,MQ才进行发送消息给Mysql消息。但是目前consumer是否已经能消费成功这也是一个系统问题。假如说,订单生成没问题,redis减库存,然后MQ就发送消息,返回给前端已经下单成功了。但是此时consumer消费出现了错误此时MQ因为ACK机制,会有消息重试机制。默认重试16次,如果16次尝试都失败了。这条消息就进入了死信队列。在死信队列中出现消息,就需要人工介入处理了。
如果想体验consumer插入数据库中的例子可以参考如下代码
代码
MqConsumer
@PostConstruct
public void init() throws MQClientException {
consumer = new DefaultMQPushConsumer("stock_consumer_group");
consumer.setNamesrvAddr(nameAddr);
//订阅所有topicName的消息
consumer.subscribe(topicName,"*");
//consumer处理过程
consumer.registerMessageListener(new MessageListenerConcurrently() {
@Override
public ConsumeConcurrentlyStatus consumeMessage(
List<MessageExt> msgs, ConsumeConcurrentlyContext consumeConcurrentlyContext) {
//实现库存真正到数据库内扣减的逻辑
Message msg = msgs.get(0);
String jsonString = new String(msg.getBody());
Map<String, Object> map = JSON.parseObject(jsonString, Map.class);
Integer itemId = (Integer) map.get("itemId");
Integer amount = (Integer) map.get("amount");
System.out.println(10/0);
itemStockDOMapper.decreaseStock(itemId,amount);
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
}
});
consumer.start();
}
因10/0出错,所以就会不断重试重试,所以ACK机制就是保证Consumer端消费成功。
三、redis完善系统
系统还可以有两个优化。
1.实现redis中Stock数据一致性
@Transactional
标签同样不能回滚redis中的stock数据,在进行redis扣减库存之后,如果插入订单失败了,此是redis也没回滚相当于少买了。
方法可以采用TransactionSynchronizationManager
的registerSynchronization
方法里面实现事务适配器TransactionSynchronizationAdapter
中的afterCompletion方法。这样就在事务回滚的同时保证了redis的一致性。
代码
OrderServiceImpl
@Transactional
public void toAsynIncreaseRedisStock(Integer itemId, Integer amount) throws BusinessException{
TransactionSynchronizationManager
.registerSynchronization(new TransactionSynchronizationAdapter() {
@Override
public void afterCompletion(int status) {
//参考连接:https://www.cnblogs.com/ciel717/p/16190723.html
/**status 0:外部方法事务提交后执行的业务逻辑
* 1:外部方法事务回滚后执行的业务逻辑
* 2:外部方法事务异常时执行的业务逻辑
*/
if(status!=0){
itemService.asynchronousIncreaseStock(itemId,amount);
}
}
});
}
OrderServiceImpl
@Transactional
promoId非空表示已预约活动价格下单
public OrderModel createOrder(Integer userId, Integer itemId, Integer promoId,
Integer amount,String stockLogId) throws BusinessException {
/**
* 1.校验下单状态下单商品是否存在,用户是否合法,购买数量是否正确
* 2.采取落单减库存(一般电商分为两种一种是落单减库存,支付减库存)
* 落单减库存就是用户先落单了,此时系统就去锁库存了。也就是再支付之前用户就相当于拥有了这个商品。
* 而支付减库存,就是落单了,系统检测还有库存,但是用户在支付完后,再次系统去锁库存,发现已经没有
* 库存了只能给用户退款了。
* 3.订单入库
* 4.返回前端
*/
//1.校验下单状态,商品是否存在,用户是否合法,购买数量是否正确
verifyingUserInformation(itemId,userId,amount,promoId);
//2.落单减库存,支付减库存
orderDecreaseStock(itemId, amount);
//应对订单出现错误的时候,回滚第二步redis扣减的订单,防止噪声少买
//注意必须放在订单前面订单失败才能回滚
toAsynIncreaseRedisStock(itemId,amount);
//3.订单入库
OrderModel orderModel = orderintoMysql(itemId, userId, amount, promoId);
stockLogToSuccess(stockLogId);
//5.返回前端
return orderModel;
}
2.redis实现售空机制
假如说我们当前商品只有100个,但我们有1w个人点击了在OrderController中。设置插入数据库,此时就会造成生成了1w个流水信息,产生了极大的浪费。所以我们希望,当卖完这一百个之后,就不再产生订单直接从后台提示前端商品全部销售完毕。
做法就是在redis中设置一个标志位“promo_item_stock_invalid_"+itemId如果是false就是还没销售空,如果是true就是销售空了。
代码
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) throws BusinessException {
//判断是否登录
Boolean isLogin = (Boolean) httpServletRequest.getSession().getAttribute("IS_LOGIN");
if(isLogin == null || !isLogin){
throw new BusinessException(EmBusinessError.USER_NOT_LOGIN, "请登录后再进行预约");
}
//获取用户的登录信息
UserModel userModel = (UserModel) httpServletRequest.getSession().getAttribute("LOGIN_USER");
//为了根据checkLocalTransaction确定消息的状态,需要引入操作流水(操作型数据:log data)
//他的作用就是先插入一条异步流水,根据这个status用来追踪异步扣减库存这个消息。
//就是根据你这个流水的状态来确定MQ返回是成功还是失败还是不知道
//若库存不足直接返回下单失败
//(2)问题又来了,假如说我们当前商品只有100个,但我们有1w个人点击了在OrderController中
//设置插入数据库,此时就会造成生成了1w个流水信息,产生了极大的浪费
if(redisTemplate.hasKey("promo_item_stock_invalid_"+itemId)){
throw new BusinessException(EmBusinessError.STOCK_NOT_ENOUGH);
}
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);
}
ItemServiceImpl
@Override
@Transactional
public boolean SoldOutAsynDecreaseStock(Integer itemId, Integer amount) {
Long result = redisTemplate.opsForValue()
.increment("promo_item_stock_" + itemId, amount.intValue() * -1);
if (result > 0) {
//更新数据库 (采用异步方式更新mysql)
//交给asyncDecreaseStock去更新
return true;
}else if(result == 0){
//增加了商品售空的信息!!!防止顶点流水表大量生成废流水
//通过放在redis中的标志位来判断是否当前商品已经售罄
//打上库存售罄的标识
redisTemplate.opsForValue().set("promo_item_stock_invalid_"+itemId, "true");
redisTemplate.expire("promo_item_stock_invalid_"+itemId,1,TimeUnit.MINUTES);
return true;
}else {
//库存为负,此次交易失败,补回redis中库存
//redisTemplate.opsForValue().increment("promo_item_stock_" + itemId, amount.intValue());
asynchronousIncreaseStock(itemId,amount);
return false;
}
}
总结
基本上就是将原来的5次查Mysql+一次并发查Mysql,变成了1次查redis(promo_item_stock_invalid_itemid)+ 2次查redis(user+item)+1次改redis(更改库存数据)+1次插表(order)+RocketMQ事务发送。