一、RabbitMQ 介绍

一、RabbitMQ 介绍
二、RabbitMQ 核心
三、RabbitMQ 扩展
四、RabbitMQ 集群


RabbitMQ 介绍


一、RabbitMQ 概念

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

二、RabbitMQ 工作原理

在这里插入图片描述


名词描述
Broker接收 和 分发 消息的应用,RabbitMQ Server 就是 Message Broker
Virtual Host出于 多租户 和 安全因素 设计的,把 AMQP 的基本组件划分到一个虚拟的分组中,类似于网络中的 Namespace 概念。当多个不同的用户使用同一个 RabbitMQ Server 提供的服务时,可以划分出多个 Virtual Host,每个用户在自己的 Virtual Host 创建 Exchange 和 Queue 等
ConnectionPublisher、Consumer 和 Broker 之间的 TCP 连接
Channel如果每一次访问 RabbitMQ 都建立一个 Connection,在消息量大的时候建立 TCP Connection 的开销将是巨大的,效率也较低。Channel 是在 Connection 内部建立的逻辑连接,如果应用程序支持多线程,通常每个 Thread 创建单独的 Channel 进行通讯,AMQP Method 包含了 Channel Id 帮助客户端 和 Message Broker 识别 Channel,所以 Channel 之间是完全隔离的。Channel 作为轻量级的 Connection,极大减少了操作系统建立 TCP Connection 的开销
ExchangeMessage 到达 Broker 的第一站,根据分发规则,匹配查询表中的 Routing Key,分发消息到 Queue 中去。常用的类型有:扇出Fanout(multicast)、直接Direct(point-to-point),主题Topic(publish-subscribe)、标题Headers
Queue消息最终被送到这里等待 Consumer 取走
BindingExchange 和 Queue 之间的虚拟连接,Binding 中可以包含 routing_key,Binding 信息被保存到 Exchange 中的查询表中,用于 Message 的分发依据

三、RabbitMQ 四大核心


