RabbitMQ高级篇
RabbitMQ高级篇
任何技术都会有失误的地点,所以我们应当考虑周全
在RabbitMQ高级部分,我们可以通过各种方式确保MQ消息的可靠性,如果真的发送失败,有没有其它的兜底方案。
生产者的可靠性
对于消息丢失的可能性,我们先从流程分析,消息从发送者发送消息,到消费者处理消息。
消息从生产者到消费者的每一步都可能导致消息丢失。
- 发送消息时丢失
- 生产者发送消息时连接MQ失败
- 生产者发送消息到MQ后未找到Exhange
- 生产者发送消息到MQ的exchange后,未找到合适的Queue
- 消息到达MQ后,处理消息的进程发生异常
- MQ导致消息丢失:
- 消息到达MQ,保存到队列后,尚未消费或突然宕机
- 消费者处理消息时:
- 消息接收后尚未处理突然宕机
- 消息接收后处理过程中抛出异常
因此我们要解决消息丢失问题,保证MQ的可靠性,就必须从3个方面入手:
- 确保生产者一定把消息发送到MQ
- 确保MQ不会将消息弄丢
- 确保消费者一定要处理消息
生产者重试机制
当生产者发送消息时,发生了网络故障,导致于MQ的连接中断。
为了解决这个问题,SpringAMQP提供的消息发送时的重试机制。也就是当与MQ连接超时,多次重试。
在yml文件中进行配置
spring: rabbitmq: connection-timeout: 1s # 设置MQ的连接超时时间 template: retry: enabled: true # 开启超时重试机制 initial-interval: 1000ms # 失败后的初始等待时间 multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长 = 失败后的初始等待时间(initial-interval) * multiplier max-attempts: 3 # 最大重试次数
就会发现当连接超时,每隔一秒重试一次,总共能重试3次
注意的是,当网络不稳定的时候,利用重试机制可以有效提高消息发送的成功率,不过SpringAMQP提供的重试机制是 阻塞式的重试,也就是说在多次重试等待的过程中,当前的线程是堵塞的。
所以对于业务性能有要求时,建议禁用重试机制,如果一定要使用,要合理配置等待时长和重试机制,也可以使用异步线程来执行发送消息的代码。
生产者确认机制
一般情况下,只要生产者与MQ之间的网络连接顺畅,基本不会出现消息丢失的情况。
不过在有的情况下,会出现消息发送到MQ之后丢失的现象
- MQ内部处理消息的进程发生了异常
- 生产者发送消息到MQ后未找到Exchange
- 生产者发送消息到达MQ的Exchange后,未找到合适的Queue,因此无法路由
针对上述情况,RabbitMQ提供了生产者确认机制
- Publisher Confirm
- Publisher Return
在开启机制的情况下,当生产者发送消息给MQ后,MQ会根据消息处理的情况返回不同的回执。
- 当消息投递到MQ,但是路由失败时,通过Publisher Return返回异常消息,同时返回ack的确认信息,代表投递成功
- 临时消息投递到了MQ,并且入队成功,返回ACK,告知投递成功
- 持久消息投递了MQ,并且入队完成持久化,返回ACK,告知投递成功
- 其他情况都会返回NACK,告知投递失败
其中ack和nack属于Publisher Confirm机制,ack是投递成功;nack是投递失败,而return则属于Publisher Return机制
默认两种机制都是关闭状态,都需要通过配置文件来开启
实现生产者确认
在模块下的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,回调机制
由于每个消息发送时的处理逻辑不一定相同,因此ConfirmCallback需要在每次发消息时定义,具体来说就是在调用RabbitTemplate中的convertAndSend方法时,多传递一个参数:
CorrelationData中包含两个核心的东西:
- id:消息的唯一标识,MQ对不同的消息的回执以此做判断,避免混淆
- SettableListenableFuture:回执结果的Future对象
将来MQ的回执就会通过这个Future来返回,我们可以提前给CorrelationData中的Future添加回调函数来处理消息回执
MQ的可靠性
消息的MQ以后,如果MQ不能及时保存,也会导致消息丢失,所以MQ的可靠性也非常重要
数据持久化
为了提高性能,默认情况下MQ的数据都是在内存存储的临时数据,重启后就会消失,为了保证数据的可靠性,必须配置数据持久化:
- 交换机持久化
- 队列持久化
- 消息持久化
交换机持久化
在控制台的Exchanges页面,添加交换机时可以配置交换机的Durability参数,设置Durable就是持久化模式,Transient就是临时模式。
队列持久化
在控制台的Queues页面,添加队列时,同样可以配置队列的Durability参数
消息持久化
在控制台发送消息的时候,可以添加很多参数,而消息的持久化是要配置一个Delivery mode;
注意
1.持久化的消息:是在没有消费的情况下存在MQ,消费了也被删除
2.在开启持久化机制之后,如果同时还开启了生产者确认,那么MQ会在消息持久化以后才发送ACK回执,进一步确保消息的可靠性。
出于性能考虑,为了减少IO次数,发送到MQ的消息并不是逐条持久化到数据库的,而是每隔一段时间批量持久化,一般间隔在100毫秒左右,这就会导致ACK有一定的延迟,一次建议生产者确认全部采用异步方式
LazyQueue
在默认情况下,RabbitMQ会将接收到的消息保存在内存中以降低消息收发的延迟,但在某些情况下,会导致消息挤压,比如:
- 消息者宕机或者出现网络故障
- 消息发送量激增,超过了消费者处理速度
- 消费者处理业务发生堵塞
一旦出现消息堆积问题,RabbitMQ的内存占用就会越来越高,直到触发内存预警上限,此时RabbitMQ会将内存消息刷到磁盘上,这个行为称为PageOut,PageOut会耗费一段时间,并且会堵塞队列进程,因此这个过程RabbitMQ不会再处理新的消息,生产者的所有请求都会被堵塞。
所以为了解决这个问题,从RabbitMQ的3.6版本开始,就增加了Lazy Queue的模式,也就是惰性队列。
惰性队列的特性:
- 接收到消息后直接存入磁盘而非内存
- 消费者要消费消息时才会从磁盘中读取并加载到内存(也就是懒加载)
- 支持数百万的消息存储
在3.12后,LazyQueue已经成为所有队列的默认格式。因此官方推荐升级MQ为3.12版本或者所有队列都设置为LazyQueue模式
代码配置Lazy模式
在利用SpringAMQP声明队列的时候,添加x-queue-mod=lazy参数也可设置队列为Lazy模式
@Bean public Queue lazyQueue(){ return QueueBuilder.durable("lazy.queue") .lazy() // 消息队列是否是lazy模式;里面设置了一个参数x-queue-mode,表示是否是lazy模式 .build(); }
当然也可以基于注解来声明队列并设置为Lazy模式
@RabbitListener( queuesToDeclare = @Queue( name = "lazy.queue", durable = "true", arguments = @Argument(name = "x-queue-mode", value = "lazy") ) ) public void listenLazyQueue(String message) { System.out.println("lazyQueue: " + message); }
消费者的可靠性
当RabbitMQ向消费者投递消息以后,需要直到消费者的处理状态,因为消息投递给消费者并不代表就一定被正确消费了,也可能出现的故障有很多
列如:
- 消息投递的过程中出现了网络故障
- 消费者接收到消息后突然宕机
- 消费者接收到消息后,因处理不当导致异常
如果出现情况,消息会丢失,因此RabbitMQ必须知道消费者的处理状态,一旦消息处理失败才能重新投递消息
消费者确认机制
为了确认消费者是否成功处理消息,RabbitMQ提供了消费者确认机制,当消费者处理消息结束后,应该向RabbitMQ发送一个回执,告知RabbitMQ自己消息处理状态
回执有三种可选值:
- ack:成功处理消息,RabbitMQ从队列中删除该消息
- nack:消息处理失败,RabbitMQ需要再次投递消息
- reject:消息处理失败并拒绝该消息,RabbitMQ从队列中删除该消息
一般reject方式用的较少,除非是消息格式有问题,那就是开发问题,因此大多数情况下我们需要将消息处理的代码通过trycatch机制捕获,消息处理成功时返回ack,处理失败时返回nack
SpringAMQP帮我们实现了消息确认,并允许我们通过配置文件设置ACK处理方式,有三种模式:
- none:不处理,即消息投递给消费者后立刻ack,消息会立刻从MQ删除,非常不安全,不建议使用
- manual:手动模式。需要自己在业务代码中调用api,发送ack或reject,存在业务入侵,但更灵活
- auto:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回ack,当业务出现异常时,根据异常判断返回不同结果;
- 如果是业务异常,会自动返回nack
- 如果是消息处理或校验异常,自动返回reject
失败重试机制
当消费者发现异常后,消息会不断重入队到队列,再重新发送给消费者,如果消费者再次执行依然出错,消息会再次requeue到队列,再次投递,直到消息处理成功为止。
极端情况就是消费者一直无法执行成功,那么消息requeue就会无限循环,导致mq的消息处理飙升,带来不必要的压力。
为了以上这种情况,Spring提供了消费者失败重试机制:在消费者出现异常时利用本地重试,而不是无限制的requue到mq队列。
修改服务的application.yml文件
spring: rabbitmq: listener: simple: retry: enabled: true max-attempts: 3 # 最大重试次数 initial-interval: 1000 # 重试间隔时间 multiplier: 1 # 重试间隔时间倍数;下次等待时长 = 上次重试间隔时间(initial-interval) * multiplier stateless: true # 是否为无状态的,即重试时会重新获取一次连接;如果包含事务则为 false
启动之后,消费者在失败后没有重新回到MQ无限重新投递,而是在本地重试了3次。抛出AmqpRejectAndDontRequeueException异常。查看RabbitMQ控制台,发现消息被删除了。说明最后SpringAMQP返回的是reject
在本地重试时,消息处理过程中抛出异常,不会requeue到队列,而是在消费者本地重试,重试达到最大次数后,Spring会返回reject,消息会被丢弃。
失败处理策略
在之前的测试中,本地测试达到最大重试次数后,消息会被丢弃,这在某些对于消息可靠性要求较高的业务场景下,显然不太合适。
因此Spring允许我们自定义重试次数耗尽后的消息处理策略,这个策略是由MessageRecovery接口来定义的,他有三个不同实现:
RejectAndDontRequeueRecoverer
:重试耗尽后,直接reject
,丢弃消息。默认就是这种方式ImmediateRequeueMessageRecoverer
:重试耗尽后,返回nack
,消息重新入队RepublishMessageRecoverer
:重试耗尽后,将失败消息投递到指定的交换机
延迟消息
在电商的支付业务中,对于一些库存有限的商品,为了更好的用户体验,通常会在用户下单时立刻扣减商品库存,列如电影院购票,高铁购票,下单后就会锁定座位资源,其他人无法重复购买,但是这种就会存在一个问题,假如用户下单之后一直不付款,就会一直占有库存资源,导致其他客户无法正常交易,最终导致商户利益受损。
因此,电商中的通常做法就是
对于超过一定时间未支付的订单,应该立刻取消订单并释放占用的库存
比如30分钟算是订单超时,那么我们应该在用户下单后的第30分钟检查订单支付状态,如果发现未支付,应该立刻取消订单,释放库存。像是这种在一段时间后才执行的任务,我们称为延迟任务,我们要实现延迟任务,最简单的方案就是利用MQ的延迟消息。
延迟消息:生存者发送消息时指定一个时间,消费者不会立刻收到消息,而是在指定时间后才收到消息。
在RabbitMQ中实现延迟消息也有两种方案:
- 死信交换机+TTL
- 延迟消息插件
死信交换机
在一个队列的消息满足下列情况之一的时候,就可以成为死信:
- 消费者适用basic.reject或者basic.nack声明消费失败,并且消息的requeue参数设置为false
- 消息是一个过期消息。超时无人消费
- 要投递的队列消息满了,无法投递
如果一个队列的消息成为死信,并且这个队列通过dead-letter-exchange属性指定了一个交换机,那么队列中的死信就会投递到这个交换机中, 而这个交换机就成为 死信交换机.而此时加入有队列与死信交换机绑定,则最终死信就会被投递到这个队列中。
死信交换机有什么作用
- 收集那些因处理失败而被拒绝的消息
- 收集那些因队列而被拒绝的消息
- 收集因TTL到期的消息
延迟消息插件
基于死信队列虽然可以实现延迟消息,但是很麻烦。因此RabbitMQ社区提供了一个延迟消息插件来实现相同的效果。
如果需要使用,则需要在服务器部署下载。
Tags · rabbitmq/rabbitmq-delayed-message-exchange · GitHub
声明延迟交换机
基于注解形式,并接收消息
/* 监听 delay.queue 队列的消息 */ @RabbitListener(bindings = @QueueBinding( value = @Queue(value = "delay.queue", durable = "true"), exchange = @Exchange(value = "delay.direct",delayed = "true"), key = "delay" )) public void listenDelayQueueMessage(String message) throws Exception { System.out.println("SpringRabbitListener listenDelayQueueMessage 消费者接收delay.queue的延迟消息: " + message + " " + LocalTime.now()); }
基于@Bean方式声明
package com.itheima.consumer.config; import org.springframework.amqp.core.*; import org.springframework.context.annotation.Configuration; @Configuration public class DelayExchangeConfig { //定义名字为 delay.direct的延时 direct类型的交换机 public DirectExchange delayDirectExchange() { return ExchangeBuilder.directExchange("delay.direct") .delayed() .durable(true) .build(); } //定义名字为 delay.queue 的队列 public Queue delayQueue() { return new Queue("delay.queue"); } //绑定队列和交换机 public Binding delayBinding(Queue delayQueue, DirectExchange delayDirectExchange) { return BindingBuilder.bind(delayQueue).to(delayDirectExchange).with("delay"); } }
发送延迟消息
/** * 发送到 delay.direct 交换机的延迟消息;延迟5秒 */ @Test public void testDelayMessage() { // 发送 5s 过期消息 System.out.println("发送时间:" + LocalTime.now()); rabbitTemplate.convertAndSend("delay.direct", "delay", "hello, delay message ", new MessagePostProcessor() { @Override public Message postProcessMessage(Message message) throws AmqpException { message.getMessageProperties().setDelay(5000); return message; } }); }
注意
延迟消息插件内部会维护一个本地数据库表,同时会使用Erlang Timers功能实现计时,如果消息的延迟时间设置较长,可能会导致堆积的延迟消息非常多,会带来较大CPU开销,同时延迟消息的时间会存在误差
所以,不建议设置延迟时间过长的延迟消息
本文作者:奕帆卷卷
本文链接:https://www.cnblogs.com/yifan0820/p/18055074
版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 2.5 中国大陆许可协议进行许可。
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步