一、消息可靠性问题
消息丢失的三种情况
- 发送者发送消息时网络故障
- MQ故障导致消息丢失
- 消费者处理业务时故障
从三方面解决可靠性问题,发送者的可靠性,MQ的可靠性,消费者的可靠性。在以上可靠性都失败的情况下,还需要延迟消息这一方案保障。
二、发送者的可靠性
发送者重连
有的时候由于网络波动,可能会出现发送者连接MQ失败的情况。通过配置我们可以开启连接失败后的重连机制:
spring:
rabbitmq:
connection-timeout: 1s # 设置MQ的连接超时时间
template:
retry:
enabled: true # 开启超时重试机制
initial-interval: 1000ms # 失败后的初始等待时间
multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长 = initial-interval * multiplier
max-attempts: 3 # 最大重试次数
修改publisher模块的application.yaml文件。
关闭RabbitMQ服务。
测试发送一条消息,可以看到重连了三次,说明发送者重连机制成功运行了。
注意:
当网络不稳定的时候,利用重试机制可以有效提高消息发送的成功率。不过SpringAMQP提供的重试机制是阻塞式的重试,也就是说多次重试等待的过程中,当前线程是被阻塞的,会影响业务性能。如果对于业务性能有要求,建议禁用重试机制。如果一定要使用,请合理配置等待时长和重试次数,当然也可以考虑使用异步线程来执行发送消息的代码。
发送者确认
SpringAMQP提供了Publisher Confirm和Publisher Return两种确认机制。开启确认机制后,当发送者发送消息给MQ后,MQ会返回确认结果给发送者。返回的结果有以下几种情况:
- 消息投递到了MQ,但是路由失败。此时会通过PublisherReturn返回路由异常原因,然后返回ACK,告知投递成功
- 临时消息投递到了MQ,并且入队成功,返回ACK,告知投递成功
- 持久消息投递到了MQ,并且入队完成持久化,返回ACK ,告知投递成功
- 其它情况都会返回NACK,告知投递失败
1. 在publisher这个微服务的application.yml中添加配置
spring:
rabbitmq:
publisher-confirm-type: correlated # 开启publisher confirm机制,并设置confirm类型
publisher-returns: true # 开启publisher return机制
这里publisher-confirm-type有三种模式可选:
- none:关闭confirm机制
- simple:同步阻塞等待MQ的回执消息
- correlated:MQ异步回调方式返回回执消息
2. 每个RabbitTemplate只能配置一个ReturnCallback,因此需要在项目启动过程中配置
package com.itheima.publisher.config;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ReturnedMessage;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.context.annotation.Configuration;
import javax.annotation.PostConstruct;
@Slf4j
@AllArgsConstructor
@Configuration
public class MqConfig {
private final RabbitTemplate rabbitTemplate;
@PostConstruct
public void init(){
rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
@Override
public void returnedMessage(ReturnedMessage returned) {
log.error("触发return callback,");
log.debug("exchange: {}", returned.getExchange());
log.debug("routingKey: {}", returned.getRoutingKey());
log.debug("message: {}", returned.getMessage());
log.debug("replyCode: {}", returned.getReplyCode());
log.debug("replyText: {}", returned.getReplyText());
}
});
}
}
3. 发送消息,指定消息ID、消息ConfirmCallback
启动MQ。
启动debug日志记录。
编写testConfirmCallback方法。
@Test
public void testConfirmCallback() throws InterruptedException {
// 0.创建correlationData
CorrelationData cd = new CorrelationData(UUID.randomUUID().toString());
cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
@Override
public void onFailure(Throwable ex) {
log.error("spring amqp 处理结果异常",ex);
}
@Override
public void onSuccess(CorrelationData.Confirm result) {
// 判断是否成功
if(result.isAck()) {
log.debug("收到ConfirmCallback ack,消息发送成功!");
} else {
log.error("收到ConfirmCallback nack,消息发送失败!reason:{}",result.getReason());
}
}
});
// 1.交换机名
String exchangeName = "hmall.direct";
// 2.消息
String message = "hello,雪miku!";
// 3.发送消息,参数分别是:交互机名称、RoutingKey(暂时为空)、消息、correlationData
rabbitTemplate.convertAndSend(exchangeName,"blue",message,cd);
Thread.sleep(2000);
// 休眠2s,以便在测试结束前能够接收回调
}
收到ConfirmCallback ack,消息发送成功。
将RoutingKey改为错误值,测试消息投递到了MQ,但是路由失败时的情况。此时会通过PublisherReturn返回路由异常原因,然后返回ACK,告知投递成功。
将交换机名改为错误值,测试回执情况。可以看到,当交换机名字错误时,返回nack。
只要正确编写returncallback,confirmcallback,在nack时重发消息,那么消息可靠性会有极大的提升。
总结
SpringAMQP中发送者消息确认的几种返回值情况:
- 消息投递到了MQ,但是路由失败。会return路由异常原因,返回ACK
- 临时消息投递到了MQ,并且入队成功,返回ACK
- 持久消息投递到了MQ,并且入队完成持久化,返回ACK
- 其它情况都会返回NACK,告知投递失败
如何处理发送者的确认消息?
- 发送者确认需要额外的网络和系统资源开销,尽量不要使用
- 对于nack消息可以有限次数重试,依然失败则记录异常消息
三、MQ可靠性
在默认情况下,RabbitMQ会将接收到的信息保存在内存中以降低消息收发的延迟。这样会导致两个问题:
- 一旦MQ宕机,内存中的消息会丢失
- 内存空间有限,当消费者故障或处理过慢时,会导致消息积压,引发MQ阻塞
数据持久化
RabbitMQ实现数据持久化包括3个方面:
- 交换机持久化
- 队列持久化
- 消息持久化
交换机创建时持久性默认是Durable持久化的,用spring amqp代码创建时也默认是持久化的。
队列创建时持久性默认也是Durable持久化的,用spring amqp代码创建时也默认是持久化的。
消息发送时投递模式默认为非持久化,要手动设置为持久化。
分别发送非持久消息“123”和持久消息“持久消息”,此时在get message可以查到。
重启mq服务。
重启mq后只能查到持久化的消息了。
向队列中分别大量发送两种消息,测试使用非持久化消息和持久化消息哪种性能更高。非持久化消息即临时消息,如果消息量非常大,发送速度过高,出现消息堆积,有可能出现MQ阻塞。
自定义消息为非持久化模式测试
为了避免性能过差,关闭消息的确认机制,将confirm和return都关闭。
消息开始在内存中,随着内存耗尽,后来paged out即写出到磁盘。当内存达到上限时,把内存的数据写出到磁盘中。写出到磁盘瞬间,消息处理速度降到0,当写完后,速度又恢复,再次paged out,不断恢复和写出,导致消息处理出现波浪线效果。每次把消息从内存写出到磁盘过程中,MQ处于阻塞状态,消息处理速度降为0,这是纯内存模式的弊端。可能信息丢失或阻塞。
将消息改为持久化模式测试
清除之前的消息,以便观察重新测试数据。
每次发送的消息都直接持久化,没有阻塞,性能很好,而且重启mq后消息也不会丢失。
Lazy Queue
在默认情况下,RabbitMQ会将接收到的信息保存在内存中以降低消息收发的延迟。但在某些特殊情况下,这会导致消息积压,比如:
- 消费者宕机或出现网络故障
- 消息发送量激增,超过了消费者处理速度
- 消费者处理业务发生阻塞
一旦出现消息堆积问题,RabbitMQ的内存占用就会越来越高,直到触发内存预警上限。此时RabbitMQ会将内存消息刷到磁盘上,这个行为成为PageOut. PageOut会耗费一段时间,并且会阻塞队列进程。因此在这个过程中RabbitMQ不会再处理新的消息,生产者的所有请求都会被阻塞。
为了解决这个问题,从RabbitMQ的3.6.0版本开始,就增加了Lazy Queue的概念,也就是惰性队列。在3.12版本后,所有队列都是Lazy Queue模式,无法更改。惰性队列的特征如下:
- 接收到消息后直接存入磁盘,不再存储到内存
- 消费者要消费消息时才会从磁盘中读取并加载到内存(可以提前缓存部分消息到内存,最多2048条)
要设置一个队列为惰性队列,只需要在声明队列时,指定x-queue-mode属性为lazy即可:
在控制台添加
先删掉之前的消息。
创建lazy.queue,在Arguments中设置x-queue-mode为lazy。
修改代码,设定接收消息的队列为lazy.queue。
运行测试,向lazy.queue发送大量消息。
由控制台信息可以看到,lazy.queue接收的消息直接被paged out即写到磁盘,而没有暂存在在内存中。
用代码添加
声明bean,用QueueBuilder
@Bean
public Queue lazyQueue(){
return QueueBuilder
.durable("lazy.queue")
.lazy() // 开启Lazy模式
.build();
}
用RabbitListener注解
@RabbitListener(queuesToDeclare = @Queue(
name = "lazy.queue",
durable = "true",
arguments = @Argument(name = "x-queue-mode", value = "lazy")
))
public void listenLazyQueue(String msg){
log.info("接收到 lazy.queue的消息:{}", msg);
}
总结
RabbitMQ如何保证消息的可靠性
- 首先通过配置可以让交换机、队列、以及发送的消息都持久化。这样队列中的消息会持久化到磁盘,MQ重启消息依然存在。
- RabbitMQ在3.6版本引入了LazyQueue,并且在3.12版本后会称为队列的默认模式。LazyQueue会将所有消息都持久化。
- 开启持久化和发送者确认时, RabbitMQ只有在消息持久化完成后才会给发送者返回ACK回执
四、消费者的可靠性
当RabbitMQ向消费者投递消息以后,需要知道消费者的处理状态如何。因为消息投递给消费者并不代表就一定被正确消费了,可能出现的故障有很多,比如:
- 消息投递的过程中出现了网络故障
- 消费者接收到消息后突然宕机
- 消费者接收到消息后,因处理不当导致异常
一旦发生上述情况,消息也会丢失。因此,RabbitMQ必须知道消费者的处理状态,一旦消息处理失败才能重新投递消息。
消费者确认机制
消费者确认机制(Consumer Acknowledgement)是为了确认消费者是否成功处理消息。当消费者处理消息结束后,应该向RabbitMQ发送一个回执,告知RabbitMQ自己消息处理状态:
- ack:成功处理消息,RabbitMQ从队列中删除该消息
- nack:消息处理失败,RabbitMQ需要再次投递消息
- reject:消息处理失败并拒绝该消息,RabbitMQ从队列中删除该消息
不管那种状态,都不能在刚收到消息时就返回,而是根据对消息的处理结果来判断。
SpringAMQP已经实现了消息确认功能。并允许我们通过配置文件选择ACK处理方式,有三种方式:
- none:不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。非常不安全,不建议使用
- manual:手动模式。需要自己在业务代码中调用api,发送ack或reject,存在业务入侵,但更灵活
- auto:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回ack. 当业务出现异常时,根据异常判断返回不同结果:
- 如果是业务异常,会自动返回nack
- 如果是消息处理或校验异常,自动返回reject
通过下面的配置可以修改SpringAMQP的ACK处理方式:
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: none # 不做处理
# none,关闭ack;manual,手动ack;auto:自动ack
none模式
在consumer模块修改application.yaml配置,先测试消息处理方式为none时。
修改consumer服务的SpringRabbitListener类中的方法,模拟一个消息处理的异常。
向simple.queue队列发送消息,进行测试。
可以看到此时simple.queue中存储了一条消息。
随后用debug模式启动消费者consumer。发现程序进入抛出异常处的断点,这表明消费者刚收到消息,正准备处理。
放行后消费者抛出异常,说明消息没有正常处理需要队列重发,但控制台中可以看到simple.queue中的消息丢失了。故none方式不可取,消息会丢失。
auto模式
将消息处理方式改为auto模式进行测试。
再次发送消息。
可以看到消息进入simple.queue。
启动调试,消费者接收到消息并暂停在抛出异常的断点之前。
此时消息处于unacked即未确认状态。
消费者此时拿到消息开始处理,放行后在处理过程中抛出了Runtime业务异常。spring会自动对该方法进行环绕增强,当发现抛出异常时,会抛出nack。nack会导致消息重新投递回来。因此放行方法再次进入断点。因为返回的是nack,rabbitmq收到消息会认为处理有问题,会再次投递消息,一直重试到宕机。
结束消费者的运行。
关闭消费者程序即宕机后,回到浏览器可以看到消息状态发生改变,变回ready了。只要没有ack,就会一直保留消息,直到成功为止。因此消费者宕机后,重启动即可。
reject模式
如果不是抛出业务异常,而是消息转换异常比如MessageConversionException,这种异常表示消息本身转换过程中出现了问题。
修改异常类型为MessageConversionException并重启调试。
执行断点前,消息处于unacked。
放行断点。
放行后发现没有再次收到消息。这是因为消息转换异常会直接reject拒绝这个消息,这个消息被丢弃了或路由到死信交换机。此时还没有死信交换机,故消息被丢弃。此时控制台可看到simple.queue消息队列中消息已经被删除了。
基于springamqp提供的自动确认机制,我们只要正常写业务代码,将来它会自动根据业务执行情况实现确认,大大增强了消息的可靠性。但有一些消息格式的异常,spring检测不到,这种情况下需要自己对业务做判断。
失败重试机制
当消费者出现异常后,消息会不断requeue(重入队)到队列,再重新发送给消费者。如果消费者再次执行依然出错,消息会再次requeue到队列,再次投递,直到消息处理成功为止。极端情况就是消费者一直无法执行成功,那么消息requeue就会无限循环,导致mq的消息处理飙升,带来不必要的压力。因此SpringAMQP提供了消费者失败重试机制,在消费者出现异常时利用本地重试,而不是无限的requeue到mq,mq再把消息投递给消费者。
无限循环重新入队和重新投递的情况示例
消费者的消息监听器中异常类型改回RuntimeException。
向simple.queue发送消息。
启动消费者。
消费者一直在抛异常。simple.queue中消息状态为redelivered即重新投递,由于投递失败一直重新入队并重新投递,导致MQ压力非常大,消费者压力也很大。
使用重试机制
为了应对上述情况Spring又提供了消费者失败重试机制:在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列。我们可以通过在application.yaml文件中添加配置来开启重试机制:
spring:
rabbitmq:
listener:
simple:
retry:
enabled: true # 开启消费者失败重试
initial-interval: 1000ms # 初识的失败等待时长为1秒
multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
max-attempts: 3 # 最大重试次数
stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
在consumer的application.yaml中添加消费者失败重试配置。
此时消息还在simple.queue队列中。只要重启消费者就会接收消息。
日志信息概览
监听到simple.queue的消息:【hello,spring amqp】
01-29 13:07:21:567 INFO 23636 --- [ main] c.itheima.consumer.ConsumerApplication : Started ConsumerApplication in 2.807 seconds (JVM running for 3.599)
01-29 13:07:22:567 INFO 23636 --- [ntContainer#8-1] c.i.consumer.mq.SpringRabbitListener : 监听到simple.queue的消息:【hello,spring amqp】
01-29 13:07:23:570 INFO 23636 --- [ntContainer#8-1] c.i.consumer.mq.SpringRabbitListener : 监听到simple.queue的消息:【hello,spring amqp】
01-29 13:07:23:578 WARN 23636 --- [ntContainer#8-1] o.s.a.r.r.RejectAndDontRequeueRecoverer : Retries exhausted for message (Body:'"hello,spring amqp"' MessageProperties [headers={__TypeId__=java.lang.String}, contentType=application/json, contentEncoding=UTF-8, contentLength=0, receivedDeliveryMode=PERSISTENT, priority=0, redelivered=true, receivedExchange=, receivedRoutingKey=simple.queue, deliveryTag=1, consumerTag=amq.ctag-WO4M5YLPXZ7HqY6AWv4WZA, consumerQueue=simple.queue])
消费者服务三次监听到消息队列 simple.queue 中的消息 “hello,spring amqp”,并尝试处理,但均失败。其中o.s.a.r.r.RejectAndDontRequeueRecoverer
,表示Spring AMQP的重试恢复器。RejectAndDontRequeueRecoverer : Retries exhausted for message
表示消息hello,spring amqp
重试次数已耗尽,最终被拒绝且不再重新入队(RejectAndDontRequeue),意味着消息将被丢弃或进入死信队列。
可知消费者收到一次消息。然后并没有把消息重新入队到队列和重新投递,而是在本地又重试两次。在simple.queue队列中,并没有重新入队,说明投递过去了。三次重试之后,重试耗尽了,消息队列中消息也没了,导致消费者可靠性降低。RejectAndDontRequeueRecoverer是springamqp的重试机制开启后的默认消息处理策略,即“拒绝且不要重新入队”,可靠性低,所以需要我们修改消息处理策略。
更改失败消息处理策略
在开启重试模式后,重试次数耗尽,如果消息依然失败,则需要有MessageRecoverer接口来处理,它包含三种不同的实现:
- RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式
- ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队
- RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机
将失败处理策略改为RepublishMessageRecoverer:
首先,定义接收失败消息的交换机、队列及其绑定关系,然后,定义RepublishMessageRecoverer。
package com.itheima.consumer.config;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.rabbit.retry.MessageRecoverer;
import org.springframework.amqp.rabbit.retry.RepublishMessageRecoverer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 错误消息处理配置类
* 功能:配置消息消费失败后的重试和重新发布机制
* 核心原理:通过定义错误交换机和队列,将无法处理的消息路由到指定位置
*/
@Configuration // 声明为Spring配置类
public class ErrorMessageConfiguration {
/**
* 创建错误消息专用直连交换机
* 作用:作为错误消息的路由中心
* 特性:
* - 直连交换机根据精确匹配的routingKey路由消息
* - 持久化默认开启(Durable)
*/
@Bean
public DirectExchange errorExchange() {
return new DirectExchange("error.direct"); // 创建名为error.direct的直连交换机
}
/**
* 创建错误消息队列
* 作用:存储所有处理失败的消息
* 特性:
* - 队列持久化(默认)
* - 未被消费的消息会一直保留直到被消费
*/
@Bean
public Queue errorQueue() {
return new Queue("error.queue"); // 创建名为error.queue的队列
}
/**
* 绑定错误队列到错误交换机
* 作用:建立路由规则,将发送到error.direct交换机且routingKey=error的消息路由到error.queue
* @param errorQueue 通过参数注入已定义的errorQueue Bean
* @param errorExchange 通过参数注入已定义的errorExchange Bean
*/
@Bean
public Binding errorQueueBinding(Queue errorQueue, DirectExchange errorExchange) {
return BindingBuilder.bind(errorQueue) // 绑定队列
.to(errorExchange) // 到交换机
.with("error"); // 使用routingKey="error"
}
/**
* 配置消息恢复处理器
* 作用:当消息重试耗尽后,将消息重新发布到指定交换机
* 工作流程:
* 1. 消息消费失败触发重试机制
* 2. 达到最大重试次数后调用此处理器
* 3. 将原始消息发送到error.direct交换机,携带error路由键
* @param rabbitTemplate 自动注入的RabbitMQ操作模板
*/
@Bean
public MessageRecoverer messageRecoverer(RabbitTemplate rabbitTemplate) {
return new RepublishMessageRecoverer(
rabbitTemplate, // RabbitMQ操作模板
"error.direct", // 目标交换机
"error" // routingKey
);
}
}
重启消费者。
再次向simple.queue发送消息。
可以看到消息重试了三次,之后RepublishMessageRecoverer生效,将失败消息发到了error.direct交换机。
可以看到error.direct中的消息记录。
error.queue中的消息记录。
可以查看到error.direct绑定了error.queue,且error.queue也接收到了消息,在get message可以看到详细信息。
总结
如何开启消费者失败重试机制?
spring:
rabbitmq:
listener:
simple:
retry:
enabled: true # 开启消费者失败重试
initial-interval: 1000ms # 初识的失败等待时长为1秒
multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
max-attempts: 3 # 最大重试次数
stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
如何配置失败重试处理策略?
@Bean
public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){
return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
}
业务幂等性
幂等是一个数学概念,用函数表达式来描述是这样的:f(x) = f(f(x)) 。在程序开发中,则是指同一个业务,执行一次或多次对业务状态的影响是一致的。例如:
- 根据id删除数据
- 查询数据
- 新增数据
但数据的更新往往不是幂等的,如果重复执行可能造成不一样的后果。比如:
- 取消订单,恢复库存的业务。如果多次恢复就会出现库存重复增加的情况
- 退款业务。重复退款对商家而言会有经济损失。
所以,我们要尽可能避免业务被重复执行。 然而在实际业务场景中,由于意外经常会出现业务被重复执行的情况,例如:
- 页面卡顿时频繁刷新导致表单重复提交
- 服务间调用的重试
- MQ消息的重复投递
我们在用户支付成功后会发送MQ消息到交易服务,修改订单状态为已支付,就可能出现消息重复投递的情况。如果消费者不做判断,很有可能导致消息被消费多次,出现业务故障。 举例:
- 假如用户刚刚支付完成,并且投递消息到交易服务,交易服务更改订单为已支付状态。
- 由于某种原因,例如网络故障导致生产者没有得到确认,隔了一段时间后重新投递给交易服务。
- 但是,在新投递的消息被消费之前,用户选择了退款,将订单状态改为了已退款状态。
- 退款完成后,新投递的消息才被消费,那么订单状态会被再次改为已支付。业务异常。
因此,我们必须想办法保证消息处理的幂等性。这里给出两种方案:
- 唯一消息ID
- 业务状态判断
唯一消息id
方案一,是给每个消息都设置一个唯一id,利用id区分是否是重复消息:
- 每一条消息都生成一个唯一的id,与消息一起投递给消费者。
- 消费者接收到消息后处理自己的业务,业务处理成功后将消息ID保存到数据库
- 如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理。
我们该如何给消息添加唯一ID呢? 其实很简单,SpringAMQP的MessageConverter自带了MessageID的功能,我们只要开启这个功能即可。 以Jackson的消息转换器为例:
@Bean
public MessageConverter messageConverter(){
// 1.定义消息转换器
Jackson2JsonMessageConverter jjmc = new Jackson2JsonMessageConverter();
// 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
jjmc.setCreateMessageIds(true);
return jjmc;
}
在发送者启动类中的消息转换器方法添加创建消息id的配置。
向simple.queue发送消息。
可以看到simple.queue收到的消息有消息id了。
消息监听者接收消息时,用Message类型接收消息。
启动消费者,可以看到监听日志中输出了simple.queue接收到的消息id和消息体。
业务判断
方案二,是结合业务逻辑,基于业务本身做判断。以我们的余额支付业务为例:
修改对支付状态的监听器,增加先查询订单,判断订单支付状态的逻辑,仅在支付状态为未支付时才执行标记订单状态为已支付的逻辑。
总结
面试题:如何保证支付服务与交易服务之间的订单状态一致性?
-
首先,支付服务会正在用户支付成功以后利用异步通知的MQ消息通知交易服务,完成订单状态同步。
- 为什么用异步通知,而不是用openfeign的方式?
- 回答同步和异步调用的区别。
-
通知失败怎么办? 其次,为了保证MQ消息的可靠性,我们采用了生产者(发送者)确认机制、消费者确认、消费者失败重试等策略,确保消息投递和处理的可靠性。同时也开启了MQ的持久化,避免因服务宕机导致消息丢失。可以确保消息一定投递到消费者,至少让消费者处理一次(因为可能出现重复投递)。
- 什么是消费者确认?
- 什么是生产者确认?
- 什么是消费者重试策略?
-
最后,由于可能重复投递消息,我们还在交易服务更新订单状态时做了业务幂等判断,避免因消息重复消费导致订单状态异常。
- 业务幂等的常见方案?
如果交易服务消息处理失败,有没有什么兜底方案?
- 我们可以在交易服务设置定时任务,定期查询订单支付状态。这样即便MQ通知失败,还可以利用定时任务作为兜底方案,确保订单支付状态的最终一致性。
五、延迟消息
在电商的支付业务中,对于一些库存有限的商品,为了更好的用户体验,通常都会在用户下单时立刻扣减商品库存。例如电影院购票、高铁购票,下单后就会锁定座位资源,其他人无法重复购买。但是这样就存在一个问题,假如用户下单后一直不付款,就会一直占有库存资源,导致其他客户无法正常交易,最终导致商户利益受损!因此,电商中通常的做法就是:对于超过一定时间未支付的订单,应该立刻取消订单并释放占用的库存。例如,订单支付超时时间为30分钟,则我们应该在用户下单后的第30分钟检查订单支付状态,如果发现未支付,应该立刻取消订单,释放库存。但问题来了:如何才能准确的实现在下单后第30分钟去检查支付状态呢?像这种在一段时间以后才执行的任务,我们称之为延迟任务,而要实现延迟任务,最简单的方案就是利用MQ的延迟消息了。
延迟消息:发送者发送消息时指定一个时间,消费者不会立刻收到消息,而是在指定时间之后才收到消息。在RabbitMQ中实现延迟消息也有两种方案:
- 死信交换机+TTL
- 延迟消息插件
死信交换机
当一个队列中的消息满足下列情况之一时,就会成为死信(dead letter):
- 消费者使用basic.reject或 basic.nack声明消费失败,并且消息的requeue参数设置为false
- 消息是一个过期消息(达到了队列或消息本身设置的过期时间),超时无人消费
- 要投递的队列消息堆积满了,最早的消息可能成为死信
如果队列通过dead-letter-exchange
属性指定了一个交换机,那么该队列中的死信就会投递到这个交换机中。这个交换机称为死信交换机(Dead Letter Exchange,简称DLX)。
在消息队列系统(如RabbitMQ)中,死信交换机(Dead Letter Exchange, DLX) 本身不直接支持延迟消息,但可以通过结合 消息TTL(Time-To-Live) 和 队列绑定规则 实现延迟消息功能。以下是具体实现原理和步骤:
实现原理
-
消息TTL机制
- 为消息设置过期时间(TTL),当消息在队列中存活超过TTL时,会变成 死信(Dead Letter)。
- TTL可通过两种方式设置:
- 消息级别TTL:每条消息单独设置过期时间。
- 队列级别TTL:队列中所有消息统一过期时间(灵活性较低)。
-
死信路由规则
- 定义队列的死信交换机(DLX)和路由键(Routing Key)。
- 当消息过期后,自动转发到DLX,并由DLX路由到目标队列。
-
延迟效果
- 消息首先进入一个 缓冲队列(带TTL),过期后通过DLX路由到 目标队列,消费者从目标队列消费时即实现延迟。
在消费者的监听器中编写监听死信队列的方法listenDlxQueue
,并用注解方式设置死信交换机和死信队列。
编写普通交换机和普通队列,并为普通队列设定死信交换机。
启动消费者。
可以看到dlx.direct与dlx.queue绑定成功,normal.direct与normal.queue绑定成功,并且normal.queue设置了死信交换机为dlx.direct。
编写向normal.direct发送延迟消息的测试方法。
发送者向normal.direct发送消息,且设置消息过期时间为10s。此时消息发送时间为37分33秒。
normal.queue中收到消息。
dlx.queue中收到消息。
可以看到,发送消息时间为37分33秒,消费者的listenDlxQueue
方法监听到死信队列消息的时间为37分44秒,基本相差10s,成功实现了延迟消息。
延迟消息插件
这个插件可以将普通交换机改造为支持延迟消息功能的交换机,当消息投递到交换机后可以暂存一定时间,到期后再投递到队列。
因为我们是基于Docker安装,所以需要先查看RabbitMQ的插件目录对应的数据卷。使用docker volume ls
查看所有数据卷,用docker volume inspect mq-plugins
查看mq的插件目录。查询结果显示插件目录被挂载到了/var/lib/docker/volumes/mq-plugins/_data
这个目录。使用cd /var/lib/docker/volumes/mq-plugins/_data
切换到该目录,上传资料里的插件到该目录下。
执行docker exec -it mq rabbitmq-plugins enable rabbitmq_delayed_message_exchange
命令,安装插件。
在消费者监听方法使用注解方式设置延迟交换机和延迟队列。
启动消费者,开启监听的同时会创建延迟交换机和延迟队列。
可以看到delay.queue和delay.direct已绑定,且delay.direct为支持延迟消息类型即x-delayed-message
的交换机。
编写发送延迟消息的方法,通过x-delay属性设定延迟时间为10s。
运行测试方法,向delay.direct发送延迟消息。发出时间约为39分41秒。
可以看到delay.queue中收到了消息。
39分41秒向delay.direct发出消息,在39分51秒时由监听器接收到delay.queue的消息,证明实现了消息的延迟发送。
注意: 延迟消息插件内部会维护一个本地数据库表,同时使用Elang Timers功能实现计时。如果消息的延迟时间设置较长,可能会导致堆积的延迟消息非常多,会带来较大的CPU开销,同时延迟消息的时间会存在误差。因此,不建议设置延迟时间过长的延迟消息。
实现订单超时取消功能
用户下单完成后,发送15分钟延迟消息,在15分钟后接收消息,检查支付状态:
- 已支付:更新订单状态为已支付
- 未支付:更新订单状态为关闭订单,恢复商品库存
无论是消息发送还是接收都是在交易服务完成,因此我们在trade-service中定义一个MQ常量类,用于记录交换机、队列、RoutingKey等常量。
修改创建订单方法,在扣减库存下面增加发送延迟消息的逻辑。为了方便测试,将延迟时间设置为10s。
PayOrderDTO为支付单的数据传输实体。
支付系统的Feign客户端PayClient中编写查询支付流水的接口。
PayClientFallback中编写支付系统的fallback逻辑。
在pay-service模块的PayController中编写支付流水的查询方法,实现payclient要调用的queryPayOrderByBizOrderNo接口。
在order模块编写一个监听器,监听延迟消息,查询订单支付状态。如果订单处于待支付状态,该代码会查询对应的支付流水。如果支付流水显示已支付,则更新订单状态为已支付;否则,取消订单并恢复库存。
编写还未实现的cancelOrder方法。
在PayClient中编写根据业务订单号修改支付订单状态的方法接口updatePayOrderStatusByBizOrderNo
。
在PayClientFallback
中编写updatePayOrderStatusByBizOrderNo
的失败回退逻辑。
在PayController
中编写updatePayOrderStatusByBizOrderNo
的实现方法。
在IPayOrderService
中编写updateStatusByOrderId
的接口。
在PayOrderServiceImpl
中编写updateStatusByOrderId
的实现方法,根据参数中的订单号,将查询到的支付单的status更改为参数status的值。
在ItemClient
中编写恢复库存的接口restoreStock
。
在ItemClientFallbackFactory
中编写恢复库存的接口restoreStock
的失败回退逻辑。
在ItemController
中实现恢复库存的方法restoreStock
。
在IItemService
中编写restoreStock
接口。
在ItemServiceImpl
中编写restoreStock
的实现。遍历所有订单详情,从数据库中查找对应的商品,对每个商品,将其库存更新为当前库存加上订单详情中的数量之和。
在pay-service模块中,暂时注释掉支付和修改支付单状态后发送消息到pay.direct的部分,这样使得order-service模块中的支付状态监听器无法立刻收到支付消息来修改order表中的支付状态,方便我们去观察延迟消息到达后的执行情况。
在MqConfig中添加Spring Boot 的条件化配置注解,仅在类路径中存在 RabbitTemplate 类时,才启用当前配置类 MqConfig。 具体表现为:
- 依赖检查:当项目引入 RabbitMQ 客户端依赖(如 spring-boot-starter-amqp)时生效
- 自动装配控制:避免在未使用 RabbitMQ 的项目中加载相关配置,防止 ClassNotFoundException
进行下单和支付操作。
order表中查到刚下的订单,status为2即“已付款,未发货”。由于order-service模块中的支付状态监听器没有收到pay.direct的支付消息来修改order表的支付状态。所以只能是延迟消息监听器中的orderService.markOrderPaySuccess(orderId)
代码执行,对order中的status进行了修改。
pay_order支付流水表中,status为3即“支付成功”,表示该订单为已支付。
在TradeApplication
的日志中,可以看到订单服务中的延迟消息监听器OrderDelayMessageListener
执行调用了PayClient
的queryPayOrderByBizOrderNo
方法,根据交易订单id查询了支付单。发现支付单为已支付,而订单状态为未支付,进而执行了orderService.markOrderPaySuccess(orderId)
,该方法內部执行了MyBatis-Plus
提供的updateById
方法。可以看到日志信息中修改订单状态的sql语句。最终实现了将订单状态同步为已支付。
总结: 在下单时,发送一条延迟消息,延迟消息时长取决于业务需求,根据订单超时支付时间确定。超过延迟时间后,查询order支付状态,如果查本地order发现已经支付,说明支付服务通知到位了。如果查本地order未支付,可能是用户没有支付或支付服务支付成功但通知失败。需要查询payorder支付流水来确定支付状态,判断查询结果。如果payorder未支付,需要取消订单,并恢复库存。如果payorder已支付,则标记order为已支付。
六、MQ高级学习总结
- 学习了发送者的可靠性,包括发送者重连机制和发送者确认机制。
- 学习了MQ的可靠性,包括数据持久化和LazyQueue。
- 学习了消费者的可靠性,包括消费者确认机制,失败重试机制,失败处理策略,业务幂等性。
- 学习了延迟消息,包括通过死信交换机和DelayExchange插件两种实现方式。
- 增加了用延迟消息处理超时订单的业务。