1. 生产者(Producer

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

2. 交换机(Exchange

  • 交换机 是 RabbitMQ 非常重要的一个部件,一方面它接收来自生产者的消息,另一方面它将消息推送到队列中。
  • 交换机 必须确切的知道如何处理它接收到的消息,是将这些消息推送到特定队列 还是推送到多个队列,亦或者是把消息丢弃,这个得由 交换机类型 决定。
    在这里插入图片描述
  • RabbitMQ 消息传递模型的核心思想是:生产者生产的消息 不会 直接发送到队列
  • 实际上,通常生产者甚至都不知道这些消息传递传递到了哪些队列中。
  • 相反,生产者只能将消息发送到交换机(Exchange)
    在这里插入图片描述

2.1 交换机类型
2.1.1 扇出(Fanout

2.1.2 直接(Direct

2.1.3 主题(Topic

2.1.4 标题(Headers

2.2 无名交换机
  • 默认交换,通过 空字符串("" 进行标识。
/**
 * 生产者发送一个消息。
 * 1. 发送到那个交换机(`""`代表默认交换机 或 无名称交换机)。
 * 2. 路由的`key`是那个(消息能路由发送到队列中,其实是由`routingKey(bindingkey)`绑定`key`指定的)。
 * 3. 其他的参数信息。
 * 4. 发送的消息体。
 */
channel.basicPublish("", QUEUE_NAME, null, message.getBytes());

2.3 延迟交换机
  • RabbitMQ 插件。

2.4 备份交换机
  • 发布确认高级用到。

3. 队列(Queue

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

3.1 临时队列
  • 随机名称的队列。

3.2 死信队列
  1. 消息 TTL(Time To Live)生存时间值过期。
  2. 队列达到最大长度。
  3. 消息被拒绝。

3.3 延迟队列

4. 消费者(Consumer

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

四、交换机类型


1. 扇出(Fanout

  • Fanout 是将接收到的消息 广播 到他绑定的所有队列中(默认创建的 Exchange 类型)。
    在这里插入图片描述

1.1 架构图

在这里插入图片描述

  • Logs 和 临时队列 的绑定关系如下图。
    在这里插入图片描述

1.2 消费者
/**
 * @author: wy
 * describe: 接收日志
 */
public class ReceiveLogsFanout1 {

    // 交换机名称
    private static final String EXCHANGE_NAME = "fanout_logs";

    public static void main(String[] args) throws IOException, TimeoutException {
        Channel channel = RabbitMQUtil.getChannel();
        // 声明一个交换机
        channel.exchangeDeclare(EXCHANGE_NAME, "fanout");
        /*
        声明一个队列(临时队列)
        1. 队列名称是随机的
        2. 队列自动删除
         */
        String queueName = channel.queueDeclare().getQueue();
        // 绑定交换机 与 队列
        channel.queueBind(queueName, EXCHANGE_NAME, "");

        // 接收消息
        channel.basicConsume(queueName, true,
                // 接收消息
                (consumerTag, message) -> {
                    System.out.println(message);
                    System.out.printf("ReceiveLogsFanout1,接收到消息: %s\r\n", new String(message.getBody()));
                },
                // 取消消息
                consumerTag -> {
                    System.out.printf("ReceiveLogsFanout1,中断消费消息: %s\r\n", consumerTag);
                });
        System.out.println("ReceiveLogsFanout1,等待接收消费...");
    }
}

1.3 生产者
/**
 * @author: wy
 * describe: 发送日志
 * 发布订阅模式
 */
public class EmitLogFanout {

    // 交换机名称
    private static final String EXCHANGE_NAME = "fanout_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);
        System.out.println("输入消息: ");
        while (scanner.hasNext()) {
            String message = scanner.next();
            channel.basicPublish(EXCHANGE_NAME, "", null, message.getBytes("UTF-8"));
            System.out.printf("发送消息: %s\r\n", message);
        }
    }
}

2. 直接(Direct

  • 上面我们构建了一个简单的日志记录系统,能够向许多接收者广播日志消息。
  • 现在我们将向其中添加一些特别的功能,比如说我们 只让某个消费者订阅部分消息

  • 例如:我们希望将日志消息写入磁盘的程序,仅接收 严重错误(Error)。
  • 而不存储哪些 警告(Warning)或 信息(Info)日志,避免浪费磁盘空间。

  • Fanout 这种交换机类型并不能给我们很大的灵活性,它只能进行无意识的广播。
  • 在这里我们将使用 Direct类型交换机 来进行替换,这种类型的工作方式是,消息只去绑定的 RoutingKey 队列中去。
    在这里插入图片描述
  • 在上面图中,我们可以看到 X 交换机 绑定了两个队列,交换机类型是 Direct。
  • 队列 Q1 绑定键为 orange,队列 Q2 绑定键有两个,一个绑定键为 black,另一个绑定键为 green。

  • 在上面这种绑定情况下,生产者发布消息到 Exchange 上,绑定键为 orange 的消息会被发布到 队列 Q1。
  • 绑定键为 black 或 green 的消息会被发布到 队列 Q2,其他消息类型的消息将被丢弃。

2.1 架构图

在这里插入图片描述
在这里插入图片描述


2.2 消费者一
/**
 * @author: wy
 * describe: 接收日志
 */
public class ReceiveLogsDirect1 {

    // 交换机名称
    private 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);
        // 声明一个队列
        String queueName = "console";
        channel.queueDeclare(queueName, false, false, false, null);
        // 绑定交换机 与 队列
        channel.queueBind(queueName, EXCHANGE_NAME, "info");
        channel.queueBind(queueName, EXCHANGE_NAME, "warning");

        // 接收消息
        channel.basicConsume(queueName, true,
                // 接收消息
                (consumerTag, message) -> {
                    System.out.println(message);
                    System.out.printf("ReceiveLogsDirect1,接收到消息: %s\r\n", new String(message.getBody()));
                },
                // 取消消息
                consumerTag -> {
                    System.out.printf("ReceiveLogsDirect1,中断消费消息: %s\r\n", consumerTag);
                });
        System.out.println("ReceiveLogsDirect1,等待接收消费...");
    }
}

2.3 消费者二
/**
 * @author: wy
 * describe: 接收日志
 */
public class ReceiveLogsDirect2 {

    // 交换机名称
    private 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);
        // 声明一个队列
        String queueName = "disk";
        channel.queueDeclare(queueName, false, false, false, null);
        // 绑定交换机 与 队列
        channel.queueBind(queueName, EXCHANGE_NAME, "error");

        // 接收消息
        channel.basicConsume(queueName, true,
                // 接收消息
                (consumerTag, message) -> {
                    System.out.println(message);
                    System.out.printf("ReceiveLogsDirect2,接收到消息: %s\r\n", new String(message.getBody()));
                },
                // 取消消息
                consumerTag -> {
                    System.out.printf("ReceiveLogsDirect2,中断消费消息: %s\r\n", consumerTag);
                });
        System.out.println("ReceiveLogsDirect2,等待接收消费...");
    }
}

2.4 生产者
/**
 * @author: wy
 * describe: 发送日志
 * 路由模式
 * 一、direct_logs交换机
 * 1. console队列
 * 1.1 routing_key_info
 * 1.2 routing_key_warning
 * 2. disk队列
 * 2.1 routing_key_error
 */
public class EmitLogDirect {

    // 交换机名称
    private 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);

        // 从控制台接收消息
        Scanner scanner = new Scanner(System.in);
        System.out.println("输入消息: ");
        while (scanner.hasNext()) {
            String message = scanner.next();
            // info、warning、error
            String routingKey = "error";
            channel.basicPublish(EXCHANGE_NAME, routingKey, null, message.getBytes("UTF-8"));
            System.out.printf("发送消息: %s\r\n", message);
        }
    }
}

2.5 多重绑定

在这里插入图片描述

  • 上图所示,如果 Exchange 的类型是 Direct,但是它绑定的多个队列的 RoutingKey 都相同
  • 在这种情况下虽然绑定类型是 Direct,但是它和 Fanout 有点类似,跟广播差不多。

3. 主题(Topic

  • 上面改进了日志记录系统,不再使用只能进行随意广播的 Fanout 交换机,而是使用了 Direct 交换机。
  • 从而实现有选择性地接收日志。

  • 尽管使用 Direct 交换机改进了我们的系统,但是它仍然存在局限性。
  • 比如说:我们想接收的日志类型有 info.baseinfo.advantage,某个队列只想接收 info.base 的消息,那这个时候 Direct 就办不到了。
  • 这个时候就只能使用 Topic 类型

3.1 规则要求
  • 发送到 Topic 类型交换机的消息的 RoutingKey 不能随意写,必须满足一定的要求,它必须是一个单词列表,以点号分隔开
  • 这些单词可以是任意单词,比如说:stock.usd.nysenyse.vmwquick.orange.rabbit,这些类型的。
  • 当然这个单词列表最多不能超过 255 个字节。

  • 在这个规则列表中,其中有两个替换符是需要注意的。
  1. *(星号):可以代替一个单词。
  2. #(井号):可以替代 零个 或 多个 单词。
  • 当一个队列绑定键是 #,那么这个队列将接收所有数据,就有点像 Fanout 了。
  • 如果 队列 绑定键当中没有 #* 出现,那么该队列绑定类型就是 Direct 了。

3.2 架构图

在这里插入图片描述
在这里插入图片描述

  • Q1 绑定的是中间带 orange 的三个单词的字符串 *.orange.*
  • Q2 绑定的是最后一个是 rabbit 的三个单词的字符串 *.*.rabbit,和第一个单词是 lazy 的多个单词的字符串 lazy.#
RoutingKey结果
quick.orange.rabbit被队列 Q1、Q2 接收到
lazy.orange.elephant被队列 Q1、Q2 接收到
quick.orange.fox被队列 Q1 接收到
lazy.brown.fox被队列 Q2 接收到
lazy.pink.rabbit虽然同时满足两个绑定,但只被队列 Q2 接收一次
quick.brown.fox不匹配任何绑定,不会被任何队列接收到,会被丢弃
quick.orange.male.rabbit四个单词不匹配任何绑定,会被丢弃
lazy.orange.male.rabbit四个单词,但匹配 Q2

3.3 消费者一
/**
 * @author: wy
 * describe: 消费者
 */
public class ReceiveLogsTopic1 {

    // 交换机名称
    private 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);
        // 队列绑定交换机
        String routingKey = "*.orange.*";
        channel.queueBind(queueName, EXCHANGE_NAME, routingKey);
        // 接收消息
        channel.basicConsume(queueName, true,
                (consumerTag, message) -> {
                    System.out.printf("ReceiveLogsTopic1,接收到消息: %s, queueName: %s, routingKey: %s",
                            new String(message.getBody(), "UTF-8"),
                            queueName,
                            message.getEnvelope().getRoutingKey()).println();
                },
                consumerTag -> {
                });
        System.out.println("ReceiveLogsTopic1, 等待接收消费...");
    }
}

3.4 消费者二
/**
 * @author: wy
 * describe: 消费者
 */
public class ReceiveLogsTopic2 {

    // 交换机名称
    private 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);
        // 队列绑定交换机
        String routingKey1 = "*.*.rabbit";
        String routingKey2 = "lazy.*.*";
        channel.queueBind(queueName, EXCHANGE_NAME, routingKey1);
        channel.queueBind(queueName, EXCHANGE_NAME, routingKey2);
        // 接收消息
        channel.basicConsume(queueName, true,
                (consumerTag, message) -> {
                    System.out.printf("ReceiveLogsTopic2,接收到消息: %s, queueName: %s, routingKey: %s",
                            new String(message.getBody(), "UTF-8"),
                            queueName,
                            message.getEnvelope().getRoutingKey()).println();
                },
                consumerTag -> {
                });
        System.out.println("ReceiveLogsTopic2, 等待接收消费...");
    }
}

3.5 生产者
/**
 * @author: wy
 * describe: 生产者
 * quick.orange.rabbit 被队列 Q1Q2 接收到
 * lazy.orange.elephant 被队列 Q1Q2 接收到
 * quick.orange.fox 被队列 Q1 接收到
 * lazy.brown.fox 被队列 Q2 接收到
 * lazy.pink.rabbit 虽然满足两个绑定但只被队列 Q2 接收一次
 * quick.brown.fox 不匹配任何绑定不会被任何队列接收到会被丢弃
 * quick.orange.male.rabbit 是四个单词不匹配任何绑定会被丢弃
 * lazy.orange.male.rabbit 是四个单词但匹配 Q2
 */
public class EmitLogTopic {

    private static final String EXCHANGE_NAME = "topic_logs";

    private static final Map<String, String> bindingKeyMap = new HashMap<>();

    static {
        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");
    }

    public static void main(String[] args) throws IOException, TimeoutException {
        Channel channel = RabbitMQUtil.getChannel();
        // 发送消息
        for (Map.Entry<String, String> entry : bindingKeyMap.entrySet()) {
            String routingKey = entry.getKey();
            String message = entry.getValue();
            channel.basicPublish(EXCHANGE_NAME, routingKey, null, message.getBytes("UTF-8"));
            System.out.printf("EmitLogTopic, 发送消息: %s", message).println();
        }
    }
}

五、队列


1. 临时队列

  • 我们连接到 RabbitMQ 时,都需要一个全新的空队列。
  • 为此我们可以创建一个具有随机名称的队列,或者能让服务器为我们选择一个随机队列名称那就更好了。
  • 其次一旦我们断开了消费者的连接,队列将被自动删除。
/*
声明一个队列(临时队列)
1. 队列名称是随机的
2. 队列自动删除
 */
String queueName = channel.queueDeclare().getQueue();

在这里插入图片描述


2. 死信队列

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

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

2.1 死信来源

2.1.1 消息 TTL 过期
  • 消费者。
// 1. 消息`TTL`过期,设置过期时间`100`秒(也可以在生产方设置)
arguments.put("x-message-ttl", 100 * 1000);

  • 生产者。
// 1. 消息`TTL`过期,设置`TTL`过期时间`10`秒(ttl: Time to live)
AMQP.BasicProperties props = new AMQP.BasicProperties().builder().expiration("10000").build();
channel.basicPublish(NORMAL_EXCHANGE, NORMAL_ROUTING_KEY, props, message.getBytes("UTF-8"));

2.1.2 队列达到最大长度
  • 队列满了,无法再添加数据到 MQ 中。
// 2. 队列达到最大长度,设置正常队列长度限制
arguments.put("x-max-length", 6);

2.1.3 消息被拒绝
  • basicReject(用于拒绝消息)basicNack(用于否定确认),并且 requeue=false
// 是否开启自动应答
boolean autoAck = false;
// 正常 接收消息
channel.basicConsume(NORMAL_QUEUE, autoAck,
        (consumerTag, message) -> {
            String msg = new String(message.getBody(), "UTF-8");
            if (msg.endsWith("6")) {
                /**
                 * 3. 消息被拒绝(拒绝info: 6)
                 * requeue 是否放回队列(false不放回)
                 */
                channel.basicReject(message.getEnvelope().getDeliveryTag(), false);
                System.out.printf("拒绝到消息: %s, queueName: %s, routingKey: %s",
                        msg,
                        NORMAL_QUEUE,
                        message.getEnvelope().getRoutingKey()).println();
            } else {
                channel.basicAck(message.getEnvelope().getDeliveryTag(), false);
                System.out.printf("接收到消息: %s, queueName: %s, routingKey: %s",
                        msg,
                        NORMAL_QUEUE,
                        message.getEnvelope().getRoutingKey()).println();
            }
        },
        consumerTag -> {
        });

2.2 架构图

在这里插入图片描述


2.3 消费者一
/**
 * @author: wy
 * describe: 消费者
 * 死信队列
 */
public class Consumer1 {

    // 交换机名称
    private static final String NORMAL_EXCHANGE = "normal_exchange";
    private static final String DEAD_EXCHANGE = "dead_exchange";
    // 队列名称
    private static final String NORMAL_QUEUE = "normal-queue";
    private static final String DEAD_QUEUE = "dead-queue";
    // routingKey
    private static final String NORMAL_ROUTING_KEY = "zhangsan";
    private static final String DEAD_ROUTING_KEY = "lisi";

    public static void main(String[] args) throws IOException, TimeoutException {
        Channel channel = RabbitMQUtil.getChannel();
        // 声明正常交换机、死信交换机
        channel.exchangeDeclare(NORMAL_EXCHANGE, BuiltinExchangeType.DIRECT);
        channel.exchangeDeclare(DEAD_EXCHANGE, BuiltinExchangeType.DIRECT);

        // 正常队列参数
        Map<String, Object> arguments = new HashMap<>();
        // 指定死信交换机
        arguments.put("x-dead-letter-exchange", DEAD_EXCHANGE);
        // 指定死信RoutingKey
        arguments.put("x-dead-letter-routing-key", DEAD_ROUTING_KEY);

        // 1. 消息TTL过期,设置过期时间100秒(也可以在生产方设置)
//        arguments.put("x-message-ttl", 100 * 1000);
        // 2. 队列达到最大长度,设置正常队列长度限制
//        arguments.put("x-max-length", 6);
        // 声明正常队列
        channel.queueDeclare(NORMAL_QUEUE, false, false, false, arguments);
        // 声明死信队列
        channel.queueDeclare(DEAD_QUEUE, false, false, false, null);

        // 队列 绑定 交换机
        channel.queueBind(NORMAL_QUEUE, NORMAL_EXCHANGE, NORMAL_ROUTING_KEY);
        channel.queueBind(DEAD_QUEUE, DEAD_EXCHANGE, DEAD_ROUTING_KEY);

		// 是否开启自动应答
        boolean autoAck = false;
        // 正常 接收消息
        channel.basicConsume(NORMAL_QUEUE, autoAck,
                (consumerTag, message) -> {
                    String msg = new String(message.getBody(), "UTF-8");
                    if (msg.endsWith("6")) {
                        /**
                         * 3. 消息被拒绝(拒绝info: 6)
                         * requeue 是否放回队列(false不放回)
                         */
                        channel.basicReject(message.getEnvelope().getDeliveryTag(), false);
                        System.out.printf("拒绝到消息: %s, queueName: %s, routingKey: %s",
                                msg,
                                NORMAL_QUEUE,
                                message.getEnvelope().getRoutingKey()).println();
                    } else {
                        channel.basicAck(message.getEnvelope().getDeliveryTag(), false);
                        System.out.printf("接收到消息: %s, queueName: %s, routingKey: %s",
                                msg,
                                NORMAL_QUEUE,
                                message.getEnvelope().getRoutingKey()).println();
                    }
                },
                consumerTag -> {
                });
        System.out.println("Consumer1, 等待接收消息...");
    }
}

2.4 消费者二
/**
 * @author: wy
 * describe: 消费者
 * 死信队列
 */
public class Consumer2 {

    // 队列名称
    private static final String DEAD_QUEUE = "dead-queue";

    public static void main(String[] args) throws IOException, TimeoutException {
        Channel channel = RabbitMQUtil.getChannel();
        // 接收消息
        channel.basicConsume(DEAD_QUEUE, true,
                (consumerTag, message) -> {
                    System.out.printf("接收到消息: %s, queueName: %s, routingKey: %s",
                            new String(message.getBody(), "UTF-8"),
                            DEAD_QUEUE,
                            message.getEnvelope().getRoutingKey()).println();
                },
                consumerTag -> {
                });
        System.out.println("Consumer2, 等待接收消息...");
    }
}

2.5 生产者
/**
 * @author: wy
 * describe: 生产者
 * 死信队列
 * 1. 消息TTL过期(延迟队列)
 * 2. 队列达到最大长度
 * 3. 消息被拒绝
 */
public class Producer {

    // 交换机名称
    private static final String NORMAL_EXCHANGE = "normal_exchange";
    // routingKey
    private static final String NORMAL_ROUTING_KEY = "zhangsan";

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

        // 1. 消息TTL过期,设置TTL过期时间10秒(ttl: time to live)
//        AMQP.BasicProperties props = new AMQP.BasicProperties().builder()
//                .expiration("10000").build();
        for (int i = 0; i < 10; i++) {
            String message = String.format("info: %s", i);
//            channel.basicPublish(NORMAL_EXCHANGE, NORMAL_ROUTING_KEY, props, message.getBytes("UTF-8"));
//            TimeUnit.SECONDS.sleep(2);
            channel.basicPublish(NORMAL_EXCHANGE, NORMAL_ROUTING_KEY, null, message.getBytes("UTF-8"));
            System.out.printf("发送消息: %s", message).println();
        }
    }
}

