RabbitMQ

RabbitMQ 消息队列

一、 MQ 介绍

1、什么是 MQ ?

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

用依赖其他服务。

2、为什么用 MQ ?

MQ 的三大特点:

流量消峰:

该场景一般在秒杀或者团购活动中使用广泛。

使用 MQ 后,用户的大量请求不在直接访问数据库,而是将大量请求积压在 MQ 消息队列中,数据库从 MQ 中拉取能处理的请求,避免了数据库因为大量请求出现崩溃、宕机等情况

1651043084451

应用解耦:

传统做法订单系统直接调用其他接口,如果有一个接口出现问题,整个订单系统无法正常运转的。

使用 MQ 后,将 MQ 作为中间件与其他接口相连,即使有一个接口出现问题,其他还是正常运转的。

1651043401951

异步处理:

场景说明:用户注册后,需要发送注册邮件和注册短信,传统的做法:1、串行方式 2、并行方式 3、MQ 消息队列

1、一套流程全部完成后,返回客户端

1651043736614

2、发送邮件的同时发送短信,节省了一定的时间

1651043772922

3、使用 MQ

1651043874778

3、MQ 的分类?

1、ActiveMQ

2、Kafka【大数据杀手锏】

3、RocketMQ【alibaba】

4、RabbitMQ

二、RabbitMQ 介绍

1、RabbitMQ 的相关概念

RabbitMQ 是一个消息中间件:它接受并转发消息。你可以把它当做一个快递站点,当你要发送一个包

裹时,你把你的包裹放到快递站,快递员最终会把你的快递送到收件人那里,按照这种逻辑 RabbitMQ 是

一个快递站,一个快递员帮你传递快件。RabbitMQ 与快递站的主要区别在于,它不处理快件而是接收,

存储和转发消息数据。

2、RabbitMQ 四大核心概念

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

**交换机:**交换机是 RabbitMQ 非常重要的一个部件,一方面它接收来自生产者的消息,另一方面它将消息

推送到队列中。交换机必须确切知道如何处理它接收到的消息,是将这些消息推送到特定队列还是推

送到多个队列,亦或者是把消息丢弃,这个得有交换机类型决定

队列: 保存消息

消费者: 消费与接收具有相似的含义。消费者大多时候是一个等待接收消息的程序。请注意生产者,消费

者和消息中间件很多时候并不在同一机器上。同一个应用程序既可以是生产者又是可以是消费者。

1651045350874

一般来说,一个消息队列对应一个消费者,就好比 一个快递对应一个收件人!!!!

3、RabbitMQ 的六个工作模式

1651045544499

简单模式、工作模式、发布订阅、routing路由模式、主题模式、RPC模式

4、RabbitMQ 的工作原理

1651045957910

组成部分说明:

Broker:消息队列服务进程,此进程包括两个部分:ExchangeQueue
Exchange:消息队列交换机,按一定的规则将消息路由转发到某个队列,对消息进行过虑。
Queue:消息队列,存储消息的队列,消息到达队列并转发给指定的消费方。
Producer:消息生产者,即生产方客户端,生产方客户端将消息发送到MQ。
Consumer:消息消费者,即消费方客户端,接收MQ转发的消息。

-----发送消息-----
1、生产者和Broker建立TCP连接。
2、生产者和Broker建立通道。
3、生产者通过通道消息发送给Broker,由Exchange将消息进行转发。
4、Exchange将消息转发到指定的Queue(队列)

----接收消息-----
1、消费者和Broker建立TCP连接
2、消费者和Broker建立通道
3、消费者监听指定的Queue(队列)
4、当有消息到达Queue时Broker默认将消息推送给消费者。
5、消费者接收到消息。

5、安装 RabbitMQ

官网下载:https://www.rabbitmq.com/download.html

(1)将以下俩个 rpm 安装包 使用 xftp 上传到 /usr/local/software 目录下。

注意erlang 和 rabbitmq 版本问题: RabbitMQ Erlang Version Requirements — RabbitMQ

1651053244344

(2)顺序执行以下命令

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

(3)设置开机自启动 rabbitMQ 、

systemctl enable rabbitmq-server.service
# 查询自启动状态
systemctl is-enabled rabbitmq-server.service

(4)启动 RabbitMQ 服务

systemctl start rabbitmq-server.service

6、安装 web 插件界面

​ (1)需要先关闭 RabbitMQ 服务

systemctl stop rabbitmq.service

(2)安装插件

rabbitmq-plugins enable rabbitmq_management

出现以下界面下载成功:

1651056717109

(3)安装完,重启 RabbitMQ 服务!

systemctl restart rabbitmq.service

(4)关闭防火墙,或者打开 15762 端口

systemctl stop firewall.service

# 开启端口
firewall-cmd --permanent --add-port=15762/tcp

(5)访问 IP:15762 【192.168.200.132:15762】

默认登录账号:guest 密码:guest

但是没有增加用户,是登录不了的。

1651057858069

(6)增加用户

# 账号 密码
rabbitmqctl add_user admin admin

(7)设置角色

rabbitmqctl set_user_tags admin administrator

(8)设置权限

# 表示 admin 用户拥有所有的权限
rabbitmqctl set_permissions -p "/" admin ".*" ".*" ".*"

(9)查看所有的用户

rabbitmqctl list_users

三、工作模式介绍

1、Hello World

简单模式:

流程:

​ P : 生产者发送消息,经过 RabbitMQ 送到消费者

​ C :消费者

1651216568660

1、创建一个 maven 工程

2、引入依赖

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

3、生产者代码

// 生产者
public class Producer {

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

    // 发消息
    public static void main(String[] args) throws IOException, TimeoutException {
        // 创建工厂
        ConnectionFactory factory = new ConnectionFactory();

        // 连接 IP 地址
        factory.setHost("192.168.200.132");

        // 用户名
        factory.setUsername("admin");

        // 密码
        factory.setPassword("admin");

        // 创建连接
        Connection connection = factory.newConnection();

        // 连接里保存了很多个信道 channel ,信道负责转发消息
        Channel channel = connection.createChannel();

        /*
            生成一个队列,参数说明:
                1、队列名称
                2、队列里面的消息是否持久化【是否同步到硬盘中】
                3、该队列是否只供一个消费者消费【是否共享数据】
                4、是否自动删除
                5、其他参数
         */
        channel.queueDeclare(QUEUE_NAME,false,false,false,null);

        // 发送的消息
        String message = "hello,world";

        /*
            发送消息
                1、发送到哪个交换机,由于本次并没有配置,设置为 "" ,会使用默认的交换机。千万不能写 null 
                2、routing key 
                3、其他参数信息
                4、发送的具体消息,byte类型
         */
        channel.basicPublish("",QUEUE_NAME,null,message.getBytes());
        System.out.println("发送消息完毕");
    }

}

1651062371108

在 web 管理界面里,队列里会有一个 名字为 hello 的数据,正在等待被发送。

4、消费者代码

// 消费者
public class Customer {

    // 队列与生产者保持一致
    public static final String QUEUE_NAME = "hello";

    // 声明消费者接受消息时的回调
   static DeliverCallback callback = (consumerTag,message) -> {
       // 接收到的消息是一个 byte 数组
        System.out.println("接收到的消息: " + new String(message.getBody()));
    };

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


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

        ConnectionFactory factory = new ConnectionFactory();
        factory.setHost("192.168.200.132");
        factory.setUsername("admin");
        factory.setPassword("admin");
        Connection connection = factory.newConnection();
        Channel channel = connection.createChannel();

        /*
            消费者消费消息
                1、消费哪个队列
                2、消费成功之后是否要自动应答 true 代表自动应答 ,false 则相反
                3、消费者未成功消费的回调  DeliverCallback 接口
                4、消费者取消回调。  CancelCallback 接口
         */
        channel.basicConsume(QUEUE_NAME,true,callback,cancelCallback);
    }

2、Work Queues

工作队列(又称任务队列)的主要思想是避免立即执行资源密集型任务,而不得不等待它完成。

比如说:在队列中有 1000条数据,而每个 线程/消费者 只能处理 5000 条数据,这就可以连接多个线程共同处理 队列中的数据。

1651066757334

一个数据只能有一个消费者 处理!并且满足你一条我一条

将 连接、创建信道 封装成一个工具类:

// RabbitMQ 工具类
public class RabbitUtil {

