【MQ】MQ消息中间件RabbitMQ

第一部分:RabbitMQ

一、MQ

概念

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

功能

  • 流量消峰
    • image-20210721111920053
    • 例如现在有一个订单系统,高峰期订单量过多,而系统最多只能处理1w次/s。此时可以通过消息队列使得这些超出处理能力的下单请求处于队列中进行等待,而不至于将所有的请求全部一次性打到订单系统中,造成订单系统宕机。这就相当于将实际一秒钟内的订单拆分成多个段来进行处理,这样的处理方式虽然加长了等待时间,但是有缺点总比不能用好。这样可以缓解业务量对系统带来的冲击,避免系统宕机而造成直接不能使用。
    • 此功能可以类比于Redis缓存预热。缓存预热实际上是将热点数据提前缓存到Redis中进行储存,避免高峰期数据请求直接下达到数据库造成数据库崩溃。一样是流量消峰,只不过一个针对的是数据库方面,一个针对的是服务层的请求。
  • 应用解耦
    • image-20210721112952484
    • 还是以订单系统为例,如果订单系统耦合调用支付系统、库存系统或者物流系统,一旦这三个系统发生故障,订单系统就会处于不可用的状态。如果转变为用消息队列处理调用请求,可以减少很多问题。当故障发生时,子系统要处理的内存会被缓存在消息队列中,而用户的下单操作还是可以正常完成;故障处理完之后,再处理用户的订单信息即可。整个过程中的故障对于用户来说是无感的,可以提高系统的可用性。
  • 异步处理
    • image-20210721114955417
    • 上图的架构中,当A需要调用B,B需要花很长事件来处理调用,A需要得知B何时处理完毕。可以实现的方式很多,但是很不方便。使用消息队列可以很轻松实现。只需要监听B处理完成的消息即可,当B处理完毕,将处理完毕的信息发送给消息队列,之后消息队列将消息反馈给A即可。这样的方式,A既不用循环调用B的查询API,也不需要B提供callback API,同时B也不用完成这些操作;A还能及时得到B服务处理完成的信息。

分类

  • ActiveMQ
    • 优点:单机吞吐量万级,时效性ms级,可用性高,基于主从架构实现高可用性,消息可靠性较低的概率丢失数据。
    • 缺点:官方社区现在对ActiveMQ 5.x维护越来越少,高吞吐铴景较少使用。
  • RabbitMQ
    • 2007年发布,是一个在AMQP(高级消息队列协议)基础上完成的MQ,可复用的企业消息系统,是当前最主流的消息中间件之一。
    • 优点:由于erlang语言的高并发特性,性能较好;吞吐量到万级,MQ功能比较完备,健壮、稳定、易用、跨平台、支持多种语言如Python、Ruby、.NET、 Java、JMS、 C、PHP、 ActionScript、XMPP、STOMP等,支持AJAX,文档齐全;开源提供的管理界面非常棒,用起来很好用,社区活跃度高;更新频率相当高
    • 缺点:商业版需要收费,学习成本较高
  • RocketMQ
    • 阿里巴巴开发的开源产品,Java语言实现,参考了Kafka,但是有改进,运用于订单、交易、充值、binlog等场景
    • 优点:单机吞吐量在十万级别,可用性高,分布式架构,扩展性好,消息可以做到0丢失,支持10亿级别的消息堆积
    • 缺点:支持的客户端语言不多,目前对Java和C++有支持,但是C++的支持比较一般
  • Kafka
    • 为大数据而生的消息中间件,百万级TPS的吞吐量
    • 优点:性能卓越,吞吐量高,分布式,消息有序,有优秀的第三方web管理界面Kafka-Manager,在日志领域比较成熟
    • 缺点:Kafka 单机超过64个队列/分区,Load 会发生明显的飙高现象,队列越多,load 越高,发送消息响应时间变长,使用短轮询方式,实时性取决于轮询间隔时间,消费失败不支持重试;支持消息顺序,但是一台代理宕机后,就会产生消息乱序,社区更新较慢。

选择建议

  • 产生大量数据的场景使用Kafka,大型公司建议采用,日志采集首选Kafka
  • 金融领域等要求可靠性高的场景建议使用RocketMQ,具有高可靠性和高可用性
  • 时效性微秒级,数据量没有特别高的情况下,中小型公司可以使用RabbitMQ

二、RabbitMQ环境准备

RabbitMQ是一个消息中间件,RabbitMQ的作用就是接收、存储和转发消息数据。

四大核心

image-20210723215214594

  • 生产者
  • 交换机
    • 交换机和队列之间是属于一对多的关系,一个交换机可以对应多个队列
  • 队列
    • 一个队列最好对应一个消费者,因为一个队列对应多个消费者最终也只会有一个消费者收到消息数据
  • 消费者
    • 同一个应用程序既可以是生产者,也可以是消费者

核心模式

image-20210723220521927

工作原理

image-20210723220548987

一次Connection会耗费较多资源,所以一个生产者会在一次连接中使用单独的一个信道Channel,通过Exchange交换机将消息传送到队列中,再将消息数据通过消费者和Broker之间的连接信道转发出去。

安装

一、文件上传,上传至/usr/local/software路径,若是没有该路径,先创建路径

mkdir /usr/local/software

按住 Alt+P进入SFTP模式,输入指令put -r “/pathName”将文件上传至命令行所在目录。或者直接将文件拖拽进CRT窗口即可。

put -r "C:\Users\swrel\Desktop\软件\erlang-21.3-1.el7.x86_64.rpm"

需要上传的是这两个文件:

image-20210723223543195

上传之后文件在/root目录下:

image-20210723223624962

使用下面的命令将文件复制到指定目录:

cp erlang-21.3-1.el7.x86_64.rpm rabbitmq-server-3.8.8-1.el7.noarch.rpm /usr/local/software

image-20210723223920257

之后可以看到:

image-20210723224013545

按照顺序输入以下安装命令:

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

第二个命令是安装MQ需要的依赖包。安装完成如图:

image-20210723224306563

常用命令

添加开机启动 RabbitMQ 服务:

chkconfig rabbitmq-server on

启动服务:

/sbin/service rabbitmq-server start

查看服务状态:

/sbin/service rabbitmq-server status

停止服务:

/sbin/service rabbitmq-server stop

开启web管理插件:

rabbitmq-plugins enable rabbitmq_management

不出意料这个时候启动是会出错的:

image-20210723224640136

根据网上提供的解决方法,这个时候需要去打开端口,具体操作如下:
配置/etc/hosts 文件 ,在里面添加上127.0.0.1 xxxx (xxx是当前hostname)

但是我重新启动CRT之后,重新启动RabbitMQ之后,又成功启动了。

image-20210723230610543

该状态下处于一直自动重启中,之后通过检查发现是虚拟机网络环境设置有误,改成NAT模式之后:

虚拟机设置-网络适配器-配置正确。【NAT模式】
可重新设置:虚拟机设置-网络适配器,移除,重新添加(NAT模式)。

重新启动CRT,重新启动服务,如下:

image-20210724105923920

active(running)表示已经成功启动,服务正常运行。

停止服务的时候,将start改为stop即可,停止后:Active: inactive(dead)

image-20210724110334852

安装web管理界面插件:

rabbitmq-plugins enable rabbitmq_management

image-20210724110552719

安装之后,重新启动MQ服务,这个时候又报错了:

image-20210724111710442

重新输入启动指令:

image-20210724111744104

根据这条信息,我输入了journalctl -xe查看了报错的log文件:

image-20210724111957260

可以看到错误信息如图显示,empd error for host 192:

解决办法:

一、跳转目录

cd /etc/rabbitmq

二、建立文件

vim rabbitmq-env.conf

三、添加文件内容

NODENAME=rabbit@localhost

保留后重启RabbitMQ,问题解决。

image-20210724112319190

开启web管理插件

此时就可以正常打开web服务页面了。但在此之前要关闭防火墙:

systemctl stop firewalld

永久禁用防火墙命令(推荐):

systemctl disable firewalld

之后在浏览器输入网址:

虚拟机ip地址:15672

打开界面如下所示:

image-20210724112744485

默认的账户密码都是guest,但是第一次登录会提示无权限:

image-20210724112845622

创建新的用户

使用命令查看所有用户:

rabbitmqctl list_users

image-20210724113201523

添加用户:

创建账号:【admin】用户名,【123】密码

rabbitmqctl add_user admin 123

设置用户角色:

rabbitmqctl set_user_tags admin administrator

设置用户权限:

set_permissions [-p <vhostpath>] <user> <conf> <write> <read>
rabbitmqctl set_permissions -p "/" admin ".*" ".*" ".*"
用户 user_admin 具有/vhost1 这个 virtual host 中所有资源的配置、写、读权限

此时,按照顺序输入三个指令,可以创建好新的角色【admin】并成功为其添加相关的权限:

image-20210724113709952

回到网页端,重新登陆,主页如下所示:

image-20210724113753114

关于users信息,可以在Admin选项下看见:

image-20210724113920776

三、创建Java开发环境

整个RabbitMQ的开发环境中,整体的大致开发环境如下所示:

image-20210802092046879

需要准备的大致有以上三个部分,我们需要在准备队列之前搭建好整个项目所需要的环境。

依赖

<!--指定 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>

生产者

整个工程的工作流程如下所示:

image-20210802092313311

编写代码的流程主要是:创建连接工厂、建立连接、获取信道、发布消息

public class Producer {

    // 队列名称
    public static final String QUEUE_NAME = "hello";

    // 发送消息
    public static void main(String[] args) throws IOException, TimeoutException {
        // 创建一个连接工厂
        ConnectionFactory factory = new ConnectionFactory();
        // 工厂IP,连接RabbitMQ的队列
        factory.setHost("192.168.111.132");
        // 用户名
        factory.setUsername("admin");
        // 密码
        factory.setPassword("123");

        // 创建连接
        Connection connection = factory.newConnection();
        // 获取连接之后,发送消息需要通过信道才能完成
        // 获取信道
        Channel channel = connection.createChannel();

        /**
         * 生成一个队列
         * 1.队列名称;
         * 2.队列是否持久化,默认不持久化保存在内存中,持久化保存在磁盘中
         * 3.是否排他,默认为false(是否只供一个消费者进行消费,是否进行消息共享,true则可以共享)
         * 4.是否自动删除,最后一个消费者端断开连接之后,如果是true表示自动删除,默认是false
         * 5.设置队列的其他一些参数
         */
        channel.queueDeclare(QUEUE_NAME, false, false, false, null);

        // 发消息
        String message = "Hello World";

        /**
         * 发送一个消息
         * 1.发送到哪个交换机
         * 2.路由的Key值,本次是队列名称
         * 3.其他参数信息
         * 4.发送消息的消息体
         */
        channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
        System.out.println("消息发送完毕");
    }

}

控制台运行结果:

image-20210802095433505

网页端可以看到消息队列中存在的消息情况:

image-20210802095516219

消费者

消费者在消费消息的时候同样需要建立一个信道,然后通过信道来接收消息。

public class Consumer {

    public static final String QUEUE_NAME = "hello";

    public static void main(String[] args) throws IOException, TimeoutException {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("192.168.111.132");
        factory.setUsername("admin");
        factory.setPassword("123");
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        // 声明回调函数,未成功消费的回调
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println(new String(message.getBody()));
        };

        // 声明回调函数,取消接收消息的回调
        CancelCallback cancelCallback = consumerTag -> {
            System.out.println("消息消费被中断");
        };

        /**
         * 消费者消费消息
         * 1.消费哪个队列,队列名
         * 2.消费者消费之后是否自动应答,true则为自动应答,false则为手动应答
         * 3.消费者消费消息的回调
         * 4.消费者取消消费的回调
         */
        channel.basicConsume(QUEUE_NAME, true, deliverCallback, cancelCallback);
    }

}

运行结果最终控制台打印消息内容:

image-20210802100958575

网页端消息显示已经被消费:

image-20210802100940627

四、工作队列原理

轮询分发消息

生产者在同一时间发送大量消息,交由多个工作线程(消费者)处理。此刻消息队列中有大量消息,等待工作线程处理。在消息队列中,一条消息只能交由一个工作线程处理,不能被重复处理。

这样的流程采取轮询的方法完成消息处理。比如1、2、3三条消息,必须按照一定的顺序分发给A、B、C工作线程,依此轮流询问,处理消息。也就是将消息按照你一个我一个他一个的方式分发消息队列中的消息,这样的方式称之为轮询。

抽取连接工具类

public class RabbitMQUtil {    // 获取连接信道    public static Channel getChannel() throws IOException, TimeoutException {        // 创建工厂        ConnectionFactory factory = new ConnectionFactory();        // 设置主机IP        factory.setHost("192.168.111.132");        // 设置用户名        factory.setUsername("admin");        // 设置密码        factory.setPassword("123");        // 建立连接        Connection connection = factory.newConnection();        // 获取信道        return connection.createChannel();    }}

生产者

生产者通过从控制台接收输入,将输入的信息发布到消息队列中,然后交由多个消费者处理。

public class Task01 {    // 队列名称    public static final String QUEUE_NAME = "hello";    // 发送大量消息    public static void main(String[] args) throws IOException, TimeoutException {        Channel channel = RabbitMQUtil.getChannel();        // 声明队列        channel.queueDeclare(QUEUE_NAME, false, false, false, null);        // 从控制台接受消息        Scanner scanner = new Scanner(System.in);        while (scanner.hasNext()) {            String message = scanner.next();            channel.basicPublish("", QUEUE_NAME, null, message.getBytes());            System.out.println("发送消息完成:" + message);        }    }}

消费者

此处要实现多个消费者共同处理消息的场景,实现轮询处理的机制,首先要创建多个消费者线程。

public class Worker01 {    // 队列名称    public static final String QUEUE_NAME = "hello";    public static void main(String[] args) throws IOException, TimeoutException {        Channel channel = RabbitMQUtil.getChannel();        DeliverCallback deliverCallback = (consumerTag, message) -> {            System.out.println("接收到的消息:" + new String(message.getBody()));        };        CancelCallback cancelCallback = consumerTag -> {            System.out.println(consumerTag + "消费者取消消费接口回调逻辑");        };        // 接收消息        System.out.println("Thread2等待接收消息...");        channel.basicConsume(QUEUE_NAME, true, deliverCallback, cancelCallback);    }}

编写完消费者代码之后,启动第一个线程,之后修改IDEA设置,让该代码能产生多个实例:

image-20210802105657497

修改完毕之后,修改接收信息的文字标识,改成Thread2,再次启动该程序。

在消费者线程启动之后输入依此输入aa、bb、cc、dd之后:

image-20210802105816413

image-20210802105831345

按照接收到的消息的数据来看,的确实现了轮询处理消息操作。

消息应答

消息应答机制:消费者接收到来自RabbitMQ的消息并处理完毕之后,需要发送一条应答消息告诉RabbitMQ自己已经将消息处理完毕,可以将消息删除了。这条应答消息就是消息应答机制,消息应答机制能够确保消息不丢失并且能够得到处理。

消息应答分为自动应答和手动应答。

自动应答

消息发送后立即被认为已经传送成功,这种应答方式在高吞吐量和数据传输安全性方面做了一定的权衡。

简单来说,该模式仅适用于消费者可以高效并且以某种速率处理消息的情况下使用。

速度和安全上做出了一定的权衡,也就是相对于手动应答并不那么靠谱。

手动应答

手动应答的方式主要有以下三种:

  • Channel.basicAck(用于肯定确认)
    • RabbitMQ己知道该消息并且成功的处理消息,可以将其丢弃了
  • Channel. basicNack(用于否定确认)
  • Channel. basicReject(用于否定确认)
    • 与Channel.basicNack相比少一个参数
    • 不处理该消息了直接拒绝,可以将其丢弃了

批量应答,是指队列Broker往信道Channel中一次性放入若干条消息,若消费者接收到某一条消息,则立即将信道Channel中的消息全部应答,则为批量应答。

对于是否进行批量应答,建议使用false,不使用批量应答,以保证数据的高可靠性。

消息自动重新入队

当有多个消费者的情况,如果消息发送出去的时候,在规定时间内并没有收到Ack,则由队列判定该连接已经断开,最终该消息就不会被队列删除,而是重新将该消息转由其他消费者处理。

消息手动应答

生产者

public class Task02 {

    // 队列名称
    public static final String TASK_QUEUE_NAME = "ack_queue";

    // 发送消息
    public static void main(String[] args) throws IOException, TimeoutException {
        Channel channel = RabbitMQUtil.getChannel();

        // 队列声明
        channel.queueDeclare(TASK_QUEUE_NAME, false, false, false, null);
        // 从控制台输入信息
        Scanner scanner = new Scanner(System.in);
        while (scanner.hasNext()) {
            String message = scanner.next();
            // 发布消息
            channel.basicPublish("", TASK_QUEUE_NAME, null, message.getBytes());
            System.out.println("生产者发送消息:" + message);
        }
    }

}

消费者C1

public class Work02 {

    // 队列名称
    public static final String TASK_QUEUE_NAME = "ack_queue";

    // 接收消息
    public static void main(String[] args) throws IOException, TimeoutException {
        Channel channel = RabbitMQUtil.getChannel();
        System.out.println("C1等待接收消息处理时间较短...");

        DeliverCallback deliverCallback = (consumerTag, message) -> {
            // 模拟线程响应时间
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("接收到的消息:" + new String(message.getBody()));

            // 手动应答
            /**
             * 1.消息的标识 tag
             * 2.是否批量应答 false不批量应答Channel中的消息
             */
            channel.basicAck(message.getEnvelope().getDeliveryTag(), false);
        };

        // 采用手动应答
        boolean autoAck = false;
        channel.basicConsume(TASK_QUEUE_NAME, autoAck, deliverCallback, (consumerTag -> {
            System.out.println(consumerTag + "消费者取消消费接口回调逻辑");
        }));
    }

}

消费者C2

public class Work03 {

    // 队列名称
    public static final String TASK_QUEUE_NAME = "ack_queue";

    // 接收消息
    public static void main(String[] args) throws IOException, TimeoutException {
        Channel channel = RabbitMQUtil.getChannel();
        System.out.println("C2等待接收消息处理时间较长...");

        DeliverCallback deliverCallback = (consumerTag, message) -> {
            // 模拟线程响应时间
            try {
                Thread.sleep(30000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("接收到的消息:" + new String(message.getBody()));

            // 手动应答
            /**
             * 1.消息的标识 tag
             * 2.是否批量应答 false不批量应答Channel中的消息
             */
            channel.basicAck(message.getEnvelope().getDeliveryTag(), false);
        };

        // 采用手动应答
        boolean autoAck = false;
        channel.basicConsume(TASK_QUEUE_NAME, autoAck, deliverCallback, ((consumerTag) -> {
            System.out.println(consumerTag + "消费者取消消费接口回调逻辑");
        }));
    }

}

此刻搭建了一个生产者,两个消费者的环境。模拟环境中,C1的应答时间较短,而C2的应答时间较长,这个时候如果发送消息正常处理,那么将会轮询处理消息队列中的消息。

但是一旦有消费者发生故障,该消费者正在处理的消息将不会发送应答消息,那么该消息将不会从队列中删除,而是重新入队交由其他的消费者进行处理。

生产者依此发送aa、bb、cc、dd消息至消息队列中:

image-20210802151532832

C1的处理时间较短,只有1s,那么将会快速做出应答。所以在处理aa、cc的消息的时候异常迅速给出了处理结果。

image-20210802151545240

C2在处理第二轮(dd)的时候出现了故障导致线程意外停止,那么将会导致dd消息重新入队,而本来交由C2处理的消息将会重新入队,将给C1处理。

image-20210802151601454

此刻只有一个C1线程在处理消息队列中的消息,所以接下来发送任何消息,都会导致消息全部交由C1处理。

image-20210802151848026

image-20210802151903648

而第二种情况,如果C2没有被意外关闭,而仅仅是因为处理时间过长,那么也可以按照轮询的方式处理队列中的消息。这种情况下,根据RabbitMQ的结构来看,消费者和Broker之间还存在一层Connection,而在Connection中,还存在许多的Channel;每一个Channel对应一个消费者线程,所以在C2在处理的时候消息处于信道Channel中。也就是C2在处理bb消息的时候,dd消息还处在和C2对应的Channel中,如果此时信道中不止存在一个消息,那么最好不要使用批量应答,如果使用批量应答,将会使得信道中的消息全部处于已应答状态。

image-20210802152144984

image-20210802152135168

image-20210802152123666

队列持久化

队列在运行的时候,MQ服务是处在内存中的,如果出现意外宕机关闭的情况,将会导致消息队列中的消息丢失。要想避免消息丢失,需要将队列持久化(Durable),也就是保存到磁盘中。

image-20210802152937729

在web端创建Queue的时候,会给出是否持久化的选项,默认为Durable持久化。如果不需要持久化,则选择Transient。这点与Java中的POJO类持久化一致,不需要持久化的属性使用transient关键字修饰。

如果没有进行持久化,则在web中的显示信息如下:

image-20210802153714550

在编写Java代码的时候,队列声明是编写在生产者代码中的,如果需要进行持久化,则在生产者代码中的队列声明处修改。

将原来的消息手动应答代码中的生产者队列声明修改为下列内容:

// 队列声明
boolean durable = true; // 队列持久化
channel.queueDeclare(TASK_QUEUE_NAME, durable, false, false, null);

运行之后会报错:

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 ‘ack_queue’ in vhost ‘/’: received ‘true’ but current is ‘false’, class-id=50, method-id=10)

这是因为该队列原先已经被生成,是不能强行被修改的,如果想要修改持久化选项,则需要将原先的队列删除后重新创建。

image-20210802153844272

删除队列后重新运行生产者代码,重新声明是未产生的队列,则能够正常运行,最终能够产生成功。

image-20210802153949434

持久化的队列,在客户端查看的时候,Feature选项下会有一个D,表示Durable持久化。持久化的队列,在MQ服务重新启动之后,还是照样存在。

消息持久化

队列持久化保证的是队列不丢失,而消息则不能保证不丢失。为了保证消息不丢失,消息也应该进行相应的持久化操作。

消息持久化需要修改的是生产者代码中的发送消息部分:

原来的消息发送代码为:

// 发布消息channel.basicPublish("", TASK_QUEUE_NAME, null, message.getBytes());

修改后为:

// 发布消息// 设置生产者发送的消息为持久化消息(消息需要保存到磁盘)默认一般是保存在内存中channel.basicPublish("", TASK_QUEUE_NAME, MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes());

这样设置的消息持久化并不绝对,可能造成消息刚刚准备存储到磁盘中,但是没有存储完毕的情况。对于简单的消息持久化已经足够,但是如果需要更加安全的持久化操作,需要进行发布确认。

不公平分发

在实际开发中,由于工作线程处理速度和处理能力不一致的情况,RabbitMQ默认使用的轮询分发原则不一定适用于所有场景,而使用不公平分发则能够很好的解决这一问题。

不公平分发,遵循的是“能者多劳”原则,处理速度快的线程就处理更多的消息。

image-20210803110906931

不公平分发开启的方式是在消费者消费消息basciConsume之前加上:

// 开启不公平分发int prefetchCount = 1;channel.basicQos(prefetchCount);

值得注意的是:

  • int prefetchCount = 1;channel.basicQos(prefetchCount);
    
    • 利用上述代码开启不公平分发,是将basicQos的参数传一个int类型的1进去。默认取值为int的默认取值,也就是0,为轮询分发。修改为1之后才能开启不公平分发。
  • 不公平分发在开启的时候,如果有多个消费者线程,需要将多个消费者线程均同时开启。(可以参考使用Spring配置AOP的方式,将配置不公平分发的代码设置为前置增强)

  • 开启不公平分发需要开启手动应答

    • 若不开启,运行结果如下:
    • C1等待接收消息处理时间较短…
      接收到的消息:aa
    • C2等待接收消息处理时间较长…
      接收到的消息:bb
      接收到的消息:cc
      接收到的消息:dd
    • 运行的结果和我们想象的大相径庭,原本时间花费少的线程只处理了少量消息,而时间花费较多的线程反而处理了大量消息,这和我们的初衷相违背。经过验证得知,需要使用不公平分发先开启手动应答。

开启不公平分发之后,给生产者线程添加四个消息aa、bb、cc、dd,之后再给消费者线程添加不公平分发。运行结果是:工作较快的线程C1(时间1s)处理了三条消息aa、cc、dd;工作较慢的线程C2(时间30s)处理了一条消息bb

C1等待接收消息处理时间较短…
接收到的消息:aa
接收到的消息:cc
接收到的消息:dd

C2等待接收消息处理时间较长…
接收到的消息:bb

预取值

预取值,可以指定一次性在信道中存储多少条信息等待被消费者线程处理。

RabbitMQ在发送消息和消费者手动确认都是异步的,因此在Channel中会存在一个消息缓存区。队列Broker中的消息会首先发送到这个缓冲区中等待消费者线程来处理。但是需要限制此缓冲区的大小,以避免缓冲区里面无限制的未确认消息问题。预取值定义通道上允许的未确认消息的最大数量,也就是一次性最多允许囤积多少条未经处理的消息。

但是一旦囤积的消息中有经过Ack确认的消息,那么队列Broker将会继续发送消息至这个缓冲区内。因此,预取值就相当于不公平分发+指定数量。

将消费者线程C1和C2分为修改预取值为2和5:

// 开启不公平分发
// int prefetchCount = 1;
// 预取值 2
int prefetchCount = 2;
channel.basicQos(prefetchCount);
// 开启不公平分发
// int prefetchCount = 1;
// 预取值 5
int prefetchCount = 5;
channel.basicQos(prefetchCount);

运行之后,可以看到在C2线程中堆积的消息数量最多是5条:

image-20210803114129318

反映在客户端中,也就是图中的折现最高只能到5,可能有一瞬间达到6(因为当刚刚存在一条消息被Ack确认,队列往Channel中添加消息的情况)。

五、发布确认

生产者将信道设置成 confirm 模式,一旦信道进入 confirm 模式,一旦消息被投递到所有匹配的队列之后,Borker会给生产者发送一个发布确认的信息,以保证生产者知道目的消息已经达到目的队列了。

如果是持久化队列消息:在消息队列使用的时候,要求保证RabbitMQ队列进行持久化、消息持久化操作。只进行持久化设置是远远不够的,还需要进行发布确认。

原因:如果消息队列中的消息在保存至磁盘的过程中,消息队列宕机意外关闭,那么消息将不会完全保存至磁盘中,导致持久化过程没有彻底完成,达不到持久化的要求。所以需要在持久化完毕之后需要由队列给生产者发送一条确认消息,以保证持久化彻底完成。这就是发布确认。

发布确认原理

image-20210804095749576

发布确认的存在是为了保证队列持久化和消息持久化的正常完成,避免持久化不完全的情况下造成消息丢失。

开启发布确认是由信道Channel开启的,所以在生产者中,只要有了Channel信道,就能开启发布确认,但一定要在发送消息之前。

// 获取信道Channel channel = RabbitMQUtil.getChannel();// 发布确认channel.confirmSelect();

发布确认的方式一共有三种:单个发布确认、批量发布确认、异步批量确认

单个发布确认

单个发布确认就是发送一条消息就确认一条,因为确认过程频繁,所以效率较低。

需求:利用单个发布确认,尝试批量发送一千条消息,查看需要耗费的时间

public class Confirm {

    public static final int MESSAGE_COUNT = 1000;


    public static void main(String[] args) throws IOException, InterruptedException, TimeoutException {
        // 1. 单个发布确认
        Confirm.publishSingleMessage();
        // 2. 批量发布确认
        // 3. 异步批量确认
    }