3. 延迟队列

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

3.1 延迟使用场景
  1. 订单在十分钟之内未支付则自动取消。
  2. 新创建的店铺,如果在十天内都没有上传过商品,则自动发送消息提醒。
  3. 用户注册成功后,如果三天内没有登陆则进行短信提醒。
  4. 用户发起退款,如果三天内没有得到处理则通知相关运营人员。
  5. 预定会议后,需要在预定的时间点前十分钟通知各个与会人员参加会议。
  • 这些场景都有一个特点,需要在某个事件发生之后 或者 之前的指定时间点完成某一项任务。

  • 如:发生订单生成事件,在十分钟之后检查该订单支付状态,然后将未支付的订单进行关闭。
  • 看起来似乎使用定时任务,一直轮询数据,每秒查一次,取出需要被处理的数据,然后处理不就完事了吗?如果数据量比较少,确实可以这样做。
  • 如:对于账单一周内未支付则进行自动结算这样的需求,如果对于时间不是严格限制,而是宽松意义上的一周,那么每天晚上跑个定时任务检查一下所有未支付的账单,确实也是一个可行的方案。
  • 但对于数据量比较大,并且时效性较强的场景。
  • 如:订单十分钟内未支付则关闭,短期内未支付的订单数据可能会有很多,活动期间甚至会达到百万甚至千万级别,对这么庞大的数据量仍旧使用轮询的方式显然是不可取的,很可能在一秒内无法完成所有订单的检查,同时会给数据库带来很大压力,无法满足业务要求而且性能低下。
    在这里插入图片描述

