RabbitMQ是如何保证消息可靠性的?——Java全栈知识(16)

发布于:2024-05-08 ⋅ 阅读:(19) ⋅ 点赞:(0)

RabbitMQ 的消息不可靠也就是 RabbitMQ 消息丢失只会发生在以下几个方面:

  1. 生产者发送消息到 MQ 或者 Exchange 过程中丢失。
  2. Exchange 中的消息发送到 MQ 中丢失。
  3. 消息在 MQ 或者 Exchange 中服务器宕机导致消息丢失。
  4. 消息被消费者消费的过程中丢失。
    image.png
    大致就分为生产者 -> MQ -> 消费者这三步的时候消息丢失。

1、生产者到 MQ

1.1. 生产者重试机制

首先第一种情况,就是生产者发送消息时,出现了网络故障,导致与 MQ 的连接中断。
为了解决这个问题,SpringAMQP 提供的消息发送时的重试机制。即:当 RabbitTemplate 与 MQ 连接超时后,多次重试。
修改 publisher 模块的 application.yaml 文件,添加下面的内容:

spring:
  rabbitmq:
    connection-timeout: 1s # 设置MQ的连接超时时间
    template:
      retry:
        enabled: true # 开启超时重试机制
        initial-interval: 1000ms # 失败后的初始等待时间
        multiplier: 1 # 失败后下次的等待时长倍数,下次等待时长 = initial-interval * multiplier
        max-attempts: 3 # 最大重试次数

1.2、生产者确认机制

1、Publisher Confirm 机制

也就是生产者确认机制,用于确保消息已经被 Exchange 成功接收和处理。一旦消息成功到达 Exchange 并被处理,RabbitMQ 会向消息生产者发送确认信号 (ACK)。如果由于某种原因(例如,Exchange 不存在或路由键不匹配)消息无法被处理,RabbitMQ 会向消息生产者发送否认信号 (NACK)

	//启用Publisher Confirms  
	channel.confirmSelect();  
	//设置Publisher Confirms回调  
	channel.addConfirmListener(new ConfirmListener() {  
    //在这里处理消息确认  
    @Override  
    public void handleAck(long deliveryTag, boolean multiple) throws IOException {  
        System.out.println("Message confirmed with deliveryTag:"deliveryTag);  
    }  
    //在这里处理消息未确认  
    @Override  
    public void handleNack(long deliveryTag, boolean multiple) throws IOException {  
        System.out.println("Message not confirmed with deliveryTag:"deliveryTag);  
    }  
});
2、Publisher Return 机制

Publisher Confirm 机制用于消息无法正常到达交换机中的情况,Publisher Return 机制用于消息无法正常路由到队列的情况。
image.png|800
当消息正常路由到队列中的时候,MQ 不会返回任何消息,当无法正常路由的时候会返回错误信息。

1.3、开启生产者确认

在 publisher 模块的 application.yaml 中添加配置:

spring:  
  rabbitmq:  
    publisher-confirm-type: correlated # 开启publisher confirm机制,并设置confirm类型  
    publisher-returns: true # 开启publisher return机制

这里 publisher-confirm-type 有三种模式可选:

  • none:关闭 confirm 机制
  • simple:同步阻塞等待 MQ 的回执
  • correlated:MQ 异步回调返回回执
    一般我们推荐使用 correlated,回调机制。
定义 ReturnCallback

每个 RabbitTemplate 只能配置一个 ReturnCallback,因此我们可以在配置类中统一设置。我们在 publisher 模块定义一个配置类:
image.png
内容如下:

package com.itheima.publisher.config;

import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ReturnedMessage;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.context.annotation.Configuration;

import javax.annotation.PostConstruct;

@Slf4j
@AllArgsConstructor
@Configuration
public class MqConfig {
    private final RabbitTemplate rabbitTemplate;

    @PostConstruct
    public void init(){
        rabbitTemplate.setReturnsCallback(new RabbitTemplate.ReturnsCallback() {
            @Override
            public void returnedMessage(ReturnedMessage returned) {
                log.error("触发return callback,");
                log.debug("exchange: {}", returned.getExchange());
                log.debug("routingKey: {}", returned.getRoutingKey());
                log.debug("message: {}", returned.getMessage());
                log.debug("replyCode: {}", returned.getReplyCode());
                log.debug("replyText: {}", returned.getReplyText());
            }
        });
    }
}
定义 ConfirmCallback

