目录
商城业务-订单服务-RabbitMQ延时队列
商城业务-订单服务-延时队列定时关单模拟
商城业务-订单服务-创建业务交换机&队列
商城业务-订单服务-监听库存解锁
商城业务-订单服务-库存解锁逻辑
商城业务-订单服务-库存自动解锁完成
商城业务-订单服务-测试库存自动解锁
商城业务-订单服务-定时关单完成
商城业务-订单服务-消息丢失、积压、重复等解决方案
使用消息队列的目的是:保证数据的最终一致性
采用定时任务的方式:每隔一段时间进行全表的扫描,会消耗系统内存和增加数据库的压力,最致命的是存在较大的时间误差
假如:10:00定时任务开始执行,则10:01有用户下订单但未支付,10:30的时候定时任务再次执行,这个订单还差1分钟才能进行关单操作,因此,下一次扫描到它要等到11:00,存在着29分钟的误差时间。
采用消息队列可以完美的解决定时任务所带来的缺陷
假如:10:00下订单,再下订单之前先给消息队列发送一条下单消息,等30分钟自动发送关闭订单消息,监听服务收到消息,去查看此订单是否完成支付,若未完成支付则关闭订单。误差也就一两分钟。对于解锁库存也是同理。
设置消息的过期时间: 在过期时间内都没有被消费则此消息将会被丢弃并称之为死信
设置队列的过期时间:在此过期时间内都没有队列被客户端连接则队列里的所有消息都被成为死信
死信路由: 消息过期未被消费的,则消息会被交给一个指定的路由器,这个路由器由于只接收死信所以被成为死信路由
RabbitMQ实现延时队列的原理:通过设置队列的过期时间使消息都变成死信,此队列是不能被任何服务监听的,当消息过期时,通过死信路由将死信路由给指定队列,指定队列只接收死信也就是延时消息,服务器专门监听指定队列从而达到定时任务的效果。
实现1:给队列设置过期时间,推荐使用
实现方式2:给消息设置过期时间,不推荐使用
不推荐使用的原因是:RabbitMQ采用的是懒检查,假如第一个消息设置的是5分钟过期,第二个消息设置的是2分钟过期,第三个消息设置的是30s过期,RabbitMQ过来一看消息5分钟后才过期,那么5分钟之后才会来将消息路由并不会关注后面消息的过期时间。
按照下图逻辑,模拟下单成功1分钟后,收到关闭订单的消息
编写队列、交换机,绑定关系
容器中的 Binding、Queue、Exchange 都会自动创建(RabbitMQ没有的情况)
RabbitMQ只要有,@Bean声明的属性发生变化也不会覆盖
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.Exchange;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class MyMQConfig {
@Bean
public Queue orderDelayQueue(){
Map arguments = new HashMap<>();
arguments.put("x-dead-letter-exchange","order-event-exchange");
arguments.put("x-dead-letter-routing-key","order.release.order");
arguments.put("x-message-ttl",60000);
// String name, boolean durable, boolean exclusive, boolean autoDelete,
// @Nullable Map arguments
Queue queue = new Queue("order.delay.queue", true, false, false, arguments);
return queue;
}
@Bean
public Queue orderReleaseOrderQueue(){
Queue queue = new Queue("order.release.order.queue", true, false, false);
return queue;
}
@Bean
public Exchange orderEventExchange(){
// String name, boolean durable, boolean autoDelete, Map arguments
TopicExchange exchange = new TopicExchange("order-event-exchange", true, false);
return exchange;
}
@Bean
public Binding orderCreateOrderBinding(){
// String destination, DestinationType destinationType, String exchange, String routingKey,
// @Nullable Map arguments
return new Binding("order.delay.queue",
Binding.DestinationType.QUEUE, "order-event-exchange",
"order.create.order",
null);
}
@Bean
public Binding orderReleaseOrderBinding(){
return new Binding("order.release.order.queue",
Binding.DestinationType.QUEUE, "order-event-exchange",
"order.release.order",
null);
}
}
监听关单事件
模拟订单完成
解锁库存的实现:
①库存服务导入RabbitMQ的依赖
org.springframework.boot
spring-boot-starter-amqp
② RabbitMQ的配置
spring:
rabbitmq:
host: 192.168.56.22
virtual-host: /
③ 配置RabbitMQ的序列化机制
import org.springframework.amqp.support.converter.AbstractMessageConverter;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class MyRabbitMQConfig {
@Bean
public AbstractMessageConverter messageConverter(){
return new Jackson2JsonMessageConverter();
}
}
④ 开启RabbitMQ
⑤ 按照下图创建交换机、队列、绑定关系
import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.support.converter.AbstractMessageConverter;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;
@Configuration
public class MyRabbitMQConfig {
@RabbitListener(queues = "stock.release.stock.queue")
public void handle(Message message){
}
@Bean
public AbstractMessageConverter messageConverter(){
return new Jackson2JsonMessageConverter();
}
@Bean
public Exchange stockEventExchange(){
// String name, boolean durable, boolean autoDelete, Map arguments
return new TopicExchange("stock-event-exchange",true,false);
}
@Bean
public Queue stockReleaseStockQueue(){
// String name, boolean durable, boolean exclusive, boolean autoDelete,@Nullable Map arguments
return new Queue("stock.release.stock.queue",true,false,false,null);
}
@Bean
public Queue stockDelayQueue(){
Map 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);
// String name, boolean durable, boolean exclusive, boolean autoDelete,@Nullable Map arguments
return new Queue("stock.delay.queue",true,false,false,arguments);
}
@Bean
public Binding stockReleaseBinding(){
// String destination, DestinationType destinationType, String exchange, String routingKey,@Nullable Map arguments
return new Binding("stock.release.stock.queue",
Binding.DestinationType.QUEUE,"stock-event-exchange","stock.release.#",null);
}
@Bean
public Binding stockLockedBinding(){
return new Binding("stock.delay.queue",Binding.DestinationType.QUEUE,
"stock-event-exchange","stock.locked",null);
}
}
出现问题: 并没创建交换机、队列、绑定关系
出现问题的原因:只有当第一次连接上RabbitMQ时,发现没有这些东西才会创建
解决方案:监听队列
交换机、队列、绑定关系创建成功后,将上述代码注释
库存解锁的两种场景:
①下单成功,订单过期没有支付被系统自动取消、被用户手动取消。都要解锁
②下单成功,库存锁定成功,接下来的业务调用失败,导致订单回滚。之前锁定的库存就要自动解锁
① 加上全参和无参构造器注解
② 保存工作单详情方便回溯
如果To仅仅保存这个两个数据的话,会存在一些问题, 当1号订单在1号仓库扣减1件商品成功,2号订单在2号仓库扣减2件商品成功,3号订单在3号仓库扣减3件商品失败时,库存工作单的数据将会回滚,此时,数据库中将查不到1号和2号订单的库存工作单的数据,但是库存扣减是成功的,导致无法解锁库存
解决方案: 保存库存工作详情To
解锁场景:
1.下单成功,库存锁定成功,接下来的业务调用失败导致订单回滚。之前锁定的库存就要自动解锁。
2.锁库存失败无需解锁
解决方案:通过查询订单的锁库存信息,如果有则仅仅说明库存锁定成功,还需判断是否有订单信息,如果有订单信息则判断订单状态,若订单状态已取消则解锁库存,反之:不能解锁库存,如果没有订单信息则需要解锁库存,如果没有锁库存信息则无需任何操作。
1.编写Vo,通过拷贝订单实体,用于接收订单信息
2. 远程服务编写,获取订单状态
/**
* 解锁库存服务
* @param stockLockedTo
*/
@Override
public void unlockStock(StockLockedTo stockLockedTo){
StockDetailTo detail = stockLockedTo.getDetail();
Long detailId = detail.getId();
// 查询库存工作单的信息 有:解锁库存 没有:库存锁定失败,数据自定义回滚无需解锁
WareOrderTaskDetailEntity detailEntity = wareOrderTaskDetailService.getById(detailId);
if (null!=detailEntity){
// 有,解锁库存
Long id = stockLockedTo.getId(); // 库存工作单的id
WareOrderTaskEntity wareOrderTaskEntity = wareOrderTaskService.getById(id);
String orderSn = wareOrderTaskEntity.getOrderSn();
// 远程服务调用,获取订单状态信息
// 先判断订单是否存在
R r = orderFeignService.getOrderStatus(orderSn);
if (r.getCode().equals(0)){
OrderVo orderVo = r.getData(new TypeReference() {});
if (null==orderVo || orderVo.getStatus().equals(4)){
// 订单不存在或者订单被关闭,都需要去解锁库存
// 当且仅当 锁定状态为 已锁定 时 才去解锁
if (detailEntity.getLockStatus().equals(1)){
releaseStock(detailEntity.getSkuId(),detailEntity.getWareId(),detail.getSkuNum(),detailEntity.getId());
}
}
}else {
// 远程服务调用失败,抛出异常 将消息放回消息队列中
throw new RuntimeException("远程调用订单服务失败!!!");
}
}else {
// 订单的库存详情信息不存在,无需解锁
}
}
private void releaseStock(Long skuId, Long wareId, Integer skuNum,Long taskDetailId) {
// 解锁库存
wareSkuDao.unLockStock(skuId,wareId,skuNum);
// 更新库存工作单状态
WareOrderTaskDetailEntity wareOrderTaskDetailEntity = new WareOrderTaskDetailEntity();
wareOrderTaskDetailEntity.setId(taskDetailId);
wareOrderTaskDetailEntity.setLockStatus(2);
wareOrderTaskDetailService.updateById(wareOrderTaskDetailEntity);
}
4. 远程服务调用可能会出现失败,需要设置手动ACK,确保其它服务能消费此消息
#手动ACK设置
spring.rabbitmq.listener.simple.acknowledge-mode=manual
出现问题: 远程调用订单服务时被拦截器拦截
解决方案:请求路径适配放行
1.定时关单代码编写
①订单创建成功,给MQ发送关单消息
订单释放和库存解锁逻辑: 当订单创建成功之后,向MQ发送关单消息,过期时间为1分钟,向MQ发送解锁库存消息,过期时间为2分钟,关单操作完成之后,过了1分钟解锁库存操作。
存在问题:由于机器卡顿、消息延迟等导致关单消息未延迟发送,解锁库存消息正常发送和监听,导致解锁库存消息被消费,当执行完关单操作后便无法再执行解锁库存操作,导致卡顿的订单永远无法解锁库存。
解决方案:采取主动补偿的策略。当关单操作正常完成之后,主动去发送解锁库存消息给MQ,监听解锁库存消息进行解锁。
③ 按上图创建绑定关系
④ common服务中,创建CreateTo(拷贝order实体)
⑤ 向MQ发送解锁库存消息
⑥ 解锁库存操作
情况一: 消息发送出去但是由于网络原因未到达服务器,解决方案:采用try-catch将发送失败的消息持久化到数据库中,采用定期扫描重发的方式。
drop table if exists mq_message;
CREATE TABLE `mq_message` (
`message_id` CHAR(32) NOT NULL,
`content` TEXT,
`to_exchange` VARCHAR(255) DEFAULT NULL,
`routing_key` VARCHAR(255) DEFAULT NULL,
`class_type` VARCHAR(255) DEFAULT NULL,
`message_status` INT(1) DEFAULT '0' COMMENT '0-新建 1-已发送 2-错误抵达 3-已抵达',
`create_time` DATETIME DEFAULT NULL,
`update_time` DATETIME DEFAULT NULL,
PRIMARY KEY (`message_id`)
) ENGINE=INNODB DEFAULT CHARSET=utf8mb4
情况二:消息抵达服务器的队列中才算完成消息的持久化,解决方案publish的ack机制
情况三: 防止自动ack带来的缺陷,采用手动ack,解决方案上面都有这里不再细说
消息被成功消费,ack时宕机,消息由unack变成ready,Broker又重新发送。解决方案:将消费者的业务消费接口应该设计为幂等性的,比如扣库存有工作单的状态标志。
消息积压即消费者的消费能力不够, 上线更多的消费者进行正常的消费。