RabbitMQ

一、概述

1.什么是MQ

MQ(Message Queue)消息队列,是基础数据结构中“先进先出”的一种数据结构。一般用来解决应用解耦,异步消息,流量削峰等问题,实现高性能,高可用,可伸缩和最终一致性架构。

MQ就是把要传输的数据(消息)放在队列中,用队列机制来实现消息传递–生产者产生消息并把消息放入队列,然后由消费者去处理。消费者可以到指定队列拉取消息,或者订阅相应的队列,由MQ服务端给其推送消息。

2.什么是AMQP

AMQP,即Advanced Message Queuing Protocol,一个提供统一消息服务的应用层标准高级消息队列协议,是应用层协议的一个开放标准,为面向消息的中间件设计。基于此协议的客户端与消息中间件可传递消息,并不受客户端/中间件不同产品,不同的开发语言等条件的限制。Erlang中的实现有RabbitMQ等。

3.什么是JMS

JMS(Java Messaging Service)是Java平台上有关面向消息中间件(MOM)的技术规范它便于消息系统中的Java应用程序进行消息交换,并且通过提供标准的产生、发送、接收消息的接口简化企业应用的开发,翻译为Java消息服务。

有两种类型

  • 点对点。在点对点的消息系统中,消息分发给一个单独的使用者。点对点消息往往与队列相关联
  • 发布/订阅。发布/订阅消息系统支持一个事件驱动模型,消息生产者和消费者都参与消息传递。生产者发布事件,而使用者订阅感兴趣的事件,并使用事件。该类型消息一般与特定的主题关联

4.AMQP与JMS区别

  • JMS是定义了统一接口的,对消息操作进行统一;AMQP通过规定协议统一数据交互的格式
  • JMS限定了必须使用java语言;AMQP只是协议,不规定实现方式,因此是跨语言的
  • JMS规定了两种消息类型(queue,topic);而AMQP的消息类型更加丰富

5.常见的MQ产品

  • ActiveMQ:基于JMS
  • RabbitMQ:基于AQMP协议,erlang语言开发,稳定性好
  • RocketMQ:基于JMS,阿里巴巴产品
  • Kafka:分布式消息系统,高通吐量,处理日志,Scala和Java编写

二、RabbitMQ的优势

  • 可靠性(Reliablity):使用了一些机制来保证可靠性,比如持久化、传输确认、发布确认。
  • 灵活的路由(Flexible Routing):在消息进入队列之前,通过Exchange来路由消息。对于典型的路由功能,Rabbit已经提供了一些内置的Exchange来实现。针对更复杂的路由功能,可以将多个Exchange绑定在一起,也通过插件机制实现自己的Exchange。
  • 消息集群(Clustering):多个RabbitMQ服务器可以组成一个集群,形成一个逻辑Broker。
  • 高可用(Highly Avaliable Queues):队列可以在集群中的机器上进行镜像,使得在部分节点出问题的情况下队列仍然可用。
    多种协议(Multi-protocol):支持多种消息队列协议,如STOMP、MQTT等。
  • 多种语言客户端(Many Clients):几乎支持所有常用语言,比如Java、.NET、Ruby等。
  • 管理界面(Management UI):提供了易用的用户界面,使得用户可以监控和管理消息Broker的许多方面。
  • 跟踪机制(Tracing):如果消息异常,RabbitMQ提供了消息的跟踪机制,使用者可以找出发生了什么。
  • 插件机制(Plugin System):提供了许多插件,来从多方面进行扩展,也可以编辑自己的插件。

三、RabbitMQ后台管理系统

先要启动后台管理插件

rabbitmq-plugins enable rabbitmq_management

设置guest可以远程访问

修改rabbitmq的配置/usr/local/rabbitmq/etc/rabbitmq/rabbitmq.config
添加:[{rabbit, [{loopback_users, []}]}].

访问地址

http://192.168.94.142:15672/
username:guest
password:guest

管理界面标签页介绍

  • overview:概览
  • connections:无论是生产者还是消费者,都需要与-RabbitMQ建立连接后才可以完成消息的生产和消费,在这里可以查看连接情况。
  • channels:通道,建立连接后,会形成通道,消息的投递获取依赖通道。
  • Exchanges:交换机,用来实现消息的路由。
  • Queues:队列,即消息队列,消息存放在队列上,等待消费,消费后被移除。
  • 端口:
    • 5672:rabbitmq的编程语言客户端连接端口
    • 15672:rabbitmq管理界面端口
    • 25672: rabbitmq集群的端口

四、消息模式

1.基本消息