由于每个消息发送时的处理逻辑不一定相同,因此 ConfirmCallback 需要在每次发消息时定义。具体来说,是在调用 RabbitTemplate 中的 convertAndSend 方法时,多传递一个参数:
image.png
这里的 CorrelationData 中包含两个核心的东西:

  • id:消息的唯一标示,MQ 对不同的消息的回执以此做判断,避免混淆
  • SettableListenableFuture:回执结果的 Future 对象
    将来 MQ 的回执就会通过这个 Future 来返回,我们可以提前给 CorrelationData 中的 Future 添加回调函数来处理消息回执:
    image.png

我们新建一个测试,向系统自带的交换机发送消息,并且添加 ConfirmCallback

@Test
void testPublisherConfirm() {
    // 1.创建CorrelationData
    CorrelationData cd = new CorrelationData();
    // 2.给Future添加ConfirmCallback
    cd.getFuture().addCallback(new ListenableFutureCallback<CorrelationData.Confirm>() {
        @Override
        public void onFailure(Throwable ex) {
            // 2.1.Future发生异常时的处理逻辑,基本不会触发
            log.error("send message fail", ex);
        }
        @Override
        public void onSuccess(CorrelationData.Confirm result) {
            // 2.2.Future接收到回执的处理逻辑,参数中的result就是回执内容
            if(result.isAck()){ // result.isAck(),boolean类型,true代表ack回执,false 代表 nack回执
                log.debug("发送消息成功,收到 ack!");
            }else{ // result.getReason(),String类型,返回nack时的异常描述
                log.error("发送消息失败,收到 nack, reason : {}", result.getReason());
            }
        }
    });
    // 3.发送消息
    rabbitTemplate.convertAndSend("hmall.direct", "q", "hello", cd);
}

执行结果如下:
image.png
可以看到,由于传递的 RoutingKey 是错误的,路由失败后,触发了 return callback,同时也收到了 ack。
当我们修改为正确的 RoutingKey 以后,就不会触发 return callback 了,只收到 ack。
而如果连交换机都是错误的,则只会收到 nack。
注意
开启生产者确认比较消耗 MQ 性能,一般不建议开启。而且大家思考一下触发确认的几种情况:

  • 路由失败:一般是因为 RoutingKey 错误导致,往往是编程导致
  • 交换机名称错误:同样是编程错误导致
  • MQ 内部故障:这种需要处理,但概率往往较低。因此只有对消息可靠性要求非常高的业务才需要开启,而且仅仅需要开启 ConfirmCallback 处理 nack 就可以了。

2、队列

2.1. 数据持久化

为了提升性能,默认情况下 MQ 的数据都是在内存存储的临时数据,重启后就会消失。为了保证数据的可靠性,必须配置数据持久化,包括:

  • 交换机持久化
  • 队列持久化
  • 消息持久化
    我们以控制台界面为例来说明。
2.1.1. 交换机持久化

在控制台的 Exchanges 页面,添加交换机时可以配置交换机的 Durability 参数:
image.png
设置为 Durable 就是持久化模式,Transient 就是临时模式。

2.1.2. 队列持久化

在控制台的 Queues 页面,添加队列时,同样可以配置队列的 Durability 参数:
image.png

2.1.3. 消息持久化

在控制台发送消息的时候,可以添加很多参数,而消息的持久化是要配置一个 properties
image.png

说明:在开启持久化机制以后,如果同时还开启了生产者确认,那么 MQ 会在消息持久化以后才发送 ACK 回执,进一步确保消息的可靠性。
不过出于性能考虑,为了减少 IO 次数,发送到 MQ 的消息并不是逐条持久化到数据库的,而是每隔一段时间批量持久化。一般间隔在 100 毫秒左右,这就会导致 ACK 有一定的延迟,因此建议生产者确认全部采用异步方式。

2.2. LazyQueue

在默认情况下,RabbitMQ 会将接收到的信息保存在内存中以降低消息收发的延迟。但在某些特殊情况下,这会导致消息积压,比如:

  • 消费者宕机或出现网络故障
  • 消息发送量激增,超过了消费者处理速度
  • 消费者处理业务发生阻塞
    一旦出现消息堆积问题,RabbitMQ 的内存占用就会越来越高,直到触发内存预警上限。此时 RabbitMQ 会将内存消息刷到磁盘上,这个行为成为 PageOut. PageOut 会耗费一段时间,并且会阻塞队列进程。因此在这个过程中 RabbitMQ 不会再处理新的消息,生产者的所有请求都会被阻塞。

