RabbitMQ 消息发送和消费的可靠性保障

发布时间 2023-10-04 10:29:28作者: 乔京飞

在一些比较重要的场景中,我们必须要保障 RabbitMQ 消息的可靠性,也就是发送给 rabbitmq 的消息必须最终成功,消费者接收消息进行处理也必须最终成功。即使是中间失败了,也必须要有其它保障措施,哪怕最后进行人工进行干预处理。

消息出现丢失的场景主要有:

  • 发送消息时丢失:比如消息发送到交换机失败,或者消息从交换机发送到队列失败
  • RabbitMQ 宕机:消息在内存中进行存储,宕机重启后导致队列中的消息全部丢失
  • 消息接收处理丢失:消息接收后,还没处理完成,消费者宕机导致消息丢失。

针对以上场景的处理方案是:

  • 生产者确认机制:监听消息发送到交换机是否成功,以及从交换机发送到队列是否成功
  • 持久化:对于 Spring AMQP 而言,其操作 RabbitMQ 默认都是持久化的,因此这块就不用担心了
  • 消费者确认机制:消费者处理完消息后,无论成功还是失败,给队列一个响应处理
  • 失败重试机制:消费者处理失败后,可以多次重试处理,可以设置重试次数耗尽后的处理策略

本篇博客将通过代码 Demo 演示以上的处理方案,在博客的最后会提供源代码的下载。


一、搭建RabbitMQ环境

之前我们已经演示了如何通过 docker 启动 RabbitMQ,本篇博客采用 docker-compose 启动 RabbitMQ

我的虚拟机操作系统为 CentOS 7.9(IP 地址为 192.168.136.128),已经安装好了 docker 和 docker-compose

新建文件夹用于存放 RabbitMQ 的数据文件:mkdir -p /app/rabbitmq/data

在 rabbitmq 文件夹中新建 docker-compose.yml 文件:vim /app/rabbitmq/docker-compose.yml 内容如下:

version: '3.2'
services:
  rabbitmq:
    # 使用的镜像
    image: rabbitmq:3.12-management
    # 主机名
    hostname: rabbitmq
    # 容器名称
    container_name: rabbitmq
    # 容器随着 docker 启动而自动启动
    restart: always
    # 映射 2 个端口,5672 是程序连接端口,15672 是 web 管理界面端口
    ports:
      - 5672:5672
      - 15672:15672
    # 配置环境变量,创建 jobs 用户,密码为 123456,使用 / 主机空间
    environment:
      RABBITMQ_DEFAULT_VHOST: '/'
      RABBITMQ_DEFAULT_USER: jobs
      RABBITMQ_DEFAULT_PASS: 123456
    # 映射数据存储位置到宿主机的文件夹中
    volumes:
      - /app/rabbitmq/data:/var/lib/rabbitmq

然后在进入到 /app/rabbitmq 文件夹中,运行 docker-compose up -d 启动 RabbitMQ 容器。

通过访问 http://192.168.136.128:15672 访问 web 管理后台,输入 jobs 和 123456 登录进去,验证部署成果。


二、搭建工程

新建一个 SpringBoot 工程,包含发送者和消费者,结构如下:

image

publish_msg 是发送者程序,PublishConfig 是配置类,publishMsgTest 是发送测试方法

consumer_msg 是消费者程序,ConsumerConfig 是配置类,SpringAmqpListener 是消息接收处理方法

总体结构非常简单,下面先看一下父工程 pom 文件内容:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.jobs</groupId>
    <artifactId>spring_rmq_reliable</artifactId>
    <packaging>pom</packaging>
    <version>1.0</version>
    <modules>
        <module>publish_msg</module>
        <module>consumer_msg</module>
    </modules>

    <properties>
        <maven.compiler.source>8</maven.compiler.source>
        <maven.compiler.target>8</maven.compiler.target>
    </properties>

    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>2.4.5</version>
    </parent>

    <dependencies>
        <!--在此主要使用 lombok 自带的 log 方法-->
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
        </dependency>
        <!--Spring AMQP 消息队列依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>
        <!--引入单元测试依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
        <!--发送和接收消息,默认使用 jdk 的序列化进行消息转换,
        引入该依赖,是为了配置消息转换使用 json 格式-->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
        </dependency>
    </dependencies>
</project>

对于 publish_msg 和 consumer_msg 这两个子工程而言,pom 文件没有引入额外的依赖包。

由于我们使用 Spring AMQP 操作 RabbitMQ 创建的交换机、队列、消息,默认都是持久化的,因此当 RabbitMQ 宕机后重启,不用担心消息丢失的问题。我们只需要把精力放在消息在发送过程和消费过程中确保可靠性的保障措施上。


三、生产者确认机制

生产者确认机制,主要从2个方面进行保障,都是在 publish_msg 工程中进行实现:

  • 在发送消息时,绑定回调通知方法,目的在于监听消息是否发送到交换机
  • 在程序启动时,给 RabbitTemplate 设置回调通知方法,目的在于监听消息是否从交换机发送到队列

