基于上篇存在的问题
1. 问题说明
MQ在分布式项目中是非常重要的,
它可以实现异步、削峰、解耦,但是在项目中引入MQ也会带来一系列的问题。
今天我们要解决以下几个常见的问题:
消息可靠性问题:如何确保消息被成功送达消费者,并且被消费者成功消费掉
延迟消息问题:如果一个消息,需要延迟15分钟再消费,像12306超时取消订单,如何实现消息的延迟投递
消息堆积问题:如果消息无法被及时消费而堆积,如何解决百万级消息堆积的问题
MQ的高可用问题:如何避免MQ因为单点故障而不可用的问题
2. 准备代码环境
注意:为了后续的演示效果,暂不声明交换机、队列、绑定关系
创建project
删除project里的src文件夹
添加依赖坐标
org.springframework.boot spring-boot-starter-parent 2.3.9.RELEASE 8 8 org.projectlombok lombok org.springframework.boot spring-boot-starter-amqp org.springframework.boot spring-boot-starter-test com.fasterxml.jackson.core jackson-databind
创建生产者模块依赖
不需要添加,直接继承父工程的依赖
配置
修改application.yaml,添加配置:
spring: rabbitmq: host: 192.168.200.137 port: 5672 virtual-host: / username: itcast password: 123321
引导类
package com.mqrebbit; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class ProducerApplication { public static void main(String[] args) { SpringApplication.run(ProducerApplication.class,args); } }
发消息测试类
package com.mqrebbit; import org.junit.jupiter.api.Test; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @SpringBootTest public class Demo01SimpleTest { @Autowired private RabbitTemplate rabbitTemplate; @Test public void test(){ rabbitTemplate.convertAndSend("demo.exchange","demo","hello"); } }
创建消费者模块
依赖
不需要添加,直接继承父工程的依赖
配置
修改application.yaml,添加配置:
spring: rabbitmq: host: 192.168.200.137 port: 5672 virtual-host: / username: itcast password: 123321 listener: simple: prefetch: 1
引导类
package com.mqrebbit; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class ConsumerApplication { public static void main(String[] args) { SpringApplication.run(ConsumerApplication.class,args); } }
创建Listener
package com.mqrebbit.listener; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.rabbit.annotation.RabbitListener; import org.springframework.stereotype.Component; @Component @Slf4j public class DemoListener { @RabbitListener(queues = "demo.queue") public void handleDemoQueueMsg(String msg){ log.info("从{}队列接收到消息:{}", "demo.queue", msg); System.out.println("模拟:处理消息中……"); log.info("消息处理完毕"); } }
1. 介绍
当我们的生产者发送一条消息后,这条消息最终会到达消费者。
那么在这整个过程中任何一个环境出错,都可能会导致消息的丢失,而导致不够可靠。
可能出问题的环节有:
生产者发送消息到Broker时 丢失:
消息未送达Exchange
消息到达了Exchange,但未到达Queue
Broker收到消息后丢失:
MQ宕机,导致未持久化保存消息
消费者从Broker接收消息丢失:
消费者接收消息后,尚未消费就宕机
针对这些问题,RabbitMQ给出了对应的解决方案
生产者发送消息丢失:使用生产者确认机制
Broker接收消息丢失:MQ消息持久化
消费者接收消息丢失:消费者确认机制与失败重试机制
2. 生产者确认机制
介绍
在了解生产者确认机制之前,
我们需要先明确一件事:生产者发送的消息,怎么样才算是发送成功了?
消息发送成功,有两个标准
消息被成功送达Exchange
消息被成功送达匹配的Queue
以上两个过程任何一步失败,都认为消息发送失败了。
生产者确认机制,可以确保生产者明确知道消息是否成功发出,如果未成功的话,是哪一步出现问题。然后开发人员就可以根据投递结果做进一步处理。
Confirm Callback机制:确定回收
说明
使用发送者的ConfirmCallback机制,
用于让生产者确认消息是否送达交换机:
如果消息成功送达交换机,MQ会给生产者返回一个ack(确认)。
当生产者得到ack之后,就可以确定消息成功送达交换机了
使用步骤,在生产者一方做如下操作:
声明一个交换机:
示例
1. 修改配置文件
修改生产者一方的配置文件application.yaml,增加如下配置
如果配置为
simple
,表示使用同步方式处理确认的结果如果配置为
correlated
,表示使用异步方式处理确认的结果,但是发送消息时需要我们准备一个CorrelationData对象,用于接收确认结果spring: rabbitmq: #生产者确认机制类型。simple同步方式确认;correlated异步方式确认,将使用CorrelationData接收确认结果 publisher-confirm-type: correlated
2.发送消息
package com.mqrebbit; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.Test; import org.springframework.amqp.rabbit.connection.CorrelationData; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; @Slf4j @SpringBootTest public class Demo01SimpleTest { @Autowired private RabbitTemplate rabbitTemplate; @Test public void test(){ //创建一个CorrelationData Correlation:关联 CorrelationData data = new CorrelationData(); //设置消息的id data.setId("msg-001"); //准备好 回调函数:Callback,用于接收 将来:Future 的确认结果 data.getFuture().addCallback( //Success:成功 /** * 当消息发送没有出现异常时,这个方法会被调用 */ result -> { if (result.isAck()){ log.info("发送消息完成,且已经到达交换机。消息id={}",data.getId()); }else { log.info("发送消息完成,但是没有到达交换机。消息id={},原因={}",data.getId(),result.getReason()); } }, //Failure:失败 /** * 当消息发送出现异常时,这个方法会被调用 */ ex -> { log.warn("发送消息出现异常,消息id={}",data.getId(),ex); } ); rabbitTemplate.convertAndSend("demo.exchange", "demo", "hello",data); } }
3.测试结果-未送达交换机的结果
首先,我们先要保证
demo.exchange
交换机不存在,再运行单元测试方法,发送消息。可看到如下结果4.测试结果-成功送达交换机
然后,我们再创建配置类,声明一个名称为
demo.exchange
的交换机package com.mqrebbit.config; import org.springframework.amqp.core.ExchangeBuilder; import org.springframework.amqp.core.TopicExchange; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration public class RabbitBindingConfig { //创建一个交换机 @Bean public TopicExchange demoExchange(){ return ExchangeBuilder.topicExchange("demo.exchange").build(); } }
然后重新发送消息,可看到如下结果:
Return Callback机制
说明
使用生产者的Confirm Callback机制,可以确保消息成功送达交换机。
但是消息是否被送达队列呢?我们同样需要进行确认。
为了解决这个问题,RabbitMQ提供了Return Callback机制:
如果消息被交换机成功路由到队列,一切正常
如果消息路由到队列时失败了,Return回调会把消息回退给生产者。生产者可以自行决定后续要如何处理
使用步骤,在生产者一方操作:
示例
1. 修改配置文件
修改生产者的配置文件application.yaml,开启return回调机制
spring: rabbitmq: publisher-returns: true #开启生产者return回调机制 template: mandatory: true #开启强制回调。如果为true,消息路由失败时会调用ReturnCallback回退消息;如果为false,消息路由失败时会丢弃消息
设置Return回调
当消息未被路由到Queue时,Return回调会执行
注意:
只要给RabbitTemplate对象设置一次回调函数即可,并不需要每次发送消息都设置Return回调。所以我们在配置类里给RabbitTemplate设置一次即可
给单例的RabbitTemplate对象设置Return回调的方式有多种,使用哪种都行,只要能够设置成功即可
创建一个配置类,在配置类里设置Return回调函数:
package com.mqrebbit.config; import lombok.extern.slf4j.Slf4j; 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; /* * ApplicationContext是: spring容器,所有bean对象都在这里面 * ApplicationContextAware接口:当ApplicationContext初始化完成之后, * 接口的setApplicationContext方法将会被自动调用 */ @Slf4j @Configuration public class RabbitConfig implements ApplicationContextAware { @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class); rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> { //当交换机把消息路由到队列出现问题时,这个方法会自动执行 log.warn("把消息路由到队列失败,replyCode{},replyText{},RoutingKey={},msg={}", replyCode,replyText,exchange,routingKey, message); }); } }
测试结果-未路由到队列
首先,我们要先保证交换机没有绑定队列
demo.queue
,再运行单元测试方法,发送消息,可看到如下结果:测试结果-成功路由到队列
然后,我们再找到
RabbitBindingConfig
配置类,增加一个队列
demo.queue
,并绑定给交换机demo.exchange
,最终代码如下:@Configuration public class RabbitBindingConfig { //创建一个交换机 @Bean public TopicExchange demoExchange(){ return ExchangeBuilder.topicExchange("demo.exchange").build(); } @Bean public Queue demoQueue(){ return QueueBuilder.durable("demo.queue").build(); } @Bean public Binding demoBinding(Queue demoQueue,TopicExchange demoExchange){ return BindingBuilder.bind(demoQueue).to(demoExchange).with("demo"); } }
然后再发送消息,不报错,就说明路由成功了。可以去RabbitMQ控制台上查看消息
3. MQ消息持久化
介绍
通过生产者确认机制,我们可以把消息投递到队列中。但是如果这时候MQ宕机了,队列里的消息同样有可能会丢失。这是因为:
交换机可能是非持久化的。MQ一重启,交换机就消失了
队列可能是非持久化的。MQ一重启,队列就消失了
消息可能是非持久化的(在RabbitMQ内存中)。MQ一重启,消息就丢失了
所以我们必须要保证:交换机、队列、消息都是持久化的。
但实际上,我们创建交换机、队列、消息的方式都是持久化创建的,所以以下内容我们仅仅了解即可
交换机持久化
@Bean public TopicExchange demoTopicExchange(){ return ExchangeBuilder .topicExchange("demo.exchange") //设置交换机为持久化的,重启也不消息。 //但其实可以不设置,因为交换机默认就是持久化的 .durable(true) .build(); }
队列持久化
@Bean public Queue demoQueue(){ return QueueBuilder //使用durable("队列名称")方法 创建的就是持久化队列 .durable("demo.queue") .build(); }
消息持久化
Message message = MessageBuilder .withBody("hello".getBytes()) //设置为持久化消息 .setDeliveryMode(MessageDeliveryMode.PERSISTENT) .build(); rabbitTemplate.convertAndSend("demo.exchange", "demo", message, data);
4. 消费者确认机制介绍
RabbitMQ采用的是阅后即焚模式,即只要消息被消费成功获取,MQ就会立刻删除掉这条消息。所以,我们必须保证,消息确实成功的被消费掉了。为此,RabbitMQ也提供了ack确认机制:
RabbitMQ将消息投递给消费者
消费者成功处理消息
消费者向RabbitMQ返回ack确认
RabbitMQ收到ack确认,删除消息
从上述过程中我们可以得到,消费者返回ack的时机是非常关键的:如果消费者仅仅是得到消息还未处理,就给RabbitMQ返回ack,然后消费者宕机了,就会导致消息丢失。
SpringAMQP允许消费者使用以下三种ack模式:
manual:手动ack。由开发人员在处理完业务后,手动调用API,向RabbitMQ返回ack确认
auto:自动ack【默认】。当消费者方法正常执行完毕后,由Spring自动给RabbitMQ返回ack确认;如果出现异常,就给RabbitMQ返回
nack
(未消费成功)none:关闭ack。MQ假定所有消息都会被成功消费,因为RabbitMQ投递消息后会立即删除
我们一般使用默认的auto模式
none模式修改配置文件
修改消费者一方的配置文件application.yaml,设置消费者确认模式为none。添加如下配置:
spring: rabbitmq: listener: simple: acknowledge-mode: none #设置 消费者确认模式为none
auto模式修改消费者
修改消费者Listener代码,模拟处理消息出现异常的情况
@Slf4j @Component public class DemoListener { @RabbitListener(queues = "demo.queue") public void handleDemoQueueMsg(String msg){ log.info("从{}队列接收到消息:{}", "simple.queue", msg); //模拟:处理消息中出现了异常 int i = 1/0; log.info("消息处理完毕"); } }
测试效果
启动消费者服务
运行生产者单元测试类,发送消息
查看消费者的运行日志控制台
去RabbitMQ控制台(http://192.168.200.137:15672)查看队列里的消息,发现队列里没有消息。消息还没
auto模式
修改配置文件
修改消费者一方的配置文件application.yaml,设置消费者确认模式为auto
spring: rabbitmq: listener: simple: acknowledge-mode: auto #设置 消费者确认模式为auto
修改消费者
代码和刚刚‘none’模式的代码相同,并没有调整。仍然是:模拟处理消息过程中出错
@Slf4j @Component public class DemoListener { @RabbitListener(queues = "demo.queue") public void handleDemoQueueMsg(String msg){ log.info("从{}队列接收到消息:{}", "simple.queue", msg); //模拟:处理消息中出现了异常 int i = 1/0; log.info("消息处理完毕"); } }
测试效果
重启消费者服务
运行生产者的单元测试方法,发送消息
查看消费者的运行日志控制台,发现程序在不停的报错。这是因为
RabbitMQ在投递消息之后,消费者收到消息后抛了异常,导致没有给RabbitMQ返回ack确认
RabbitMQ尝试重新投递消息,消费者收到消息后又抛了异常……
5. 消费者失败重试介绍
当消费者出现异常后,消息会不断requeue(重入队)到队列,再重新发送给消费者,然后再次异常,再次requeue,无限循环,导致mq的消息处理飙升,带来不必要的压力:
我们可以利用Spring本身的retry机制,在消费者出现异常后,在消费者内部进行本地重试;而不是让消息重新入队列,然后让RabbitMQ重新投递。
消费者本地重试
只要修改消费者一方的配置文件,设置消费者本地重试,并配置重试参数
修改消费者一方的配置文件
application.yaml
,增加如下配置:spring: rabbitmq: listener: simple: retry: enabled: true #开始 消费者本地的失败重试 initial-interval: 1000 #初始的失败等待时长,单位是ms,默认1000 multiplier: 1 #与上次重试间隔时长的倍数(1表示每次重试的时间间隔相同)。默认1 max-interval: 10000 #最大重试的间隔,默认值10000 max-attempts: 3 #最多重试几次。默认3 stateless: true #是否无状态。默认true。如果涉及事务,要改成false
重启消费者服务后,发现:
消费者重复获取了3次消息,在3次尝试中并没有抛出异常
在3次尝试都失败后,才抛出了
RejectAndDontRequeueRecoverer
异常
然后再去RabbitMQ控制台(http://192.168.200.137:15672),从队列里查看消息,发现消息已经被删除了
失败后的恢复策略在刚刚的本地重试中,在达到最大次数后,消息会被丢弃,这是Spring内部机制决定的。
但是,其实在重试多次消费仍然失败后,SpringAMQP提供了
MessageRecoverer
接口,定义了不同的恢复策略可以用来进一步处理消息:
RejectAndDontRequeueRecoverer
:重试次数耗尽后,直接reject
,丢弃消息。是默认的处理策略
ImmediateRequeueMessageRecoverer
:重试次数耗尽后,立即重新入队requeue
RepublishMessageRecoverer
:重试次数耗尽后,将失败消息投递到指定的交换机实际开发中,比较优雅的一个方案是
RepublishMessageRecoverer
,将失败消息重新投递到一个专门用于存储异常消息的队列中,等待后续人工处理。使用步骤:
声明消息的恢复策略
声明交换机、队列、绑定关系
声明消息恢复策略
import org.springframework.amqp.core.*; 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; //使用Topic实现 @Configuration public class RepublishMessageRecovererConfig { /* * 消息消费失败后的恢复策略:使用RepublishMessageRecoverer策略 */ @Bean public MessageRecoverer republishMsgRecoverer(RabbitTemplate rabbitTemplate) { return new RepublishMessageRecoverer(rabbitTemplate, "error.exchange", "error"); } @Bean public TopicExchange errorExchange() { return ExchangeBuilder.topicExchange("error.exchange").build(); } @Bean public Queue errorQueue() { return QueueBuilder.durable("error.queue").build(); } @Bean public Binding errorQueueBinding(TopicExchange errorExchange, Queue errorQueue) { return BindingBuilder.bind(errorQueue).to(errorExchange).with("error.#"); } }
//使用 Direct(Routing)实现 @Configuration public class RepublishMessageRecovererConfig { /* * 消息消费失败后的恢复策略:使用RepublishMessageRecoverer策略 */ @Bean public MessageRecoverer republishMsgRecoverer(RabbitTemplate rabbitTemplate){ return new RepublishMessageRecoverer(rabbitTemplate, "error.exchange", "error"); } @Bean public DirectExchange errorExchange(){ return ExchangeBuilder.directExchange("error.exchange").build(); } @Bean public Queue errorQueue(){ return QueueBuilder.durable("error.queue").build(); } @Bean public Binding errorQueueBinding(Queue errorQueue, DirectExchange errorExchange){ return BindingBuilder.bind(errorQueue).to(errorExchange).with("error"); } }
测试效果
消费者收到消息,模拟报错。耗尽重试次数
打开RabbitMQ控制台(http://192.168.200.137:15672),查看错误队列里的消息
6. 小结
1. 确认消息是否送达交换机:使用Confirm Callback机制
修改生产者的配置文件,设置 confirm确认的类型,使用correlated
在RabbitTemplate发送消息之前,预先准备一个CorrelationData对象,设置好消息id、设置Confirm回调函数
发送消息时,把CorrelationData对象设置到convertAndSend方法里
2. 确认消息是否送达队列:使用Return Callback机制
修改生产者的配置文件,开启return机制,设置 如果消息路由失败,就强制回退消息
给Spring容器里唯一的RabbitTemplate设置ReturnCallback回调函数:当消息路由失败后,会执行的回调函数
3. 确定Broker是持久化的,包括:
交换机:需要是持久化的
队列:需要是持久化的
消息:需要是持久化的
4. 消费者确认机制:确认消费成功了,才让MQ删除消息
none:不确认。MQ只要投递消息,就直接删除消息。无论是否消费成功
manual:手动确认。需要消费者手动调用API给MQ发送ack确认,MQ到ack之后删除消息
auto:自动确认。如果消费者方法执行中没有异常,就自动给MQ发送ack确认,MQ删除消息
如果消费者方法执行中出现异常,默认 会把消息重新入队requeue,重新投递给消费者,死循环……
5. 设置消费者本地重试
当消费者消费失败后,不要重新入队requeue,而在消费者服务本地进行重试
当重试次数耗尽之后,默认会丢弃消息
使用方式:
修改消费者的配置文件,设置retry为true
6. 如果消费者本地重试次数耗尽之后,有最终的恢复策略(回收策略)
RejectAndDontRequeueRecoverer:向MQ发送一个reject通知,丢弃消息
ImmediateRequeueMessageRecoverer:消息重新入队列Queue
RepublishMessageRecoverer:把消息投递给另外一个指定的交换机,重新路由到新的队列里,后续可以人工干预再处理
什么是死信
当一个队列中的消息满足下列情况之一时,可以成为死信(dead letter):
消费者使用basic.reject或 basic.nack声明消费失败,并且消息的requeue参数设置为false
拒绝消息不再重新入队
消息是一个过期消息,超时无人消费
要投递的队列消息满了,无法投递
默认情况下,死信会直接丢弃
死信交换机
死信交换机就是普通交换机
如果这个包含死信的队列配置了
dead-letter-exchange
属性,指定了一个交换机,那么队列中的死信就会投递到这个交换机中,而这个交换机称为死信交换机(Dead Letter Exchange,检查DLX)。如图,一个消息被消费者拒绝了,变成了死信;因为demo.queue绑定了死信交换机 dl.direct,因此死信会投递给这个交换机;如果这个死信交换机也绑定了一个队列,则消息最终会进入这个存放死信的队列:
注意:
死信交换机,其实是普通交换机。只是用于处理死信,所以称为死信交换机
死信队列,其实也是普通队列。只是用于处理死信,所以称为死信队列
死信交换机都与队列有关系
2. 消费失败成为死信【拓展】
在失败重试策略中,默认的
RejectAndDontRequeueRecoverer
会在本地重试次数耗尽后,发送reject给RabbitMQ,消息变成死信,被丢弃。我们可以给
demo.queue
添加一个死信交换机,给死信交换机绑定一个队列。这样消息变成死信后也不会丢弃,而是最终投递到死信交换机,路由到与死信交换机绑定的队列。取消恢复策略
把消费者服务中
RabbitMsgRecovererConfig
配置类里的恢复策略注释掉,则SpringAMQP将会使用默认的RejectAndDontRequeueRecoverer
策略,在本地重试次数耗尽后,发送reject给RabbitMQ。
配置死信交换机修改生产者的配置类,在声明队列时绑定死信交换机
2.声明队列
@Configuration public class RabbitBindingConfig { @Bean//声明一个交换机 public TopicExchange demoExchange() { return ExchangeBuilder.topicExchange("demo.exchange").build(); } @Bean //声明一个队列 public Queue demoQueue() { return QueueBuilder.durable("demo.queue") //给队列绑定死信交换机,名称为dl.exchange .deadLetterExchange("dl.exchange") //如果队列信息成为死信,在投递给死信交换机的时候,需要携带的RoutingKey为dl .deadLetterRoutingKey("dl") .build(); } @Bean public Binding demoBinding(Queue demoQueue, TopicExchange demoExchange) { return BindingBuilder.bind(demoQueue).to(demoExchange).with("demo"); } //-------------------------死信交换机、死信队列、绑定关系--------------------------------------- @Bean public DirectExchange dlExchange() { return ExchangeBuilder.directExchange("dl.exchange").build(); } @Bean public Queue dlQueue() { return QueueBuilder.durable("dl.queue").build(); } @Bean public Binding dlBinding(DirectExchange dlExchange, Queue dlQueue) { return BindingBuilder.bind(dlQueue).to(dlExchange).with("dl"); } }
测试效果
先去RabbitMQ控制台页面(http://192.168.200.137:15672)中,把
demo.queue
队列删除掉。因为之前声明的队列并没有绑定死信交换机,必须要删除掉,重新声明才行运行生产者的单元测试方法,发送消息
启动消费者服务,开始从
demo.queue
中接收消息但出现异常;在耗尽重试次数后,因为恢复策略是默认的RejectAndDontRequeueRecoverer
成为死信。消息被投递到死信交换机,然后路由到死信队列在RabbitMQ控制台中查看死信队列
dl.queue
,可看到死信队列中有一条消息3. 消息超时成为死信
说明
如果一条消息超时未被消费,也会成为死信。而超时有两种方式:
消息所在的队列设置了超时
消息本身设置了超时
我们将按照如下设计,演示超时成为死信的效果:
队列TTL示例注意:为了方便演示死信队列的效果,我们将创建一个新的project,准备新的代码环境。参考第一章节中准备的代码环境。
生产者
声明队列和交换机
声明死信交换机与死信队列,并绑定
声明普通交换机与普通队列,并绑定。
注意,声明普通队列时要:
设置队列的TTL
给队列设置死信交换机与死信的RoutingKey
@Configuration public class RabbitBindingConfig { @Bean public Queue ttlQueue(){ return QueueBuilder.durable("ttl.queue") //设置队列的超时时间为10s .ttl(10*1000) //给队列设置死信交换机,名称为dl.ttl.exchange;设置投递死信时的RoutingKey为ttl .deadLetterExchange("dl.ttl.exchange").deadLetterRoutingKey("ttl") .build(); } @Bean public DirectExchange ttlExchange(){ return ExchangeBuilder.directExchange("ttl.exchange").build(); } @Bean public Binding ttlBinding(Queue ttlQueue, DirectExchange ttlExchange){ return BindingBuilder.bind(ttlQueue).to(ttlExchange).with("demo"); } //--------------------死信交换机、死信队列、死信绑定关系------------------------------ @Bean public DirectExchange dlTtlExchange(){ return ExchangeBuilder.directExchange("dl.ttl.exchange").build(); } @Bean public Queue dlTtlQueue(){ return QueueBuilder.durable("dl.ttl.queue").build(); } @Bean public Binding tlTtlBinding(Queue dlTtlQueue, DirectExchange dlTtlExchange){ return BindingBuilder.bind(dlTtlQueue).to(dlTtlExchange).with("ttl"); } }
发送消息
注意:声明队列时已经给队列设置了TTL,所以发送消息时不需要给消息设置TTL
@Slf4j @SpringBootTest public class DemoMessageTest { @Autowired private RabbitTemplate rabbitTemplate; @Test public void test() { rabbitTemplate.convertAndSend("ttl.exchange", "demo", "demo dead letter,发送时间是:" + LocalTime.now()); } }
消费者
@Slf4j @Component public class DemoListener { /** * 监听死信队列 */ @RabbitListener(queues = "dl.ttl.queue") public void handleDemoQueueMsg(String msg){ log.info("现在时间是:{},从{}队列接收到消息:{}", LocalTime.now(), "dl.ttl.queue", msg); } }
测试效果
运行生产者的单元测试代码,发送一条消息
启动消费者服务,等待接收消息。发现消费者在10s后收到了消息
消息TTL示例生产者
声明队列和交换机
可以直接使用刚刚的“队列TTL示例”中的配置,与之相比,仅仅是声明队列时不再设置队列的TTL。代码如下:
@Configuration public class RabbitBindingConfig { @Bean public Queue ttlQueue(){ return QueueBuilder.durable("ttl.queue") //给队列设置死信交换机,名称为dl.ttl.exchange;设置投递死信时的RoutingKey为ttl .deadLetterExchange("dl.ttl.exchange").deadLetterRoutingKey("ttl") .build(); } @Bean public DirectExchange ttlExchange(){ return ExchangeBuilder.directExchange("ttl.exchange").build(); } @Bean public Binding ttlBinding(Queue ttlQueue, DirectExchange ttlExchange){ return BindingBuilder.bind(ttlQueue).to(ttlExchange).with("demo"); } //--------------------死信交换机、死信队列、死信绑定关系------------------------------ @Bean public DirectExchange dlTtlExchange(){ return ExchangeBuilder.directExchange("dl.ttl.exchange").build(); } @Bean public Queue dlTtlQueue(){ return QueueBuilder.durable("dl.ttl.queue").build(); } @Bean public Binding tlTtlBinding(Queue dlTtlQueue, DirectExchange dlTtlExchange){ return BindingBuilder.bind(dlTtlQueue).to(dlTtlExchange).with("ttl"); } }
发送消息
发送消息时设置消息的TTL
@Slf4j @SpringBootTest public class DemoMessageTest { @Autowired private RabbitTemplate rabbitTemplate; @Test public void test() { String msgStr = "消息TTL demo,发送时间是:" + LocalTime.now(); Message message = MessageBuilder .withBody(msgStr.getBytes()) //设置消息TTL为5000毫秒 .setExpiration("5000") .build(); //发送消息时: // 如果消息和队列都设置了TTL,则哪个TTL短,哪个生效 // 如果消息和队列只设置了一个TTL,则直接以设置的为准 rabbitTemplate.convertAndSend("ttl.exchange", "demo", message); } }
消费者
直接使用刚刚“队列TTL示例”中的消费者代码即可。代码如下:
@Slf4j @Component public class DemoListener { /** * 监听死信队列 */ @RabbitListener(queues = "dl.ttl.queue") public void handleDemoQueueMsg(String msg){ log.info("现在时间是:{},从{}队列接收到消息:{}", LocalTime.now(), "dl.ttl.queue", msg); } }
测试效果
运行生产者的单元测试代码,发送消息
启动消费者服务,开始监听消息。发现消费者在5s后收到了消息
4.1 介绍
利用TTL结合死信交换机,我们实现了消息发出后,消费者延迟收到消息的效果。这种消息模式就称为延迟队列(Delay Queue)模式。延迟队列的使用场景非常多,例如:
用户下单,如果用户在15 分钟内未支付,则自动取消订单
预约工作会议,20分钟后自动通知所有参会人员
因为延迟消息的需求非常多,所以RabbitMQ官方也推出了一个延迟队列插件,原生支持延迟消息功能。插件名称是:
rabbitmq_delayed_message_exchange
官网插件列表地址:Community Plugins — RabbitMQ
4.2 安装
大家可以去对应的GitHub页面下载3.8.9版本的插件,这个版本的插件对应RabbitMQ3.8.5以上版本:Release v3.8.9 · rabbitmq/rabbitmq-delayed-message-exchange · GitHub
也可以直接使用资料中提供好的插件。
安装步骤如下:
查看mq的数据卷目录(docker篇创建的数据卷)
因为我们的MQ是使用docker安装的,而创建mq容器时挂载了名称为
mp-plugins
的数据卷。我们要先查看一下数据卷的位置,执行命令:
docker volumes inspect mq-plugins
可知数据卷的目录在
/var/lib/docker/volumes/mq-plugins/_data
中
使用finalshell或其它工具,把插件上传到虚拟机的
/var/lib/docker/volumes/mq-plugins/_data
目录中
2.进入mq容器内部:
docker exec -it mq /bin/bash
3.在容器内执行命令:
rabbitmq-plugins enable rabbitmq_delayed_message_exchange
4.3 原理
DelayExchange需要将一个交换机声明为delayed类型。当我们发送消息到delayExchange时,流程如下:
接收消息
判断消息是否具备x-delay属性
如果有x-delay属性,说明是延迟消息,持久化到硬盘,读取x-delay值,作为延迟时间
返回routing not found结果给消息发送者
x-delay时间到期后,重新投递消息到指定队列
4.4 使用示例
插件的使用步骤也非常简单:
声明一个交换机,交换机的类型可以是任意类型,只需要设定
delayed
属性为true
,然后声明队列与其绑定即可。发送消息时,指定一个消息头
x-delay
,值是延迟的毫秒值声明队列和交换机
@Bean public DirectExchange delayExchange(){ return ExchangeBuilder.directExchange("delay.direct.exchange") //设置为“延迟交换机” .delayed() .build(); } @Bean public Queue delayQueue(){ return QueueBuilder.durable("delay.queue").build(); } @Bean public Binding delayBinding(Queue delayQueue, DirectExchange delayExchange){ return BindingBuilder.bind(delayQueue).to(delayExchange).with("delay"); }
也可以使用注解方式声明,示例代码:
@RabbitListener(bindings = @QueueBinding( value = @Queue("delay.queue"), exchange = @Exchange(value = "delay.direct.exchange", type = ExchangeTypes.DIRECT), key = "delay" )) public void handleDelayQueueMsg(String msg){ log.info("现在时间是:{},从{}队列接收到消息:{}", LocalTime.now(), "delay.queue", msg); }
发送消息
发送消息时,必须指定
x-delay
头,设置延迟时间
@Test public void test2(){ String msgStr = "这是一条延迟消息,发送时间是:" + LocalTime.now(); Message message = MessageBuilder .withBody(msgStr.getBytes()) .setHeader("x-delay", 10000) .build(); rabbitTemplate.convertAndSend("delay.direct.exchange", "delay", message); }
监听消息
@RabbitListener(queues="delay.queue") public void handleDelayQueueMsg(String msg){ log.info("现在时间是:{},从{}队列接收到消息:{}", LocalTime.now(), "delay.queue", msg); }
测试:先启动生产者--》再把消费者重启
1. 一条消息如何成为死信
消息被消费失败(重试次数耗尽,并且不再重新入队),成为死信
消息超时成为死信(无论是队列的ttl,还是消息ttl)
队列满了成为死信
MQ默认处理死信的方式是:丢弃消息
给队列绑定死信交换机,可以把死信重新投递到新的队列里2. 消息超时成为死信:
给队列设置ttl:在声明队列的时候,
QueueBuilder.durable("队列名称")
.ttl(毫秒值)
.deadLetterExchange("死信交换机名称")
.deadLetterRoutingKey("投递到死信交换机时要携带的RoutingKey")
.build();
给消息设置ttl:使用MessageBuilder创建消息
MessageBuilder.withBody(消息的字节数组).setExperation("毫秒值").build();3. 延迟消息插件的使用
声明队列:和之前一样
声明交换机:ExchangeBuilder.topicExchange("xxx").delayed().build();
绑定关系:和之前一样
发送消息:构造消息对象时,需要指定一个头
MessageBuilder.withBody("消息内容字节数组").setHeader("x-delay", 毫秒值).build();
1. 消息堆积问题
什么是消息堆积
当生产者发送消息的速度超过了消费者处理消息的速度,就会导致队列中的消息堆积,
直到队列存储消息达到上限。
之后发送的消息就会成为死信,可能会被丢弃,这就是消息堆积问题。
如何解决消息堆积
解决消息堆积有两种思路:
增加更多消费者,提高消费速度。也就是我们之前说的work queue模式
扩大队列容积,提高堆积上限
从RabbitMQ的3.6.0版本开始,就增加了Lazy Queues的概念,也就是惰性队列。
惰性队列的特征如下:
接收到消息后直接存入磁盘而非内存
消费者要消费消息时才会从磁盘中读取并加载到内存
支持数百万条的消息存储
2. 惰性队列
要设置一个队列为惰性队列,只需要在声明队列时,指定
x-queue-mode
属性为lazy
即可。指定属性的方式有三种:
命令行方式,把一个已存在的队列修改为惰性队列
基于@Bean方式声明惰性队列
基于注解方式声明惰性队列
1.命令行方式
需要进入mq容器,然后执行命令:
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
:命令的作用目标对象,是对所有队列做以上操作2.@Bean方式
在声明队列时,调用一下
lazy()
方法即可//---------------------Lazy Queue------------------------- @Bean public Queue lazyQueue(){ return QueueBuilder.durable("lazy.queue") //设置为惰性队列 .lazy() .build(); } @Bean public DirectExchange lazyExchange(){ return ExchangeBuilder.directExchange("lazy.exchange").build(); } @Bean public Binding lazyBinding(Queue lazyQueue, DirectExchange lazyExchange){ return BindingBuilder.bind(lazyQueue).to(lazyExchange).with("lazy"); }
3.注解@RabbitListener方式
在@RabbitListener注解中声明队列时,添加x-queue-mode参数
@RabbitListener(queuesToDeclare = @Queue( value = "lazy.queue", durable = "true", arguments = @Argument(name = "x-queue-mode", value = "lazy") )) public void handleLazyQueueMsg(String msg) { log.info("从{}队列接收到消息:{}", "lazy.queue", msg); }
3. 小结
1. 如果MQ里消息堆积比较严重,该怎么处理
增加消费者,提升消费消息的能力
使用惰性队列,提升消息堆积的能力
2. 如何声明一个惰性队列
QueueBuilder.durable("队列名称").lazy().build();
1. 集群分类
RabbitMQ的是基于Erlang语言编写,而Erlang又是一个面向并发的语言,天然支持集群模式。RabbitMQ的集群有两种模式:
普通集群:是一种分布式集群,将队列分散到集群的各个节点,从而提高整个集群的并发能力与堆积能力。
镜像集群:是一种主从集群,普通集群的基础上,添加了主从备份功能,提高集群的数据可用性。
镜像集群虽然支持主从,但主从同步并不是强一致的,某些情况下可能有数据丢失的风险。因此在RabbitMQ的3.8版本以后,推出了新的功能:
仲裁队列:用来代替镜像集群,底层采用Raft协议确保主从的数据一致性。
2. 普通集群
介绍
普通集群,或者叫标准集群(classic cluster),具备下列特征:
会在集群的各个节点间共享 元数据,包括:交换机、队列元信息。不包含队列中的消息。
当访问集群某节点时,如果队列不在该节点,该节点将会承担路由的作用,从数据所在节点中获取数据并返回
队列所在节点宕机,队列中的消息就会丢失
普通集群的架构如图所示:
![]()
部署
我们的计划部署3节点的mq集群:
主机名 控制台端口 amqp通信端口 mq1 15671 ---> 15672 5671 ---> 5672 mq2 15672 ---> 15672 5672 ---> 5672 mq3 15673---> 15672 5673 ---> 5672 集群中的节点标示默认都是:
rabbit@[hostname]
,因此以上三个节点的名称分别为:
rabbit@mq1
rabbit@mq2
rabbit@mq3
获取cookie
集群模式中的每个RabbitMQ 节点使用 cookie 来确定它们是否被允许相互通信,集群每个节点必须有相同的Cookie。cookie 只是一串最多 255 个字符的字母数字字符。
我们先在之前运行中的mq容器中获取一个Cookie值,作为稍后我们要搭建的集群的Cookie。
删除旧容器
接下来,停止并删除当前的mq容器,我们重新搭建集群
执行命令:
docker rm -f mq
准备集群配置
1. 准备三个文件夹
mkdir ~/01classic cd ~/01classic mkdir mq1 mq2 mq32. 准备mq1的配置文件
进入mq1文件夹:
cd ~/01classic/mq1
用vi编辑rabbitmq.conf文件:
vi rabbitmq.conf
然后按
i
进入编辑模式,在文件中添加下面的内容,然后保存并退出viloopback_users.guest = false
listeners.tcp.default = 5672
cluster_formation.peer_discovery_backend = rabbit_peer_discovery_classic_config
cluster_formation.classic_config.nodes.1 = rabbit@mq1
cluster_formation.classic_config.nodes.2 = rabbit@mq2
cluster_formation.classic_config.nodes.3 = rabbit@mq33.再创建一个文件,记录Cookie:
#把cookie值保存到.erlang.cookie文件里
echo "TCXMOWUEDEXDZSGZHUZG" > ~/01classic/mq1/.erlang.cookie拷贝配置文件
3. 拷贝配置文件
把mq1里的配置文件和cookie文件,拷贝到mq2和mq3文件夹里
cp ~/01classic/mq1/rabbitmq.conf ~/01classic/mq2 cp ~/01classic/mq1/rabbitmq.conf ~/01classic/mq3 cp ~/01classic/mq1/.erlang.cookie ~/01classic/mq2 cp ~/01classic/mq1/.erlang.cookie ~/01classic/mq3 #修改文件的权限 chmod 600 ~/01classic/mq1/.erlang.cookie chmod 600 ~/01classic/mq2/.erlang.cookie chmod 600 ~/01classic/mq3/.erlang.cookie
启动集群
#1. 创建虚拟网络 docker network create mq-net #2. 创建数据卷 docker volume create #3. 回到家目录 cd ~ #4. 创建mq1节点 docker run -d --net mq-net \ -v ${PWD}/01classic/mq1/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf \ -v ${PWD}/01classic/mq1/.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie \ -e RABBITMQ_DEFAULT_USER=itcast \ -e RABBITMQ_DEFAULT_PASS=123321 \ --name mq1 \ --hostname mq1 \ -p 5671:5672 \ -p 15671:15672 \ rabbitmq:3.8-management #5. 创建mq2节点 docker run -d --net mq-net \ -v ${PWD}/01classic/mq2/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf \ -v ${PWD}/01classic/mq2/.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie \ -e RABBITMQ_DEFAULT_USER=itcast \ -e RABBITMQ_DEFAULT_PASS=123321 \ --name mq2 \ --hostname mq2 \ -p 5672:5672 \ -p 15672:15672 \ rabbitmq:3.8-management #6. 创建mq3节点 docker run -d --net mq-net \ -v ${PWD}/01classic/mq3/rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf \ -v ${PWD}/01classic/mq3/.erlang.cookie:/var/lib/rabbitmq/.erlang.cookie \ -e RABBITMQ_DEFAULT_USER=itcast \ -e RABBITMQ_DEFAULT_PASS=123321 \ --name mq3 \ --hostname mq3 \ -p 5673:5672 \ -p 15673:15672 \ rabbitmq:3.8-management
测试
元数据共享测试
打开mq1的控制台 http://192.168.200.137:15671,手动添加一个队列
数据共享测试
2.在mq2和mq3上,可以查看到这条消息。其实不是数据共享,而是mq2和mq3帮我们从mq1上查询到消息,展示给我们看了
可用性测试
关闭mq1容器(刚刚发送的消息,是在mq1上发送的)
执行命令:
docker stop mq1
再登录mq2或mq3的控制台,发现
simple.queue
不可用了说明:仅仅是把simple.queue的信息拷贝到了mq2和mq3,但是队列里的数据并没有拷贝过去
![]()
3. 镜像集群
介绍
在刚刚的案例中,一旦创建队列的主机宕机,队列就会不可用。不具备高可用能力。如果要解决这个问题,必须使用官方提供的镜像集群方案。
镜像集群:本质是主从模式,具备下面的特征:
交换机、队列、队列中的消息会在各个mq的节点之间同步备份。
创建队列的节点称为该队列的主节点,备份到的其它节点叫做该队列的镜像节点。镜像队列是一主多从结构
不同队列可能在任意节点上创建
所有操作都是主节点完成,然后同步给镜像节点;镜像节点仅仅作为备份
主宕机后,镜像节点会替代成新的主
语法
镜像集群的三种模式
镜像模式的配置有3种模式:
ha-mode ha-params 效果 exactly (准确模式) count (队列的副本数量) 集群中队列副本(主服务器和镜像服务器之和)的数量。count如果为1意味着单个副本:即队列主节点。count值为2表示2个副本:1个队列主和1个队列镜像。换句话说:count = 镜像数量 + 1。如果群集中的节点数少于count,则该队列将镜像到所有节点。如果有集群总数大于count+1,并且包含镜像的节点出现故障,则将在另一个节点上创建一个新的镜像。 all (none) 队列在群集中的所有节点之间进行镜像。队列将镜像到任何新加入的节点。镜像到所有节点将对所有群集节点施加额外的压力,包括网络I / O,磁盘I / O和磁盘空间使用情况。推荐使用exactly,设置副本数为(N / 2 +1)。 nodes node names 指定队列创建到哪些节点,如果指定的节点全部不存在,则会出现异常。如果指定的节点在集群中存在,但是暂时不可用,会创建节点到当前客户端连接到的节点。 这里我们以rabbitmqctl命令作为案例来讲解配置语法。语法示例:
exactly模式
rabbitmqctl set_policy ha-two "^two\." '{"ha-mode":"exactly","ha-params":2,"ha-sync-mode":"automatic"}'
说明:
rabbitmqctl set_policy
:固定写法
ha-two
:策略名称,自定义
"^two\."
:匹配队列的正则表达式,符合命名规则的队列才生效,这里是任何以two.
开头的队列名称
'{"ha-mode":"exactly","ha-params":2,"ha-sync-mode":"automatic"}'
: 策略内容
"ha-mode":"exactly"
:策略模式,此处是exactly模式,指定副本数量
"ha-params":2
:策略参数,这里是2,就是副本数量为2,1主1镜像
"ha-sync-mode":"automatic"
:同步策略,默认是manual,即新加入的镜像节点不会同步旧的消息。如果设置为automatic,则新加入的镜像节点会把主节点中所有消息都同步,会带来额外的网络开销all模式
rabbitmqctl set_policy ha-all "^all\." '{"ha-mode":"all"}'
说明:
ha-all
:策略名称,自定义
"^all\."
:匹配所有以all.
开头的队列名
'{"ha-mode":"all"}'
:策略内容
"ha-mode":"all"
:策略模式,此处是all模式,即所有节点都会称为镜像节点nodes模式
rabbitmqctl set_policy ha-nodes "^nodes\." '{"ha-mode":"nodes","ha-params":["rabbit@nodeA", "rabbit@nodeB"]}'
说明:
rabbitmqctl set_policy
:固定写法
ha-nodes
:策略名称,自定义
"^nodes\."
:匹配队列的正则表达式,符合命名规则的队列才生效,这里是任何以nodes.
开头的队列名称
'{"ha-mode":"nodes","ha-params":["rabbit@nodeA", "rabbit@nodeB"]}'
: 策略内容
"ha-mode":"nodes"
:策略模式,此处是nodes模式
"ha-params":["rabbit@mq1", "rabbit@mq2"]
:策略参数,这里指定副本所在节点名称创建集群
我们使用exactly模式的镜像,镜像数量设置为2.
执行以下命令:
docker exec -it mq1 rabbitmqctl set_policy ha-two "^two\." '{"ha-mode":"exactly","ha-params":2,"ha-sync-mode":"automatic"}'
测试
元数据共享测试
在mq1上创建一个队列
two.queue
在mq2和mq3上可以看到队列
two.queue
![]()
数据共享测试
向
two.queue
发送一条消息
在mq1、mq2、mq3任意一个节点上,都可以从
two.queue
队列中看到消息其实查询消息,都是从mq1上查询得到的数据。因为two.queue在mq1节点上,mq1是主节点
可用性测试
4. 仲裁队列
介绍
仲裁队列:仲裁队列是RabbitMQ3.8版本以后才有的新功能,用来替代镜像队列,具备下列特征:
与镜像队列一样,都是主从模式,支持主从数据同步
使用非常简单,没有复杂的配置
主从同步基于Raft协议,强一致
添加仲裁队列
创建队列
Type:选择Quorum
Name:队列名称,随便起
2.查看队列 下图中“+2”字样,表示队列有2个镜像队列
代码声明仲裁队列
@Bean public Queue quorumQueue() { return QueueBuilder .durable("quorum.queue") // 持久化 .quorum() // 仲裁队列 .build(); }
5. RabbitTemplate连接MQ集群
只要使用
addresses
代替掉原来的host
和port
即可spring: rabbitmq: addresses: 192.168.200.137:5671, 192.168.200.137:5672, 192.168.200.137:5673 username: itcast password: 123321 virtual-host: /
6. 小结
MQ普通集群:
集群节点之间,只会同步元数据(队列的信息、交换机的信息等等),而不会同步消息数据
访问集群的任何一个节点,都可以访问到所有数据:因为如果数据不在本节点上,本节点会帮我们去目标节点上拉取
队列创建到哪个节点上,一旦节点宕机,这个队列就不可用了。队列、交换机并没有备份
好处:增加了消息堆积能力,增加了并发能力,提高了可用性
缺点:队列、交换机等等没有备份,一旦所在的节点宕机,队列、交换机就不可用了
MQ镜像集群:
集群节点之间,只会同步元数据,而不会同步消息数据
创建的每个队列,都可以有镜像副本在其它节点上。
主队列与镜像队列之间进行数据同步
如果要收发消息,只有主队列可以提供服务;镜像队列仅仅作为副本进行备份,当主队列宕机时顶上
访问集群的任何一个节点,都可以访问到所有数据:因为如果数据不在本节点上,本节点会帮我们去目标节点上拉取
队列创建到哪个节点上,一旦节点宕机,这个队列还可以使用,因为它的镜像队列会顶上去
好处:增加了消息堆积能力,增加了并发能力,提高了可用性,解决了备份的问题,某个节点宕机,队列仍然可用
缺点:麻烦,主从之间同步不是强一致
仲裁队列:
是为了代替镜像队列。比镜像队列创建更简单,能实现强一致
.