为了解决这个问题,从 RabbitMQ 的 3.6.0 版本开始,就增加了 Lazy Queues 的模式,也就是惰性队列。惰性队列的特征如下:

  • 接收到消息后直接存入磁盘而非内存
  • 消费者要消费消息时才会从磁盘中读取并加载到内存(也就是懒加载)
  • 支持数百万条的消息存储
    而在 3.12 版本之后,LazyQueue 已经成为所有队列的默认格式。因此官方推荐升级 MQ 为 3.12 版本或者所有队列都设置为 LazyQueue 模式。
2.2.1. 控制台配置 Lazy 模式

在添加队列的时候,添加 x-queue-mod=lazy 参数即可设置队列为 Lazy 模式:
image.png

2.2.2. 代码配置 Lazy 模式

在利用 SpringAMQP 声明队列的时候,添加 x-queue-mod=lazy 参数也可设置队列为 Lazy 模式:

@Bean
public Queue lazyQueue(){
    return QueueBuilder
            .durable("lazy.queue")
            .lazy() // 开启Lazy模式
            .build();
}

这里是通过 QueueBuilderlazy() 函数配置 Lazy 模式,底层源码如下:
image.png

当然,我们也可以基于注解来声明队列并设置为 Lazy 模式:

@RabbitListener(queuesToDeclare = @Queue(
        name = "lazy.queue",
        durable = "true",
        arguments = @Argument(name = "x-queue-mode", value = "lazy")
))
public void listenLazyQueue(String msg){
    log.info("接收到 lazy.queue的消息:{}", msg);
}
2.2.3. 更新已有队列为 lazy 模式

对于已经存在的队列,也可以配置为 lazy 模式,但是要通过设置 policy 实现。
可以基于命令行设置 policy:

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:策略的作用对象,是所有的队列

当然,也可以在控制台配置 policy,进入在控制台的 Admin 页面,点击 Policies,即可添加配置:
image.png

3、消费者

有了持久化机制后,那么怎么保证消息在持久化下来之后一定能被消费者消费呢?这里就涉及到消息的消费确认机制。
在 RabbitMQ 中,消费者处理消息成功后可以向 MQ 发送 ack 回执,MQ 收到 ack 回执后才会删除该消息,这样才能确保消息不会丢失。如果消费者在处理消息中出现了异常,那么就会返回 ack 回执,MQ 收到回执之后就会重新投递一次消息,如果消费者一直都没有返回 ACK/NACK 的话,那么他也会在尝试重新投递。

3.1、消费者确认机制

为了确认消费者是否成功处理消息,RabbitMQ 提供了消费者确认机制(Consumer Acknowledgement)。即:当消费者处理消息结束后,应该向 RabbitMQ 发送一个回执,告知 RabbitMQ 自己消息处理状态。回执有三种可选值:

  • ack:成功处理消息,RabbitMQ 从队列中删除该消息
  • nack:消息处理失败,RabbitMQ 需要再次投递消息
  • reject:消息处理失败并拒绝该消息,RabbitMQ 从队列中删除该消息

一般 reject 方式用的较少,除非是消息格式有问题,那就是开发问题了。因此大多数情况下我们需要将消息处理的代码通过 try catch 机制捕获,消息处理成功时返回 ack,处理失败时返回 nack.
由于消息回执的处理代码比较统一,因此 SpringAMQP 帮我们实现了消息确认。并允许我们通过配置文件设置 ACK 处理方式,有三种模式:

  • none:不处理。即消息投递给消费者后立刻 ack,消息会立刻从 MQ 删除。非常不安全,不建议使用
  • manual:手动模式。需要自己在业务代码中调用 api,发送 ackreject,存在业务入侵,但更灵活
  • auto:自动模式。SpringAMQP 利用 AOP 对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回 ack. 当业务出现异常时,根据异常判断返回不同结果:
    • 如果是业务异常,会自动返回 nack
    • 如果是消息处理或校验异常,自动返回 reject;
      通过下面的配置可以修改 SpringAMQP 的 ACK 处理方式:
spring:  
  rabbitmq:  
    listener:  
      simple:  
        acknowledge-mode: none # 不做处理

修改 consumer 服务的 SpringRabbitListener 类中的方法,模拟一个消息处理的异常:

@RabbitListener(queues = "simple.queue")  
public void listenSimpleQueueMessage(String msg) throws InterruptedException {  
    log.info("spring 消费者接收到消息:【" + msg + "】");  
    if (true) {  
        throw new MessageConversionException("故意的");  
    }  
    log.info("消息处理完成");  
}

