RabbitMQ笔记(持续更新中~)

news2024/12/26 12:39:23

1.消息队列

1.1 MQ的相关概念

1.1.1 什么是MQ

MQ(message queue),从字面上看,本质是个队列,FIFO先进先出,只不过队列中存放的内容是消息而已,还是一种跨进程的通信机制,用于上下游传递消息。在互联网架构中,MQ是一种非常常见的上下游”逻辑解耦+物理解耦“的消息通信服务。使用了MQ之后,消息发送上游只需要依赖MQ,不用依赖其他服务。

1.1.2 为什么要用MQ

1.流量消峰

举个例子,如果订单系统最多能处理一万次订单,这个处理能力应付正常时段的下单时绰绰有余,正常时段我们下单一秒后就能返回结果。但是在高峰期,如果有两万次下单操作系统是处理不了的,只能限 制订单超过一万后不允许用户下单。使用消息队列做缓冲,我们可以取消这个限制,把一秒内下的订单分 散成一段时间来处理,这时有些用户可能在下单十几秒后才能收到下单成功的操作,但是比不能下单的体 验要好。

2.应用解耦

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

image-20230523104733158

3.异步处理

有些服务间调用是异步的,例如 A 调用 B,B 需要花费很长时间执行,但是 A 需要知道 B 什么时候可 以执行完,以前一般有两种方式,A 过一段时间去调用 B 的查询 api 查询。或者 A 提供一个 callback api, B 执行完之后调用 api 通知 A 服务。这两种方式都不是很优雅,使用消息总线,可以很方便解决这个问题, A 调用 B 服务后,只需要监听 B 处理完成的消息,当 B 处理完成后,会发送一条消息给 MQ,MQ 会将此 消息转发给 A 服务。这样 A 服务既不用循环调用 B 的查询 api,也不用提供 callback api。同样 B 服务也不 用做这些操作。A 服务还能及时的得到异步处理成功的消息。

image-20230523105316402

1.1.3 MQ的分类

1.ActiveMQ

优点:单机吞吐量万级,时效性ms级,可用性高,基于主从架构实现高可用性,消息可靠性较低的概率丢失数据。

缺点:官方社区现在对ActiveMQ5.x维护越来越少,高吞吐量场景使用较少。

2.Kafka

大数据的杀手锏,谈到大数据领域内的消息传输,则绕不开Kafaka,这款为大数据而生的消息中间件,以期百万级TPS的吞吐量名声大噪,迅速成为大数据领域的宠儿,在数据采集、传输、存储的过程中发挥举足轻重的作用。

优点:性能卓越,单击写入TPS约在百万条/秒,最大的优点,就是吞吐量高。时效性ms级可用性非常高,kafka是分布式的,一个数据多个副本,少数机器宕机,不会丢失数据,不会导致不可用,消费者采用Pull方式获取消息,消息有序,通过控制能够保证所有消息被消费者且仅被消费一次;有优秀的第三方 Kafka Web 管理界面 Kafka-Manager;在日志领域比较成熟,被多家公司和多个开源项目使用;功能支持:功能较为简单,主要支持简单的MQ功能,在大数据领域的实时计算以及日志采集被大规模使用。

缺点:Kafka单击超过64个队列/分区,Load会发生明显的飙高现象,队列越多,load越高,发送消息响应时间变长,使用短轮询方式,实时性取决于轮询间隔时间,消费失败不支持重试;支持消息顺序,但是一台宕机后,就会生产消息乱序,社区更新较慢;

3.RocketMQ

出自阿里巴巴的开源产品,用Java语言实现,在设计时参考了Kafka,并作出了自己的一些改进。被阿里巴巴广泛应用在订单、交易、充值、流计算,消息推送,日志流式处理,binglog分发等场景。

优点:单击吞吐量十万级,可用性非常高,分布式架构,消息可以做到0丢失,MQ功能较为完善,还是分布式的,扩展性好,支持10亿级别的消息堆积,不会因为堆积导致性能下降,源码是java我们可以自己阅读源码,定制自己公司的MQ。

缺点:支持的客户端语言不多,目前是Java及C++,其中C++不成熟;社区活跃度一般,没有在MQ核心中去实现JMS等接口,有些系统要迁移需要修改大量代码。

4.RabbitMQ

2007年发布,是一个在AMQP(高级消息队列协议)基础上完成的,可复用的企业消息系统,是当前最主流的消息中间件之一

优点:由于erlang语言的高并发特性,性能较好;吞吐量到万级,MQ功能比较完备,健壮、稳定、易用、跨平台、支持多种语言,如:Python、Ruby、.NET、Java、PHP、ActionScript、XMPP、STOMP等,支持AJAX文档齐全;开源提供的管理界面非常棒,用起来很好用,社区活跃度高;更新频率相当高。

缺点:商业版需要收费

1.1.4 MQ的选择

1.Kafka

Kafka主要特点是基于Pull非模式来处理消息消费,追求高吞吐量,一开始的目的就是用于日志收集和传输,适合产生大量数据的互联网服务的数据收集业务。大型公司建议选用,如果有日志采集功能,肯定是首选Kafka。

2.RocketMQ

天生为金融互联网领域而生,对于可靠性要求很高的场景,尤其是电商里面的点单扣款,以及业务晓峰,在大量交易涌入时,后端可能无法及时处理的情况。RocketMQ在稳定性上可能更值得信赖,这些业务场景在阿里双11已经经历了多次考验,如果你的业务有上述并发场景,建议可以选择RocketMQ。

3.RabbitMQ

结合erlang语言本身的并发优势,性能好,时效性微秒级,社区活跃度也比较高,管理界面用起来十分方便,如果你的数据量没那么大,中小型公司有限选择功能比较完备的RabbitMQ。

1.2 RabbitMQ

1.2.1 RabbitMQ的概念

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

hello-world-example-routing

1.2.2 四大核心概念

  • 生产者(Producer)

    产生数据发送消息的程序是生产者

  • 交换机(Exchange)

    交换机是RabbitMQ非常重要的一个部件,一方面它接受来自生产者的消息,另一方面它将消息推送到队列中。交换机必须确切知道如何处理它接收到的消息,是将这些消息推送到特定队列还是推送到多个队列,亦或者是把消息丢弃,这个得有交换机类型决定。

  • 队列(Queue)

    队列是RabbitMQ内部使用的一种数据结构,尽管消息流经RabbitMQ和应用程序,但它们只能存储在队列中。队列仅受主机的内存和磁盘限制的约束,本质上是一个大的消息缓冲区。许多生产者可以将消息发送到一个队列,许多消费者可以尝试从一个队列中接收数据。这就是我们使用队列的方式。

  • 消费者(Consumer)

    消费与接收具有相似的含义。消费者大多时候是一个等待接收消息的程序。请注意生产者,消费者和消息中间件很多时候并不在同一机器上。同一个应用程序既可以是生产者又可以是消费者。

1.2.3 RabbitMQ核心部分

image-20230523141216769

1.2.4 名词介绍

image-20230523141340142

Broker:接收和分发消息的应用,RabbitMQ Server 就是 Message Broker

Virtual host:处于多租户和安全因素设计的,把AMQP的基本组件划分到一个虚拟的分组中,类似于网络中的 namespace 概念。当多个不同的用户使用同一个 RabbitMQ Server 提供的服务时,可以划分出多个vhost,每个用户在自己的 vhost 创建 exchange / queue 等。

Connection:publisher / consumer 和 broker 之间的 TCP 连接。

Channel:如果每一次访问 RabbitMQ 都建立一个 Connection,在消息量大的时候建立 TCP, Connection 的开销将是巨大的,效率也较低。Channel 是在 connection 内部建立的逻辑连接,如果应用程序支持多线程,通常每个 thread 创建单独的 channel 进行通讯,AMQP method 包含了 channel id 帮助客户端和 message broker 识别 channel,所以 channel 之间是完全隔离的。Channel 作为轻量级的Connection 极大减少了操作系统建立TCP connection的开销

Exchange:message 到达 broker 的第一站,根据分发规则,匹配查询表中的 routing key,粉发消息到 queue 中去。常用的类型有:direct(point-to-point),topic(publish-subscribe)以及 fanout(multicast)

Queue:消息最终被送到这里等待 consumer 取走。

Binding:exchange 和 queue 之间的虚拟连接,binding 中可以包含 routing key,Binding 信息被保存到 exchange 中的查询表中,用于 message 的分发依据。

1.2.5 安装

1.RabbitMQ服务端下载网址以及Erlang语言环境下载网址

2.文件上传

将下载好的rpm文件上传到虚拟机的某个目录中(我这里是opt目录)。

3.安装文件(按以下顺序)

rpm -ivh erlang-21.3-1.el7.x86_64.rpm 
yum install socat -y 
rpm -ivh rabbitmq-server-3.8.8-1.el7.noarch.rpm

4.常用命令(按以下顺序执行)

添加开机启动 RabbitMQ 服务

chkconfig rabbitmq-server on

启动服务

/sbin/service rabbitmq-server start 或 systemctl start rabbitmq-server

查看服务状态

/sbin/service rabbitmq-server status

image-20230523150428739

停止服务(选择执行)

/sbin/service rabbitmq-server stop 

开启 web 管理插件

rabbitmq-plugins enable rabbitmq_management

使用默认账号密码(guest)访问地址 http://rabbitmq-server:15672/ (注意:rabbitmq-server是虚拟机的地址,我这里在hosts中配置了映射),会出现如下页面,并提示错误信息,原因是guest账户拥有所有的权限,且是第一次登陆,存在不安全问题所以不让登陆,当然也可以通过RabbitMQ的配置文件rabbitmq.config进行修改。我这里是新增用户。

image-20230523150814302

5.添加一个新用户

创建账号

rabbitmqctl add_user admin admin     ##(两个admin分别为用户名和密码)

设置用户角色

rabbitmqctl set_user_tags admin administrator ## 给admin用户添加角色

设置用户权限

## 语法
rabbitmqctl set_permissions [-p <vhostpath>] <user> <conf> <write> <read>
## 样例
rabbitmqctl set_permissions -p "/" admin ".*" ".*" ".*"       
## 说明:用户admin具有virtual host中所有的配置、读、写权限

查看当前用户和角色

rabbitmqctl list_users

6.再次登录

image-20230523151903386

7.重置命令

关闭应用

rabbitmqctl stop_app

清除命令

rabbitmqctl reset

重启命令

rabbitmqctl strat_app

2.Hello World

在本教程的这一部分中,我们将用 Java 编写两个程序;发送单个消息的生产者和接收消息并将其打印出来的消费者。我们将忽略Java API中的一些细节,专注于这个非常简单的事情,只是为了开始。这是一个消息传递的“Hello World”。

在下图中,“P”是我们的生产者,“C”是我们的消费者。中间的框是一个队列 - RabbitMQ 代表消费者保留的消息缓冲区。

1

2.1 依赖

<!--指定 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.7</version>
        </dependency>
    </dependencies>

2.2 封装连接工具类

public class ConnectionUtil {


    private static Connection connection = null;

    /**
     * 获取连接
     * @param host 主机ip
     * @param username 连接用户名
     * @param password 连接密码
     * @return 返回值
     */
    public static Connection getConnection(String host, String username, String password) {
        // 创建一个连接工厂
        ConnectionFactory connectionFactory = new ConnectionFactory();
        // 设置连接属性
        connectionFactory.setHost(host);
        connectionFactory.setUsername(username);
        connectionFactory.setPassword(password);

        try {
            // 创建连接
            connection = connectionFactory.newConnection();
        } catch (IOException e) {
            throw new RuntimeException(e);
        } catch (TimeoutException e) {
            throw new RuntimeException(e);
        }
        return connection;
    }
    
    // 根据官方文档因为Connection和Channel都实现java.lang.AutoCloseable。这样我们就不需要在代码中显式关闭它们,所以不需要写关闭方法
}

2.3 消息生产者

/**
 * @author 蜡笔小新
 * @version 1.0
 * @description 生产者发消息
 */
public class Producer {

    // 创建队列
    public static final String QUEUE_NAME = "hello";

    // 发消息
    public static void main(String[] args) throws IOException, TimeoutException {

        // 创建连接
        Connection connection = ConnectionUtil.getConnection("192.168.2.138", "admin", "admin");

        // 获取信道
        Channel channel = connection.createChannel();
       
        channel.queueDeclare(QUEUE_NAME,false,false,false,null);

        // 定义消息
        String msg = "Hello RabbitMQ";

        // 推送消息
        channel.basicPublish("",QUEUE_NAME,null,msg.getBytes());

        System.out.println("消息发送成功");
    }
}

详解 queueDeclare 方法:

Channel channel = connection.createChannel();
channel.queueDeclare(QUEUE_NAME,false,false,false,null);

queueDeclare 方法是AMQP协议中定义的一个方法,用于声明一个队列。该方法可以被客户端程序调用,以便在RabbitMQ服务器上创建一个新的队列或者修改现有的队列。可以通过传递不同的参数来控制队列的行为和特性。其参数解释如下:

name:表示要声明的队列的名称

durable:表示是否持久化。如果为true,则表示队列将在服务器重启后仍然存在;否则,队列将在服务器重启时被删除

exclusive:标识队列是否是排他性的。如果为true,则表示只有一个消费者可以访问该队列;否则,多个消费者可以同时访问该队列

autoDelete:表示队列是否自动删除。如果为true,则表示当没有消费者使用该队列时,队列将被自动删除;否则队列一直存在服务器上

arguments:表示队列的其他属性参数,例如消息最大长度、死信交换机等。

	x-max-length:指定队列可以容纳的最大消息数。如果队列中已经存在的消息数量达到最大值,则新的消息将被拒绝并返回给生产者或被丢弃。
    x-dead-letter-exchange:指定一个交换器名称,当消息在队列中过期或被拒绝时,会将它们重新路由到该指定的交换器。
    x-dead-letter-routing-key:指定消息在被发送到死信队列时的路由键(routing key)。
    x-max-length-bytes:指定队列可以容纳的最大字节数。如果队列中消息的总字节数达到最大值,则新的消息将被拒绝并返回给生产者或被丢弃。
    x-max-priority:指定队列支持的最大优先级数。当消息设置了优先级后,优先级高的消息将会被优先处理。
    x-lazy-mode:指定队列是否启用惰性模式。启用惰性模式可以减少RabbitMQ服务器的内存使用,但会增加对磁盘的使用。
    x-queue-mode:该参数用于指定队列的模式,如懒惰模式(lazy)、默认模式(default)等。
    x-queue-master-locator:该参数用于指定队列的主节点(master)的选择策略,如随机选择、最少连接等。
    x-queue-type:该参数用于指定队列的类型,如ClassicQuorumNodisk等。不同类型的队列具有不同的特性和限制,可以根据实际需求进行选择。
    x-message-ttl:该参数用于设置消息的过期时间。当消息的存活时间超过指定的时间后,消息将被删除或转发到死信队列。
    x-overflow:该参数用于指定队列在达到最大长度或最大字节数时如何处理新的消息,如拒绝、删除或转发到死信队列等。
    x-single-active-consumer:该参数用于启用队列的单活动消费者模式。当该模式启用后,同一时刻只有一个消费者可以消费队列中的消息。

详解 basicPublish 方法:

channel.basicPublish("",QUEUE_NAME,null,message.getBytes());

basicPublish 方法是用于向对列中发布消息的基本方法。它接受一个消息对象和一些可选参数,并将该消息发送到指定的队列中。其参数解释如下:

exchange:表示要将消息发送到哪个交换机。如果未指定,则默认为空字符串。

routing_key:表示要将消息路由到哪个队列。如果未指定,则默认为#号路由键。

body:表示要发送的消息体。必须提供此参数。

properties:表示要设置在消息上的属性字典。如果未指定,则默认为空字典。可以使用pika.BasicProperties类来创建属性对象。例如,可以设置delivery_mode、content_type等属性。

mandatory:表示是否要求消息必须被传递。默认值为False。

immediate:表示是否立即传送笑死。默认值为True。如果设置为False,则消息将在确认应答后才被传送。

2.4 消息消费者

/**
 * @Author 蜡笔小新
 * @Description 消费者接受消息
 * @Version 1.0
 */
public class Consumer {
    // 这里也声明了队列。因为我们可能在发布者之前启动使用者,所以在尝试使用来自队列的消息之前,
    // 我们希望确保队列存在。
    public static final String QUEUE_NAME = "hello";

    public static void main(String[] args) throws IOException, TimeoutException {

        // 获取连接
        Connection connection = ConnectionUtil.getConnection("192.168.2.138", "admin", "admin");

        // 创建信道
        Channel channel = connection.createChannel();

        // 消费者成功消费消息后的回调函数,用于处理消息传递后的后续操作
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println(new String(message.getBody()));
        };

        // 消费消息时会在取消订阅时调用此回调接口
        CancelCallback cancelCallback = consumerTag -> {
            System.out.println("消费行为被中断");
        };
        // 接收消息
        channel.basicConsume(QUEUE_NAME,true,deliverCallback,cancelCallback);
    }
}

详解 basicConsume 方法:

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

basicConsume 方法是用于消费队列消息的基本方法之一。它可以接收到一个或多个队列中的未读取消息,并在消费者处理玩这些消息后返回。该方法需要传入一下参数:

queueName:队列名称,表示要从哪个队列中消费消息。

autoAck:是否自动确认消息是否被成功消费,默认为True,推荐使用 False。

deliverCallback:回调函数;当消费者成功消费(接收到)消息后会调用该方法,可以用来处理消息传递后的后续操作。他是一个函数式接口,定义了一个名为handle的方法,该方法接收两个参数:一个字符串类型的consumerTag和一个Delivery类型的delivery,并且该方法没有返回值。其中,delivery表示从队列中接收到的消息,包含一下属性:

  • body:消息的内容,以字节数组(bety array)的形式表示。
  • envelope:一个Envelope对象,包含了消息的元数据,如交换机名称、路由键、消息标签、传递模式等。
  • properties:消息的属性,以AMQP属性的格式表示,如消息的优先级、过期时间等。

cancelCallback:取消回调函数;当消费者因除调用Channel.basicCancel之外的原因被取消时调用。