需要注意:生产者确认机制,要求发送的每条消息必须有全局唯一的 id,这样在消息发送出现问题后才能有效识别。

首先看一下 publish_msg 工程的 application.yml 文件:

spring:
  rabbitmq:
    host: 192.168.136.128
    port: 5672
    username: jobs
    password: 123456
    virtual-host: /
    # 开启发送者确认,simple 为同步等待模式,correlated 异步回调模式
    # 主要是监听消息是否发送到交换机,无论成功或者失败,都可以定义相应的回调通知方法
    publisher-confirm-type: correlated
    # 开启发送者回执,监听消息是否从交换机发送到队列
    publisher-returns: true
    template:
      # 如果消息从交换机发送到队列失败,true 表示回调通知方法,false 表示丢弃忽略
      mandatory: true

主要增加了 publisher 相关的配置项,上面的注释比较详细,然后看看具体发送消息的代码细节:

//发送消息测试
//你可以故意修成为错误交换机名字,测试消息发送到交换机失败的回调
//你可以故意修改为错误的队列名字,测试消息从交换机发送到队列失败的回调
@Test
void publishMessageTest() {

    String message = "hello world rabbitmq test";
    String exchange = "test.exchange";
    String rootingkey = "test";

    //使用 CorrelationData 为消息设置全局唯一id,以及设置消息发送到交换机成功或失败的回调方法
    CorrelationData correlationData = new CorrelationData(UUID.randomUUID().toString());
    correlationData.getFuture().addCallback(result -> {
        if (result.isAck()) {
            log.info("消息成功发送到交换机!消息ID为:{}", correlationData.getId());
        } else {
            log.error("消息发送到交换机失败!消息ID为:{}", correlationData.getId());
        }
    }, ex -> {
        log.error("消息发送未知异常:", ex);
    });

    //发送消息
    rabbitTemplate.convertAndSend(exchange, rootingkey, message, correlationData);
}

以上代码细节,只是监听消息是否发送到交换机,我们还需要保障消息是否从交换机发送到队列

package com.jobs.config;

import lombok.extern.slf4j.Slf4j;
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 PublishConfig implements ApplicationContextAware {

    //配置使用 json 格式进行消息转换
    @Bean
    public MessageConverter messageConverter() {
        return new Jackson2JsonMessageConverter();
    }

    //Spring 程序启动时执行
    @Override
    public void setApplicationContext(ApplicationContext applicationContext)
            throws BeansException {

        //给 RabbitTemplate 对象设置回调通知方法,当消息从交换机发送到队列失败时,会进行回调。
        RabbitTemplate rabbitTemplate = applicationContext.getBean(RabbitTemplate.class);
        rabbitTemplate.setReturnsCallback(returnedMessage -> {
            //如果是发送延迟消息时,由于交换机延迟发送给队列,而不是立即发送给队列,会被误认为发送失败
            //因此这里需要将发送延迟消息的情况,排除在外
            Integer receivedDelay =
                    returnedMessage.getMessage().getMessageProperties().getReceivedDelay();
            if (receivedDelay != null && receivedDelay > 0) {
                return;
            }

            //记录错误日志
            log.error("从交换机发送到队列失败:" + returnedMessage.getMessage().toString());
            log.error("错误码:{},错误信息:{},交换机:{},路由key:{}",
                    returnedMessage.getReplyCode(),
                    returnedMessage.getReplyText(),
                    returnedMessage.getExchange(),
                    returnedMessage.getRoutingKey());

            //可以继续自己的其它业务,比如重新发送消息,或者把错误的消息内容,记录到数据库中等等
        });
    }
}

对应的消费者接收消息的代码细节如下:

@RabbitListener(bindings = @QueueBinding(
    value = @Queue(name = "test.queue"),
    exchange = @Exchange(name = "test.exchange", type = ExchangeTypes.DIRECT),
    key = "test"))
public void listenerTestQueue(String msg) {
    log.info("接收到 test.queue 消息:" + msg);
}

四、消费者确认机制和失败重试机制

消费者在收到消息并处理完成后,无论是否成功,需要给队列一个回执响应,SpringAMQP 可以配置三种确认模式:

  • manual:手动 ack,需要在业务代码结束后,调用 api 发送 ack
  • auto:自动 ack,由 spring 监测消费者处理程序是否异常,没有异常则返回 ack,否则返回 nack
  • none:关闭 ack,假定消费者获取消息后会成功处理,因此消息投递后立即被删除

由于我们有 Spring AMQP 框架的辅助,因此采用 auto ack 是最合适的,代码简单并且执行效率高。当消费者代码处理异常后,消息会重新入队,后面在此会被消费者程序进行处理,具有重试的特点。

但是 auto ack 也有明显的缺点,那就是如果消费者代码本身有问题,总是出现异常,消息总是重新入队,总是被再次处理,这样就会无限循环,导致 RabbitMQ 本身压力负载很高。如果消息非常多的话,就会导致 RabbitMQ 所在机器宕机。

