RabbitMQ超详细学习笔记

RabbitMQ 超详细学习笔记(章节清晰 + 通俗易懂)

消息中间件(MQ)是后端开发绕不开的核心组件。本文是一份系统化、案例化的 RabbitMQ 学习笔记,从 MQ 基础概念讲起,覆盖安装、入门、六种消息模式、各类高级机制与集群搭建,配合大量可运行的 Java 代码示例,力求“好理解、案例多、够详细”。


目录

  1. MQ 相关的概念
  2. RabbitMQ 安装启动
  3. RabbitMQ 快速入门
  4. 简单模式——Hello World
  5. 工作模式——Work Queues
  6. RabbitMQ 交换机
  7. RabbitMQ 的一些机制
  8. RabbitMQ 集群

一、MQ 相关的概念

RabbitMQ 是一种分布式消息中间件,消息中间件也称消息队列(MQ)。本节先探讨什么是 MQ,以及为什么要使用它。

1.1 MQ 的基本概念

什么是 MQ

MQ(Message Queue),从字面意思看就是一个 FIFO 先入先出的队列,只不过队列中存放的内容是 message 而已,它是一种具有接收数据、存储数据、发送数据等功能的技术服务。

在互联网架构中,MQ 是一种非常常见的上下游“逻辑解耦 + 物理解耦”的消息通信服务,用于上下游传递消息。使用了 MQ 之后,消息发送上游只需要依赖 MQ,不用依赖其他服务。

为什么用 MQ

常见的 MQ 消息中间件有 ActiveMQ、RabbitMQ、Kafka、RocketMQ 等。之所以使用它,是因为它能很好地解决一些复杂特殊的场景:

1. 高并发的流量削峰

假设某订单系统每秒最多能处理 1 万次订单(即承受 10000 QPS),正常时段下单一秒后就能返回结果。但在高峰期如果有 2 万次下单,系统处理不了,只能限制超过 1 万后不允许下单。使用消息队列做缓冲,可以取消这个限制,把一秒内下的订单分散到一段时间内处理。虽然部分用户可能在下单十几秒后才收到成功反馈,但体验总比不能下单要好。

2. 应用解耦

以电商应用为例,应用中有订单系统、库存系统、物流系统、支付系统。用户创建订单后,如果耦合调用库存、物流、支付系统,任何一个子系统出故障都会导致下单异常。转变成基于消息队列的方式后,系统间调用的问题会减少很多。比如物流系统故障需要几分钟修复,期间要处理的内容会被缓存在消息队列中,用户的下单操作可以正常完成;物流系统恢复后再继续处理即可,用户感受不到故障,提升了系统可用性。

3. 异步处理

有些服务间调用是异步的。例如 A 调用 B,B 需要花很长时间执行,而 A 需要知道 B 什么时候执行完。以前一般有两种方式:A 过一段时间去调用 B 的查询 API,或者 A 提供一个 callback API 由 B 执行完后回调通知。这两种方式都不够优雅。使用消息队列可以很方便解决:A 调用 B 服务后,只需监听 B 处理完成的消息;B 处理完后发送一条消息给 MQ,MQ 再转发给 A 服务。这样 A 既不用循环调用 B 的查询 API,也不用提供 callback API,还能及时得到异步处理成功的消息。

4. 分布式事务

以订单服务为例,传统单体应用把支付、修改订单状态、创建物流订单三个步骤集成在一个服务中,因此可以放在一个 JDBC 事务里,要么全成功、要么全失败。而在微服务环境下,三个步骤被拆成支付、订单、物流三个服务,各自操作不同的数据库,无法用 JDBC 事务保证一致性。而 MQ 能很好地解决分布式事务问题——一个容易理解的方案是二次提交:MQ 作为二次提交的中间节点,负责存储请求数据,失败时可进行多次尝试,或基于 MQ 中的队列数据进行回滚,既能保证性能,又能保证业务一致性。

5. 数据分发

MQ 具有发布订阅机制,不仅是简单的上下游一对一关系,还支持一对多或广播的模式,并且可以根据规则选择分发对象。这样一份上游数据,众多下游系统可以根据规则选择是否接收,扩展性很高。

1.2 常用 MQ 对比

中间件 优点 缺点 选用场景
ActiveMQ 单机吞吐量万级,时效性 ms 级,可用性高,基于主从架构实现高可用,消息可靠性较高(低概率丢失) 官方社区对 5.x 维护越来越少,高吞吐量场景较少使用 中小规模、可靠性要求较高的场景
Kafka 吞吐量极高(单机写入约百万条/秒),时效性 ms 级,可用性非常高;分布式、多副本,少数机器宕机不丢失数据;消费者 Pull 方式,消息有序,可保证“所有消息被消费且仅被消费一次”;有成熟的 Kafka-Manager 管理界面,日志领域成熟 单机超过 64 个队列/分区后 Load 明显飙高;短轮询,实时性取决于轮询间隔;消费失败不支持重试;代理宕机后消息乱序;社区更新较慢 大数据领域的实时计算与日志采集,适合产生大量数据的收集业务
RocketMQ 单机吞吐量十万级,可用性非常高,分布式架构,消息可做到 0 丢失,功能较完善、扩展性好,支持 10 亿级消息堆积且不因堆积降性能,Java 实现 支持的客户端语言不多(目前 Java 及 C++,且 C++ 不成熟);社区活跃度一般;未在核心实现 JMS 等接口,迁移可能需改大量代码 金融互联网领域,可靠性要求高的场景(电商订单扣款、业务削峰)
RabbitMQ 基于 Erlang 高并发特性,性能好,时效性微秒级;吞吐量到万级,功能完备、健壮、稳定、易用、跨平台,支持多种语言;管理界面非常棒,社区活跃、更新频率高 开源核心免费(MPL 2.0,可免费商用);收费的是原厂商业支持/企业增强版与云厂商托管服务;无官方 24/7 技术支持、旧版安全补丁仅面向商业客户,学习成本较高 数据量不大、中小型公司优先选择功能完备的 RabbitMQ

1.3 消息队列协议

什么是协议

协议是在 TCP/IP 协议基础之上构建的一种约定成俗的规范和机制,目的是让客户端进行沟通和通讯,并且这种协议必须具有持久性、高可用、高可靠的性能。

为什么不直接采用 TCP/IP 协议传递消息?因为 TCP/IP 协议太简单,并不能承载消息的内容和载体,因此需要在它之上增加一些内容,为消息的传递分发高可用提供基础。

消息中间件负责数据的传递、存储和分发消费三个部分,存储和分发过程中肯定要遵循某种约定成俗的规范,是采用底层 TCP/IP、UDP 协议还是在此基础上自己构建等,这些约定成俗的规范就称之为协议。

网络协议的三要素:

  • 语法:用户数据与控制信息的结构与格式,以及数据出现的顺序。
  • 语义:解释控制信息每个部分的意义,规定需要发出何种控制信息以及完成的动作与响应。
  • 时序:对事件发生顺序的详细说明。

类比 HTTP 请求协议:

  1. 语法:HTTP 规定了请求报文和响应报文的格式;
  2. 语义:客户端主动发起请求称之为请求(如发起的是 POST/GET 请求);
  3. 时序:一个请求对应一个响应(先有请求、后有响应)。

消息中间件采用的并不是 HTTP 协议,常见的有 OpenWire、AMQP、MQTT、Kafka、OpenMessage 协议。

面试题:为什么消息中间件不直接使用 HTTP 协议?

因为 HTTP 请求报文头和响应报文头比较复杂,包含了 cookie、数据加解密、状态码、响应码等附加功能;但对一个消息而言并不需要这么复杂,它本质只负责数据传递、存储、分发,追求的是高性能,应尽量简洁快速。另外 HTTP 大部分是短链接,一个请求到响应很可能会中断,中断后不会进行持久化,会造成请求丢失,不利于消息中间件长期获取消息、故障时持久化以保证高可靠性的业务场景。

常用消息中间件协议

协议 特点 支持者
AMQP(高级消息队列协议) 应用层开放标准,统一消息服务;支持分布式事务、消息持久化、高性能高可靠 RabbitMQ、ActiveMQ(RabbitMQ 由 Erlang 开发)
MQTT(消息队列遥测传输协议) 基于发布/订阅模式的“轻量级”通讯协议,构建于 TCP/IP 上;轻量、结构简单、传输快,不支持事务、无持久化设计 适用于计算能力有限、低带宽、网络不稳定的场景;RabbitMQ、ActiveMQ 支持
OpenMessage 结构简单、解析速度快,支持事务和持久化设计 阿里、雅虎、滴滴等共同创立的标准
Kafka 基于 TCP/IP 的二进制协议,消息内部通过长度分割;结构简单、解析快、无事务支持、有持久化设计 Kafka

1.4 消息队列持久化

持久化简单来说就是将数据存入磁盘,而不是存在内存中随服务器重启断开而消失,使数据能够永久保存。

持久化方式 ActiveMQ RabbitMQ Kafka RocketMQ
文件存储 支持 支持 支持 支持
数据库 支持 / / /

1.5 消息的分发策略

MQ 消息队列有如下几个角色:

  • Producer:消息生产者,负责产生和发送消息到 Broker。
  • Broker:消息处理中心,负责消息存储、确认、重试等,一般包含多个 queue。
  • Consumer:消息消费者,负责从 Broker 中获取消息,并进行相应处理。

生产者产生消息后,MQ 进行存储,消费者如何获得消息?一般的获取方式无外乎外推(push)或拉取(pull)两种方式,消息队列 MQ 是一种推送过程,会适用到很多业务场景,也有很多对应的推机制策略。

  • 场景分析一:在 APP 上下了一个订单,系统和服务很多,如何得知这个消息被哪个系统或服务消费,此时就需要一个消费策略(消费方法论)。
  • 场景分析二:发送消息过程中可能出现异常、网络抖动、故障等导致消息无法消费,此时消息中间件必须支持消息重试机制,即在出现问题和故障的情况下,消息不丢失、还可以重发。

消息分发策略的机制和对比

机制 ActiveMQ RabbitMQ Kafka RocketMQ
发布订阅 支持 支持 支持 支持
轮询分发 支持 支持 支持 /
公平分发 / 支持 支持 /
重发 支持 支持 / 支持
消息拉取 / 支持 支持 支持

1.6 消息队列的高可用和高可靠

所谓高可用,是指产品在规定的条件和规定的时刻/时间内处于可执行规定功能状态的能力。

当业务量增加、请求过大时,一台消息中间件服务器会触及硬件(CPU、内存、磁盘)极限,无法满足业务需求,因此消息中间件必须支持集群部署来达到高可用。常见的高可用部署方式:

  1. Master-slave 主从共享数据的部署方式:多个 Broker 连接共享一块消息存储空间,Master 节点负责消息写入,客户端写入 Master,Master 挂掉后 slave 继续服务,形成高可用。
  2. Master-slave 主从同步部署方式:写入同样在 Master,但主节点会同步数据到 slave 形成副本(类似 ZooKeeper/Redis 主从)。可达到负载均衡效果,但消息拷贝和同步会占用大量带宽和网络资源(RabbitMQ 中会使用)。
  3. 多主集群同步部署模式:与上述方式区别不大,但任意节点都可以写入。
  4. 多主集群转发部署模式:如果插入的数据是 Broker1,元数据信息会存储数据的相关描述和记录存放位置(队列);如果消费者在 Broker2 消费时发现自己没有对应消息,就会在元数据中查询,查不到则将该消息信息携带在请求中转发到其他节点询问,直到找到为止。类似“黄牛买票,第一个黄牛没有顾客要的票,会去联系其他黄牛询问”。
  5. Master-slave 与 Broker-cluster 组合方案:实现多主多从的热备机制来完成消息高可用及数据热备,生产规模达到一定阶段时使用频率较高。

这些集群模式的最终目的都是:保证消息服务器不会挂掉,出现故障依然可以继续提供消息服务。归根结底三句话——要么消息共享、要么消息同步、要么元数据共享。


二、RabbitMQ 安装启动

关于 RabbitMQ 的安装,本文不再展开(可自行搜索超详细安装教程《RabbitMQ 超详细安装教程(Linux)》),建议先完成单机安装并启动 RabbitMQ 服务后,再进入后续章节的代码实践。


三、RabbitMQ 快速入门

3.1 RabbitMQ 的概念

RabbitMQ 是一个消息中间件,它接受并转发消息。可以把它当做一个快递站点:当你要发送一个包裹时,把包裹放到快递站,快递员最终会把快递送到收件人那里。按照这种逻辑,RabbitMQ 是一个快递站,快递员帮你传递快件。与快递站的主要区别在于:它不处理快件,而是接收、存储和转发消息数据。

3.2 AMQP 协议

RabbitMQ 是一种遵循 AMQP 协议的分布式消息中间件。AMQP 全称 “Advanced Message Queuing Protocol”(高级消息队列协议),是应用层协议的一个开放标准,为面向消息的中间件设计。

3.3 RabbitMQ 架构组成

  • Broker:就是 RabbitMQ 服务,用于接收和分发消息,接受客户端的连接,实现 AMQP 实体服务。
  • Virtual Host:出于多租户和安全因素设计的,把 AMQP 的基本组件划分到一个虚拟分组中,类似于网络中的 namespace 概念。多个用户使用同一个 RabbitMQ server 时,可划分多个 vhost,每个用户在自己的 vhost 创建 exchange 或 queue 等。
  • Connection:连接,生产者/消费者与 Broker 之间的 TCP 网络连接。
  • Channel:网络信道。如果每次访问 RabbitMQ 都建立一个 Connection,消息量大时建立连接的开销巨大、效率低。Channel 是在 connection 内部建立的逻辑连接;若应用程序支持多线程,通常每个线程创建单独的 channel 进行通讯,AMQP method 包含 channel id 帮助客户端和 broker 识别 channel,所以 channel 之间完全隔离。Channel 作为轻量级的 Connection,极大减少了操作系统建立 TCP connection 的开销。
  • Message:消息,服务与应用程序之间传送的数据,由 Properties 和 Body 组成。Properties 可对消息进行修饰(如优先级、延迟等高级特性),Body 则是消息体内容。
  • Exchange:交换机,是 message 到达 broker 的第一站,根据分发规则、匹配查询表中的 routing key 将消息分发到 queue,不具备消息存储功能。常用类型有 direct、topic、fanout。
  • Bindings:exchange 和 queue 之间的虚拟连接,binding 中可以包含 routing key,Binding 信息保存到 exchange 的查询表中,作为 message 的分发依据。
  • Routing key:路由规则,虚拟机可用它来确定如何路由一个特定消息。
  • Queue:消息队列,保存消息并将它们转发给消费者消费。