    // 单个发布确认
    public static void publishSingleMessage() throws IOException, TimeoutException, InterruptedException {
        Channel channel = RabbitMQUtil.getChannel();

        // 队列声明
        String QUEUE_NAME = UUID.randomUUID().toString();
        channel.queueDeclare(QUEUE_NAME, true, false, false, null);

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

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

        // 批量发送消息
        for (int i = 0; i < MESSAGE_COUNT; i++) {
            String message = i + "";
            channel.basicPublish("", QUEUE_NAME, null, message.getBytes(StandardCharsets.UTF_8));
            // 等待确认
            boolean flag = channel.waitForConfirms();
            if (flag) {
                System.out.println("第" + i + "条消息发布完成");
            }
        }

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

        System.out.println("一共发布" + MESSAGE_COUNT + "条信息,单独发布确认,花了" + (end - begin) + "ms");

    }

}

运行结果如下所示:

一共发布1000条信息,单独发布确认,花了1010ms

单个发布确认,使用的是同步的方式。虽然效率较低,吞吐量不高,但是查出错误比较容易,能够非常快速地知道在哪个环节或者哪个数据节点出现了问题,也比较便于发现问题所在。

批量发布确认

批量发布确认,相比于单个发布确认,区别就在于进行 channel.waitForConfirms(); 的频率区别。前者发一次确认一次,后者发一批确认一次。开启确认发布、队列声明方面没有任何区别。

// 批量发布确认public static void publishBatchMessage() throws IOException, TimeoutException, InterruptedException {    Channel channel = RabbitMQUtil.getChannel();    // 队列声明    String QUEUE_NAME = UUID.randomUUID().toString();    channel.queueDeclare(QUEUE_NAME, true, false, false, null);    // 开启发布确认    channel.confirmSelect();    // 开始时间    long begin = System.currentTimeMillis();    // 批量确认的数据大小    int batchSize = 100;    // 批量发送消息,批量发布确认    for (int i = 1; i < MESSAGE_COUNT + 1; i++) {        String message = i + "";        channel.basicPublish("", QUEUE_NAME, null, message.getBytes(StandardCharsets.UTF_8));        System.out.println("第" + i + "条消息发布完成");        // 每进行batchSize个消息的发送就确认        if (i % batchSize == 0) {            boolean flag = channel.waitForConfirms();        }    }    // 结束时间    long end = System.currentTimeMillis();    System.out.println("一共发布" + MESSAGE_COUNT + "条信息,批量发布确认,花了" + (end - begin) + "ms");}

运行结果:

一共发布1000条信息,批量发布确认,花了159ms

该方式的效率和吞吐量相比于单个发布确认都更高,但是一旦出了问题,就难以确认是哪一条消息出了问题。

异步发布确认

异步发布确认相比于上两个,效率和可靠性都达到了一个很高的水准,但是异步发布确认是利用回调函数来达到消息可靠性传递的,这个中间件也是通过函数回调来保证是否投递成功,所以实现过程比较复杂。

image-20210804104741064

此种类型的发布确认与之前的发布确认实现步骤并不一致。之前的发布确认需要开启发布确认之后进行等待确认:

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

但是异步发布确认中,并不需要进行等待确认的步骤,而是通过开启监听器的方法来进行。

// 异步发布确认public static void publishAsyncMessage() throws IOException, TimeoutException, InterruptedException {    Channel channel = RabbitMQUtil.getChannel();    // 队列声明    String QUEUE_NAME = UUID.randomUUID().toString();    channel.queueDeclare(QUEUE_NAME, true, false, false, null);    // 开启发布确认    channel.confirmSelect();    // 开始时间    long begin = System.currentTimeMillis();    // 消息确认成功,回调函数    ConfirmCallback ackCallback = (deliveryTag, multiple) -> {        System.out.println("消息发送成功:" + deliveryTag);    };    // 消息确认失败,回调函数    /**     * 1.消息的标识     * 2.是否为批量确认     */    ConfirmCallback nackCallback = (deliveryTag, multiple) -> {        System.out.println("消息发送失败:" + deliveryTag);    };    // 添加监听器,监听哪些消息发送成功了,哪些消息发送失败了    /**     * 1.监听发送成功的消息     * 2.监听发送失败的消息     */    channel.addConfirmListener(ackCallback, nackCallback);  // 异步通知    // 批量发布消息,异步发布确认    for (int i = 0; i < MESSAGE_COUNT; i++) {        String message = i + "";        channel.basicPublish("", QUEUE_NAME, null, message.getBytes(StandardCharsets.UTF_8));        System.out.println("第" + i + "条消息发布完成");    }    // 结束时间    long end = System.currentTimeMillis();    System.out.println("一共发布" + MESSAGE_COUNT + "条信息,异步发布确认,花了" + (end - begin) + "ms");}

运行结果如下所示:

一共发布1000条信息,异步发布确认,花了97ms

可以看出在运行效率上,异步发布确认也要比前两者更高。

处理异步未确认消息

最好的解决的解决方案就是把未确认的消息放到一个基于内存的能被发布线程访问的队列, 比如说用ConcurrentLinkedQueue 这个队列在 confirm callbacks 与发布线程之间进行消息的传递。

对于未确认的消息,处理方法就是将之保存起来,再次进行发送。

// 异步发布确认
public static void publishAsyncMessage() throws IOException, TimeoutException, InterruptedException {
    Channel channel = RabbitMQUtil.getChannel();

    // 队列声明
    String QUEUE_NAME = UUID.randomUUID().toString();
    channel.queueDeclare(QUEUE_NAME, true, false, false, null);

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

    /**
     * 线程安全有序的一个哈希表,并且适用于高并发场景
     * 1.轻松地将序号和消息关联起来
     * 2.轻松通过序号删除条目
     * 3.支持高并发(多线程安全)
     */
    ConcurrentSkipListMap<Long, String> outstandingConfirms = new ConcurrentSkipListMap<>();

    // 消息确认成功,回调函数
    ConfirmCallback ackCallback = (deliveryTag, multiple) -> {
        // 2.删除掉已经确认的消息,剩下的就是未经确认的消息
        if (multiple) {
            ConcurrentNavigableMap<Long, String> confirmed = outstandingConfirms.headMap(deliveryTag);
            confirmed.clear();
        } else {
            outstandingConfirms.remove(deliveryTag);
        }
        System.out.println("消息发送成功:" + deliveryTag);
    };

    // 消息确认失败,回调函数
    /**
     * 1.消息的标识
     * 2.是否为批量确认
     */
    ConfirmCallback nackCallback = (deliveryTag, multiple) -> {
        // 3.打印一下未经确认的消息
        String message = outstandingConfirms.get(deliveryTag);
        System.out.println("未确认的消息是:" + message + "消息发送失败tag:" + deliveryTag);
    };


    // 添加监听器,监听哪些消息发送成功了,哪些消息发送失败了
    /**
     * 1.监听发送成功的消息
     * 2.监听发送失败的消息
     */
    channel.addConfirmListener(ackCallback, nackCallback);  // 异步通知

    // 开始时间
    long begin = System.currentTimeMillis();
    // 批量发布消息,异步发布确认
    for (int i = 0; i < MESSAGE_COUNT; i++) {
        String message = i + "";
        channel.basicPublish("", QUEUE_NAME, null, message.getBytes(StandardCharsets.UTF_8));
        System.out.println("第" + i + "条消息发布完成");
        // 1.记录下所有要发送的消息:记录下消息的总和
        outstandingConfirms.put(channel.getNextPublishSeqNo(), message);

    }

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

    System.out.println("一共发布" + MESSAGE_COUNT + "条信息,异步发布确认,花了" + (end - begin) + "ms");

}

对于上述程序,需要将其中消息存储到一个Hash表中,且支持多线程安全。监听器和发送程序之间存在多线程的关系,需要进行安全性考量。

大致思路就是将全部消息存储到一个hash表,然后将成功发送的消息剔除,剩下的消息就是未发送的消息,之后根据需求再另作处置。

运行结果:

一共发布1000条信息,异步发布确认,花了46ms

总结

// 1. 单个发布确认// Confirm.publishSingleMessage(); // 一共发布1000条信息,单独发布确认,花了1010ms// 2. 批量发布确认// Confirm.publishBatchMessage();  // 一共发布1000条信息,批量发布确认,花了159ms// 3. 异步批量确认Confirm.publishAsyncMessage();  // 一共发布1000条信息,异步发布确认,花了97ms

三种发布确认方式,按照处理速度来看,异步批量处理速度最快。

  • 单个发布确认
    • 处理速度稍慢,吞吐量低,但是一旦出问题能够立马得知是哪个消息出了问题
  • 批量发布确认
    • 处理速度稍快,吞吐量高,但是出问题之后很难排查问题所在
  • 异步发布确认
    • 处理速度和吞吐量都属上乘,但是实现过程稍微复杂

六、交换机

上述的内容中没有提到交换机,在队列发布消息的时候,交换机的参数都用空串代替。一条消息只由一个消费者消费一次,这种简单的模式称之为“简单模式”或者“工作模式”。

image-20210804155040418

但是需要实现一个生产者生产的消息被多个线程中的消费者消费,这种简单模式就不再适用了。由于一条消息只能被一个消费者消费,要想实现这种方式,就要创造多个队列,那么与之对应的,就要存在多个Exchange交换机,由交换机来指定创造多个队列。这种模式称之为“发布/订阅模式”。

image-20210804155948668

概念

RabbitMQ消息传递模型的核心思想是:生产者生产的消息从不会直接发送到队列。实际上,通常生产者甚至都不知道这些消息传递传递到了哪些队列中。相反,生产者只能将消息发送到交换机Exchange,交换机工作的内容非常简单,一方面它接收来自生产者的消息,另一方面将它们推送至队列。

那么这就要求交换机如何处理来自生产者的消息了。这根据交换机的类型来决定,交换机一共有四种类型:

  • 直接direct
  • 主题topic
  • 标题headers
  • 扇出fanout

类型消息可以在web上的Exchanges选项中看到。

image-20210804160513295

在使用消息队列时,想要发送消息,就要在消息发布的时候指定一下交换机的类型。

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

其中第一个参数指定的就是交换机名称。空串表示的是默认交换机或无名称交换机。如果存在,消息能路由发送到队列中其实是由 routingKey(bindingkey)绑定 key 指定的;不存在则按照默认规则发送。

临时队列

临时队列,名称随机产生,且断开了与消费者的连接将被自动删除。

创建临时队列的方法:

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

创建之后,可以在web端看见以下信息:

image-20210804161708619

停止程序的运行(断开连接)将会自动删除。

绑定

binding,绑定,就是通过RoutingKey将路由器和队列进行连接的操作。这样一来,交换机就可以通过RoutingKey将消息发送给指定的消息队列了。

添加绑定关系可以在网页端内进行操作:

一、添加队列

image-20210805141000560

二、添加交换机

image-20210805140936167

三、在交换机下指定Binding绑定关系:

image-20210805141056309

这一步需要将指定的交换机与指定的队列建立绑定关系,需要指定一个RoutingKey,以保证二者的绑定关系,也可以是空串。

绑定完毕之后就可以在Bindings一栏中看到绑定关系:

image-20210805141224887

消费者代码的编写思路:

获取Channel——声明交换机——声明队列——绑定队列交换机——接收消息

生产者代码的编写思路:

获取Channel——声明交换机——准备消息——发布消息

而通过Java代码的方式绑定交换机和队列,需要在消费者代码中进行绑定:

// 队列绑定交换机
/**
 * 1.目的队列,2.源交换机,3.RoutingKey
 */
channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "");

Fanout

Fanout,扇出,即将接收到的所有消息广播到它知道的所有队列中。

指定路由的RoutingKey都一致(本次中均为空串)即可将消息发送至多个消费者队列中。

image-20210804155948668

生产者代码:

/**
 * @ author: Real
 * @ date: 2021年08月05日 14:40
 * 发消息给交换机
 */
public class EmitLog {

    // 交换机名称
    public static final String EXCHANGE_NAME = "logs";

    public static void main(String[] args) throws IOException, TimeoutException {
        Channel channel = RabbitMQUtil.getChannel();
        // 声明交换机
        channel.exchangeDeclare(EXCHANGE_NAME, "fanout");

        // 发送消息
        Scanner scanner = new Scanner(System.in);
        while (scanner.hasNext()) {
            String message = scanner.next();
            // 发布消息
            channel.basicPublish(EXCHANGE_NAME, "", null, message.getBytes(StandardCharsets.UTF_8));
            System.out.println("生产者发出消息..." + message);
        }
    }

}

消费者C1代码:

/**
 * @ author: Real
 * @ date: 2021年08月05日 14:17
 * Fanout接收消息
 */
public class ReceiveLogs1 {

    public static final String EXCHANGE_NAME = "logs";

    public static void main(String[] args) throws IOException, TimeoutException {
        Channel channel = RabbitMQUtil.getChannel();
        // 声明一个交换机
        channel.exchangeDeclare(EXCHANGE_NAME, "fanout");

        // 声明一个临时队列
        /**
         * 临时队列,消费者断开与队列的连接,临时队列就会自动删除
         * 产生的临时队列名称都是随机的
         */
        String QUEUE_NAME = channel.queueDeclare().getQueue();

        // 队列绑定交换机
        /**
         * 1.目的队列,2.源交换机,3.RoutingKey
         */
        channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "");
        System.out.println("等待接收消息,将消息打印在控制台上......");

        // 接收消息
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("ReceiveLogs1接收到的消息:" + new String(message.getBody(), StandardCharsets.UTF_8));
        };
        channel.basicConsume(QUEUE_NAME, true, deliverCallback, consumerTag -> {});
    }

}

消费者C2代码:

public class ReceiveLogs2 {

    public static final String EXCHANGE_NAME = "logs";

