目录
1、为什么要保证消息可靠性?
先看一张图:
根据上图,我们可以看到,支付服务给消息代理代理发送消息,消息代理再去告诉交易服务“你得更新订单状态了”。但是,如果说消息代理没有收到支付服务发送的消息怎么办?或者说交易服务没有收到消息代理的消息怎么办?交易服务异常没有处理到这个消息怎么办?这一系列的问题都会导致用户已经支付了,但订单状态迟迟没有显示已支付! 因此,我们需要做一些措施,来保证消息的可靠性~
2、如何保证消息可靠性
保证消息可靠性,主要是以下三个方面:
- 发送者的可靠性
- MQ的可靠性
- 消费者的可靠性
2.1、保证生产者的可靠性
发送者这边可能会出现的问题:
- 由于网络波动,可能会出现连接MQ失败
- 生产者发送消息给消息代理这个过程出问题
为了解决上述可能会出现的问题,发送者的可靠性,主要从两个方面解决:
- 生产者重连
- 生产者确认
2.1.1、生产者重连
主要为了解决由于网络波动,可能会出现连接MQ失败的情况,我们可以通过配置开启连接失败后的重连机制,配置如下:
spring:
rabbitmq:
host: env-base
port: 5672
virtual-host: /
username: root
password: 1111
listener:
simple:
prefetch: 1
connection-timeout: 1s # 设置MQ的连接超时时间
template:
retry:
enabled: true # 开启超时重试机制
initial-interval: 1000ms # 失败后的初始等待时间
multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长initial = interval * multiplier
max-attempts: 3 # 最大重试次数
网络不稳定时,用重试机制可提高消息发送成功率。但SpringAMQP提供的重试机制是阻塞式的重试,多次重试等待的过程中,当前线程是被阻塞的。
如果对于业务性能有要求,建议禁用重试机制。
2.1.2、生产者确认
2.1.2.1、生产者确认原理
主要为了解决如下问题:
- 生产者发送消息到达MQ后未找到Exchange,
- 生产者发送消息到达MQ的Exchange后,未找到合适的Queue,
- 消息到达MQ后,处理消息的进程发生异常
RabbitMQ提供了两个确认机制:Publisher Confirm和 Publisher Return。 在开启确认机制后,MQ收到消息会返回确认消息给生产者,返回的结果有以下几种:
- 消息投递到了MQ,但是路由失败,此时会通过Publisher Return返回路由异常原因,然后返回ACK,告知投递成功【路由失败不是MQ的问题,是我们自己配置的问题】
- 临时消息投递到MQ,并且成功入队,返回ACK,告知投递成功
- 持久消息投递到MQ,并且成功入队并完成持久化【写到硬盘】,返回ACK,告知投递成功
- 其他情况都会返回NACK,告知投递失败
通过上述我们能知道,生产者确认其实就是在把消息发送给代理后,代理会给生产者一个回执~
那生产者是发送消息后,就一直等着这个回执,还是发送消息后,就干别的事了,等回执来了,再处理呢?也就是说是同步还是异步呢?其实这都是可以配置的~
补充说明:
-
Confirm模式确保消息能够从生产者发送到交换机 ,无论消息发送是否成功都执行一个回调方法
-
Return模式确保消息从交换机发送到队列,在发送失败的情况下Exchange有两种处理失败消息的模式,一种直接丢弃失败消息(默认是此种模式处理),一种将失败消息发送给ReturnCallBack
生产者确认需要额外的网络和系统资源开销,尽量不要使用;一定要用,就不要开启Return机制(路由失败是自己的业务代码有问题);对nack消息可以设置有限次数重试,依然失败则记录异常消息~
2.1.2.1、生产者确认代理实现
步骤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异步回调方式返回回执消息
- Publisher-returns机制一般在真实的生产环境中是不需要开启的
步骤二:每个RabbitTemplate只能配置一共ReturnCallback,因此需要在项目启动过程中配置:
代码如下:
package com.itheima.publisher.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ReturnedMessage;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.BeansException;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.context.annotation.Configuration;
/**
* Created with IntelliJ IDEA.
* Description:
* User:龙宝
* Date:2024-07-13
* Time:14:51
*/
@Configuration
@Slf4j
public class MqConfirmConfig implements ApplicationContextAware {
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);
// 配置回调
rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
@Override
public void returnedMessage(ReturnedMessage returnedMessage) {
log.info("收到消息的return1了");
}
});
}
}
步骤三:发送消息,指定消息ID、消息ConfirmCallback
代码如下:
@Test
public void returndemo() throws InterruptedException {
// 创建cd
CorrelationData cd = new CorrelationData(UUID.randomUUID().toString());
//添加confirmCallback
cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
@Override
public void onFailure(Throwable ex) {
System.out.println("消息回调方法调用失败");
}
@Override
public void onSuccess(CorrelationData.Confirm result) {
System.out.println("回执");
if(result.isAck()){
System.out.println("成功 - ack");
} else {
System.out.println("失败 - nack");
// 重发消息
// ...
}
}
});
//交换机名
String exchangeName = "fan.fanout";
//消息
String message = "are you ok ? I am ok !";
//发送消息
rabbitTemplate.convertAndSend(exchangeName,null, message,cd);
Thread.sleep(2000);
}
注意点:
2.2、保证MQ的可靠性
为什么需要保证MQ的可靠性?
在默认情况下,RabbitMQ会将接收到的信息保存在内存中以降低消息收发的延迟。这样会导致两个问题:
- 一旦MQ宕机,内存中的消息会丢失
- 内存空间有限,当消费者故障或处理过慢是,会导致消息积压,引发MQ阻塞(当MQ中消息满了,他就接收不了新的消息,此时他会把一些老的消息放到磁盘上去,腾出一定的空间来,但这个动作会让MQ阻塞,无法处理其他请求,全权去做这一个动作)
解决MQ的可靠性问题,在3.6版本以前是采用数据持久化的方式;在3.6以后,MQ推出一种新的方式:LazyQueue
2.2.1、数据持久化
RabbitMQ实现数据持久化包括3个方面:
- 交换机持久化
- 队列持久化
- 消息持久化
实现交换机持久化:
实现队列持久化:
实现消息持久化:
交换机中的消息持久化:
队列中的消息持久化:
上面的各个持久化,在spring中,他会默认把他们就创建为持久化的~
如果说在spring中想把消息设置为非持久化的,代码如下:
@Test
public void demo(){
//交换机名
String exchangeName = "fan.fanout";
//消息
Message message = MessageBuilder.withBody("hello".getBytes(StandardCharsets.UTF_8))
.setDeliveryMode(MessageDeliveryMode.NON_PERSISTENT).build();
//发送消息
rabbitTemplate.convertAndSend(exchangeName,null, message);
}
2.2.2、LazyQueue
LazyQueue介绍:
从RabbitMQ的3.6.0版本开始,就增加了Lazy Queue的概念,翻译为惰性队列~
惰性队列有以下的特征:
- 接收到消息后直接存入磁盘而非内存(内存中只保留最近的消息,默认是2048条)
- 消费者要消费消息时才会从磁盘中读取并加载到内存
- 支持数百万条的消息存储
在3.12版本后,所有队列都是Lazy Queue模式,无法更改~
如何创建惰性队列:
1、基于图形化界面
要设置一个队列为惰性队列,只需要在声明队列时,指定x-queue-mode属性为lazy即可:
2、基于java代码
@Bean
public Queue lazyQueue(){
return QueueBuilder.durable("lazy.queue").lazy().build();
}
3、基于注解
@RabbitListener(queuesToDeclare = @Queue(
name = "lazy.queue",
durable = "true",
arguments = @Argument(name = "x-queue-mode",value = "lazy")
))
public void listen(String msg) throws InterruptedException {
System.err.println("接收到" + msg);
}
2.3、保证消费者的可靠性
保证消费者的可靠性主要分为以下三个方面:
- 消费者确认机制
- 消费失败重试 处理
- 业务幂等性
2.3.1、消费者确认机制
为了确认消费者是否成功处理消息,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
实现:在消费者服务的配置中:
2.3.2、消费失败重试处理
步骤一:
当消费者出现异常后,消息会不断重新入列到队列中,再重新发送给消费者,然后再次异常,再次重新入列,无限循环,导致mq的消息处理飙升,带来不必要的压力。
我们可以利用spring的retry机制,在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列:
spring:
rabbitmq:
host: env-base
port: 5672
virtual-host: /
username: root
password: 1111
listener:
simple:
prefetch: 1
acknowledge-mode: auto # 确认机制
retry:
enabled: true # 开启消费者失败重试
initial-interval: 1000ms #初始的失败等待时长为1秒
multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长initial = interval * multiplier
max-attempts: 3 # 最大重试次数
stateless: true # true无状态 false有状态(包含事务时,这里需要是false)
注:
- 这个消费者可靠性在listener下面的,而生产者重连是在template下面的~
- 消费者失败重试中还多了一个stateless配置
步骤二:
当我们开启重试模式后,重试次数耗尽,如果消息依然失败,则需要有MessageRecover接口来处理,它包含三种不同的实现:
- RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。(默认)
- ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入列
- RepublicMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机
注:上图的error.queue队列中可以向开发人员发送邮箱警告~(例)
步骤三:
将失败处理策略改为RepublicMessageRecoverer:
- 首先,定义接收失败消息的交换机、队列及其绑定关系
- 然后,定义RepublicMessageRecoverer
代码如下:
生产者这边的测试代码:
@Test
public void demo() {
//交换机名
String exchangeName = "demo.direct";
//发送消息
rabbitTemplate.convertAndSend(exchangeName, "news", "message: are you ok???");
}
消费者那边配置文件添加好了之后,上代码:
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "demoqueue.direct"),
exchange = @Exchange(name = "demo.direct", type = ExchangeTypes.DIRECT),
key = "news"
))
public void listenTopicQueue2(String msg){
System.out.println("消费者2接收到topic.queue2的消息:【" + msg + "】");
throw new RuntimeException("故意的");
}
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "error.queue"),
exchange = @Exchange(name = "error.direct", type = ExchangeTypes.DIRECT),
key = "error"
))
public void listen_demo(String msg){
System.out.println("接收到error内容");
}
@Bean
public MessageRecoverer republishRecover(RabbitTemplate rabbitTemplate){
return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
}
重点:
2.3.3、业务幂等性
2.3.3.1、怎么理解业务幂等性?
幂等是一个数学概念,用函数表达式来描述是这样的:f(x) = f(f(x)) 。在程序开发中,则是指同一个业务,执行一次或多次对业务状态的影响是一致的。
例如:我们在处理业务时,想要一个操作是幂等的,我们可以准备一个key value,当业务执行时,先判断redis中有没有这个key value,如果没有就写入这个redis,并执行业务后续操作,如果redis中已经存在这个kv了,就直接返回了~
2.3.3.2、RabbitMQ中处理业务幂等性
方案一:给消息添加一个唯一id
给每个消息都设置一个唯一id,利用id区分是否是重复消息:
-
每一条消息都生成一个唯一的id,与消息一起投递给消费者
- 消费者接收到消息后处理自己的业务,业务处理成功后将消息ID保存到数据库
- 如果下次又收到相同消息,去数据库查询判断是否存在,存在则为重复消息放弃处理
SpringAMQP的MessageConverter自带了MessageID的功能,我们只要开启这个功能即可。
@Bean
public MessageConverter messageConverter(){
// 1.定义消息转换器
Jackson2JsonMessageConverter jjmc = new Jackson2JsonMessageConverter();
// 2.配置自动创建消息id,用于识别不同消息,也可以在业务中基于ID判断是否是重复消息
jjmc.setCreateMessageIds(true);
return jjmc;
}
方案二:业务判断
结合业务逻辑,基于业务本身做判断。例:支付后修改订单状态为已支付,应该在修改订单状态前先查询订单状态,判断状态是否是未支付。只有未支付订单才要修改,其他状态不做处理~