1.消息丢失情况
消息从发送到消费者接收会经历多个过程:
其中的每一步都可能导致消息丢失,常见的丢失原因包括:
-
发送时丢失:
- 生产者发送的消息未送达exchange
- 消息到达exchange后未到达queue
-
MQ宕机,queue将消息丢失
-
consumer接收到消息后未消费就宕机
2.消息确认机制和持久化
针对这些问题,RabbitMQ分别给出了解决方案:
- 生产者确认机制
- 消费者确认机制
- 失败重试机制
- mq持久化
2.1 生产者消息确认
RabbitMQ提供了publisher confirm机制来避免消息发送到MQ过程中丢失。这种机制必须给每个消息指定一个唯一ID。消息发送到MQ以后,会返回一个结果给发送者,表示消息是否处理成功。
返回结果有两种方式:
-
publisher-confirm,发送者确认
- 消息成功投递到交换机,返回ack
- 消息未投递到交换机,返回nack
- 消息发送过程中出现异常,没有收到回执
-
publisher-return,发送者回执
- 消息投递到交换机了,但是没有路由到队列。返回ACK,及路由失败原因。
2.1.1 修改配置
首先,修改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:则直接丢弃消息
2.1.2 定义ReturnCallback
每个RabbitTemplate只能配置一个ReturnCallback,因此需要在项目加载时配置:
修改publisher服务,添加一个:
package cn.chao.mq.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;
@Slf4j
@Configuration
public class MQReturnCallbackConfig implements ApplicationContextAware {
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
// 获取RabbitTemplate对象
RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);
// 配置ReturnCallback
rabbitTemplate.setReturnCallback((message, replyCode, replyText, exchange, routingKey) -> {
// 判断是否是延迟消息
Integer receivedDelay = message.getMessageProperties().getReceivedDelay();
if (receivedDelay != null && receivedDelay > 0) {
// 是一个延迟消息,忽略这个错误提示
return;
}
// 记录日志
log.error("消息发送到队列失败,响应码:{}, 失败原因:{}, 交换机: {}, 路由key:{}, 消息: {}",
replyCode, replyText, exchange, routingKey, message.toString());
// 如果有需要的话,重发消息
});
}
}
复制代码
2.1.3 定义ConfirmCallback
ConfirmCallback可以在生产者发送消息时指定,因为每个业务处理confirm成功或失败的逻辑不一定相同。
@Test
public void testConfirmCallbackSendMessage() throws InterruptedException {
// 1.准备消息
Map msg=new HashMap();
msg.put("email","[email protected]");
msg.put("name","xiaochao");
//设置消息id
String uuid = UUID.randomUUID().toString();
MessageProperties messageProperties=new MessageProperties();
messageProperties.setMessageId(uuid);
Message message = new Message(JSONObject.toJSONString(msg).getBytes(StandardCharsets.UTF_8),messageProperties);
// 2.准备CorrelationData
// 2.1.消息ID
CorrelationData correlationData = new CorrelationData(uuid);
// 2.2.准备ConfirmCallback
correlationData.getFuture().addCallback(result -> {
// 判断结果
if (result.isAck()) {
// ACK
log.info("消息成功投递到交换机!消息ID: {}", correlationData.getId());
} else {
// NACK
log.error("消息投递到交换机失败!消息ID:{}", correlationData.getId());
// 重发消息
}
}, ex -> {
// 记录日志
log.error("消息发送失败!", ex);
// 重发消息
});
// 3.发送消息
rabbitTemplate.convertAndSend("chao.direct", "white", message, correlationData);
}
复制代码
2.2 消费者消息确认
2.2.1 消费者确认方式
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即可。
2.2.2 consumer消息确认配置
修改consumer服务的application.yml文件,添加下面内容:
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: auto #自动确认
复制代码
用consumer服务的ConsumerConfirmListener监听模拟一个消息处理异常:
package cn.chao.mq.listener;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ExchangeTypes;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.Exchange;
import org.springframework.amqp.rabbit.annotation.Queue;
import org.springframework.amqp.rabbit.annotation.QueueBinding;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
@Component
@Slf4j
public class ConsumerConfirmListener {
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue"),
exchange = @Exchange(name = "chao.direct", type = ExchangeTypes.DIRECT),
key = {"white"}
))
public void listenDirectQueue1(Message msg) {
String message = new String(msg.getBody());
log.info("消费者接收到消息:【" + message + "】 msgId:" + msg.getMessageProperties().getMessageId());
System.out.println(1 / 0);
log.info("消费者消费成功");
}
@RabbitListener(bindings = @QueueBinding(
value = @Queue(name = "direct.queue2"),
exchange = @Exchange(name = "chao.direct", type = ExchangeTypes.DIRECT),
key = {"white", "blue"}
))
public void listenDirectQueue2(Message msg) {
String message = new String(msg.getBody());
log.info("消费者接收到消息:【" + message + "】 msgId:" + msg.getMessageProperties().getMessageId());
log.info("消费者2消费成功");
}
}
复制代码
2.3本地失败重试机制
按照 2.2 中消费者自动确认机制当消费者出现异常后,消息会不断requeue(重入队)到队列,再重新发送给消费者,然后再次异常,再次requeue,无限循环,导致mq的消息处理飙升,带来不必要的压力;我们可以利用Spring的retry机制,在消费者出现异常时利用本地重试,而不是无限制的requeue到mq队列。
2.3.1失败重试配置
修改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
复制代码
重启consumer服务,重复之前的测试。可以发现:
- 在重试3次后,SpringAMQP会抛出异常AmqpRejectAndDontRequeueException,说明本地重试触发了
- 查看RabbitMQ控制台,发现消息被删除了,说明最后SpringAMQP返回的是ack,mq删除消息了
结论:
- 开启本地重试时,消息处理过程中抛出异常,不会requeue到队列,而是在消费者本地重试
- 重试达到最大次数后,Spring会返回ack,消息会被丢弃
2.3.2失败重试策略
在之前的测试中,达到最大重试次数后,消息会被丢弃,这是由Spring内部机制决定的。
在开启重试模式后,重试次数耗尽,如果消息依然失败,则需要有MessageRecovery接口来处理,它包含三种不同的实现:
- RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式
- ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队
- RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机
比较优雅的一种处理方案是RepublishMessageRecoverer,失败后将消息投递到一个指定的,专门存放异常消息的队列,后续由人工集中处理。配置方式如下:
package cn.chao.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.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;
@Configuration
public class ErrorMessageQueueConfig {
/**
* 定义异常消息交换机
* @return
*/
@Bean
public DirectExchange errorMessageExchange() {
return new DirectExchange("error.direct");
}
/**
* 定义异常消息队列
* @return
*/
@Bean
public Queue errorQueue() {
return new Queue("error.queue");
}
/**
* 队列和交换机绑定并指定路由
* @return
*/
@Bean
public Binding errorMessageBinding() {
return BindingBuilder.bind(errorQueue()).to(errorMessageExchange()).with("error");
}
/**
* 定义一个RepublishMessageRecoverer 指定重试耗尽后,将失败消息投递到指定的交换机
* @param rabbitTemplate
* @return
*/
@Bean
public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate) {
return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
}
}
复制代码
2.4 MQ持久化
生产者确认可以确保消息投递到RabbitMQ的队列中,但是消息发送到RabbitMQ以后,如果突然宕机,也可能导致消息丢失。
要想确保消息在RabbitMQ中安全保存,必须开启消息持久化机制。
- 交换机持久化
- 队列持久化
- 消息持久化
SpringAMQP中交换机,队列和消息都是默认持久化的。
3 幂等性
出现幂等性的情况
- 1.当消费者消费完消息时,在给mq返回ack时由于网络中断,导致mq未收到确认信息,该条消息会重新发送并被消费者消费,但实际上该消费者已成功消费了该条消息,这就是重复消费问题。
解决方案: 使用全局唯一的messageId进行解决,判断该messageId已经处理过了则不再处理该消息。如2.1.3 定义ConfirmCallback 中就定义了全局唯一的uuid 确保消息唯一。只需要在消费的过程中判断id是否被消费过即可。