3.4 四大核心概念

  • 生产者:产生数据发送消息的程序。
  • 交换机:一方面接收来自生产者的消息,另一方面将消息推送到队列。交换机必须确切知道如何处理接收到的消息——推送到特定队列、多个队列,还是丢弃,这由交换机类型决定。
  • 队列:RabbitMQ 内部使用的一种数据结构,消息只能存储在队列中。队列仅受主机内存和磁盘限制的约束,本质上是一个大的消息缓冲区。多个生产者可发送消息到同一队列,多个消费者可尝试从同一队列接收数据。
  • 消费者:与接收含义相似,大多时候是一个等待接收消息的程序。注意:生产者、消费者和消息中间件很多时候并不在同一台机器上;同一个应用程序既可以是生产者又可以是消费者。

3.5 RabbitMQ 角色分类

角色 权限
none 不能访问 management plugin
management 查看自己相关节点信息:列出自己可通过 AMQP 登入的虚拟机;查看自己虚拟机节点的 queues、exchanges 和 bindings;查看和关闭自己的 channels 和 connections;查看自己虚拟机节点的统计信息
Policymaker 包含 management 所有权限;可查看、创建和删除自己 virtual hosts 所属的 policies 和 parameters
Monitoring 包含 management 所有权限;罗列所有 virtual hosts;查看其他用户的 connections 和 channels;查看节点级数据(clustering、memory 等);查看所有 virtual hosts 的全局统计信息
Administrator 最高权限:可创建和删除 virtual hosts;查看、创建和删除 users;查看创建 permissions;关闭所有用户的 connections

3.6 RabbitMQ 消息模式

RabbitMQ 提供 6 种模式:Hello World、Work Queues、Publish/Subscribe、Routing、Topics、RPC。本文详细讲述前 5 种,并给出代码实现和思路。其中 Publish/Subscribe、Routing、Topics 三种模式可统一归为 Exchange 模式,只是创建时交换机的类型不一样,分别是 fanout、direct、topic 三种交换机类型。

注意:简单模式和工作模式虽然没有画出交换机,但都会有一个默认的交换机,类型为 direct。

简单模式:一个生产者、一个消费者、一个队列,采用默认交换机。可理解为生产者 P 发送消息到队列 Q,一个消费者 C 接收。

工作模式:一个生产者、多个消费者、一个队列,采用默认交换机。生产者 P 发送消息到队列 Q,可由多个消费者 C1、C2 接收。

发布/订阅模式(fanout):一个生产者、一个 fanout 类型交换机、多个队列、多个消费者。生产者发送的消息会被多个消费者获取。fanout 类型就是发布订阅模式,只有订阅了该生产者的消费者才会收到消息。

路由模式(direct):一个生产者、一个 direct 类型交换机、多个队列,交换机与队列之间通过 routing-key 关联绑定。生产者发送消息到交换机并指定 routing-key,消息根据交换机与队列间的 routing-key 绑定规则被路由到指定消费者消费。

主题模式(topic):一个生产者、一个 topic 类型交换机、多个队列。与路由模式不同,routing-key 有指定规则,更通用,能满足更多场景。routing-key 规则:

  • #:匹配一个或多个词,例如 lazy.# 可匹配 lazy.xxx 或 lazy.xxx.xxx。
  • *:只能匹配一个词,例如 lazy.* 只能匹配 lazy.xxx。

参数模式(headers):可以携带参数,根据参数进行过滤。新建一个 headers 类型的交换机,绑定队列时设置相关参数,发送消息时指定参数,只有满足参数条件的队列才收到消息。


四、简单模式——Hello World

RabbitMQ 中最简单的 Hello World 模式,也就是一个生产者、一个消费者、一个队列:生产者 P 发送消息到队列 Q,一个消费者 C 接收消息。

下面用 Java 代码实现。首先创建一个空项目,然后添加一个 maven 模块。

4.1 导入 RabbitMQ 依赖

导入 RabbitMQ 依赖,并指定 JDK 的编译版本。

<!-- 指定 jdk 编译版本 -->
<build>
    <plugins>
        <plugin>
            <groupId>org.apache.maven.plugins</groupId>
            <artifactId>maven-compiler-plugin</artifactId>
            <configuration>
                <source>8</source>
                <target>8</target>
            </configuration>
        </plugin>
    </plugins>
</build>

<dependencies>
    <!-- rabbitmq 依赖客户端 -->
    <dependency>
        <groupId>com.rabbitmq</groupId>
        <artifactId>amqp-client</artifactId>
        <version>5.8.0</version>
    </dependency>
    <!-- 操作文件流的一个依赖 -->
    <dependency>
        <groupId>commons-io</groupId>
        <artifactId>commons-io</artifactId>
        <version>2.6</version>
    </dependency>
</dependencies>

4.2 编写消息生产者

步骤:创建连接工厂 ConnectionFactory → 通过连接工厂创建连接 Connection → 通过连接获取通道 Channel → 通过通道声明队列 Queue → 发送消息到队列 Queue 中。

package mode1_Simple;

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;

public class Producer {
    // 队列名称
    public static String QUEUE_NAME = "hello";

    public static void main(String[] args) throws Exception {
        // 创建一个连接工厂
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("82.156.9.173");
        factory.setUsername("zsr");
        factory.setPassword("123456");

        // 创建一个 connection
        Connection connection = factory.newConnection();

        // 创建一个 channel
        Channel channel = connection.createChannel();

        /**
         * 创建一个队列
         * 1.队列名称
         * 2.队列里面的消息是否持久化(默认为 false,代表消息存储在内存中)
         * 3.该队列是否只供一个消费者进行消费,是否进行共享(true 表示可以多个消费者消费)
         * 4.表示最后一个消费者断开连接以后,该队列是否自动删除(true 表示自动删除)
         * 5.其他参数
         */
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);

        /**
         * 发送一个消息
         * 1.发送到那个交换机(空代表默认交换机)
         * 2.路由 key
         * 3.其他的参数信息
         * 4.发送消息的消息体
         */
        String message = "hello";
        channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
        System.out.println("消息发送完毕");
    }
}

4.3 编写消息消费者

步骤:创建连接工厂 ConnectionFactory → 通过连接工厂创建连接 Connection → 通过连接获取通道 Channel → 通过通道接收消息。

package mode1_Simple;

import com.rabbitmq.client.*;

public class Consumer {
    // 队列名称
    public static String QUEUE_NAME = "hello";

    public static void main(String[] args) throws Exception {
        // 创建一个连接工厂
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("82.156.9.173");
        factory.setUsername("zsr");
        factory.setPassword("123456");

        // 创建一个 connection
        Connection connection = factory.newConnection();

        // 创建一个 channel
        Channel channel = connection.createChannel();

        /**
         * 消费者消费消息
         * 1.消费的队列名称
         * 2.消费成功之后是否要自动应答(true 代表自动应答,false 代表手动应答)
         * 3.消费者消费消息的回调(函数式接口)
         * 4.消费者取消消费的回调(函数式接口)
         */
        // 消费消息的回调
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("成功消费消息,内容为:" + new String(message.getBody()));
        };

        // 取消消费的回调
        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };

        channel.basicConsume(QUEUE_NAME, true, deliverCallback, cancelCallback);
    }
}

4.4 测试

运行程序,观察消息在 rabbitmq-server 服务中的过程:

  1. 启动生产者,可以看到创建了一个队列 hello,并发送了一条消息,此时还未被消费;
  2. 再启动消费者,控制台打印收到的消息,同时可以看到消息已经被消费。

此外,这里的 hello 队列是一个非持久化队列,所以如果 rabbitmq 服务重启,该队列会消失。

4.5 代码优化——抽取工具类

上述生产者、消费者获取 ConnectionFactory、Connection 和 Channel 的过程是一样的,可以封装成一个工具类 RabbitMqUtils:

package utils;

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;

public class RabbitMqUtils {
    // 获得 RabbitMQ 连接的 channel
    public static Channel getChannel() throws Exception {
        // 创建一个连接工厂
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("82.156.9.173");
        factory.setUsername("zsr");
        factory.setPassword("123456");

        // 创建一个 connection
        Connection connection = factory.newConnection();

        // 创建一个 channel
        Channel channel = connection.createChannel();
        return channel;
    }
}

优化后的消费者:

package mode1_Simple;

import com.rabbitmq.client.*;

public class Consumer {
    public static String QUEUE_NAME = "hello";

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();

        /**
         * 消费者消费消息
         * 1.消费的队列名称
         * 2.消费成功之后是否要自动应答(true 代表自动应答,false 代表手动应答)
         * 3.消费者消费消息的回调(函数式接口)
         * 4.消费者取消消费的回调(函数式接口)
         */
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("消息成功消费!内容为:" + new String(message.getBody()));
        };
        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };
        channel.basicConsume(QUEUE_NAME, true, deliverCallback, cancelCallback);
    }
}

优化后的生产者:

package mode1_Simple;

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.Connection;
import com.rabbitmq.client.ConnectionFactory;

public class Producer {
    public static String QUEUE_NAME = "hello";

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();

        /**
         * 创建一个队列
         * 1.队列名称
         * 2.队列里面的消息是否持久化(默认为 false,代表消息存储在内存中)
         * 3.该队列是否只供一个消费者进行消费,是否进行共享(true 表示可以多个消费者消费)
         * 4.表示最后一个消费者断开连接以后,该队列是否自动删除(true 表示自动删除)
         * 5.其他参数
         */
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);

        /**
         * 发送一个消息
         * 1.发送到那个交换机(空代表默认交换机)
         * 2.路由 key
         * 3.其他的参数信息
         * 4.发送消息的消息体
         */
        String message = "hello";
        channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
        System.out.println("消息发送完毕");
    }
}

五、工作模式——Work Queues

Work Queues 是工作队列模式,也就是一个生产者、多个消费者、一个队列。

它的主要思想是避免排队等待,避免一个消息处理时间过久而无法处理下一个。相比简单模式可以有多个消费者,原理就是把任务封装为消息并发送到队列中,多个消费者可以一起处理队列中的任务。

RabbitMQ 中的工作模式默认采用轮询方式,如果有两个消费者,消息逐一分给每个消费者消费。

5.1 编写消息生产者

package mode2_WorkQueues.polling;

import com.rabbitmq.client.Channel;
import java.util.Scanner;

public class Producer {
    public static String QUEUE_NAME = "work";

    public static void main(String[] args) throws Exception {
        // 创建 channel
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 声明队列
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);

        // 发送消息
        Scanner scanner = new Scanner(System.in);
        while (scanner.hasNext()) {
            String message = scanner.next();
            channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
            System.out.println("消息发送完毕" + message);
        }
    }
}

5.2 编写消息消费者

消费者 1:

package mode2_WorkQueues.polling;

import com.rabbitmq.client.CancelCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;

// 消费者1
public class Consumer01 {
    public static String QUEUE_NAME = "work";

    public static void main(String[] args) throws Exception {
        // 创建 channel
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 消费消息
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("消息成功消费!内容为:" + new String(message.getBody()));
        };
        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };
        channel.basicConsume(QUEUE_NAME, true, deliverCallback, cancelCallback);
    }
}

消费者 2:

package mode2_WorkQueues.polling;

import com.rabbitmq.client.CancelCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;

// 消费者2
public class Consumer02 {
    public static String QUEUE_NAME = "work";

    public static void main(String[] args) throws Exception {
        // 创建 channel
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 消费消息
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("消息成功消费!内容为:" + new String(message.getBody()));
        };
        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };
        channel.basicConsume(QUEUE_NAME, true, deliverCallback, cancelCallback);
    }
}

5.3 测试

首先分别启动两个消费者,然后启动生产者发送 4 条消息。根据结果可以看到,consumer01 收到第 1、3 条消息,consumer02 收到第 2、4 条消息——即多个消费者轮询消费生产者发送的消息。


六、RabbitMQ 交换机

前面我们演示了 Hello World、Work Queues 两种工作模式。接下来看剩下的三种模式:Publish/Subscribe、Routing、Topics,它们统一归为 Exchange 模式,只是创建时交换机的类型不同(fanout、direct、topic)。

6.1 Exchanges 简介

RabbitMQ 消息传递模型的核心思想是:生产者生产的消息从不会直接发送到队列。实际上,通常生产者甚至都不知道消息传递到了哪些队列中。

相反,生产者只能将消息发送到交换机(exchange),交换机一方面接收来自生产者的消息,另一方面将它们推入队列。交换机必须确切知道如何处理收到的消息——放到特定队列、放到多个队列,还是丢弃,这由交换机的类型决定。

Exchanges 的类型总共有四种:direct(直接)、topic(主题)、headers(标题)、fanout(扇出)。

无名 exchange:本文前面部分对 exchange 一无所知,但仍然能将消息发送到队列,这是因为我们使用的是默认交换机,通过空字符串 "" 进行标识。

临时队列:上文所有例子使用的都是具有特定名称的队列。很多时候我们可能不想指定队列名字,只想实验测试一下,此时可以创建一个具有随机名称的队列,一旦断开消费者的连接,该队列将被自动删除,这就是临时队列。

// 创建一个临时队列
String queueName = channel.queueDeclare().getQueue();

绑定(Binding):生产者发消息都是发给交换机,由交换机对消息进行路由到队列,那么交换机怎么知道将消息发给哪个队列呢?这就是 routing-key 要做的事,它是 exchange 和 queue 之间的桥梁,告诉我们 exchange 和哪个队列进行了绑定。queueBind 方法中的第三个参数就是设定 routing-key 的值,用于设定交换机和队列的绑定关系。