测试可以发现:当消息处理发生异常时,消息依然被 RabbitMQ 删除了。
我们再次把确认机制修改为 auto:

spring:  
  rabbitmq:  
    listener:  
      simple:  
        acknowledge-mode: auto # 自动ack

在异常位置打断点,再次发送消息,程序卡在断点时,可以发现此时消息状态为 unacked(未确定状态): image.png 放行以后,由于抛出的是消息转换异常,因此 Spring 会自动返回 reject,所以消息依然会被删除: image.png
我们将异常改为 RuntimeException 类型:

@RabbitListener(queues = "simple.queue")  
public void listenSimpleQueueMessage(String msg) throws InterruptedException {  
    log.info("spring 消费者接收到消息:【" + msg + "】");  
    if (true) {  
        throw new RuntimeException("故意的");  
    }  
    log.info("消息处理完成");  
}

在异常位置打断点,然后再次发送消息测试,程序卡在断点时,可以发现此时消息状态为 unacked(未确定状态): image.png 放行以后,由于抛出的是业务异常,所以 Spring 返回 ack,最终消息恢复至 Ready 状态,并且没有被 RabbitMQ 删除: image.png 当我们把配置改为 auto 时,消息处理失败后,会回到 RabbitMQ,并重新投递到消费者。

3.2、失败重试机制

当消费者出现异常后,消息会不断 requeue(重入队)到队列,再重新发送给消费者。如果消费者再次执行依然出错,消息会再次 requeue 到队列,再次投递,直到消息处理成功为止。极端情况就是消费者一直无法执行成功,那么消息 requeue 就会无限循环,导致 mq 的消息处理飙升,带来不必要的压力: image.png

当然,上述极端情况发生的概率还是非常低的,不过不怕一万就怕万一。为了应对上述情况 Spring 又提供了消费者失败重试机制:在消费者出现异常时利用本地重试,而不是无限制的 requeue 到 mq 队列。

修改 consumer 服务的 application. yml 文件,添加内容:

spring:  
  rabbitmq:  
    listener:  
      simple:  
        retry:  
          enabled: true # 开启消费者失败重试  
          initial-interval: 1000ms # 初识的失败等待时长为1秒  
          multiplier: 1 # 失败的等待时长倍数,下次等待时长 = multiplier * last-interval  
          max-attempts: 3 # 最大重试次数  
          stateless: true # true无状态;false有状态。如果业务中包含事务,这里改为false

重启 consumer 服务,重复之前的测试。可以发现:

  • 消费者在失败后消息没有重新回到 MQ 无限重新投递,而是在本地重试了3次
  • 本地重试 3 次以后,抛出了 AmqpRejectAndDontRequeueException 异常。查看 RabbitMQ 控制台,发现消息被删除了,说明最后 SpringAMQP 返回的是 reject

结论:

  • 开启本地重试时,消息处理过程中抛出异常,不会 requeue 到队列,而是在消费者本地重试
  • 重试达到最大次数后,Spring 会返回 reject,消息会被丢弃

3.3、失败处理策略

在之前的测试中,本地测试达到最大重试次数后,消息会被丢弃。这在某些对于消息可靠性要求较高的业务场景下,显然不太合适了。因此 Spring 允许我们自定义重试次数耗尽后的消息处理策略,这个策略是由 MessageRecovery 接口来定义的,它有 3 个不同实现:

  • RejectAndDontRequeueRecoverer:重试耗尽后,直接 reject,丢弃消息。默认就是这种方式
  • ImmediateRequeueMessageRecoverer:重试耗尽后,返回 nack,消息重新入队
  • RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机

比较优雅的一种处理方案是 RepublishMessageRecoverer,失败后将消息投递到一个指定的,专门存放异常消息的队列,后续由人工集中处理。

1)在 consumer 服务中定义处理失败消息的交换机和队列


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

2)定义一个 RepublishMessageRecoverer,关联队列和交换机

@Bean  
public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){  
    return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");  
}

完整代码如下:


package com.itheima.consumer.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;  
​  
@Configuration  
@ConditionalOnProperty(name = "spring.rabbitmq.listener.simple.retry.enabled", havingValue = "true")  
public class ErrorMessageConfig {  
    @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");  
    }  
​  
    @Bean  
    public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){  
        return new RepublishMessageRecoverer(rabbitTemplate, "error.direct", "error");  
    }  
}