服务异步通讯高级篇一(消息可靠性)
消息队列在使用过程中,面临着很多实际问题需要思考:
消息从生产者发送到交换机(exchange),再到队列(queue),再到消费者,有哪些导致消息丢失的可能性?
- 发送时丢失:
- 生产者发送的消息为发送发哦exchange
- 消息到达了exchange未到queue
- MQ宕机,queue将消息丢失
- consumer接收到消息后为消费就宕机
1、生产者消息确认
RabbitMQ提供了publisher confirm机制来避免消息发送到MQ过程中丢失。这种机制必须给每个消息指定一个唯一ID。消息发送到MQ以后,会返回一个结果给发送者,表示消息是否处理成功。
返回结果有两种方式:
- publisher-confirm,发送者确认
- 消息成功投递到交换机,返回ack
- 消息未投递到交换机,返回nack
- publisher-return,发送者回执
- 消息投递到交换机了,但是没有路由到队列。返回ACK,及路由失败原因。
注意:确认机制发送消息时,需要给每一个消息设置一个全局的唯一id,以区分不同的消息,避免不同的ack冲突
1.1、项目准备
创建一个
mq-advanced-demo
聚合的工程,里面再创建两个子模块,都是基于springboot的开发,环境的搭建与之前的一直githun地址:https://github.com/ZhongChunle/mq-advanced-demo.git
1.2、SpringAMQP实现生产确认
-
在
publisher
模块中的application.yml
文件中添加配置信息spring: rabbitmq: publisher-confirm-type: correlated publisher-returns: true template: mandatory: true
说明:
publish-confirm-type
:开启publisher-confirm,这里支持两种类型:simple
:同步等待confirm结果,直到超时correlated
:异步回调,定义ConfirmCallback,MQ返回结果时会回调这个ConfirmCallback
publish-returns
:开启publish-return功能,同样是基于callback机制,不过是定义ReturnCallbacktemplate.mandatory
:定义消息路由失败时的策略。true,则调用ReturnCallback;false:则直接丢弃消息
-
定义Return回调
每个RabbitTemplate只能配置一个ReturnCallback,因此需要在项目加载时配置:
修改publisher服务,添加一个:
package cn.itcast.mq.config; import lombok.extern.slf4j.Slf4j; import org.springframework.amqp.core.Message; import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter; import org.springframework.amqp.support.converter.MessageConverter; import org.springframework.beans.BeansException; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Slf4j @Configuration public class CommonConfig implements ApplicationContextAware { @Override public void setApplicationContext(ApplicationContext applicationContext) throws BeansException { // 1、获取rabbittemplate对象 RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class); // 2、配置ReturnCallback【使用了lambda表达式】 rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> { // 投递失败,记录日志 log.info("消息发送到队列失败,应答码{},失败原因{},交换机{},路由键key{},消息{}", replyCode, replyText, exchange, routingKey, message.toString()); // 如果有业务需要,可以重发消息 }); } }
IDEA对于可简化的代码可以按照键盘的
Alt + 回车
进行一个lambda简化,鼠标需要放到可以简化的位置才会生效 -
定义ConfirmCallback
ConfirmCallback可以在发送消息时指定,因为每个业务处理confirm成功或失败的逻辑不一定相同。
在publisher服务的cn.itcast.mq.spring.SpringAmqpTest类中,定义一个单元测试方法:
package cn.itcast.mq.spring; import lombok.extern.slf4j.Slf4j; import org.junit.Test; import org.junit.runner.RunWith; 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; import org.springframework.test.context.junit4.SpringRunner; import org.springframework.util.concurrent.FailureCallback; import org.springframework.util.concurrent.SuccessCallback; import java.util.UUID; @Slf4j @RunWith(SpringRunner.class) @SpringBootTest public class SpringAmqpTest { @Autowired private RabbitTemplate rabbitTemplate; @Test public void testSendMessage2SimpleQueue() throws InterruptedException { String routingKey = "simple.test"; // 1、准备消息 String message = "hello, spring amqp!"; // 2、准备correlationData // 2.1、消息id CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString()); // 2.2、准备ConfirmCallback correlationData.getFuture().addCallback(confirm -> { // 判断结果 if(confirm.isAck()){ // ACK log.info("消息投递到交换机成功!消息ID:{}",correlationData.getId()); }else{ // NCAK log.info("消息投递到交换机失败,消息ID:{}"+correlationData.getId()); } }, throwable -> { // 记录日志 log.info("消息发送失败:"+ throwable); // 重发消息 }); // 3、发送消息 rabbitTemplate.convertAndSend("zcl.topic", routingKey, message); } }
点击启动消息方法,查看管理页面的消息队列是否有消息
目前的上面测试有一个IDEA问题就是,我将队列名弄错了可以在控制台输出(log.info)的队列错误日志,但是我将交换机弄错了,就是上面设置的
SpringAmqpTest
中的(log.info)日志就输出不了,我也不知道是啥子原因,有细心留意同伴请给我留言!
2、消息持久化
MQ默认是内存存储消息,开启持久化功功能可有确保缓存在MQ中的消息不会丢失
重启交换机
docker restart [容器名称或id]
重启之后的rabbitmq的队列消息都会消失,而系统的交换机都会存在,而想要交换机持久化或队列持久化就需要通过管理页面或者java代码进行设置了,下面就是在管理页面创建的时候的一个创建持久化的一个方法(队列也是一样的,创建的时候选上这个持久化选项就可以了)
交换机持久化设置
在消费者
consumer
中创建
package cn.itcast.mq.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.support.converter.Jackson2JsonMessageConverter;
import org.springframework.amqp.support.converter.MessageConverter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class CommonConfig {
/**
* 创建持久化交换机
* @return
*/
@Bean
public DirectExchange simpleDirect() {
// 第一个参数:交换机名称 第二个参数:是否开启持久化 第三个参数:是否自动删除
return new DirectExchange("simpl.direct",true,false);
}
}
队列消息持久化设置
也是在消费者中创建,接上上面创建交换机的下面
/**
* 创建持久化的队列
*/
public Queue simpleQueue() {
return QueueBuilder.durable("simple.queue").build();
}
启动消费者模块,回到管理页面查看交换机和队列的创建是否有创建成持久化【一个D标识】
可以点击进去给队列添加信息,然后重启MQ再次查看,队列会持久而我们的消息展示不会持久
消息持久化设置
在消息生产者中设置消息持久化设置,发送消息的时候要想将消费者的服务停止掉,否则的化发送的消息一下就被消费了,那么在管路页面是不会再看到发送的数据的
/**
* 发送消息持久化
*/
@Test
public void testDueableMessage(){
// (MessageDeliveryMode.PERSISTENT:设置消息持久
Message build = MessageBuilder.withBody("hellow spring".getBytes(StandardCharsets.UTF_8))
.setDeliveryMode(MessageDeliveryMode.PERSISTENT).build();
// 发送消息,指定通道
rabbitTemplate.convertAndSend("simple.queue",build);
}
查看管理页面队列消息
重启MQ再次访问是否真正的持久化了
在SpringAMQP中的【队列、交换机、消息】默认情况下都是持久的
3、消费者消息确认
RabbitMQ是阅后即焚机制,RabbitMQ确认消息被消费者消费后会立刻删除。
而RabbitMQ是通过消费者回执来确认消费者是否成功处理消息的:消费者获取消息后,应该向RabbitMQ发送ACK回执,表明自己已经处理消息。
设想这样的场景:
- 1)RabbitMQ投递消息给消费者
- 2)消费者获取消息后,返回ACK给RabbitMQ
- 3)RabbitMQ删除消息
- 4)消费者宕机,消息尚未处理
这样,消息就丢失了。因此消费者返回ACK的时机非常重要。
而SpringAMQP则允许配置三种确认模式:
•manual:手动ack,需要在业务代码结束后,调用api发送ack。
•auto:自动ack,由spring监测listener代码是否出现异常,没有异常则返回ack;抛出异常则返回nack
•none:关闭ack,MQ假定消费者获取消息后会成功处理,因此消息投递后立即被删除
由此可知:
- none模式下,消息投递是不可靠的,可能丢失
- auto模式类似事务机制,出现异常时返回nack,消息回滚到mq;没有异常,返回ack
- manual:自己根据业务情况,判断什么时候该ack
一般,我们都是使用默认的auto即可。
在消费者中的配置文件中设置
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: none # 关闭ack
如果需要观察的,就根据下面的断点来查看没一个模式的销毁成功,测试了一个再换一个模式进行测试
案例模拟一下消息异常
@RabbitListener(queues = "simple.queue")
public void listenSimpleQueue(String msg) {
log.info("消费者接收到simple.queue的消息:【{}】", msg);
// 模拟异常
System.out.println(1 / 0);
log.debug("消息处理完成!");
}
启动测试,通过断电
5行
返回查看管理页面的队列消息,是否销毁
如果是auto
模式的话当遇到报错就会出现下面的情况,然后不停的发送消息
4、消费失败本地重试机制
当消费者出现异常后,消息会不断requeue(重入队)到队列,再重新发送给消费者,然后再次异常,再次requeue,无限循环,导致mq的消息处理飙升,带来不必要的压力:
我们可以利用Spring的retry机制,在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列。
修改consumer服务的application.yml文件,添加内容:
spring:
rabbitmq:
listener:
simple:
retry:
enabled: true # 开启消费者失败重试
initial-interval: 1000 # 初识的失败等待时长为1秒
multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
max-attempts: 3 # 最大重试次数
stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
模拟错误接收消费
@Slf4j
@Component
public class SpringRabbitListener {
@RabbitListener(queues = "simple.queue")
public void listenSimpleQueue(String msg) {
log.debug("消费者接收到simple.queue的消息:【" + msg + "】");
System.out.println(1/0);
log.info("消费者处理消息成功");
}
}
重启consumer服务,重复之前的测试。可以发现:
- 在重试3次后,SpringAMQP会抛出异常AmqpRejectAndDontRequeueException,说明本地重试触发了
- 查看RabbitMQ控制台,发现消息被删除了,说明最后SpringAMQP返回的是ack,mq删除消息了
默认情况下,本地重试耗尽之后会把消息丢弃,返回一个reject
拒绝,消息就会丢弃,这是SpringAMQP的默认机制
结论:
- 开启本地重试时,消息处理过程中抛出异常,不会requeue到队列,而是在消费者本地重试
- 重试达到最大次数后,Spring会返回ack,消息会被丢弃
5、失败策略
在之前的测试中,达到最大重试次数后,消息会被丢弃,这是由Spring内部机制决定的。
在开启重试模式后,重试次数耗尽,如果消息依然失败,则需要有MessageRecovery接口来处理,它包含三种不同的实现:
-
RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式
-
ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队
-
RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机
比较优雅的一种处理方案是RepublishMessageRecoverer,失败后将消息投递到一个指定的,专门存放异常消息的队列,后续由人工集中处理。
1)在consumer服务中定义处理失败消息的交换机和队列
package cn.itcast.mq.config;
import org.springframework.amqp.core.*;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 项目名称:mq-advanced-demo
* 描述:覆盖默认的重试策略
*
* @author zhong
* @date 2022-06-01 10:17
*/
@Configuration
public class ErrorMessages {
/**
* 定义交换机
*/
@Bean
public DirectExchange erroeMessages() {
return new DirectExchange("error.direct");
}
/**
* 定义队列
*/
@Bean
public Queue errorQueue() {
return new Queue("error.queue");
}
/**
* 绑定交换机和队列
*/
@Bean
public Binding errorMessagesBinding() {
return BindingBuilder.bind(errorQueue()).to(erroeMessages()).with("error");
}
}
2)定义一个RepublishMessageRecoverer,关联队列和交换机
接着上一段代码
定义的交换机名称和
routingKey
一定要与上面代码定义的一直
@Bean
public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){
return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
}
3)启动项目测试
-
目前启动的消费者中有一个接收消息的,同时在接收消息后面添加了一个自杀死错误,用来默认接收消息重试失败的
-
在管理页面手动的给队列发送消息
-
重新查看IDEA的打印
10:28:54:768 INFO 16296 --- [ main] cn.itcast.mq.ConsumerApplication : Started ConsumerApplication in 1.024 seconds (JVM running for 1.434) 10:30:29:567 DEBUG 16296 --- [ntContainer#0-1] c.i.mq.listener.SpringRabbitListener : 消费者接收到simple.queue的消息:【hello error】 10:30:30:568 DEBUG 16296 --- [ntContainer#0-1] c.i.mq.listener.SpringRabbitListener : 消费者接收到simple.queue的消息:【hello error】 10:30:31:572 DEBUG 16296 --- [ntContainer#0-1] c.i.mq.listener.SpringRabbitListener : 消费者接收到simple.queue的消息:【hello error】 10:30:32:576 DEBUG 16296 --- [ntContainer#0-1] c.i.mq.listener.SpringRabbitListener : 消费者接收到simple.queue的消息:【hello error】 10:30:32:580 WARN 16296 --- [ntContainer#0-1] o.s.a.r.retry.RepublishMessageRecoverer : Republishing failed message to exchange 'error.direct' with routing key error
在接受四次重试后就会将消息妆发到
error.direct
交换机,也就是我们声明的交换机,同时key为error
-
再次查看我们的管理页面
原来队列的信息以及销毁了,而我们的错误信息队列保存销毁的信息
点击队列可以查看到清楚的错误消息以及消息