6.2 Fanout 模式——发布订阅模式

Fanout 类型非常简单,它将接收到的所有消息广播到它知道的所有队列中。RabbitMQ 系统中默认有一个 fanout 类型的交换机。

下面通过代码实战来看 fanout 交换机的实现效果。示意图:一个生产者、一个 fanout 模式的交换机绑定了两个临时队列,分别对应两个消费者。

生产者:

package mode2_WorkQueues.exchange;

import com.rabbitmq.client.BuiltinExchangeType;
import com.rabbitmq.client.Channel;

// 生产者
public class Producer {
    public static void main(String[] args) throws Exception {
        // 创建 channel
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 声明交换机
        channel.exchangeDeclare("logs", BuiltinExchangeType.FANOUT);

        // 发送10条消息
        for (int i = 0; i < 10; i++) {
            String message = i + "";
            channel.basicPublish("logs", "", null, message.getBytes());
            System.out.println("消息发送完毕" + message);
        }
    }
}

消费者 1:

package mode2_WorkQueues.exchange;

import com.rabbitmq.client.BuiltinExchangeType;
import com.rabbitmq.client.CancelCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;

// 消费者1
public class Consumer01 {
    public static void main(String[] args) throws Exception {
        // 创建 channel
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 声明交换机
        channel.exchangeDeclare("logs", BuiltinExchangeType.FANOUT);

        // 声明临时队列
        String queueName = channel.queueDeclare().getQueue();

        // 绑定队列与交换机
        channel.queueBind(queueName, "logs", "");

        // 消费消息
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("获得消息:" + new String(message.getBody()));
        };
        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };
        channel.basicConsume(queueName, true, deliverCallback, cancelCallback);
    }
}

消费者 2:

package mode2_WorkQueues.exchange;

import com.rabbitmq.client.BuiltinExchangeType;
import com.rabbitmq.client.CancelCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;

// 消费者2
public class Consumer02 {
    public static void main(String[] args) throws Exception {
        // 创建 channel
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 声明交换机
        channel.exchangeDeclare("logs", BuiltinExchangeType.FANOUT);

        // 声明临时队列
        String queueName = channel.queueDeclare().getQueue();

        // 绑定队列与交换机
        channel.queueBind(queueName, "logs", "");

        // 消费消息
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("获得消息:" + new String(message.getBody()));
        };
        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };
        channel.basicConsume(queueName, true, deliverCallback, cancelCallback);
    }
}

运行测试:分别启动 consumer01、consumer02,再启动 producer 发送 10 条消息。根据终端打印结果,consumer01 和 consumer02 都收到了这 10 条消息,因此 fanout 类型的交换机起到广播作用。

6.3 Direct 模式——路由模式

Fanout 这种交换类型只能进行无意识的广播,灵活性不够。这里使用 direct 类型来替换,direct 类型的工作方式是:消息只去到它绑定的 routingKey 队列。

举个简单的例子:交换机 X 为 direct 模式,绑定两个队列——队列 Q1 绑定键为 orange,队列 Q2 有两个绑定键 black 和 green。在此绑定情况下,绑定键为 orange 的消息发到队列 Q1,绑定键为 black、green 的消息发到队列 Q2,其他消息类型将被丢弃。

Fanout 模式本质就是 Direct 模式的一种特殊情况:如果 direct 类型的交换机绑定的多个队列 routing-key 都相同,也就类似 fanout 模式,跟广播差不多。

消费者 1(绑定 info 和 warning):

package mode2_WorkQueues.exchange.direct;

import com.rabbitmq.client.BuiltinExchangeType;
import com.rabbitmq.client.CancelCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;

// 消费者1
public class Consumer01 {
    private static final String QUEUE_NAME = "console";
    private static final String EXCHANGE_NAME = "direct_logs";

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 声明交换机
        channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.DIRECT);

        // 声明队列
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);

        // 绑定队列与交换机
        channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "info");
        channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "warning");

        // 消费消息
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("获得消息:" + new String(message.getBody()));
        };
        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };
        channel.basicConsume(QUEUE_NAME, true, deliverCallback, cancelCallback);
    }
}

消费者 2(绑定 error):

package mode2_WorkQueues.exchange.direct;

import com.rabbitmq.client.BuiltinExchangeType;
import com.rabbitmq.client.CancelCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;

// 消费者2
public class Consumer02 {
    private static final String QUEUE_NAME = "disk";
    private static final String EXCHANGE_NAME = "direct_logs";

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 声明交换机
        channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.DIRECT);

        // 声明队列
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);

        // 绑定队列与交换机
        channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "error");

        // 消费消息
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("获得消息:" + new String(message.getBody()));
        };
        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };
        channel.basicConsume(QUEUE_NAME, true, deliverCallback, cancelCallback);
    }
}

生产者:

package mode2_WorkQueues.exchange.direct;

import com.rabbitmq.client.BuiltinExchangeType;
import com.rabbitmq.client.Channel;
import java.util.HashMap;
import java.util.Map;

// 生产者
public class Producer {
    private static final String EXCHANGE_NAME = "direct_logs";

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 声明交换机
        channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.DIRECT);

        // 发送消息
        Map<String, String> messageMap = new HashMap<>();
        messageMap.put("info", "普通 info 信息");
        messageMap.put("warning", "警告 warning 信息");
        messageMap.put("error", "错误 error 信息");
        messageMap.put("debug", "调试 debug 信息");

        for (Map.Entry<String, String> mes : messageMap.entrySet()) {
            String routingKey = mes.getKey();
            String message = mes.getValue();
            channel.basicPublish(EXCHANGE_NAME, routingKey, null, message.getBytes());
            System.out.println("消息发送完毕" + message);
        }
    }
}

测试:分别启动 consumer01、consumer02、producer,可以看到消费者根据指定的 key 收到了对应的消息。

6.4 Topics 模式——主题模式

尽管 direct 交换机改进了系统,但它仍然存在局限性:direct 只做完全精确匹配,无法用通配符表达“某一类”的路由规则。比方说,当消息的 routing key 是带点分层的多级类型(如 error.user、error.order、warning.pay),而某个队列想接收所有以 error 开头的一类(即 error.*)时,direct 就办不到了——它只能逐个绑定精确的 routing key。于是引入 topic 类型,用 * 和 # 通配符实现模式匹配。

发送到 topic 交换机的消息,其 routing_key 不能随意写,必须是一个单词列表、以点号分隔,如 stock.usd.nyse、nyse.vmw、quick.orange.rabbit。单词列表最多不能超过 255 个字节。规则中有两个替换符需要特别注意:

  • 星号 *:可以代替一个单词;
  • 井号 #:可以替代零个或多个单词。

此外需要注意:当一个队列绑定键是 # 时,该队列将接收所有数据(类似 fanout);如果队列绑定键中没有 # 和 *,那么该队列绑定类型就是 direct。

案例演示:分析该模式消息的路由结果,然后通过代码验证。

  • Q1 → 绑定的是:中间带 orange、3 个单词的字符串(*.orange.*)。
  • Q2 → 绑定的是:最后一个单词是 rabbit 的 3 个单词(*.*.rabbit),以及第一个单词是 lazy 的多个单词(lazy.#)。

消息接收情况:

消息 接收队列
quick.orange.rabbit 被队列 Q1、Q2 接收到
lazy.orange.elephant 被队列 Q1、Q2 接收到
quick.orange.fox 被队列 Q1 接收到
lazy.brown.fox 被队列 Q2 接收到
lazy.pink.rabbit 虽然满足两个绑定,但只被队列 Q2 接收一次
quick.brown.fox 不匹配任何绑定,不会被任何队列接收到,会被丢弃
quick.orange.male.rabbit 四个单词,不匹配任何绑定,会被丢弃
lazy.orange.male.rabbit 四个单词,但匹配 Q2

生产者:

package mode2_WorkQueues.exchange.topic;

import com.rabbitmq.client.BuiltinExchangeType;
import com.rabbitmq.client.Channel;
import java.util.HashMap;
import java.util.Map;

public class Producer {
    private static final String EXCHANGE_NAME = "topic_logs";

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 声明交换机
        channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.TOPIC);

        // 发送消息
        Map<String, String> messageMap = new HashMap<>();
        messageMap.put("quick.orange.rabbit", "被队列 Q1Q2 接收到");
        messageMap.put("lazy.orange.elephant", "被队列 Q1Q2 接收到");
        messageMap.put("quick.orange.fox", "被队列 Q1 接收到");
        messageMap.put("lazy.brown.fox", "被队列 Q2 接收到");
        messageMap.put("lazy.pink.rabbit", "虽然满足两个绑定但只被队列 Q2 接收一次");
        messageMap.put("quick.brown.fox", "不匹配任何绑定不会被任何队列接收到会被丢弃");
        messageMap.put("quick.orange.male.rabbit", "是四个单词不匹配任何绑定会被丢弃");
        messageMap.put("lazy.orange.male.rabbit", "是四个单词但匹配 Q2");

        for (Map.Entry<String, String> mes : messageMap.entrySet()) {
            String message = mes.getValue();
            String routingKey = mes.getKey();
            channel.basicPublish(EXCHANGE_NAME, routingKey, null, message.getBytes());
            System.out.println("消息发送完毕" + message);
        }
    }
}

消费者 Consumer01(Q1,绑定 *.orange.*):

package mode2_WorkQueues.exchange.topic;

import com.rabbitmq.client.BuiltinExchangeType;
import com.rabbitmq.client.CancelCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;

public class Consumer01 {
    private static final String EXCHANGE_NAME = "topic_logs";
    private static final String QUEUE_NAME = "Q1";

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 声明交换机
        channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.TOPIC);

        // 创建Q1队列
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);

        // 绑定队列与交换机
        channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "*.orange.*");

        // 消费消息
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("获得消息:" + new String(message.getBody()));
        };
        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };
        channel.basicConsume(QUEUE_NAME, true, deliverCallback, cancelCallback);
    }
}

消费者 Consumer02(Q2,绑定 *.*.rabbit 和 lazy.#):

package mode2_WorkQueues.exchange.topic;

import com.rabbitmq.client.BuiltinExchangeType;
import com.rabbitmq.client.CancelCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;

public class Consumer02 {
    private static final String EXCHANGE_NAME = "topic_logs";
    private static final String QUEUE_NAME = "Q2";

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 声明交换机
        channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.TOPIC);

        // 创建Q2队列
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);

        // 绑定队列与交换机
        channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "*.*.rabbit");
        channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "lazy.#");

        // 消费消息
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("获得消息:" + new String(message.getBody()));
        };
        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };
        channel.basicConsume(QUEUE_NAME, true, deliverCallback, cancelCallback);
    }
}

运行测试,发送上面指定的那些消息后,观察两个消费者消费的消息,与前面假定的结果一致。


七、RabbitMQ 的一些机制

7.1 消息应答

默认情况下,RabbitMQ 一旦向消费者发送了一条消息后,便立即将该消息标记为删除。由于消费者处理一个消息可能需要一段时间,假如在处理消息中途消费者挂掉,我们会丢失其正在处理的消息以及后续发送给该消费者的消息。

为了保证消息在发送过程中不丢失,RabbitMQ 引入消息应答机制:消费者在接收消息并处理完该消息之后,才告知 RabbitMQ 可以把该消息删除了。

RabbitMQ 中消息应答方式有两种:自动应答(默认)、手动应答。

消息应答通过以下方法实现:

// 肯定确认
void basicAck(long deliveryTag, boolean multiple)

// 否定确认
void basicNack(long deliveryTag, boolean multiple, boolean requeue)

void basicReject(long deliveryTag, boolean requeue)

其中 deliveryTag 表示消息的标志,multiple 表示是否为批量应答(true 代表批量应答 channel 上未应答的消息:比如当前 channel 上有传送 tag 为 5、6、7、8 的消息,如果应答时 tag=8,则 5~8 这些还未应答的消息都会被确认;如果为 false,则只应答 tag=8 的消息)。

自动应答:消息发送后立即被认为传送成功,是 RabbitMQ 默认采用的应答方式。这种模式需要在高吞吐量和数据传输安全性方面做权衡——如果消息在被接收之前消费者的 connection 或 channel 关闭,消息就丢失了。此外,由于消费者没有对传递的消息数量进行限制,发送方可以传递过载的消息,可能造成消费者积压过多消息、内存耗尽,最终导致消费者线程被操作系统杀死。所以这种模式仅适用于消费者可以高效并以某种速率处理消息的情况。

手动应答:采用手动应答后,消息自动重新入队可以避免自动应答中消息丢失的情况。如果消费者因某些原因失去连接(通道已关闭、连接已关闭或 TCP 连接丢失),导致消息未发送 ACK 确认,RabbitMQ 将了解到消息未完全处理并对其重新排队。如果此时其他消费者可以处理,它将很快被重新分发给另一个消费者。这样,即使某个消费者偶尔死亡,也可以确保不丢失任何消息。

手动应答示例(只需对 Work Queues 示例代码作出简单修改)。生产者:

package mode2_WorkQueues.ack;

import com.rabbitmq.client.Channel;
import java.util.Scanner;

public class Producer {
    public static String QUEUE_NAME = "ack";

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 声明队列
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);

        // 发送消息
        Scanner scanner = new Scanner(System.in);
        while (scanner.hasNext()) {
            String message = scanner.next();
            channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
            System.out.println("消息发送完毕" + message);
        }
    }
}

消费者 1(模拟接收消息延迟 1s):

package mode2_WorkQueues.ack;

import com.rabbitmq.client.CancelCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;

// 测试手动应答
public class Consumer01 {
    public static String QUEUE_NAME = "ack";

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();
        System.out.println("consumer1收到消息时间较短");

        // 消费消息的回调
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            // 模拟接受消息的延迟 1s
            try {
                Thread.sleep(1000 * 1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("消息成功消费!内容为:" + new String(message.getBody()));
            // 手动应答:第一个参数表示消息标记 tag、第二个参数 false 表示不进行批量应答
            channel.basicAck(message.getEnvelope().getDeliveryTag(), false);
        };

        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };

        // 消费消息(第2个参数修改为 false 表示手动应答)
        channel.basicConsume(QUEUE_NAME, false, deliverCallback, cancelCallback);
    }
}

消费者 2(模拟接收消息延迟 10s):

package mode2_WorkQueues.ack;

import com.rabbitmq.client.CancelCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;

// 测试手动应答
public class Consumer02 {
    public static String QUEUE_NAME = "ack";

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();
        System.out.println("consumer2收到消息时间较长");

        // 消费消息的回调
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            // 模拟接受消息的延迟 10s
            try {
                Thread.sleep(1000 * 10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("消息成功消费!内容为:" + new String(message.getBody()));
            // 手动应答:第一个参数表示消息标记 tag、第二个参数 false 表示不进行批量应答
            channel.basicAck(message.getEnvelope().getDeliveryTag(), false);
        };

        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };

        // 消费消息(第2个参数修改为 false 表示手动应答)
        channel.basicConsume(QUEUE_NAME, false, deliverCallback, cancelCallback);
    }
}

测试:先启动生产者,再启动两个消费者,然后通过 producer 发送 4 条消息。由于 consumer01 消费时间只有 1s,会立刻根据轮询规则收到第 1、3 条;第 2、4 条按轮询机制由 consumer02 消费,10s 后正常收到。但此时若停止 Consumer02 线程,过一会再看 consumer01,会发现它消费了第 2、4 条消息——这就是手动应答的消息重新入队机制,避免了 consumer02 停机而造成原本分配给它消息的丢失。

7.2 持久化

前面通过手动应答处理了消息丢失的情况,但如何保障 RabbitMQ 服务停掉以后,生产者发送过来的消息不丢失?默认情况下 RabbitMQ 退出或崩溃时会清空队列和消息,除非告知它不要这样做。确保消息不丢失需要做两件事:将队列和消息都标记为持久化。

队列持久化:之前创建的队列都是非持久化的,RabbitMQ 重启后队列就会被删除。要实现队列持久化,需要在声明队列时把 durable 参数设置为 true。

注意:如果之前声明的队列不是持久化的,需要先把原先队列删除,或者重新创建一个持久化的队列,否则会报错。

消息持久化:要想让消息持久化,需要在消息生产者修改代码,添加 MessageProperties.PERSISTENT_TEXT_PLAIN 属性。

将消息标记为持久化并不能完全保证不丢失消息。尽管它告诉 RabbitMQ 将消息保存到磁盘,但依然存在消息刚准备存储到磁盘、还没存完、消息还在缓存中的一个间隔点,此时并未真正写入磁盘。持久性保证并不强,但对简单任务队列而言已绰绰有余。如果需要更强有力的持久化策略,参考后面“发布确认”章节。

7.3 不公平分发

前面了解到 RabbitMQ 默认采用轮询分发模式,但在某些场景下这种策略并不好。比如两个消费者处理任务,consumer01 处理速度非常快,consumer02 很慢,若仍采用轮询分发,处理快的 consumer01 会有很大一部分时间处于空闲状态,而 consumer02 一直在干活。RabbitMQ 并不知道这种情况,依然很公平地分发。

为避免这种情况,可以设置参数 channel.basicQos(1),意思是每个消费者只能处理完当前消息才能接受新的消息。如果当前消息没处理完或没应答,新的消息先别分配给我;RabbitMQ 就会把任务分配给没那么忙的空闲消费者。如果所有消费者都没完成任务、队列还在不停添加新任务,队列可能会被撑满,此时只能添加新消费者或改变存储任务策略。

7.4 预取值

消息的发送本身是异步的,所以在任何时候 channel 上肯定不止一个消息;另外消费者手动确认本质也是异步的,因此存在一个未确认的消息缓冲区,希望开发人员能限制此缓冲区的大小,避免缓冲区里无限制地累积未确认消息。

这时可以通过 basic.qos 方法设置“预取计数”值。该值定义通道上允许的未确认消息的最大数量。一旦数量达到配置值,RabbitMQ 将停止在通道上传递更多消息,除非至少有一个未处理的消息被确认。假设通道上有未确认消息 5、6、7、8,预取计数设为 4,此时 RabbitMQ 不会再传递任何消息,除非至少有一个未应答消息被 ack。比如 tag=6 的消息刚被确认 ACK,RabbitMQ 会感知并再发送一条消息。

消息应答和 QoS 预取值对吞吐量有重大影响:通常增加预取会提高消息传递速度,但自动应答传输速率最佳的同时,已传递但未处理的消息数量也会增加,从而增加消费者 RAM 消耗。100~300 范围内的预取值通常可提供最佳吞吐量且风险不大;预取值为 1 是最保守的,但吞吐量很低,尤其在消费者连接延迟严重的环境中。对大多数应用来说,稍微高一点的值是最佳选择。

代码示例:编写两个消费者 consumer01 和 consumer02,预取值分别为 5 和 2,再编写一个生产者发送 7 条消息。

消费者 1(预取值 5,模拟延迟 1s):

package mode2_WorkQueues.ack;

import com.rabbitmq.client.CancelCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;

// 测试手动应答
public class Consumer01 {
    public static String QUEUE_NAME = "prefetch";

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();
        System.out.println("consumer1收到消息时间较短");

        // 设置预取值5
        channel.basicQos(5);

        // 消费消息(第2个参数修改为 false 表示手动应答)
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            // 模拟接受消息的延迟 1s
            try {
                Thread.sleep(1000 * 1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("消息成功消费!内容为:" + new String(message.getBody()));
            // 手动应答
            channel.basicAck(message.getEnvelope().getDeliveryTag(), false);
        };
        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };
        channel.basicConsume(QUEUE_NAME, false, deliverCallback, cancelCallback);
    }
}

消费者 2(预取值 2,模拟延迟 10s):

package mode2_WorkQueues.ack;

import com.rabbitmq.client.CancelCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;

// 测试手动应答
public class Consumer02 {
    public static String QUEUE_NAME = "prefetch";

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();
        System.out.println("consumer2收到消息时间较长");

        // 设置预取值2
        channel.basicQos(2);

        // 消费消息(第2个参数修改为 false 表示手动应答)
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            // 模拟接受消息的延迟 10s
            try {
                Thread.sleep(1000 * 10);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("消息成功消费!内容为:" + new String(message.getBody()));
            // 手动应答
            channel.basicAck(message.getEnvelope().getDeliveryTag(), false);
        };
        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };
        channel.basicConsume(QUEUE_NAME, false, deliverCallback, cancelCallback);
    }
}

生产者(发送 10 条消息):

package mode2_WorkQueues.ack;

import com.rabbitmq.client.Channel;

public class Producer {
    public static String QUEUE_NAME = "prefetch";

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 发送消息
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);
        for (int i = 0; i < 10; i++) {
            String message = i + "";
            channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
            System.out.println("消息发送完毕" + message);
        }
    }
}

启动消费者并发送 10 条消息:前 7 条按预取值的设定应分 5 条给 consumer01、2 条给 consumer02。由于 consumer01 处理速度较快,处理完 5 条时 consumer02 还未处理完第一条,因此后面的 8、9、10 条消息都会分配给 consumer01 消费。对比结果与我们设想的一致。

7.5 发布确认

生产者将信道设置成 confirm 模式,一旦信道进入 confirm 模式,所有在该信道发布的消息都会被指派一个唯一的 ID(从 1 开始)。一旦消息被投递到所有匹配的队列后,broker 就会发送一个确认给生产者(包含消息的唯一 ID),使生产者知道消息已正确到达目的队列。如果消息和队列是持久化的,确认消息会在消息写入磁盘之后发出;broker 回传给生产者的确认消息中 delivery-tag 域包含确认消息的序列号,此外 broker 也可以设置 basic.ack 的 multiple 域,表示到这个序列号之前的所有消息都已处理。

confirm 模式最大的好处在于它是异步的:一旦发布一条消息,生产者应用就可以在等信道返回确认的同时继续发送下一条消息;当消息最终确认后,生产者通过回调方法处理该确认消息。如果 RabbitMQ 因内部错误导致消息丢失,会发送一条 nack 消息,生产者同样可以在回调方法中处理。

发布确认默认没有开启,需要调用方法 confirmSelect:

// 开启发布确认
channel.confirmSelect();

发布确认机制有三种策略:单个确认发布、批量确认发布、异步确认发布。前两者是同步确认方式(发布一个/一批消息后只有被确认才能继续发布),后者是异步确认方式(只管发布,是否确认通过回调函数接收)。

单个确认发布:一种简单的同步确认方式,发布一个消息之后只有它被确认,后续消息才能继续发布。waitForConfirmsOrDie(long) 方法只有在消息被确认时才返回,如果指定时间内消息没被确认则抛出异常。这种方式的缺点是发布速度特别慢——没被确认的消息会阻塞所有后续消息的发布,最多提供每秒不超过数百条消息的吞吐量。

package mode2_WorkQueues.confirm;

import com.rabbitmq.client.Channel;

// 测试单个确认发布
public class SingleProducer {
    public static String QUEUE_NAME = "confirm";
    public static int MESSAGE_COUNT = 10;

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 开启发布确认
        channel.confirmSelect();

        // 开始时间
        long begin = System.currentTimeMillis();

        // 批量发送10条消息
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);
        for (int i = 0; i < MESSAGE_COUNT; i++) {
            String message = i + "";
            channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
            // 单个消息发送完毕马上确认
            boolean flag = channel.waitForConfirms();
            // 服务端返回 false 或超时时间内未返回,生产者可以消息重发
            if (flag) System.out.println("消息" + i + "发送成功");
        }

        // 结束时间
        long end = System.currentTimeMillis();
        System.out.println("发布" + MESSAGE_COUNT + "条单独确认消息耗时:" + (end - begin) + "ms");
    }
}

批量确认发布:先发布一批消息然后一起确认,可以极大提高吞吐量。缺点是一旦发生故障,不知道是哪条消息出现问题,必须把整个批处理保存在内存中记录重要信息后重新发布。该方案仍是同步的,同样阻塞消息发布。

package mode2_WorkQueues.confirm;

import com.rabbitmq.client.Channel;

// 测试批量确认发布
public class BatchProducer {
    public static String QUEUE_NAME = "confirm";
    public static int MESSAGE_COUNT = 10;

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 开启发布确认
        channel.confirmSelect();

        // 确定批量大小
        int batchSize = 5;
        // 未确认消息个数
        int noConfirmMesNum = 0;

        // 开始时间
        long begin = System.currentTimeMillis();

        // 发送消息
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);
        for (int i = 0; i < MESSAGE_COUNT; i++) {
            String message = i + "";
            channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
            noConfirmMesNum++;

            // 每5个一批发布确认
            if (noConfirmMesNum == batchSize) {
                boolean flag = channel.waitForConfirms();
                if (flag) System.out.println("消息" + i + "与之前的" + batchSize + "条发送成功");
                noConfirmMesNum = 0;
            }
        }

        // 为了确保还有剩余没有确认消息 再次确认
        if (noConfirmMesNum > 0)
            channel.waitForConfirms();

        // 结束时间
        long end = System.currentTimeMillis();
        System.out.println("发布" + MESSAGE_COUNT + "条批量确认消息耗时:" + (end - begin) + "ms");
    }
}

异步确认发布:编程逻辑比前两者复杂,但性价比最高,无论是可靠性还是效率都好。它利用回调函数达到消息可靠传递。代码中,把未确认的消息放到一个基于内存、能被发布线程访问的队列(如 ConcurrentLinkedQueue 或线程安全的有序表)在 confirm callbacks 与发布线程之间传递消息。

package mode2_WorkQueues.confirm;

import com.rabbitmq.client.Channel;
import com.rabbitmq.client.ConfirmCallback;
import java.util.concurrent.ConcurrentNavigableMap;
import java.util.concurrent.ConcurrentSkipListMap;

public class AsyncProducer {
    public static String QUEUE_NAME = "confirm";
    public static int MESSAGE_COUNT = 10;

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 开启发布确认
        channel.confirmSelect();

        // 开始时间
        long begin = System.currentTimeMillis();

        // 准备一个线程安全有序的哈希表,用于存放消息的序号以及内容
        ConcurrentSkipListMap<Long, String> concurrentSkipListMap = new ConcurrentSkipListMap<>();

        // 消息确认成功回调函数(第一个参数表示消息标志,第二个参数表示是否为批量确认)
        ConfirmCallback ackCallback = (long deliveryTag, boolean multiple) -> {
            // 删除掉已经确认的消息,剩下就是未确认的消息
            if (multiple) { // 如果是批量 则批量删除
                ConcurrentNavigableMap<Long, String> confirmed = concurrentSkipListMap.headMap(deliveryTag);
                confirmed.clear();
            } else concurrentSkipListMap.remove(deliveryTag); // 如果不是批量发送 则删除当前消息
            System.out.println("消息:" + deliveryTag + "已确认发布");
        };

        // 消息确认失败回调函数
        ConfirmCallback nackCallback = (long deliveryTag, boolean multiple) -> {
            String message = concurrentSkipListMap.get(deliveryTag);
            System.out.println("未确认的消息为:" + message);
        };

        // 首先准备异步消息监听器,监听哪些消息成功了,哪些消息失败了
        channel.addConfirmListener(ackCallback, nackCallback); // 异步通知

        // 发送消息
        for (int i = 0; i < MESSAGE_COUNT; i++) {
            String message = "消息" + i;
            channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
            // 在此记录下所有要发送的消息
            concurrentSkipListMap.put(channel.getNextPublishSeqNo(), message);
        }

        // 结束时间
        long end = System.currentTimeMillis();
        System.out.println("发布" + MESSAGE_COUNT + "条异步确认消息耗时:" + (end - begin) + "ms");
    }
}

三种发布确认方式对比

方式 特点 适用场景
单个确认发布 同步等待确认,简单,但吞吐量非常有限 消息量极小的场景
批量确认发布 批量同步等待确认,简单,合理的吞吐量,但出现问题很难推断是哪条消息 对吞吐有一定要求、可容忍批量重发
异步确认发布 最佳性能和资源使用,出错时可很好控制,但实现稍微难些 高性能、可靠性要求高的生产环境