基于上面的问题,我们需要采用本地重试机制,减轻 RabbitMQ 的压力,本地重试可以设置重试的间隔时间,以及最大的重试次数。

对于 Spring AMQP 来说,默认情况下如果重试次数耗尽,有以下处理策略:

  • RejectAndDontRequeueRecoverer:重试次数耗尽后,直丢弃消息,这是默认的处理方式

  • ImmediateRequeueMessageRecoverer:重试次数耗尽后,返回 nack,消息重新入队

  • RepublishMessageRecoverer:重试次数耗尽后,将失败消息投递到我们所指定的其它交换机

重试次数耗尽后,默认会丢弃消息,在重要场景下肯定是不能接受的,如果重新入队的话,又可能会出现无限循环的问题,因此最佳方案就是将处理失败的消息投递到我们指定的其它交换机,比如 error 交换机,将消息都投递到 error 队列,我们对 error 队列中的消息进行日志记录,存入数据库等等,后续交由人工进行干预处理,确保处理失败的消息能够最终得到妥善的处理。

首先看一下 consumer_msg 的 application.yml 配置文件内容:

spring:
  rabbitmq:
    host: 192.168.136.128
    port: 5672
    username: jobs
    password: 123456
    virtual-host: /
    listener:
      simple:
        # 每次取 1 条消息进行消费,消费成功后再去取下一条消息
        # 防止多个消费者被均匀分配消息,让消费能力强的消费者能够获取更多的消息
        prefetch: 1
        # 开启消费者自动 ack 模式:
        # 当接收消息监听的方法出现异常时,则返回 nack,没有异常则返回 ack
        acknowledge-mode: auto
        retry:
          # 开启本地重试机制
          enabled: true
          # 重试间隔 1 秒
          initial-interval: 1000
          # 间隔的时间倍数:
          # 消息处理失败后,1 秒后进行第一次重试
          # 如果再处理失败,2 秒后进行第二次重试
          # 如果继续失败,4 秒后进行第三次重试
          multiplier: 2
          # 一共处理 4 次(正常处理 1 次,加上重试 3 次)
          max-attempts: 4

为了模拟失败重试,并且在重试次数耗尽后,再次投递给其它交换机处理,代码中编写了 2 个监听处理方法。

//别的队列中消息,处理错误被丢弃,或者超时未消费,则在该队列中进行处理
//toppic 表达式中的占位符:# 表示一个或多个单词,* 表示一个单词
@RabbitListener(bindings = @QueueBinding(
    value = @Queue(name = "error.queue"),
    exchange = @Exchange(name = "test.exchange", type = ExchangeTypes.DIRECT),
    key = "error"
))
public void listenerErrorQueue(String msg) {
    log.info("接收到 error.queue 消息:" + msg);
    //在这里可以记录日志,或将消息存储到数据库中,后续由人工进行干预处理
}

//接收到消息后,处理程序会报异常
//在 application.yml 中配置了本地重试 3 次,如果都是失败,默认会丢弃消息,这个不是我们期望的。
//因此在 ConsumerConfig 进行了配置,如果都是失败后,会将消息重新发送到 error.exchange 交换机
//从而转发到 error.queue 队列中进行处理。
@RabbitListener(bindings = @QueueBinding(
    value = @Queue(name = "test.queue"),
    exchange = @Exchange(name = "test.exchange", type = ExchangeTypes.DIRECT),
    key = "exception"
))
public void listenerExceptionQueue(String msg) {
    log.info("接收到 exception.queue 消息:" + msg);
    //以下代码会抛出异常
    Integer result = 1 / 0;
}

默认情况下,重试次数耗尽后会丢弃消息,我们需要对该处理方式进行覆盖:

//RejectAndDontRequeueRecoverer:重试次数耗尽后,丢弃消息。spring amqp 默认就是这种方式
//ImmediateRequeueMessageRecoverer:重试次数耗尽后,返回nack,消息重新加入队列。
//RepublishMessageRecoverer:重试次数耗尽后,将失败消息投递到指定的交换机中,最好使用这种方案
//这里使用 RepublishMessageRecoverer,当消息重试次数耗尽后,投递到 error.queue 进行处理
@Bean
public MessageRecoverer republishMessageRecoverer(RabbitTemplate rabbitTemplate){
    return new RepublishMessageRecoverer(
        rabbitTemplate, "test.exchange", "error");
}

最后我们可以编写一个发送消息的方法,进行测试验证:

//发送到 exception.queue,重试 3 次,一共处理 4 次都抛异常,然后再发送到 error.queue 处理
@Test
void publishErrorTest() {
    String message = "error message test";
    String exchange = "test.exchange";
    String rootingkey = "exception";
    //发送消息
    rabbitTemplate.convertAndSend(exchange, rootingkey, message);
}

到此为止,有关消息的可靠性保障措施已经介绍完毕,代码都经过测试无误,可以下载源代码进行验证。

本篇博客的源代码下载地址为:https://files.cnblogs.com/files/blogs/699532/spring_rmq_reliable.zip