3.2 RabbitMQ 中的 TTL
  • TTL 是 RabbitMQ 中一个消息或者队列的属性。
  • 表明一条消息 或者 该队列中的所有消息的最大存活时间, 单位是毫秒。

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

3.2.1 队列设置 TTL
/**
 * 设置队列TTL为10秒
 */
@Bean("queueA")
public Queue queueA() {
    Map<String, Object> arguments = new HashMap<>(3);
    // 设置死信交换机
    arguments.put("x-dead-letter-exchange", Y_DEAD_LETTER_EXCHANGE);
    // 设置死信RoutingKey
    arguments.put("x-dead-letter-routing-key", "YD");
    // 队列设置TTL
    arguments.put("x-message-ttl", 10 * 1000);
    return QueueBuilder
            .durable(QUEUE_A)
            .withArguments(arguments)
            .build();
}

3.2.2 消息设置 TTL
// 发送消息的时候,设置延迟时长
message.getMessageProperties().setExpiration(ttlTime);

3.2.3 两者的区别
  • 如果设置了 队列 的 TTL 属性,那么一旦消息过期,就会被队列丢弃(如果配置了死信队列会被丢到死信队列中)。

  • 消息 设置了 TTL 属性,消息即使过期,也不一定会被马上丢弃,因为 消息是否过期是在即将投递到消费者之前判定的。如果当前队列有严重的消息积压情况,则已过期的消息也许还能存活较长时间。

  • 如果不设置 TTL,表示消息永远不会过期。

  • 如果将 TTL 设置为 0,则表示此时可以直接投递给该消息到消费者,否则该消息将会被丢弃。

  • 延时队列,不就是想要消息延迟多久被处理吗?TTL 则刚好能让消息在延迟多久之后成为死信。
  • 另一方面,成为死信的消息都会被投递到死信队列里,这样只需要消费者一直消费死信队列里的消息就完事了,因为里面的消息都是希望被立即处理的消息。

