RabbitMQ 的消息不可靠也就是 RabbitMQ 消息丢失只会发生在以下几个方面:
- 生产者发送消息到 MQ 或者 Exchange 过程中丢失。
- Exchange 中的消息发送到 MQ 中丢失。
- 消息在 MQ 或者 Exchange 中服务器宕机导致消息丢失。
- 消息被消费者消费的过程中丢失。
大致就分为生产者 -> MQ -> 消费者这三步的时候消息丢失。
1、生产者到 MQ
1.1. 生产者重试机制
首先第一种情况,就是生产者发送消息时,出现了网络故障,导致与 MQ 的连接中断。
为了解决这个问题,SpringAMQP 提供的消息发送时的重试机制。即:当 RabbitTemplate
与 MQ 连接超时后,多次重试。
修改 publisher
模块的 application.yaml
文件,添加下面的内容:
spring:rabbitmq:connection-timeout: 1s # 设置MQ的连接超时时间template:retry:enabled: true # 开启超时重试机制initial-interval: 1000ms # 失败后的初始等待时间multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长 = initial-interval * multipliermax-attempts: 3 # 最大重试次数
1.2、生产者确认机制
1、Publisher Confirm 机制
也就是生产者确认机制,用于确保消息已经被 Exchange 成功接收和处理。一旦消息成功到达 Exchange 并被处理,RabbitMQ 会向消息生产者发送确认信号 (ACK)。如果由于某种原因(例如,Exchange 不存在或路由键不匹配)消息无法被处理,RabbitMQ 会向消息生产者发送否认信号 (NACK)
//启用Publisher Confirms channel.confirmSelect(); //设置Publisher Confirms回调 channel.addConfirmListener(new ConfirmListener() { //在这里处理消息确认 @Override public void handleAck(long deliveryTag, boolean multiple) throws IOException { System.out.println("Message confirmed with deliveryTag:"deliveryTag); } //在这里处理消息未确认 @Override public void handleNack(long deliveryTag, boolean multiple) throws IOException { System.out.println("Message not confirmed with deliveryTag:"deliveryTag); }
});
2、Publisher Return 机制
Publisher Confirm 机制用于消息无法正常到达交换机中的情况,Publisher Return 机制用于消息无法正常路由到队列的情况。
当消息正常路由到队列中的时候,MQ 不会返回任何消息,当无法正常路由的时候会返回错误信息。
1.3、开启生产者确认
在 publisher 模块的 application.yaml
中添加配置:
spring: rabbitmq: publisher-confirm-type: correlated # 开启publisher confirm机制,并设置confirm类型 publisher-returns: true # 开启publisher return机制
这里 publisher-confirm-type
有三种模式可选:
none
:关闭 confirm 机制simple
:同步阻塞等待 MQ 的回执correlated
:MQ 异步回调返回回执
一般我们推荐使用correlated
,回调机制。
定义 ReturnCallback
每个 RabbitTemplate
只能配置一个 ReturnCallback
,因此我们可以在配置类中统一设置。我们在 publisher 模块定义一个配置类:
内容如下:
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;@PostConstructpublic void init(){rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {@Overridepublic 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());}});}
}
定义 ConfirmCallback
由于每个消息发送时的处理逻辑不一定相同,因此 ConfirmCallback 需要在每次发消息时定义。具体来说,是在调用 RabbitTemplate 中的 convertAndSend 方法时,多传递一个参数:
这里的 CorrelationData 中包含两个核心的东西:
id
:消息的唯一标示,MQ 对不同的消息的回执以此做判断,避免混淆SettableListenableFuture
:回执结果的 Future 对象
将来 MQ 的回执就会通过这个Future
来返回,我们可以提前给CorrelationData
中的Future
添加回调函数来处理消息回执:
我们新建一个测试,向系统自带的交换机发送消息,并且添加 ConfirmCallback
:
@Test
void testPublisherConfirm() {// 1.创建CorrelationDataCorrelationData cd = new CorrelationData();// 2.给Future添加ConfirmCallbackcd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {@Overridepublic void onFailure(Throwable ex) {// 2.1.Future发生异常时的处理逻辑,基本不会触发log.error("send message fail", ex);}@Overridepublic void onSuccess(CorrelationData.Confirm result) {// 2.2.Future接收到回执的处理逻辑,参数中的result就是回执内容if(result.isAck()){ // result.isAck(),boolean类型,true代表ack回执,false 代表 nack回执log.debug("发送消息成功,收到 ack!");}else{ // result.getReason(),String类型,返回nack时的异常描述log.error("发送消息失败,收到 nack, reason : {}", result.getReason());}}});// 3.发送消息rabbitTemplate.convertAndSend("hmall.direct", "q", "hello", cd);
}
执行结果如下:
可以看到,由于传递的 RoutingKey
是错误的,路由失败后,触发了 return callback
,同时也收到了 ack。
当我们修改为正确的 RoutingKey
以后,就不会触发 return callback
了,只收到 ack。
而如果连交换机都是错误的,则只会收到 nack。
注意:
开启生产者确认比较消耗 MQ 性能,一般不建议开启。而且大家思考一下触发确认的几种情况:
- 路由失败:一般是因为 RoutingKey 错误导致,往往是编程导致
- 交换机名称错误:同样是编程错误导致
- MQ 内部故障:这种需要处理,但概率往往较低。因此只有对消息可靠性要求非常高的业务才需要开启,而且仅仅需要开启 ConfirmCallback 处理 nack 就可以了。
2、队列
2.1. 数据持久化
为了提升性能,默认情况下 MQ 的数据都是在内存存储的临时数据,重启后就会消失。为了保证数据的可靠性,必须配置数据持久化,包括:
- 交换机持久化
- 队列持久化
- 消息持久化
我们以控制台界面为例来说明。
2.1.1. 交换机持久化
在控制台的 Exchanges
页面,添加交换机时可以配置交换机的 Durability
参数:
设置为 Durable
就是持久化模式,Transient
就是临时模式。
2.1.2. 队列持久化
在控制台的 Queues 页面,添加队列时,同样可以配置队列的 Durability
参数:
2.1.3. 消息持久化
在控制台发送消息的时候,可以添加很多参数,而消息的持久化是要配置一个 properties
:
说明:在开启持久化机制以后,如果同时还开启了生产者确认,那么 MQ 会在消息持久化以后才发送 ACK 回执,进一步确保消息的可靠性。
不过出于性能考虑,为了减少 IO 次数,发送到 MQ 的消息并不是逐条持久化到数据库的,而是每隔一段时间批量持久化。一般间隔在 100 毫秒左右,这就会导致 ACK 有一定的延迟,因此建议生产者确认全部采用异步方式。
2.2. LazyQueue
在默认情况下,RabbitMQ 会将接收到的信息保存在内存中以降低消息收发的延迟。但在某些特殊情况下,这会导致消息积压,比如:
- 消费者宕机或出现网络故障
- 消息发送量激增,超过了消费者处理速度
- 消费者处理业务发生阻塞
一旦出现消息堆积问题,RabbitMQ 的内存占用就会越来越高,直到触发内存预警上限。此时 RabbitMQ 会将内存消息刷到磁盘上,这个行为成为PageOut
.PageOut
会耗费一段时间,并且会阻塞队列进程。因此在这个过程中 RabbitMQ 不会再处理新的消息,生产者的所有请求都会被阻塞。
为了解决这个问题,从 RabbitMQ 的 3.6.0 版本开始,就增加了 Lazy Queues 的模式,也就是惰性队列。惰性队列的特征如下:
- 接收到消息后直接存入磁盘而非内存
- 消费者要消费消息时才会从磁盘中读取并加载到内存(也就是懒加载)
- 支持数百万条的消息存储
而在 3.12 版本之后,LazyQueue 已经成为所有队列的默认格式。因此官方推荐升级 MQ 为 3.12 版本或者所有队列都设置为 LazyQueue 模式。
2.2.1. 控制台配置 Lazy 模式
在添加队列的时候,添加 x-queue-mod=lazy
参数即可设置队列为 Lazy 模式:
2.2.2. 代码配置 Lazy 模式
在利用 SpringAMQP 声明队列的时候,添加 x-queue-mod=lazy
参数也可设置队列为 Lazy 模式:
@Bean
public Queue lazyQueue(){return QueueBuilder.durable("lazy.queue").lazy() // 开启Lazy模式.build();
}
这里是通过 QueueBuilder
的 lazy()
函数配置 Lazy 模式,底层源码如下:
当然,我们也可以基于注解来声明队列并设置为 Lazy 模式:
@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);
}
2.2.3. 更新已有队列为 lazy 模式
对于已经存在的队列,也可以配置为 lazy 模式,但是要通过设置 policy 实现。
可以基于命令行设置 policy:
rabbitmqctl set_policy Lazy "^lazy-queue$" '{"queue-mode":"lazy"}' --apply-to queues
命令解读:
rabbitmqctl
:RabbitMQ 的命令行工具set_policy
:添加一个策略Lazy
:策略名称,可以自定义"^lazy-queue$"
:用正则表达式匹配队列的名字'{"queue-mode":"lazy"}'
:设置队列模式为 lazy 模式--apply-to queues
:策略的作用对象,是所有的队列
当然,也可以在控制台配置 policy,进入在控制台的 Admin
页面,点击 Policies
,即可添加配置:
3、消费者
有了持久化机制后,那么怎么保证消息在持久化下来之后一定能被消费者消费呢?这里就涉及到消息的消费确认机制。
在 RabbitMQ 中,消费者处理消息成功后可以向 MQ 发送 ack 回执,MQ 收到 ack 回执后才会删除该消息,这样才能确保消息不会丢失。如果消费者在处理消息中出现了异常,那么就会返回 ack 回执,MQ 收到回执之后就会重新投递一次消息,如果消费者一直都没有返回 ACK/NACK 的话,那么他也会在尝试重新投递。
3.1、消费者确认机制
为了确认消费者是否成功处理消息,RabbitMQ 提供了消费者确认机制(Consumer Acknowledgement)。即:当消费者处理消息结束后,应该向 RabbitMQ 发送一个回执,告知 RabbitMQ 自己消息处理状态。回执有三种可选值:
- ack:成功处理消息,RabbitMQ 从队列中删除该消息
- nack:消息处理失败,RabbitMQ 需要再次投递消息
- reject:消息处理失败并拒绝该消息,RabbitMQ 从队列中删除该消息
一般 reject 方式用的较少,除非是消息格式有问题,那就是开发问题了。因此大多数情况下我们需要将消息处理的代码通过 try catch
机制捕获,消息处理成功时返回 ack,处理失败时返回 nack.
由于消息回执的处理代码比较统一,因此 SpringAMQP 帮我们实现了消息确认。并允许我们通过配置文件设置 ACK 处理方式,有三种模式:
- none:不处理。即消息投递给消费者后立刻 ack,消息会立刻从 MQ 删除。非常不安全,不建议使用
- manual:手动模式。需要自己在业务代码中调用 api,发送
ack
或reject
,存在业务入侵,但更灵活 - auto:自动模式。SpringAMQP 利用 AOP 对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回
ack
. 当业务出现异常时,根据异常判断返回不同结果:- 如果是业务异常,会自动返回
nack
; - 如果是消息处理或校验异常,自动返回
reject
;
通过下面的配置可以修改 SpringAMQP 的 ACK 处理方式:
- 如果是业务异常,会自动返回
spring: rabbitmq: listener: simple: acknowledge-mode: none # 不做处理
修改 consumer 服务的 SpringRabbitListener 类中的方法,模拟一个消息处理的异常:
@RabbitListener(queues = "simple.queue")
public void listenSimpleQueueMessage(String msg) throws InterruptedException { log.info("spring 消费者接收到消息:【" + msg + "】"); if (true) { throw new MessageConversionException("故意的"); } log.info("消息处理完成");
}
测试可以发现:当消息处理发生异常时,消息依然被 RabbitMQ 删除了。
我们再次把确认机制修改为 auto:
spring: rabbitmq: listener: simple: acknowledge-mode: auto # 自动ack
在异常位置打断点,再次发送消息,程序卡在断点时,可以发现此时消息状态为 unacked
(未确定状态): 放行以后,由于抛出的是消息转换异常,因此 Spring 会自动返回 reject
,所以消息依然会被删除:
我们将异常改为 RuntimeException 类型:
@RabbitListener(queues = "simple.queue")
public void listenSimpleQueueMessage(String msg) throws InterruptedException { log.info("spring 消费者接收到消息:【" + msg + "】"); if (true) { throw new RuntimeException("故意的"); } log.info("消息处理完成");
}
在异常位置打断点,然后再次发送消息测试,程序卡在断点时,可以发现此时消息状态为 unacked
(未确定状态): 放行以后,由于抛出的是业务异常,所以 Spring 返回 ack
,最终消息恢复至 Ready
状态,并且没有被 RabbitMQ 删除: 当我们把配置改为 auto
时,消息处理失败后,会回到 RabbitMQ,并重新投递到消费者。
3.2、失败重试机制
当消费者出现异常后,消息会不断 requeue(重入队)到队列,再重新发送给消费者。如果消费者再次执行依然出错,消息会再次 requeue 到队列,再次投递,直到消息处理成功为止。极端情况就是消费者一直无法执行成功,那么消息 requeue 就会无限循环,导致 mq 的消息处理飙升,带来不必要的压力:
当然,上述极端情况发生的概率还是非常低的,不过不怕一万就怕万一。为了应对上述情况 Spring 又提供了消费者失败重试机制:在消费者出现异常时利用本地重试,而不是无限制的 requeue 到 mq 队列。
修改 consumer 服务的 application. yml 文件,添加内容:
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 服务,重复之前的测试。可以发现:
- 消费者在失败后消息没有重新回到 MQ 无限重新投递,而是在本地重试了3次
- 本地重试 3 次以后,抛出了
AmqpRejectAndDontRequeueException
异常。查看 RabbitMQ 控制台,发现消息被删除了,说明最后 SpringAMQP 返回的是reject
结论:
- 开启本地重试时,消息处理过程中抛出异常,不会 requeue 到队列,而是在消费者本地重试
- 重试达到最大次数后,Spring 会返回 reject,消息会被丢弃
3.3、失败处理策略
在之前的测试中,本地测试达到最大重试次数后,消息会被丢弃。这在某些对于消息可靠性要求较高的业务场景下,显然不太合适了。因此 Spring 允许我们自定义重试次数耗尽后的消息处理策略,这个策略是由 MessageRecovery
接口来定义的,它有 3 个不同实现:
RejectAndDontRequeueRecoverer
:重试耗尽后,直接reject
,丢弃消息。默认就是这种方式ImmediateRequeueMessageRecoverer
:重试耗尽后,返回nack
,消息重新入队RepublishMessageRecoverer
:重试耗尽后,将失败消息投递到指定的交换机
比较优雅的一种处理方案是 RepublishMessageRecoverer
,失败后将消息投递到一个指定的,专门存放异常消息的队列,后续由人工集中处理。
1)在 consumer 服务中定义处理失败消息的交换机和队列
@Bean
public DirectExchange errorMessageExchange(){ return new DirectExchange("error.direct");
}
@Bean
public Queue errorQueue(){ return new Queue("error.queue", true);
}
@Bean
public Binding errorBinding(Queue errorQueue, DirectExchange errorMessageExchange){ return BindingBuilder.bind(errorQueue).to(errorMessageExchange).with("error");
}
2)定义一个 RepublishMessageRecoverer,关联队列和交换机
@Bean
public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){ return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
}
完整代码如下:
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;
@Configuration
@ConditionalOnProperty(name = "spring.rabbitmq.listener.simple.retry.enabled", havingValue = "true")
public class ErrorMessageConfig { @Bean public DirectExchange errorMessageExchange(){ return new DirectExchange("error.direct"); } @Bean public Queue errorQueue(){ return new Queue("error.queue", true); } @Bean public Binding errorBinding(Queue errorQueue, DirectExchange errorMessageExchange){ return BindingBuilder.bind(errorQueue).to(errorMessageExchange).with("error"); }
@Bean public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){ return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error"); }
}