7.6 死信队列

死信队列的概念

死信就是无法被消费的消息。一般来说,producer 将消息投递到 broker 或直接到 queue,consumer 从 queue 取出消息进行消费,但某些时候由于特定原因导致 queue 中某些消息无法被消费,这样的消息如果没有后续处理就变成了死信;有死信自然就有死信队列。

应用场景:为了保证订单业务的消息数据不丢失,需要使用死信队列机制——当消息消费发生异常时,将消息投入死信队列;还有如用户在商城下单成功并点击去支付后,指定时间未支付则自动失效。

死信的原因:

  • 消息 TTL 过期;
  • 队列达到最大长度(队列满了无法再添加数据到 MQ 中);
  • 消息被拒绝(basic.reject 或 basic.nack)且 requeue=false。

下面通过代码模仿三种导致死信的效果:消息被拒绝、消息 TTL 过期、队列达到最大长度。

① 消息 TTL 过期

生产者 Producer:

package mode2_WorkQueues.dead;

import com.rabbitmq.client.AMQP;
import com.rabbitmq.client.BuiltinExchangeType;
import com.rabbitmq.client.Channel;

public class Producer {
    private static final String NORMAL_EXCHANGE = "normal_exchange"; // 普通交换机名称

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 声明普通交换机
        channel.exchangeDeclare(NORMAL_EXCHANGE, BuiltinExchangeType.DIRECT);

        // 设置消息到 TTL 时间为10s=10000ms
        AMQP.BasicProperties properties = new AMQP.BasicProperties().builder().expiration("10000").build();

        // 发送10条消息
        for (int i = 0; i < 10; i++) {
            String message = i + "";
            channel.basicPublish(NORMAL_EXCHANGE, "zhangsan", properties, message.getBytes());
            System.out.println("生产者发送消息:" + message);
        }
    }
}

消费者 Consumer01(声明普通队列并设置死信交换机及 routing-key):

package mode2_WorkQueues.dead;

import com.rabbitmq.client.BuiltinExchangeType;
import com.rabbitmq.client.CancelCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;
import utils.RabbitMqUtils;

import java.util.HashMap;
import java.util.Map;

public class Consumer01 {
    private static final String NORMAL_EXCHANGE = "normal_exchange"; // 普通交换机名称
    private static final String DEAD_EXCHANGE = "dead_exchange"; // 死信交换机名称
    private static final String NORMAL_QUEUE = "normal_queue"; // 普通队列名称
    private static final String DEAD_QUEUE = "dead_queue"; // 死信队列名称

    public static void main(String[] args) throws Exception {
        Channel channel = RabbitMqUtils.getChannel();

        // 声明普通和死信交换机(类型都为 DIRECT)
        channel.exchangeDeclare(NORMAL_EXCHANGE, BuiltinExchangeType.DIRECT);
        channel.exchangeDeclare(DEAD_EXCHANGE, BuiltinExchangeType.DIRECT);

        // 声明普通和死信队列(普通队列需要传递参数设置死信交换机及其对应的路由 key)
        Map<String, Object> arguments = new HashMap<>();
        arguments.put("x-dead-letter-exchange", DEAD_EXCHANGE); // 设置死信交互机
        arguments.put("x-dead-letter-routing-key", "lisi"); // 设置与死信交换机间的 routing-key
        channel.queueDeclare(NORMAL_QUEUE, false, false, false, arguments);
        channel.queueDeclare(DEAD_QUEUE, false, false, false, null);

        // 绑定队列与交换机,设置其间的路由 key
        channel.queueBind(NORMAL_QUEUE, NORMAL_EXCHANGE, "zhangsan");
        channel.queueBind(DEAD_QUEUE, DEAD_EXCHANGE, "lisi");

        // 消费消息
        System.out.println("Consumer01等待接收普通队列到消息......");
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("Consumer01接收到普通队列的消息:" + new String(message.getBody()));
        };
        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };
        channel.basicConsume(NORMAL_QUEUE, deliverCallback, cancelCallback);
    }
}

消费者 Consumer02(消费死信队列):

package mode2_WorkQueues.dead;

import com.rabbitmq.client.BuiltinExchangeType;
import com.rabbitmq.client.CancelCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;
import utils.RabbitMqUtils;

public class Consumer02 {
    private static final String DEAD_EXCHANGE = "dead_exchange"; // 死信交换机名称
    private static final String DEAD_QUEUE = "dead_queue"; // 死信队列名称

    public static void main(String[] args) throws Exception {
        Channel channel = RabbitMqUtils.getChannel();

        // 声明死信交换机(类型都为 DIRECT)
        channel.exchangeDeclare(DEAD_EXCHANGE, BuiltinExchangeType.DIRECT);

        // 声明死信队列
        channel.queueDeclare(DEAD_QUEUE, false, false, false, null);

        // 绑定队列与交换机,设置其间的路由 key
        channel.queueBind(DEAD_QUEUE, DEAD_EXCHANGE, "lisi");

        // 消费消息
        System.out.println("Consumer02等待接受死信队列的消息......");
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("Consumer02接收到死信队列的消息:" + new String(message.getBody()));
        };
        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };
        channel.basicConsume(DEAD_QUEUE, deliverCallback, cancelCallback);
    }
}

测试:先启动 Consumer01 创建交换机和队列,然后关闭它模拟接收不到消息。再启动生产者发送 10 条消息,可以看到 10 条消息先进入 normal-queue 队列,10s 后消息过期进入 dead-queue 死信队列。最后启动 Consumer02 从死信队列消费消息。

② 队列达到最大长度

修改 Producer 代码,去掉消息的 TTL 过期属性:

package mode2_WorkQueues.dead;

import com.rabbitmq.client.BuiltinExchangeType;
import com.rabbitmq.client.Channel;

public class Producer {
    private static final String NORMAL_EXCHANGE = "normal_exchange"; // 普通交换机名称

    public static void main(String[] args) throws Exception {
        Channel channel = utils.RabbitMqUtils.getChannel();

        // 声明普通交换机
        channel.exchangeDeclare(NORMAL_EXCHANGE, BuiltinExchangeType.DIRECT);

        // 发送10条消息
        for (int i = 0; i < 10; i++) {
            String message = i + "";
            channel.basicPublish(NORMAL_EXCHANGE, "zhangsan", null, message.getBytes());
            System.out.println("生产者发送消息:" + message);
        }
    }
}

修改 Consumer01,添加设置正常队列的长度限制为 6(注意:需先把原先的 dead-queue 和 normal-queue 队列删除,因为参数改变了):

package mode2_WorkQueues.dead;

import com.rabbitmq.client.BuiltinExchangeType;
import com.rabbitmq.client.CancelCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;
import utils.RabbitMqUtils;

import java.util.HashMap;
import java.util.Map;

public class Consumer01 {
    private static final String NORMAL_EXCHANGE = "normal_exchange"; // 普通交换机名称
    private static final String DEAD_EXCHANGE = "dead_exchange"; // 死信交换机名称
    private static final String NORMAL_QUEUE = "normal_queue"; // 普通队列名称
    private static final String DEAD_QUEUE = "dead_queue"; // 死信队列名称

    public static void main(String[] args) throws Exception {
        Channel channel = RabbitMqUtils.getChannel();

        // 声明普通和死信交换机(类型都为 DIRECT)
        channel.exchangeDeclare(NORMAL_EXCHANGE, BuiltinExchangeType.DIRECT);
        channel.exchangeDeclare(DEAD_EXCHANGE, BuiltinExchangeType.DIRECT);

        // 声明普通和死信队列(普通队列需要传递参数设置死信交换机及其对应的路由 key)
        Map<String, Object> arguments = new HashMap<>();
        arguments.put("x-dead-letter-exchange", DEAD_EXCHANGE); // 设置死信交互机
        arguments.put("x-dead-letter-routing-key", "lisi"); // 设置与死信交换机间的 routing-key
        arguments.put("x-max-length", 6); // 设置正常队列长度为6
        channel.queueDeclare(NORMAL_QUEUE, false, false, false, arguments);
        channel.queueDeclare(DEAD_QUEUE, false, false, false, null);

        // 绑定队列与交换机,设置其间的路由 key
        channel.queueBind(NORMAL_QUEUE, NORMAL_EXCHANGE, "zhangsan");
        channel.queueBind(DEAD_QUEUE, DEAD_EXCHANGE, "lisi");

        // 消费消息
        System.out.println("Consumer01等待接收普通队列到消息......");
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("Consumer01接收到普通队列的消息:" + new String(message.getBody()));
        };
        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };
        channel.basicConsume(NORMAL_QUEUE, deliverCallback, cancelCallback);
    }
}

Consumer02 代码不变。测试:先启动 Consumer01 创建更改后的队列,然后关闭它模拟接收不到消息。再启动生产者发送 10 条消息,可以看到 6 条消息在 normal-queue 队列中,4 条消息进入 dead-queue 死信队列。然后启动 Consumer02 可消费到这 4 条消息。

③ 消息被拒绝

Producer 代码不变(发送 10 条消息)。Consumer01 去掉普通队列长度的限制,开启自动应答机制,并在发送消息的回调方法中拒收内容为 "5" 的消息,其他消息正常接收(注意:需先删除原先的 dead-queue 和 normal-queue 队列,因为参数改变):

package mode2_WorkQueues.dead;

import com.rabbitmq.client.BuiltinExchangeType;
import com.rabbitmq.client.CancelCallback;
import com.rabbitmq.client.Channel;
import com.rabbitmq.client.DeliverCallback;
import utils.RabbitMqUtils;

import java.util.HashMap;
import java.util.Map;

public class Consumer01 {
    private static final String NORMAL_EXCHANGE = "normal_exchange"; // 普通交换机名称
    private static final String DEAD_EXCHANGE = "dead_exchange"; // 死信交换机名称
    private static final String NORMAL_QUEUE = "normal_queue"; // 普通队列名称
    private static final String DEAD_QUEUE = "dead_queue"; // 死信队列名称

    public static void main(String[] args) throws Exception {
        Channel channel = RabbitMqUtils.getChannel();

        // 声明普通和死信交换机(类型都为 DIRECT)
        channel.exchangeDeclare(NORMAL_EXCHANGE, BuiltinExchangeType.DIRECT);
        channel.exchangeDeclare(DEAD_EXCHANGE, BuiltinExchangeType.DIRECT);

        // 声明普通和死信队列(普通队列需要传递参数设置死信交换机及其对应的路由 key)
        Map<String, Object> arguments = new HashMap<>();
        arguments.put("x-dead-letter-exchange", DEAD_EXCHANGE); // 设置死信交互机
        arguments.put("x-dead-letter-routing-key", "lisi"); // 设置与死信交换机间的 routing-key
        channel.queueDeclare(NORMAL_QUEUE, false, false, false, arguments);
        channel.queueDeclare(DEAD_QUEUE, false, false, false, null);

        // 绑定队列与交换机,设置其间的路由 key
        channel.queueBind(NORMAL_QUEUE, NORMAL_EXCHANGE, "zhangsan");
        channel.queueBind(DEAD_QUEUE, DEAD_EXCHANGE, "lisi");

        // 消费消息
        System.out.println("Consumer01等待接收普通队列到消息......");
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            String mes = new String(message.getBody(), "UTF-8");
            if (mes.equals("5")) {
                System.out.println("Consumer01接收到普通队列的消息" + mes + "并拒收该消息");
                // 第二个参数 requeue 设置为 false,代表拒绝重新入队,也就是该队列如果配置了死信交换机将发送到死信队列中
                channel.basicReject(message.getEnvelope().getDeliveryTag(), false);
            } else {
                System.out.println("Consumer01接收到普通队列的消息:" + new String(message.getBody()));
                channel.basicAck(message.getEnvelope().getDeliveryTag(), false);
            }
        };
        CancelCallback cancelCallback = (consumerTag) -> {
            System.out.println("消息消费被中断");
        };

        // 开启手动应答
        boolean autoAck = false;
        channel.basicConsume(NORMAL_QUEUE, autoAck, deliverCallback, cancelCallback);
    }
}

Consumer02 代码不变。测试:先启动 Consumer01,再启动 Consumer02,最后启动 Producer。根据结果可以看到,Consumer01 拒绝了消息 5,消息 5 进入死信队列被 Consumer02 消费。

7.7 延迟队列

概念:延迟队列,队列内部是有序的,最重要的特性体现在它的延时属性上——延迟队列中的元素是希望在指定时间到了以后取出和处理。简单来说,延迟队列就是用来存放需要在指定时间被处理的元素的队列。

使用场景:

  • 订单在十分钟之内未支付则自动取消;
  • 新创建的店铺,如果在十天内都没有上传过商品,则自动发送消息提醒;
  • 用户注册成功后,如果三天内没有登录则进行短信提醒;
  • 用户发起退款,如果三天内没有得到处理则通知相关运营人员;
  • 预定会议后,需要在预定的时间点前十分钟通知各个与会人员参加会议。

这些场景都有共同特点:需要在某个事件发生之后或之前的指定时间点完成某一项任务。看起来似乎用定时任务轮询数据也能做,但如果数据量较小、时间宽松(如“账单一周内未支付自动结算”),每晚跑定时任务确实可行;但对于数据量大、时效性强的场景(如“订单十分钟内未支付则关闭”,活动期间未支付订单可达百万甚至千万级),仍用轮询显然不可取——很可能一秒内无法完成所有订单检查,还会给数据库带来很大压力、性能低下。

RabbitMQ 中的 TTL:TTL 是 RabbitMQ 中一个消息或队列的属性,表明一条消息或该队列中所有消息的最大存活时间(单位毫秒)。如果一条消息设置了 TTL 属性,或进入了设置 TTL 属性的队列,那么这条消息在 TTL 时间内没被消费就会成为“死信”。如果同时配置了队列的 TTL 和消息的 TTL,较小的那个值会被使用。有两种方式设置 TTL:

  1. 消息设置 TTL:针对每条消息设置。
  2. 队列设置 TTL:创建队列时设置 x-message-ttl 属性。

