Bootstrap

阐述MQ的可靠性

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 ConfirmPublisher 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");
}

这样消息失败处理的配置也就完成了.

;