3.3 队列设置 TTL

3.3.1 架构图
  1. 创建两个队列 QA 和 QB,两个队列 TTL 分别设置为 10S 和 40S。
  2. 然后在创建一个 交换机 X 和 死信交换机 Y,它们的类型都是 Direct。
  3. 创建一个 死信队列 QD。
  • 它们的绑定关系如下。
    在这里插入图片描述
  1. 第一条消息在 10S 后变成了死信消息,然后被消费者消费掉。
  2. 第二条消息在 40S 后变成了死信消息,然后被消费者消费掉。
  • 这样一个延时队列就打造完成了。

3.3.2 Config
/**
 * @author: wy
 * describe: 死信队列配置
 * 一、X交换机
 * 1. QA队列,XA,TTL10秒
 * 2. QB队列,XB,TTL40秒
 * 3. QC队列,XC,不设置TTL
 * 二、Y交换机
 * 1. QD死信队列,YD
 */
@Configuration
public class TTLQueueConfig {

    // 普通交换机
    public static final String X_EXCHANGE = "X";
    // 普通队列,TTL为10秒
    public static final String QUEUE_A = "QA";
    // TTL为40秒
    public static final String QUEUE_B = "QB";
    // 死信交换机
    public static final String Y_DEAD_LETTER_EXCHANGE = "Y";
    // 死信队列
    public static final String DEAD_LETTER_QUEUE_D = "QD";