如果设置了队列的 TTL 属性,一旦消息过期就会被队列丢弃(若配置了死信队列则丢到死信队列)。而如果仅设置消息的 TTL,即使消息过期也不一定会被马上丢弃,因为消息是否过期是在即将投递到消费者之前判定的;如果当前队列有严重积压,已过期的消息也许还能存活较长时间。还需要注意:不设置 TTL 表示消息永不过期;将 TTL 设为 0 表示除非此时可以直接投递该消息到消费者,否则该消息将被丢弃。

延迟队列的实现思路:延迟队列,不就是想让消息延迟多久被处理吗——TTL 能让消息在延迟多久之后成为死信;而成为死信的消息都会被投递到死信队列里,这样只需要消费者一直消费死信队列里的消息即可,因为里面的消息都是希望被立即处理的消息。至此,利用 RabbitMQ 实现延时队列的两大要素(死信 + TTL)已集齐。

SpringBoot 整合 RabbitMQ 实现延迟队列

在根目录新建一个 springboot module,导入依赖:

<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <!--RabbitMQ 依赖-->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-amqp</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-web</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>com.alibaba</groupId>
        <artifactId>fastjson</artifactId>
        <version>1.2.47</version>
    </dependency>
    <dependency>
        <groupId>org.projectlombok</groupId>
        <artifactId>lombok</artifactId>
    </dependency>
    <!--swagger-->
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger2</artifactId>
        <version>2.9.2</version>
    </dependency>
    <dependency>
        <groupId>io.springfox</groupId>
        <artifactId>springfox-swagger-ui</artifactId>
        <version>2.9.2</version>
    </dependency>
    <!--RabbitMQ 测试依赖-->
    <dependency>
        <groupId>org.springframework.amqp</groupId>
        <artifactId>spring-rabbit-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

修改 src/main/resources/application.properties,添加 RabbitMQ 配置(host、username、password 换成自己的):

spring.rabbitmq.host=82.156.9.173
spring.rabbitmq.port=5672
spring.rabbitmq.username=zsr
spring.rabbitmq.password=123456

方式一:设置队列 TTL + 死信实现消息延迟

代码架构:两个 direct 类型交换机 X、Y,其中 Y 为死信交换机;还有三个队列 QA、QB、QD,QA 和 QB 为普通队列(QA 中消息 TTL 为 10s,QB 中消息 TTL 为 40s),QD 为死信队列。

  1. 编写初始化类(springboot 主启动类同级目录新建 init 包,新建 QueueTTLDelay.java):
package com.zsr.springbootrabbitmq.init;

import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;

@Configuration
public class QueueTTLDelay {
    public static final String X_EXCHANGE = "X";
    public static final String QUEUE_A = "QA";
    public static final String QUEUE_B = "QB";
    public static final String Y_DEAD_LETTER_EXCHANGE = "Y";
    public static final String DEAD_LETTER_QUEUE = "QD";

    // 声明交换机X
    @Bean("xExchange")
    public DirectExchange xExchange() {
        return new DirectExchange(X_EXCHANGE);
    }

    // 声明死信交换机Y
    @Bean("yExchange")
    public DirectExchange yExchange() {
        return new DirectExchange(Y_DEAD_LETTER_EXCHANGE);
    }

    // 声明队列QA,设置队列的ttl为10s并绑定死信交换机
    @Bean("queueA")
    public Queue queueA() {
        Map<String, Object> arguments = new HashMap<>();
        arguments.put("x-dead-letter-exchange", Y_DEAD_LETTER_EXCHANGE); // 声明当前队列绑定的死信交换机
        arguments.put("x-dead-letter-routing-key", "YD"); // 声明当前队列的死信 routing-key
        arguments.put("x-message-ttl", 10000); // 声明队列的 TTL
        return QueueBuilder.durable(QUEUE_A).withArguments(arguments).build();
    }

    // 声明队列QB,设置队列的ttl为40s并绑定死信交换机
    @Bean("queueB")
    public Queue queueB() {
        Map<String, Object> arguments = new HashMap<>();
        arguments.put("x-dead-letter-exchange", Y_DEAD_LETTER_EXCHANGE);
        arguments.put("x-dead-letter-routing-key", "YD");
        arguments.put("x-message-ttl", 40000);
        return QueueBuilder.durable(QUEUE_B).withArguments(arguments).build();
    }

    // 声明死信队列QD
    @Bean("queueD")
    public Queue queueD() {
        return new Queue(DEAD_LETTER_QUEUE);
    }

    // 死信队列QD绑定死信交换机Y
    @Bean
    public Binding deadLetterBindingQAD(@Qualifier("queueD") Queue queueD, @Qualifier("yExchange") DirectExchange yExchange) {
        return BindingBuilder.bind(queueD).to(yExchange).with("YD");
    }

    // 队列A绑定X交换机
    @Bean
    public Binding queueABindExchangeX(@Qualifier("queueA") Queue queueA, @Qualifier("xExchange") DirectExchange xExchange) {
        return BindingBuilder.bind(queueA).to(xExchange).with("XA");
    }

    // 队列B绑定X交换机
    @Bean
    public Binding queueBBindExchangeX(@Qualifier("queueB") Queue queueB, @Qualifier("xExchange") DirectExchange xExchange) {
        return BindingBuilder.bind(queueB).to(xExchange).with("XB");
    }
}
  1. 编写消息生产者(新建 producer 包,新建 SendMessageController.java):
package com.zsr.springbootrabbitmq.Producer;

import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import java.util.Date;

@RestController
@Slf4j
public class SendMessageController {
    @Autowired
    private RabbitTemplate rabbitTemplate;

    @GetMapping("sendMsg/{message}")
    public void sendMsg(@PathVariable String message) {
        log.info("当前时间:{},发送一条信息给两个TTL队列:{}", new Date(), message);
        rabbitTemplate.convertAndSend("X", "XA", "消息来自ttl=10s的队列" + message);
        rabbitTemplate.convertAndSend("X", "XB", "消息来自ttl=40s的队列" + message);
    }
}
  1. 编写消息消费者(新建 Consumer 包,新建 MessageConsumer.java):
package com.zsr.springbootrabbitmq.consumer;

import com.rabbitmq.client.Channel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.util.Date;

@Slf4j
@Component
public class MessageConsumer {
    @RabbitListener(queues = "QD")
    public void receiveD(Message message, Channel channel) throws IOException {
        String msg = new String(message.getBody());
        log.info("当前时间:{},收到死信队列信息{}", new Date().toString(), msg);
    }
}
  1. 运行测试:启动主启动类,访问 http://localhost:8080/sendMsg/hello 发送 hello 消息,观察控制台日志,可以看到 10s 和 40s 后消费者从死信队列消费到了 hello 消息,达到延时队列效果。

方式二:设置消息 TTL + 死信实现消息延迟

以上延时队列实现目前只有 10s 和 40s 两个时间选项,如果需要更灵活的时间(如预定会议室提前通知),增加无数个固定 TTL 队列显然不现实。因此做出优化:新增一个队列 QC,该队列不设置 TTL 时间,通过指定消息的 TTL 来实现延迟。

在 init 包下新建 MessageTTLDelay.java 初始化 QC 队列并与死信交换机 Y 绑定:

package com.zsr.springbootrabbitmq.init;

import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;

@Configuration
public class MessageTTLDelay {
    public static final String Y_DEAD_LETTER_EXCHANGE = "Y";
    public static final String QUEUE_C = "QC";

    // 声明队列C,绑定死信交换机
    @Bean("queueC")
    public Queue queueB() {
        Map<String, Object> args = new HashMap<>(3);
        args.put("x-dead-letter-exchange", Y_DEAD_LETTER_EXCHANGE); // 声明当前队列绑定的死信交换机
        args.put("x-dead-letter-routing-key", "YD"); // 声明当前队列的死信路由 key
        // 没有声明TTL属性
        return QueueBuilder.durable(QUEUE_C).withArguments(args).build();
    }

    // 绑定队列C和X交换机
    @Bean
    public Binding queueCBindingX(@Qualifier("queueC") Queue queueC, @Qualifier("xExchange") DirectExchange xExchange) {
        return BindingBuilder.bind(queueC).to(xExchange).with("XC");
    }
}

在 SendMessageController.java 中新增方法,用于发送指定延时时间的消息:

@GetMapping("sendExpirationMsg/{message}/{ttlTime}")
public void sendMsg(@PathVariable String message, @PathVariable String ttlTime) {
    rabbitTemplate.convertAndSend("X", "XC", message, correlationData -> {
        correlationData.getMessageProperties().setExpiration(ttlTime);
        return correlationData;
    });
    log.info("当前时间:{},发送一条时长{}毫秒TTL信息给队列C:{}", new Date(), ttlTime, message);
}

测试,分别访问:

http://localhost:9090/sendExpirationMsg/你好1/10000(延迟10s)
http://localhost:9090/sendExpirationMsg/你好2/20000(延迟20s)

观察日志,可以看到 10s 和 20s 后在死信队列中分别收到消息 1 和消息 2。

注意:这种方式存在一个缺陷。因为 RabbitMQ 只会检查第一个消息是否过期,如果第一个消息延时时长很长、第二个很短,第二个消息并不会优先执行(延迟队列是排队的,先来后到)。比如请求以下两个 URL:

http://localhost:9090/sendExpirationMsg/你好3/10000(延迟10s)
http://localhost:9090/sendExpirationMsg/你好4/2000(延迟2s)

结果会发现,你好4明明只需要 2s 却等了 20s。因此如果无法实现在消息粒度上的 TTL 并使其在设定时间及时死亡,就无法设计成通用延时队列,需要通过 RabbitMQ 插件解决。

方式三:通过 RabbitMQ 插件实现延迟队列

  1. 下载延迟队列插件:下载 rabbitmq_delayed_message_exchange 插件(下载地址: https://www.rabbitmq.com/community-plugins.html ,点击 Releases 选择版本),解压放置到 RabbitMQ 的插件目录。上传到服务器:
scp rabbitmq_delayed_message_exchange-3.9.0.ez root@82.156.9.173:/

进入服务器终端,将文件移动到 RabbitMQ 安装目录下的 plugins 目录,再执行命令使插件生效并重启:

# 1.将插件移动到RabbitMQ的插件plugins目录
cp rabbitmq_delayed_message_exchange-3.9.0.ez /usr/lib/rabbitmq/lib/rabbitmq_server-3.8.14/plugins/

# 2.使rabbitmq_delayed_message_exchange插件生效
rabbitmq-plugins enable rabbitmq_delayed_message_exchange

# 3.重启rabbitMQ
systemctl restart rabbitmq-server

安装完成后,在 web 界面新建交换机时可看到多了一种延迟交换机 x-delayed-message。

  1. 编写初始化类:只需要一个延迟交换机路由到一个队列,在交换机处延迟。在 init 包下新建 PluginsDelay.java:
package com.zsr.springbootrabbitmq.init;

import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.CustomExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.util.HashMap;
import java.util.Map;

@Configuration
public class PluginsDelay {
    public static final String DELAYED_QUEUE_NAME = "delayed.queue";
    public static final String DELAYED_EXCHANGE_NAME = "delayed.exchange";
    public static final String DELAYED_ROUTING_KEY = "delayed.routingkey";

    // 声明延迟队列
    @Bean
    public Queue delayedQueue() {
        return new Queue(DELAYED_QUEUE_NAME);
    }

    // 自定义交换机(这里定义的是一个延迟交换机)
    @Bean
    public CustomExchange delayedExchange() {
        Map<String, Object> args = new HashMap<>();
        args.put("x-delayed-type", "direct"); // 自定义交换机的类型
        return new CustomExchange(DELAYED_EXCHANGE_NAME, "x-delayed-message", true, false, args);
    }

    // 绑定延迟队列与延迟交换机
    @Bean
    public Binding bindingDelayedQueue(@Qualifier("delayedQueue") Queue queue,
                                       @Qualifier("delayedExchange") CustomExchange delayedExchange) {
        return BindingBuilder.bind(queue).to(delayedExchange).with(DELAYED_ROUTING_KEY).noargs();
    }
}

这种新的交换类型支持延迟投递机制:消息传递后并不会立即投递到目标队列,而是存储在 mnesia(一个分布式数据系统)表中,当达到投递时间时才投递到目标队列。

  1. 编写消息生产者:在 SendMessageController.java 中新增:
@GetMapping("sendDelayMsg/{message}/{delayTime}")
public void sendMsg(@PathVariable String message, @PathVariable Integer delayTime) {
    rabbitTemplate.convertAndSend(PluginsDelay.DELAYED_EXCHANGE_NAME, PluginsDelay.DELAYED_ROUTING_KEY, message, correlationData -> {
        correlationData.getMessageProperties().setDelay(delayTime);
        return correlationData;
    });
    log.info("当前时间:{},发送一条延迟{}毫秒的信息给队列delayed.queue:{}", new Date(), delayTime, message);
}
  1. 编写消息消费者:在 MessageConsumer.java 中添加:
@RabbitListener(queues = PluginsDelay.DELAYED_QUEUE_NAME)
public void receiveDelayedQueue(Message message) {
    String msg = new String(message.getBody());
    log.info("当前时间:{},收到延时队列的消息:{}", new Date().toString(), msg);
}
  1. 运行测试:启动主启动类,发起以下两个请求:
http://localhost:8080/sendDelayMsg/delay1/20000(延迟20s)
http://localhost:8080/sendDelayMsg/delay2/2000(延迟2s)

可以看到第二条消息先被消费掉,并不是像死信实现那样排队等消息 1 处理完再处理消息 2。插件方式等价于在交换机处延迟,实现更简单,只需一个交换机、一个队列。

延迟队列总结:RabbitMQ 实现延迟队列有两种方式——一种通过死信实现(细分为队列 TTL 和消息 TTL),原理是消息在指定时间过期后经死信交换机进入死信队列被消费;但基于消息 TTL 实现会存在消息积压导致后面发送的消息不能在指定延迟时间被消费的情况(本质是遵循先来先到原则)。另一种通过 RabbitMQ 插件实现,原理是消息在交换机处延迟,生产者发送消息到延迟交换机,等待指定时间后才路由到队列由消费者消费,方式简单且能避免上述问题,只需一个延迟交换机即可实现,不需要死信交换机和死信队列。

延时队列在需要延时处理的场景下非常有用,利用 RabbitMQ 可实现消息可靠发送、可靠投递,并通过死信队列保障消息至少被消费一次、未被正确处理的消息不被丢弃。另外通过 RabbitMQ 集群特性可很好解决单点故障问题。当然延时队列还有其它选择,如 Java 的 DelayQueue、Redis 的 zset、Quartz 或 Kafka 的时间轮,各有特点,按需选用。


7.8 发布确认高级

在生产环境中,由于一些不明原因导致 RabbitMQ 重启,重启期间生产者消息投递失败导致消息丢失,需要手动处理和恢复。于是我们思考:如何才能进行 RabbitMQ 的消息可靠投递?特别是在 RabbitMQ 集群不可用这种极端情况下,无法投递的消息该如何处理?

解决方案就是缓存:比如生产者发送消息到交换机时交换机不存在,应将消息放入缓存;或者交换机存在但队列不存在了,交换机发不到队列中也应将消息放入缓存。然后在缓存中配置一个定时任务,对没发送成功的消息重新投递,从而避免消息丢失。

回调接口——消息确认

下面通过代码实现以上机制,核心是生产者的回调接口 ConfirmCallback。

  1. 修改配置文件:添加配置表示开启发布消息成功到交换器后会触发回调方法:
spring.rabbitmq.publisher-confirm-type=correlate

参数说明:

  • NONE:禁用发布确认模式,是默认值;
  • CORRELATED:发布消息成功到交换器后会触发回调方法;
  • SIMPLE:经测试有两种效果,其一效果和 CORRELATED 一样会触发回调方法;其二在发布消息成功后使用 rabbitTemplate 调用 waitForConfirms 或 waitForConfirmsOrDie 方法等待 broker 节点返回发送结果,根据返回结果判定下一步逻辑。注意:waitForConfirmsOrDie 方法如果返回 false 会关闭 channel,后续无法再发送消息到 broker。
  1. 编写初始化类:在 init 包新建 Confirm.java:
package com.zsr.springbootrabbitmq.init;

import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class Confirm {
    public static final String CONFIRM_EXCHANGE_NAME = "confirm.exchange";
    public static final String CONFIRM_QUEUE_NAME = "confirm.queue";
    public static final String CONFIRM_ROUTING_KEY = "key1";

    // 声明Exchange
    @Bean("confirmExchange")
    public DirectExchange confirmExchange() {
        return new DirectExchange(CONFIRM_EXCHANGE_NAME);
    }

    // 声明确认队列
    @Bean("confirmQueue")
    public Queue confirmQueue() {
        return QueueBuilder.durable(CONFIRM_QUEUE_NAME).build();
    }

    // 声明确认队列与交换机的绑定关系
    @Bean
    public Binding queueBinding(@Qualifier("confirmQueue") Queue queue,
                                @Qualifier("confirmExchange") DirectExchange exchange) {
        return BindingBuilder.bind(queue).to(exchange).with("key1");
    }
}
  1. 实现回调接口:在 init 包新建 MyCallBack.java,实现 RabbitTemplate.ConfirmCallback 接口:
package com.zsr.springbootrabbitmq.init;

import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;

@Slf4j
@Component
public class MyCallBack implements RabbitTemplate.ConfirmCallback {
    @Autowired
    private RabbitTemplate rabbitTemplate;

    // 将我们实现的 MyCallBack 接口注入到 RabbitTemplate 中
    @PostConstruct
    public void init() {
        rabbitTemplate.setConfirmCallback(this);
    }

    /**
     * 交换机确认回调方法
     * @param correlationData 保存回调消息的ID以及相关信息
     * @param ack 表示交换机是否收到消息(true表示收到)
     * @param cause 表示消息接收失败的原因(收到消息为 null)
     */
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        String id = correlationData != null ? correlationData.getId() : "";
        if (ack) {
            log.info("交换机已经收到ID为:{}的消息", id);
        } else {
            log.info("交换机还未收到ID为:{}的消息,原因为:{}", id, cause);
        }
    }
}
  1. 编写消息生产者:在 SendMessageController.java 中新增:
@GetMapping("/confirm/sendMes/{message}")
public void sendConfirmMessage(@PathVariable String message) {
    // 指定消息的id为1
    CorrelationData correlationData1 = new CorrelationData("1");
    rabbitTemplate.convertAndSend(Confirm.CONFIRM_EXCHANGE_NAME, Confirm.CONFIRM_ROUTING_KEY, message, correlationData1);
    log.info("发送消息内容为:{}", message);
}
  1. 编写消息消费者:在 MessageConsumer.java 中添加:
@RabbitListener(queues = Confirm.CONFIRM_QUEUE_NAME)
public void receiveConfirmMessage(Message message) {
    String msg = new String(message.getBody());
    log.info("接收到的队列confirm.queue的消息为:{}", msg);
}
  1. 运行测试:请求 http://localhost:9090/confirm/sendMes/hellofdafasfad,可以看到消息成功发送到交换机并触发了回调函数。如果交换机出现问题(把交换机名字改成一个不存在的名字),会报错并显示找不到该交换机,回调函数收到失败信息。

那如果交换机成功收到消息、但队列出错没收到呢?把交换机与队列之间绑定的路由 key 修改成未知名称后再测试:消息成功被交换机接收、也收到交换机的确认回调,但消费者没收到消息——因为该消息的 RoutingKey 与队列的 BindingKey 不一致,也没有其它队列能接收,该消息被直接丢弃。这显然不是我们希望的结果,于是引入消息回退。

回调接口——消息回退

在仅开启生产者确认机制的情况下,交换机收到消息后直接给生产者发送确认,但如果发现该消息不可路由,消息会被直接丢弃,而生产者并不知道这个丢弃事件。如何让无法被路由的消息被生产者感知并处理?可以通过设置 mandatory 参数,在消息传递过程中不可达目的地时将消息返回给生产者。

  1. 修改配置文件:添加配置表示开启消息路由失败后会触发消息回退回调方法:
spring.rabbitmq.publisher-returns=true
  1. 修改回调接口:修改 MyCallBack.java,新增实现 RabbitTemplate.ReturnsCallback 接口:
package com.zsr.springbootrabbitmq.init;

import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.ReturnedMessage;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;

@Slf4j
@Component
public class MyCallBack implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnsCallback {
    @Autowired
    private RabbitTemplate rabbitTemplate;

    @PostConstruct
    public void init() {
        // 设置确认消息交给谁处理
        rabbitTemplate.setConfirmCallback(this);
        // 设置回退消息交给谁处理
        rabbitTemplate.setReturnsCallback(this);
    }

    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        String id = correlationData != null ? correlationData.getId() : "";
        if (ack) {
            log.info("交换机已经收到ID为:{}的消息", id);
        } else {
            log.info("交换机还未收到ID为:{}的消息,原因为:{}", id, cause);
        }
    }

    /**
     * 路由出现问题的消息回退方法
     * @param returned 返回消息及其元数据
     */
    @Override
    public void returnedMessage(ReturnedMessage returned) {
        log.info("消息{}:,被交换机{}退回,退回原因:{},路由key:{}",
                new String(returned.getMessage().getBody()),
                returned.getExchange(),
                returned.getReplyText(),
                returned.getRoutingKey());
    }
}

测试时发送消息指定错误的 routing-key,可以看到消息回退的回调函数成功被触发,错误原因是路由 key 出错。

备份交换机

前面提到交换机出问题就进行消息确认、让生产者重新发消息;队列出问题就进行消息回退。此外还有一种解决方案:给交换机添加一个备份交换机——不用把消息回退给生产者,而是将无法投递的消息交给备份交换机,让备份交换机通过自己的路由及队列发送给消费者,达到消息不丢失的目的。这种方式还能建立一个报警队列,用独立消费者进行监测和报警。

用代码实现:

  1. 修改初始化类:修改 Confirm.java,声明备份交换机、备份队列、警告队列及其绑定关系:
package com.zsr.springbootrabbitmq.init;

import org.springframework.amqp.core.*;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class Confirm {
    public static final String CONFIRM_EXCHANGE_NAME = "confirm.exchange";
    public static final String CONFIRM_QUEUE_NAME = "confirm.queue";
    public static final String CONFIRM_ROUTING_KEY = "key1";
    public static final String BACKUP_EXCHANGE_NAME = "backup.exchange";
    public static final String BACKUP_QUEUE_NAME = "backup.queue";
    public static final String WARNING_QUEUE_NAME = "warning.queue";

    // 声明确认交换机,并绑定备份交换机
    @Bean("confirmExchange")
    public DirectExchange confirmExchange() {
        ExchangeBuilder exchangeBuilder = ExchangeBuilder.directExchange(CONFIRM_EXCHANGE_NAME)
                .durable(true).withArgument("alternate-exchange", BACKUP_EXCHANGE_NAME);
        return exchangeBuilder.build();
    }

    // 声明备份交换机
    @Bean("backupExchange")
    public FanoutExchange backupExchange() {
        return new FanoutExchange(BACKUP_EXCHANGE_NAME);
    }

    // 声明备份队列
    @Bean("backQueue")
    public Queue backQueue() {
        return QueueBuilder.durable(BACKUP_QUEUE_NAME).build();
    }

    // 声明确认队列
    @Bean("confirmQueue")
    public Queue confirmQueue() {
        return QueueBuilder.durable(CONFIRM_QUEUE_NAME).build();
    }

    // 声明警告队列
    @Bean("warningQueue")
    public Queue warningQueue() {
        return QueueBuilder.durable(WARNING_QUEUE_NAME).build();
    }

    // 声明确认队列绑定关系
    @Bean
    public Binding queueBinding(@Qualifier("confirmQueue") Queue queue,
                                @Qualifier("confirmExchange") DirectExchange exchange) {
        return BindingBuilder.bind(queue).to(exchange).with("key1");
    }

    // 声明备份队列绑定关系
    @Bean
    public Binding backupBinding(@Qualifier("backQueue") Queue queue,
                                 @Qualifier("backupExchange") FanoutExchange backupExchange) {
        return BindingBuilder.bind(queue).to(backupExchange);
    }

    // 声明报警队列绑定关系
    @Bean
    public Binding warningBinding(@Qualifier("warningQueue") Queue queue,
                                  @Qualifier("backupExchange") FanoutExchange backupExchange) {
        return BindingBuilder.bind(queue).to(backupExchange);
    }
}
  1. 编写报警消费者:在 MessageConsumer.java 中新增:
@RabbitListener(queues = Confirm.WARNING_QUEUE_NAME)
public void receiveWarningMsg(Message message) {
    String msg = new String(message.getBody());
    log.error("报警发现不可路由消息:{}", msg);
}
  1. 运行测试:重新启动项目时需删除原来的 confirm.exchange(因为我们修改了其绑定属性,否则报错)。启动成功后发送消息并指定错误的 routing-key,请求 http://localhost:9090/confirm/sendMes/hellofdafasfad,可以看到消息走了备份交换机。由此可得出结论:当回调函数和备用交换机一起使用时,备份交换机优先级更高。

7.9 幂等性

所谓幂等性,是指用户对同一操作发起的一次请求或多次请求的结果是一致的,不会因为多次点击而产生副作用。

比如用户购买商品后支付已扣款成功,但返回结果时出现网络异常,用户并不知道已付费成功,于是再次点击按钮,进行了第二次扣款,结果扣了两次钱,这就出现了不满足幂等性的情况(对统一操作发起一次或多次请求结果不一致,产生副作用导致多扣费)。

对应到消息队列 MQ 中,幂等性问题就是消息重复消费。比如消费者在消费 MQ 中的消息时,MQ 已把消息发送给消费者,消费者在给 MQ 返回 ack 时网络中断,故 MQ 未收到确认信息,该条消息会重新发给其他消费者,或在网络重连后再次发送给该消费者,但该消费者实际上已成功消费了该消息,造成重复消费。

怎么解决

在海量订单生成的业务高峰期,生产端有可能重复发生消息,这时消费端就要实现幂等性。MQ 消费者的幂等性解决一般使用全局 ID:每次完成一次操作生成一个唯一标识(如时间戳、UUID、消息队列中消息的 id 号等)。每次消费消息时先通过该唯一标识判断该消息是否已消费过,如果消费过则不再消费,从而避免消息重复消费。

业界主流的幂等性解决方案有以下两种:

  • 指纹码机制:指纹码是按照一定规则(如时间戳、其他服务给的唯一信息码)拼接出来的唯一标识,不一定是我们系统生成的,基本由业务规则拼接而来。然后用查询语句判断这个指纹码是否存在数据库中。优点是实现简单,只需拼接后查询判断是否重复;缺点是在高并发时,如果单个数据库会出现写入性能瓶颈(可采用分库分表提升性能)。(不推荐)
  • Redis 原子性:利用 redis 执行 setnx 命令,天然具有幂等性,从而实现不重复消费。(推荐)

7.10 优先级队列

使用场景:电商系统中常会遇到订单催付的场景。比如用户在淘宝下单后,商家会及时推送订单信息,如果用户没在设定时间内付款就会推送短信提醒。当订单量十分大时,商家往往要进行优先级排序:大客户先推送、小客户后推送。曾经后端的做法是使用 redis 的 List 做一个简单的消息队列实现定时轮询推送,但这并不能实现优先级场景,所以订单量大之后应采用 RabbitMQ 进行改造优化,给要发送的消息设置优先级,满足不同场景需要。

如何在 RabbitMQ 中实现优先级队列

在代码中实现:

// 1.队列声明的代码中添加优先级参数
Map<String, Object> params = new HashMap();
params.put("x-max-priority", 10); // 官方允许是0~255之间,此处设置为10,即允许优先级范围从0~10(不要设置过大以免浪费cpu和内存)
channel.queueDeclare("hello", true, false, false, params);

