赞
踩
本项目通过RabbitMQ延时队列实现柔性事务+可靠消息+最终一致性
引入RabbitMq延时队列的目的是为了解决事务最终一致性。
定义:延迟队列存储的对象肯定是对应的延时消息;所谓"延时消息"是指当消息被发送以后,并不想让消费者立即拿到消息,而是等待指定时间后,消费者才拿到这个消息进行消费。
场景:
比如未付款订单,超过一定时间后,系统自动取消订单并释放占有商品的库存。
常见解决方案:
使用SpringSchedule定时任务轮训数据库
缺点:
消耗系统内存(需要创建定时任务且定时任务一直运行)、增加了数据库的压力、存在较大的时间误差
这里我解释一下后两点:因为为了避免每个订单都创建一个定时任务导致需要大量定时任务所以我们把订单查询定时任务设置为一个且一直存在。增加数据库压力是指这个定时任务每隔一段时间(比如订单失效为30分钟)就会对数据库中订单表进行全表扫描。而且由于我们只有一个定时任务,那么其实是非常有可能发生定时任务的时效性问题的(也许就在这个定时任务轮询的下一秒这个订单才会过期)。
解决:
RabbitMQ的消息TTL和死信Exchange结合组成延时队列
因此解决未付款订单,超过一定时间后,系统自动取消订单并解锁库存这个问题的解决方法我们是使用RabbitMQ的延时队列进行实现,但RabbitMQ原生并不支持延时队列,它是使用的消息TTL加死信Exchange来实现的。
消息的TTL就是消息的存活时间。
RabbitMQ 可以对队列和消息分别设置TTL.
如果队列设置了,消息也设置了,那么会取小的。所以一个消息如果被路由到不同的队列中,这个消息死亡的时间有可能不一样(不同的队列设置)。这里单讲单个消息的TTL,因为它才是实现延迟任务的关键。可以通过设置消息的expiration字段或者x-message-ttl属性来设置时间,两者是一样的效果。
死信队列与死信交换机:当消息变成一个死信之后,如果这个消息所在的队列存在x-dead-letter-exchange参数,那么它会被发送到x-dead-letter-exchange对应值的交换机上,这个交换机就称之为死信交换机,然后根据死信路由路由到与这个死信交换机绑定的队列即死信队列(存放死信的队列)。
死信交换机(DeadLetterExchange)其实就是一个普通的交换机,和创建其他交换机没啥区别,只是在某一个设置了DeadLetterExchange的队列中有消息过期之后会自动触发消息的转发,转发到死信交换机中。
RabbitMQ 的 Queue 可以配置x-dead-letter-exchange
和x-dead-letter-routing-key
(可选)两个参数,如果队列内出现了 Dead Letter,则按照这两个参数重新路由转发到指定的队列。
x-dead-letter-exchange
:出现dead letter之后将 Dead Letter 重新发送到指定 exchangex-dead-letter-routing-key
:出现dead letter之后将 Dead Letter 重新按照指定的 routing-key 发送死信消息:
过期消息:
在 rabbitmq 中存在2种方可设置消息的过期时间,
如果同时使用这2种方法,那么以过期时间小的那个数值为准。当消息达到过期时间还没有被消费,那么那个消息就成为了一个 死信 消息。
**延时队列:**在rabbitmq中不存在延时队列,但是我们可以通过设置死信队列中的消息的过期时间来让其在一段时候后变成死信,又可以控制变成死信的消息被路由到一个指定的交换机,结合两者来模拟出延时队列。消费者监听死信交换绑定的队列,而不要监听消息发送的队列。
关于如何设置过期时间我们有两种实现方式,实现图如下:
但是我们这里推荐第一种实现方式即设置队列过期时间,因为RabbitMQ采用的惰性检查机制即懒检查机制,比如我们给队列连发三条消息(第一条10分钟过期,二三条1分钟过期),但服务器来拿消息时根据队列应该先拿第一条消息,拿到之后发现10分钟过期,那么十分钟之后才会再次来拿消息,那么2.3条消息拿取的时间就会也在十分钟之后了。这里的队列是无人消费的队列,拿取的意思是拿到消息然后放到死信队列中。
订单超时未支付触发订单过期状态修改与库存解锁
创建订单时消息会被发送至队列order.delay.queue
,经过 TTL 的时间后消息会变成死信以order.release.order
的路由键经交换机转发至队列order.release.order.queue
,再通过监听该队列的消息来实现过期订单的处理
order.release.other
发送消息至队列stock.release.stock.queue
进行库存解锁具体在我们业务中使用流程图如下:
Order-event-exchange交换机绑定了两个队列
总结大致流程为:我们创建订单发送消息先进入一个无人消费的队列,这个队列是有过期时间的,然后给这个队列绑定一个交换机,这个交换机绑定的是死信队列,然后消息过期进入死信队列,我们监听这个死信队列。
下面我们在我们的项目中创建这些资源,因为这些资源是固定的,所以我们可以把它写入订单服务的容器中,然后服务连上RabbitMQ之后监听消息时发现没有这些资源就会自动创建。
**第一步、**创建相应的交换机、队列、以及交换机和队列的绑定 并 编写一个队列监听
package com.atguigu.gulimall.order.config; @Configuration public class MyMQConfig { @RabbitListener(queues = "order.release.order.queue") public void listener(OrderEntity entity, Channel channel, Message message) throws IOException { System.out.println("收到过期的订单信息:准备关闭订单!" + entity.getOrderSn()); channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } /** * Spring中注入Bean之后,容器中的Binding、Queue、Exchange 都会自动创建(前提是RabbitMQ中没有) * RabbitMQ 只要有这些组件,即使之后重新启动,@Bean的组件属性发生变化也不会覆盖之前的组件 * @return * Queue(String name, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments) */ @Bean public Queue orderDelayQueue(){ HashMap<String, Object> arguments = new HashMap<>(); /** * x-dead-letter-exchange :order-event-exchange 设置死信路由 * x-dead-letter-routing-key : order.release.order 设置死信路由键 * x-message-ttl :60000 */ arguments.put("x-dead-letter-exchange","order-event-exchange"); arguments.put("x-dead-letter-routing-key","order.release.order"); arguments.put("x-message-ttl",30000); Queue queue = new Queue("order.delay.queue", true, false, false,arguments); return queue; } @Bean public Queue orderReleaseOrderQueue(){ return new Queue("order.release.order.queue", true, false, false); } @Bean public Exchange orderEventExchange(){ // TopicExchange(String name, boolean durable, boolean autoDelete, Map<String, Object> arguments) return new TopicExchange("order-event-exchange",true,false); } @Bean public Binding orderCreateOrder(){ // Binding(String destination, Binding.DestinationType destinationType, String exchange, String routingKey, Map<String, Object> arguments) return new Binding("order.delay.queue", Binding.DestinationType.QUEUE, "order-event-exchange", "order.create.order", null); } @Bean public Binding orderReleaseOrder(){ // Binding(String destination, Binding.DestinationType destinationType, String exchange, String routingKey, Map<String, Object> arguments) return new Binding("order.release.order.queue", Binding.DestinationType.QUEUE, "order-event-exchange", "order.release.order", null); } }
第二步、编写个Controller用来发送消息
修改“com.atguigu.gulimall.order.web.HelloController”类代码如下:
package com.atguigu.gulimall.order.web; @Controller public class HelloController { @Autowired RabbitTemplate rabbitTemplate; // ...... @ResponseBody @GetMapping("/test/createOrder") public String createOrderTest(){ // 此处模拟:省略订单下单成功,并保存到数据库 OrderEntity entity = new OrderEntity(); entity.setOrderSn(UUID.randomUUID().toString()); entity.setModifyTime(new Date()); // 给MQ发送消息 rabbitTemplate.convertAndSend("order-event-exchange","order.create.order",entity); return "ok"; } }
库存锁定后延迟检查是否需要解锁库存
在库存锁定后通过路由键stock.locked
发送至延迟队列stock.delay.queue
,延迟时间到,死信通过路由键stock.release
转发至stock.release.stock.queue
,通过监听该队列进行判断当前订单状态,来确定库存是否需要解锁
1、导入依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
2、添加配置
spring:
rabbitmq:
host: 124.222.223.222
virtual-host: /
username: guest
password: guest
listener:
simple:
acknowledge-mode: manual
3、主启动类添加注解
@EnableRabbit
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallWareApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallWareApplication.class, args);
}
}
创建相应的交换机、队列、以及交换机和队列的绑定
gulimall-ware服务中添加“com.atguigu.gulimall.ware.config.MyRabbitConfig”类,代码如下:
package com.atguigu.gulimall.ware.config; @Configuration public class MyRabbitConfig { @Autowired RabbitTemplate rabbitTemplate; @RabbitListener(queues = "stock.release.stock.queue") public void handle(Message message){ } /** * 使用JSON序列化机制,进行消息转换 * @return */ @Bean public MessageConverter messageConverter(){ return new Jackson2JsonMessageConverter(); } @Bean public Exchange exchange(){ return new TopicExchange("stock-event-exchange", true, false); } @Bean public Queue stockReleaseStockQueue() { return new Queue("stock.release.stock.queue", true, false, false); } @Bean public Queue stockDelayQueue() { // String name, boolean durable, boolean exclusive, boolean autoDelete, // @Nullable Map<String, Object> arguments Map<String, Object> arguments = new HashMap<>(); arguments.put("x-dead-letter-exchange", "stock-event-exchange"); arguments.put("x-dead-letter-routing-key", "stock.release"); arguments.put("x-message-ttl", 120000); return new Queue("stock.delay.queue", true, false, false, arguments); } @Bean public Binding stockReleaseStockBinding() { return new Binding("stock.release.stock.queue", Binding.DestinationType.QUEUE, "stock-event-exchange", "stock.release.#", new HashMap<>()); } @Bean public Binding orderLockedBinding() { return new Binding("stock.delay.queue", Binding.DestinationType.QUEUE, "stock-event-exchange", "stock.locked", new HashMap<>()); } }
库存解锁的两种场景:
①下单成功,订单过期没有支付被系统自动取消、被用户手动取消。都要解锁
②下单成功,库存锁定成功,接下来的业务调用失败,导致订单回滚。之前锁定的库存就要自动解锁
1、修改gulimall-ware 仓储服务数据库的wms_ware_order_task_detail表结构,主要是增加了一个库存锁定状态字段,用来判断订单中某个商品的锁库存状态。
2、修改“com.atguigu.gulimall.ware.entity.WareOrderTaskDetailEntity”类,代码 如下:
package com.atguigu.gulimall.ware.entity; @Data @TableName("wms_ware_order_task_detail") public class WareOrderTaskDetailEntity implements Serializable { private static final long serialVersionUID = 1L; /** * id */ @TableId private Long id; /** * sku_id */ private Long skuId; /** * sku_name */ private String skuName; /** * 购买个数 */ private Integer skuNum; /** * 工作单id */ private Long taskId; /** * 仓库id */ private Long wareId; /** * 锁定状态,1-已锁定 2-已解锁 3-扣减 */ private Integer lockStatus; }
3、修改 Mapper文件
修改resources/mapper/ware/WareOrderTaskDetailDao.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.atguigu.gulimall.ware.dao.WareOrderTaskDetailDao">
<!-- 可根据自己的需求,是否要使用 -->
<resultMap type="com.atguigu.gulimall.ware.entity.WareOrderTaskDetailEntity" id="wareOrderTaskDetailMap">
<result property="id" column="id"/>
<result property="skuId" column="sku_id"/>
<result property="skuName" column="sku_name"/>
<result property="skuNum" column="sku_num"/>
<result property="taskId" column="task_id"/>
<result property="wareId" column="ware_id"/>
<result property="lockStatus" column="lock_status"/>
</resultMap>
</mapper>
1)、封装给MQ发送的数据 To类
gulimall-conmmon服务 com.atguigu.common.to.mq
路径下编写:StockLockedTo类(方便MQ发消息)、StockDetailTo类,这里需要为库存工作单专门抽取成一个TO作为属性是因为如果To仅仅保存一个库存单号和一个详情单id时,会存在一些问题, 当1号订单在1号仓库扣减1件商品成功,2号订单在2号仓库扣减2件商品成功,3号订单在3号仓库扣减3件商品失败时,库存工作单的数据将会回滚,此时,数据库中将查不到1号和2号订单的库存工作单的数据,但是库存扣减是成功的,导致无法解锁库存
package com.atguigu.common.to.mq; /** * Data time:2022/4/14 20:21 * StudentID:2019112118 * Author:hgw * Description: 详情单 */ @Data public class StockDetailTo { private Long id; /** * sku_id */ private Long skuId; /** * sku_name */ private String skuName; /** * 购买个数 */ private Integer skuNum; /** * 工作单id */ private Long taskId; /** * 仓库id */ private Long wareId; /** * 锁定状态,1-已锁定 2-已解锁 3-扣减 */ private Integer lockStatus; }
package com.atguigu.common.to.mq;
@Data
public class StockLockedTo {
/**
* 库存工作单的id
*/
private Long id;
/**
* 工作单详情
*/
private StockDetailTo detailTo;
}
2)、编写业务逻辑告诉MQ库存锁定成功
修改gulimall-ware 服务 com.atguigu.gulimall.ware.service.imp
路径下的 WareSkuServiceImpl 类,代码如下
@Transactional @Override public Boolean orderLockStock(WareSkuLockVo vo) { /** * 保存库存工作单的性情 * 追溯 */ // 1、保存库存工作单 WareOrderTaskEntity taskEntity = new WareOrderTaskEntity(); taskEntity.setOrderSn(vo.getOrderSn()); orderTaskService.save(taskEntity); // 1、每个商品在哪个库存里有库存 List<OrderItemVo> locks = vo.getLocks(); List<SkuWareHashStock> collect = locks.stream().map(item -> { SkuWareHashStock stock = new SkuWareHashStock(); Long skuId = item.getSkuId(); stock.setSkuId(skuId); stock.setNum(item.getCount()); // 查询这个商品在哪里有库存 List<Long> wareIds = wareSkuDao.listWareIdHashSkuStock(skuId); stock.setWareId(wareIds); return stock; }).collect(Collectors.toList()); // 2、锁定库存 for (SkuWareHashStock hashStock : collect) { Boolean skuStocked = false; Long skuId = hashStock.getSkuId(); List<Long> wareIds = hashStock.getWareId(); if (wareIds == null || wareIds.size()==0){ // 没有任何仓库有这个商品的库存 throw new NoStockException(skuId); } // 1、如果每一个商品都锁定成功,将当前商品锁定了几件的的工作单记录发送给MQ // 2、如果有一个商品锁定失败,则前面锁定的就回滚了。发送出去的消息,即使要解锁记录,由于去数据库查不到id,所以就不用解锁 // 1、 for (Long wareId : wareIds) { // 成功就返回1,否则就返回0 Long count = wareSkuDao.lockSkuStock(skuId,wareId,hashStock.getNum()); if (count == 1){ skuStocked = true; // TODO 告诉MQ库存锁定成功 // 2、保存库存工作单详情 WareOrderTaskDetailEntity entity = new WareOrderTaskDetailEntity(null,skuId,"",hashStock.getNum(),taskEntity.getId(),wareId,1); orderTaskDetailService.save(entity); StockLockedTo lockedTo = new StockLockedTo(); lockedTo.setId(taskEntity.getId()); StockDetailTo stockDetailTo = new StockDetailTo(); BeanUtils.copyProperties(entity,stockDetailTo); // 只发id不行,防止回滚以后找不到数据,这就是前面那个封装一个TO的原因 lockedTo.setDetailTo(stockDetailTo); rabbitTemplate.convertAndSend("stock-event-exchange", "stock.locked", lockedTo); break; } else { // 当前仓库锁失败,重试下一个仓库 } } if (skuStocked == false){ // 当前商品所有仓库都没有锁住,其他商品也不需要锁了,直接返回没有库存了 throw new NoStockException(skuId); } } // 3、运行到这,全部都是锁定成功的 return true; } @Data class SkuWareHashStock{ private Long skuId; // skuid private Integer num; // 锁定件数 private List<Long> wareId; // 锁定仓库id }
解锁场景:
首先需要查询数据库关于这个订单的锁库存消息:
1.有这个锁库存消息表示下单成功,库存锁定成功:
2.锁库存失败,库存回滚了。这种情况无需解锁
解决方案:通过查询订单的锁库存信息,如果有则仅仅说明库存锁定成功,还需判断是否有订单信息,如果有订单信息则判断订单状态,若订单状态已取消则解锁库存,反之:不能解锁库存,如果没有订单信息则需要解锁库存,如果没有锁库存信息则无需任何操作。
1)、主体代码封装
gulimall-ware 服务中 com.atguigu.gulimall.ware.listener
路径下 StockReleaseListener
@Slf4j @Service @RabbitListener(queues = "stock.release.stock.queue") public class StockReleaseListener { @Autowired WareSkuService wareSkuService; @RabbitHandler public void handleStockLockedRelease(StockLockedTo to, Message message, Channel channel) throws IOException { System.out.println("收到解锁库存的消息"); try { wareSkuService.unlockStock(to); channel.basicAck(message.getMessageProperties().getDeliveryTag(),false); } catch (Exception e){ channel.basicReject(message.getMessageProperties().getDeliveryTag(),true); } } } /** * 1、库存自动解锁 * 下订单成功,库存锁定成功,接下来的业务调用失败,导致订单回滚。之前锁定的库存就要自动解锁 * 2、订单失败 * 锁库存失败,则库存回滚了,这种情况无需解锁 * 如何判断库存是否锁定失败呢?查询数据库关于这个订单的锁库存消息即可 * 自动ACK机制:只要解决库存的消息失败,一定要告诉服务器解锁是失败的。启动手动ACK机制 * @param to * */ @Override public void unlockStock(StockLockedTo to) { StockDetailTo detail = to.getDetailTo(); Long detailId = detail.getId(); /** * 1、查询数据库关于这个订单的锁库存消息 * 有,证明库存锁定成功了。 * 1、没有这个订单。必须解锁 * 2、有这个订单。不是解锁库存。 * 订单状态:已取消:解锁库存 * 订单状态:没取消:不能解锁 * 没有,库存锁定失败了,库存回滚了。这种情况无需解锁 */ WareOrderTaskDetailEntity byId = orderTaskDetailService.getById(detailId); if (byId != null) { Long id = to.getId(); // 库存工作单的Id,拿到订单号 WareOrderTaskEntity taskEntity = orderTaskService.getById(id); String orderSn = taskEntity.getOrderSn(); // 根据订单号查询订单的状态 R r = orderFeignService.getOrderStatus(orderSn); if (r.getCode() == 0) { // 订单数据返回成功 OrderVo data = r.getData(new TypeReference<OrderVo>() { }); if (data == null || data.getStatus() == 4) { // 订单不存在、订单已经被取消了,才能解锁库存 if (byId.getLockStatus() == 1){ // 当前库存工作单详情,状态1 已锁定但是未解锁才可以解锁 unLockStock(detail.getSkuId(), detail.getWareId(), detail.getSkuNum(), detailId); } } else { // 消息拒绝以后重新放到队列里面,让别人继续消费解锁 throw new RuntimeException("远程服务失败"); } } } else { // 无需解锁 } } /** * 解库存锁 * * @param skuId 商品id * @param wareId 仓库id * @param num 解锁数量 * @param taskDetailId 库存工作单ID */ private void unLockStock(Long skuId, Long wareId, Integer num, Long taskDetailId) { // 库存解锁 wareSkuDao.unlockStock(skuId, wareId, num); // 更新库存工作单的状态 WareOrderTaskDetailEntity entity = new WareOrderTaskDetailEntity(); entity.setId(taskDetailId); entity.setLockStatus(2);// 变为已解锁 orderTaskDetailService.updateById(entity); }
2)、编写一个远程方法查询订单的状态
1、编写远程调用 gulimall-order 服务feign接口
gulimall-ware服务中 com.atguigu.gulimall.ware.feign
路径下的 OrderFeignService类,代码如下:
package com.atguigu.gulimall.ware.feign;
@FeignClient("gulimall-order")
public interface OrderFeignService {
@GetMapping("/order/order/status/{orderSn}")
R getOrderStatus(@PathVariable("orderSn") String orderSn);
}
2、gulimall-order服务中提供接口
gulimall-order服务中 com.atguigu.gulimall.order.controller
路径下的 OrderController类,代码如下:
@RestController @RequestMapping("order/order") public class OrderController { @Autowired private OrderService orderService; /** * 通过订单号获取订单的详细信息 * @param orderSn * @return */ @GetMapping("/status/{orderSn}") public R getOrderStatus(@PathVariable("orderSn") String orderSn){ OrderEntity orderEntity = orderService.getOrderByOrderSn(orderSn); return R.ok().setData(orderEntity); }
gulimall-order服务中 com.atguigu.gulimall.order.service.impl
路径下的 OrderServiceImpl类,代码如下:
@Override
public OrderEntity getOrderByOrderSn(String orderSn) {
OrderEntity order_sn = this.getOne(new QueryWrapper<OrderEntity>().eq("order_sn", orderSn));
return order_sn;
}
3、本地编写接收信息的VO
gulimall-ware服务中 com.atguigu.gulimall.ware.vo
路径下的 OrderVo类,代码如下:
package com.atguigu.gulimall.ware.vo; import lombok.Data; import java.math.BigDecimal; import java.util.Date; /** * Data time:2022/4/14 21:05 * StudentID:2019112118 * Author:hgw * Description: */ @Data public class OrderVo { private Long id; /** * member_id */ private Long memberId; /** * 订单号 */ private String orderSn; /** * 使用的优惠券 */ private Long couponId; /** * create_time */ private Date createTime; /** * 用户名 */ private String memberUsername; /** * 订单总额 */ private BigDecimal totalAmount; /** * 应付总额 */ private BigDecimal payAmount; /** * 运费金额 */ private BigDecimal freightAmount; /** * 促销优化金额(促销价、满减、阶梯价) */ private BigDecimal promotionAmount; /** * 积分抵扣金额 */ private BigDecimal integrationAmount; /** * 优惠券抵扣金额 */ private BigDecimal couponAmount; /** * 后台调整订单使用的折扣金额 */ private BigDecimal discountAmount; /** * 支付方式【1->支付宝;2->微信;3->银联; 4->货到付款;】 */ private Integer payType; /** * 订单来源[0->PC订单;1->app订单] */ private Integer sourceType; /** * 订单状态【0->待付款;1->待发货;2->已发货;3->已完成;4->已关闭;5->无效订单】 */ private Integer status; /** * 物流公司(配送方式) */ private String deliveryCompany; /** * 物流单号 */ private String deliverySn; /** * 自动确认时间(天) */ private Integer autoConfirmDay; /** * 可以获得的积分 */ private Integer integration; /** * 可以获得的成长值 */ private Integer growth; /** * 发票类型[0->不开发票;1->电子发票;2->纸质发票] */ private Integer billType; /** * 发票抬头 */ private String billHeader; /** * 发票内容 */ private String billContent; /** * 收票人电话 */ private String billReceiverPhone; /** * 收票人邮箱 */ private String billReceiverEmail; /** * 收货人姓名 */ private String receiverName; /** * 收货人电话 */ private String receiverPhone; /** * 收货人邮编 */ private String receiverPostCode; /** * 省份/直辖市 */ private String receiverProvince; /** * 城市 */ private String receiverCity; /** * 区 */ private String receiverRegion; /** * 详细地址 */ private String receiverDetailAddress; /** * 订单备注 */ private String note; /** * 确认收货状态[0->未确认;1->已确认] */ private Integer confirmStatus; /** * 删除状态【0->未删除;1->已删除】 */ private Integer deleteStatus; /** * 下单时使用的积分 */ private Integer useIntegration; /** * 支付时间 */ private Date paymentTime; /** * 发货时间 */ private Date deliveryTime; /** * 确认收货时间 */ private Date receiveTime; /** * 评价时间 */ private Date commentTime; /** * 修改时间 */ private Date modifyTime; }
3)、解锁库存方法编写详情
gulimall-ware服务中的 /com/atguigu/gulimall/ware/service/impl/WareSkuServiceImpl.java
路径下 WareSkuServiceImpl.java类的方法
/**
* 解库存锁
* @param skuId 商品id
* @param wareId 仓库id
* @param num 解锁数量
* @param taskDetailId 库存工作单ID
*/
private void unLockStock(Long skuId, Long wareId, Integer num, Long taskDetailId) {
wareSkuDao.unlockStock(skuId,wareId,num);
}
void unlockStock(@Param("skuId") Long skuId, @Param("wareId") Long wareId, @Param("num") Integer num);
gulimall-ware服务中的 resources/mapper/ware/WareSkuDao.xml
文件
<update id="unlockStock">
UPDATE wms_ware_sku SET stock_locked=stock_locked-#{num} WHERE sku_id=#{skuId} AND ware_id=#{wareId}
</update>
4)、由于gulimall-order添加了拦截器,只要使用该服务必须登录才行。因为这边需要远程调用订单,但不需要登录,所以给这个路径放行
修改gulimall-order 服务的 com.atguigu.gulimall.order.interceptoe
路径下 LoginUserInterceptor类
package com.atguigu.gulimall.order.interceptoe; @Component public class LoginUserInterceptor implements HandlerInterceptor { public static ThreadLocal<MemberRespVo> loginUser = new ThreadLocal<>(); /** * 用户登录拦截器 * @param request * @param response * @param handler * @return * 用户登录:放行 * 用户未登录:跳转到登录页面 * @throws Exception */ @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // /order/order/status/222222222 String uri = request.getRequestURI(); boolean match = new AntPathMatcher().match("/order/order/status/**", uri); if (match){ return true; } MemberRespVo attribute = (MemberRespVo) request.getSession().getAttribute(AuthServerConstant.LOGIN_USER); if (attribute!=null){ loginUser.set(attribute); return true; } else { // 没登录就去登录 request.getSession().setAttribute("msg", "请先进行登录"); response.sendRedirect("http://auth.gulimall.cn/login.html"); return false; } } }
1)、创建一个类监听 stock.release.stock.queue
队列
gulimall-ware服务的 com.atguigu.gulimall.ware.listener
路径 StockReleaseListener 类,接收到消息之后调用 Service层 WareSkuServiceImpl.java 实现类的 unlockStock 方法实现解锁库存:
没有异常捕捉则成功解锁消息。手动ACK
#手动ACK设置
spring.rabbitmq.listener.simple.acknowledge-mode=manual
远程服务调用可能会出现失败,因此需要设置手动ACK,确保其它服务能消费此消息,否则远程失败之后,解锁库存的消息在死信队列中就会被自动确认,导致解锁库存消息被消费但是库存没有被解锁。
捕捉到异常(解锁库存方法单独抽取出来,然后里面的只要出异常或者解锁不成功都抛出异常让解锁库存方法感知),则消息拒绝以后重新放到队列里面,让别人继续消费解锁
package com.atguigu.gulimall.ware.listener; @Slf4j @Service @RabbitListener(queues = "stock.release.stock.queue") public class StockReleaseListener { @Autowired WareSkuService wareSkuService; @RabbitHandler public void handleStockLockedRelease(StockLockedTo to, Message message, Channel channel) throws IOException { System.out.println("收到解锁库存的消息"); try { wareSkuService.unlockStock(to); channel.basicAck(message.getMessageProperties().getDeliveryTag(),false); } catch (Exception e){ channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);//true表示拒收重新入队 } } }
2)、service层业务方法
gulimall-ware服务的 com.atguigu.gulimall.ware.service.impl
路径 WareSkuServiceImpl 类
/** * 1、库存自动解锁 * 下订单成功,库存锁定成功,接下来的业务调用失败,导致订单回滚。之前锁定的库存就要自动解锁 * 2、订单失败 * 锁库存失败,则库存回滚了,这种情况无需解锁 * 如何判断库存是否锁定失败呢?查询数据库关于这个订单的锁库存消息即可 * 自动ACK机制:只要解决库存的消息失败,一定要告诉服务器解锁是失败的。启动手动ACK机制 * @param to * */ @Override public void unlockStock(StockLockedTo to) { StockDetailTo detail = to.getDetailTo(); Long detailId = detail.getId(); /** * 1、查询数据库关于这个订单的锁库存消息 * 有,证明库存锁定成功了。 * 1、没有这个订单。必须解锁 * 2、有这个订单。不是解锁库存。 * 订单状态:已取消:解锁库存 * 订单状态:没取消:不能解锁 * 没有,库存锁定失败了,库存回滚了。这种情况无需解锁 */ WareOrderTaskDetailEntity byId = orderTaskDetailService.getById(detailId); if (byId != null) { Long id = to.getId(); // 库存工作单的Id,拿到订单号 WareOrderTaskEntity taskEntity = orderTaskService.getById(id); String orderSn = taskEntity.getOrderSn(); // 根据订单号查询订单的状态 R r = orderFeignService.getOrderStatus(orderSn); if (r.getCode() == 0) { // 订单数据返回成功 OrderVo data = r.getData(new TypeReference<OrderVo>() { }); if (data == null || data.getStatus() == 4) { // 订单不存在、订单已经被取消了,才能解锁库存 if (byId.getLockStatus() == 1){ // 当前库存工作单详情,状态1 已锁定但是未解锁才可以解锁 unLockStock(detail.getSkuId(), detail.getWareId(), detail.getSkuNum(), detailId); } } else { // 消息拒绝以后重新放到队列里面,让别人继续消费解锁 throw new RuntimeException("远程服务失败"); } } } else { // 无需解锁 } } /** * 解库存锁 * * @param skuId 商品id * @param wareId 仓库id * @param num 解锁数量 * @param taskDetailId 库存工作单ID */ private void unLockStock(Long skuId, Long wareId, Integer num, Long taskDetailId) { // 库存解锁 wareSkuDao.unlockStock(skuId, wareId, num); // 更新库存工作单的状态 WareOrderTaskDetailEntity entity = new WareOrderTaskDetailEntity(); entity.setId(taskDetailId); entity.setLockStatus(2);// 变为已解锁 orderTaskDetailService.updateById(entity); }
定时关单和自动解锁库存都是根据延时队列实现的,根据监听死信队列中的信息分别修改订单信息和库存信息。两者是一体的,只是操作对象不同。但是如果处理下单请求时出现异常导致下单失败,订单数据回滚,那么之前锁的库存以为远程调用并不会回滚(这里因为并发原因没有使用分布式事务Seata处理),所以需要有一个自动解锁库存的功能。也就是说自动解锁库存是防止下单出现异常而存在的,因为定时关单成功之后会发送一个解锁库存的消息给MQ。
而且不会出现解锁两次库存导致库存解锁多的场景,因为订单和库存工作单都有其状态,每次关单和解锁库存时都会判断其状态,只有状态符合关单和解锁状态时才会执行相应的业务逻辑。即实现了关单和解锁库存的幂等性。
在订单创建成功时向MQ中延时队列发送消息,携带路由键:
order.create.order
30分钟后未支付,则释放订单服务向MQ中发送消息,携带路由键:
order.release.order
order.release.order.queue
队列的服务会收到关单的消息,然后进行释放订单服务此时存在一种情况,订单创建成功之后出现延时卡顿,消息延迟,导致订单解锁在库存解锁之后完成,这样库存解锁时判断订单状态就会为新建然后不会解锁库存,那么解锁库存消息就被消费掉了,而之后如果订单被取消,那么之前锁住的库存就不会被解锁了。
所以每次定时关单之后向MQ中发送消息(解锁库存),携带路由键:
order.release.other
监听
stock.release.stock.queu
编写一个重载方法,进行判断
package com.atguigu.gulimall.order.config; @Configuration public class MyMQConfig { /** * Spring中注入Bean之后,容器中的Binding、Queue、Exchange 都会自动创建(前提是RabbitMQ中没有) * RabbitMQ 只要有,@Bean属性发生变化也不会覆盖 * @return * Queue(String name, boolean durable, boolean exclusive, boolean autoDelete, Map<String, Object> arguments) */ @Bean public Queue orderDelayQueue(){ HashMap<String, Object> arguments = new HashMap<>(); /** * x-dead-letter-exchange :order-event-exchange 设置死信路由 * x-dead-letter-routing-key : order.release.order 设置死信路由键 * x-message-ttl :60000 */ arguments.put("x-dead-letter-exchange","order-event-exchange"); arguments.put("x-dead-letter-routing-key","order.release.order"); arguments.put("x-message-ttl",30000); Queue queue = new Queue("order.delay.queue", true, false, false,arguments); return queue; } @Bean public Queue orderReleaseOrderQueue(){ return new Queue("order.release.order.queue", true, false, false); } @Bean public Exchange orderEventExchange(){ // TopicExchange(String name, boolean durable, boolean autoDelete, Map<String, Object> arguments) return new TopicExchange("order-event-exchange",true,false); } @Bean public Binding orderCreateOrder(){ // Binding(String destination, Binding.DestinationType destinationType, String exchange, String routingKey, Map<String, Object> arguments) return new Binding("order.delay.queue", Binding.DestinationType.QUEUE, "order-event-exchange", "order.create.order", null); } @Bean public Binding orderReleaseOrder(){ // Binding(String destination, Binding.DestinationType destinationType, String exchange, String routingKey, Map<String, Object> arguments) return new Binding("order.release.order.queue", Binding.DestinationType.QUEUE, "order-event-exchange", "order.release.order", null); } /** * 订单释放直接和库存释放进行绑定 * @return */ @Bean public Binding orderReleaseOtherBingding(){ // Binding(String destination, Binding.DestinationType destinationType, String exchange, String routingKey, Map<String, Object> arguments) return new Binding("stock.release.stock.queue", Binding.DestinationType.QUEUE, "order-event-exchange", "order.release.other.#", null); } }
在订单创建成功时向MQ中延时队列发送消息,将创建该订单的消息放入延时队列,以便之后如果订单过期执行自动关单逻辑。
在订单的关闭之后时向MQ发送消息,以免之前所说的库存解锁不掉的bug出现,即每次订单成功关闭都会向库存服务发送解锁库存逻辑
/** * 订单的关闭 * @param entity */ @Override public void closeOrder(OrderEntity entity) { // 1、查询当前这个订单的最新状态 OrderEntity orderEntity = this.getById(entity.getId()); if (orderEntity.getStatus() == OrderStatusEnum.CREATE_NEW.getCode()) { // 2、关单 OrderEntity update = new OrderEntity(); update.setId(entity.getId()); update.setStatus(OrderStatusEnum.CANCLED.getCode()); this.updateById(update); OrderTo orderTo = new OrderTo(); BeanUtils.copyProperties(orderEntity, orderTo); // 3、发给MQ一个 rabbitTemplate.convertAndSend("order-event-exchange","order.release.other",orderTo); } }
order.release.order.queue
队列,释放订单服务com.atguigu.gulimall.order.listener
路径下的 OrderClassListener类。package com.atguigu.gulimall.order.listener; @RabbitListener(queues = "order.release.order.queue") @Service public class OrderClassListener { @Autowired OrderService orderService; @RabbitHandler public void listener(OrderEntity entity, Channel channel, Message message) throws IOException { System.out.println("收到过期的订单信息:准备关闭订单!" + entity.getOrderSn()); try { orderService.closeOrder(entity); channel.basicAck(message.getMessageProperties().getDeliveryTag(), false); } catch (Exception e){ channel.basicReject(message.getMessageProperties().getDeliveryTag(), true); } } }
订单释放和库存解锁逻辑: 当订单创建成功之后,向MQ发送关单消息,过期时间为1分钟,向MQ发送解锁库存消息,过期时间为2分钟,关单操作完成之后,过了1分钟解锁库存操作。
存在问题:由于机器卡顿、消息延迟等导致关单消息未延迟发送,解锁库存消息正常发送和监听,导致解锁库存消息被消费,当执行完关单操作后便无法再执行解锁库存操作,导致卡顿的订单永远无法解锁库存。
解决方案:采取主动补偿的策略。当关单操作正常完成之后,主动去发送解锁库存消息给MQ,监听解锁库存消息进行解锁。
/** * 订单的关闭 * @param entity */ @Override public void closeOrder(OrderEntity entity) { // 1、查询当前这个订单的最新状态 OrderEntity orderEntity = this.getById(entity.getId()); if (orderEntity.getStatus() == OrderStatusEnum.CREATE_NEW.getCode()) { // 2、关单 OrderEntity update = new OrderEntity(); update.setId(entity.getId()); update.setStatus(OrderStatusEnum.CANCLED.getCode()); this.updateById(update); OrderTo orderTo = new OrderTo(); BeanUtils.copyProperties(orderEntity, orderTo); // 3、发给MQ一个 rabbitTemplate.convertAndSend("order-event-exchange","order.release.other",orderEntity); } }
stock.release.stock.queue
队列,进行库存解锁在 gulimall-ware 服务中,进行监听处理
1)、编写 StockReleaseListener 进行监听队列
package com.atguigu.gulimall.ware.listener; import com.atguigu.common.to.mq.OrderTo; import com.atguigu.common.to.mq.StockLockedTo; import com.atguigu.gulimall.ware.service.WareSkuService; import com.rabbitmq.client.Channel; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.core.Message; import org.springframework.amqp.rabbit.annotation.RabbitHandler; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; import java.io.IOException; /** * Data time:2022/4/14 21:47 * StudentID:2019112118 * Author:hgw * Description: */ @Slf4j @Service @RabbitListener(queues = "stock.release.stock.queue") public class StockReleaseListener { @Autowired WareSkuService wareSkuService; /** * 库存自己过期处理 * @param to * @param message * @param channel * @throws IOException */ @RabbitHandler public void handleStockLockedRelease(StockLockedTo to, Message message, Channel channel) throws IOException { System.out.println("收到解锁库存的消息"); try { wareSkuService.unlockStock(to); channel.basicAck(message.getMessageProperties().getDeliveryTag(),false); } catch (Exception e){ channel.basicReject(message.getMessageProperties().getDeliveryTag(),true); } } /** * 订单关闭处理 * @param orderTo * @param message * @param channel * @throws IOException */ @RabbitHandler public void handleOrderCloseRelease(OrderTo orderTo, Message message, Channel channel) throws IOException { System.out.println("订单关闭准备解锁库存"); try { wareSkuService.unlockStock(orderTo); channel.basicAck(message.getMessageProperties().getDeliveryTag(),false); } catch (Exception e){ channel.basicReject(message.getMessageProperties().getDeliveryTag(),true); } } }
2、Service层 WareSkuServiceImpl 实现类中,进行方法处理
/** * 防止订单服务卡顿,导致订单状态一直修改不了,库存消息优先到期。查订单状态肯定是新建状态,什么都不做就走了 * 导致卡顿的订单,永远不能解锁库存 * @param orderTo */ @Transactional @Override public void unlockStock(OrderTo orderTo) { String orderSn = orderTo.getOrderSn(); // 查一下最新库存的状态,防止重复解锁库存 WareOrderTaskEntity task = orderTaskService.getOrderTeskByOrderSn(orderSn); Long taskId = task.getId(); // 按照工作单找到所有 没有解锁的库存,进行解锁 List<WareOrderTaskDetailEntity> entities = orderTaskDetailService.list(new QueryWrapper<WareOrderTaskDetailEntity>() .eq("task_id", taskId) .eq("lock_status", 1)); // 进行解锁 for (WareOrderTaskDetailEntity entity : entities) { unLockStock(entity.getSkuId(),entity.getWareId(),entity.getSkuNum(),entity.getId()); }
3、编写查询最新库存的状态,防止重复解锁库存
package com.atguigu.gulimall.ware.service.impl;
@Service("wareOrderTaskService")
public class WareOrderTaskServiceImpl extends ServiceImpl<WareOrderTaskDao, WareOrderTaskEntity> implements WareOrderTaskService {
//.....
@Override
public WareOrderTaskEntity getOrderTeskByOrderSn(String orderSn) {
WareOrderTaskEntity one = this.getOne(new QueryWrapper<WareOrderTaskEntity>().eq("order_sn", orderSn));
return one;
}
}
4、消息共享封装To
package com.atguigu.common.to.mq; @Data public class OrderTo { /** * id */ private Long id; /** * member_id */ private Long memberId; /** * 订单号 */ private String orderSn; /** * 使用的优惠券 */ private Long couponId; /** * create_time */ private Date createTime; /** * 用户名 */ private String memberUsername; /** * 订单总额 */ private BigDecimal totalAmount; /** * 应付总额 */ private BigDecimal payAmount; /** * 运费金额 */ private BigDecimal freightAmount; /** * 促销优化金额(促销价、满减、阶梯价) */ private BigDecimal promotionAmount; /** * 积分抵扣金额 */ private BigDecimal integrationAmount; /** * 优惠券抵扣金额 */ private BigDecimal couponAmount; /** * 后台调整订单使用的折扣金额 */ private BigDecimal discountAmount; /** * 支付方式【1->支付宝;2->微信;3->银联; 4->货到付款;】 */ private Integer payType; /** * 订单来源[0->PC订单;1->app订单] */ private Integer sourceType; /** * 订单状态【0->待付款;1->待发货;2->已发货;3->已完成;4->已关闭;5->无效订单】 */ private Integer status; /** * 物流公司(配送方式) */ private String deliveryCompany; /** * 物流单号 */ private String deliverySn; /** * 自动确认时间(天) */ private Integer autoConfirmDay; /** * 可以获得的积分 */ private Integer integration; /** * 可以获得的成长值 */ private Integer growth; /** * 发票类型[0->不开发票;1->电子发票;2->纸质发票] */ private Integer billType; /** * 发票抬头 */ private String billHeader; /** * 发票内容 */ private String billContent; /** * 收票人电话 */ private String billReceiverPhone; /** * 收票人邮箱 */ private String billReceiverEmail; /** * 收货人姓名 */ private String receiverName; /** * 收货人电话 */ private String receiverPhone; /** * 收货人邮编 */ private String receiverPostCode; /** * 省份/直辖市 */ private String receiverProvince; /** * 城市 */ private String receiverCity; /** * 区 */ private String receiverRegion; /** * 详细地址 */ private String receiverDetailAddress; /** * 订单备注 */ private String note; /** * 确认收货状态[0->未确认;1->已确认] */ private Integer confirmStatus; /** * 删除状态【0->未删除;1->已删除】 */ private Integer deleteStatus; /** * 下单时使用的积分 */ private Integer useIntegration; /** * 支付时间 */ private Date paymentTime; /** * 发货时间 */ private Date deliveryTime; /** * 确认收货时间 */ private Date receiveTime; /** * 评价时间 */ private Date commentTime; /** * 修改时间 */ private Date modifyTime; }
定时关单并解锁库存的总流程如下图:
————————————————————————————————————————————————————————————
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-jH12kwp6-1680405222216)(https://xjxdeimg.oss-cn-beijing.aliyuncs.com/img/820954ff38714407aae8fd72b48e74de%20(2)].png)
这里我们保证了最终一致性,哪怕订单最后不是过期了中间出现各种异常,我们的库存最后也会自动解锁。但这个方案我们必须要保证的一定就是必须保证是可靠消息。
消息问题通常有以下三类:
这里第一种虽然可以try-catch重试,但是网络问题重试大部分可能也不行,所以最好还是将发送失败的消息持久化到数据库中,然后采用定期扫描重发的方式。
其实最主要的就是确认机制,我们最好的就是消息接收端接收到消息返回一个确认接收,消息消费端消息完返回一个确认消费。这里都需要手动的确认,即由MQ的默认自动Ack改为我们程序员pulisher、consumer手动Ack
@Configuration public class MyRabbitConfig { @Autowired RabbitTemplate rabbitTemplate; /** * 使用JSON序列化机制,进行消息转换 * @return */ @Bean public MessageConverter messageConverter(){ return new Jackson2JsonMessageConverter(); } @PostConstruct //MyRabbitConfig对象创建完以后,执行这个方法 public void initRabbitTemplate(){ //设置确认回调 rabbitTemplate.setConfirmCallback(new RabbitTemplate.ConfirmCallback() { /** * 只要消息抵达Broker就b = true * @param correlationData 当前消息的唯一关联数据(这个消息的唯一id) * @param ack 消息是否成功收到 * @param s 失败的原因 */ @Override public void confirm(CorrelationData correlationData, boolean ack, String s) { /** *1、做好消息确认机制(publisher,consumer【手动ack】) * 2、每一个发送的消息都在数据库做好记录。定期将失效的消息再次发送 */ //服务器收到了 System.out.println("confirm...correlationData["+correlationData+"]==>ack["+ack+"]s==>["+s+"]"); } }); //设置消息抵达队列的确认回调 rabbitTemplate.setReturnCallback(new RabbitTemplate.ReturnCallback() { /** * 只要消息没有投递给指定的队列,就触发这个失败回调 * @param message 投递失败的消息详细信息 * @param i 回复的状态码 * @param s 回复的文本内容 * @param s1 当时这个消息发给哪个交换机 * @param s2 当时这个消息用哪个路由键 */ @Override public void returnedMessage(Message message, int i, String s, String s1, String s2) { //报错误了。修改数据库当前消息的错误状态-》错误 System.out.println("Fail Message["+message+"]==>i["+i+"]==>s["+s+"]==>s1["+s1+"]==>s2["+s2+"]"); } }); } }
消息重复问题就是由于我们处理完事务之后,在提交确认(ack)时出现了异常导致确认消息没有发送回去,让MQ误以为消息没有正确被消费从而导致消息重复消费。这里我们需要幂等性设计,代码中查一下对应消息所要操作的数据的状态标志位。防重表其实跟标志位差不多,都是判断一下消息是否处理过了。第三点字段其实也是标志位判断,但不建议使用,因为我们真的处理失败时是需要重新发送的,这里最好还是标志一下其操作的数据比如订单。
消息积压即消费者的消费能力不够, 上线更多的消费者进行正常的消费。消息积压问题会严重影响MQ的性能。
感谢耐心看到这里的同学,觉得文章对您有帮助的话希望同学们不要吝啬您手中的赞,动动您智慧的小手,您的认可就是我创作的动力!
之后还会勤更自己的学习笔记,感兴趣的朋友点点关注哦。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。