    /**
     * DirectExchange 直接交换机
     */
    @Bean("xExchange")
    public DirectExchange xExchange() {
        return new DirectExchange(X_EXCHANGE);
    }

    @Bean("yExchange")
    public DirectExchange yExchange() {
        return new DirectExchange(Y_DEAD_LETTER_EXCHANGE);
    }

    /**
     * 设置队列TTL为10秒
     */
    @Bean("queueA")
    public Queue queueA() {
        Map<String, Object> arguments = new HashMap<>(3);
        // 设置死信交换机
        arguments.put("x-dead-letter-exchange", Y_DEAD_LETTER_EXCHANGE);
        // 设置死信RoutingKey
        arguments.put("x-dead-letter-routing-key", "YD");
        // 设置TTL
        arguments.put("x-message-ttl", 10 * 1000);
        return QueueBuilder
                .durable(QUEUE_A)
                .withArguments(arguments)
                .build();
    }

    /**
     * 设置队列TTL为40秒
     */
    @Bean("queueB")
    public Queue queueB() {
        Map<String, Object> arguments = new HashMap<>(3);
        // 设置死信交换机
        arguments.put("x-dead-letter-exchange", Y_DEAD_LETTER_EXCHANGE);
        // 设置死信RoutingKey
        arguments.put("x-dead-letter-routing-key", "YD");
        // 设置TTL
        arguments.put("x-message-ttl", 40 * 1000);
        return QueueBuilder
                .durable(QUEUE_B)
                .withArguments(arguments)
                .build();
    }
    
	/**
     * 死信队列
     */
    @Bean("queueD")
    public Queue queueD() {
        return QueueBuilder
                .durable(DEAD_LETTER_QUEUE_D)
                .build();
    }

    /**
     * queueA 绑定 xExchange
     */
    @Bean
    public Binding queueABindingX(@Qualifier("queueA") Queue queueA,
                                  @Qualifier("xExchange") DirectExchange xExchange) {
        return BindingBuilder
                .bind(queueA)
                .to(xExchange)
                .with("XA");
    }

    /**
     * queueB 绑定 xExchange
     */
    @Bean
    public Binding queueBBindingX(@Qualifier("queueB") Queue queueB,
                                  @Qualifier("xExchange") DirectExchange xExchange) {
        return BindingBuilder
                .bind(queueB)
                .to(xExchange)
                .with("XB");
    }

    /**
     * queueD 绑定 yExchange
     */
    @Bean
    public Binding queueDBindingY(@Qualifier("queueD") Queue queueD,
                                  @Qualifier("yExchange") DirectExchange yExchange) {
        return BindingBuilder
                .bind(queueD)
                .to(yExchange)
                .with("YD");
    }
}

3.3.3 消费者
  • 死信队列。
/**
 * @author: wy
 * describe: 消费者消费消息
 * 死信队列
 */
@Slf4j
@Component
public class DeadLetterQueueConsumer {

    @RabbitListener(queues = TTLQueueConfig.DEAD_LETTER_QUEUE_D)
    public void receiveQueueD(Message message, Channel channel) {
        String msg = new String(message.getBody());
        log.info("当前时间: {}, 收到死信队列的消息: {}", new Date().toString(), msg);
    }
}

3.3.4 生产者
/**
 * @author: wy
 * describe: 生产者发消息
 */
@Slf4j
@RestController
@RequestMapping("/ttl")
public class SendMsgController {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    /**
     * 1. 发送固定时间的延迟消息
     * http://127.0.0.1:8080/ttl/sendMsg/%E4%BD%A0%E5%A5%BD%E5%91%80
     */
    @GetMapping("/sendMsg/{msg}")
    public String sendMsg(@PathVariable String msg) {
        log.info("当前时间: {}, 发送一条消息给两个TTL队列: {}", new Date().toString(), msg);
        rabbitTemplate.convertAndSend(TTLQueueConfig.X_EXCHANGE, "XA", String.format("消息来自TTL为10秒的队列: %s", msg));
        rabbitTemplate.convertAndSend(TTLQueueConfig.X_EXCHANGE, "XB", String.format("消息来自TTL为40秒的队列: %s", msg));
        return "success";
    }
}    

3.4 消息设置 TTL
  • 上面如果这样使用的话,岂不是每增加一个新的时间需求,就要新增一个队列。
  • 这里只有 10S 和 40S 两个时间选项,如果需要一个小时后处理,那么就需要增加 TTL 为一个小时的队列。
  • 如果是预定会议室然后提前通知这样的场景,岂不是要增加无数个队列才能满足需求?