// 2.消息中代码添加优先级(要在队列优先级设置的范围内)
AMQP.BasicProperties properties = new AMQP.BasicProperties().builder().priority(5).build();
channel.basicPublish("", QUEUE_NAME, properties, message.getBytes());

注意:队列需要设置为优先级队列的同时,消息也必须设置消息的优先级才能生效;而且消费者需要等待消息全部发送到队列后才去消费,因为这样才有机会对消息进行排序。

7.11 惰性队列

使用场景:RabbitMQ 从 3.6.0 版本开始引入了惰性队列的概念。惰性队列会尽可能将消息存入磁盘,在消费者消费到相应消息时才加载到内存中。它的重要设计目标是能够支持更长的队列,即支持更多的消息存储。当消费者因各种原因(下线、宕机、维护关闭等)长时间不能消费消息造成堆积时,惰性队列就很有必要了——将消息存储在磁盘中,避免占用大量内存。

默认情况下,生产者将消息发送到 RabbitMQ 时,队列中的消息会尽可能存储在内存中,以便更快地发送给消费者。即使是持久化的消息,在写入磁盘的同时也会在内存中驻留一份备份。当 RabbitMQ 需要释放内存时,会将内存中的消息换页至磁盘,这个操作较耗时,也会阻塞队列操作,无法接收新消息。虽然开发者一直在升级相关算法,但效果始终不太理想,尤其在消息量特别大时。

队列的两种模式:队列具备 default 和 lazy 两种模式。默认是 default 模式(3.6.0 之前的版本无需做任何变更)。lazy 模式即惰性队列模式,可以通过调用 channel.queueDeclare 方法在参数中设置,也可以通过 Policies 方式设置;如果同时使用这两种方式设置,Policies 方式具备更高优先级。如果要通过声明方式改变已有队列的模式,只能先删除队列再重新声明。

在队列声明时通过 x-queue-mode 参数设置队列模式,取值为 default 和 lazy:

Map<String, Object> args = new HashMap<String, Object>();
args.put("x-queue-mode", "lazy");
channel.queueDeclare("myqueue", false, false, false, args);

内存开销对比:在发送 100 万条消息、每条消息约占 1KB 的情况下,普通队列占用内存约 1.2GB,而惰性队列仅仅占用约 1.5MB。


八、RabbitMQ 集群

以上所有章节都是基于单机版 RabbitMQ 讲解的,但这无法满足真实应用的要求。如果 RabbitMQ 服务器遇到内存崩溃、机器掉电或主板故障等情况该怎么办?单台 RabbitMQ 服务器可满足每秒 1000 条消息的吞吐量,如果应用需要每秒 10 万条消息的吞吐量呢?购买昂贵服务器来增强单机性能显得捉襟见肘,搭建 RabbitMQ 集群才是解决实际问题的关键。

8.1 三节点 RabbitMQ 集群搭建

环境准备:准备三台 centos 机器并都具备 RabbitMQ 环境。

1. 修改 3 台机器的主机名称

vim /etc/hostname
# 修改完记得重启,才能看到生效
reboot

三台机器的名称分别为 node1、node2、node3,其中 node1 作为主节点,后续 node2、node3 添加到 node1 组成集群。

2. 配置各个节点的 hosts 文件,让各节点能互相识别

# 在三台机器上分别修改 /etc/host 文件,增加三台机器节点的域名ip映射
vim /etc/hosts

3. 确保各节点的 cookie 文件使用同一个值

RabbitMQ 集群要求底层 erlang 使用的 cookie 值相同。在节点 1 上使用远程复制命令将该机器的 cookie 复制给节点 2 和节点 3:

scp /var/lib/rabbitmq/.erlang.cookie root@node2:/var/lib/rabbitmq/.erlang.cookie
scp /var/lib/rabbitmq/.erlang.cookie root@node3:/var/lib/rabbitmq/.erlang.cookie

4. 启动 RabbitMQ 服务(顺带启动 Erlang 虚拟机和应用服务)

# 在三台节点上分别执行该命令
rabbitmq-server -detached

5. 将节点 2、节点 3 添加到节点 1 组成集群

在节点 2 执行:

rabbitmqctl stop_app # 关闭RabbitMQ服务
rabbitmqctl reset # 重置
rabbitmqctl join_cluster rabbit@node1 # 加入到node1节点
rabbitmqctl start_app # 启动RabbitMQ服务

在节点 3 同样执行:

rabbitmqctl stop_app # 关闭RabbitMQ服务
rabbitmqctl reset # 重置
rabbitmqctl join_cluster rabbit@node2 # 加入到node2节点(等价于加入到node1节点)
rabbitmqctl start_app # 启动RabbitMQ服务

命令区别:rabbitmqctl stop 会将 Erlang 虚拟机关闭,rabbitmqctl stop_app 只关闭 RabbitMQ 服务。

6. 查看集群状态

在三台机器任何一台执行以下命令均可查询到集群状态:

rabbitmqctl cluster_status

7. 为集群创建账户

在任意一台机器执行以下命令即可:

# 创建账号
rabbitmqctl add_user admin 123

# 设置用户角色
rabbitmqctl set_user_tags admin administrator

# 设置用户权限
rabbitmqctl set_permissions -p "/" admin ".*" ".*" ".*"

此时可通过该用户登录 web 管理界面,通过任意一台机器的 ip:15672 均可登录。

集群搭建完成后也可以脱离

在 node2 和 node3 两台机器分别执行以下命令即可解除集群关系:

rabbitmqctl stop_app
rabbitmqctl reset
rabbitmqctl start_app
rabbitmqctl cluster_status

然后在 node1 机器上执行以下命令忘记 node2 和 node3 机器:

rabbitmqctl forget_cluster_node rabbit@node2
rabbitmqctl forget_cluster_node rabbit@node3

8.2 镜像队列

上一节搭建了三节点集群,但此时集群不可复用:在 node1 创建的队列,在 node2 和 node3 是没有的,因此一旦 node1 宕机,这个队列也就消失了,不会因为有三台机器就有三个队列,会造成消息丢失。那把所有消息及对应队列都设为持久化能否避免?答案是否定的——消息在发送之后、写入磁盘并执行刷盘动作之间存在一个短暂却会产生问题的时间窗,因此该方式仍无法避免缓存导致的问题。

因此引入镜像队列。镜像的意思就是备份:给集群中一个节点发送消息,不应只在这个节点存在,而是集群中其他节点也有备份,这样可避免一个节点宕机导致消息丢失。

引入镜像队列 Mirror Queue 机制后,可以将队列镜像到集群中的其他 Broker 节点上;如果集群中一个节点失效,队列能自动切换到镜像中的另一个节点上,保证服务可用性。

镜像队列怎么实现

只需在集群中任意一个节点上添加策略即可。界面上一个 Policy 策略需要的元素:

  • Name:策略名称,可随便指定,最好语义化便于理解;
  • Pattern:指定正则表达式去匹配 Queues/Exchanges 名称,如 ^mirror 表示所有以 mirror 开头的队列名都会绑定该策略(如 hello_mirror 等);
  • Apply to:该策略对 Queue 还是对 Exchange 生效,或两者都适用;
  • Priority:优先级;
  • Definition:添加的 args 键值对。

常用参数:

ha-mode=exactly            # 表示开启备份模式
ha-params=2                # 表示备份数量为2
ha-sync-mode=automatic     # 表示进行自动备份

设置完成后添加策略。新增一个名为 mirror_hello 的队列后,队列会有一个 +1 的标识;点进去查看队列详细信息,可以看到它在其他节点上进行了备份。此时在 node1 上执行 rabbitmqctl stop_app 停止 RabbitMQ 服务模拟宕机,查看 web 界面可看到 node1 宕机了;再次查看 mirror_hello 队列的详细信息,可看到该队列已转移到其他节点,并在另一个节点上有备份。此时在该队列上发送消息并不会丢失,因为它在其他机器上做了备份。

8.3 Haproxy + Keepalive 实现高可用负载均衡

上一节通过镜像队列实现了消息不再丢失,但存在一个问题:生产者和消费者只能连接集群中的一个节点。比如当前连接 node1,如果 node1 宕机连不上,生产者和消费者就需要连接集群中的其他机器,但生产者完全不知道集群中其他机器的存在(代码中写死了连接的 ip 地址)。这就产生了生产者/消费者无法变更 ip 连接其他节点的问题,因此需要借助第三方负载均衡软件来解决。

使用负载均衡后的架构:生产者发消息先走 VIP,VIP 路由到集群中的主机,由主机转发到多台 MQ 节点处理;一旦主机宕机,keepalive 监测到主机状况后将 ip 漂移到另一台备机上,从此 VIP 路由到备机,备机再转发到多台 MQ 节点处理;备机还定时监测主机是否恢复,如果没恢复就接管主机的所有工作。

这是市面上最主流的高可用负载均衡方案,是为了解决生产者无法连接多台机器的问题。只需安装 Haproxy 和 keepalive 两个软件即可使用。

8.4 Federation Exchange(联合交换机)

假设一个 RabbitMQ 集群中的两台机器分布在异地:一台 broker 位于北京,另一台位于深圳,两者网络延迟较大。北京的客户端连接北京的 broker 向 exchangeA 发送消息时网络延迟小,可迅速收到确认;但深圳的客户端向 exchangeA 发送消息时会经历一定延迟,尤其是在开启 publisherconfirm 或事务机制的情况下,会等待很长的确认时间,进而造成发送线程性能降低,甚至阻塞。

怎么解决异地数据访问延迟

将深圳的业务部署到北京机房即可,但如果深圳的业务还要调用原本处在深圳的业务,又会引发新的时延问题,总不见得把所有业务都部署在一个机房(容灾无法实现)。在 RabbitMQ 集群中,使用 Federation 插件就可以很好地解决这个问题。

原理:这里有两个节点 node1 和 node2(可理解为 node1 在北京、node2 在深圳),node1 处于 upstream 上游,node2 处于 downstream 下游。为了解决异地访问延迟,需要做数据同步(上游数据往下游走)。数据同步以交换机为节点:上游 node1 节点的联邦交换机同步数据给下游 node2 节点的联邦交换机,其中经历了一个配置 federation: fed_exchange -> rabbit@node2,即给 node1 的联邦交换机配置了 node2 的地址,让 node1 能找到 node2;两边使用相同名称的交换机来同步数据。

注意:数据同步的前提是 consumer 在 node2(下游)创建了联邦交换机,然后在上游配置地址映射,方可通过联邦交换机同步。

如何在 RabbitMQ 集群中配置

首先在集群中的三台机器上分别执行以下命令开启 Federation 插件:

rabbitmq-plugins enable rabbitmq_federation
rabbitmq-plugins enable rabbitmq_federation_management

回到 web 界面即可看到插件已经出现。然后在 node2 节点上创建一个名为 fed_exchange 的交换机;接着在处于下游(downstream)的 node2 节点配置上游(upstream)的 node1 节点;最后设置一条 policy 规则,给所有以 fed 名称开头的交换机添加上游策略。配置完成后,可在 Federation Status 界面看到联合交换机已配置成功。

8.5 Federation Queue(联邦队列)

上节联邦交换机的实现本质上解决不同地区间的数据同步问题,按交换机进行同步(上游交换机通过链接地址链接到下游交换机进行数据传递)。除了按交换机同步,还可以按队列同步,于是引入联邦队列 Federation Queue。

联邦队列可以在多个 Broker 节点(或集群)之间为单个队列提供均衡负载功能。一个联邦队列可以连接一个或多个上游队列(upstream queue),并从这些上游队列中获取消息,以满足本地消费者消费消息的需求。

原理类似联邦交换机:node1 的队列想同步数据给 node2 的队列,就需要将处于下游的 node2 与上游的 node1 进行绑定。

在 RabbitMQ 集群中的配置同联邦交换机类似:首先在两个节点上创建名为 fed.queue 的两个队列;然后在处于下游(downstream)的 node2 节点配置上游(upstream)的 node1 节点(该步骤前面已完成);最后设置一条 policy 规则,给所有以 fed 名称开头的队列添加上游策略,即可配置成功。

8.6 Shovel(铲子)

与 Federation 具备类似的数据转发功能,Shovel 能够可靠持续地从一个 Broker 中的队列(作为 source 源端)拉取数据并转发至另一个 Broker 中的交换机(作为 destination 目的端)。作为源端的队列和作为目的端的交换机可以同时位于同一个 Broker,也可以位于不同 Broker。Shovel 可以翻译为“铲子”,是一种比较形象的比喻:这个“铲子”可以将消息从一方“铲”到另一方。Shovel 行为就像优秀的客户端应用程序,能负责连接源和目的地、负责消息读写及负责连接失败问题的处理。

原理:发送消息给 Q1 时,消息会通过 Shovel 同步给 Q2。如果往 Q1 和 Q2 各发一条消息,Q1 只有 1 条消息,而 Q2 会收到 2 条消息(其中 1 条来自 Q1 的同步)。

在 RabbitMQ 集群中使用 Shovel 的方法:

  1. 开启插件:
# 在集群中需要开启该插件的节点机器上执行以下命令:
rabbitmq-plugins enable rabbitmq_shovel
rabbitmq-plugins enable rabbitmq_shovel_management

开启后在 web 界面可以看到该插件。

  1. 添加 shovel 源和目的地:将 node1 节点中的 Q1 队列同步到 node2 节点的 Q2 队列。

结语

本文从 MQ 基础概念出发,系统梳理了 RabbitMQ 的安装、架构、六大消息模式、交换机机制,以及消息应答、持久化、公平分发、预取值、发布确认、死信队列、延迟队列、发布确认高级、幂等性、优先级队列、惰性队列等高级特性,最后介绍了集群搭建与高可用方案。文中所有示例代码均基于 amqp-client 5.8.0 与 Spring Boot,可直接运行验证。希望这份笔记能帮你系统地掌握 RabbitMQ。

本文为学习笔记整理,主要参考尚硅谷 2021 新版 RabbitMQ 教程与【学相伴】RabbitMQ 教程。

posted @ 2026-09-29 10:26  当下是吾  阅读(9)  评论(0)    收藏  举报