当调用 basicConsume() 方法时,RabbitMQ会创建一个名为“amq.rabbitmq.listener”的监听器,用于监听消费者处理消息的状态变化。该方法具有良好的扩展性和可靠性。

3.Work Queues

工作队列(又称任务队列)的主要思想是避免立即执行资源密集型任务,而不得不等待他完成。相反我们安排任务在之后执行。我们把任务封装为消息并将其发送到队列。在后台运行的工作进程将弹出任务并最终执行作业。当有多个工作线程时,任务将在它们之间共享。

image-20230525102010633

3.1 轮询分发

RabbitMQ的轮询分发(Round-robin Dispatch)是一种默认的消息分发方式,它会将消息均匀地分配给所有的消费者。

在RabbitMQ中,消息被发送到队列中,并由一个或多个消费者从队列中接收并处理。如果有多个消费者连接到同一个队列,那么默认情况下,RabbitMQ会将消息均匀地分配给所有的消费者。这意味着,所有的消费者都会尽可能地处理相同数量的消息,不管它们的工作负载是否相同。

但是,轮询分发可能会导致一些消费者拥有更多的工作负载,而其他消费者则很少处理消息。因为在轮询分发中,RabbitMQ无法知道消费者的处理速度,而且每个消费者都会接收到相同数量的消息,这样就可能导致某些消费者处理消息的速度比其他消费者慢。

要避免这种情况,可以使用RabbitMQ的公平调度(Fair Dispatch)来确保每个消费者都能公平地分配工作负载。在公平调度中,RabbitMQ会将消息分配给一个消费者,直到该消费者处理完当前的消息并将确认(ACK)发送回RabbitMQ。然后,RabbitMQ才会将下一个消息分配给该消费者。这样,消费者处理消息的速度就不会影响到其他消费者的工作负载,因为每个消费者都只会处理一个消息,直到它完成为止。

总的来说,轮询分发是一种简单且高效的消息分发方式,但可能会导致不公平分发的问题。如果需要确保每个消费者都能公平地分配工作负载,可以考虑使用公平调度方式。

案例:

在这个案例中我们会启动两个工作线程,一个发送消息线程,一个消费消息线程。默认情况下,RabbitMQ将按顺序将每条消息发送给下一个消费者。平均而言,每个消费者都会收到相同数量的消息。

image-20230525123544774

/**
 * @Author 蜡笔小新
 * @Description 工作队列发布消息
 * @Version 1.0
 */
public class NewTask {

    private static final String QUEUE_NAME = "hello";