    public static void main(String[] args) throws IOException, TimeoutException {
        Channel channel = RabbitMQUtil.getChannel();
        // 声明一个交换机
        channel.exchangeDeclare(EXCHANGE_NAME, "fanout");

        // 声明一个临时队列
        /**
         * 临时队列,消费者断开与队列的连接,临时队列就会自动删除
         * 产生的临时队列名称都是随机的
         */
        String QUEUE_NAME = channel.queueDeclare().getQueue();

        // 队列绑定交换机
        /**
         * 1.目的队列,2.源交换机,3.RoutingKey
         */
        channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "");
        System.out.println("等待接收消息,将消息打印在控制台上......");

        // 接收消息
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            String info = new String(message.getBody(), StandardCharsets.UTF_8);
            System.out.println("ReceiveLogs2接收到的消息:" + info);
            FileOutputStream fis = new FileOutputStream("D:\\Java\\IdeaProjects\\RabbitMQ\\RabbitMQ-Hello\\test.txt");
            fis.write(info.getBytes(StandardCharsets.UTF_8));
            System.out.println("数据文件写入成功");
        };
        channel.basicConsume(QUEUE_NAME, true, deliverCallback, consumerTag -> {
        });
    }

}

运行之后,控制台输入的消息,可以在两个消费者都看到,且第二个消费者将消息写入了txt文件,实现了持久化操作。

C1运行结果:

等待接收消息,将消息打印在控制台上…
ReceiveLogs1接收到的消息:aa
ReceiveLogs1接收到的消息:bb
ReceiveLogs1接收到的消息:cc
ReceiveLogs1接收到的消息:dd

C2运行结果:

等待接收消息,将消息打印在控制台上…
ReceiveLogs2接收到的消息:aa
数据文件写入成功
ReceiveLogs2接收到的消息:bb
数据文件写入成功
ReceiveLogs2接收到的消息:cc
数据文件写入成功
ReceiveLogs2接收到的消息:dd
数据文件写入成功

Direct

上述的扇出fanout交换机,是由于在两个消费者绑定的交换机和队列中,指定的RoutingKey两者都是空串(本质上是因为相当于两者相同),消息由交换机转发出去之后,一条消息走了两条一样的道路,而且两条道路的通行证都一致。fanout扇出交换机相当于网络中的广播机制。

而direct直接交换机,在将消息收到之后,多个消费者之间的routingKey都各不相同,所以能够进行消息的指定发送。与fanout的广播策略恰好相反,direct的作用类似于单播机制。

image-20210804155948668

实现direct直接交换机,单个交换机对应的多个队列的RoutingKey都不相同,重点就是实现多重绑定

下列需要实现需求,写一个Logs系统,需要将不同等级的消息发送至不同的队列以便区分不同层次的消息。

image-20210816212959388

对应关系如图所示,创建一个名为“direct_logs"的交换机,然后绑定至两个队列,之后根据不同的绑定RoutingKey,将消息指发送到指定队列。

生产者代码:

public class DirectLogs {    // 交换机名称    public static final String EXCHANGE_NAME = "direct_logs";    public static void main(String[] args) throws IOException, TimeoutException {        Channel channel = RabbitMQUtil.getChannel();        // 发送消息        Scanner scanner = new Scanner(System.in);        while (scanner.hasNext()) {            String message = scanner.next();            // 发布消息            channel.basicPublish(EXCHANGE_NAME, "error", null, message.getBytes(StandardCharsets.UTF_8));            System.out.println("生产者发出消息..." + message);        }    }}

消费者01代码:

public class ReceiveLogsDirect01 {

    // 交换机名字
    public static final String EXCHANGE_NAME = "direct_logs";

    public static void main(String[] args) throws IOException, TimeoutException {
        // 获取信道
        Channel channel = RabbitMQUtil.getChannel();
        // 声明交换机,直接交换机
        channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.DIRECT);
        // 声明一个队列
        channel.queueDeclare("console", false, false, false, null);
        // 队列绑定,设定routingKey为info
        channel.queueBind("console", EXCHANGE_NAME, "info");
        channel.queueBind("console", EXCHANGE_NAME, "warning");

        // 接收消息
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("ReceiveLogs01控制台接收到的消息:" + new String(message.getBody(), StandardCharsets.UTF_8));
        };

        // 消费者取消消息时回调接口,接收消息
        channel.basicConsume("console", true, deliverCallback, consumerTag -> {});
    }

}

消费者02代码:

public class ReceiveLogsDirect02 {

    // 交换机名字
    public static final String EXCHANGE_NAME = "direct_logs";

    public static void main(String[] args) throws IOException, TimeoutException {
        // 获取信道
        Channel channel = RabbitMQUtil.getChannel();
        // 声明交换机,直接交换机
        channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.DIRECT);
        // 声明一个队列
        channel.queueDeclare("disk", false, false, false, null);
        // 队列绑定,设定routingKey为info
        channel.queueBind("disk", EXCHANGE_NAME, "error");

        // 接收消息
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("ReceiveLogs02控制台接收到的消息:" + new String(message.getBody(), StandardCharsets.UTF_8));
        };

        // 消费者取消消息时回调接口,接收消息
        channel.basicConsume("disk", true, deliverCallback, consumerTag -> {});
    }

}

最终修改DirectLogs中的bisciPublish中的RoutingKey的值,就可以实现输入不同的信息发送至不同的队列:

ReceiveLogs02控制台接收到的消息:Hello
ReceiveLogs01控制台接收到的消息:World

Topic

要想实现根据某种规则匹配RoutingKey达到指定转发至一个或多个队列的目的,使用上述的Direct交换机已经不能实现该目的,所以为了应对这种需求,出现了Topic主题交换机。

Topic主题交换机,RoutingKey的命名规则有一定的要求:由单词组成,单词之间用点号隔开。例如:rabbitmq.queue.message、logs.info、logs.warning等。需要注意的是,单词列表最多不能超过 255 个字节。

为了实现匹配一个至多个队列,有对应的匹配规则:

*(星号)可以代替一个单词
#(井号)可以替代零个或多个单词

根据这种匹配规则可以写出类似于正则表达式的匹配规则,可以得知:

当一个队列绑定键是#,那么这个队列将接收所有数据,就有点像 fanout 扇出交换机。
如果队列绑定键当中没有#和*出现,那么该队列绑定类型就是 direct 直接交换机。

要求:实现下列的绑定关系,并且实现测试不同的RoutingKey发送不同的消息,发送的结果。

image-20210817095750098

消费者01:

public class ReceiveLogsTopic01 {

    // 交换机名称
    public static final String EXCHANGE_NAME = "topic_logs";

    public static void main(String[] args) throws IOException, TimeoutException {
        // 声明信道
        Channel channel = RabbitMQUtil.getChannel();
        // 声明交换机
        channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.TOPIC);
        // 声明队列
        String queueName = "Q1";
        channel.queueDeclare(queueName, false, false, false, null);
        // 队列绑定
        channel.queueBind(queueName, EXCHANGE_NAME, "*.orange.*");
        System.out.println("C1等待接收消息...");
        // 接收消息
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("C1接收到消息:" + new String(message.getBody(), StandardCharsets.UTF_8));
            System.out.println("接收队列:" + queueName + " 绑定键: " + message.getEnvelope().getRoutingKey());
        };
        channel.basicConsume(queueName, true,  deliverCallback, consumerTag -> {});
    }

}

消费者02:

public class ReceiveLogsTopic02 {

    // 交换机名称
    public static final String EXCHANGE_NAME = "topic_logs";

    public static void main(String[] args) throws IOException, TimeoutException {
        // 声明信道
        Channel channel = RabbitMQUtil.getChannel();
        // 声明交换机
        channel.exchangeDeclare(EXCHANGE_NAME, BuiltinExchangeType.TOPIC);
        // 声明队列
        String queueName = "Q2";
        channel.queueDeclare(queueName, false, false, false, null);
        // 队列绑定
        channel.queueBind(queueName, EXCHANGE_NAME, "*.*.rabbit");
        channel.queueBind(queueName, EXCHANGE_NAME, "lazy.#");
        System.out.println("C2等待接收消息...");
        // 接收消息
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println("C2接收到消息:" + new String(message.getBody(), StandardCharsets.UTF_8));
            System.out.println("接收队列:" + queueName + " 绑定键: " + message.getEnvelope().getRoutingKey());
        };
        channel.basicConsume(queueName, true,  deliverCallback, consumerTag -> {});
    }

}

生产者:

public class TopicLogs {

    // 交换机名称
    public static final String EXCHANGE_NAME = "topic_logs";

    public static void main(String[] args) throws IOException, TimeoutException {
        Channel channel = RabbitMQUtil.getChannel();

        /**
         * Q1-->绑定的是
         * 中间带 orange 带 3 个单词的字符串(*.orange.*)
         * Q2-->绑定的是
         * 最后一个单词是 rabbit 的 3 个单词(*.*.rabbit)
         * 第一个单词是 lazy 的多个单词(lazy.#)
         */
        Map<String, String> bindingKeyMap = new HashMap<>();
        // 将map集合的Key值当作RoutingKey,将value值当作发送的内容
        bindingKeyMap.put("quick.orange.rabbit", "被队列 Q1Q2 接收到");
        bindingKeyMap.put("lazy.orange.elephant", "被队列 Q1Q2 接收到");
        bindingKeyMap.put("quick.orange.fox", "被队列 Q1 接收到");
        bindingKeyMap.put("lazy.brown.fox", "被队列 Q2 接收到");
        bindingKeyMap.put("lazy.pink.rabbit", "虽然满足两个绑定但只被队列 Q2 接收一次");
        bindingKeyMap.put("quick.brown.fox", "不匹配任何绑定不会被任何队列接收到会被丢弃");
        bindingKeyMap.put("quick.orange.male.rabbit", "是四个单词不匹配任何绑定会被丢弃");
        bindingKeyMap.put("lazy.orange.male.rabbit", "是四个单词但匹配 Q2");

        for (Map.Entry<String, String> bindingKeyEntry : bindingKeyMap.entrySet()) {
            String routingKey = bindingKeyEntry.getKey();
            String message = bindingKeyEntry.getValue();
            // 发送消息
            channel.basicPublish(EXCHANGE_NAME, routingKey, null, message.getBytes(StandardCharsets.UTF_8));
            System.out.println("生产者发出消息:" + message);
        }

    }

}

这其中的 bindingKeyMap.put("lazy.pink.rabbit", "虽然满足两个绑定但只被队列 Q2 接收一次");代码,由于字符串的识别顺序以及优先匹配原则,可以得出是由Q2队列接收。

最终的运行结果符合预期,如下所示:

image-20210817100135945

image-20210817100149479

image-20210817100205021

七、死信队列

概念

死信,字面理解可以为无法被消费的消息。正常情况下,生产者生产的消息会投递到Broker中或者queue中,消费者从其中取出消息进行消费。如果某些情况下,由于特定的原因导致queue中的消费无法被消费者消费,这样的消息由于缺乏后续的处理就成了死信,死信拥有其对应的队列,名为死信队列。

在商城业务中,为了保证订单业务不丢失,自然就需要使用到RabbitMQ的死信队列;当订单数据消息消费不正常,将其投入死信队列中。还有在订单下单时候,规定时间内未进行支付的订单,将被自动取消。

来源

  • 消息TTL(Time to Live)过期,消息失效
  • 队列达到最大长度,队列满了无法再向MQ中添加消息
  • 消息被拒绝 basic.rejectbasic.nack 并且 requeue== false

死信处理

根据下面死信队列的处理,写出相应的代码。可以看出,死信产生之后会由死信交换机dead_exchange接收,之后转发到死信队列dead_queue,这个过程中是直接direct转发的。

image-20210817101653481

整个过程中,应该书写三个角色的代码。分别是:正常消费者,死信消费者,消息生产者。其中,死信队列的消费者的代码就是处理死信消息的代码,在这一部分决定了对死信的处理。

TTL过期

消费者C1需要完成的任务有:声明交换机、绑定两者的交换机(正常消息队列绑定正常交换机、死信队列绑定死信交换机)、声明普通队列、声明过期时间、设置死信RoutingKey等。