3.4.1 架构图
  • 在这里新增了一个队列 QC,绑定关系如下,该队列不设置 TTL 时间,由生产者设置消息 TTL。
    在这里插入图片描述
  • 看起来似乎没什么问题,但是在最开始的时候,就介绍过如果使用在消息属性上设置 TTL 的方式,消息可能并 不会按时死亡
  • 因为 RabbitMQ 只会检查第一个消息是否过期,如果过期则丢到死信队列,如果第一个消息的延时很长,而第二个消息的延时很短,第二个消息并不会优先得到执行。

3.4.2 Config
/**
 * @author: wy
 * describe: 死信队列配置
 * 一、X交换机
 * 1. QA队列,XA,TTL10秒
 * 2. QB队列,XB,TTL40秒
 * 3. QC队列,XC,不设置TTL
 * 二、Y交换机
 * 1. QD死信队列,YD
 */
@Configuration
public class TTLQueueConfig {

    // 普通交换机
    public static final String X_EXCHANGE = "X";
    // 普通队列,TTL为10秒
    public static final String QUEUE_A = "QA";
    // TTL为40秒
    public static final String QUEUE_B = "QB";
    // 不设置TTL
    public static final String QUEUE_C = "QC";
    // 死信交换机
    public static final String Y_DEAD_LETTER_EXCHANGE = "Y";
    // 死信队列
    public static final String DEAD_LETTER_QUEUE_D = "QD";

    /**
     * DirectExchange 直接交换机
     */
    @Bean("xExchange")
    public DirectExchange xExchange() {
        return new DirectExchange(X_EXCHANGE);
    }

    @Bean("yExchange")
    public DirectExchange yExchange() {
        return new DirectExchange(Y_DEAD_LETTER_EXCHANGE);
    }

    /**
     * 设置队列TTL为10秒
     */
    @Bean("queueA")
    public Queue queueA() {
        Map<String, Object> arguments = new HashMap<>(3);
        // 设置死信交换机
        arguments.put("x-dead-letter-exchange", Y_DEAD_LETTER_EXCHANGE);
        // 设置死信RoutingKey
        arguments.put("x-dead-letter-routing-key", "YD");
        // 设置TTL
        arguments.put("x-message-ttl", 10 * 1000);
        return QueueBuilder
                .durable(QUEUE_A)
                .withArguments(arguments)
                .build();
    }

    /**
     * 设置队列TTL为40秒
     */
    @Bean("queueB")
    public Queue queueB() {
        Map<String, Object> arguments = new HashMap<>(3);
        // 设置死信交换机
        arguments.put("x-dead-letter-exchange", Y_DEAD_LETTER_EXCHANGE);
        // 设置死信RoutingKey
        arguments.put("x-dead-letter-routing-key", "YD");
        // 设置TTL
        arguments.put("x-message-ttl", 40 * 1000);
        return QueueBuilder
                .durable(QUEUE_B)
                .withArguments(arguments)
                .build();
    }
    
    /**
     * 不设置TTL
     */
    @Bean("queueC")
    public Queue queueC() {
        Map<String, Object> arguments = new HashMap<>(2);
        // 设置死信交换机
        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();
    }

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

    /**
     * queueA 绑定 xExchange
     */
    @Bean
    public Binding queueABindingX(@Qualifier("queueA") Queue queueA,
                                  @Qualifier("xExchange") DirectExchange xExchange) {
        return BindingBuilder
                .bind(queueA)
                .to(xExchange)
                .with("XA");
    }

    /**
     * queueB 绑定 xExchange
     */
    @Bean
    public Binding queueBBindingX(@Qualifier("queueB") Queue queueB,
                                  @Qualifier("xExchange") DirectExchange xExchange) {
        return BindingBuilder
                .bind(queueB)
                .to(xExchange)
                .with("XB");
    }

    /**
     * queueC 绑定 xExchange
     */
    @Bean
    public Binding queueCBindingX(@Qualifier("queueC") Queue queueC,
                                  @Qualifier("xExchange") DirectExchange xExchange) {
        return BindingBuilder
                .bind(queueC)
                .to(xExchange)
                .with("XC");
    }

    /**
     * queueD 绑定 yExchange
     */
    @Bean
    public Binding queueDBindingY(@Qualifier("queueD") Queue queueD,
                                  @Qualifier("yExchange") DirectExchange yExchange) {
        return BindingBuilder
                .bind(queueD)
                .to(yExchange)
                .with("YD");
    }
}

3.4.3 消费者
/**
 * @author: wy
 * describe: 消费者消费消息
 * 死信队列
 */
@Slf4j
@Component
public class DeadLetterQueueConsumer {

    @RabbitListener(queues = TTLQueueConfig.DEAD_LETTER_QUEUE_D)
    public void receiveQueueD(Message message, Channel channel) {
        String msg = new String(message.getBody());
        log.info("当前时间: {}, 收到死信队列的消息: {}", new Date().toString(), msg);
    }
}

3.4.4 生产者
/**
 * @author: wy
 * describe: 生产者发消息
 */
@Slf4j
@RestController
@RequestMapping("/ttl")
public class SendMsgController {

    @Autowired
    private RabbitTemplate rabbitTemplate;