    public static void main(String[] args) {
        // 获取连接
        Connection connection = ConnectionUtil.getConnection("192.168.2.138", "admin", "admin");
        
        try {
            String message = "Work.Queue";
            // 获取信道
            Channel channel = connection.createChannel();
            // 声明队列
            channel.queueDeclare(QUEUE_NAME,false,false,false,null);
            // 发送消息到队列中
            channel.basicPublish("",QUEUE_NAME,null,message.getBytes());

            System.out.println("Sent Message: '" + message + "'");
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}
/**
 * @Author 蜡笔小新
 * @Description 工作队列接收消息
 * @Version 1.0
 */
public class Worker {

    /**
     * 声明队列名
     */
    private static final String QUEUE_NAME ="hello";

    public static void main(String[] args) {

        Connection connection = ConnectionUtil.getConnection("192.168.2.138", "admin", "admin");

        try {

            Channel channel = connection.createChannel();

            // 成功接收消息回调函数
            // 这段代码是用来将队列绑定到交换机上的。其中,`queueName`是要绑定的队列的名称,
            // `EXCHANGE_NAME`是要绑定到的交换机的名称,
            // `""`表示绑定的路由键为空。这意味着所有发送到该交换机的消息都将被路由到该队列。
            // 如果需要指定特定的路由键,可以将其替换为空字符串。
            DeliverCallback deliverCallback = (consumerTag, delivery) -> {

                String message = new String(delivery.getBody(), "UTF-8");

                System.out.println("Received '" + message + "'");
            };

            // consumerTag 相当于 CancelCallback  cancelCallback=(consumerTag)->{}
            boolean autoAck = true;
            channel.basicConsume(QUEUE_NAME, autoAck, deliverCallback, consumerTag -> {
                System.out.println(consumerTag + "消费者取消消费接口回调逻辑");
            });
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

通过设置IDEA平行启动多个Worker任务,来实现一个生产者多个消费者的用例;测试结果是:RabbitMQ 将按顺序将每条消息发送给下一个消费者。平均而言,每个消费者都会收到相同数量的消息。这就是轮询分发消息。

3.2 消息应答

消费者完成一项任务可能需要几秒钟的时间,但如果消费者启动了一项长任务,但它在任务完成之前就终止了,会发生什么。RabbitMQ一旦向消费者传递了一条消息,便立即将该消息标记为删除。在这种情况下,如果终止一个工作进程,他刚刚处理的消息就会丢失。已发送到此特定工作进程但尚未处理的消息也将丢失,所以为了避免这种情况,需要使用消息应答这种机制。

消息应答是指当一个消息被消费者接收后,消费者需要向RabbitMQ服务器发送一个应答,告诉服务器这个消息已经被接受并处理完成。RabbitMQ服务器会在收到应答后将这个消息从队列中删除,确保消息只会被处理一次,避免消息的重复处理。

如果一个消费者在没有发送ack的情况下死亡(其通道关闭、连接关闭或TCP连接丢失),RabbitMQ将理解消息没有得到完全处理,并将其重新排队。如果同时有其他消费者在线,它将迅速将其重新传递给另一个消费者。这样,即使消费者偶尔会死亡,也可以确保不会丢失任何信息。

消息应答机制分为两种模式:自动应答和手动应答

3.2.1 自动应答

自动应答模式下,当消费者是接收到一个消息时,RabbitMQ会自动发送一个应答,告诉服务器这个消息已经被接受并处理完成。这种模式下,消费者无需关心消息应答的问题,但是在处理消息时可能会出现异常,导致消息没有被正确处理。

在自动确认模式下,消息在发送后立即被视为成功传递。此模式以更高的吞吐量(只要消费者能够跟上)为代价,以较低交付和消费者处理的安全性。此模式通常称为“即发即弃”。与手动确认模型不同,如果消费者的TCP连接或通道在成功传递之前关闭,则服务器发送的消息将丢失。因此,应将自动消息确认视为不安全,并且不适合所有工作负载。

3.2.2 手动应答

手动应答模式下,消费者需要显式地向RabbitMQ服务器发送应答,告诉服务器这个消息已经被正确处理。这种模式下,消费者可以在处理消息时进行异常处理,确保消息不会被误删除。但是,手动应答模式需要消费者在处理消息时显式地调用应答方法,增加了代码的复杂性。

默认情况下,手动消息应答处于打开状态。在前面的例子中,我们通过autoAck=true标志明确地关闭了它们。一旦我们完成了一项任务,是时候将此标志设置为false,并从消费者那里发送一个适当的确认了。

手动发送的确认可以是正数或负数,并使用以下协议方法之一:

  • basic.ack 用于肯定的确认
  • basic.nack 用于否定确认
  • basic.reject 用于否定确认,但与 basic.nack 相比有一个限制
basic.nack与basic.reject的区别
1.`basic.reject`会立即拒绝所有未被确认的消息,而不会等待客户端发送回调来处理这些消息。
2.`basic.nack` 则允许你选择要接收或拒绝的特定消息。他将发送一个包含所选消息ID的`basic.reject`消息给客户端,告诉它那些消息已被拒绝。
因此,如果您只想拒绝某些消息,可以使用basic.reject;如果您需要拒绝所有未被确认的消息,或者想要更精细地控制哪些消息被拒绝,可以使用basic.nack。

详解三种协议:

详解 channel#basicAck 方法:该方法是RabbitMQ中用于确认消息是否已经被成功处理的方法之一。他允许您选择要接收或拒绝的特定消息,并将这些消息标记为已确认。

void basicAck(long deliveryTag, boolean multiple)

deliveryTag :表示要确认的消息的唯一标识符。在生产者发送消息时,可以使用此标识符来标识消息。

multiple :标识是否接受多个未确认消息的确认请求。如果设置为true,则会接受所有未确认消息的确认请求;如果设置为 false ,则只会接受一个未确认消息的确认请求。

详解 channel#basicNack 方法:是RabbitMQ中用于拒绝消息的方法之一。它允许您选择要接收或拒绝的特定消息,并将这消息标记为已拒绝。

void basicNack(long deliveryTag, boolean multiple, boolean requeue)

deliveryTag :表示要拒绝的消息的唯一标识符。在生产者发送消息时,可以使用此标识符来标识消息。

multiple :表示是否接受多个未确认消息的拒绝请求。如果设置为true,则会接受所有未确认消息的拒绝请求;如果设置为 false,则只会接受一个未确认消息的拒绝请求 。

requeue :表示是否将被拒绝的消息重新放入队列中。如果设置为 true,则会被重新放入队列中;如果设置为 false,则会被丢弃。

详解 channel#basicReject 方法:该方法是 RabbitMQ中用于拒绝消息的方法之一。它允许您选择要接收或拒绝的特定消息,并将这些消息标记为已拒绝。

void basicReject(long deliveryTag, boolean requeue)

下面将讨论如何在客户端库 API 中公开这些方法。

肯定的确认只是指示 RabbitMQ 将消息记录为已传递,并且可以丢弃。对 basic.reject 的否定确认也有同样的效果。区别主要在于语义:可定的确认假设消息已成功处理,而否定的确认表名传递违背处理,但仍应删除。

3.2.3 二者区别

使用自动确认模式时要考虑的另一件重要事情是使用者过载。手动确认模式通常与有界通道预取一起使用,该预取限制通道上未完成(“正在进行”)交付的数量。但是,对于自动确认,根据定义没有这样的限制。因此,使用者可能会被交付速率压垮。因此,使用者可能会被交付速率压垮,可能会在内存中累积积压并耗尽堆或使其进程作系统终止。某些客户端库将应用TCP被压(停止从套接字读取,指导未处理交付的积压超过特定限制)。因此,仅建议能够高效且稳定地处理交付的消费者使用自动确认模式。

3.2.4 一次确认多个交付

可以对手动确认进行批处理以减少网络流量。这是通过将确认方法的multiple 参数值设置为true来完成的。请注意,basic.reject 历史上没有该字段,这就是为什么RabbitMQ将basic.nack作为协议扩展引入的原因。

当多个multiple 参数值为true时,RabbitMQ 将确认所有未完成的交付标签,包括确认中指定的标签。与确认相关的其他所有内容一样,这是按通道限定的。例如,假设通道Hh上有未确认的传递标记5、6、7和8,当确认帧到达该通道确且deliveryTag 设置为 8 且多个设置为 true 时,将从 5 到 8的所有标记都得到确认。如果将multiple 参数值设置为 false,则传递 5、6和 7 仍不被确认。

如图:

image-20230603182514489

要使用 RabbitMQ Java 客户端确认多次交付,请将 multiple 参数的 true 传递给 Channel#basicAck:

// this example assumes an existing channel instance

boolean autoAck = false;
channel.basicConsume(queueName, autoAck, "a-consumer-tag",
     new DefaultConsumer(channel) {
         @Override
         public void handleDelivery(String consumerTag,
                                    Envelope envelope,
                                    AMQP.BasicProperties properties,
                                    byte[] body)
             throws IOException
         {
             long deliveryTag = envelope.getDeliveryTag();
             // positively acknowledge all deliveries up to
             // this delivery tag
             channel.basicAck(deliveryTag, true);
         }
     });

3.2.5 否定确认和重新排队交付

有时,消费者无法立即处理交付,但其他实例可能可以。在这种情况下,可能需要将其重新排队,并让另一个消费者接收和处理它。basic.reject 和 basic.nack 是用于此目的的两种协议方法。

这些方法通常用于否定确认交付。此类交付可以由代理丢弃或死信或重新排队。此行为由重新排队字段控制。当该字段设置为 true 时,代理将使用指定的交付标记对交付(或多个交付,稍后将解释)重新排队。或者,当此字段设置为 false 时,消息将被路由到死信交换(如果已配置),否则将被丢弃。

a

此图是一个渐进图,当消费者1的连接断开后,消费者1未应答。此时消费者可以处理此消息。

3.2.6 手动应答代码案例

生产者:

/**
 * @Author 蜡笔小新
 * @Description 测试手动应答时消息是否丢失
 * @Version 1.0
 */
public class NewTask2 {

    /**
     * 定义队列名称
     */
    public static final String QUEUE_NAME = "ack_queue";

    public static void main(String[] args) {
        try {
            // 获取连接对象
            Connection connection = ConnectionUtil.getConnection("192.168.2.138", "admin", "admin");
            // 创建信道
            Channel channel = connection.createChannel();
            // 声明队列
            channel.queueDeclare(QUEUE_NAME,false,false,false,null);
            Scanner sc = new Scanner(System.in);
            while (sc.hasNext()){
                String message = sc.next();
                channel.basicPublish("",QUEUE_NAME,null,message.getBytes("UTF-8"));
                System.out.println("消息已发送...");
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

消费者C1:

/**
 * @Author 蜡笔小新
 * @Description 测试手动应答的消费者1,要求不允许消息丢失,如果某个消费者宕机,消息重新排队被其他的消费者进行消费
 * @Version 1.0
 */
public class Consumer1 {

    /**
     * 声明队列名
     */
    public static final String QUEUE_NAME = "ack_queue";

    public static void main(String[] args) {
        // 获取连接对象
        Connection connection = ConnectionUtil.getConnection("192.168.2.138", "admin", "admin");
        System.out.println("C1 等待接收消息...  处理时间较短");
        try {
            // 创建信道
            Channel channel = connection.createChannel();
            // 声明队列
            channel.queueDeclare(QUEUE_NAME, false, false, false, null);
            // 消费成功回调
            DeliverCallback deliverCallback = (consumerTag, message) -> {
                // 这里模拟场景睡眠2秒
                try {
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("消息接收成功" + new String(message.getBody(),"UTF-8"));

                // 进行手动应答
                channel.basicAck(message.getEnvelope().getDeliveryTag(), false);
            };
            // 取消回调函数
            CancelCallback cancelCallback = (consumerTag)->{
                System.out.println("消费消息被取消");
            };

            // 自动应答
            boolean autoAck = false;
            // 消费消息
            channel.basicConsume(QUEUE_NAME,autoAck,deliverCallback,cancelCallback);

        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

消费者C2:

/**
 * @Author 蜡笔小新
 * @Description 测试手动应答的消费者2
 * @Version 1.0
 */
public class Consumer2 {
    /**
     * 声明队列名
     */
    public static final String QUEUE_NAME = "ack_queue";

    public static void main(String[] args) {
        // 获取连接对象
        Connection connection = ConnectionUtil.getConnection("192.168.2.138", "admin", "admin");
        System.out.println("C2 等待接收消息...  处理时间较长");
        try {
            // 创建信道
            Channel channel = connection.createChannel();
            // 声明队列
            channel.queueDeclare(QUEUE_NAME, false, false, false, null);
            // 消费成功回调
            DeliverCallback deliverCallback = (consumerTag, message) -> {
                // 这里模拟场景睡眠2秒
                try {
                    TimeUnit.SECONDS.sleep(10);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("消息接收成功" + new String(message.getBody(),"UTF-8"));

                // 进行手动应答
                channel.basicAck(message.getEnvelope().getDeliveryTag(), false);
            };
            // 取消回调函数
            CancelCallback cancelCallback = (consumerTag)->{
                System.out.println("消费消息被取消");
            };

            // 自动应答
            boolean autoAck = false;
            // 消费消息
            channel.basicConsume(QUEUE_NAME,autoAck,deliverCallback,cancelCallback);

        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

测试结果:

image-20230604165055719

首先生产者发送AA,由于轮询分发,该消息被C1接收,生产者再次发送消息BB,由于C2睡眠了10秒,此时消息BB不会被C1接收,这是因为轮询分发,等待10秒到了后,消息BB被C2接收;此时,再发送消息CC,消息CC则被C1接收,再发送消息DD,由于C2需要等待10秒,假设过了5秒C2的进程被杀掉,本该C2接收的消息DD由于C2还没有执行 basicAck,消息DD被C1接收了。说明消息重新入队了,然后分配给能处理消息的C1处理了。

3.3 持久化

我们已经学会了如何确保即使消费者死亡,任务也不会丢失。但是如果 RabbitMQ 服务器停止,我们的任务仍然会丢失。

当 RabbitMQ 退出或崩溃时,它会忘记队列和消息,除非您告诉它不要这样做。要确保消息不会丢失,需要做两件事:我们需要将队列和消息都标记为持久。

3.3.1 队列持久化

在这之前我们声明的队列都是非持久化的,如果RabbitMQ的服务器重启的话,声明的非持久化队列就会被删除掉。我们需要确保队列在 RabbitMQ 节点重新启动后能够幸存下来。为此,我们需要将其声明为持久:只需将 durable 参数的值设置为 True 即可。

boolean durable = true;
channel.queueDeclare("hello", durable, false, false, null);

尽管此命令本身是正确的,但它在我们当前的设置中不起作用。这是因为我们已经定义了一个名为 hello 的队列,它不持久。RabbitMQ 不允许您使用不同的参数重新定义现有队列,并且会向任何尝试这样做的程序返回错误,如下:

Caused by: com.rabbitmq.client.ShutdownSignalException: channel error; protocol method: #method<channel.close>(reply-code=406, reply-text=PRECONDITION_FAILED - inequivalent arg 'durable' for queue 'hello' in vhost '/': received 'true' but current is 'false', class-id=50, method-id=10)

但是有一个快速的解决方法 - 让我们声明一个具有不同名称的队列,例如task_queue:

boolean durable = true;
channel.queueDeclare("task_queue", durable, false, false, null);

此队列声明更改需要同时应用于生产者代码和使用者代码。

在这一点上,我们确信及时RabbitMQ重新启动,task_queue 队列也不会丢失。

3.3.2 消息持久化

现在我们需要将我们的消息也标记为持久-通过 MessageProperties (实现 BasicProperties) 设置值为 PERSISTENT_TEXT_PLAIN。

channel.basicPublish("", "task_queue",
            MessageProperties.PERSISTENT_TEXT_PLAIN,
            message.getBytes());

3.4 公平调度

你可能已经注意到,调度(上文的轮询分发)仍然不能完全按照我们想要的方式工作。例如,在有两个工作线程的情况下,当所有奇数消息都很重而偶数消息都很轻时,一个工作者将一直很忙,而另一个工作者几乎不工作。但RabbitMQ 对此一无所知,仍然会均匀地分发消息而不管它们的工作负载是否相同。

发生这种情况是因为 RabbitMQ 只是在消息进入队列时分派消息。他不考虑消费者未确认消息的数量。他只是盲目地将第n条消息发送给第n个消费者。

prefetch-count

为了解决这个问题,我们可以采用RabbitMQ的公平调度模式。在公平调度模式中,RabbitMQ会将消息分配给一个消费者,直到该消费者处理完当前的消息并将确认(ACK)发送回RabbitMQ。然后,RabbitMQ才会将下一个消息分配给该消费者。这样,消费者处理消息的速度就不会影响到其他消费者的工作负载,因为每个消费者都只会处理一个消息,直到他完成为止。

要实现公平调度,需要在消费之之间使用RabbitMQ的基础QoS(Quality of Service)功能。可以使用basic.qos 方法指定预取计数(prefetch count),即指定RabbitMQ一次向一个消费者发送多少个消息。然后,消费者可以使用basic.ack 方法来确认消息,以便RabbitMQ知道何时可以将下一个消息分配给它。

例如,basic.qos(1) 表示每个消费者一次只能获取一个消息。这样,当有多个消费者连接到同一个队列时,每个消费者都会一次获取一个消息,直到所有消息都被消费完为止。

// 该设置是在消费方设置的且在接收消息之前
int prefetchCount = 1;
channel.basicQos(prefetchCount);

但是呢,公平调度并不是绝对的,因为 RabbitMQ 只能保证在同一时间内每个消费者获取相等数量的消息。如果某个消费者处理消息的速度比其他消费者快或者消费者所处理的消息的大小和复杂度的不同,那么它仍然可能会获取更多的消息。因此,在实际应用中,需要根据具体情况进行调整和优化,以确保系统能够高效稳定地运行。

public class NewTask2 {

    	//... 部分代码省略
    
            // 持久化队列
            boolean durable = true;
            // 声明队列
            channel.queueDeclare(QUEUE_NAME,durable,false,false,null);

            Scanner sc = new Scanner(System.in);
            while (sc.hasNext()){
                String message = sc.next();

            // 发送消息
            // MessageProperties.PERSISTENT_TEXT_PLAIN 将消息持久化
            channel.basicPublish("",QUEUE_NAME,MessageProperties.PERSISTENT_TEXT_PLAIN ,message.getBytes("UTF-8"));
}
public class Consumer1 {
    
    // ... 部分代码省略

            // 队列持久化
            boolean durable = true;

            // 声明队列
            channel.queueDeclare(QUEUE_NAME, durable, false, false, null);
            // 消费成功回调
            DeliverCallback deliverCallback = (consumerTag, message) -> {
                // 这里模拟场景睡眠2秒
                try {
                    TimeUnit.SECONDS.sleep(2);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                System.out.println("C1消息接收成功" + new String(message.getBody(),"UTF-8"));

                // 进行手动应答
                channel.basicAck(message.getEnvelope().getDeliveryTag(), false);
            };
         

            int prefetchCount = 1;
            // 设置公平调度
            channel.basicQos(prefetchCount);

            // 关闭自动应答
            boolean autoAck = false;
            // 消费消息
            channel.basicConsume(QUEUE_NAME,autoAck,deliverCallback,cancelCallback);
}

Consumer2与Consumer1类似,不在粘贴;执行结果如下图:

可以看到结果中C1接收到了三个消息,而C2只接收了一个,因为设置了公平调度,C2在睡眠10秒钟的过程中处理消息的速度过慢导致ACK不能返回给RabbitMQ,与此同时,C1已经处理了3条消息,因而C2只接收到了一条消息。相比轮询分发,不管C2睡眠多长时间,C1与C2都会接收到两条消息,并且是C1接收AA、CC,C2接收BB、DD。

image-20230604191048993

如果我们把 channel.basicQos(prefetchCount); 注释掉,再看看结果:

image-20230605190405203

总的来说,公平调度可以帮助消费者在处理消息时尽可能公平地分配工作负载,但在实际应用中,仍然需要根据具体情况来选择合适的消息分发方式,并根据业务需求和性能要求来调整公平调度的预取计数等参数。

3.5.1 通道预取设置(预取值)

RabbitMQ的通道预取(Channel Prefetch)设置是指指定消费者从RabbitMQ队列中预取(Prefetch)多少条消息。通道预取设置可以帮助优化RabbitMQ消费者的性能,并控制消费者从队列中获取消息的速度。

在RabbitMQ中,消费者可以使用 basic.qos 方法来设置通道预取设置。通过设置通道预取值,消费者可以控制从队列中获取的消息数量,从而有效地控制其处理消息的速度。

通道预取设置可以在两个级别进行设置:

  1. 每个通道(Channel):在每个通道级别上设置通道预取数量。这意味着每个消费者都可以独立地控制其从队列中获取消息的速度。

  2. 每个连接(Connection):在每个连接级别上设置通道预取数量。这意味着所有消费者将共享相同的通道预取值,并且将以相同的速度从队列中获取消息。

通道预取设置可以帮助控制消费者从队列中获取消息的速度,从而有效地避免消费者过度消耗资源或过度加载RabbitMQ服务器。通过设置合理的通道预取值,可以提高消费者的性能,并确保其能够有效地处理从队列中获取的消息。

该值定义通道上允许的最大未确认传递数。当数量达到配置的计数时,RabbitMQ 将停止在通道上传递更多消息,直到至少确认一个未完成的消息。

值为 0 表示 “无限制” , 允许任意数量的未确认消息。

例如,假设通道 Ch 上有四个交付标签为5、6、7 和 8 未确认,并且通道 Ch 的预取计数设置为 4 ,RabbitMQ将不会在 Ch 上推送任何交付,除非至少确认了一个未完成的交付(被ack)。当确认帧到达该信道,比如delivery_tag为5这个消息被Ack,RabbitMQ将逐一到并再传递一条消息。

确认模式和QoS预取值对消费者吞吐量有显著影响。一般来说,增加预取将提高向消费者传递消息的速率。自动确认模式可产生尽可能好的交付速率。然而,在这两种情况下,已发送但尚未处理的消息的数量也将增加,从而增加消费者的RAM消耗。因谨慎使用具有无限制预取的自动确认模式或手动确认模式。消费者在没有确认的情况下消耗了大量消息,这将导致其连接的节点上的内存消耗增长。找到合适的预取值是一个反复尝试的问题,并且会因工作负载而异。在100到300范围内的值通常提供最佳吞吐量,并且不会带来压倒消费者的重大风险。更高的价值往往符合收益递减定律。预取值1是最保守的。它将显著降低吞吐量,特别是在消费者连接延迟较高的环境中。对于许多应用来说,更高的值将是合适和最佳的。

3.5.2 公平调度与轮询分发

学到这里感觉公平调度与轮询分发很相似,那么他们的区别是什么呢?

RabbitMQ的公平调度(Fair Dispatch)和轮询分发(Round-robin Dispatch)是两种不同的消息分发方式,它们的区别主要在于消息的分配方式和消费者的工作负载。

轮询分发是默认的分发方式,它会将消息均匀地分配给每个消费者,所有的消费者都尽可能都处理相同数量的消息,不管他们的工作负载是否相同。但是,如果某个消费者处理消息的速度较慢,那么他将会阻塞其他消费者的工作。在这种分发模式下,每个消息只会被发送给一个消费者,这种方式也叫作”竞争消费者模式“。

相比之下,公平调度会确保每个消费者都能公平地分配工作负载。在公平调度中,RabbitMQ会将消息分配给一个消费者,直到该消费者处理完当前的消息并将确认(ACK)发送回RabbitMQ。然后,RabbitMQ才会将下一个消息分配给该消费者。这样,消费者处理消息的速度就不会影响到其他消费者的工作负载,因为每个消费者都只会处理一个消息,直到它完成为止。

因此,公平调度相比轮询分发能够更公平地分配工作负载,避免一些消费者拥有更多的工作负载,而其他消费者则很少处理消息。但是,公平调度需要更多的处理开销,因为RabbitMQ需要追踪每个消费者处理的消息数量,并根据需要将下一个消息分配给它。

3.5 发布者确认

3.5.1 发布者确认原理

在 RabbitMQ 中,发布者确认(publisher confirm)是一种机制,用于确保生产者发送的消息已经被成功地传递到RabbitMQ服务器。这个机制可以帮助开发人员确保消息已经被正确地处理,从而减少消息丢失或重复的可能性。

当生产者发送一条消息到RabbitMQ时,它可以选择启用发布者确认。如果启用了发布者确认,生产者将消息发送到RabbitMQ并等待RabbitMQ的确认。生产者可以通过channel.confirmSelect()方法启用确认模式。在确认模式下,每次发送消息时,生产者会为该消息分配一个唯一的deliveryTag标识符。当RabbitMQ成功接收到消息时,它会向生产者发送一个Basic.Ack消息,其中包含deliveryTag标识符。如果消息无法到达RabbitMQ,则会向生产者发送一个Basic.Nack消息,以指示消息发送失败。在这种情况下,生产者可以选择重新发送消息或者采取其他措施来处理这个错误。如果mandatory标志设置为true且消息无法路由到任何队列,则RabbitMQ将向生产者发送Basic.Return消息。

需要注意的是,启用发布者确认会增加消息传递的延迟,因为RabbitMQ需要向生产者发送确认消息。但是,这个机制可以提高系统的可靠性和鲁棒性,从而使系统在高负载和故障情况下更加稳定。

3.5.2 发布者确认策略

RabbitMQ提供了以下三种发布者确认策略:

  1. 单个确认模式(single-ack mode):在单个确认模式下,生产者只需要等待一个确认消息,以确认消息已经成功到达队列。如果收到了nack消息,则表示消息未能成功到达队列。单个确认模式是默认的确认模式。
  2. 多个确认模式(multiple-ack mode):在多个确认模式下,生产者可以将多个消息一起发送到RabbitMQ,并等待一组确认消息,以确认这些消息已经全部成功到达队列。如果其中一个消息未能成功到达队列,则RabbitMQ会发送nack消息,并返回整个批次的消息。否则,RabbitMQ会发送一组ack消息,以确认所有消息都已经成功到达队列。
  3. 异步确认模式(async-ack mode)
    异步确认模式是最高效的确认模式之一,它不需要等待消费者确认消息的处理结果。在异步确认模式下,当RabbitMQ成功地将消息传递到队列中时,它会立即发送一条确认消息给发布者,告诉它消息已经被成功处理。这种确认模式不会阻塞发布者线程,因此可以提高消息发布的吞吐量。但是,由于确认消息和实际处理消息的顺序可能不同,因此无法保证消息的顺序。同时,如果其中有一条消息处理失败,整个批次的消息都需要重新确认,可能会浪费已经处理过的消息。

选择哪种确认模式取决于具体的应用场景和需求。如果需要高吞吐量并且可以容忍一些消息丢失的情况,可以选择单个确认模式。如果需要确保所有消息都被成功地发送到队列中,并且可以容忍一些额外的延迟,可以选择多个确认模式。

3.5.3 如何开启发布者确认

在Java语言中使用RabbitMQ开启发布者确认(Publisher Confirms)机制,需要完成以下步骤:

  1. 创建一个RabbitMQ连接,并创建一个channel。
ConnectionFactory factory = new ConnectionFactory();
factory.setHost("localhost");
Connection connection = factory.newConnection();
Channel channel = connection.createChannel();
  1. 将channel设置为confirm模式。
channel.confirmSelect();
  1. 通过addConfirmListener()方法向channel对象添加一个监听器,以处理RabbitMQ服务器发送的确认消息。
channel.addConfirmListener(new ConfirmListener() {
    @Override
    public void handleAck(long deliveryTag, boolean multiple) throws IOException {
        // 处理Ack消息
    }

    @Override
    public void handleNack(long deliveryTag, boolean multiple) throws IOException {
        // 处理Nack消息
    }
});
  1. 发送消息,并等待确认消息。
String message = "Hello World!";
channel.basicPublish("", "my_queue", null, message.getBytes());
channel.waitForConfirmsOrDie();

以上代码中,我们使用ConnectionFactory创建了一个RabbitMQ连接,并创建了一个channel。然后,我们将channel设置为confirm模式,并使用 addConfirmListener() 方法向channel对象添加一个监听器,以处理RabbitMQ服务器发送的确认消息。最后,我们发送了一条消息,并使用 waitForConfirmsOrDie() 方法等待确认消息。如果消息已经成功发送到队列中,RabbitMQ服务器会发送一个Ack消息。如果消息未能成功发送到队列中,RabbitMQ服务器会发送一个Nack消息。

需要注意的是,waitForConfirmsOrDie() 方法是一个同步方法,会一直等待确认消息的到来,直到收到Ack或Nack消息或者发生异常为止。因此,在生产环境中,建议使用异步的confirm模式,以避免阻塞应用程序的运行。

3.5.4 代码实现

/**
 * @Author 蜡笔小新
 * @Description 发布者确认模式
 * @Version 1.0
 */
public class PublisherConfirm {

    // 获取连接对象
    static Connection connection = ConnectionUtil.getConnection("192.168.2.138", "admin", "admin");

    // 批量发消息的个数
    static final int message_count = 1000;


    public static void main(String[] args) {

        // 调用单个发布确认
//        SingleAcknowledgement();

        // 调用多个发布确认
//        MultipleAcknowledgements();

        // 调用异步发布确认
        AsynchronousAcknowledgement();

    }

    /**
     * 单个确认
     */
    private static void SingleAcknowledgement() {

        // 定义队列名称
        String QUEUE_NAME = "single_ack";

        // 创建信道
        try (Channel channel = connection.createChannel()) {

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

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

            // 获取开始时间
            long beginTime = System.currentTimeMillis();

            for (int i = 0; i < message_count; i++) {
                String message = i + " ";
                // 发布消息
                channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
                // 等待确认
                boolean flag = channel.waitForConfirms();
                if (flag) {
                    System.out.println("消息发送成功");
                }
            }
            // 结束时间
            long endTime = System.currentTimeMillis();
            System.out.println("发布" + message_count + "个消息单独确认总耗时" + (endTime - beginTime) + "ms");

        } catch (IOException e) {
            throw new RuntimeException(e);
        } catch (TimeoutException e) {
            throw new RuntimeException(e);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 多个发布确认
     */
    private static void MultipleAcknowledgements() {

        // 定义队列名称
        String QUEUE_NAME = "multiple_ack";

        // 创建信道
        try (Channel channel = connection.createChannel()) {

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

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

            // 定义多少条消息确认
            int batchSize = 100;

            // 获取开始时间
            long beginTime = System.currentTimeMillis();

            for (int i = 0; i < message_count; i++) {
                String message = i + " ";
                // 发布消息
                channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
                // 每当发布100条时消息确认一次
                if (i % batchSize == 0) {
                    // 等待确认
                    channel.waitForConfirms();
                }

            }
            // 结束时间
            long endTime = System.currentTimeMillis();
            System.out.println("发布" + message_count + "个消息批量确认总耗时" + (endTime - beginTime) + "ms");

        } catch (IOException e) {
            throw new RuntimeException(e);
        } catch (TimeoutException e) {
            throw new RuntimeException(e);
        } catch (InterruptedException e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 异步确认
     */
    private static void AsynchronousAcknowledgement() {

        // 定义队列名称
        String QUEUE_NAME = "async_ack";

        try (Channel channel = connection.createChannel()) {

            channel.queueDeclare(QUEUE_NAME,true,false,false,null);

            // 开启确认模式
            channel.confirmSelect();

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

            // 给channel对象添加一个监听器
            channel.addConfirmListener(new ConfirmListener() {
                @Override
                public void handleAck(long deliveryTag, boolean multiple) throws IOException {
                    // 删除已确认消息,剩下的就是未确认消息
                    System.out.println("确认消息编号" + deliveryTag +"" + multiple);
                }

                @Override
                public void handleNack(long deliveryTag, boolean multiple) throws IOException {
                    // 打印未确认消息
                    System.out.println("未确认消息编号" + deliveryTag);
                }
            });

            for (int i = 0; i < message_count; i++) {

                String message = i + "";
                // 发送消息
                channel.basicPublish("",QUEUE_NAME,null,message.getBytes());

                // 1.处理异步未确认的消息


            }

            // 结束时间
            long endTime = System.currentTimeMillis();

            System.out.println("发布" + message_count + "个消息异步确认总耗时" + (endTime - beginTime) + "ms");


        } catch (IOException e) {
            throw new RuntimeException(e);
        } catch (TimeoutException e) {
            throw new RuntimeException(e);
        }
    }
}

3.5.4 如何处理异步未确认消息

发布确认未确认的消息通常是指在发送消息后,消息发布者没有立即收到确认消息,或者接收方没有立即发送确认消息。这可能是由于网络延迟、故障或其他原因导致的。

要处理发布确认未确认的消息,可以采用以下方法:

  1. 重试机制:如果消息发布者没有收到确认消息,可以尝试重新发送消息。可以设置重试次数和时间间隔,以确保消息被成功发布。然而,应该注意避免在短时间内频繁地重试,以免浪费资源或导致网络拥塞。
  2. 超时机制:如果在一定时间内没有收到确认消息,可以认为消息未被成功发布。可以设置超时时间,超时后可以采取相应的措施,例如发送警告通知或记录日志。
  3. 状态跟踪:可以使用状态机或其他方法跟踪消息的状态,以便及时发现未确认消息。这样可以及时处理未确认消息,避免消息丢失或重复处理。
  4. 双向确认:在发布消息时,可以要求接收方发送确认消息。这样可以确保消息已被成功接收,避免发布确认未确认的问题。

总之,处理发布确认未确认的消息需要综合考虑网络延迟、故障和其他因素,采取合适的措施以确保消息的正确发布和处理。

案例:

private static void AsynchronousAcknowledgement() {
    // 获取连接、创建信道、开启发布确认
    // ....
    
    // 使用线程安全的有序映射表处理未确认的消息
    ConcurrentSkipListMap<Long,String> map = new ConcurrentSkipListMap<>();


    // 给channel对象添加一个监听器
    channel.addConfirmListener(new ConfirmListener() {
        @Override
        public void handleAck(long deliveryTag, boolean multiple) throws IOException {
            // 返回的消息有时候是批量确认的,批量确认需要判断!
            if(multiple){
                // 返回的是小于等于当前序列号的未确认消息 是以map
                ConcurrentNavigableMap confirmed = map.headMap(deliveryTag,true);
                // 清除确认的 剩余的就是未确认的
                confirmed.clear();
            }else {
                // 只清除当前序列号的消息
                map.remove(deliveryTag);
            }
            System.out.println("确认消息编号" + deliveryTag);
        }

        @Override
        public void handleNack(long deliveryTag, boolean multiple) throws IOException {
            // 打印未确认消息
            System.out.println("未确认消息编号" + deliveryTag);
        }
    });

    for (int i = 0; i < message_count; i++) {

        String message = i + "";
        // 发送消息
        channel.basicPublish("",QUEUE_NAME,null,message.getBytes());

        // 通过map,将消息与消息序列号进行绑定
        map.put(channel.getNextPublishSeqNo(),message);
        
    }

4.Publish/Subscribe

在上一教程中,我们创建了一个工作队列。工作队列背后的假设是每个任务只传递给一个工作人员。在这一部分中,我们将做一些完全不同的事情 - 我们将向多个消费者传递消息。此模式称为“发布/订阅”。

为了说明这种模式,我们将构建一个简单的日志记录系统。它将由两个程序组成 - 第一个将发出日志消息,第二个将接收和打印它们。

在我们的日志记录系统中,接收器程序的每个运行副本都将获得消息。这样,我们将能够运行一个接收器并将日志定向到磁盘;同时,我们将能够运行另一个接收器并在屏幕上查看日志。

实质上,已发布的日志消息将广播给所有接收方。

4.1 交换机

在本教程的前几部分中,我们向队列发送消息和从队列接收消息。现在是时候在 Rabbit 中引入完整的消息传递模型了。

4.1.1 交换机概念

RabbitMQ 中消息传递模型的核心思想是,生产者从不将任何消息直接发送到队列。实际上,很多时候,生产者甚至根本不知道消息是否会传递到任何队列。

相反,生产者只能向交换机发送消息。交换是一件非常简单的事情。一方面,它接收来自生产者的消息,另一方面将它们推送到队列中。交换机必须确切地知道如何处理它收到的消息。是否应将其附加到特定队列?是否应该将其附加到许多队列中?或者应该丢弃它。其规则由交换类型定义。

exchanges

4.1.2 交换机类型

有几种可用的交换类型:直接(direct)、主题(topic)、标题(headers )和扇出(fanout)。我们将专注于最后一个"扇出"。

RabbitMQ自带的交换机

image-20230607212453888

4.1.3 列出交换机

要列出服务器上的交换机,您可以运行有用的 rabbitmqctl命令:

sudo rabbitmqctl list_exchanges

4.1.4 无名交换机

在前面的章节中,我们对交换机一无所知,但仍然能够将消息发送到队列。这是可能的,因为我们使用的是默认交换机,我们用空字符串 (“”) 标识。

channel.basicPublish("", "hello", null, message.getBytes());

第一个参数是交换机的名称。空字符串表示默认或无名称交换机:消息使用路由密钥指定的名称路由到队列(如果存在)。

现在,我们可以改为发布到我们命名的交换机:

channel.basicPublish( "logs", "", null, message.getBytes());

4.2 临时队列

您可能还记得以前我们使用具有特定名称的队列(还记得 hello 和 task_queue 吗?能够命名队列对我们来说至关重要 - 我们需要将工人指向同一个队列。如果要在生产者和使用者之间共享队列,为队列命名非常重要。

但对于我们的记录器来说,情况并非如此。我们希望听到所有日志消息,而不仅仅是其中的一部分。我们也只对当前流动的消息感兴趣,而不是旧消息。为了解决这个问题,我们需要两件事。

首先,每当我们连接到Rabbit时,我们都需要一个新的空队列。为此,我们可以创建一个具有随机名称的队列,或者更好的是 - 让服务器为我们选择一个随机队列名称。

其次,一旦我们断开了消费者的连接,队列应该被自动删除。

在Java客户端中,当我们不向queueDeclare()提供参数时,我们会创建一个具有生成名称的非持久、独占、自动删除队列:

String queueName = channel.queueDeclare().getQueue();

您可以在队列指南中了解有关独占标志和其他队列属性的更多信息。

此时,队列名称包含一个随机队列名称。例如,它可能看起来像amq.gen-JzTY20BRgKO-HjmUJj0wLg。

4.3 绑定

bindings

绑定是交换机和队列之间的关系。可以简单地理解为:队列对来自此交换机的消息感兴趣。

可以在客户端使用命令查看当前的绑定信息:

rabbitmqctl list_bindings

可以使用 queueBind 方法将队列与交换机绑定到一起,如下:

channel.queueBind("队列名", "交换机名", "路由键");

在RabbitMQ的可视化界面中查看绑定关系如下图:

image-20230608134321878

4.4 多重绑定

direct-exchange-multiple

使用相同的绑定键绑定多个队列是完全合法的。在我们的示例中,我们可以在 X 和 Q1 之间添加一个绑定,绑定键为 black。在这种情况下,直接交换的行为类似于扇出,并将消息广播到所有匹配的队列。路由密钥为black的消息将同时传递到 Q1 和 Q2。

4.5 Fanout Exchange(扇出)

python-three-overall

发出日志消息的生产者程序看起来与上一节没有太大区别。最重要的变化是我们现在希望将消息发布到我们的日志交换,而不是无名称的。我们需要在发送时提供一个路由密钥,但对于扇出交换,它的值会被忽略。下面是 EmitLog.java 程序的代码:

/**
 * @Author 蜡笔小新
 * @Description 日志生产者
 * @Version 1.0
 */
public class EmitLog {

    /**
     * 定义交换机名称
     */
    private static final String EXCHANGE_NAME = "logs";

    public static void main(String[] args) {
		// 注意这里不要是用 try-with-resources 
        // 因为代码块执行完毕后,Java 会自动关闭资源			
        // Channel 和 Connection 类都实现了 AutoCloseable 接口
        try {
            // 创建信道
        	Channel channel = ConnectionUtil.getChannel();
            // 声明一个广播类型的交换机
            channel.exchangeDeclare(EXCHANGE_NAME, "fanout");

            Scanner sc = new Scanner(System.in);

            while (sc.hasNext()) {
                String message = sc.next();
                // 发布消息
                channel.basicPublish(EXCHANGE_NAME, "", null, message.getBytes("UTF-8"));
                System.out.println("消息" + message + "已发送");
            }
        } catch (IOException e) {
            throw new RuntimeException(e);
        }

    }
}

ReceiveLogs1.java:

/**
 * @Author 蜡笔小新
 * @Description 消费者1
 * @Version 1.0
 */
public class ReceiveLogs1 {

    /**
     * 定义交换机名称
     */
    private static final String EXCHANGE_NAME = "logs";

    public static void main(String[] args) {

        try {
            // 创建信道
            Channel channel = ConnectionUtil.getChannel();
            // 创建临时队列
            String queueName = channel.queueDeclare().getQueue();

            // 将交换机与临时队列进行绑定

            channel.queueBind(queueName, EXCHANGE_NAME, "");

            // 这段代码是用来将队列绑定到交换机上的。其中,`queueName`是要绑定的队列的名称,
            // `EXCHANGE_NAME`是要绑定到的交换机的名称,
            // `""`表示绑定的路由键为空。这意味着所有发送到该交换机的消息都将被路由到该队列。
            // 如果需要指定特定的路由键,可以将其替换为空字符串。
            DeliverCallback deliverCallback = (consumerTag, message) -> {
                System.out.println("ReceiveLogs1 接收到的消息:  "+new String(message.getBody(), "UTF-8"));
            };

            // 接收消息
            channel.basicConsume(queueName, true, deliverCallback, consumerTag -> {
            });
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }
}

ReceiveLogs2与ReceiveLogs1一样,不再粘贴。执行结果如下图:

image-20230608133454451

可以看到,ReceiveLogs1与ReceiveLogs2接收到的结果是相同的。也就是说来自日志交换机的数据进入具有服务器分配名称的两个队列。

总结:

RabbitMQ中的fanout类型交换机是一种广播机制,它会将消息发送到所有与之绑定的队列中,无论队列是否有消费者。当你需要一条消息被多个消费者同时接收时,可以使用fanout类型交换机。

在fanout类型交换机中,消息不会被路由到特定的队列,而是直接广播到所有与该交换机绑定的队列中。这种交换机是最简单的一种交换机类型,它不需要路由键,只需要将队列绑定到交换机即可。

使用fanout类型交换机时,需要将交换机与队列进行绑定。绑定时需要指定交换机的名称,队列的名称以及可选的路由键。如果路由键为空,则会将消息广播到所有绑定的队列中。

4.6 Direct Exchange(直接)

在上一节中,我们构建了一个简单的日志记录系统。我们能够将日志消息广播到许多接收器。在本本节,我们将为其添加一个功能 - 我们让某个消费者仅订阅发布消息的子集(部分消息)。例如,我们只将关键错误消息定向到日志文件(以节省磁盘空间),同时仍然能够在控制台上打印所有日志消息。

绑定可以采用额外的路由键参数。为了避免与basic_publish参数混淆,我们将其称为绑定键。绑定键的含义取决于交换类型。

上一节中的日志记录系统向所有消费者广播所有消息。我们希望将其扩展为允许根据消息的严重性对其进行筛选。例如,我们可能希望将日志消息写入磁盘的程序只接收关键错误,而不在警告或信息日志消息上浪费磁盘空间。

我们使用的是扇出类型得交换机,这并没有给我们太多的灵活性——它只能进行无意识的广播。本节将使用direct (直接)类型的交换机,它的特点是消息进入绑定键与消息的路由键完全匹配的队列。

direct-exchange

在这个设置中,我们可以看到直接交换机 X 绑定了两个队列。第一个队列绑定了绑定键orange,第二个队列有两个绑定,一个绑定键为black ,另一个绑定为green 。在这样的设置中,发布到交换机的具有路由关键字orange的消息将被路由到队列Q1。路由关键字为blackgreen 的消息将进入Q2。所有其他消息都将被丢弃。

案例:将日志级别作为路由键。这样,消费者将能够选择它想要接收的级别消息。

python-four

EmitLogDirect .java

public static void directExchange(){
    // 创建信道
    Channel channel = ConnectionUtil.getChannel();

    try {
        // 创建交换机
        channel.exchangeDeclare(DIRECT_EXCHANGE_NAME, BuiltinExchangeType.DIRECT);
        // 创建多个绑定Key
        Map<String,String> bindingKeyMap = new HashMap<>();
        bindingKeyMap.put("info", "info信息");
        bindingKeyMap.put("warning", "warning信息");
        bindingKeyMap.put("error", "error信息");
        // 没有消费者接收这个键的消息,所以将丢弃
        bindingKeyMap.put("debug", "debug信息");

        // 遍历绑定键和消息的映射
        for (Map.Entry<String,String> bindingKeyEntry : bindingKeyMap.entrySet()){
            // 绑定Key
            String bindingKey = bindingKeyEntry.getKey();

            // 消息
            String message = bindingKeyEntry.getValue();

            // 将消息发布到交换机上
            channel.basicPublish(DIRECT_EXCHANGE_NAME, bindingKey, null,message.getBytes("UTF-8") );

            System.out.println(message + "  已发送");
        }
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

ReceiveLogsDirect1.java:

private static void directExchange(){

    try {
        // 创建信道
        Channel channel = ConnectionUtil.getChannel();

        String queueName = "console";
        // 创建队列
        channel.queueDeclare(queueName,false,false,false,null);
        // 将临时队列与交换机绑定
        channel.queueBind(queueName,DIRECT_EXCHANGE_NAME,"info");
        channel.queueBind(queueName,DIRECT_EXCHANGE_NAME,"warning");
        // 接收响应
        DeliverCallback deliverCallback=(consumerTag,delivery)->{
            String message = new String(delivery.getBody(),"UTF-8");
            System.out.println("绑定键 " + delivery.getEnvelope().getRoutingKey() + " 对应消息 " + message);
        };
        // 接收消息
        channel.basicConsume(queueName, true, deliverCallback, consumerTag -> {});

    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

ReceiveLogsDirect2.java:

private static void directExchange(){

        try {
            // 创建信道
            Channel channel = ConnectionUtil.getChannel();

            String queueName = "disk";
            // 创建队列
            channel.queueDeclare(queueName,false,false,false,null);

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

            DeliverCallback deliverCallback = (consumerTag,delivery)->{
                String message = new String(delivery.getBody(),"UTF-8");
                System.out.println("绑定建 " + delivery.getEnvelope().getRoutingKey() + " 对应消息 " + message);
                File file = new File("C:\\Users\\蜡笔小新\\Desktop\\log.txt");
                FileUtils.writeStringToFile(file,message,"UTF-8");
            };

            // 接收消息
            channel.basicConsume(queueName, true,deliverCallback,consumerTag -> {});

        } catch (IOException e) {
            throw new RuntimeException(e);
        }

    }

执行结果:

image-20230608204121789

可以看到,总共发了四条消息,ReceiveLogsDirect1指定路由键是info与warning,所以接收到的是这两个路由键对应的消息;ReceiveLogsDirect2指定的路由键是error,接收的消息是error。而debug级别没有指定路由键,所以会被丢弃,这是应为direct交换机是一种由路由严格匹配的交换机,只有消息的路由键与绑定的队列的路由键完全匹配时,才会将消息发送到该队列。

4.7 Topics Exchange(主题)

在上一节中,我们改进了日志记录系统。我们没有使用只能进行虚拟广播的扇出交换,而是使用了直接交换,并获得了有选择地接收日志的可能性。尽管使用直接交换改进了我们的系统,但它仍然存在局限性 - 它不能基于多个标准进行路由。

在我们的日志记录系统中,我们可能不仅希望根据日志级别订阅日志,还希望根据发出日志的源订阅日志。您可能从 syslog unix 工具中知道这个概念,该工具根据日志级别(info/warn/crit…)和设施(auth/cron/kern…)路由日志。

这将给我们很大的灵活性 - 我们可能只想侦听来自“cron”的关键错误,但也要听来自“kern”的所有日志。为了在我们的日志记录系统中实现这一点,我们需要了解更复杂的主题交换。

发送到主题交换的消息不能具有任意routing_key - 它必须是单词列表,由点分隔。单词可以是任何东西,但通常它们指定与消息相关的一些特征。一些有效的路由键示例:“stock.usd.nyse”、“nyse.vmw”、“quick.orange.rabbit”。路由键中可以有任意数量的单词,最多可以包含 255 个字节。

绑定键也必须采用相同的形式。主题交换背后的逻辑类似于直接交换 - 使用特定路由键发送的消息将被传递到与匹配绑定键绑定的所有队列。但是,绑定键有两种重要的特殊情况:

  • *可以代替一个词
  • #可以替换零个或多个单词

用一个例子来解释这一点最简单:

python-five

在这个例子中,我们将发送所有描述动物的信息。消息将使用路由密钥发送,路由密钥由三个单词(两个点)组成。路由关键字中的第一个单词将描述速度,第二个单词描述颜色,第三个单词描述物种:“..”。

我们创建了三个绑定:Q1用绑定键 *.orange.* 绑定,Q2用*.*.rabbit以及lazy.#绑定。

这些绑定可以概括为:

  • Q1对所有的orange 动物都感兴趣。
  • Q2想听听关于rabbits的一切,以及关于lazy 动物的一切。

路由关键字设置为quick.orange.rabbit的消息将同时发送到两个队列。信息lazy.orange.elephant也会去他们两个。另一方面,quick.orange.fox只会排在第一位,而lazy.brown.fox则只排在第二位。lazy.pink.rabbit将只被传递到第二个队列一次,即使它匹配两个绑定。quick.brown.fox与任何绑定都不匹配,因此将被丢弃。

如果我们违反了约定,发了一个或四个字的消息,比如orangequick.orange.new.rabbit,会发生什么?好吧,这些消息与任何绑定都不匹配,将丢失。另一方面,lazy.orange.new.rabbit,即使它有四个单词,也将与最后一个绑定相匹配,并将被发送到第二个队列。


主题交换机功能强大,可以像其他交换一样运行。
当队列绑定“#”(哈希)绑定键时 - 它将接收所有消息,而不考虑路由键 - 就像在扇出交换中一样。
当绑定中不使用特殊字符“*”(星号)和“#”(哈希)时,主题交换的行为将类似于直接交换。

代码:

生产者类:

//...
private static void topicExchange() {

    try {
        // 创建信道
        Channel channel = ConnectionUtil.getChannel();
        // 声明交换机
        channel.exchangeDeclare(TOPIC_EXCHANGE_NAME, "topic");

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

        // 遍历map集合
        for (Map.Entry<String, String> keyValueMap : map.entrySet()) {
            // 获取map中的键与值
            String routingKey = keyValueMap.getKey();
            String message = keyValueMap.getValue();
            // 发送消息
            channel.basicPublish(TOPIC_EXCHANGE_NAME,routingKey, null, message.getBytes("UTF-8"));
            System.out.println(message+" 已发送");
        }

    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

消费者类:

//...
private static void topicExchange(){

    String queueName = "Q1";
    try {
        // 创建信道
        Channel channel = ConnectionUtil.getChannel();
        // 创建队列
        channel.queueDeclare(queueName,false,false,false,null);
        // 将队列与交换机进行绑定

        channel.queueBind(queueName,TOPIC_EXCHANGE_NAME,"*.orange.*");

        DeliverCallback deliverCallback = (consumerTag, delivery)->{
            String message = new String(delivery.getBody(),"UTF-8");
            System.out.println("绑定键 " + delivery.getEnvelope().getRoutingKey() + " 对应消息 " + message);
        };

        channel.basicConsume(queueName, true,deliverCallback,consumerTag -> {});

    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

执行结果:

image-20230609174659289

消费者1设置的路由键是*.orange.*所以匹配这个规则的键都能被Q1收到,消费者2设置的路由键*.*.orangelazy.#匹配这两个规则的消息被Q2接收,其中,只要符合*.*.orange规则的消息都能被Q1及Q2接收。只要不符合这三个路由键规则的消息就会被丢弃。显然,我们是不想让消息丢弃掉,那么怎么做呢?继续往下看。

4.8 死信队列

死信队列是RabbitMQ的一个重要的特性(Dead Letter Queue,DLQ)。

死信队列是一种特殊的队列,它用于处理无法被消费者处理的消息。当一条消息被发送到队列中,但无法被消费者处理时,它就会被推送到死信队列中。通常,无法处理的原因可能是消息格式不正确、消费者处理消息时发生异常、消息超时等等。

在RabbitMQ中,可以通过为队列设置死信交换机(Dead Letter Exchange,DLX)和死信路由键(Dead Letter Routing Key,DLK)来配置死信队列。当一条消息被推送到队列中,如果无法被消费者处理,则会被发送到DLX,并且使用DLK作为路由键重新发送到死信队列中。

通过配置死信队列,可以实现对无法处理的消息进行重试或者进行人工处理。另外,还可以通过监控死信队列中的消息数量和内容,来发现系统中的异常情况并进行调整和优化。

image-20230612125852403

4.8.1 消息在队列中过期

Consumer1.java:启动后停掉模拟假死

/**
     * 正常交换机
     */
public static final String NORMAL_EXCHANGE = "normal_exchange";

/**
     * 死信队列
     */
public static final String DEAD_EXCHANGE = "dead_exchange";

/**
     * 正常队列
     */
public static final String NORMAL_QUEUE = "normal_queue";

/**
     * 死信队列
     */
public static final String DEAD_QUEUE = "dead_queue";

public static void main(String[] args) {

    try {
        // 创建信道
        Channel channel = ConnectionUtil.getChannel();
        // 创建交换机
        channel.exchangeDeclare(NORMAL_EXCHANGE,"direct");
        channel.exchangeDeclare(DEAD_EXCHANGE, "direct");
        // 声明个Map 用来存放队列的其他参数
        Map<String,Object> arguments = new HashMap<>();

        arguments.put("x-dead-letter-exchange", DEAD_EXCHANGE);
        arguments.put("x-dead-letter-routing-key","dead_letter_key");

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

        // 正常队列与正常交换机绑定
        channel.queueBind(NORMAL_QUEUE, NORMAL_EXCHANGE, "normal_key");
        // 死信队列与死信交换机绑定
        channel.queueBind(DEAD_QUEUE, DEAD_EXCHANGE,"dead_letter_key");

        DeliverCallback deliverCallback = (consumerTag, delivery)->{
            System.out.println(new String(delivery.getBody(), "UTF-8"));
        };

        channel.basicConsume(NORMAL_QUEUE, true, deliverCallback, consumerTag -> {});

    } catch (IOException e) {
        throw new RuntimeException(e);
    }

image-20230615181010958

Producer.java

/**
     * 正常交换机
     */
public static final String NORMAL_EXCHANGE = "normal_exchange";

public static void main(String[] args) {
    try {
        Channel channel = ConnectionUtil.getChannel();

        // 设置消息的属性: TTL-消息的存活时间 ms
        AMQP.BasicProperties properties = new AMQP.BasicProperties()
            .builder().expiration("10000").build();

        for (int i = 1; i < 11; i++) {
            String message = "message" + i;
            channel.basicPublish(NORMAL_EXCHANGE, "normal_key", properties , message.getBytes("UTF-8"));
        }
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

启动生产者后,过了10秒,可以看到,10条消息全部进入了死信队列。

image-20230615222415332

image-20230615222152444

4.8.2 队列达到最大长度

Producer.java

public static void main(String[] args) {
    try {
        Channel channel = ConnectionUtil.getChannel();
        for (int i = 1; i < 11; i++) {
            String message = "message" + i;
            channel.basicPublish(NORMAL_EXCHANGE, "normal_key", null , message.getBytes("UTF-8"));
        }
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

Consumer1.java:在上一种情况下怎加一个参数x-max-length

public static void main(String[] args) {
	//...

    Map<String,Object> arguments = new HashMap<>();

    arguments.put("x-dead-letter-exchange", DEAD_EXCHANGE);
    arguments.put("x-dead-letter-routing-key","dead_letter_key");
    // 声明队列最大长度
    arguments.put("x-max-length", 6);
    
    
    //...
}

image-20230615224050609

启动生产者后,由于生产者发送了10条消息,而正常队列的消息只能入队6条,所以有4条消息将进入死信队列。

image-20230615224720760

启动Consumer2后,可以看到死信队列中的4条消息被消费了。正常队列里的6条消息需要启动Consumer1进行消费。

image-20230615224850304

Consumer2的控制台打印的是前4条消息,可能有人问为什么是前4条而不是后4条。原因是死信队列是用来存储处理失败的消息的,消息在进入正常队列的同时,由于Consumer1无法正常处理消息,所以前四条消息进入了死信队列。

image-20230615230339637

4.8.3 消息被拒绝

Producer.java

//...

for (int i = 1; i < 11; i++) {
    String message = "message" + i;
    channel.basicPublish(NORMAL_EXCHANGE, "normal_key", null , message.getBytes("UTF-8"));
}

//....

Cousumer1.java

//...
Map<String, Object> arguments = new HashMap<>();

arguments.put("x-dead-letter-exchange", DEAD_EXCHANGE);
arguments.put("x-dead-letter-routing-key", "dead_letter_key");

//...

DeliverCallback deliverCallback = (consumerTag, delivery) -> {
    String message = new String(delivery.getBody(), "UTF-8");
    if ("message5".equals(message)) {
        channel.basicReject(Long.parseLong(consumerTag),false);
        System.out.println(message + " 消息被拒绝");
    } else {
        // 确认消息
        channel.basicAck(delivery.getEnvelope().getDeliveryTag(),false);
        System.out.println("Consumer1接收的消息" + message);
    }
};

//...

启动生产者:10条消息进入正常队列

image-20230619145330002

启动Consumer1但并未关闭,所以normal_queue队列的消息被消费了,其中有一条进入死信队列:
image-20230616000924638

可以看到有一条消息(message5)因为被拒绝进入了死信队列。当启动Consumer2时:死信队列中的消息被消费。

image-20230616001042657

4.9 延迟队列

延迟队列(Delay Queue)是一种消息队列的实现方式,它可以将消息延迟一段时间后在进行投递。在某些场景下,需要在一定时间后才能处理某些任务,例如定时任务、订单超时处理等,这时候就可以使用延迟队列来实现。

在延迟队列中,消息会先被放入队列中,但并不立即被消费者取出,而是要等待一定的时间后在进行投递。延迟队列通常会使用一个定时器来检测队列中是否有消息已经到达了投递时间,如果有,则将消息发送给消费者进行处理。

延迟队列(Delay Queue)通常用于需要延迟处理的场景,例如:

  1. 订单超时处理:在电商系统中,如果用户下单后一定时间内未支付,则需要将该订单设置为超时状态,取消订单并释放库存。

  2. 定时任务:在某些场景下,需要定时执行一些任务,例如数据备份、日志清理等。

  3. 消息重试:当消息处理失败时,可以将该消息重新发送到延迟队列中,在一定时间后再进行重试。

  4. 缓存失效处理:在缓存系统中,如果某个缓存项在一定时间内没有被访问,则可以将其从缓存中移除。

  5. 异步消息通知:在某些场景下,需要在一定时间后向用户发送通知消息,例如订单发货通知、活动开始提醒等。

在以上场景中,延迟队列可以通过将消息延迟一定时间后再进行投递,来实现任务的延迟处理。延迟队列可以有效地避免任务处理时的系统资源浪费和性能问题,同时还可以提高系统的稳定性和可靠性。

需要注意的是,在使用延迟队列时,应该仔细考虑延迟时间的设置和精度,以确保任务可以在预期的时间内得到处理。同时,延迟队列的实现也需要考虑到系统的吞吐量、延迟时间的精度和可靠性等问题,以确保系统的性能和可靠性。

案例:延迟队列的实现;生产者P向正常交换机发送消息,正常交换机通过路由键BindingABindingB将消息路由到与其绑定的队列A与队列B中,由于没有正常的消费者来消费这两个队列中的消息,过了一段时间(TTL)消息被死信交换机路由到死信队列D中,然后被消费者C消费掉。

image-20230620012117491

创建三个队列分别为A、B、D。其中,A与B为正常队列且TTL(存活时间)为10s和40s,D是死信队列。两个交换机分别为正常交换机与死信交换机。关系如上图。

代码:

TTLQueueConfig.java

/**
 * @Author 蜡笔小新
 * @Description TTL(存活时间)队列配置类
 * @Version 1.0
 */
@Configuration
public class TTLQueueConfig {

    /**
     * 正常交换机
     */
    public static final String NORMAL_EXCHANGE = "normalExchange";
    /**
     * 死信交换机
     */
    public static final String DEAD_LETTER_EXCHANGE = "deadLetterExchange";
    /**
     * 普通队列
     */
    public static final String QUEUE_A = "QueueA";
    public static final String QUEUE_B = "QueueB";
    /**
     * 死信队列
     */
    public static final String DEAD_LETTER_QUEUE = "deadLetterQueue";


    /**
     * 正常交换机
     *
     * @return 返回一个交换机
     */
    @Bean
    public DirectExchange normalExchange() {
        return new DirectExchange(NORMAL_EXCHANGE);
    }

    /**
     * 死信交换机
     *
     * @return 返回一个交换机
     */
    @Bean
    public DirectExchange deadLetterExchange() {
        return new DirectExchange(DEAD_LETTER_EXCHANGE);
    }

    /**
     * 设置队列A
     *
     * @return 返回一个队列
     */
    @Bean
    public Queue queueA() {
        Map<String, Object> arguments = new HashMap<>(3);
        arguments.put("x-dead-letter-exchange", DEAD_LETTER_EXCHANGE);
        arguments.put("x-dead-letter-routing-key", "bindingD");
        arguments.put("x-message-ttl", 10000);
        return QueueBuilder.durable(QUEUE_A).withArguments(arguments).build();
    }

    /**
     * 队列A与交换机绑定
     *
     * @param queueA
     * @param normalExchange
     * @return
     */
    @Bean
    public Binding bindingA(@Qualifier("queueA") Queue queueA, @Qualifier("normalExchange") DirectExchange normalExchange) {
        return BindingBuilder.bind(queueA).to(normalExchange).with("bindingA");
    }

    /**
     * 设置队列B
     *
     * @return 返回一个队列
     */
    @Bean
    public Queue queueB() {
        Map<String, Object> arguments = new HashMap<>(3);
        arguments.put("x-dead-letter-exchange", DEAD_LETTER_EXCHANGE);
        arguments.put("x-dead-letter-routing-key", "bindingD");
        arguments.put("x-message-ttl", 40000);
        return QueueBuilder.durable(QUEUE_B).withArguments(arguments).build();
    }

    /**
     * 队列B与交换机绑定
     *
     * @param queueB
     * @param normalExchange
     * @return
     */
    @Bean
    public Binding bindingB(@Qualifier("queueB") Queue queueB, @Qualifier("normalExchange") DirectExchange normalExchange) {
        return BindingBuilder.bind(queueB).to(normalExchange).with("bindingB");
    }


    /**
     * 死信队列
     *
     * @return 返回一个队列
     */
    @Bean
    public Queue deadLetterQueue() {
        return new Queue(DEAD_LETTER_QUEUE);
    }

    /**
     * 死信队列与死信交换机绑定
     * @param deadLetterQueue
     * @param deadLetterExchange
     * @return
     */
    @Bean
    public Binding deadLetterBinding(@Qualifier("deadLetterQueue") Queue deadLetterQueue, @Qualifier("deadLetterExchange") DirectExchange deadLetterExchange) {
        return BindingBuilder.bind(deadLetterQueue).to(deadLetterExchange).with("bindingD");
    }
}

DeadLetterQueueConsumer.java

/**
 * @Author 蜡笔小新
 * @Description 死信队列消费者
 * @Version 1.0
 */
@Slf4j
@Component // 将当前类声明为一个Bean对象
public class DeadLetterQueueConsumer {

    /**
     * @RabbitListener:是Spring AMQP提供的一个注解,用于声明一个监听器,监听指定的RabbitMQ队列
     * @param message
     * @param channel
     */
    @RabbitListener(queues = "deadLetterQueue")
    public void receiveMsg(Message message, Channel channel) throws Exception {
        String msg = new String(message.getBody());
        log.info("当前时间;{},收到死信队列的消息:{}",new Date().toString(),msg);
    }


}

SendMsgController.java

/**
 * @Author 蜡笔小新
 * @Description 消息发送方控制器
 * @Version 1.0
 */
@Slf4j
@RestController
@RequestMapping("/ttl")
public class SendMsgController {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    @GetMapping("sendMsg/{message}")
    public String sendMsg(@PathVariable String message) {
        log.info("当前时间:{},发送一条消息给两个TTL队列:{}", new Date(), message);
        // convertAndSend是RabbitTemplate提供的一个方法,用于将消息发送到指定的RabbitMQ交换机和路由键中。
        // 可以将消息对象自动转换成指定的消息格式,并发送到指定的目的地
        // 重载:convertAndSend(Object message):将消息发送到默认交换机中,路由键为空
        // convertAndSend(String exchange, String routingKey, Object message)
        // convertAndSend(String exchange, String routingKey, Object message, MessagePostProcessor messagePostProcessor):
        // 将指定的消息对象发送到指定的交换机和路由键中,并使用指定的 MessagePostProcessor 对消息进行后处理。
        // 如:MessagePostProcessor postProcessor = message -> {
        //    message.getMessageProperties().setExpiration("5000"); // 设置消息过期时间为5秒
        //    return message;
        //};
        //rabbitTemplate.convertAndSend("exchangeB", "routingKeyB", "Hello, RabbitMQ!", postProcessor);
        rabbitTemplate.convertAndSend("normalExchange", "bindingA", "消息来自TTL为10s的队列" + message);
        rabbitTemplate.convertAndSend("normalExchange", "bindingB", "消息来自TTL为40s的队列" + message);
        return "SEND SUCCESS!";
    }
}

浏览器发送请求:http://localhost:8080/ttl/sendMsg/项目运行成功

控制台打印信息:首先打印下图中第一行内容,过了10s打印下图中第二行内容,再过了30s打印下图中第三行内容。

image-20230620005551703

在RabbitMQ的Web界面上看:

当发送请求成功后,队列A、B都被正常交换机路由了一条消息

image-20230620010055706

过了10s可以看到,队列A中的消息进入到死信队列,并被消费者C消费掉

image-20230620010044507

又过了30s,队列B中的消息也以同样的方式被消费

image-20230620010420977

这个场景是只有10s与40s,那如果在增加个50s、60s…,岂不是每增加一个新的时间需求,就要新增一个队列,这样做代码量岂不是很大,很明显是不符合开发要求的,那么该如可改进呢?

4.9.1 延迟队列优化

上文中的方法也可以实现,但是不能动态修改消息的TTL。本节新增一个队列C,绑定关系如下如,该队列不设置TTL时间,消息的TTL通过生产者发送消息时设置。

image-20230620012604438

TTLQueueConfig.java:

@Configuration
public class TTLQueueConfig {
    // ...
    // ##############优化

    /**
     * 通用的延迟队列
     *
     * @return 返回一个队列
     */
    @Bean
    public Queue queueC() {
        Map<String, Object> arguments = new HashMap<>();
        arguments.put("x-dead-letter-exchange", DEAD_LETTER_EXCHANGE);
        arguments.put("x-dead-letter-routing-key", "bindingD");
        return QueueBuilder.durable(QUEUE_C).withArguments(arguments).build();
    }

    /**
     * 与正常交换机进型绑定
     * @param queueC
     * @param normalExchange
     * @return
     */
    @Bean
    public Binding bindingC(Queue queueC, DirectExchange normalExchange) {
        return BindingBuilder.bind(queueC).to(normalExchange).with("bindingC");
    }
}

SendMsgController.java:

@Slf4j
@RestController
@RequestMapping("/ttl")
public class SendMsgController {
    //...

    /**
     * 发送一个带有TTL(存活时间)的消息
     * @param message
     * @param ttlTime
     * @return
     */
    @GetMapping("/sendExpirationMsg/{message}/{ttlTime}")
    public String sendMsg(@PathVariable String message, @PathVariable String ttlTime) {

        log.info("当前时间:{},发送一条时长 {} 毫秒TTL消息给队列QC:{}", new Date().toString(), ttlTime, message);
        // 设置消息的TTL
        MessagePostProcessor postProcessor = msg -> {
            msg.getMessageProperties().setExpiration(ttlTime); // 设置消息过期时间
            return msg;
        };
        rabbitTemplate.convertAndSend("normalExchange", "bindingC", message, postProcessor);
        return "MSG SEND SUCCESS!";
    }

}

测试用例1:浏览器发送请求 http://localhost:8080/ttl/sendExpirationMsg/测试带有TTL的消息/3000

控制台打印信息如下图:第一行信息是生产者将消息发送到正常交换机上,交换机通过bindingC绑定建将消息路由到队列C中,由于传的TTL时间参数是3000,所以3秒后该消息又被死信交换机路由到死信队列中,最终被消费者C消费。

image-20230620020938103

测试用例2:先发送http://localhost:8080/ttl/sendExpirationMsg/测试带有TTL的消息1/30000,然后再快速发送http://localhost:8080/ttl/sendExpirationMsg/测试带有TTL的消息2/3000

image-20230620022500412

很明显看到明明设置了TTL为3000(3秒)的请求与设置30000(30秒)的死亡时间一样。原因是RabbitMQ只会检查第一个消息是否过期,如果过期则丢到死信队列中,如果第一个消息的延时时长很长,而第二个消息的延时时长很短,第二个消息并不会优先得到执行。

这个问题就是使用死信队列做延迟队列的缺陷,无法弥补。

4.9.2 RabbitMQ插件实现延迟队列

插件下载地址

#在Linux中输入命令:
cd /usr/lib/rabbitmq/lib/rabbitmq_server-3.8.8/plugins
#查看rabbitmq中是否有此插件,如果没有执行以下命令:
cd /opt  #我的文件是放在了opt目录下
#再在opt目录下执行以下命令将文件拷贝到rabbitmq的插件目录下
cp rabbitmq_delayed_message_exchange-3.8.0.ez /usr/lib/rabbitmq/lib/rabbitmq_server-3.8.8/plugins
#然后再执行安装命令:
rabbitmq-plugins enable rabbitmq_delayed_message_exchange

如图示,即安装成功。

image-20230620200208150

#执行命令重启rabbitmq服务
systemctl restart rabbitmq-server

在RabbitMQ的Web界面的交换机选项处,点击添加一个交换机出现如下图所示选项即安装成功

image-20230620200724318

使用插件实现延迟队列与使用死信队列实现延迟队列的区别:

基于插件:

image-20230620204436713

该插件会创建一个新的交换机类型,用于支持消息的延迟投递机制。在使用该插件时,可以将需要延迟处理的消息发送到一个普通的队列中,并在消息的属性中添加一个“delay”的属性,表示消息的延迟时间。然后,该插件会将消息存储在 Mnesia 表中,并在指定的延迟时间后再将消息投递到目标队列中,等待消费者进行处理。

基于死信队列:

image-20230620204450671

代码案例:

image-20230620210201951

DelayedExchangeConfig.java:延迟交换机配置类

/**
 * @Author 蜡笔小新
 * @Description 延迟队列配置类
 * @Version 1.0
 */
@Slf4j
@Configuration
public class DelayedExchangeConfig {

    /**
     * 延迟交换机
     */
    public static final String DELAYED_EXCHANGE = "delayedExchange";

    /**
     * 延迟队列
     */
    public static final String DELAYED_QUEUE = "delayedQueue";

    /**
     * 绑定Key
     */
    public static final String BINDING_KEY = "bindingKey";

    /**
     * 声明延迟交换机(自定义类型的),这是一种新的交换类型,该类型消息支持延迟投递机制 消息传递后并不会立即
     投递到目标队列中,而是存储在 mnesia(一个分布式数据系统)表中,当达到投递时间时,才投递到目标队列中。
     *
     * @return
     */
    @Bean
    public CustomExchange exchange() {
        Map<String, Object> arguments = new HashMap<>();
        // 表示这个Exchange类型是direct类型,并且支持延迟消息
        arguments.put("x-delayed-type", "direct");
        // “x-delayed-message”不是 RabbitMQ 默认提供的交换机类型,而是由 RabbitMQ Delayed Message Plugin 提供的一种交换机类型,用于实现延迟队列。
        // 在这里使用自定义交换机类型的方式来创建一个延迟队列。
        return new CustomExchange(DELAYED_EXCHANGE, "x-delayed-message", true, false, arguments);
    }

    /**
     * 队列
     *
     * @return
     */
    @Bean
    public Queue queue() {
        return new Queue(DELAYED_QUEUE);
    }

    /**
     * 队列与自定义交换机绑定
     *
     * @return
     */
    @Bean
    public Binding queueBindingDelayedExchange(@Qualifier("queue") Queue queue, @Qualifier("exchange") Exchange exchange) {
        // 这里使用了 noargs() 方法,表示不需要传递任何参数或者额外的信息。
        return BindingBuilder.bind(queue).to(exchange).with(BINDING_KEY).noargs();
    }

}

SendMsgController.java:生产者

@Slf4j
@RestController
@RequestMapping("/ttl")
public class SendMsgController {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    /**
     * 发送消息,使用延迟交换机实现延迟队列
     *
     * @param message
     * @return
     */
    @GetMapping("/sendDelayedMsg/{message}/{delayedTime}")
    public String sendMsg(@PathVariable String message, @PathVariable Integer delayedTime) {
        log.info("当前时间:{},发送一条时长 {} 毫秒的消息:{}", new Date().toString(), delayedTime, message);
        rabbitTemplate.convertAndSend("delayedExchange", "bindingKey", message, msg -> {
            // getMessageProperties()方法获取消息的属性,并设置消息的延迟时间
            msg.getMessageProperties().setDelay(delayedTime);
            return msg;
        });
        return message+" SEND SUCCESS!";
    }
}

DelayedExchangeConsumer.java:消费者

**
 * @Author 蜡笔小新
 * @Description 基于延迟交换机实现的延迟队列的消费者
 * @Version 1.0
 */

@Slf4j
@Component
public class DelayedExchangeConsumer {

//    @RabbitListener(queues = "delayedQueue")
    @RabbitListener(queues = DelayedExchangeConfig.DELAYED_QUEUE)
    public void receiveMsg(Message message) {
        String msg = new String(message.getBody());
        log.info("当前时间:{},收到延迟队列的消息:{}",new Date().toString(),msg);
    }

}

测试用例1:浏览器发送请求http://localhost:8080/ttl/sendDelayedMsg/基于延迟交换机实现延迟队列_1/20000

测试结果:

image-20230620223310521

测试用例2:浏览器同时发送请求http://localhost:8080/ttl/sendDelayedMsg/基于延迟交换机实现延迟队列_1/20000http://localhost:8080/ttl/sendDelayedMsg/基于延迟交换机实现延迟队列_2/2000

测试结果:

image-20230620223832842

可以看到,基于插件实现的延迟队列第二条消息先被消费掉了,与基于死信队列实现的延迟队列相比,基于插件实现延迟队列符合实际要求。

4.10 总结

延时队列在需要延时处理的场景下非常有用,使用 RabbitMQ 来实现延时队列可以很好的利用RabbitMQ 的特性,如:消息可靠发送、消息可靠投递、死信队列来保障消息至少被消费一次以及未被正确处理的消息不会被丢弃。另外,通过 RabbitMQ 集群的特性,可以很好的解决单点故障问题,不会因为单个节点挂掉导致延时队列不可用或者消息丢失。

当然,延时队列还有很多其它选择,比如利用 Java 的 DelayQueue,利用 Redis 的 zset,利用 Quartz或者利用 kafka 的时间轮,这些方式各有特点,看需要适用的场景

5.发布者确认高级

在生产环境中由于一些不明原因,导致RabbitMQ重启,在RabbitMQ重启期间生产者消息投递失败,导致消息丢失,需要手动处理和恢复。于是,我们开始思考,如何才能进行RabbitMQ的消息可靠投递呢?

特别是在这样比较极端的情况下,RabbitMQ集群不可用的时候,无法投递的消息如何处理呢?

应 用 [xxx][08-1516:36:04]  发 生 [  错 误 日 志 异 常 ]  , alertId=[xxx]  。 由
[org.springframework.amqp.rabbit.listener.BlockingQueueConsumer:start:620]	触  发  。
应用 xxx 可能原因如下
服	务	名	为	:异 常 为 : org.springframework.amqp.rabbit.listener.BlockingQueueConsumer:start:620,产 生 原 因 如 下 :1.org.springframework.amqp.rabbit.listener.QueuesNotAvailableException: Cannot prepare queue for listener. Either the queue doesn't exist or the broker will not allow us to use it.||Consumer received fatal=false exception on startup:

其实确保 RabbitMQ 消息的可靠投递可以采取以下措施:

  1. 消息持久化

在生产者发送消息时,可以将消息的 deliveryMode 属性设置为 2,表示将消息持久化到磁盘上。这样,即使 RabbitMQ 重启或者宕机,消息也不会丢失。需要注意的是,消息持久化会对 RabbitMQ 的性能产生一定的影响,因此需要根据实际情况进行权衡。

  1. 消息确认机制

在生产者发送消息时,可以使用 RabbitMQ 提供的消息确认机制,确保消息被成功接收。可以使用普通确认机制、批量确认机制或者发布确认机制等多种确认机制方案。如果消息发送失败或者确认失败,可以根据实际情况进行重发或者其他处理。

  1. 消息备份

在 RabbitMQ 中,可以使用备份交换机和镜像队列等方式来实现消息的备份。备份交换机可以将消息发送到备份队列中,从而保证消息不会丢失。镜像队列可以将队列的消息同步到其他节点中,从而提高消息的可用性。需要注意的是,备份和镜像都会对 RabbitMQ 的性能产生一定的影响,因此需要根据实际情况进行权衡。

  1. RabbitMQ 集群和高可用性

在 RabbitMQ 中,可以通过集群和高可用性等方式来提高 RabbitMQ 的可用性。可以将多个 RabbitMQ 节点组成集群,从而实现负载均衡和高可用性。可以使用镜像队列和磁盘节点等方式来实现队列的数据备份和恢复。需要注意的是,集群和高可用性的实现需要考虑多个方面,包括网络延迟、节点故障等因素。

总之,在实际应用中,需要根据具体的业务需求和实际情况来选择合适的方式来确保 RabbitMQ 消息的可靠投递。同时,还需要考虑消息的重发、幂等性等问题,从而保证消息传递的正确性和可靠性。

这里主要介绍消息确认机制。

5.1 确认机制方案

image-20230620235015818

情景一:RabbitMQ宕机了(交换机X与队列Q没了)

情景二:交换机没了,

情景三:队列没了

这三种情况下,生产者发送的消息都会丢失。所以在发生这三种情况之一的时候需要将消息临时放到缓存中,通过定时任务对未成功发送的消息重新投递。

5.2 确认机制案例

目标:测试确认机制方案

思路:

image-20230621001837039

代码:

CallbackConfig.java:回调配置类

@Slf4j
@Component
public class CallbackConfig implements RabbitTemplate.ConfirmCallback {

    @Autowired
    RabbitTemplate rabbitTemplate;

    /**
     * @PostConstruct 是一个Java注解,用于标记一个方法,指示该方法应在对象创建后立即执行。
     * 这个方法被称为初始化方法。
     * 当容器创建一个带有@PostConstruct注解的Bean时,容器将在完成依赖注入之后立即调用该方法。
     */
    @PostConstruct // 该注解确保CallbackConfig对象创建后立即执行该初始化方法,避免实例化CallbackConfig对象时,rabbitTemplate对象可能还没有被完全初始化
    public void init(){
        // 因为ConfirmCallback是个内部接口,所以需要将CallbackConfig(实现类自身)作为回调函数设置给RabbitTemplate对象,从而调用下方重写的confirm方法来处理消息确认
        rabbitTemplate.setConfirmCallback(this);
    }

    /**
     * 交换机出现故障,导致消息发送失败的回调方法
     *
     * @param correlationData 发送到RabbitMQ的消息相关数据
     * @param ack             表示消息是否已被RabbitMQ确认接收,如果为true,表示消息已被确认接收,如果为false,表示消息未被确认接收
     * @param cause           表示未能确认消息接收的原因,当ack为false时,该参数将包含错误信息
     */
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {

        // 获取消息Id
        String messageId = correlationData != null ? correlationData.getId() : "";

        if (ack) {
            log.info("交换机已收到id为{}的消息", messageId);
        } else {
            log.info("交换机未收到Id为{}的消息,原因可能是‘{}’",messageId,cause);
        }

    }
}

AcknowledgementConfig.java:确认机制RabbitMQ的配置类

@Configuration
public class AcknowledgementConfig {

    /**
     * 交换机
     */
    public static final String CONFIRM_EXCHANGE = "confirmExchange";

    /**
     * 队列
     */
    public static final String CONFIRM_QUEUE = "confirmQueue";

    /**
     * 绑定键
     */
    public static final String BINDING_KEY = "key1";

    /**
     * 构建交换机
     *
     * @return
     */
    @Bean
    public DirectExchange confirmExchange() {
        return ExchangeBuilder.directExchange(CONFIRM_EXCHANGE).build();
    }

    /**
     * 构建队列
     *
     * @return
     */
    @Bean
    public Queue confirmQueue() {
        return QueueBuilder.durable(CONFIRM_QUEUE).build();
    }

    /**
     * 进行绑定
     *
     * @param confirmQueue
     * @param confirmExchange
     * @return
     */
    @Bean
    public Binding queueBindingExchange(@Qualifier("confirmQueue") Queue confirmQueue, @Qualifier("confirmExchange") DirectExchange confirmExchange) {
        return BindingBuilder.bind(confirmQueue).to(confirmExchange).with(BINDING_KEY);
    }

}

AckConsumer.java:确认机制消费者

@Slf4j
@Component
public class AckConsumer {

    @RabbitListener(queues = AcknowledgementConfig.CONFIRM_QUEUE)
    public void receiveMsg(Message message) {
        String msg = new String(message.getBody());
        log.info("接收到的消息为:{}", msg);
    }
}

AckMechanismController.java:确认机制的控制器,就是生产者

@RestController
@RequestMapping("/ack")
public class AckMechanismController {

    @Autowired
    RabbitTemplate rabbitTemplate;

    @GetMapping("/ackSendMsg/{message}")
    public String sendMsg(@PathVariable String message){

        CorrelationData correlationData = new CorrelationData("1");
        rabbitTemplate.convertAndSend(AcknowledgementConfig.CONFIRM_EXCHANGE, AcknowledgementConfig.BINDING_KEY, message,correlationData);

        return message+"发送成功!";
    }

}

application.properties

...
# 配置RabbitMQ Publisher Confirm机制的属性,
# none:禁用Publisher Confirm机制;
# simple:启用Publisher Confirm机制,并使用简单的模式来确认消息;
# correlated:启用Publisher Confirm机制,并使用相关的Correlation Data来确认消息。
spring.rabbitmq.publisher-confirm-type=correlated

测试用例1:正常情况,浏览器发送请求http://localhost:8080/ack/ackSendMsg/this is message

测试结果:

image-20230621163520825

消费者可以收到消息,交换机也可以应答

测试用例2:假如交换“死了”,在交换机后拼接一个字符串,在发送用例1的请求

image-20230621163809928

测试结果:
image-20230621164121236

很明显,报错打印日志说交换机不存在

测试用例3:假如队列“死了”,在队列名称后拼接一个字符串,在发送用例1的请求

测试结果:

image-20230621164412382

很明显,交换机收到消息并给生产者发送确认消息,而队列死了,消息自然无法入队,进而消费者也无从消费,这些消息也就成了无法路由消息。换句话说,就是交换机和队列之间的“路”断掉了,消息也就会被丢失。生产者是不知道消息丢弃这件事的。那么如何处理这些无法被路由的消息呢?这就需要使用消息回退了。

5.3 消息回退

RabbitMQ支持消息回退机制,也称作“消息退回”或“消息重入队列”。当消息无法被路由到队列中时,可以通过消息回退将消息重新发送到队列中,以便进行重试或者其他处理。

在RabbitMQ中,消息回退的处理方式取决于交换机的类型。对于直接交换机和主题交换机,当消息无法被路由到队列时,可以将消息发送到备用交换机或者直接将消息回退到生产者。而对于扇形交换机和头部交换机,当消息无法被路由到队列时,只能将消息回退到生产者。

5.4 消息回退案例

CallbackConfig.java

public class CallbackConfig implements RabbitTemplate.ConfirmCallback,RabbitTemplate.ReturnsCallback {
	//...
    
    @PostConstruct
    public void init(){
        //..
        
        rabbitTemplate.setReturnsCallback(this);
    }

    //... 
    
    
    /**
     * 可以将消息在传递过程中不达目的地时将消息返回给生产者
     * @param returnedMessage
     */
    @Override
    public void returnedMessage(ReturnedMessage returnedMessage) {
        log.info("消息{},被交换机{}退回,退回原因{},路由键{},",
                new String(returnedMessage.getMessage().getBody()),
                returnedMessage.getExchange(),
                returnedMessage.getReplyText(),
                returnedMessage.getRoutingKey()
                );
    }
}

application.properties

...
# 用于配置RabbitMQ中的生产者模板的属性。当设置为true时,表示生产者发送的消息无法被路由到任何队列时,Broker会将消息返回给生产者。
spring.rabbitmq.template.mandatory=true

测试用例与确认机制案例中测试用例3一样

测试结果:

image-20230621175424947

5.5 备份交换机

有了回退消息,我们获得了对无法投递消息的感知能力,有机会在生产者的消息无法被投递时发现并处理。但有时候,我们并不知道该如何处理这些无法被路由的消息,最多打个日志,然后触发报警,再来手动处理。而通过日志来处理这些无法路由的消息是很不优雅的做法,特别是当生产者所在的服务有多台机器的时候,手动复制日志会更加麻烦而且容易出错。而且设置 mandatory 参数会增加生产者的复杂性,需要添加处理这些被退回的消息的逻辑。如果既不想丢失消息,又不想增加生产者的复杂性,该怎么做呢?前面在设置死信队列的文章中,我们提到,可以为队列设置死信交换机来存储那些处理失败的消息,可是这些不可路由消息根本没有机会进入到队列,因此无法使用死信队列来保存消息。在 RabbitMQ 中,有一种备份交换机的机制存在,可以很好的应对这个问题。什么是备份交换机呢?备份交换机可以理解为 RabbitMQ 中交换机的“备胎”,当我们为某一个交换机声明一个对应的备份交换机时,就是为它创建一个备胎,当交换机接收到一条不可路由消息时,将会把这条消息转发到备份交换机中,由备份交换机来进行转发和处理,通常备份交换机的类型为 Fanout ,这样就能把所有消息都投递到与其绑定的队列中,然后我们在备份交换机下绑定一个队列,这样所有那些原交换机无法被路由的消息,就会都进入这个队列了。当然,我们还可以建立一个报警队列,用独立的消费者来进行监测和报警。

概念:

RabbitMQ的备份交换机(Alternate Exchange)是一种特殊的交换机,它可以用来处理无法被路由的消息。当一条消息无法被正确路由到任何队列时,RabbitMQ会将消息发送到备份交换机所绑定的队列中,从而保证消息不会丢失。

备份交换机的使用可以增强消息的可靠性和稳定性。如果生产者无法确定要将消息发送到哪个队列,或者无法预测某个特定的队列是否会存在,那么可以使用备份交换机来处理这些情况。

要使用备份交换机,需要创建一个备份交换机,并将其绑定到一个备份队列中。然后,将备份交换机的名称设置到生产者的属性中,以便在消息无法被正确路由时,能够将消息发送到备份队列中。

案例:

image-20230621214442824

AcknowledgementConfig.java:在该类上文中的代码基础上添加如下代码

// ...

/**
* 备份交换机
*/
public static final String BACKUP_EXCHANGE = "backupExchange";

/**
* 备份队列
*/
public static final String BACKUP_QUEUE = "backupQueue";

/**
* 警告队列
*/
public static final String WARNING_QUEUE = "warningQueue";

//...

/**
     * 构建备份交换机
     *
     * @return :
     */
@Bean
public FanoutExchange backupExchange() {
    return ExchangeBuilder.fanoutExchange(BACKUP_EXCHANGE).build();
}
/**
     * 构建备份队列
     *
     * @return :
     */
@Bean
public Queue backupQueue() {
    return QueueBuilder.durable(BACKUP_QUEUE).build();
}

/**
     * 构建警告队列
     *
     * @return :
     */
@Bean
public Queue warningQueue() {
    return QueueBuilder.durable(WARNING_QUEUE).build();
}

//...

/**
     * 备份队列与备份交换机进行绑定
     *
     * @param backupQueue
     * @param backupExchange
     * @return :
     */
@Bean
public Binding backupQueueBindingBackupExchange(@Qualifier("backupQueue") Queue backupQueue, @Qualifier("backupExchange") FanoutExchange backupExchange) {
    // 扇出类型交换机不需要指定绑定键
    return BindingBuilder.bind(backupQueue).to(backupExchange);
}

/**
     * 警告队列与备份交换机绑定
     * @param warningQueue
     * @param warningExchange
     * @return :
     */
@Bean
public Binding warningQueueBindingWarningExchange(@Qualifier("warningQueue") Queue warningQueue, @Qualifier("backupExchange") FanoutExchange warningExchange) {
    return BindingBuilder.bind(warningQueue).to(warningExchange);
}

新增WarningConsumer.java:警告队列消费者

@Slf4j
@Component
public class WarningConsumer {

    @RabbitListener(queues = AcknowledgementConfig.WARNING_QUEUE)
    public void receiver(Message message) {
        log.info("Waring:发现不可路由消息:{}",new String(message.getBody()));
    }
}

新增BackupConsumer.java:备份队列消费者

@Slf4j
@Component
public class BackupConsumer {

    @RabbitListener(queues = AcknowledgementConfig.BACKUP_QUEUE)
    public void receiveMsg(Message message){
        log.info("备份交换机{}将消息发{}送到了备份队列{}中",AcknowledgementConfig.BACKUP_EXCHANGE,new String(message.getBody()),AcknowledgementConfig.BACKUP_QUEUE);
    }
}

AckMechanismController.java

@RestController
@RequestMapping("/ack")
public class AckMechanismController {

    @Autowired
    RabbitTemplate rabbitTemplate;

    @GetMapping("/ackSendMsg/{message}")
    public String sendMsg(@PathVariable String message) {
        
        // 这里不传参会自动传入UUID
        CorrelationData correlationData1 = new CorrelationData("1");
        rabbitTemplate.convertAndSend(AcknowledgementConfig.CONFIRM_EXCHANGE, AcknowledgementConfig.BINDING_KEY + " ", message + "1", correlationData1);


        CorrelationData correlationData2 = new CorrelationData("2");
        rabbitTemplate.convertAndSend(AcknowledgementConfig.CONFIRM_EXCHANGE, AcknowledgementConfig.BINDING_KEY, message + "2", correlationData2);

        return message + "发送成功!";
    }
}

测试用例:浏览器输入请求http://localhost:8080/ack/ackSendMsg/message

测试结果:

image-20230621232649951

可以看到,两条消息都发送到了确认交换机中,但是message1这条消息由于路由键不存在找不到确认队列,他并没有被回退给生产者,而是由确认交换机发送给了备份交换机,由于备份交换机是扇出类型的(此案例中),所以备份交换机将消息分发给了与其绑定的所有队列中。

mandatory参数与备份交换机同时使用的时候,消息究竟何去何从呢?

在RabbitMQ中,当mandatory参数和备份交换机(Alternate Exchange)同时使用时,消息的路由取决于它们的配置和消息是否能被正确路由。

  1. 如果消息能被正确路由到至少一个队列,那么它就会被正常处理,mandatory参数和备份交换机都不会起作用。
  2. 如果消息无法被路由到任何队列,那么:
    • 如果mandatory参数被设置为true,RabbitMQ会将这个消息返回给生产者。
    • 如果mandatory参数被设置为false,RabbitMQ会将这个消息发送到备份交换机,前提是已经配置了备份交换机。

所以,当mandatory参数和备份交换机同时使用时,mandatory参数的设置会优先影响消息的路由。只有当mandatory参数被设置为false,备份交换机才会起作用。这种设计可以确保生产者有更多的控制权,可以决定如何处理无法路由的消息。

6.RabbitMQ的其他知识

6.1 幂等性

6.1.1 概念

**幂等性是指一个操作多次执行后,产生的结果和执行一次产生的结果相同。**幂等性通常涉及到两个方面:生产者投递消息和消费者处理消息。

举个栗子:支付。用户购买商品后支付,支付扣款成功,但是返回结果的时候网络异常,此时钱已经扣除了,用户再次点击按钮,此时会进行第二次扣款,返回结果成功,用户查询余额发现多扣钱了,流水记录也变成了两条。在以前的单应用系统中,我们只需要把数据操作放入事务中即可,发生错误立即回滚,但是再响应客户端的时候也有可能出现网络中断或者异常等。

说白了就是消息重被复消费

6.1.2 消息重复消费

消费者在消费MQ中的消息时,MQ已把消息发送给消费者,消费者在给MQ返回ack时网络中断,导致MQ未收到确认信息,该条消息会重新发送给其他的消费者,或者在网络重连后再次发送给该消费者,但实际上该消费者已经成功消费了该条消息,造成了消费者重复消费消息。

6.1.3 解决思路

为了结局消息重复消费的问题,可以采用以下策略:

  1. 确保幂等操作:设计你的服务以支持幂等性,即使相同的请求被处理多次,结果也应该保持一致。
  2. 使用唯一标识符:给每条消息分配一个唯一ID,并在接收者端记录已经处理过的ID。如果再次收到相同ID的消息,则跳过它。
  3. 确认机制与重试策略:实现确认机制,在成功处理完某条消息后发送确认信息;同时建立合适的重试策略以防止因暂时性错误而导致未能及时响应。

生产者投递消息的幂等性

生产者投递消息的幂等性是指确保一条消息只被投递到队列中一次。要实现这一点,可以使用以下方法:

  1. 消息去重使用唯一标识符(例如,UUID)作为消息的属性,并在生产者和消费者之间共享。这样,在生产者发送重复的消息时,消费者可以识别并忽略它们。
  2. 发布确认:RabbitMQ支持发布确认机制,生产者可以确保它们发送的每条消息都被正确接收。当生产者收到RabbitMQ的确认消息后,它可以确认此消息已成功发送,从而避免重复发送。
  3. 应用分布式锁:在需要处理并发情况下,可以使用分布式锁来保证同一时间只有一个生产者实例执行消息发送操作。这样可以避免因多个生产者同时处理相同事件而引发的重复问题。

消费者处理消息的幂等性

消费者处理消息的幂等性是指确保一条消息只被消费一次。要实现这一点,可以使用以下方法:

  1. 使用唯一标识符:为每条消息分配一个全局唯一ID,并在消费者端记录已经处理过的消息ID。当接收到新消息时,首先检查该消息的ID是否已被处理过;如果是,则跳过这条消息。
  2. 利用数据库特性:许多数据库系统提供了原子性和事务支持功能,可利用这些特性确保数据完整性。例如,在关系型数据库中可以使用INSERT … ON DUPLICATE KEY UPDATE(MySQL) 或 UPSERT(PostgreSQL) 语句进行插入或更新操作。
  3. 采用乐观锁定:通过版本控制字段(如时间戳、版本号)实现乐观锁定策略以解决并发问题。当有多个消费者同时尝试更改相同资源时, 只有第一个提交成功后才能继续; 其他消费者需要重新检查数据并重试。(最佳选择方案)

6.2 优先级队列(Priority Queue)

6.2.1 概念

优先级队列允许您为每个消息设定一个优先级值,在这样的队列中,具有较高优先级的消息将被排在前面,并在低优先级消息之前得到消费。

6.2.2 使用场景

在我们系统中有一个订单催付的场景,客户在淘宝下的订单,淘宝会及时将订单推送给客户,如果在用户设定的时间内未付款那么就会给用户推送一条短信提醒,很简单的一个功能。但是,对于淘宝商家来说肯定有大小可户区分的,大客户的订单须优先处理。搁在以前,我们开发的系统是使用Redis来存放定时轮询,而redis只能用List做一个简简单单的消息队列,并不能实现一个优先级的场景。所以订单量太大以后采用RabbitMQ进行改善和优化,如果发现是大客户的订单给一个相对比较高的优先级,否则就是默认优先级。

image-20230622145923423

6.2.3 要使用RabbitMQ的优先级队列,请遵循以下步骤:

  1. 创建带有x-max-priority参数的队列:该参数定义了可以处理的最大优先级
  2. 为消息设置优先级:当发布消息时,可以通过指定 priority 属性来为每个消息分配一个整数值(0-255 之间)。较高数字表示较高优先级。
  3. 消费者接收处理消息:对于消费者而言,无需特殊设置即可接收按优先级排序的消息。如果多个消息具有相同的优先级,那么他们将按照先进先出(FIFO)的顺序被消费。

内部实现:RabbitMQ 使用多个内部分层 (sub-queues) 来管理不同 priority 值的消息。这些 sub-queues 是FIFO(First In, First Out)结构,并根据其包含 message 的 priority 值进行排序。

投递顺序:当消费者准备从 queue 中获取 message 时,RabbitMQ 将检查所有非空 sub-queues 并选择具有最高 priority 值且尚未被锁定或预取 (prefetched) 的 message 进行投递。

性能影响虽然优先级队列可以帮助你控制消息的处理顺序,但是它可能会对RabbitMQ的性能产生影响。因为RabbitMQ需要在内存中维护一个按优先级排序的消息列表,所以如果队列中的消息数量非常大,或者优先级的范围非常广,那么优先级队列可能会消耗更多的内存和CPU资源。

6.2.4 测试代码

PriorityQueueConfig.java:优先级队列配置类

@Configuration
public class PriorityQueueConfig {


    /**
     * 优先级队列
     */
    public static final String PRIORITY_QUEUE = "priorityQueue";

    /**
     * 交换机
     */
    public static final String exchangeName = "priority.exchange";


    /**
     * 绑定键
     */
    public static final String routingKey = "priority.routingKey";


    /**
     * 构建优先级队列
     *
     * @return
     */
    @Bean
    public Queue priorityQueue() {

        return QueueBuilder.durable(PRIORITY_QUEUE).withArgument("x-max-priority", 10).build();
    }

    /**
     * 构建交换机
     *
     * @return
     */
    @Bean
    public DirectExchange directExchange() {
        return ExchangeBuilder.directExchange(exchangeName).build();
    }

    /**
     * 绑定
     * @param priorityQueue
     * @param directExchange
     * @return
     */
    @Bean
    public Binding queueBindingExchange(Queue priorityQueue, DirectExchange directExchange) {
        return BindingBuilder.bind(priorityQueue).to(directExchange).with(routingKey);
    }
}

PriorityQueueController.java:优先级队列Demo的控制器

@Slf4j
@RestController
@RequestMapping("/priority")
public class PriorityQueueController {

    @Autowired
    RabbitTemplate rabbitTemplate;

    @GetMapping("/msg")
    public void receivedMsg() {

        for (int i = 1; i < 11; i++) {
            if (i == 6) {
                System.out.println("####");
                rabbitTemplate.convertAndSend(PriorityQueueConfig.exchangeName,
                        PriorityQueueConfig.routingKey,
                        "priority_message"+i, msg -> {
                            msg.getMessageProperties().setPriority(5);
                            return msg;
                        });
            } else {
                rabbitTemplate.convertAndSend(PriorityQueueConfig.exchangeName,
                        PriorityQueueConfig.routingKey, "message" + i);
            }
        }
    }
}

PriorityQueueConsumer.java优先级队列的消费者

@Slf4j
@Component
public class PriorityQueueConsumer {

    @RabbitListener(queues = PriorityQueueConfig.PRIORITY_QUEUE)
    public void receiveMsg(Message message) throws InterruptedException {
        log.info("接收到的优先级队列中的消息:{}", new String(message.getBody()));
    }
}

测试结果:下图中的priority_message6第一个被消费

image-20230622182129758

6.3 惰性队列(Lazy Queue)

6.3.1 概念

RabbitMQ中的惰性队列是一种特殊类型的队列。它只在真正需要时才会创建。

常规队列在声明时就会创建,无论是否有消费者与之绑定。而惰性队列在声明时不会创建,他会处于“未创建”的状态,只有当第一个消费者与之绑定时才会实际创建。

惰性队列的主要优点是:

  1. 减少资源消耗。如果一个惰性队列最终没有被使用,那么它就不会占用任何资源。
  2. 延迟队列创建。你可以提前声明许多队列,但只有真正需要使用时才会创建,这可以推迟资源分配。
  3. 和普通队列相同。一旦被至少一个消费者使用,惰性队列就会变成一个普通队列,功能和特性也都相同。
  4. 删除。你可以随时使用rabbitmqctl delete_queue命令删除一个惰性队列,无论其是否已经被创建。
  5. 查看状态。使用rabbitmqctl list_queues命令可以查看所有队列的状态。惰性队列的状态会显示为“uncreated"。

队列声明时设置x-queue-modelazy ,这个队列只会在第一个消费者绑定到它时才真正创建。

6.3.2 使用场景

  1. 大量队列声明:如果你需要提前声明大量队列,但实际上只有少数队列会被使用,那么惰性队列可以节省大量资源。因为只有被使用的队列才会被真正创建。
  2. 短暂队列:如果你需要创建一些寿命短的临时队列,那么惰性队列可以避免这些队列创建和销毁带来的资源开销。因为如果这些队列最终没有被使用,那么他们根本就不会被创建。
  3. 网站/APP statuesc 队列:一些网站或APP会使用大量队列来统计和跟踪各种事件或状态。如果使用普通队列,这会占用很多资源。而是用惰性队列,只有真正需要统计数据时,响应的队列才会被创建。这可以极大提高资源利用率。

6.3.4 惰性队列与普通队列

普通队列与惰性队列的区别主要在于消息的存放位置:

普通队列:

  • 消息发布之后,会立即存放在RabbitMQ的内存中。
  • 消费者消费消息之后,消息才会从内存中删除。

惰性队列:

  • 消息发布之后,并不会立即存放在RabbitMQ的内存中。
  • 只有在有消费者订阅并开始消费消息时,RabbitMQ才会把消息从磁盘上取出存入内存。

简单来说:

  • 普通队列:消息一直存放在内存中。
  • 惰性队列:消息初期存储在磁盘上,只有被消费时才加载到内存中。

6.3.5 代码

LazyQueueConfirm.java

/**
 * @Author 蜡笔小新
 * @Description 惰性队列配置类, 只有与该队列绑定的消费者消费消息时才会创建
 * @Version 1.0
 */
@Component
public class LazyQueueConfirm {


    /**
     * 队列名称
     */
    public static final String LAZY_QUEUE_NAME = "lazyQueue";


    /**
     * 交换机名称
     */
    public static final String EXCHANGE_NAME = "lazy.exchange";


    /**
     * 绑定键
     */
    public static final String ROUTING_KEY = "lazy.key";


    /**
     * 构建惰性队列
     *
     * @return
     */
    @Bean
    public Queue lazyQueue() {
        return QueueBuilder.durable(LAZY_QUEUE_NAME)
                .withArgument("x-queue-mode", "lazy")
                .build();
    }

    public DirectExchange lazyExchange() {
        return ExchangeBuilder.directExchange(EXCHANGE_NAME).build();
    }

    public Binding binding(Queue lazyQueue, DirectExchange lazyExchange) {
        return BindingBuilder.bind(lazyQueue).to(lazyExchange).with(ROUTING_KEY);
    }

}

LazyQueueConsumer.java

/**
 * @Author 蜡笔小新
 * @Description 惰性队列消费者
 * @Version 1.0
 */
@Slf4j
@Component
public class LazyQueueConsumer {

    @RabbitListener(queues = LazyQueueConfirm.LAZY_QUEUE_NAME)
    public void receiveMessage(Message message) {
        log.info("惰性队列消息接收成功: {}", new String(message.getBody()));
    }
}
/**
 * @Author 蜡笔小新
 * @Description TODO
 * @Version 1.0
 */
@RestController
@RequestMapping("/test")
public class LazyQueueController {

    @Autowired
    RabbitTemplate rabbitTemplate;

    /**
     * 惰性队列x
     */
    @GetMapping("/lazy/queue/demo/{message}")
    public void sendMsgToLazyQueue(@PathVariable String message){
        //rabbitTemplate.convertAndSend(LazyQueueConfirm.EXCHANGE_NAME, LazyQueueConfirm.ROUTING_KEY, "测试惰性队列");
        rabbitTemplate.convertAndSend(LazyQueueConfirm.LAZY_QUEUE_NAME, message);
    }
}

运行程序,虽然在RabbitMQ的管理界面可以看到惰性队列被创建了,你可能会说他不是在使用时才被创建吗?这是因为SpringBoot的自动装配的原因,但是消息依然是存储在磁盘上的。如果要实现什么时候使用,什么时候创建,可以参考以下代码:

@Autowired
private RabbitTemplate rabbitTemplate;

@Autowired
private AmqpAdmin amqpAdmin;

public void sendMessage(String exchange, String routingKey, Object message) {
    // 创建队列
    Queue queue = QueueBuilder.durable("yourQueueName")
    .withArgument("x-queue-mode", "lazy")
    .build();

    // 创建交换机
    DirectExchange directExchange = new DirectExchange(exchange);
    amqpAdmin.declareExchange(directExchange);

    // 绑定队列和交换机
    Binding binding = BindingBuilder.bind(queue).to(directExchange).with(routingKey);
    amqpAdmin.declareBinding(binding);

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

7.RabbitMQ集群

7.1 搭建步骤

7.1.1 克隆虚拟机

选择需要克隆的虚拟机关闭->右击选择【管理】->选择【克隆】。

其中一步选择【完整克隆】,等待读条。

7.1.2 配置每个节点的 hosts 文件

克隆完成后,通过以下命令查看ip地址

ifconfig

修改主机名

vi /etc/hostname

设置映射各个主机的映射ip

vi /etc/hosts

image-20230625224302972

需要在三台机器上都设置

7.1.3 配置集群节点的 Erlang cookie

Erlang cookie 是用于加密节点之间通信的密钥,必须在所有节点之间保持一致

使用以下命令将本地主机上的 .erlang.cookie 文件复制到结点 node2node3 上,以确保在使用 RabbitMQ 时,本地主机和远程主机共享相同的 Erlang cookie

scp /var/lib/rabbitmq/.erlang.cookie root@【这里填写远程主机地址或主机名】:/var/lib/rabbitmq/.erlang.cookie
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

7.1.4 启动 RabbitMQ 服务器并启用集群插件

在每个节点上,启动 RabbitMQ 服务器

rabbitmq-server -detached
rabbitmq-plugins enable rabbitmq_cluster

7.1.5 将节点加入集群

在其中一个节点上,使用以下命令将其他节点加入集群:

rabbitmqctl stop_app
rabbitmqctl join_cluster rabbit@node1
rabbitmqctl start_app

其中,rabbit@node2 是要加入集群的节点名称(主机名)。在其他结点中重复这一步骤,以完成集群配置。

此案例中有三个节点,可以让node2node3都加入node1中,或者让node2加入node1 ,让node3加入node2

7.1.6 查看集群状态

rabbitmqctl cluster_status

出现如下图所示信息即集群搭建成功

image-20230626003941616

在RabbitMQ的管理页面展示如下图所示:

image-20230626004107091

如果在每个节点中的状态信息中显示的当前节点是同一个的话,可能是因为当前用户没有足够的权限查看整个集群

7.1.7 解除集群结点

在解除一个节点之前,您需要停止该节点上的 RabbitMQ 应用程序:

rabbitmqctl stop_app

从集群中删除节点:例如,如果您要从名为 rabbit@node2 的集群中删除节点 rabbit@node3

rabbitmqctl forget_cluster_node rabbit@node3

这将从集群中删除节点 rabbit@node3,并将其配置数据和状态同步删除。

删除节点数据:在从集群中删除节点之后,您需要删除该节点的数据和配置

sudo rm -rf /var/lib/rabbitmq

7.2 镜像队列

7.2.1 场景

假如有个场景是:三个节点中其中一个宕机或出现故障,这三个节点之间的关系如何,消息又该何去何从呢?

image-20230626121233573

此时消息队列中有一条消息,但还未等消费者消费,主节点挂掉了。

image-20230626120208613

这时候去管理界面查看,发现另外两个节点中队列的状态全部是 down

image-20230626120708220

这是后通过程序启动访问另外两个节点却报错:

image-20230626121021177

然后再启动挂掉的节点,发现其状态已经恢复,但是队列中的消息已经丢失了。

7.2.2 解决方法

RabbitMQ集群可以通过以下几种方式来保证消息不会全部发送到同一个节点:

  1. 镜像队列(Mirrored Queues):在RabbitMQ集群中,可以使用镜像队列来将消息在多个节点之间复制。这样,当一个节点宕机时,其他节点仍然可以继续工作,并且可以保证消息不会丢失。镜像队列可以通过在队列定义时设置“mirrored”参数来启用。

  2. 分区交换器(Partitioned Exchange):分区交换器是一种特殊的交换器,它将消息分发到多个队列中,每个队列由不同的节点负责。这样,即使一个节点宕机,其他节点仍然可以继续工作,并且可以保证消息不会全部发送到同一个节点。分区交换器可以通过在交换器定义时设置“x-consistent-hash”参数来启用。

  3. 负载均衡(Load Balancing):在RabbitMQ集群中,可以使用负载均衡来将消息均匀地分配到多个节点上。这样,即使一个节点宕机,其他节点仍然可以继续工作,并且可以保证消息不会全部发送到同一个节点。负载均衡可以通过在客户端代码中使用RabbitMQ提供的API来实现。例如,在使用RabbitMQ的Java客户端时,可以使用“ConnectionFactory.setLoadBalancer”方法来设置负载均衡策略。

7.2.3 步骤

创建集群中的镜像队列步骤:

如果您在集群的某个节点上添加、修改或删除账户或权限,这些更改将自动同步到其他节点上。这是因为 RabbitMQ 集群使用 Erlang 分布式协议来管理节点之间的通信和状态同步,以确保集群中的每个节点具有相同的配置和状态。

.withArgument("x-queue-mode", "lazy")
.build();

// 创建交换机
DirectExchange directExchange = new DirectExchange(exchange);
amqpAdmin.declareExchange(directExchange);

// 绑定队列和交换机
Binding binding = BindingBuilder.bind(queue).to(directExchange).with(routingKey);
amqpAdmin.declareBinding(binding);

// 发送消息
rabbitTemplate.convertAndSend(exchange, routingKey, message);

}


# 7.RabbitMQ集群

## 7.1 搭建步骤

### 7.1.1 克隆虚拟机

选择需要克隆的虚拟机关闭->右击选择【管理】->选择【克隆】。

其中一步选择【完整克隆】,等待读条。

### 7.1.2 配置每个节点的 hosts 文件

克隆完成后,通过以下命令查看ip地址

```shell
ifconfig

修改主机名

vi /etc/hostname

设置映射各个主机的映射ip

vi /etc/hosts

[外链图片转存中…(img-U7XiDUxO-1688185511597)]

需要在三台机器上都设置

7.1.3 配置集群节点的 Erlang cookie

Erlang cookie 是用于加密节点之间通信的密钥,必须在所有节点之间保持一致

使用以下命令将本地主机上的 .erlang.cookie 文件复制到结点 node2node3 上,以确保在使用 RabbitMQ 时,本地主机和远程主机共享相同的 Erlang cookie

scp /var/lib/rabbitmq/.erlang.cookie root@【这里填写远程主机地址或主机名】:/var/lib/rabbitmq/.erlang.cookie
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

7.1.4 启动 RabbitMQ 服务器并启用集群插件

在每个节点上,启动 RabbitMQ 服务器

rabbitmq-server -detached
rabbitmq-plugins enable rabbitmq_cluster

7.1.5 将节点加入集群

在其中一个节点上,使用以下命令将其他节点加入集群:

rabbitmqctl stop_app
rabbitmqctl join_cluster rabbit@node1
rabbitmqctl start_app

其中,rabbit@node2 是要加入集群的节点名称(主机名)。在其他结点中重复这一步骤,以完成集群配置。

此案例中有三个节点,可以让node2node3都加入node1中,或者让node2加入node1 ,让node3加入node2

7.1.6 查看集群状态

rabbitmqctl cluster_status

出现如下图所示信息即集群搭建成功

[外链图片转存中…(img-8CSaysuD-1688185511598)]

在RabbitMQ的管理页面展示如下图所示:

[外链图片转存中…(img-NVNl3mVK-1688185511598)]

如果在每个节点中的状态信息中显示的当前节点是同一个的话,可能是因为当前用户没有足够的权限查看整个集群

7.1.7 解除集群结点

在解除一个节点之前,您需要停止该节点上的 RabbitMQ 应用程序:

rabbitmqctl stop_app

从集群中删除节点:例如,如果您要从名为 rabbit@node2 的集群中删除节点 rabbit@node3

rabbitmqctl forget_cluster_node rabbit@node3

这将从集群中删除节点 rabbit@node3,并将其配置数据和状态同步删除。

删除节点数据:在从集群中删除节点之后,您需要删除该节点的数据和配置

sudo rm -rf /var/lib/rabbitmq

7.2 镜像队列

7.2.1 场景

假如有个场景是:三个节点中其中一个宕机或出现故障,这三个节点之间的关系如何,消息又该何去何从呢?

[外链图片转存中…(img-lxHnk7bC-1688185511598)]

此时消息队列中有一条消息,但还未等消费者消费,主节点挂掉了。

[外链图片转存中…(img-vOdKVdXl-1688185511599)]

这时候去管理界面查看,发现另外两个节点中队列的状态全部是 down

[外链图片转存中…(img-K40N8MXi-1688185511599)]

这是后通过程序启动访问另外两个节点却报错:

[外链图片转存中…(img-97d82sS6-1688185511600)]

然后再启动挂掉的节点,发现其状态已经恢复,但是队列中的消息已经丢失了。

7.2.2 解决方法

RabbitMQ集群可以通过以下几种方式来保证消息不会全部发送到同一个节点:

  1. 镜像队列(Mirrored Queues):在RabbitMQ集群中,可以使用镜像队列来将消息在多个节点之间复制。这样,当一个节点宕机时,其他节点仍然可以继续工作,并且可以保证消息不会丢失。镜像队列可以通过在队列定义时设置“mirrored”参数来启用。

  2. 分区交换器(Partitioned Exchange):分区交换器是一种特殊的交换器,它将消息分发到多个队列中,每个队列由不同的节点负责。这样,即使一个节点宕机,其他节点仍然可以继续工作,并且可以保证消息不会全部发送到同一个节点。分区交换器可以通过在交换器定义时设置“x-consistent-hash”参数来启用。

  3. 负载均衡(Load Balancing):在RabbitMQ集群中,可以使用负载均衡来将消息均匀地分配到多个节点上。这样,即使一个节点宕机,其他节点仍然可以继续工作,并且可以保证消息不会全部发送到同一个节点。负载均衡可以通过在客户端代码中使用RabbitMQ提供的API来实现。例如,在使用RabbitMQ的Java客户端时,可以使用“ConnectionFactory.setLoadBalancer”方法来设置负载均衡策略。

7.2.3 步骤

创建集群中的镜像队列步骤:

如果您在集群的某个节点上添加、修改或删除账户或权限,这些更改将自动同步到其他节点上。这是因为 RabbitMQ 集群使用 Erlang 分布式协议来管理节点之间的通信和状态同步,以确保集群中的每个节点具有相同的配置和状态。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/707790.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Pandas之Series(一)

Hi&#x1f60a;&#x1f60a;~大家好呀~最近两天釉色酱在学习python中的数据分析的一个基本库——pandas。今天就先学习pandas中最基本的数据结构Series。下面我们一起进入Series的世界吧&#xff01;&#x1f61d; Pandas简介&#xff1a; Pandas是一种基于Python语言的快速…

sklearn.model_selection模块介绍

数据集划分方法 train_test_split train_test_split(*arrays, test_sizeNone, train_sizeNone, random_stateNone, shuffleTrue, stratifyNone)参数包括&#xff1a; test_size&#xff1a;可选参数&#xff0c;表示测试集的大小。可以是一个表示比例的浮点数&#xff08;例…

Android:ViewPager2

简介 ViewPager2内部使用RecyclerView实现&#xff0c;并提供了增强功能 特性 支持水平、垂直方向布局 android:orientation “vertical” 支持从右到左 android:layoutDirection “rtl” 禁止滑动 setUserInputEnabled() 可修改Fragment集合 对可修改的Fragment集合进行分…

深入探究Bean生命周期的扩展点:Bean Post Processor

概要 在Spring框架中&#xff0c;Bean生命周期的管理是非常重要的一部分。在Bean的创建、初始化和销毁过程中&#xff0c;Spring提供了一系列的扩展点&#xff0c;使开发者能够在不破坏原有功能的基础上&#xff0c;对Bean的生命周期进行定制化操作。其中&#xff0c;Bean Post…

LLM记录202304-202306

RLHF RAFT RAFT: Reward rAnked FineTuning for Generative Foundation Model Alignment code RRHF RRHF: Rank Responses to Align Language Models with Human Feedback without tears code p i = ∑ t lo

English Learning - L3 作业打卡 Lesson7 Day53 2023.6.28 周三

English Learning - L3 作业打卡 Lesson7 Day53 2023.6.28 周三 引言&#x1f349;句1: It was this moment that I asked myself that life-defining question:成分划分同化连读爆破语调 &#x1f349;句2: If my life were a book and I were the author, how would I want t…

基于Web的小学学科数字教学资源管理系统

摘要 小学学科数字教学资源管理是一个典型的学习项目&#xff0c;从教学资源、教材信息的统计和分析&#xff0c;在过程中会产生大量的、各种各样的数据。本文以小学学科数字教学资源管理系统为目标&#xff0c;采用B/S模式&#xff0c;以Springboot为开发框架&#xff0c;java…

计算机网络面经之TCP三次握手和四次挥手的详解

常见问题 1.详细描述三次握手和四次挥手的过程。 2.三次握手可以变成两次握手吗&#xff1f; 3.简述 TCP 连接和关闭的状态转移。 4.简述TCP 四次挥手的 TIME_WAIT状态&#xff0c;以及为什么需要有这个状态 重要的字段定义与作用 &#xff08;1&#xff09;序号(sequence nu…

循环双链表

目录 双向循环链表结构体初始化函数添加数据头插删除数据显示函数示例程序一(简易版本)&#xff1a;运行结果&#xff1a;示例程序二输出结果&#xff1a; 双向循环链表 结构图示&#xff1a; 结构体 typedef struct node {int data;struct node* pre; //指向前驱struct …

C++迭代器

目录 1.iterator 2.数组 1.iterator 迭代器就是个内置指针&#xff0c;可以 -- &#xff0c;可以解引用。 迭代器分两种类型 iterator 和const_iterator&#xff08;只读&#xff0c;不能修改&#xff09; 迭代器要用作用域限定类型 vector<int>::iterator it; 如果不限制…

Yarn的实现原理详解

概要 Yarn作为分布式集群的资源调度框架&#xff0c;它的出现伴随着Hadoop的发展&#xff0c;使Hadoop从一个单一的大数据计算引擎&#xff0c;成为一个集存储、计算、资源管理为一体的完整大数据平台&#xff0c;进而发展出自己的生态体系&#xff0c;成为大数据的代名词。 Ya…

C++11新特性 智能指针

智能指针 nuique_ptr特点不允许拷贝构造和赋值运算符重载-> () *unique_ptr 删除器仿写删除文件删除普通对象 shared_ptr特点示意图仿写shared_ptr删除器部分特化拷贝构造 移动构造 && 左值赋值 和移动赋值完整实现 weak_ptr特点weak_ptr 实现解决循环引用弱指针一个…

java: 警告: 源发行版 11 需要目标发行版 11解决方案

出现这样的问题首先检查一下自己的项目结构是否使用的对应的jdk 如果这里是正确的&#xff0c;之后查看一下自己的pom文件中是否指定了正确的jdk 这里的时候你改完运行就会发现还会报错&#xff0c;一定要记得刷新一下maven 再重新启动项目&#xff0c;即解决

剑指 Offer 63: 股票的最大利润

最标准答案 不可以有前一项的影响&#xff0c;只能用来对比并不叠加 这里max设置0就会导致先行进入大于max的判断语句&#xff01; 无语了&#xff0c;自己把问题想的太复杂了&#xff01; class Solution {public int maxProfit(int[] prices) {if(prices.length<2) retur…

十二个常用化学文献检索网站

一、Royal Society of Chemistry英国皇家化学学会 英国皇家化学学会&#xff08;Royal Society of Chemistry&#xff0c;简称RSC&#xff09;&#xff0c;是一个国际权威的学术机构&#xff0c;是化学信息的一个主要传播机构和出版商&#xff0c;其出版的期刊及资料库一向是化…

886. 可能的二分法

链接&#xff1a;886. 可能的二分法 题解&#xff1a; class Solution { public:bool possibleBipartition(int n, vector<vector<int>>& dislikes) {// -1&#xff0c;代表这个点没有访问过&#xff0c; 0&#xff0c;1代表两个染色的组std::vector<int&…

python机器学习——聚类评估方法 K-Means聚类 神经网络模型基础

目录 聚类模型的评价方法&#xff08;1&#xff09;轮廓系数&#xff1a;&#xff08;2&#xff09;评价分类模型 【聚类】K-Means聚类模型&#xff08;1&#xff09;聚类步骤&#xff1a;&#xff08;2&#xff09;sklearn参数解析&#xff08;3&#xff09;k-means算法特点 神…

GPT模型训练实践(3)-参数训练和代码实践

一、参数训练 GPT模型参数的训练过程宏观上有两个大环节&#xff0c;先从上往下进行推理&#xff0c;再从下往上进行训练&#xff0c;具体过程为&#xff1a; 1、模型初始化参数随机取得&#xff1b; 2、计算模型输出与真实数据的差距&#xff08;损失值和梯度&#xff09; …

VS2019的安装和简单使用

这里写自定义目录标题 欢迎使用Markdown编辑器新的改变功能快捷键合理的创建标题&#xff0c;有助于目录的生成如何改变文本的样式插入链接与图片如何插入一段漂亮的代码片生成一个适合你的列表创建一个表格设定内容居中、居左、居右SmartyPants 创建一个自定义列表如何创建一个…

【数据结构与算法】学校运动会管理系统(C/C++)

这是一个完整的项目&#xff0c;若有需要整个项目的压缩包&#xff08;源代码、文档、md文件等&#xff09;可私聊发送"学校运动会管理系统"。 问题描述 在“学校运动会管理系统”中&#xff0c;设有n个单位参加运动会&#xff08;单位可是学院、系、年级等&#xf…