public class Consumer01 {    // 普通交换机名称    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) throws IOException, TimeoutException {        Channel channel = RabbitMQUtil.getChannel();        // 声明死信队列和普通队列交换机的类型为direct交换机        channel.exchangeDeclare(NORMAL_EXCHANGE, BuiltinExchangeType.DIRECT);        channel.exchangeDeclare(DEAD_EXCHANGE, BuiltinExchangeType.DIRECT);        // 设置arguments所需要的参数        Map<String, Object> arguments = new HashMap<>();        // 设置过期时间,单位ms,通常也可以由生产者指定时间        arguments.put("x-message-ttl", 10000);        // 给正常队列设置死信交换机        arguments.put("x-dead-letter-exchange", DEAD_EXCHANGE);        // 给正常队列设置死信RoutingKey        arguments.put("x-dead-letter-routing-key", "lisi");        // 声明普通队列        channel.queueDeclare(NORMAL_QUEUE, false, false, false, arguments);        // 声明死信队列        channel.queueDeclare(DEAD_QUEUE, false, false, false, null);        // 绑定普通交换机和普通队列        channel.queueBind(NORMAL_QUEUE, NORMAL_EXCHANGE, "zhangsan");        // 绑定死信交换机和死信队列        channel.queueBind(DEAD_QUEUE, DEAD_EXCHANGE, "lisi");        System.out.println("等待接收消息...");        DeliverCallback deliverCallback = (consumerTag, message) -> {            System.out.println("Consumer1接受的消息是:" + new String(message.getBody(), StandardCharsets.UTF_8));        };        // 消费者消费消息        channel.basicConsume(NORMAL_QUEUE, true, deliverCallback, consumerTag -> {        });    }}

生产者负责产生消息,然后将消息转发至正常队列。该过程模拟的是消费者消费消息的时候消息过期的场景。这里将TTL的数值设置成10s,然后当时间超过了10s,则会将消息从正常队列中转到死信队列中。

image-20210829110501210

public class Producer {    // 普通交换机名称    public static final String NORMAL_EXCHANGE = "normal_exchange";    // 死信队列生产者    public static void main(String[] args) throws IOException, TimeoutException {        Channel channel = RabbitMQUtil.getChannel();        // 设置死信消息 TTL时间 单位毫秒值        AMQP.BasicProperties properties = new AMQP.BasicProperties()                .builder().expiration("10000").build();        for (int i = 0; i < 10; i++) {            String message = "info" + i;            // 发布消息            channel.basicPublish(NORMAL_EXCHANGE, "zhangsan", properties, message.getBytes(StandardCharsets.UTF_8));        }    }}

死信消费者,主要处理的是死信消息。死信消息该由谁来被消费,该怎么样被消费,都由该消费者决定。

public class Consumer02 {    // 死信队列名称    public static final String DEAD_QUEUE = "dead_queue";    public static void main(String[] args) throws IOException, TimeoutException {        Channel channel = RabbitMQUtil.getChannel();        System.out.println("等待接收消息...");        DeliverCallback deliverCallback = (consumerTag, message) -> {            System.out.println("Consumer2接受的消息是:" + new String(message.getBody(), StandardCharsets.UTF_8));        };        // 消费者消费消息        channel.basicConsume(DEAD_QUEUE, true, deliverCallback, consumerTag -> {        });    }}

死信消费者主要负责的是将死信队列的消息消费。所以直接将死信队列的消息接收并处理即可。

上述过程模拟的是TTL过期所造成的消息成为死信。也就是关闭Consumer01,最终时间超过TTL,将会把消息转到Consumer02也就是死信消费者。

队列达到最大长度

当正常消息队列中的消息超出队列所能承受的最大长度,溢出的消息将会被转到死信队列。

模拟该场景,需要更改的地方有Consumer01和Producer。将生产者中设置的TTL删除,然后将Consumer01中的参数列表新增一个设置最大消息数的限制。

Producer

// 设置死信消息 TTL时间 单位毫秒值/*AMQP.BasicProperties properties = new AMQP.BasicProperties()        .builder().expiration("10000").build();*/for (int i = 0; i < 10; i++) {    String message = "info" + i;    // 发布消息    channel.basicPublish(NORMAL_EXCHANGE, "zhangsan", null, message.getBytes(StandardCharsets.UTF_8));}

Consumer01

// 设置arguments所需要的参数Map<String, Object> arguments = new HashMap<>();// 设置过期时间,单位ms,通常也可以由生产者指定时间// arguments.put("x-message-ttl", 10000);// 给正常队列设置死信交换机arguments.put("x-dead-letter-exchange", DEAD_EXCHANGE);// 给正常队列设置死信RoutingKeyarguments.put("x-dead-letter-routing-key", "lisi");// 给正常队列设置限制消息数arguments.put("x-max-length", 6);

此时如果再次启动Consumer01会报错,因为正常队列已经产生,不能再次修改一些参数,所以需要先删除普通队列,之后再重新启动。

image-20210829112557183

现在将Consumer01重新启动,创建好队列之后,将Consumer01停止运行,防止消息不能被积压在正常队列中,最终产生不了消息积压的现象。之后重新启动生产者,最终将消息发送到正常队列中,全部产生的消息,超过6条的部分会转为死信消息,存放在死信队列中等待处理。

消息被拒绝

当消息被消费者拒绝应答,并且选择不放回原有正常队列进行二次消费,那么消息将会进入到死信队列中。

将原有的C1中的参数注释掉:

// 设置arguments所需要的参数Map<String, Object> arguments = new HashMap<>();// 设置过期时间,单位ms,通常也可以由生产者指定时间// arguments.put("x-message-ttl", 10000);// 给正常队列设置死信交换机arguments.put("x-dead-letter-exchange", DEAD_EXCHANGE);// 给正常队列设置死信RoutingKeyarguments.put("x-dead-letter-routing-key", "lisi");// 给正常队列设置限制消息数// arguments.put("x-max-length", 6);

在C1消费消息的时候设置手动应答,设置特定消息将会被特殊处理:

DeliverCallback deliverCallback = (consumerTag, message) -> {    String msg = new String(message.getBody(), StandardCharsets.UTF_8);    if ("info5".equals(msg)) {        System.out.println("Consumer1接受的消息是:" + msg + ":此消息被C1拒绝");        // 拒绝消息,获取被拒绝的消息的Tag标签,然后决定是否放回队列,此处选择不放回原有队列,最终将消息转发到死信队列        channel.basicReject(message.getEnvelope().getDeliveryTag(), false);    } else {        System.out.println("Consumer1接受的消息是:" + msg);        // 应答消息,获取被应答的消息的Tag标签,然后决定是否批量应答,此处选择不批量应答        channel.basicAck(message.getEnvelope().getDeliveryTag(), false);    }};// 消费者消费消息,开启手动应答channel.basicConsume(NORMAL_QUEUE, false, deliverCallback, consumerTag -> {});

这样设置之后,将会有info5这条消息不会被应答,且不会放回原有正常队列中进行二次尝试,最终将会放到死信队列中等待处理。此过程一样需要删除原有正常队列之后操作。

image-20210829114127787

最终查看可以得知,只有一条消息成为死信。查看消息内容,可以得知消息内容为info5。

image-20210829114236439

八、延迟队列

之前的死信队列中,程序的架构图如下所示:

image-20210817101653481

在这个架构中,当消息队列想要延迟某个时间段接收到消息,直接使用死信队列中的TTL过期一种情况即可。也就是相当于C1不存在,所有由Producer生产的消息在经过TTL时间之后将会直接交由C2接收,这种情况就属于延迟队列。

概念

延迟队列,队列内部是有序的,突出的是延迟属性,是用来存放需要在指定时间被处理的元素的队列。

image-20210829120526355

简单来说,整体的结构如图所示,最终实现的是延迟特定时间再处理。

常见的应用场景有:未支付订单倒计时结束的处理、支付订单倒计时结束自动确认、退款订单规定时间后自动退款等,用延迟队列处理可以减少监听线程的开销。

TTL

TTL,Time To Live,生存时间值。

如果一条消息设置了 TTL 属性或者进入了设置 TTL 属性的队列,那么这条消息如果在 TTL 设置的时间内没有被消费,则会成为"死信"。如果同时配置了队列的 TTL 和消息的 TTL,那么较小的那个值将会被使用,有两种方式设置 TTL。

  • 队列设置TTL

    • // 设置arguments所需要的参数
      Map<String, Object> arguments = new HashMap<>();
      // 设置过期时间,单位ms,通常也可以由生产者指定时间
      arguments.put("x-message-ttl", 10000);
      // 声明普通队列
      channel.queueDeclare(NORMAL_QUEUE, false, false, false, arguments);
      
  • 消息设置TTL

    • // 设置死信消息 TTL时间 单位毫秒值
      AMQP.BasicProperties properties = new AMQP.BasicProperties()
              .builder().expiration("10000").build();
      

整合Spring Boot

利用Spring Initializr创建一个Spring Boot工程。

image-20210829123127456

创建完成之后在pom.xml中添加必要的依赖坐标:

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

之后在application.properties添加配置信息:

spring.rabbitmq.host=192.168.111.138spring.rabbitmq.port=5672spring.rabbitmq.username=adminspring.rabbitmq.password=123

在启动类下新建一个config包,之后添加一个SwaggerConfig类作为测试:

package com.company.rabbitmq.springbootrabbitmq.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import springfox.documentation.builders.ApiInfoBuilder;
import springfox.documentation.service.ApiInfo;
import springfox.documentation.service.Contact;
import springfox.documentation.spi.DocumentationType;
import springfox.documentation.spring.web.plugins.Docket;
import springfox.documentation.swagger2.annotations.EnableSwagger2;

/**
 * @ author: Real
 * @ date: 2021年08月29日 12:41
 */
@Configuration
@EnableSwagger2
public class SwaggerConfig {
    @Bean
    public Docket webApiConfig() {
        return new Docket(DocumentationType.SWAGGER_2)
                .groupName("webApi")
                .apiInfo(webApiInfo())
                .select()
                .build();
    }

    private ApiInfo webApiInfo() {
        return new ApiInfoBuilder()
                .title("rabbitmq 接口文档")
                .description("本文档描述了 rabbitmq 微服务接口定义")
                .version("1.0")
                .contact(new Contact("enjoy6288", "https://blog.csdn.net/qq_43103529?spm=1001.2101.3001.5343",
                        "1551388580@qq.com"))
                .build();
    }
}

整体的项目结构如下所示:

image-20210829124717242

至此,项目创建完成。

TTL延迟队列

创建两个队列 QA 和 QB,两者队列 TTL 分别设置为 10S 和 40S,然后在创建一个交换机 X 和死信交换机 Y,它们的类型都是 direct,创建一个死信队列 QD,它们的绑定关系如下:

image-20210829125929026

由于使用了SpringBoot配置类,在使用的时候由于需要解耦合,所以消费者和生产者中间的队列交换机部分可以直接通过配置类实现。配置类如下所示:

package com.company.rabbitmq.springbootrabbitmq.config;

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

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

/**
 * @ author: Real
 * @ date: 2021年08月29日 13:02
 * TTL队列 配置文件类
 */
@Configuration
public class TtlQueueConfig {

    // 普通交换机的名称
    public static final String X_EXCHANGE = "X";
    // 死信交换机的名称
    public static final String Y_DEAD_LETTER_EXCHANGE = "Y";
    // 普通队列的名称
    public static final String QUEUE_A = "QA";
    public static final String QUEUE_B = "QB";
    // 死信队列的名称
    public static final String DEAD_LETTER_QUEUE = "QD";

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

    // 声明yExchange交换机,别名
    @Bean("yExchange")
    public DirectExchange yExchange() {
        return new DirectExchange(Y_DEAD_LETTER_EXCHANGE);
    }

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

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

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

    // 绑定,从交换机到队列之间的绑定,即将队列到交换机之间用routingKey绑定
    @Bean
    public Binding queueABindingX(@Qualifier("queueA") Queue queueA,
                                  @Qualifier("xExchange") DirectExchange xExchange) {
        return BindingBuilder.bind(queueA).to(xExchange).with("XA");
    }
    @Bean
    public Binding queueBBindingX(@Qualifier("queueB") Queue queueB,
                                  @Qualifier("xExchange") DirectExchange xExchange) {
        return BindingBuilder.bind(queueB).to(xExchange).with("XB");
    }
    @Bean
    public Binding queueDBindingY(@Qualifier("queueD") Queue queueD,
                                  @Qualifier("yExchange") DirectExchange yExchange) {
        return BindingBuilder.bind(queueD).to(yExchange).with("YD");
    }
}

整个过程中,队列和交换机的声明、绑定全部交由这些Bean实例完成。之后只需要编写生产者和消费者即可,完成了解耦合,也完成了代码的简化工作。

消费者:

package com.company.rabbitmq.springbootrabbitmq.consumer;

import com.rabbitmq.client.AMQP;
import com.rabbitmq.client.Channel;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.core.Message;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.stereotype.Component;

import java.nio.charset.StandardCharsets;
import java.util.Date;

/**
 * @ author: Real
 * @ date: 2021年08月29日 13:58
 * 队列TTL消费者
 */
@Slf4j
@Component
public class DeadLetterQueueConsumer {

    // 接收消息
    @RabbitListener(queues = "QD")
    public void receiveD(Message message, Channel channel) {
        String msg = new String(message.getBody(), StandardCharsets.UTF_8);
        log.info("当前时间:{},收到死信队列的消息:{}", new Date(), msg);
    }

}

生产者:

package com.company.rabbitmq.springbootrabbitmq.controller;

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

import java.util.Date;

/**
 * @ author: Real
 * @ date: 2021年08月29日 13:46
 * 发送消息  http://localhost:8080/ttl/sendMsg/嘻嘻嘻
 */
@Slf4j
@RestController
@RequestMapping("/ttl")
public class SendMessageController {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    // 开始发消息
    @GetMapping("/sendMsg/{message}")
    public void sendMsg(@PathVariable String message) {
        log.info("当前时间:{},发送一条消息给TTL队列:{}", new Date(), message);
        rabbitTemplate.convertAndSend("X", "XA", "消息来自ttl为10s的队列:" + message);
        rabbitTemplate.convertAndSend("X", "XB", "消息来自ttl为40s的队列:" + message);
    }
}

整个过程中出现的问题:

org.springframework.amqp.rabbit.listener.BlockingQueueConsumer$DeclarationException: Failed to declare queue(s):[queueD]

// 声明死信队列@Bean("queueD")public Queue queueD() {    return QueueBuilder.durable(DEAD_LETTER_QUEUE).build();}

经检查发现queueD的声明代码编写错误,应该修改为:

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

此外,如果事先运行过一次,还是会发生此类错误,需要将之前产生的队列删除后重新运行。

image-20210829141627446

将原先没修改前产生的队列删除后重新运行,之后发现还是无法正常启动,最终检查后发现,得知在接收消息的代码中,队列名字没有写正确,最后修改后运行成功。

public class DeadLetterQueueConsumer {    // 接收消息    @RabbitListener(queues = "QD")    public void receiveD(Message message, Channel channel) {        String msg = new String(message.getBody(), StandardCharsets.UTF_8);        log.info("当前时间:{},收到死信队列的消息:{}", new Date(), msg);    }}

运行时先启动Spring Boot启动类,之后浏览器访问:localhost:8080/ttl/sendMsg/嘻嘻嘻,最后出现消息。

运行结果如下所示:此处连续发送了两条

image-20210829142837010

运行结果来看,整体结果符合我们的需求,但是在实际使用中,这样的结果应该是不符合要求的。我们一共使用了两个延迟队列,一个死信队列;设置了两个延迟时间,分别是10s和40s;但是如果我们需要多种不同的延迟时间来处理不同的消息,岂不是每多设置一个新的延迟时间,将会新增加一个延迟队列?

所以,在实际使用中,我们应该针对代码进行优化,减少不必要的资源消耗。

延迟队列优化

之前我们使用的队列的延迟时间都是固定的,最终只能适应某一特定的时间场景使用。

image-20210829144938205

在这里我们新添加一个QC队列,不设置TTL时间,它的绑定关系如图所示。

// 声明QC普通队列,不设置TTL时间@Bean("queueC")public Queue queueC() {    Map<String, Object> arguments = new HashMap<>();    // 设置死信交换机    arguments.put("x-dead-letter-exchange", Y_DEAD_LETTER_EXCHANGE);    // 设置死信RoutingKey    arguments.put("x-dead-letter-routing-key", "YD");    return QueueBuilder.durable(QUEUE_C).withArguments(arguments).build();}
@Beanpublic Binding queueCBindingX(@Qualifier("queueC") Queue queueC,                              @Qualifier("xExchange") DirectExchange xExchange) {    return BindingBuilder.bind(queueC).to(xExchange).with("XC");}

声明队列并且完成绑定之后,在生产者添加相关的代码部分:

// 开始发消息 消息 TTL@GetMapping("/sendExpirationMsg/{message}/{ttlTime}")public void sendExpirationMsg(@PathVariable String message, @PathVariable String ttlTime) {    log.info("当前时间:{},发送一条时长为{}毫秒消息给QC:{}", new Date(), ttlTime, message);    rabbitTemplate.convertAndSend("X", "XC", message, msg -> {        // 设置发送消息的时候的发送时长 延迟时长        msg.getMessageProperties().setExpiration(ttlTime);        return msg;    });}

发送两个请求

http://localhost:8080/ttl/sendExpirationMsg/你好 1/20000

http://localhost:8080/ttl/sendExpirationMsg/你好 2/2000

之后可以在控制台看到相关的消息:

image-20210829151608393

以上就完成了TTL队列的优化步骤,能够让一个队列实现不同时间的延迟需求,适应多种情形。

但是其实在运行时候存在多种情况,如下所示:

image-20210829152203980

看起来似乎没什么问题,但是在最开始的时候,就介绍过如果使用在消息属性上设置 TTL 的方式,消息可能并不会按时“死亡“,因为 RabbitMQ 只会检查第一个消息是否过期,如果过期则丢到死信队列, 如果第一个消息的延时时长很长,而第二个消息的延时时长很短,第二个消息并不会优先得到执行。

这样的情况下,第二个时间短的延迟消息将会在第一个延迟消息被处理后再被处理,这样的结果就会导致第二个消息的处理不够及时,这样的队列也就不能成为一个通用的延迟队列。

RabbitMQ 延迟队列插件

如果不能实现在消息粒度上的TTL,并且使其在设置的时间及时死亡,就无法设计成一个通用的延迟队列。接下来我们可以使用插件来完成这个事。

在官网上下载,下载 rabbitmq_delayed_message_exchange 插件,然后解压放置到 RabbitMQ 的插件目录。 进入 RabbitMQ 的安装目录下的 plgins 目录:

/usr/lib/rabbitmq/lib/rabbitmq_server-3.8.8/plugins

执行下面命令让该插件生效,然后重启 RabbitMQ 服务。

rabbitmq-plugins enable rabbitmq_delayed_message_exchange

首先将文件上传到Linux下,之后进入文件目录:

image-20210829163316505

将插件文件复制到指定的目录下:

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-20210829165047930

安装之后,使用指令/sbin/service rabbitmq-server restart重启RabbitMQ服务,插件生效。

重启之后,可以在添加交换机中看到新的类型的交换机,x-delayed-message

image-20210829165729415

之前没有使用插件的情况下,延迟队列的实现基于死信队列实现:

image-20210829172354555

安装延迟插件之后,延迟队列的实现:

image-20210829172211771

插件实现延迟队列

延迟队列的实现上,代码架构如下所示:

image-20210830132445254

一、首先编写延迟队列配置类DelayedQueueConfig

package com.company.rabbitmq.springbootrabbitmq.config;


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

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

/**
 * @ author: Real
 * @ date: 2021年08月30日 13:39
 * 延迟队列配置类
 */
@Configuration
public class DelayedQueueConfig {

    // 交换机
    public static final String DELAYED_EXCHANGE_NAME = "delayed.exchange";
    // 队列
    public static final String DELAYED_QUEUE_NAME = "delayed.queue";
    // Routing Key
    public static final String DELAYED_ROUTING_KEY = "delayed.routingkey";

    // 声明交换机,延迟交换机需要自定义,基于插件
    @Bean
    public CustomExchange delayedExchange() {
        /**
         * 1.String name        交换机名称
         * 2.String type        交换机类型
         * 3.boolean durable    是否持久化
         * 4.boolean autoDelete 是否自动删除
         * 5.Map<String, Object> arguments  交换机参数
         */
        Map<String, Object> arguments = new HashMap<>();
        // 延迟类型为直接类型
        arguments.put("x-delayed-type", "direct");
        return new CustomExchange(DELAYED_EXCHANGE_NAME,
                "x-delayed-message", false, false, arguments);
    }

    // 声明队列
    @Bean
    public Queue delayedQueue() {
        /**
         * 1.String name        队列名称
         * 2.boolean durable    是否持久化
         * 3.boolean exclusive  是否排他
         * 4.boolean autoDelete 是否自动删除
         * 5.@Nullable Map<String, Object> arguments    其他参数
         */
        return new Queue(DELAYED_QUEUE_NAME);
    }

    // 绑定
    @Bean
    public Binding delayedQueueBindingDelayedExchange(
            @Qualifier("delayedExchange") CustomExchange delayedExchange,
            @Qualifier("delayedQueue") Queue delayedQueue) {
        // 此处需要调用一个构建方法,因为延迟交换机的类型,所以与之前有所不同
        return BindingBuilder.bind(delayedQueue).to(delayedExchange).with(DELAYED_ROUTING_KEY).noargs();
    }
}

二、编写生产者,负责向队列发送消息

// 发送消息,基于延迟插件的消息以及延迟时间@GetMapping("/sendDelayMsg/{message}/{delayTime}")public void sendDelayMsg(@PathVariable String message, @PathVariable Integer delayTime) {    log.info("当前时间:{},发送一条延迟时长为{}毫秒消息给延迟队列delayed.queue:{}", new Date(), delayTime, message);    rabbitTemplate.convertAndSend(DelayedQueueConfig.DELAYED_EXCHANGE_NAME, DelayedQueueConfig.DELAYED_ROUTING_KEY, message, msg -> {        // 发送延迟消息,设置延迟时间,ms        msg.getMessageProperties().setDelay(delayTime);        return msg;    });}

三、编写消费者类DelayedQueueConsumer

package com.company.rabbitmq.springbootrabbitmq.consumer;import com.company.rabbitmq.springbootrabbitmq.config.DelayedQueueConfig;import lombok.extern.slf4j.Slf4j;import org.springframework.amqp.core.Message;import org.springframework.amqp.rabbit.annotation.RabbitListener;import org.springframework.stereotype.Component;import java.nio.charset.StandardCharsets;import java.util.Date;/** * @ author: Real * @ date: 2021年08月30日 14:09 * 延迟队列的消费者,基于插件的延迟队列 */@Slf4j@Componentpublic class DelayedQueueConsumer {    // 监听消息,接收消息    @RabbitListener(queues = "delayed.queue")    public void receiveDelayQueue(Message message) {        String msg = new String(message.getBody(), StandardCharsets.UTF_8);        log.info("当前时间:{},收到延迟队列的消息:{}", new Date(), msg);    }}

完成之后启动启动类,在浏览器中输入下面的网址,进行测试:

http://localhost:8080/ttl/sendDelayMsg/come1/20000
http://localhost:8080/ttl/sendDelayMsg/come2/2000

其中发生了一些错误:

报错:reply-code=406, reply-text=PRECONDITION_FAILED
原因:设置了交换机持久化,之后二次运行由于交换机已经存在,所以出现报错
解决:删除原有的交换机重启即可

解决之后,运行结果如下所示:

image-20210830154456512

观察时间进度可以得知,这种情况下的延迟队列解决了之前的问题(消息进入队列设置延迟时间后,会按照入队顺序处理消息,最终处理消息的时间不符合预期)。

总结

这一节中,延迟队列存在两种实现方式,第一种是通过死信队列实现,但是存在弊端,不符合需求;第二种是安装RabbitMQ延迟消息插件实现,可以得到符合需求的延迟消息。

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

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

九、发布确认高级

在生产环境中,可能由于某些原因导致RabbitMQ服务器宕机,那么在其重启的这段时间内,生产者投递的消息可能会失败,那么将如何处理这些投递失败的消息?将怎样才能保证消息不会丢失?

如果消息投递不成功,可能会报以下错误:

org.springframework.amqp.rabbit.listener.BlockingQueueConsumer:start:620

确认机制方案

如果RabbitMQ的服务器发生问题,那么交换机和队列都有可能接收不到消息。但是这个反馈是生产者所不知道的,那么现在就需要一个机制来暂时保存交换机和队列接收不到的消息。

image-20210830174350328

所以根据这种思路,我们应该在生产者发送的消息无法正常投递的时候,设立一个缓存机制来正常保存生产者发出的消息。代码的整体思路如下:

image-20210830174630608

第一种情况是交换机出问题,无法将消息转发到正确的队列中,那么这个时候应该如何处理?

首先来编写相关的代码进行测试实验:

一、配置类PublishConfirmConfig类

package com.company.rabbitmq.springbootrabbitmq.config;

import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.DirectExchange;
import org.springframework.amqp.core.Queue;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
 * @ author: Real
 * @ date: 2021年08月30日 17:49
 * 发布确认配置类
 */
@Configuration
public class PublishConfirmConfig {

    // 交换机
    public static final String CONFIRM_EXCHANGE_NAME = "confirm.exchange";
    // 队列
    public static final String CONFIRM_QUEUE_NAME = "confirm.queue";
    // RoutingKey
    public static final String CONFIRM_ROUTING_KEY = "key1";

    // 声明定义交换机
    @Bean
    public DirectExchange confirmExchange() {
        return new DirectExchange(CONFIRM_EXCHANGE_NAME);
    }

    // 声明定义队列
    @Bean
    public Queue confirmQueue() {
        return new Queue(CONFIRM_QUEUE_NAME);
    }

    // 绑定
    @Bean
    public Binding queueBindingExchange(
            @Qualifier("confirmExchange") DirectExchange exchange,
            @Qualifier("confirmQueue") Queue queue) {
        return BindingBuilder.bind(queue).to(exchange).with(CONFIRM_ROUTING_KEY);
    }

}

二、生产者PublishConfirmController

package com.company.rabbitmq.springbootrabbitmq.controller;

import com.company.rabbitmq.springbootrabbitmq.config.PublishConfirmConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @ author: Real
 * @ date: 2021年08月30日 18:02
 * 发布确认测试,生产者
 */
@Slf4j
@RestController
@RequestMapping("/confirm")
public class PublishConfirmController {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    // 发消息
    @GetMapping("/sendMessage/{message}")
    public void sendMessage(@PathVariable String message) {
        rabbitTemplate.convertAndSend(PublishConfirmConfig.CONFIRM_EXCHANGE_NAME,
                PublishConfirmConfig.CONFIRM_ROUTING_KEY, message);
        log.info("发送消息内容:{}", message);
    }

}

三、消费者PublishConfirmConsumer

package com.company.rabbitmq.springbootrabbitmq.consumer;import com.company.rabbitmq.springbootrabbitmq.config.PublishConfirmConfig;import lombok.extern.slf4j.Slf4j;import org.springframework.amqp.core.Message;import org.springframework.amqp.rabbit.annotation.RabbitListener;import org.springframework.stereotype.Component;/** * @ author: Real * @ date: 2021年08月30日 18:07 * 发布确认消费者 */@Slf4j@Componentpublic class PublishConfirmConsumer {    // 接收消息    @RabbitListener(queues = PublishConfirmConfig.CONFIRM_QUEUE_NAME)    public void receiveConfirmMessage(Message message) {        String msg = new String(message.getBody());        log.info("接收到队列confirm.queue的消息:{}", msg);    }}

编写完成三个架构之后,我们启动Spring Boot的启动类,之后在浏览器窗口输入网址:http://localhost:8080/confirm/sendMessage/Hello!,可以在控制台窗口看到相关的信息:

image-20210830181303054

此时所有的队列结构都是正常的。

发布确认问题实现

上述的项目在运行的过程中是没有问题的,但是如果出现了极端情况导致服务器集群宕机,最终交换机和队列集体挂掉,会出现生产者发出消息无法被接收处理的问题。

image-20210830174630608

根据结构图显示,分为三种情况:

  • 队列宕机
    • 这种情况下,最终应该在队列所处位置进行一定的处理。
  • 交换机宕机
    • 交换机宕机
    • 交换机和队列宕机
    • 上述两种情况都归咎于交换机,因为消息发送的顺序决定最先收到消息的应该是交换机。

出现上述这种情况,我们应该使用发布确认的回调功能来实现。

一、回调接口的实现类MyCallback类

package com.company.rabbitmq.springbootrabbitmq.config;import lombok.extern.slf4j.Slf4j;import org.springframework.amqp.rabbit.connection.CorrelationData;import org.springframework.amqp.rabbit.core.RabbitTemplate;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.stereotype.Component;import javax.annotation.PostConstruct;/** * @ author: Real * @ date: 2021年08月31日 11:23 * 发布确认的回调接口 */@Slf4j@Componentpublic class MyCallback implements RabbitTemplate.ConfirmCallback {    @Autowired    private RabbitTemplate rabbitTemplate;    // 注入    @PostConstruct    public void init() {        rabbitTemplate.setConfirmCallback(this);    }    /**     * 交换机确认回调方法     * @param correlationData     * @param ack     * @param cause     * 1.发消息交换机接收到了回调     *  1.1 correlationData 保存回调消息的ID及相关信息     *  1.2 交换机收到消息,ack = true     *  1.3 cause null     * 2.发消息交换机接收失败了回调     *  2.1 correlationData 保存回调消息的ID及相关信息     *  2.2 交换机收到消息     *  2.3 cause 失败的原因     */    @Override    public void confirm(CorrelationData correlationData, boolean ack, String cause) {        String ID = correlationData != null ? correlationData.getId() : "";        if (ack) {            log.info("交换机已经收到了ID为:{}的消息", ID);        } else {            log.info("交换机未收到ID为:{}的消息;由于原因:{}", ID, cause);        }    }}

二、在生产者的代码中添加必要的参数CorrelationData来实现回调

// 发消息@GetMapping("/sendMessage/{message}")public void sendMessage(@PathVariable String message) {    CorrelationData correlationData = new CorrelationData("1");    rabbitTemplate.convertAndSend(PublishConfirmConfig.CONFIRM_EXCHANGE_NAME,            PublishConfirmConfig.CONFIRM_ROUTING_KEY, message, correlationData);    log.info("发送消息内容:{}", message);}

三、添加配置文件中的参数信息

spring.rabbitmq.publisher-confirm-type=correlated

该配置信息有以下三种参数选项:

  • NONE 禁用发布确认模式,是默认值
  • CORRELATED 发布消息成功到交换器后会触发回调方法
  • SIMPLE 经测试有两种效果,其一效果和 CORRELATED 值一样会触发回调方法, 其二在发布消息成功后使用 rabbitTemplate 调用 waitForConfirms 或 waitForConfirmsOrDie 方法等待 broker 节点返回发送结果,根据返回结果来判定下一步的逻辑,要注意的点是 waitForConfirmsOrDie 方法如果返回 false 则会关闭 channel,则接下来无法发送消息到 broker。

添加完成之后,将程序启动,输入网址localhost:8080/confirm/sendMessage/Hello,之后可以在控制台看到打印的消息。

image-20210831115616747

如果此时在发送消息处修改交换机名字,相当于交换机出现问题,模拟除了交换机宕机的场景,此时重新发送请求,控制台的消息为:

2021-08-31 11:58:35.557 INFO 11068 — [nio-8080-exec-1] c.c.r.s.c.PublishConfirmController : 发送消息内容:Hello
2021-08-31 11:58:35.563 ERROR 11068 — [68.111.139:5672] o.s.a.r.c.CachingConnectionFactory : Shutdown Signal: channel error; protocol method: #method<channel.close>(reply-code=404, reply-text=NOT_FOUND - no exchange ‘confirm.exchange123’ in vhost ‘/’, class-id=60, method-id=40)
2021-08-31 11:58:35.628 INFO 11068 — [nectionFactory2] c.c.r.s.config.MyCallback : 交换机未收到ID为:1的消息;由于原因:channel error; protocol method: #method<channel.close>(reply-code=404, reply-text=NOT_FOUND - no exchange ‘confirm.exchange123’ in vhost ‘/’, class-id=60, method-id=40)

从打印的消息可以看出原因是找不到交换机(交换机“confirm.exchange123”不存在)。

如果此时将routingKey修改,可以模拟出队列出现问题的场景:

2021-08-31 12:04:39.338 INFO 13184 — [nio-8080-exec-1] c.c.r.s.c.PublishConfirmController : 发送消息内容:Hello
2021-08-31 12:04:39.341 INFO 13184 — [nectionFactory1] c.c.r.s.config.MyCallback : 交换机已经收到了ID为:3的消息

可以看出并没有出现消费者打印的消息,所以可以看出消费者并没有接收到消息。

以上测试的生产者部分的代码如下:

package com.company.rabbitmq.springbootrabbitmq.controller;

import com.company.rabbitmq.springbootrabbitmq.config.PublishConfirmConfig;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.connection.CorrelationData;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

/**
 * @ author: Real
 * @ date: 2021年08月30日 18:02
 * 发布确认测试,生产者
 */
@Slf4j
@RestController
@RequestMapping("/confirm")
public class PublishConfirmController {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    // 发消息
    @GetMapping("/sendMessage/{message}")
    public void sendMessage(@PathVariable String message) {

        /*CorrelationData correlationData1 = new CorrelationData("1");
        rabbitTemplate.convertAndSend(PublishConfirmConfig.CONFIRM_EXCHANGE_NAME,
                PublishConfirmConfig.CONFIRM_ROUTING_KEY, message, correlationData1);
        log.info("发送消息内容:{}", message);

        CorrelationData correlationData2 = new CorrelationData("2");
        rabbitTemplate.convertAndSend(PublishConfirmConfig.CONFIRM_EXCHANGE_NAME + "123",
                PublishConfirmConfig.CONFIRM_ROUTING_KEY, message, correlationData2);
        log.info("发送消息内容:{}", message);*/

        CorrelationData correlationData3 = new CorrelationData("3");
        rabbitTemplate.convertAndSend(PublishConfirmConfig.CONFIRM_EXCHANGE_NAME,
                PublishConfirmConfig.CONFIRM_ROUTING_KEY + "123", message, correlationData3);
        log.info("发送消息内容:{}", message);
    }

}

回退消息

此时可以看出在三种情况下,有两种情形是归于交换机,一种情形是归于队列。所以应该如果处理未被正常转发的消息呢?

上述的情况下我们仅仅开启了生产者的确认机制,在这种情况下,交换机接收到消息后,会直接给消息生产者发送确认消息。如果发现该消息不可路由(也就是无法转发到队列中),那么消息会被直接丢弃,此时生产者是不知道消息被丢弃这个事件的。所以我们应该设置一个机制来处理这些无法被路由而被丢弃的消息。

按照逻辑来说,无法正常被路由的消息,应该让生产者得知是哪条消息无法被路由,之后让生产者在特定的时候选择重新发送消息。

一、首先在配置文件application.properties中打开回退消息

spring.rabbitmq.publisher-returns=true

二、在配置类中实现RabbitTemplate.ReturnsCallback接口

/**
 * @ author: Real
 * @ date: 2021年08月31日 11:23
 * 发布确认的回调接口
 */
@Slf4j
@Component
public class MyCallback implements RabbitTemplate.ConfirmCallback, RabbitTemplate.ReturnCallback {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    // 注入
    @PostConstruct
    public void init() {
        rabbitTemplate.setConfirmCallback(this);
        rabbitTemplate.setReturnCallback(this);
    }

    /**
     * 交换机确认回调方法
     * @param correlationData
     * @param ack
     * @param cause
     * 1.发消息交换机接收到了回调
     *  1.1 correlationData 保存回调消息的ID及相关信息
     *  1.2 交换机收到消息,ack = true
     *  1.3 cause null
     * 2.发消息交换机接收失败了回调
     *  2.1 correlationData 保存回调消息的ID及相关信息
     *  2.2 交换机收到消息
     *  2.3 cause 失败的原因
     */
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        String ID = correlationData != null ? correlationData.getId() : "";
        if (ack) {
            log.info("交换机已经收到了ID为:{}的消息", ID);
        } else {
            log.info("交换机未收到ID为:{}的消息;由于原因:{}", ID, cause);
        }
    }

    /**
     * Returned message callback.
     * 可以在消息传递过程中不可达目的地时将消息回退给生产者
     * 只有在消息不可达的情况下才会回退消息
     * @param message    the returned message.
     * @param replyCode  the reply code.
     * @param replyText  the reply text.
     * @param exchange   the exchange.
     * @param routingKey the routing key.
     */
    @Override
    public void returnedMessage(Message message, int replyCode, String replyText, String exchange, String routingKey) {
        log.info("消息:{},被交换机{}退回。退回原因:{},路由Key:{}",
                new String(message.getBody()), exchange, replyText, routingKey);
    }

}

三、修改不同的生产者的情况之后,启动程序,在浏览器中输入测试网址localhost:8080/confirm/sendMessage/Hello,得到测试结果:

  • 如果此时修改的是RoutingKey,等同于队列出现问题,消息无法从交换机路由到队列中,此时运行结果:

2021-08-31 12:47:14.051 INFO 2224 — [nio-8080-exec-1] c.c.r.s.c.PublishConfirmController : 发送消息内容:Hello
2021-08-31 12:47:14.053 INFO 2224 — [nectionFactory1] c.c.r.s.config.MyCallback : 消息:Hello,被交换机confirm.exchange退回。退回原因:NO_ROUTE,路由Key:key1123
2021-08-31 12:47:14.054 INFO 2224 — [nectionFactory1] c.c.r.s.config.MyCallback : 交换机已经收到了ID为:3的消息

可以看出此时消息被交换机接收,之后执行了回退消息的操作。

  • 如果此时修改的是交换机的名字,等同于交换机宕机,此时运行结果:

2021-08-31 12:42:12.419 INFO 3096 — [nio-8080-exec-1] c.c.r.s.c.PublishConfirmController : 发送消息内容:Hello
2021-08-31 12:42:12.423 ERROR 3096 — [68.111.139:5672] o.s.a.r.c.CachingConnectionFactory : Shutdown Signal: channel error; protocol method: #method<channel.close>(reply-code=404, reply-text=NOT_FOUND - no exchange ‘confirm.exchange123’ in vhost ‘/’, class-id=60, method-id=40)
2021-08-31 12:42:12.425 INFO 3096 — [nectionFactory2] c.c.r.s.config.MyCallback : 交换机未收到ID为:2的消息;由于原因:channel error; protocol method: #method<channel.close>(reply-code=404, reply-text=NOT_FOUND - no exchange ‘confirm.exchange123’ in vhost ‘/’, class-id=60, method-id=40)

无法看到回退的控制台打印的消息,所以可以看出在交换机宕机的情况下,回退消息是无法完成的。回退消息只能在消息无法被交换机路由到正确的队列中的时候生效。

备份交换机

上述的处理过程中,我们只是将无法被路由的消息打印了日志,并没有真正处理这些消息。那么如果想要处理这些消息,我们有两种方法,第一种是将消息给生产者重新投递,但是这样无疑会增加生产者的复杂性而且很不优雅。第二种方式就是备份交换机,将交换机无法路由的消息备份到备份交换机中。通常情况下,我们将备份交换机的类型设置为Fanout扇出交换机,这样就能将其收到的消息转发到所有与他绑定的队列中,这样那些无法被路由的消息将会全部被备份交换机所绑定的队列所接收。当然,我们还可以建立一个报警队列,用独立的消费者来进行监测和报警。

image-20210831125651022

代码的结构如图所示,下面开始编码:

一、在配置类PublishConfirmConfig类中添加新的配置,声明备份交换机以及备份队列、警报队列

package com.company.rabbitmq.springbootrabbitmq.config;import org.springframework.amqp.core.*;import org.springframework.beans.factory.annotation.Qualifier;import org.springframework.context.annotation.Bean;import org.springframework.context.annotation.Configuration;/** * @ author: Real * @ date: 2021年08月30日 17:49 * 发布确认配置类 */@Configurationpublic class PublishConfirmConfig {    // 交换机    public static final String CONFIRM_EXCHANGE_NAME = "confirm.exchange";    // 队列    public static final String CONFIRM_QUEUE_NAME = "confirm.queue";    // RoutingKey    public static final String CONFIRM_ROUTING_KEY = "key1";    // 备份交换机    public static final String BACKUP_EXCHANGE_NAME = "backup.exchange";    // 备份队列    public static final String BACKUP_QUEUE_NAME = "backup.queue";    // 报警队列    public static final String WARNING_QUEUE_NAME = "warning.queue";    // 声明定义交换机    @Bean    public DirectExchange confirmExchange() {        // 将无法投递的消息能够转发给备份交换机的关联操作        return ExchangeBuilder.directExchange(CONFIRM_EXCHANGE_NAME).durable(true)                .withArgument("alternate-exchange", BACKUP_EXCHANGE_NAME).build();    }    // 声明定义队列    @Bean    public Queue confirmQueue() {        return new Queue(CONFIRM_QUEUE_NAME);    }    // 绑定    @Bean    public Binding queueBindingExchange(            @Qualifier("confirmExchange") DirectExchange exchange,            @Qualifier("confirmQueue") Queue queue) {        return BindingBuilder.bind(queue).to(exchange).with(CONFIRM_ROUTING_KEY);    }    // 声明备份交换机    @Bean    public FanoutExchange backupExchange() {        return new FanoutExchange(BACKUP_EXCHANGE_NAME);    }    // 声明备份队列    @Bean    public Queue backupQueue() {        return new Queue(BACKUP_QUEUE_NAME);    }    // 声明警报队列    @Bean    public Queue warningQueue() {        return new Queue(WARNING_QUEUE_NAME);    }    // 备份队列绑定备份交换机    @Bean    public Binding backupQueueBindingBackupExchange(            @Qualifier("backupExchange") FanoutExchange exchange,            @Qualifier("backupQueue") Queue queue) {        return BindingBuilder.bind(queue).to(exchange);    }    // 警报队列绑定备份交换机    @Bean    public Binding warningQueueBindingBackupExchange(            @Qualifier("backupExchange") FanoutExchange exchange,            @Qualifier("warningQueue") Queue queue) {        return BindingBuilder.bind(queue).to(exchange);    }}

二、编写警报消费者类WarningConsumer类

package com.company.rabbitmq.springbootrabbitmq.consumer;import com.company.rabbitmq.springbootrabbitmq.config.PublishConfirmConfig;import lombok.extern.slf4j.Slf4j;import org.springframework.amqp.core.Message;import org.springframework.amqp.rabbit.annotation.RabbitListener;import org.springframework.stereotype.Component;/** * @ author: Real * @ date: 2021年08月31日 13:44 * 警报消费者 */@Slf4j@Componentpublic class WarningConsumer {    // 接收警报消息    @RabbitListener(queues = PublishConfirmConfig.WARNING_QUEUE_NAME)    public void receiveWarningMsg(Message message) {        String msg = new String(message.getBody());        log.info("警报消费者接收到不可路由消息:{}", msg);    }}

三、测试运行,结果如下:

image-20210831135945748

由测试结果可以得知,在备份交换机和回退消息同时存在时,备份交换机的优先级更高。消息都已经转发到备份交换机了,也就没有回退的必要了,所以应该交由备份交换机处理。

十、其他内容

幂等性

用户对于同一操作发起的一次请求或者多次请求的结果是一致的,不会因为多次点击而产生了副作用。

最常见的场景就是支付场景下,用户点击一次支付扣款成功,返回结果的时候网络错误,最后用户点击两次,扣款两次。用户查询余额发现扣款两次,流水记录也变成了两次。

消息重复消费

在消费者消费完消息之后,发送Ack给MQ的时候网络出现问题,最终导致MQ没有收到Ack信息。该条消息会转发给其他消费者(备份交换机处理)或者网络恢复之后重新发给该消费者(回退消息),但实际上消费者已经成功消费该消息,最终导致消息被重复消费。

解决方法

MQ消费者的幂等性一般通过使用全局ID或唯一标识比如时间戳或UUID等来解决。本质上是使用唯一标识符来判断该消息是否已消费过。

业务高峰期中,有可能出现生产者重复产生消息,要实现消费者的幂等性保证消息永不可能被重复消费两次。一般提供两者方法:

  • 唯一ID+指纹码机制
    • 指纹码并不一定是我们系统生成的,可能只是由我们自己制定的某些规则拼接实现的。之后利用唯一性查询数据库,优势是使用简单,查询判断重复;劣势是性能有限,高并发情况下数据库性能有限,即便可以分库分表提升性能,但是还是存在瓶颈。
  • Redis原子性
    • 利用Redis的原子性实现幂等性。Redis中的setnx命令天然具有幂等性,可以实现不重复消费。

优先级队列

在实际订单的处理系统中,如果出现大量的高并发请求任务,但是由于处理的优先级以及性能限制,并不能将所有的订单都按照入队顺序处理,而是要按照轻重缓急的优先级处理。所以这个时候就需要使用优先级队列,简单来说就是将Tag和Priority分开区分,用Priority将队列中的消息排序处理。

image-20210901100830383

添加优先级队列,可以通过客户端添加:

image-20210901102800975

选择x-max-priority,取值范围是0-255,设置的是最大优先级。实际使用中一般使用0-10的范围,方便使用也方便计算,节约运算资源。

要实现优先队列,首先需要将队列设置为优先队列,设置队列最大优先级,还要对消息设置优先级。消费者需要等待消息全部进入到消息队列才会去消费,因为这样优先队列才能够对消息进行优先级排序。

优先级队列生产者

package com.company.rabbitmq.priority;

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

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeoutException;

/**
 * @ author: Real
 * @ date: 2021年08月02日 9:24
 * 生产者: 发送消息
 */
public class Producer {

    // 队列名称
    public static final String QUEUE_NAME = "hello";

    // 发送消息
    public static void main(String[] args) throws IOException, TimeoutException {
        // 创建一个连接工厂
        ConnectionFactory factory = new ConnectionFactory();
        // 工厂IP,连接RabbitMQ的队列
        factory.setHost("192.168.111.140");
        // 用户名
        factory.setUsername("admin");
        // 密码
        factory.setPassword("123");

        // 创建连接
        Connection connection = factory.newConnection();
        // 获取连接之后,发送消息需要通过信道才能完成
        // 获取信道
        Channel channel = connection.createChannel();

        /**
         * 生成一个队列
         * 1.队列名称;
         * 2.队列是否持久化,默认不持久化保存在内存中,持久化保存在磁盘中
         * 3.是否排他,默认为false(是否只供一个消费者进行消费,是否进行消息共享,true则可以共享)
         * 4.是否自动删除,最后一个消费者端断开连接之后,如果是true表示自动删除,默认是false
         * 5.设置队列的其他一些参数
         */
        Map<String, Object> arguments = new HashMap<>();
        arguments.put("x-max-priority", 10);
        channel.queueDeclare(QUEUE_NAME, true, false, false, arguments);

        // 发消息
        for (int i = 0; i < 10; i++) {
            String message = "info" + i;
            if (i == 5) {
                // 构建优先级
                AMQP.BasicProperties properties = new AMQP.BasicProperties()
                        .builder().priority(5).build();
                // 发布消息
                channel.basicPublish("", QUEUE_NAME, properties, message.getBytes());
            } else {
                // 发布消息
                channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
            }
        }

        // String message = "Hello World";

        /**
         * 发送一个消息
         * 1.发送到哪个交换机
         * 2.路由的Key值,本次是队列名称
         * 3.其他参数信息
         * 4.发送消息的消息体
         */
        // channel.basicPublish("", QUEUE_NAME, null, message.getBytes());
        System.out.println("消息发送完毕");
    }

}

优先级队列消费者

package com.company.rabbitmq.priority;

import com.rabbitmq.client.*;

import java.io.IOException;
import java.util.concurrent.TimeoutException;

/**
 * @ author: Real
 * @ date: 2021年08月02日 9:55
 */
public class Consumer {

    public static final String QUEUE_NAME = "hello";

    public static void main(String[] args) throws IOException, TimeoutException {
        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("192.168.111.140");
        factory.setUsername("admin");
        factory.setPassword("123");
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        // 声明回调函数,未成功消费的回调
        DeliverCallback deliverCallback = (consumerTag, message) -> {
            System.out.println(new String(message.getBody()));
        };

        // 声明回调函数,取消接收消息的回调
        CancelCallback cancelCallback = consumerTag -> {
            System.out.println("消息消费被中断");
        };

        /**
         * 消费者消费消息
         * 1.消费哪个队列,队列名
         * 2.消费者消费之后是否自动应答,true则为自动应答,false则为手动应答
         * 3.消费者消费消息的回调
         * 4.消费者取消消费的回调
         */
        channel.basicConsume(QUEUE_NAME, true, deliverCallback, cancelCallback);
    }

}

运行之后的结果为:

image-20210901113248901

最终可以看出,info5这条消息是最先被消费的。也就是说,这条消息的优先级最高。当消息进入优先级队列之后,队列将会按照优先级顺序给消息进行排序,然后再将消息转发给消费者进行消费。

惰性队列

  • 正常情况:消息是保存在内存中
  • 惰性队列:消息是保存在磁盘中

根据这上面的情况可以得知,惰性队列的读取速度不如正常队列。但是惰性队列的优势是能够存储更多的消息,在消费者下线或者宕机时,生产者如果产生大量的消息而且长期得不到消费造成堆积时,惰性队列就很有必要了。

客户端创建惰性队列:

image-20210901114300683

代码创建惰性队列:

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

内存占比分别为:

image-20210901114528610

惰性队列内存占比相比正常队列小得多,惰性队列主要仅仅存储消息的索引值,而消息的主题则存储在磁盘中,所以内存占比两者会有巨大的差距。

搭建集群

在实际生产中,由于性能的限制,可能需要多台机器一起构建RabbitMQ集群来共同处理消息。这个时候就需要搭建集群。将多台机器共同联接在一起,形成一个共享的环境,共同处理来自生产者的消息。

image-20210901115249927

实际操作:

一、克隆三台虚拟机,全部启动

image-20210901115706153

二、分别修改三台机器的主机名称

vim /etc/hostname

分别修改为node1、node2、node3

三、配置各个节点的 hosts 文件,让各个节点都能互相识别对方

vim /etc/hosts 
10.211.55.74 node1 
10.211.55.75 node2 
10.211.55.76 node3
[ip address] [nodeName]

四、确保各个节点的 cookie 文件使用的是同一个值

在 node1 上执行远程操作命令

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

五、启动 RabbitMQ 服务,顺带启动 Erlang 虚拟机和 RbbitMQ 应用服务

在三台机器上均运行以下命令:

rabbitmq-server -detached

六、在node2上运行下面命令:

rabbitmqctl stop_app
(rabbitmqctl stop 会将 Erlang 虚拟机关闭,rabbitmqctl stop_app 只关闭 RabbitMQ 服务)
rabbitmqctl reset
rabbitmqctl join_cluster rabbit@node1
rabbitmqctl start_app(只启动应用服务)

七、在node3上运行下面命令:

rabbitmqctl stop_app
rabbitmqctl reset
rabbitmqctl join_cluster rabbit@node2
rabbitmqctl start_app

这两个步骤执行顺序,逻辑在于将node2添加到node1之后,再将node3添加到node2上,搭建成为一个集群。

八、使用命令查看集群状态:

rabbitmqctl cluster_status

九、重新创建账号

创建账号
rabbitmqctl add_user admin 123
设置用户角色
rabbitmqctl set_user_tags admin administrator
设置用户权限
rabbitmqctl set_permissions -p "/" admin ".*" ".*" ".*"

十、解除集群状态,分别在node2和node3上运行

rabbitmqctl stop_app
rabbitmqctl reset
rabbitmqctl start_app
rabbitmqctl cluster_status
rabbitmqctl forget_cluster_node rabbit@node2(node1 机器上执行)

集群搭建完毕之后,任意登录一个客户端,都可以在其中看到节点的情况:

image-20210901121459873

镜像队列

集群搭建完之后,直接使用还是无法达到预期效果。此时实际上一台机器中只存在一个队列,最终工作的还是只有一台机器。如果 RabbitMQ 集群中只有一个 Broker 节点,那么该节点的失效将导致整体服务的临时性不可用,并且也可能会导致消息的丢失。即便此时将所有消息都设置持久化,并且队列也设置为持久化,但是仍然无法避免缓存带来的问题:在消息发送之后和将消息缓存写入磁盘的写盘动作之间存在一个时间空窗。通过发布确认机制PublishConfirm机制可以得知哪些消息已经存入磁盘,但还是应该尽量避免单点故障导致服务不可用。

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

一、启动三台集群节点,随便选择一个添加Policy

image-20210901130517733

二、在 node1 上创建一个队列发送一条消息,队列存在镜像队列

image-20210901130545056

三、停掉 node1 之后发现 node2 成为镜像队列

image-20210901130617098

四、就算整个集群只剩下一台机器了,依然能消费队列里面的消息

说明队列里面的消息被镜像队列传递到相应机器里面了

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

高可用的负载均衡需要借助负载均衡软件使用,整体的架构如下所示:

image-20210901131617670

因为在实现上述的集群搭建之后,如果发生node1宕机的情况,此时生产者并不会改变发送消息的目的队列,所以需要借助一个工具实现对消息的均衡转发。在RabbitMQ上,我们借助的是HAProxy+Keepalive来实现。此外,要实现负载均衡,还可以通过Nginx、LVS等。搭建步骤:

一、下载 haproxy(在 node1 和 node2上)

yum -y install haproxy

二、修改 node1 和 node2 的 haproxy.cfg

vim /etc/haproxy/haproxy.cfg 

需要修改红色 IP 为当前机器 IP

image-20210901132155590

三、在两台节点启动

haproxy haproxy -f /etc/haproxy/haproxy.cfg
ps -ef | grep haproxy 

四、访问地址 http://10.211.55.71:8888/stats

至此,负载均衡搭建完成。此外,还可以借助HAProxy和keepalive实现主从热备份。

HAProxy+keepalive实现主从热备份

如果HAProxy主机出现宕机或者网络,那么对于外界来说,RabbitMQ服务将会完全断开,为了确保负载均衡的可靠性,热备份就显得十分重要了。此时需要借助keepalive来实现备机转移成为主机,接收生产者的消息并且接管主机的工作。

一、下载 keepalived

yum -y install keepalived

二、节点 node1 配置文件

vim /etc/keepalived/keepalived.conf

把资料里面的 keepalived.conf 修改之后替换

三、节点 node2 配置文件

需要修改 global_defs 的 router_id,如:nodeB ;其次要修改 vrrp_instance_VI 中 state 为"BACKUP"; 最后要将 priority 设置为小于 100 的值

四、添加 haproxy_chk.sh

为了防止 HAProxy 服务挂掉之后 Keepalived 还在正常工作而没有切换到 Backup 上,所以这里需要编写一个脚本来检测 HAProxy 务的状态,当 HAProxy 服务挂掉之后该脚本会自动重启 HAProxy 的服务,如果不成功则关闭 Keepalived 服务,这样便可以切换到 Backup 继续工作

vim /etc/keepalived/haproxy_chk.sh(可以直接上传文件)
修改权限 chmod 777 /etc/keepalived/haproxy_chk.sh

五、启动 keepalive 命令(node1 和 node2 启动)

systemctl start keepalived

六、观察 Keepalived 的日志

tail -f /var/log/messages -n 200

七、观察最新添加的 vip

ip add show

八、node1 模拟 keepalived 关闭状态

systemctl stop keepalived

九、使用 vip 地址来访问 rabbitmq 集群

Federation Exchange

在多地部署服务时,会产生远程调用,在地理位置上可能会跨越非常远的空间距离,会造成相当时间的网络延时。如果将服务客户端部署到同一个地区,如果该服务仍需要调用原来的地点部署的服务,仍会产生远程调用,且容灾也不能很好的解决。为了解决这种情况,可以使用Federation Exchange联邦交换机来解决这个问题。

搭建步骤:

一、需要保证每台节点单独运行;在每台机器上开启 federation 相关插件

rabbitmq-plugins enable rabbitmq_federation
rabbitmq-plugins enable rabbitmq_federation_management

二、原理图(先运行 consumer 在 node2 创建 fed_exchange)

image-20210901134647913

三、在 downstream(node2)配置 upstream(node1)

image-20210901134726304

四、添加 policy

image-20210901134800787

五、正常运行的状态,前提

image-20210901134850951

Federation Queue

联邦队列可以在多个 Broker 节点(或者集群)之间为单个队列提供均衡负载的功能。

image-20210901134945065

一、添加 upstream(同Federation Exchange步骤)

二、添加Policy

image-20210901135039198

Shovel

与Federation 具备的数据转发功能类似,Shovel 能够可靠、持续地从一个 Broker 中的队列(作为源端,即 source)拉取数据并转发至另一个 Broker 中的交换器(作为目的端,即 destination)。

image-20210901135245234

搭建步骤:

一、在所有需要的机器上开启插件

rabbitmq-plugins enable rabbitmq_shovel
rabbitmq-plugins enable rabbitmq_shovel_management

二、添加 shovel 源和目的地

image-20210901135311122

  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值