    /**
     * 1.1 发送指定过期时间的延迟消息
     * http://127.0.0.1:8080/ttl/sendMsgToQC/%E4%BD%A0%E5%A5%BD1/20000 延迟20秒
     * http://127.0.0.1:8080/ttl/sendMsgToQC/%E4%BD%A0%E5%A5%BD2/2000 延迟2秒
     */
    @GetMapping("/sendMsgToQC/{msg}/{ttlTime}")
    public String sendMsgToQC(@PathVariable String msg, @PathVariable String ttlTime) {
        log.info("当前时间: {}, 发送一条TTL为{}毫秒的消息给QC队列: {}", new Date().toString(), ttlTime, msg);
        rabbitTemplate.convertAndSend(TTLQueueConfig.X_EXCHANGE, "XC",
                String.format("消息来自TTL为%s毫秒的QC队列: %s", ttlTime, msg),
                message -> {
                    // 发送消息的时候,延迟时长
                    message.getMessageProperties().setExpiration(ttlTime);
                    return message;
                });
        return "success";
    }
}    
  • 问题:消息一延时 20 秒,消息二延时 2 秒,而消息二必须要等待消息一延时超时。
  • 所以消息二也延时 20 秒。

3.5 RabbitMQ 插件——实现延迟队列
  • 上文中提到的问题,如果不能实现在消息粒度上的 TTL,并使其在设置的 TTL 时间及时死亡,就无法设计成一个通用的延时队列。那如何解决呢?
  • x-delayed-message 交换机。

3.5.1 架构图
  • 在这里新增了一个队列 delayed.queue,一个自定义交换机 delayed.exchange,绑定关系如下。
    在这里插入图片描述
  • 在我们自定义的交换机中,x-delayed-message 是一种新的交换类型,该类型消息 支持延迟投递机制
  • 消息传递后并不会立即投递到目标队列中,而是存储在 mnesia(一个分布式数据系统)表中,当达到投递时间时,才投递到目标队列中。

3.5.2 Config
/**
 * @author: wy
 * describe: 延迟交换机配置
 * 基于`x-delayed-message`插件延迟消息
 */
@Configuration
public class DelayExchangeConfig {

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

    @Bean
    public CustomExchange delayExchange() {
        Map<String, Object> arguments = new HashMap<>();
        // 延迟类型"直接"
        arguments.put("x-delayed-type", "direct");
        /**
         * 1. 交换机的名称
         * 2. 交换机的类型
         * 3. 是否需要持久化
         * 4. 是否需要自动删除
         * 5. 其他的参数
         */
        return new CustomExchange(DELAYED_EXCHANGE_NAME, "x-delayed-message", false, false, arguments);
    }

    @Bean
    public Queue delayQueue() {
        return new Queue(DELAYED_QUEUE_NAME);
    }

    /**
     * delayedQueue 绑定 DelayedExchange
     */
    @Bean
    public Binding delayedQueueBindingDelayedExchange(@Qualifier("delayQueue") Queue delayQueue,
                                                      @Qualifier("delayExchange") CustomExchange delayExchange) {
        return BindingBuilder
                .bind(delayQueue)
                .to(delayExchange)
                .with(DELAYED_ROUTING_KEY)
                .noargs();
    }
}

3.5.3 消费者
/**
 * @author: wy
 * describe: 延迟交换机
 * 基于插件延迟消息
 */
@Slf4j
@Component
public class DelayExchangeConsumer {

    @RabbitListener(queues = DelayExchangeConfig.DELAYED_QUEUE_NAME)
    public void receiveQueueD(Message message, Channel channel) {
        String msg = new String(message.getBody());
        log.info("当前时间: {}, 收到延迟队列的消息: {}", new Date().toString(), msg);
    }
}

3.5.4 生产者
/**
 * @author: wy
 * describe: 生产者发消息
 */
@Slf4j
@RestController
@RequestMapping("/ttl")
public class SendMsgController {

    @Autowired
    private RabbitTemplate rabbitTemplate;
    
	/**
	 * 2. 基于插件,发送指定过期时间的延迟消息
	 * http://127.0.0.1:8080/ttl/sendDelayMsg/%E4%BD%A0%E5%A5%BD1/20000
	 * http://127.0.0.1:8080/ttl/sendDelayMsg/%E4%BD%A0%E5%A5%BD2/2000
	 */
	@GetMapping("/sendDelayMsg/{msg}/{delayTime}")
	public String sendDelayMsg(@PathVariable String msg, @PathVariable Integer delayTime) {
	    log.info("当前时间: {}, 发送一条时长{}的延迟消息给delayed.queue队列: {}", new Date().toString(), delayTime, msg);
	    rabbitTemplate.convertAndSend(
	            DelayExchangeConfig.DELAYED_EXCHANGE_NAME,
	            DelayExchangeConfig.DELAYED_ROUTING_KEY,
	            String.format("消息来自时长为%s毫秒的delayed.queue队列: %s", delayTime, msg),
	            message -> {
	                // 发送消息的时候,延迟时长
	                message.getMessageProperties().setDelay(delayTime);
	                return message;
	            });
	    return "success";
	}
}

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

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

六、绑定(Binding

  • Binding 是 Exchange 和 Queue 之间的桥梁,告诉我们 交换机 和 那个 队列 进行了绑定关系。
  • 比如说下面这张图告诉我们 X交换机 与 Q1 和 Q2 进行了绑定。
    在这里插入图片描述
  • Binding 是绑定 交换机 和 队列 之间的桥梁关系,也可以理解 队列只对它绑定的 交换机 的消息感兴趣
  • 绑定用参数 routingKey 来表示,也可称该参数为 bindingKey
  • 创建绑定我们用代码:channel.queueBind(queueName, EXCHANGE_NAME, "routingKey") 绑定之后的意义由其交换类型决定。

  • 0
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

骑士梦

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

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

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

打赏作者

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

抵扣说明:

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

余额充值