MQ可靠性(针对RabbitMQ)
一.概述
RabbiMQ是目前市场主流的异步发送消息的中间件,广泛运用在微服务架构之中
在发送消息如何保证消息的可靠性,成为了关注的问题.
针对发送消息的过程,我们可以分为三个过程.生产者(发送消息),到达MQ
,消费者(处理消息).我们要建立其可靠性,则需要从这三个方面入手
二.生产者可靠性
从生产者的角度上看,发送消息时,消息丢失的情况可能会出现,连接不上
MQ.这需要生产者重试机制,即连接不上MQ重试几次.若消息到达MQ,MQ会给生产者返回响应.响应分为两种情况,ACK和NACK.
ACK的情况为:
- 消息到达MQ,返回ACK
- 消息路由到队列,返回ACK
- 需要消息持久化,则消息持久化到了磁盘才返回ACK
其余的情况均为NACK
1.1 生产者重试机制
当MQ宕机或出现异常时,生产者发送消息到达不了MQ.可以开启重试机制,重新连接MQ,以便MQ重新恢复时,生产者能够发送消息到达MQ.(此过程阻塞,影响性能).
我们可以在application.yml下做以下的配置
spring:
rabbitmq:
connection-timeout: 1s #最大连接超时时间
template:
retry:
enabled: true # 开启超时重试机制
initial-interval: 1000ms # 失败后的初始等待时间
multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长 = initial+interval * multiplier
max-attempts: 3 # 最大重试次数
注意:当网络不稳定的时候,利用重试机制可以有效提高消息发送的成功率。不过SpringAMQP提供的重试机制是阻塞式的重试,也就是说多次重试等待的过程中,当前线程是被阻塞的。
如果对于业务性能有要求,建议禁用重试机制。如果一定要使用,请合理配置等待时长和重试次数,当然也可以考虑使用异步线程来执行发送消息的代码。
1.2 生产者确认机制
当消息到达MQ后,也有可能出现消息丢失的情况.例如
- MQ内部处理消息的时候进程发生了异常
- 生产者发送消息到MQ后为未能找到交换机(Exchange)
- 生产者发送消息到MQ的Exchange之后未能找到合适的Queue.
针对上述情况,RabbitMQ提供了生产者消息确认机制,包括Publisher Confirm和Publisher Return两种。在开启确认机制的情况下,当生产者发送消息给MQ后,MQ会根据消息处理的情况返回不同的回执。
ACK和NACK属于Publisher Confirm机制,ack是投递成功;nack是投递失败。而return则属于Publisher Return机制。
默认两种机制都是关闭状态,需要通过配置文件来开启。
1.2.1 开启生产者确认模式
配置文件为:
spring:
rabbitmq:
publisher-confirm-type: correlated #异步发送
publisher-returns: true #开启路由失败返回机制
其中:
publisher-confirm-type有三个值,
- none:关闭confirm机制
- simple:同步阻塞等待MQ的回执
- correlated:MQ异步回调返回回执
推荐使用correlated异步调用,提升性能
对于publisher-returns开启后,我们需要对RabbitTemlate做对应的设置.
@Slf4j
@Component
public class MqConfig {
@Autowired
private RabbitTemplate rabbitTemplate;
@PostConstruct
public void init(){
rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
@Override
public void returnedMessage(ReturnedMessage returnedMessage) {
log.error("消息路由失败");
log.error("exchange:{}",returnedMessage.getExchange());
log.error("routingKey:{}",returnedMessage.getRoutingKey());
log.error("message:{}",returnedMessage.getMessage());
log.error("replyCode:{}",returnedMessage.getReplyCode());
log.error("replyText:{}",returnedMessage.getReplyText());
}
});
}
}
我们关心的是这个机制何时会被调用,当你的路由Key未能正确对应,即找不到对应的队列,路由失败时,将会调用此回调函数.
1.2.2 发送消息
下一步,则是需要去发送消息,我们需要给每一个消息配置对应的id,以来在MQ中能正确的找到对应的消息.接着调用getFuture(),及添加回调函数,当将来MQ发送消息成功之后将会将结果返回到回调函数之中.
@Test
public void testPublisherConfirm() throws InterruptedException {
//1.队列名
String exchangeName = "message.direct";
CorrelationData cd = new CorrelationData(UUID.randomUUID().toString());
cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
@Override
public void onFailure(Throwable ex) {
//1.spring amqp出现异常,getFuture中出现异常
log.error("出现异常:{}",ex.getMessage());
}
@Override
public void onSuccess(CorrelationData.Confirm result) {
if(result.isAck()){
log.info("消息发送成功!!!");
}else{
log.error("消息发送失败:{}",result.getReason());
}
}
});
//2.消息
String message = "发送者确认机制";
rabbitTemplate.convertAndSend(exchangeName,"red",message,cd);
Thread.sleep(3000);//线程休眠以来看到mq发送的回调函数的结果
}
总结就是,Publisher-confirm机制中,只要消息到了MQ就会返回ACK.Confirm Callback是保证发送到交换机了;Returncallback是保证路由到队列了
三.MQ的可靠性
3.1 数据持久化
大家可以思考一下,MQ效率快是基于缓存实现的,消息到达MQ之后存入缓存当中.而对于缓存来讲,一旦MQ宕机啦,里面的消息将全部丢失,这是我们需要去解决的一个问题.所以针对此问题,我们想的当然是将消息存储到磁盘当中,即消息的持久化.这样无论MQ宕机或者出故障,消息依旧没有丢失.
而对于持久化,则有交换机的持久化,队列的持久化,还有消息的持久化.好在我们在IDEA配置的交换机与队列,默认都是持久化的.消息的发送默认也是持久化的.这里就不再赘述了.介绍一下如何去基于注解配置交换机与队列,这里才有广播的交换机作为演示.
@Configuration
public class FanoutConfig {
@Bean
public FanoutExchange fanoutExchange(){
return new FanoutExchange("mq.fanout");
}
@Bean
public Queue fanoutQueue1(){
return new Queue("fanout.queue1);
}
}
对于发送消息,我们可以指定其为持久化消息或者临时消息,消息发送的源码当中其实默认把我们的消息做了Message的封装处理,所以此时我们不需要底层默认实现,我们自己来实现.利用MessageBuilder来构建消息.
@Test
public void testSendMessage(){
//1.队列名
String queueName = "simple.queue";
//2.构建消息
String message = "hello amqp!";
//withBody 配置你需要封装的消息的字节数据
Message mess = MessageBuilder.withBody(message.getBytes(StandardCharsets.UTF_8))
//配置发送的模式,即数据的模式 NON_PERSISTENT 非持久化 PERSISTENT 持久化
.setDeliveryMode(MessageDeliveryMode.PERSISTENT)
.build();
rabbitTemplate.convertAndSend(queueName,mess);
}
注意的是: 配置了持久化消息后,mq发送消息先在内存保存了一份,同时也往磁盘中写入一份.而非持久化的数据,mq在接收大量的消息,消费者来不及消费时,会存在一个PageOut的操作,即将很大一部分的数据先持久化到磁盘当中,此过程是阻塞的,mq的传输速度急剧下降到达0.
3.2 LazyQueue
在默认情况下,RabbitMQ会将接收到的信息保存在内存中以降低消息收发的延迟。但在某些特殊情况下,这会导致消息积压,比如:
- 消费者宕机或出现网络故障
- 消息发送量激增,超过了消费者处理速度
- 消费者处理业务发生阻塞
一旦出现消息堆积问题,RabbitMQ的内存占用就会越来越高,直到触发内存预警上限。此时RabbitMQ会将内存消息刷到磁盘上,这个行为成为PageOut. PageOut会耗费一段时间,并且会阻塞队列进程。因此在这个过程中RabbitMQ不会再处理新的消息,生产者的所有请求都会被阻塞。
为了解决这个问题,从RabbitMQ的3.6.0版本开始,就增加了Lazy Queues的模式,也就是惰性队列。惰性队列的特征如下:
- 接收到消息后直接存入磁盘而非内存(这与持久化消息是不同的)
- 消费者要消费消息时才会从磁盘中读取并加载到内存(也就是懒加载)
- 支持数百万条的消息存储
而在3.12版本之后,LazyQueue已经成为所有队列的默认格式。
LazyQueue的代码配置:
@Configuration
public class LazyConfig {
@Bean
public Queue lazyQueue(){
return QueueBuilder
.durable("lazy.queue")
.lazy()
.build();
}
}
四.消费者的可靠性
4.1 消费者确认机制
为了确认消费者是否成功处理了消息,RabbitMQ提供了消费者确认机制.即消费者处理消息结束后应当返回一个结果响应给MQ,这个回执有三种可能性.
- ack:成功处理消息,RabbitMQ从队列中删除该消息
- nack:消息处理失败,RabbitMQ需要再次投递消息
- reject:消息处理失败并拒绝该消息,RabbitMQ从队列中删除该消息
reject通常为消息的格式问题或者校验问题,例如抛出MessageConversionException,mq会将消息直接丢弃
我们可以自己配置消费者处理消息回执的模式.有三种模式:
- none: 不处理.只要消息到达消费者,mq直接将消息剔除.不安全,不建议使用.
- manual:手动处理.即程序员自己来判定如何返回ack或者reject,存在业务入侵(因为这段逻辑与业务无关),但灵活性高.
- auto: 自动模式.由Spring AMQP利用AOP来对消费者处加强,若正常处理返回ack.业务异常返回nack,消息处理异常或者校验异常返回reject
通过下面的配置可以修改SpringAMQP的ACK处理方式:
spring:
rabbitmq:
listener:
simple:
acknowledge-mode: auto #none消息发到队列后,无论处理成功或不成功,消息都从mq剔除
#manual手动处理ack
#auto 有spring amqp自动对消费消息处做增强,返回nack继续发送,若为reject从mq当中剔除
4.2 消费者失败重试机制
当消费者出现异常后,消息会不断requeue(重入队)到队列,再重新发送给消费者。如果消费者再次执行依然出错,消息会再次requeue到队列,再次投递,直到消息处理成功为止。若消费者一直处理不了,则mq会一直发送,造成cpu压力剧增(此时电脑风扇声确实有点大).
所以避免这种情况出现,我们可以配置消费者失败重试机制.
配置如下:
spring:
rabbitmq:
listener:
simple:
retry:
enabled: true # 开启消费者失败重试
initial-interval: 1000ms # 初识的失败等待时长为1秒
multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval
max-attempts: 3 # 本地最大重试次数
stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false
注意最大重试次数是本地,不是从mq再重试三次发送,而是mq发送一次,出了业务异常,代码在本地再运行三次.
本地重试达到上限以后,抛出了AmqpRejectAndDontRequeueException异常。查看RabbitMQ控制台,发现消息被删除了,说明最后SpringAMQP返回的是reject
4.3 消息失败处理策略
我们注意到,当达到最大重试次数后,消息是被直接丢弃啦,这对消息可靠性要求高的业务是不友好的.
Spring AMQP提供了三种消息失败处理的模式:
- RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式
- ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队
- RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机
配置消息失败处理的类
@Bean
public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){
return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");
}
声明对应的交换机和队列
@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");
}
这样消息失败处理的配置也就完成了.