    // 连接工厂、创建信道
    public static Channel getChannel() throws IOException, TimeoutException {
        // 创建工厂
        ConnectionFactory factory = new ConnectionFactory();

        // 连接 IP 地址
        factory.setHost("192.168.200.132");

        // 用户名
        factory.setUsername("admin");

        // 密码
        factory.setPassword("admin");

        // 创建连接
        Connection connection = factory.newConnection();

        // 连接里保存了很多个信道 channel ,信道负责转发消息
        return connection.createChannel();
    }
}

消费者:

public class Work01 {

    public static final String QUEUE_NAME = "hello";

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

        // 接收成功的回调函数
        DeliverCallback deliverCallback = (consumerTag,message) -> {
            // 打印
            System.out.println(new String(message.getBody()));
        };

        // 接受失败回调函数
        CancelCallback callback = consumerTag -> {
            System.out.println(consumerTag + "接受数据失败回调的函数");
        };


        // 获取信道
        Channel channel = RabbitUtil.getChannel();

        // 接受消息
        System.out.println("C2 等待接受消息....");
        channel.basicConsume(QUEUE_NAME,deliverCallback,callback);
    }
}

在 IDEA 中启动多个 main 线程。

1651069101324

生产者:

// 生产者
public class Task01 {

    public static final String QUEUE_NAME = "hello";

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

        Channel channel = RabbitUtil.getChannel();

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

        // 接收控制台中的消息
        Scanner scanner = new Scanner(System.in);

        while(scanner.hasNext()){

            String message = scanner.next();

            // 发布消息
            /*
                1、使用哪个交换机: "" 表示使用默认交换机
                2、routing key 
                3、其他配置
                4、发布的消息
             */
            channel.basicPublish("",QUEUE_NAME,null,message.getBytes());
            System.out.println("发布消息成功!");
        }

    }
}

1651069205794

最后满足一个消息只能由一个线程处理!!

(1)、消息应答机制

消费者处理一个消息是需要时间的,假如消费者在接受消息的时候,突然宕机了,会出现什么情况呢 ?

消息会丢失!

RabbitMQ 针对这种情况提供了消息应答机制:

(1)自动应答

  • ​ RabbitMQ 只要将消息发送给消费者就被认为消息传递成功,就会删除队列中的数据。但是如果消费者只是接收到了消息,在处理过程中出现异常,那么信息就丢失 了,因此这个自动应答就很鸡肋!

(2)手动应答

  • RabbitMQ 将消息发送给消费者,并且当消费者处理完成之后,才会将队列中的数据删除。

5、自动应答:

//  第二个参数:是否开启自动应答。true :开启 false :关闭channel.basicConsume(QUEUE_NAME,true,callback,cancelCallback);

手动应答:

// 肯定确认。表示消费者已经处理完消息,RabbitMQ 可以将消息丢弃
// tag : 消息的标记,通过 message.getEnvelope().getDeliveryTag() 获取
// multiple : 是否批量应答
channel.basicAck(tag,multiple);
// 否定确认,不用处理消息了,直接丢弃
// requeue : 否定确认后,是否重新入队列。加入到队列的尾部
channel.basicNack(tag,multiple,requeue);
// 否定确认
channel.basicReject(tag,requeue);

/*
	basicReject(); 比 basicNack();少了一个 multiple 参数
*/

1651114438220

multiple参数的意思是: 是否批量应答队列中的消息。 true : 是 false :否,只应答当前的消息

(2)、消息重新入队requeue

如果消费者由于某些原因失去连接(其通道已关闭,连接已关闭或 TCP 连接丢失),导致消息

未发送 ACK 确认,RabbitMQ 将了解到消息未完全处理,并将对其重新排队。如果此时其他消费者

可以处理,它将很快将其重新分发给另一个消费者。这样,即使某个消费者偶尔死亡,也可以确

保不会丢失任何消息。

1651115676461

(3)、模拟手动消息应答

队列向俩个消费者发送消息,看是不是满足你一个我一个的原理,如果消费者1在处理的时候突然宕机,看看消费者 1 处理的消息是否重新入队分配给 消费者 2

生产者:

    public static final String QUEUE_NAME = "ack-queue";

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

         Channel channel = RabbitUtil.getChannel();


         // 创建队列
        // 队列名称、是否持久化、是否共享数据、是否自动删除、是否有其他参数
        channel.queueDeclare(QUEUE_NAME,true,false,false,null);
        // 开启发布确认
        channel.confirmSelect();

        // 从控制台中获取信息
        Scanner scanner = new Scanner(System.in);

        while (scanner.hasNext()){
            String message = scanner.next() ;
            // 使用默认交换机、其他参数、发布的消息
            // 要求发布的消息持久化 MessageProperties.PERSISTENT_TEXT_PLAIN
            channel.basicPublish("",QUEUE_NAME, MessageProperties.PERSISTENT_TEXT_PLAIN,message.getBytes());
            System.out.println("发布的消息: " + message);
        }
    }