在这里插入图片描述
P:生产者,一个发送消息的用户应用程序。
C:消费者,消费和接受有类似的意思,消费者是一个主要用来等待接受消息的用户应用程序。
队列(红色区域):队列只受主机的内存和磁盘限制,实质上是一个大的消息缓冲区。许多生产者可以发送消息到一个队列,许多消费者可以尝试从一个队列接受数据。
总之,生产者将消息发送到队列,消费者从队列中获取信息,队列是存储消息的缓冲区。

2、work消息模型

在这里插入图片描述
work消息模型又称为竞争消费者模式,生产者将消息发送到队列之后,消息在消费者之间共享,但是一个消息只能被一个消费者获取。并且一旦消息被消费,就会消失。

3.订阅模型(三种)

1.总体框架解释

在这里插入图片描述

1、一个生产者,多个消费者
2、每一个消费者都有自己的一个队列;
3、生产者没有将消息直接发送到队列,而是发送到交换机;
4、每个队列都要绑定到交换机
5、生产者发送的消息,经过交换机到达队列,实现一个消息被多个消费者获取的目的。

交换机(Exchange):交换机一方面接受生产者发送的消息,另一方面进行消息的处理,比如将消息递交给特定队列、全部队列或者将消息丢弃,到底该如何操作,取决于交换机的类型(Fanout,Direct,Topic)。

Exchange(交换机):只负责转发消息,不具备存储消息的能力。因此如果没有任何队列与交换价绑定,或者没有符合路由规则的队列,那么信息会丢失。

1.Fanout(广播式交换机)

这种模式类似于广播的方式,所有发送到Fanout Exchange交换机上的消息,都会被发送到该交换机上面的所有队列上,这样绑定到这些队列的消费者就可以接收到该消息。
在这里插入图片描述

  • 这种模式不需要指定Routing key路由键,一个交换机可以绑定多个队列queue,一个queue可同时与多个exchange交换机进行绑定。
  • 如果消息发送到交换机上,但是这个交换价上面没有绑定的队列,那么这些消息将会被丢弃。

公共获取连接类

public class MQConnectionUtils {
    public static Connection getConnection() throws IOException, TimeoutException {
        ConnectionFactory connectionFactory = new ConnectionFactory();
        connectionFactory.setHost("192.168.94.142");
        connectionFactory.setUsername("guest");
        connectionFactory.setPassword("guest");
        connectionFactory.setPort(5672);
        connectionFactory.setVirtualHost("/");
        return connectionFactory.newConnection();
    }
}

生产者