消费者 1 :

    public static final String QUEUE_NAME = "ack-queue";

    public static void main(String[] args) throws IOException, TimeoutException {
        Channel channel = RabbitUtil.getChannel();
        // 不公平分发, 0: 公平分发
        // 0 后边的数字:预取值。指定分配的消息
        // channel.basicQos(1);
        channel.basicQos(1);

        // 声明消费者接受消息时的回调
         DeliverCallback callback = (consumerTag, message) -> {

             // 在接收消息的时候,睡眠
             try {
                 Thread.sleep(1000);
             } catch (InterruptedException e) {
                 e.printStackTrace();
             }

             // 接收到的消息是一个 byte 数组
            System.out.println("C1 接收到的消息: " + new String(message.getBody()));


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

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


        System.out.println("C1 等待时间较短");

        // false: 手动应答
        channel.basicConsume(QUEUE_NAME,false,callback,cancelCallback);
    }

消费者 2 :

    public static final String QUEUE_NAME = "ack-queue";

    public static void main(String[] args) throws IOException, TimeoutException {
        Channel channel = RabbitUtil.getChannel();
        // 不公平分发, 0: 公平分发
        // 0 后边的数字:预取值。指定分配的消息
        // channel.basicQos(1);
        channel.basicQos(5);

        // 声明消费者接受消息时的回调
        DeliverCallback callback = (consumerTag, message) -> {

            // 在接收消息的时候,睡眠
            try {
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            // 接收到的消息是一个 byte 数组
            System.out.println("C2 接收到的消息: " + new String(message.getBody()));


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

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


        System.out.println("C2 等待时间较长");

        // false: 手动应答
        channel.basicConsume(QUEUE_NAME,false,callback,cancelCallback);

    }
(4)、队列持久化

刚刚我们已经看到了如何处理任务不丢失的情况,但是如何保障当 RabbitMQ 服务停掉以后消

息生产者发送过来的消息不丢失。默认情况下 RabbitMQ 退出或由于某种原因崩溃时,它忽视队列

和消息,除非告知它不要这样做。确保消息不会丢失需要做两件事:我们需要将队列和消息都标

记为持久化

实现持久化: 在生产者创建 队列的时候声明是否持久化

// 创建队列
// 队列名称、是否持久化、是否共享数据、是否自动删除、是否有其他参数
channel.queueDeclare(QUEUE_NAME,true,false,false,null);

创建队列的时候,第二参数 改为 true

如果在不是持久化的队列上修改为持久化队列是会报错的:

1651128675731

解决方式:将之前的队列删掉,重新生成持久化队列,D 表示 Durable 持久化

1651128814799

(5)、消息持久化

尽管队列已经进行了持久化,但是如果消息不进行持久化的话,还是无法保证数据的完整性

消息持久化:在生产者发布消息时设置消息是否持久化

// 使用默认交换机、其他参数、发布的消息
// 要求发布的消息持久化 MessageProperties.PERSISTENT_TEXT_PLAIN
channel.basicPublish("",QUEUE_NAME, MessageProperties.PERSISTENT_TEXT_PLAIN,message.getBytes());

SpringBoot中的消息持久化:

MessageProperties 中的 DEFAULT_DELIVERY_MODE 属性为消息持久化,默认就是消息持久化

(6)、不公平分发

在开始讲的是 RabbitMQ 使用的 轮训/轮询 分发,但是有的 线程处理消息快,有的处理慢,如果还是按照 轮询/轮训 分发,是很影响效率的。

因此需要采用不公平分发【能者多劳】

设置不公平分发:在 消费者中设置

// 不公平分发
// 0 : 表示公平分发
// 0往后的 : 不公平分发,也叫预取值
// channel.basicQos(2);
channel.basicQos(5);

比如: basicQos(5) ; 在队列开始分发消息时,优先给当前消费者 5 条消息进行处理。这就叫预取值,提前指定 处理消息的数量 !!

(7)、发布确认

生产者将信道设置成 confirm 模式,一旦信道进入 confirm 模式,所有在该信道上面发布的

消息都将会被指派一个唯一的 ID(从 1 开始),一旦消息被投递到所有匹配的队列之后,broker

就会发送一个确认给生产者(包含消息的唯一 ID),这就使得生产者 磁盘之后发出,broker 回传

给生产者的确认消息中 delivery-tag 域包含了确认消息的序列号,此外 broker 也可以设置

basic.ack 的 multiple 域,表示到这个序列号之前的所有消息都已经得到了处理。

confirm 模式最大的好处在于他是异步的,一旦发布一条消息,生产者应用程序就可以在等信

道返回确认的同时继续发送下一条消息,当消息最终得到确认之后,生产者应用便可以通过回调

方法来处理该确认消息,如果 RabbitMQ 因为自身内部错误导致消息丢失,就会发送一条 nack 消

息,生产者应用程序同样可以在回调方法中处理该 nack 消息。

要想数据持久化磁盘上一共有三步:

1、队列持久化

2、消息持久化

3、发布确认

开启发布确认:在生产者中,创建信道之后

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

发布确认有三种策略:

1、单个确认发布

一种简单的发布确认的方式,它是一种同步确认发布方式,只有在发布一个消息被确认后,才能发布下一个消息。效率非常慢!!并且如果 没有确认,会阻塞后面的消息发布。

waitForConfirmsOrDie(long) 这个方法只有在消息被确认 的时候才返回,如果在指定时间范围内这个消息没有被确认那么它将抛出异常。

waitForConfirms 方法,如果确认发布成功,会返回 true 否则返回 false

    // 发布消息的数量
    public static final Integer MESSAGE_COUNT = 1000 ;

    public static void main(String[] args) throws IOException, InterruptedException, TimeoutException {
        // 单个确认发布
        single();
    }

    // 单个确认
    public static void single() throws IOException, TimeoutException, InterruptedException {

        String queueName = UUID.randomUUID().toString();
        Channel channel = RabbitUtil.getChannel();
        // 开启确认发布
        channel.confirmSelect();
        // 开启队列持久化
        channel.queueDeclare(queueName,true,false,false,null);

        // 开启时间
        long start = System.currentTimeMillis();
        // 批量发布消息
        for (Integer i = 0; i < MESSAGE_COUNT; i++) {
            String message = i + "";
            // 发布消息,并开启消息持久化
            channel.basicPublish("",queueName,MessageProperties.PERSISTENT_TEXT_PLAIN,message.getBytes());
            // 进行单个确认
            if (channel.waitForConfirms()) {
                System.out.println(message + "消息发送成功");
            }
        }
        // 结束时间
        long end = System.currentTimeMillis();
        System.out.println("发布 " + MESSAGE_COUNT + " 条消息,单个确认共耗时 : " + (end - start));
    }

2、批量确认发布

上面那种方式非常慢,与单个等待确认消息相比,先发布一批消息然后一起确认可以极大地

提高吞吐量,当然这种方式的缺点就是:当发生故障导致发布出现问题时,不知道是哪个消息出现

问题了,我们必须将整个批处理保存在内存中,以记录重要的信息而后重新发布消息。当然这种

方案仍然是同步的,也一样阻塞消息的发布

// 批量确认
    public static void batch() throws Exception {
        String queueName = UUID.randomUUID().toString();
        Channel channel = RabbitUtil.getChannel();
        // 开启确认发布
        channel.confirmSelect();
        // 开启队列持久化
        channel.queueDeclare(queueName, true, false, false, null);

        // 开启时间
        long start = System.currentTimeMillis();
        // 批量发布确认的条数,每隔 100 条确认一次,【可以自定义】
        int batchSize = 100;
        // 批量发布消息
        for (int i = 0; i < MESSAGE_COUNT; i++) {
            String message = i + "";
            // 发布消息,并开启消息持久化
            channel.basicPublish("", queueName, MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes());

            // 批量确认发布
            if ((i + 1) % batchSize == 0) {
                // 确认发布
                channel.waitForConfirms();
            }

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

3、异步批量确认发布

1651138980480

异步确认虽然编程逻辑比上两个要复杂,但是性价比最高,无论是可靠性还是效率都没得说,

他是利用回调函数来达到消息可靠性传递的,这个中间件也是通过函数回调来保证是否投递成功,

确认发布是由 broker 进行管理的,生产者 只管发布消息即可,由 broker 通知你 是否发布成功。

并且 这个 过程和 发布消息是异步的。

// 在发布消息前,增加一个监听消息确认的监听器
channel.addConfirmListener(ackCallback,nackCallback);

参数是俩个回调函数:

1651136080688

一个是 成功发布 的回调,一个是 发布失败的回调 !

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

最好的解决的解决方案就是把未确认的消息放到一个基于内存的能被发布线程访问的队列,

比如说在发布消息的时候用 ConcurrentSkipListMap 这个map 将 消息的序号和 消息进行关联。在 发布成功回调中 【ackCallBack】 删除已经确认的消息。剩下的就是未确认的消息。

    // 异步批量确认
    public static void asynchronousBatch() throws Exception {

        /**
         * 线程安全有序的一个哈希表,是用于高并发的情况下
         *  1、轻松地将序号和消息关联
         *  2、轻松地删除发布确认过的消息
         *  3、支持高并发
         */
        ConcurrentSkipListMap<Long,Object> skipListMap =
                new ConcurrentSkipListMap<>();
        /**
         * 确认发布 成功地回调函数
         *  1、消息的标记
         *  2、是否批量处理
         */
        ConfirmCallback ackCallback = (deliveryTag,multiple) -> {
            /*
                2、将确认的消息进行删除,剩下的就是 未成功发布的消息。
             */
                // 批量清除
                skipListMap.headMap(deliveryTag).clear();

            System.out.println("确认发布成功的消息:" + deliveryTag);
        };

        /**
         * 确认发布 失败的回调函数
         *  1、消息标记
         *  2、是否批量处理
         */
        ConfirmCallback nackCallback = (deliveryTag, multiple) -> {
            // 未成功发布的消息
            String message = (String)skipListMap.get(deliveryTag);
            System.out.println("确认发布失败的消息 : " + message);
        };

        String queueName = UUID.randomUUID().toString();
        Channel channel = RabbitUtil.getChannel();
        // 开启确认发布
        channel.confirmSelect();
        // 创建队列,开启队列持久化
        channel.queueDeclareNoWait(queueName, true, false, false, null);

        // 增加一个监听消息确认的监听器, 一定要在发布消息前进行设置
        channel.addConfirmListener(ackCallback,nackCallback);
        // 开启时间
        long start = System.currentTimeMillis();

        // 批量发布消息
        for (int i = 0; i < MESSAGE_COUNT; i++) {
            String message = i + "";
            // 发布消息,并开启消息持久化
            channel.basicPublish("", queueName, MessageProperties.PERSISTENT_TEXT_PLAIN, message.getBytes());

            /*
                记录发布的消息
                    1、key 是消息的序号
                    2、value 是消息
             */
            skipListMap.put(channel.getNextPublishSeqNo(),message);
        }

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

四、交换机

RabbitMQ 消息传递模型的核心思想是: 生产者生产的消息从不会直接发送到队列。在前面我们使用队列,并不是没有用到交换机,而是使用了一个默认交换机

生产者是不知道生产的消息在哪个队列中。生产者只能将消息发送到交换机(exchange),交换机工作的内容非常简单:

​ 一方面它接收来 自生产者的消息

​ 另一方面将它们推入队列。交换机必须确切知道如何处理收到的消息。是应该把这些消 息放到特定队列还是说把他们到许多队列中还是说应该丢弃它们。这就的由交换机的类型来决定。

交换机如何知道消息推送到哪个队列中呢?

这就需要 routingKey 了,可以理解为它是 queue 和 exchange 的桥梁,通过代码指定!

交换机的类型:

直接(direct), 主题(topic) ,标题(headers) , 扇出(fanout)

无名 exchange :

其实就是我们之前使用的 默认交换机,

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

临时队列:

// 自动获取临时队列,队列名是随机的。并且当消费者断开连接后,该队列被删除
channel.queueDeclare().getQueue();

绑定:

通过 routing key 将 交换机 和 队列进行绑定。生产者可以 通过 routing key 指定哪个队列 处理消息

1、fanout 类型交换机 — 发布/订阅模式

前面说过队列中的消息只能被消费者处理一次,如果一个消息被转发给多个消费者,类似于广播。这就是发布订阅模式

这个模式是怎么实现的呢? 是通过 fanout 交换机来实现的。

1651140858624

// 声明交换机
channel.exchangeDeclare(EXCHANGE_NAME, "fanout",true);
// 将队列和队列绑定
channel.queueBind(queueName, EXCHANGE_NAME, "");

实战:

使用 fanout 交换机演示 发布/订阅 模式,也就是一条消息 多个消费者接收

1、在生产者、消费者中声明交换机都可以,或者在 RabbitMQ 网页管理界面手动增加

2、生产者无需关注消息放在哪个队列,只需要在 消费者 中将交换机与队列绑定即可!!!

生产者:

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

    public static void main(String[] args) throws Exception {
        Channel channel = RabbitUtil.getChannel();
       /*
            声明一个交换机
                1、交换机名字
                2、交换机类型
                3、是否持久化
            也可以在 RabbitMQ 管理界面 创建交换机,就无需在这里声明了。
         */
        channel.exchangeDeclare(EXCHANGE_NAME, "fanout",true);

        Scanner scanner = new Scanner(System.in);

        System.out.println("正在发布消息.....");
        while (scanner.hasNext()) {
            String message = scanner.next();

            // 发布消息
            channel.basicPublish(EXCHANGE_NAME, "", null, message.getBytes(StandardCharsets.UTF_8));
            System.out.println("消息发布成功 : " + message);
        }
    }

消费者 1:

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

    public static void main(String[] args) throws Exception {

        // 成功回调
        DeliverCallback callback = (consumerTag, message) -> {
            System.out.println("接受到的消息: " + new String(message.getBody()));
        };

        // 失败回调
        CancelCallback cancelCallback = consumerTag -> {
            System.out.println("未接受到消息");
        };

        Channel channel = RabbitUtil.getChannel();

        // 获取一个临时队列
        String queueName = channel.queueDeclare().getQueue();
        /*
        将 交换机与队列 绑定
            1、队列名
            2、交换机明
            3、routing key
        */
        channel.queueBind(queueName, EXCHANGE_NAME, "");
        System.out.println("Work01等待接受消息....");

        // 队列名,是否自动确认,成功回调,失败回调
        channel.basicConsume(queueName, true, callback, cancelCallback);
    }

消费者 2 :

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

    public static void main(String[] args) throws Exception {

        // 成功回调
        DeliverCallback callback = (consumerTag,message) -> {
            System.out.println("接受到的消息: " + new String(message.getBody()));
        };

        // 失败回调
        CancelCallback cancelCallback = consumerTag -> {
            System.out.println("未接受到消息");
        };

        Channel channel = RabbitUtil.getChannel();
        // 获取一个临时队列
        String queueName = channel.queueDeclare().getQueue();
        /*
            将 交换机与队列 绑定
                1、队列名
                2、交换机明
                3、routing key
         */
        channel.queueBind(queueName,EXCHANGE_NAME,"");
        System.out.println("Work02等待接受消息....");

        // 队列名,是否自动确认,成功回调,失败回调
        channel.basicConsume(queueName,true,callback,cancelCallback);
    }

1651143986550

这就实现了 一条消息多个消费者接收!!!

2、direct exchange — routing 路由模式

直通交换机,又被叫做直连交换机,即 Direct Exchange ,生产者可以根据指定 routingKey 将消息存储到与之绑定的队列中

1651201170463

Direct 可以多重绑定:

生产者指定的 routingKey 绑定了多个队列,那么就将一条消息保存到多个队列中!

这种方式就类似于 fanout/ 发布订阅 模式了,和广播一样!

1651201392677

实战:

1、声明一个 交换机 direct_logs

2、消费者声明队列 dis、console

3、交换机 与 disk 绑定,routingKey 为 error

3、交换机与 console 实现多重绑定,routingKey 为 info、warning

1651202715318

消费者 1

   public static final String EXCHANGE_NAME = "direct_log";

    public static void main(String[] args) throws Exception{
        // 成功回调
        DeliverCallback callback = (consumerTag, message) -> {
            System.out.println("Consumer01 接受到的消息: " + new String(message.getBody()));
        };

        // 失败回调
        CancelCallback cancelCallback = consumerTag -> {
            System.out.println("未接受到消息");
        };
        Channel channel = RabbitUtil.getChannel();
        System.out.println("Consumer 正在等待接受消息.....");
        // 创建队列
        channel.queueDeclare("console",false,false,false,null);
        // 绑定
        channel.queueBind("console",EXCHANGE_NAME,"info");
        // 多重绑定
        channel.queueBind("console",EXCHANGE_NAME,"warning");

        channel.basicConsume("console",true,callback,cancelCallback);


    }

消费者2 :

  public static final String EXCHANGE_NAME = "direct_log";

    public static void main(String[] args) throws Exception{
        // 成功回调
        DeliverCallback callback = (consumerTag, message) -> {
            System.out.println("Consumer02 接受到的消息: " + new String(message.getBody()));
        };

        // 失败回调
        CancelCallback cancelCallback = consumerTag -> {
            System.out.println("未接受到消息");
        };
        Channel channel = RabbitUtil.getChannel();
        System.out.println("Consumer02 正在等待接受消息.....");


        // 创建队列
        channel.queueDeclare("disk",false,false,false,null);
        // 绑定
        channel.queueBind("disk",EXCHANGE_NAME,"error");


        channel.basicConsume("disk",true,callback,cancelCallback);


    }

生产者 :

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

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

        // 声明交换机,类型为 direct、持久化
        channel.exchangeDeclare(EXCHANGE_NAME,"direct",true) ;

        // 创建 Map 集合,保存多个 routingKey
        Map<String,String> maps = new HashMap<>();
        maps.put("info","普通信息");
        maps.put("warning","警告信息");
        maps.put("error","错误信息");

        // 遍历所有的key
        for (String key:maps.keySet()) {
            // 获取消息
            String message =  maps.get(key);
            // 指定 routingKey 发送到指定的队列
            channel.basicPublish(EXCHANGE_NAME,key,null,message.getBytes(StandardCharsets.UTF_8));
            System.out.println(message + "发送成功,指定 routingKey" + key);
        }

    }

之前我们模拟,都是在生产者里声明队列,使用 交换机后,生产者可通过 routingKey 来指定消息的存储队列。就无需直接关心队列 !如果想真正的解耦,最好的办法是在 网页管理界面中增加队列、交换机!

3、topic exchange — 主题模式

Topic 类型的 exchange 与 direct 相比,都是可以根据 RoutingKey 把消息路由到不同的队列.

只不过 Topic 类型 exchange 可以让队列在绑定 RoutingKey 的时候使用 通配符,这种模型的 RoutingKey 一般都是由 一个或多个 单词组成,多个单词之间以 ‘.’ 分割 。

1651214213822

* 表示匹配一个单词

# 表示匹配 0个或者多个单词

比如: 
 add.orange.head    ---- 增加到 Q1 队列中
  add.orange.rabbit    ---- 增加到 Q1和Q2 队列中
   lazy.rabbit.head    ---- 增加到 Q2 队列中

实战:

模拟上面图中的场景, 声明一个 类型为 topic 的交换机,消费者 与 队列绑定时使用通配符的方式绑定 。

1651221847710

生产者:

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

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

        // 声明交换机,类型为 direct、持久化
        channel.exchangeDeclare(EXCHANGE_NAME,"topic",true) ;

        // 创建 Map 集合,保存多个 routingKey
        Map<String,String> maps = new HashMap<>();
        maps.put("add.orange.head","add");
        maps.put("delete.orange.rabbit","delete");
        maps.put("lazy.rabbit.head","lazy");

        // 遍历所有的key
        for (String key:maps.keySet()) {
            // 获取消息
            String message =  maps.get(key);
            // 指定 routingKey 发送到指定的队列
            channel.basicPublish(EXCHANGE_NAME,key,null,message.getBytes(StandardCharsets.UTF_8));
            System.out.println(message + "发送成功,指定 routingKey : " + key);
        }
    }

消费者 1 :

    public static final String EXCHANGE_NAME = "topic_log";

    public static void main(String[] args) throws Exception{
        // 成功回调
        DeliverCallback callback = (consumerTag, message) -> {
            System.out.println("Consumer01 接受到的消息: " + new String(message.getBody()));
        };

        // 失败回调
        CancelCallback cancelCallback = consumerTag -> {
            System.out.println("未接受到消息");
        };

        Channel channel = RabbitUtil.getChannel();
        // 声明交换机,类型为 direct、持久化
        channel.exchangeDeclare(EXCHANGE_NAME,"topic",true) ;
        System.out.println("Consumer01 正在等待接受消息.....");
        // 创建队列
        channel.queueDeclare("Q1",false,false,false,null);
        // 使用通配符的方式进行绑定,可以接收 routingKey是 orange 开头,并且前面后面只有一个字母的 队列
        channel.queueBind("Q1",EXCHANGE_NAME,"*.orange.*");
        // 多重绑定

        channel.basicConsume("Q1",true,callback,cancelCallback);
    }

消费者 2 :

    public static final String EXCHANGE_NAME = "topic_log";

    public static void main(String[] args) throws Exception{
        // 成功回调
        DeliverCallback callback = (consumerTag, message) -> {
            System.out.println("Consumer01 接受到的消息: " + new String(message.getBody()));
        };

        // 失败回调
        CancelCallback cancelCallback = consumerTag -> {
            System.out.println("未接受到消息");
        };
        Channel channel = RabbitUtil.getChannel();
        // 声明交换机,类型为 direct、持久化
        channel.exchangeDeclare(EXCHANGE_NAME,"topic",true) ;

        System.out.println("Consumer02 正在等待接受消息.....");
        // 创建队列
        channel.queueDeclare("Q2",false,false,false,null);
        // 绑定,使用通配符
        channel.queueBind("Q2",EXCHANGE_NAME,"*.*.rabbit");
        // 多重绑定,使用通配符
        channel.queueBind("Q2",EXCHANGE_NAME,"lazy.#");

        channel.basicConsume("Q2",true,callback,cancelCallback);
    }

1651216276457

五、SpringBoot 整合 RabbitMQ

5.0 搭建初始环境

1、增加依赖

        <!--增加 RabbitMQ依赖-->
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-amqp</artifactId>
        </dependency>

2、相关配置

spring:
  rabbitmq:
    # RabbitMQ 连接 IP 地址
    addresses: 192.168.200.130
      # 用户名
    username: admin
      # 密码
    password: admin
    # 端口号
    port: 5672
    # 虚拟主机地址
    virtual-host: /

SpringBoot中提供了一个 操作 RabbitMQ 的模板对象: RabbitTemplate 就和 Redis 的 RedisTemplate一样

5.1 SpringBoot 编写 简单模式

@RabbitListener 用于监听消费者

配置类:

/**
 * RabbitMQ 工作模式---简单模式
 *  消费者--队列--消费者
 */
@Component
public class SimpleConfig {
    public static final String QUEUE_NAME = "hello" ;

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

生产者 :

    @Autowired
    private RabbitTemplate rabbitTemplate ;

    @GetMapping("/simple/{msg}")
    public String simple(@PathVariable String msg){

        /*
            不使用指定交换机时,队列就是routingKey
         */
        rabbitTemplate.convertAndSend("hello",msg);
        log.info("发送的消息:{}",msg);

        return  "发送成功";
    }

消费者 :

@Component
@Slf4j
public class Consumers {

    /**
     * 简单模式消费这
     */
    @RabbitListener(queues = SimpleConfig.QUEUE_NAME)
    public void simpleConsumer(Message message){
      log.info("消费者" + message.getMessageProperties().getMessageId() +
              "接收到消息: " + new String(message.getBody()));
    }
}

5.2 SpringBoot 编写 工作模式

工作模式默认采用 轮询/轮训 的一种工作方式,公平分配 !

**原则 :**你一条我一条,无论执行的多慢,也遵循这个原则。

配置类:

/**
 * RabbitMQ 工作模式---工作模式
 *  消费者--队列--消费者1--消费者2
 */
@Component
public class WorkConfig {
    public static final String QUEUE_NAME = "work" ;

    /**
     * 声明队列
     * @return
     */
    @Bean
    public Queue queue01(){
        return new Queue(QUEUE_NAME);
    }
}

生产者:

    /**
     * 工作模式--生产者
     */
    @GetMapping("/work/{msg}")
    public String work(@PathVariable String msg){

        /*
            不使用指定交换机时,队列就是routingKey
         */
        for (int i = 0; i < 10; i++) {
            String message = msg + i ;
            rabbitTemplate.convertAndSend(WorkConfig.QUEUE_NAME,message);
            log.info("发送的消息:{}",msg);
        }
        return  "发送成功";
    }

消费者:

@Component
@Slf4j
public class WorkConsumers {

    /**
     * 工作模式----消费者
     */
    @RabbitListener(queues = WorkConfig.QUEUE_NAME)
    public void simpleConsumer1(Message message){
        log.info("消费者 1 接受到消息:" + new String(message.getBody()));

    }

    @RabbitListener(queues = WorkConfig.QUEUE_NAME)
    public void simpleConsumer2(Message message){
        log.info("消费者 2 接受到消息:" + new String(message.getBody()));
    }
}

5.3 SpringBoot 编写 发布/订阅 模式

生产者:

    /**
     * 发布订阅模式--fanout
     */
    @Test
    void fanout() {
        // 交换机名、routingKey、消息
        rabbitTemplate.convertAndSend("logs","","hello,fanout");
    }

消费者:

/**
 * fanout-- 发布/订阅模式
 */
@Component
public class Consumers {

    // 消费者 1
    @RabbitListener(bindings = {
            @QueueBinding(
                    value = @Queue, // 不指定队列名,随机生成队列
                    exchange = @Exchange(value = "logs",type = "fanout")
            )
    })
    public void consumer01(String message){
        System.out.println("consumer01接收到的消息 : " + message);
    }

    // 消费者 2
    @RabbitListener(bindings = {
            @QueueBinding(
                    value = @Queue, // 不指定队列名,随机生成队列
                    exchange = @Exchange(value = "logs",type = "fanout")
            )
    })
    public void consumer02(String message){
        System.out.println("consumer02接收到的消息 : " + message);
    }

**bindings属性 :**队列绑定数组提供侦听器的队列名称,以及交换和可选的绑定信息。与 queues() 和 queuesToDeclare() 互斥。是一个 @QueueBinding 数组

1651224761371

5.4 SpringBoot 编写 路由模式

生产者:

    /**
     * 发布订阅模式--direct
     */
    @Test
    void direct() {
        Map<String,String> map = new HashMap<>();
        map.put("info","info信息");
        map.put("error","error信息");
        map.put("warning","warning信息");

        for (String key:map.keySet()) {
            String message = map.get(key);
            // 交换机名、routingKey、消息
            rabbitTemplate.convertAndSend("direct_logs",key,message);
        }
    }

消费者:

@Component
public class Consumers03 {

    @RabbitListener(bindings = {
            @QueueBinding(
                    value = @Queue, // 随机生成队列
                    exchange = @Exchange(value = "direct_logs",type = "direct"), // 默认就是direct类型的交换机
                    // 队列绑定 routingKey
                    key = {"info","warning"}
            )
    })
    public void consumer01(String  message){
        System.out.println("consumer01 接收到的消息 : " + message);
    }

    @RabbitListener(bindings = {
            @QueueBinding(
                    value = @Queue, // 随机生成队列
                    exchange = @Exchange(value = "direct_logs",type = "direct"), // 默认就是direct类型的交换机
                    // 队列绑定 routingKey
                    key = {"error"}
            )
    })
    public void consumer02(String  message){
        System.out.println("consumer02 接收到的消息 : " + message);
    }
}

5.5 SpringBoot 编写 主题模式

生产者:

    /**
     * 发布订阅模式--topic
     */
    @Test
    void topic() {
        // 交换机名、routingKey、消息
        rabbitTemplate.convertAndSend("topic_logs","add.user.age","hello,topic");
    }

消费者:

/**
 * 主题模式--topic
 */
@Component
public class Consumers04 {

    @RabbitListener(bindings = {
            @QueueBinding(
                    value = @Queue, // 随机生成队列
                    exchange = @Exchange(value = "topic_logs",type = "topic"), // 默认就是direct类型的交换机
                    // 队列绑定 routingKey
                    key = {"*.user.*"}
            )
    })
    public void consumer01(String  message){
        System.out.println("consumer01 接收到的消息 : " + message);
    }

    @RabbitListener(bindings = {
            @QueueBinding(
                    value = @Queue, // 随机生成队列
                    exchange = @Exchange(value = "topic_logs",type = "topic"), // 默认就是direct类型的交换机
                    // 队列绑定 routingKey
                    key = {"*.user.#"}
            )
    })
    public void consumer02(String  message){
        System.out.println("consumer02 接收到的消息 : " + message);
    }
}

七、死信队列

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

死信的来源:

1、TTL 过期、消息已经过期了

2、队列达到最大长度 (队列满了,无法再添加数据到 队列 中 )

3、消息被拒绝(basic.reject 或 basic.nack)并且 不允许重新放到队列中【requeue=false】

实战:

1、正常情况下,Producer 生产消息,C1 消费

2、如果 C1 出现了 死信,将消息通过 死信交换机 放到 死信的队列中,由 C2消费

1651225922308

1、设置 TTL 形成死信

通过一个 Map 集合声明死信消息

生产者:

public class Producer {
    // 正常交换机
    public static final String NORMAL_EXCHANGE = "normal_exchange" ;

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

        // 声明交换机
        channel.exchangeDeclare(NORMAL_EXCHANGE,"direct");

        // 设置发送消息的过期时间【TTL】
        AMQP.BasicProperties properties = new AMQP.BasicProperties().builder().expiration("10000").build();


        for (int i = 1; i < 11; i++) {
            String message = "info" + i ;
            // 发送消息
            channel.basicPublish(NORMAL_EXCHANGE,"zhangsan",properties,message.getBytes(StandardCharsets.UTF_8));
            System.out.println(message + " 发送成功");
        }
    }
}

消费者 1 :


    // 正常交换机
    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 Exception {
        DeliverCallback callback = (consumerTag, message) -> {
            System.out.println("C1 接收到消息 : " + new String(message.getBody()));
        };
        Channel channel = RabbitUtil.getChannel();

        // 声明正常交换机
        channel.exchangeDeclare(NORMAL_EXCHANGE,"direct");
        // 声明死信交换机
        channel.exchangeDeclare(DEAD_EXCHANGE,"direct");

        // map声明了死信队列的信息
        Map<String, Object> map = new HashMap<>();
        // 正常队列设置死信交换机 参数 key 是固定值
        map.put("x-dead-letter-exchange", DEAD_EXCHANGE);
        // 正常队列设置死信 routing-key 参数 key 是固定值
        map.put("x-dead-letter-routing-key", "lisi");

        // 声明正常队列
        channel.queueDeclare(NORMAL_QUEUE,false,false,false,map);
        // 绑定队列-交换机
        channel.queueBind(NORMAL_QUEUE,NORMAL_EXCHANGE,"zhangsan");

        // 声明死信队列
        channel.queueDeclare(DEAD_QUEUE,false,false,false,null);
        // 绑定死信队列
        channel.queueBind(DEAD_QUEUE,DEAD_EXCHANGE,"lisi");

        System.out.println("C1 正在等待接受消息....");
        // 接收消息
        channel.basicConsume(NORMAL_QUEUE,true, callback, consumerTag -> {});

    }

消费者2 :

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

    public static void main(String[] args) throws Exception{
        DeliverCallback callback = (consumerTag, message) -> {
            System.out.println("C1 接收到消息 : " + new String(message.getBody()));
        };
        Channel channel = RabbitUtil.getChannel();
        System.out.println("C2 正在等待接受消息.....");

        channel.basicConsume(DEAD_QUEUE,true,callback,consumerTag -> {});
    }

1651231874665

当 正常队列中的消息,超过设置的 TTL 时,就会把消息转到 死信队列中。而当与死信队列绑定的消费者启动之后,死信队列中的数据又会被 该消费者消费!

1651231982491

2、设置 队列 长度

在 生产者 中取消设置 TTL ,在 C1 中的 map 集合中增加一个配置:

        // 设置正常队列的最大长度为6
        map.put("x-max-length",6);

这样就设置了 正常队列的长度为 6 ,发送 10 条数据,会将多余的四条发送到 死信队列!!

测试前一定要将之前声明的 normal_queue,dead_queue 删除掉,重新生成,不然会报错!!

最终结果:

1651232867126

3、消息被拒

1、在消费者 1 中开启手动应答。消费者 2 和 生产者不变

2、在接受消息的时候拒绝某条消息,其余消息确认

3、测试之前先将之前的队列删除掉

消费者1 :


    // 正常交换机
    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 Exception {
        Channel channel = RabbitUtil.getChannel();
        DeliverCallback callback = (consumerTag, message) -> {
            String msg = new String(message.getBody()) ;


            if (msg.equals("info5")) {
                System.out.println(message + "被拒绝了");
                // 拒绝消息的标记、是否批量应答、是否重新加入队列【一定要设置成false,不然被拒绝的消息不会放到 死信队列中】
                channel.basicNack(message.getEnvelope().getDeliveryTag(),false,false);
            }else{
                System.out.println("C1 接收到消息 : " + msg);
                // 确认应答
                channel.basicAck(message.getEnvelope().getDeliveryTag(),false);
            }


        };


        // 声明正常交换机
        channel.exchangeDeclare(NORMAL_EXCHANGE,"direct");
        // 声明死信交换机
        channel.exchangeDeclare(DEAD_EXCHANGE,"direct");

        // map声明了死信队列的信息
        Map<String, Object> map = new HashMap<>();
        // 正常队列设置死信交换机 参数 key 是固定值
        map.put("x-dead-letter-exchange", DEAD_EXCHANGE);
        // 正常队列设置死信 routing-key 参数 key 是固定值
        map.put("x-dead-letter-routing-key", "lisi");
        // 设置正常队列的最大长度
        // map.put("x-max-length",6);

        // 声明正常队列
        channel.queueDeclare(NORMAL_QUEUE,false,false,false,map);
        // 绑定队列-交换机
        channel.queueBind(NORMAL_QUEUE,NORMAL_EXCHANGE,"zhangsan");

        // 声明死信队列
        channel.queueDeclare(DEAD_QUEUE,false,false,false,null);
        // 绑定死信队列
        channel.queueBind(DEAD_QUEUE,DEAD_EXCHANGE,"lisi");

        System.out.println("C1 正在等待接受消息....");
        // 接收消息----开启手动应答
        channel.basicConsume(NORMAL_QUEUE,false, callback, consumerTag -> {});

    }

此时死信队列中就有了一个 被拒绝的消息 !!!

1651233618664

八、延迟队列

延时队列,队列内部是有序的,最重要的特性就体现在它的延时属性上,延时队列中的元素是希望

在指定时间到了以后或之前取出和处理,简单来说,延时队列就是用来存放需要在指定时间被处理的

元素的队列。 其实也可以将延迟队列理解为死信队列

延迟队列使用场景:

1.订单在十分钟之内未支付则自动取消

2.新创建的店铺,如果在十天内都没有上传过商品,则自动发送消息提醒。

3.用户注册成功后,如果三天内没有登陆则进行短信提醒。

4.用户发起退款,如果三天内没有得到处理则通知相关运营人员。

5.预定会议后,需要在预定的时间点前十分钟通知各个与会人员参加会议

以上这些场景都有个特点,都是在某个特定的时间执行特定的任务,虽然说定时任务也能完成,但是如果数据量庞大,比如双十一的淘宝订单,设置定时任务效率就会及其低下,使用 RabbitMQ 的延迟队列是一种很好的选择。

1、SpringBoot 模拟 延迟队列

俩个direct类型交换机:

​ X : 正常交换机

​ Y : 延迟交换机【死信交换机】

三个队列 :

​ QA : 延迟 10 s

​ QB : 延迟 40 s

​ QD : 死信队列

三个 routingKey

​ XA、XB、YD

生产者、消费者

1651235626415

SpringBoot 中 提供了俩种构建工具:

​ QueueBuilder :快速构建队列,将一些构建队列的参数都封装成了方法

​ BindingBuilder :快速构建绑定关系。

注意:

​ 被 @Bean 标注的方法中的形参是根据参数类型自动注入的,如果有多个类型就需要使用 @Qualifier 指定 ID

RabbitMQ 配置类:

/**
 * 模拟延迟队列
 */
@Configuration
public class RabbitConfig {
    // X 交换机
    public static final String X_EXCHANGE = "X" ;
    // Y 交换机 -- 死信交换机
    public static final String Y_EXCHANGE = "Y" ;
    // QA 队列 -- 10s
    public static final String QUEUE_A = "QA" ;
    // QB 队列 -- 40s
    public static final String QUEUE_B = "QB" ;
    // QD 队列 -- 延迟队列
    public static final String QUEUE_D = "QD" ;

    // 注册X交换机
    @Bean
    public DirectExchange xExchange(){
        return  new DirectExchange(X_EXCHANGE);
    }

    // 注册Y交换机
    @Bean
    public DirectExchange yExchange(){
        return  new DirectExchange(Y_EXCHANGE);
    }

    // 注册QA队列
    @Bean
    public Queue queueA(){
        /*
            构建队列的配置都封装成了方法
         */
        return QueueBuilder
                // 持久化QA队列
                .durable(QUEUE_A)
                // 设置QA过期时间
                .ttl(10000)
                // 设置死信交换机
                .deadLetterExchange(Y_EXCHANGE)
                // 设置死信 RoutingKey
                .deadLetterRoutingKey("YD")
                .build();
    }

    // 注册QB队列
    @Bean
    public Queue queueB(){
        /*
            构建队列的配置都封装成了方法
         */
        return QueueBuilder
                // 持久化QA队列
                .durable(QUEUE_B)
                // 设置QA过期时间
                .ttl(40000)
                // 设置死信交换机
                .deadLetterExchange(Y_EXCHANGE)
                // 设置死信 RoutingKey
                .deadLetterRoutingKey("YD")
                .build();
    }

    // 声明 QD
    @Bean
    public Queue queueD(){
        return new Queue(QUEUE_D);
    }

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

    // 绑定 QB队列-交换机X
    @Bean
    public Binding queueBBindingX(@Qualifier("queueB")Queue queueB,
                                  @Qualifier("xExchange")DirectExchange xExchange){
        return  BindingBuilder
                // 绑定队列 QA
                .bind(queueB)
                // 绑定交换机 X
                .to(xExchange)
                // 绑定 routingKey
                .with("XB");
    }

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

生产者:

利用 Controller 生产消息

/**
 * 生产者--发送消息
 */
@Log4j2
@RestController
public class SendMsgController {

    @Autowired
    private RabbitTemplate rabbitTemplate ;

    @GetMapping("/ttl/{msg}")
    public String sendMsg(@PathVariable String msg){
        // 发送到QA队列
        rabbitTemplate.convertAndSend("X","XA",msg);
        // 发送到 QB 队列
        rabbitTemplate.convertAndSend("X","XB",msg);

        log.info("时间是:{},给俩个队列发送的消息:{}",new Date(),msg);

        return  "success";
    }
}

消费者:

/**
 * 监听器,监听消费者
 */
@Component
@Log4j2
public class ConsumersList {

    /*
        接受 QD 队列的消息
     */
    @RabbitListener(queuesToDeclare = @Queue("QD"))
    public void consumer01(String message){
        log.info("时间:{},接受到的消息为: {}",new Date(),message);
    }
}

2、优化延迟队列

前面创建了俩个队列,一个延迟 10s 一个延迟 40s ,如果在增加一个需求 要求延迟一个小时,就还需要增加一个延迟队列,在增加一个需求还需要增加队列…

因此可以在创建一个普通队列,没有延迟,在生产者发消息时指定延迟时间…

1651298355227

配置类:

增加 QC 队列,进行绑定

    // 声明 QC
    @Bean
    public Queue queueC(){
        return QueueBuilder
                .durable(QUEUE_C)
                .deadLetterExchange(Y_EXCHANGE)
                .deadLetterRoutingKey("YD")
                .build();
    }

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

生产者:

通过路径变量的方式获取 ttl 和 msg 自定义延迟时间

MessagePostProcessor接口 消息后处理器: 用于对消息的一些设置

MessageProperties 类:保存了所有对消息的配置


    /**
     * 自定义ttl
     * @param ttl 延迟时间
     * @param msg 发送的消息
     * @return
     */
    @GetMapping("/sendMsg/{ttl}/{msg}")
    public String sendMsgByTTL(@PathVariable String ttl,@PathVariable String msg){

        rabbitTemplate.convertAndSend("X", "XC", msg, message -> {
            // 设置过期时间
            message.getMessageProperties().setExpiration(ttl);
            return message;
        });

        // 打印日志
        log.info("当前时间:{},延迟时间:{},发送的消息:{}",new Date(),ttl,msg);

        return "发送成功";
    }

3、基于死信延迟队列的问题

在上面优化了 ttl ,设置成自定义的形式,但是还有一个问题,当发过超过俩条的消息就会形成堵塞!

1651301835323

比如:第一次发消息延迟 20 s ,第二次消息 延迟 2s ,但是第二次却是等待第一次发送完,才会发送第二次、

解决方法:安装插件

(1)将此插件放到 rabbit 的 plugins 插件目录下

1651302480698

# 切换到rabbit 的 plugins 插件目录下
cd rabbitmq/lib/rabbitmq_server-3.8.8/plugins/

(2)启动插件

rabbitmq-plugins enable rabbitmq_delayed_message_exchange

(3)重启rabbitMQ服务

systemctl restart rabbit-server

1651302881676

如果交换机类型中多了 上图 的 类型,说明安装插件成功。

4、基于插件的延迟队列实例

1651303781332

配置类:

/**
 * 基于插件的延迟队列
 */
@Configuration
public class DelayedExchangeConfig {

    // 延迟交换机
    public static final String DELAYED_EXCHANGE_NAME = "delayed_exchange";
    // 延迟队列
    public static final String DELAYED_QUEUE_NAME = "delayed_queue";
    // routingKey
    public static final String ROUTING_KEY = "delayed_routingKey";

    // 自定义交换机
    @Bean
    public CustomExchange delayedExchange(){
        Map<String,Object> map = new HashMap<>();
        // 自定义交换机类型。
        map.put("x-delayed-type","direct");

        return new CustomExchange(DELAYED_EXCHANGE_NAME,"x-delayed-message",false,false,map);
    }

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

    // 绑定 队列
    @Bean
    public Binding  delayedQueueBindingDelayedExchange(Queue delayedQueue, CustomExchange delayedExchange){
        return BindingBuilder.bind(delayedQueue).to(delayedExchange).with(ROUTING_KEY).noargs();
    }
}

自定义交换机 名字、类型、是否持久化、是否自动删除、其他参数【指定交换机类型】

1651303979006

生产者:


    /**
     * 基于插件的延迟队列
     */

    @GetMapping("/sendMsgByDelayed/{ttl}/{msg}")
    public String delayedExchange(@PathVariable String ttl,@PathVariable String msg){
        // 发送消息
        rabbitTemplate.convertAndSend("delayed_exchange",
                "delayed_routingKey", msg, message -> {
            // 设置过期时间
            message.getMessageProperties().setExpiration(ttl);
            return message;
        });

        // 打印日志
        log.info("当前时间:{},延迟时间:{},发送的消息:{}",new Date(),ttl,msg);
        return "发送成功";

    }

消费者:

/**
 * 监听器,监听消费者
 */
@Component
@Log4j2
public class DelayedConsumerList {

    /*
        接受 QD 队列的消息
     */
    @RabbitListener(queuesToDeclare = @Queue(DelayedExchangeConfig.DELAYED_QUEUE_NAME))
    public void consumer01(String message){
        log.info("当前时间:{},接受到的消息为: {}",new Date().toString(),message);
    }

}

九、发布确认高级

在生产环境中由于一些不明原因,导致 rabbitmq 重启,在 RabbitMQ 重启期间生产者消息投递失败,

导致消息丢失,需要手动处理和恢复。于是,我们开始思考,如何才能进行 RabbitMQ 的消息可靠投递呢? 特别是在这样比较极端的情况,RabbitMQ 集群不可用的时候,无法投递的消息该如何处理呢:

搭建好以下队列环境-…模拟队列接受不到消息的情况,也就是 routingKey 错误的情况

1651307279870

1、回调接口

RabbitTemplate 内部有一个 ConfirmCallback 接口【回调接口 】

接口中的 confirm 方法作为交换机的 确认回调方法,就是不管交换机接收没接收到消息,都会执行 该方法。

1651308554108

(1)配置文件中配置:

# 发布确认之后执行回调方法y
spring:
  rabbitmq:
    publisher-confirm-type: correlated

(2)编写回调方法

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

    @Autowired
    RabbitTemplate rabbitTemplate ;

    @PostConstruct  // 注解的作用,将以上组件都注入完毕之后,才会执行该方法
    public void init(){
        // 将回调方法注入给 ConfirmCallback 接口
        rabbitTemplate.setConfirmCallback(this);
    }


    /*
        交换机确认回调方法
            一、交换机接收到消息
                1、correlationData 中保存消息的id以及相关信息
                2、ack=true
                3、cause=null
            二、交换机没有接收到消息
                1、correlationData 中保存消息的id以及相关信息
                2、ack=false
                3、cause=失败的信息
     */
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        if (ack) {
            // 接收成功
            log.info( correlationData.getId() + "交换机接收到了消息");
        }else{
            // 接收失败
            log.info( correlationData.getId() + "交换机没有接收到了消息,失败原因:{}",cause);
        }
    }
}

(3)生产者

    /**
     * 发布确认--高级
     * @return
     */
    @GetMapping("/confirm/{msg}")
    public String confirm(@PathVariable String msg){
        // 手动传入一个 id
        CorrelationData correlationData = new CorrelationData("1");
        rabbitTemplate.convertAndSend("confirm_exchange","key1",msg,correlationData);

        // 发送错误的 routingKey
        CorrelationData newCorrelationData = new CorrelationData("2");
        rabbitTemplate.convertAndSend("confirm_exchange","key2",msg,newCorrelationData);

        // 打印日志
        log.info("发送的消息:{}",msg);
        return "发送成功";
    }

(4)消费者

@Component
@Log4j2
public class ConfirmConsumersList {

    /*
        接受 QD 队列的消息
     */
    @RabbitListener(queuesToDeclare = @Queue("confirm_queue"))
    public void consumer01(String message){
        log.info("消费者接受到的消息为: {}",message);
    }
}

(5)配置类

@Configuration
public class ConfirmConfig {
    // 交换机
    public static final String EXCHANGE_NAME = "confirm_exchange";
    // 队列
    public static final String QUEUE_NAME = "confirm_queue";

    public static final String ROUTING_KEY = "key1";

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

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

    // 绑定 队列
    @Bean
    public Binding delayedQueueBindingDelayedExchange(Queue confirmQueue, DirectExchange confirmExchange){
        return BindingBuilder.bind(confirmQueue).to(confirmExchange).with(ROUTING_KEY) ;
    }

}

2、回退接口

以上实例出现的问题:

发送俩次消息,其中第二次的消息 的 routingKey 是错误的,也就是说找不到对应的 队列,消费者只接受了一次消息,并没有接受到第二次发送的消息,并且生产者不知道消息丢失了,因此该消息就会莫名其妙的没了。所以需要设置回退消息。

1651310634929

(1)配置文件中配置

 # 回退消息
spring:
  rabbitmq:
    publisher-returns: true

(2)实现回退接口,重写 returnedMessage 方法

@Slf4j
@Component
public class MyConfirmCallback implements RabbitTemplate.ConfirmCallback,RabbitTemplate.ReturnsCallback{

    @Autowired
    RabbitTemplate rabbitTemplate ;

    @PostConstruct  // 注解的作用,将以上组件都注入完毕之后,才会执行该方法
    public void init(){
        // 将回调方法注入给 ConfirmCallback 接口
        rabbitTemplate.setConfirmCallback(this);
        // 注入 回退消息方法
        rabbitTemplate.setReturnsCallback(this);
    }


    /*
        交换机确认回调方法
            一、交换机接收到消息
                1、correlationData 中保存消息的id以及相关信息。需要生产者传送消息
                2、ack=true
                3、cause=null
            二、交换机没有接收到消息
                1、correlationData 中保存消息的id以及相关信息
                2、ack=false
                3、cause=失败的信息
     */
    @Override
    public void confirm(CorrelationData correlationData, boolean ack, String cause) {
        // 防止报空指针异常
        String id = cause == null ? correlationData.getId():"";
        if (ack) {
            // 接收成功
            log.info( id + "交换机接收到了消息");
        }else{
            // 接收失败
            log.info( id+ "交换机没有接收到了消息,失败原因:{}",cause);
        }
    }


    /**
     *  回退消息
     * @param returned
     */
    @Override
    public void returnedMessage(ReturnedMessage returned) {
        log.info("被回退的消息:{},交换机:{},routingKey:{},被回退的原因:{}",
                returned.getMessage().getBody(),
                returned.getExchange(),
                returned.getRoutingKey(),
                returned.getReplyText());
    }
}

1651311625239

此时消息就已经回退了。

十、优先级队列

通过设置消息的优先级 对 优先级高的消息优先处理

1、RabbitMQ 管理页面设置

1651314319951

2、SpringBoot 中设置优先级队列

配置类:

在声明队列的时候设置最大优先级

/**
 * 优先级队列
 */
@Configuration
public class PriorityConfig {
    // 交换机
    public static final String EXCHANGE_NAME = "priority_exchange";
    // 队列
    public static final String QUEUE_NAME = "priority_queue";

    public static final String ROUTING_KEY = "priority";

    // 声明交换机
    @Bean
    public DirectExchange priorityExchange(){
        return  new DirectExchange(EXCHANGE_NAME);
    }

    // 声明队列
    @Bean
    public Queue priorityQueue(){
        return QueueBuilder.nonDurable(QUEUE_NAME)
                // 设置队列最大优先级 10
                .maxPriority(10).build();
    }

    // 绑定 队列
    @Bean
    public Binding delayedQueueBindingDelayedExchange(Queue priorityQueue, DirectExchange priorityExchange){
        return BindingBuilder.bind(priorityQueue).to(priorityExchange).with(ROUTING_KEY) ;
    }

}

生产者:

    /**
     * 优先队列
     */
    @GetMapping("/priority")
    public String priority(){
        // 发送 5次
        for (int i = 0; i < 10; i++) {
            if (i==5){
                // 只设置一个优先级 i= 5
                rabbitTemplate.convertAndSend(PriorityConfig.EXCHANGE_NAME,PriorityConfig.ROUTING_KEY,i, message1 -> {
                    // 设置优先级
                    message1.getMessageProperties().setPriority(5);
                    return message1;
                });
            }else{
                // 其他的正常发
                rabbitTemplate.convertAndSend(PriorityConfig.EXCHANGE_NAME,PriorityConfig.ROUTING_KEY,i);
            }
            log.info("发送的消息:{}",i);

        }
        return  "发送成功";
    }

消费者:

@Component
@Log4j2
public class PriorityConsumersList {


    /*
        接受队列的消息
     */
    @RabbitListener(queues = PriorityConfig.QUEUE_NAME)
    public void consumer01(Message message,String msg) {

        log.info("consumer01接受到的消息为: {},优先级:{}", msg, message.getMessageProperties().getPriority());
    }
}

优先级的范围是:0~255 ,一般是 0~10 ,设置越大,越浪费内存。

设置的值越大优先级越高。

注意:

要让队列实现优先级需要做的事情有如下事情:队列需要设置为优先级队列,消息需要设置消息的优先

级,消费者需要等待消息已经发送到队列中才去消费因为,这样才有机会对消息进行排序

十一、RabbitMQ 集群

1、搭建集群

1651371650717

(1)启动三台集群,搭建集群,node1 为主节点

(2)修改三台机器的 hostname 为:node1 node2 node3

​ 修改完重启才能生效 !

vim /etc/hostname

(3)配置各个主机的 hosts 文件,让每个主机都能识别对方

vim /etc/hosts

192.168.200.132 node1
192.168.200.133 node2
192.168.200.134 node3

(4)确保各个节点的 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

(5)三台机器启动 RabbitMQ 服务,顺便启动 Erlang虚拟机和应用服务

rabbitmq-server -detached

(6)将 node2 加到集群中

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

(7)将node3加到集群中

rabbitmqctl stop_app
rabbitmqctl reset
rabbitmqctl join_cluster rabbit@node2
rabbitmqctl start_app

(8)查询集群状态

rabbitmqctl cluster_status

(9)增加用户

# 设置密码
rabbitmqctl add_user admin 123
# 设置角色
rabbitmqctl set_user_tags admin administrator
# 设置权限
rabbitmqctl set_permissions -p "/" admin ".*" ".*" ".*"

解除集群节点:

(1)【解除哪个就在哪个机器上执行】

rabbitmqctl stop_app
rabbitmqctl reset
rabbitmqctl start_app
rabbitmqctl cluster_status

(2)在 node1 上执行

# 解除 node2 
rabbitmqctl forget_cluster_node rabbit@node2

2、镜像队列

虽然上面搭建了集群,但是一个节点中的队列是不共享的,也就是说当一个 节点 down 掉之后,数据就会丢失,虽然可以通过 druable 持久化保存到磁盘上,但是队列中的消息是无法被消费者消费的…

因此 引入镜像队列(Mirror Queue)的机制,可以将队列镜像【备份】到集群中的其他 Broker 节点之上,如果集群中

的一个节点失效了,队列能自动地切换到镜像中的另一个节点上以保证服务的可用性。

1、增加镜像规则

1651373324799

1651373530097

2、这个时候,节点中的队列被备份到另外俩个节点中。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

鲨瓜2号

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值