public class Producer {
    private static final String EXCHANGE_NAME = "fanout_exchange";
    //广播式交换机
    private static final String EXCHANGE_TYPE = "fanout";
    public static void main(String[] args) {
        //获取MQ连接
        Connection connection = MQConnecitonUtils.getConnection();
        //从连接中获取Channel通道对象
        Channel channel = null;
        try {
            channel = connection.createChannel();
            //创建交换机对象
            channel.exchangeDeclare(EXCHANGE_NAME, EXCHANGE_TYPE);
            //发送消息到交换机exchange上
            String msg = "hello fanout exchange!!";
            channel.basicPublish(EXCHANGE_NAME, "", null, msg.getBytes());
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (null != channel) {
                try {
                    channel.close();
                } catch (IOException | TimeoutException e) {
                    e.printStackTrace();
                }
            }
            if (null != connection) {
                try {
                    connection.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

消费者1

public class Consumer01 {
    private static final String QUEUE_NAME = "fanout_exchange_queue01";
    private static final String EXCHANGE_NAME = "fanout_exchange";
    public static void main(String[] args) {
        //获取MQ连接对象
        Connection connection = MQConnecitonUtils.getConnection();
        try {
            //创建消息通道对象
            final Channel channel = connection.createChannel();
            //创建队列
            channel.queueDeclare(QUEUE_NAME, false, false, false, null);
            //将队列绑定到交换机上
            channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "");
            //创建消费者对象
            DefaultConsumer consumer = new DefaultConsumer(channel) {
                @Override
                public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                    //消息消费者获取消息
                    String message = new String(body, StandardCharsets.UTF_8);
                   System.out.println("【Consumer01】receive message: " + message);
                }
            };
            //监听消息队列
            channel.basicConsume(QUEUE_NAME, true, consumer);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

消费者2

public class Consumer02 {
    private static final String QUEUE_NAME = "fanout_exchange_queue02";
    private static final String EXCHANGE_NAME = "fanout_exchange";
    public static void main(String[] args) {
        //获取MQ连接对象
        Connection connection = MQConnecitonUtils.getConnection();
        try {
            //创建消息通道对象
            final Channel channel = connection.createChannel();
            //创建队列
            channel.queueDeclare(QUEUE_NAME, false, false, false, null);
            //将队列绑定到交换机上
            channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, "");
            //创建消费者对象
            DefaultConsumer consumer = new DefaultConsumer(channel) {
                @Override
                public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                    //消息消费者获取消息
                    String message = new String(body, StandardCharsets.UTF_8);
                     System.out.println("【Consumer02】receive message: " + message);
                }
            };
            //监听消息队列
            channel.basicConsume(QUEUE_NAME, true, consumer);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

因为消费者1和消费者2分别绑定了队列fanout_exchange_queue01和fanout_exchange_queue02,而且这两个队列都给绑定到了交换机fanout_exchange上面,所有两个消费者都能够接受到此消息。

2.Direct(直连交换机)

在这里插入图片描述

  • 生产者生产消息的时候需要执行Routing Key路由键
  • 队列绑定交换机的时候需要指定Binding Key,只有路由键与绑定键相同的话,才能将消息发送到绑定这个队列的消费者,如果没有相同的,则该消息会被丢弃。

生产者

public class Producer {
    private static final String EXCHANGE_NAME = "direct_exchange";
    //交换机类型:direct
    private static final String EXCHANGE_TYPE = "direct";
    //路由键
    private static final String EXCHANGE_ROUTE_KEY = "user.add";

    public static void main(String[] args) throws IOException, TimeoutException {
        //获取MQ连接
        Connection connection = MQConnectionUtils.getConnection();
        //从连接中获取Channel通道对象
        Channel channel = null;
        try {
            channel = connection.createChannel();
            //创建交换机对象
            channel.exchangeDeclare(EXCHANGE_NAME, EXCHANGE_TYPE);
            //发送消息到交换机exchange上
            String msg = "hello direct exchange!!!";
            //指定routing key为info
            channel.basicPublish(EXCHANGE_NAME, EXCHANGE_ROUTE_KEY, null, msg.getBytes());
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (null != channel) {
                try {
                    channel.close();
                } catch (IOException | TimeoutException e) {
                    e.printStackTrace();
                }
            }
            if (null != connection) {
                try {
                    connection.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

消费者1

public class Consumer01 {

    private static final String QUEUE_NAME = "direct_exchange_queue01";
    private static final String EXCHANGE_NAME = "direct_exchange";
    //binding key
    private static final String EXCHANGE_ROUTE_KEY = "user.delete";

    public static void main(String[] args) {
        //获取MQ连接对象
        Connection connection = null;
        try {
            connection = MQConnectionUtils.getConnection();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (TimeoutException e) {
            e.printStackTrace();
        }
        try {
            //创建消息通道对象
            final Channel channel = connection.createChannel();
            //创建队列
            channel.queueDeclare(QUEUE_NAME, false, false, false, null);
            //将队列绑定到交换机上,并且指定routing_key
            channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, EXCHANGE_ROUTE_KEY);

            channel.basicQos(1);

            //创建消费者对象
            DefaultConsumer consumer = new DefaultConsumer(channel) {
                @Override
                public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                    //消息消费者获取消息
                    String message = new String(body, StandardCharsets.UTF_8);
                    System.out.println("【Consumer01】receive message: " + message);
                }
            };
            //监听消息队列
            channel.basicConsume(QUEUE_NAME, true, consumer);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

消费者2

public class Consumer02 {
    private static final String QUEUE_NAME = "direct_exchange_queue02";
    private static final String EXCHANGE_NAME = "direct_exchange";
    //binding key
    private static final String EXCHANGE_ROUTE_KEY01 = "user.add";
    private static final String EXCHANGE_ROUTE_KEY02 = "user.delete";

    public static void main(String[] args) {
        //获取MQ连接对象
        Connection connection = null;
        try {
            connection = MQConnectionUtils.getConnection();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (TimeoutException e) {
            e.printStackTrace();
        }
        try {
            //创建消息通道对象
            final Channel channel = connection.createChannel();
            //创建队列
            channel.queueDeclare(QUEUE_NAME, false, false, false, null);

            //将队列绑定到交换机上,并且指定routing_key
            channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, EXCHANGE_ROUTE_KEY01);
            channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, EXCHANGE_ROUTE_KEY02);

            //创建消费者对象
            DefaultConsumer consumer = new DefaultConsumer(channel) {
                @Override
                public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                    //消息消费者获取消息
                    String message = new String(body, StandardCharsets.UTF_8);
                    System.out.println("【Consumer02】receive message: " + message);
                }
            };
            //监听消息队列
            channel.basicConsume(QUEUE_NAME, true, consumer);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

因为生产者生产者消息指定的路由键为user.add,而消费者1绑定的队列对应的绑定键为user.delete,显然消费者1接受不了;而消费者2指定的绑定键位user.add和user.delete,显然消费者2就能成功消费此消息。

3.Topic(通配符交换机)

在这里插入图片描述

  • 生产者发送消息的时候需要指定Route Key,同时绑定Exchange与Queue的时候也需要指定Binding Key。
  • 如果Exchange没有发现能够与RouteKey模糊匹配的队列Queue,则会抛弃此消息。

“ # ” 表示0个或多个关键字, “ * ” 表示匹配一个关键字

生产者

public class Producer {
    private static final String EXCHANGE_NAME = "topic_exchange";
    //交换机类型:direct
    private static final String EXCHANGE_TYPE = "topic";
    //路由键
    private static final String EXCHANGE_ROUTE_KEY = "user.add.submit";
    public static void main(String[] args) throws IOException, TimeoutException {
        //获取MQ连接
        Connection connection = MQConnectionUtils.getConnection();
        //从连接中获取Channel通道对象
        Channel channel = null;
        try {
            channel = connection.createChannel();
            //创建交换机对象
            channel.exchangeDeclare(EXCHANGE_NAME, EXCHANGE_TYPE);
            //发送消息到交换机exchange上
            String msg = "hello topic exchange!!!";
            //指定routing key为info
            channel.basicPublish(EXCHANGE_NAME, EXCHANGE_ROUTE_KEY, null, msg.getBytes());
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            if (null != channel) {
                try {
                    channel.close();
                } catch (IOException | TimeoutException e) {
                    e.printStackTrace();
                }
            }
            if (null != connection) {
                try {
                    connection.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

消费者1

public class Consumer1 {
    private static final String QUEUE_NAME = "direct_exchange_queue01";
    private static final String EXCHANGE_NAME = "topic_exchange";
    //binding key
    private static final String EXCHANGE_ROUTE_KEY = "user.#";

    public static void main(String[] args) throws IOException, TimeoutException {
        //获取MQ连接对象
        Connection connection = MQConnectionUtils.getConnection();
        try {
            //创建消息通道对象
            final Channel channel = connection.createChannel();
            //创建队列
            channel.queueDeclare(QUEUE_NAME, false, false, false, null);
            //将队列绑定到交换机上,并且指定routing_key
            channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, EXCHANGE_ROUTE_KEY);
            channel.basicQos(1);
            //创建消费者对象
            DefaultConsumer consumer = new DefaultConsumer(channel) {
                @Override
                public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                    //消息消费者获取消息
                    String message = new String(body, StandardCharsets.UTF_8);
                    System.out.println("【Consumer01】receive message: " + message);
                }
            };
            //监听消息队列
            channel.basicConsume(QUEUE_NAME, true, consumer);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

消费者2

public class Consumer2 {
    private static final String QUEUE_NAME = "direct_exchange_queue02";
    private static final String EXCHANGE_NAME = "topic_exchange";
    //binding key
    private static final String EXCHANGE_ROUTE_KEY01 = "user.*";

    public static void main(String[] args) throws IOException, TimeoutException {
        //获取MQ连接对象
        Connection connection = MQConnectionUtils.getConnection();
        try {
            //创建消息通道对象
            final Channel channel = connection.createChannel();
            //创建队列
            channel.queueDeclare(QUEUE_NAME, false, false, false, null);
            //将队列绑定到交换机上,并且指定routing_key
            channel.queueBind(QUEUE_NAME, EXCHANGE_NAME, EXCHANGE_ROUTE_KEY01);
            //创建消费者对象
            DefaultConsumer consumer = new DefaultConsumer(channel) {
                @Override
                public void handleDelivery(String consumerTag, Envelope envelope, AMQP.BasicProperties properties, byte[] body) throws IOException {
                    //消息消费者获取消息
                    String message = new String(body, StandardCharsets.UTF_8);
                    System.out.println("【Consumer02】receive message: " + message);
                }
            };
            //监听消息队列
            channel.basicConsume(QUEUE_NAME, true, consumer);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

因为生产者发送消息的时候指定了Routing Key为user.add.submit,而消费者1所在的队列Binding Key为user.#,#能够匹配一个或多个,所以消费者1能够接受到这个消息;但是消费者2指定的Binding Key为user.* ,而“*”只能匹配一个,所有并不能够匹配到user.add.submit这个路由键,所以消费者2不能消费次消息。

五、RabbitMQ保证消息不丢失

RabbitMQ一般情况下消息不会发生丢失的问题,但是不能排除意外,为了保证我们自己的系统高可用,我们必须做出更好完善的措施,保证系统的稳定性。

1.消息持久化

RabbitMQ的消息默认放在内存上面,如果不特别声明设置,消息不会持久化到磁盘上,如果节点重启或者宕机,就会导致消息丢失。

  1. Exchange持久化:channel.exchangeDeclare(exchangeName, “direct/topic/header/fanout”, true);即在声明的时候讲durable字段设置为true即可。
  2. Quque持久化:queue的持久化是通过durable=true来实现的。
  3. Message消息持久化:发送消息设置发送模式deliveryMode=2,代表持久化消息。

2.ACK确认机制(异步)

多个消费者者同时受到消息,比如消息收到一般的时候,一个消费者死掉了(逻辑复杂时间太长,超时或消费被停机或网络断开连接),如何保证消息不丢失

使用Message acknowledgment机制,消费者消费完成要通知服务端,服务端才把消息从内存删除。

举例
①生产者发生消息丢失:

//设置消息发送确认--回调 开关
connectionFactory.setPublisherConfims(true);

//同时启动一个监听(生产者监听确认):监听生产者是否消息发送成功。
template.setConfirmCallback(confirmCallback());

②消费者信息丢失

消费者获取到消息之后,没有来得及处理完毕,自己直接宕机了,因为消息这默认采用自动ack,此时RabbitMQ的自动ack机制会通知MQ Server这条消息已经处理好了,此时消息就丢了,并不是预期的。

采用手动ACK机制来解决这个问题,消费端处理完逻辑之后在通知MQ Server,这样消费者没处理完消息不会发送ack,如果在消费者拿到消息,没来得及处理的情况下自己挂了,此时MQ集群会自动感知到,它会自觉的重发消息给其他的消费者服务实例。

# 需要两步操作
1、消费者监听设置手动ack
this.channel.basicConsume(queue, false, consumerTag, this);

2、业务完成后手动ack
context.getChannel().basicAck(deliveryTag, false);

3.设置集群镜像模式

RabbitMQ三种部署模式:

  1. 单节点模式:最简单的情况,节点挂了,消息就不能用了
  2. 普通模式:默认的集群模式,某个节点挂了,该节点上的消息不能用,有影响的业务瘫痪,只能等待节点恢复重启可用(消息持久化的)
  3. 镜像模式:把需要的队列做成镜像队列,存在于多个节点,输入RabbitMQ的HA方案

三种HA策略模式:

  • 同步所有的
  • 同步最多的N个机器
  • 只同步符合制定名称的nodes

4.消息补偿机制

持久化消息,保存到硬盘中,当前队列节点挂了,存储节点磁盘又坏了,消息丢失了,怎么办?

产线网络环境太复杂,所以不知数太多,消息补偿机制需要建立在消息要写入DB日志,发送日志,节后日志,两者的状态必须记录
然后根据DB日志记录check消息发送消费是否成功,不成功,进行消息补偿措施,重新发送消息处理

5.路由保障(解决交换机消息丢失问题)

路由保障的出现就是为了解决路由信息不匹配的情况 ,当消息没有成功通过路由键路由到队列中时,那么消息就会发生丢失的情况,主要有两种方式失败通知备用交换机

失败通知
这时候就需要有一个失败通知(mandatory+ReturnListener)。

//路由失败通知
template.setMandatory(true);
//回调
template.setReturnListener(returnCallback());

备用交换机
在路由的时候,有可能是路由键路由的范围不够广。

所以在创建交换机的时候可以再创建一个其他路由键的交换机(比如Fanout广播式交换机),以此来确保消息可以路由到另一个队列中,从而保证消息不会丢失

6.生产者生产消息到RabbitMQ Server可靠性保证?

  1. AMQP协议提供的一个事务机制(用的比较少)
  2. 发送方确认机制

首先生产者通过调用channel.confirmSelect方法将信道设置为confirm模式,一旦
信道进入confirm模式,所有在该信道上面发布的小都会被指派一个唯一的ID(从1开始),一旦消息被投递到所有匹配的队列之后,RabbitMQ就会发送一个确认给生产者(包含消息的唯一deliveryTag和multiple参数),这就使得生产者知晓消息已经正确到达了目的地了。

其中Confirm模式有三种方式实现

  • 串行confirm模式:生产者每发送一条消息后,调用waitForConfirms()方法,等待broker端confirm,如果服务器端返回false或者在超时时间内未返回,客户端进行消息重传。

  • 批量confirm模式:生产者每发送一批消息后,调用waitForConfirms()方法,等待broker端confirm。

  • 异步confirm模式:提供一个回调方法,broker confirm了一条或者多条消息后producer端会回调这个方法。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值