RocketMQ 入门篇-基础介绍

简介

阿里巴巴基于Java语言开发的分布式消息中间件。RocketMQ是Mateq3.0之后的开源版本。Metaq最早源于Kafka,早期借鉴了Kafka很多优秀的设计。

RocketMQ的使用场景

应用解耦系统的耦合性越高,容错性就越低。以电商应用为例,用户创建订单后,如果耦合调用库存系统、物流系统、支付系统,任何一个子系统出了故障或者因为升级等原因暂时不可用,都会造成下单操作异常,影响用户使用体验。

流量削峰应用系统如果遇到系统请求流量的瞬间猛增,有可能会将系统压垮。有了消息队列可以将大量请求缓存起来,分散到很长一段时间处理,这样可以大大提到系统的稳定性和用户体验。思考:RocketMQ是如何分撒请求的?

举例:业务系统正常时段的QPS如果是1000,流量最高峰是10000,为了应对流量高峰配置高性能的服务器显然不划算,这时可以使用消息队列对峰值流量削峰。

数据分发:通过消息队列可以让数据在多个系统之间进行流通。数据的产生方不需要关心谁来使用数据,只需要将数据发送到消息队列,数据使用方直接在消息队列中直接获取数据即可。

RocketMQ的角色介绍

Producer:        消息的发送者;举例:发信者
Consumer:      消息接收者;举例:收信者
Broker:            暂存和传输消息;举例:邮局
NameServer:  管理Broker 统计borker的各种元数据信息 ;举例:邮局的管理机构
Topic:              逻辑上概念:区分消息的种类,一个发送者可以发送消息给一个或者多个Topic;                            一个消息的接收者可以订阅一个或者多个Topic消息
Message Queue:相当于是Topic的分区,用于并行发送和接收消息。 比如:一个主题分为三个分区,就可以将一个主题存放在三台服务器中。

NameServer是无状态的

  • NameServer:是一个几乎无状态节点(启动即用,不启动就不用),节点之间无任何信息同步,可集群部署。
  • Broker  :部署相对复杂,Broker分为Master与Slave,BrokerName相同即为一组(主从结构),不同的BrokerId来定义,BrokerId为0表示Master,非0表示Slave。Master也可以部署多个。每个Broker与NameServer集群中的所有节点建立长连接,定时注册Topic信息到所有NameServer。 注意:当前RocketMQ版本在部署架构上支持一Master多Slave,但只有BrokerId=1的从服务器才会参与消息的读负载。
  • Producer:  与NameServer集群中的其中一个节点(随机选择)建立长连接,定期从NameServer获取Topic路由信息,并向提供Topic 服务的Master建立长连接,且定时向Master发送心跳。Producer完全无状态,可集群部署。
  • Consumer:与NameServer集群中的其中一个节点(随机选择)建立长连接,定期从NameServer获取Topic路由信息,并向提供Topic服务的Master、Slave建立长连接,且定时向Master、Slave发送心跳。Consumer既可以从Master订阅消息,也可以从Slave订阅消息,
    消费者在向Master拉取消息时,Master服务器会根据拉取偏移量与最大偏移量的距离(判断是否读老消息,产生读I/O),以及从服务器是否可读等因素建议下一次是从Master还是Slave拉取。 思考:读负载均衡是如何实现的?

执行流程:
1. 启动NameServer,NameServer起来后监听端口,等待Broker、Producer、Consumer连上来,相当于一个路由控制中心。
2. Broker启动,跟所有的NameServer保持长连接,定时发送心跳包。心跳包中包含当前Broker信息(IP+端口等)以及存储所有Topic信息。注册成功后,NameServer集群就知道了:哪些Broker存放了哪些Topic。
3. 收发消息前,先创建Topic,创建Topic时需要指定该Topic要存储在哪些Broker上,也可以在发送消息时自动创建Topic。
4. Producer发送消息,启动时先跟NameServer集群中的其中一台建立长连接,并从NameServer中获取当前发送的Topic存在哪些Broker上,轮询从队列列表中选择一个队列(message Queue 相当于topic的分区 ),然后与队列所在的Broker建立长连接从而向Broker发消息。
5. Consumer跟Producer类似,跟其中一台NameServer建立长连接,获取当前订阅Topic存在哪些Broker上,然后直接跟Broker建立连接通道,开始消费消息。

RocketMQ特性

  1. 订阅与发布:消息的发布是指某个生产者向某个topic发送消息;消息的订阅是指某个消费者关注了某个topic中带有某些tag的消息。
  2. 消息顺序: 消息有序指的是一类消息消费时,能按照发送的顺序来消费。
  3. 消息过滤:RocketMQ的消费者可以根据Tag(主要是根据Tag的hash值)/ SQL92标准 进行消息过滤,也支持自定义属性过滤。消息过滤目前是在Broker端实现(Broker端主要依靠众多的Filter实现)。各种消息过滤器机制,例如SQL和Tag
  4. 消息可靠性:  主要是通过 将消息持久化:刷盘,和集群化:复制 来实现的。
  5. 至少一次:指每个消息必须投递一次。Consumer先Pull消息到本地,消费完成后,才向服务器返回ack,如果没有消费一定不会ack消息,所以RocketMQ可以很好的支持此特性。
  6. 回溯消费:回溯消费是指Consumer已经消费成功的消息,由于业务上需求需要重新消费,要支持此功能, Broker在向Consumer投递成功消息后,消息仍然需要保留。并且重新消费一般是按照时间维度,例如:由于Consumer系统故障,恢复后需要重新消费1小时前的数据,那么Broker要提供一种机制,可以按 照时间维度来回退消费进度。RocketMQ支持按照时间回溯消费,时间维度精确到毫秒。

  7. 事务消息:是指应用本地事务和发送消息操作可以被定义到全局事务中,要么同时成功,要么同时失败。
  8. 定时消息:是指消息发送到broker后,不会立即被消费,等待特定时间投递给真正topic。定时消息会暂存在名为SCHEDULE_TOPIC_XXXX的topic中
  9. 消息重试:消费者消费消息失败后,要提供一种重试机制,令消息再消费一次。 
  10. 消息重投: 生产者在发送消息时,当发生以下情况可以设置消息重投: 1.同步消息失败会重投     2.异步消息有重试     3.oneway没有任何保证   ,其实这是保证了消息至少一次特性

  11. 流量控制:生产者流控,因为broker处理能力达到瓶颈;消费者流控,因为消费能力达到瓶颈。   

  12. 死信队列: 死信队列用于:处理无法被正常消费的消息。死信消息(Dead-Letter Message), 死信队列(Dead-Letter Queue),可通过控制台手动消费。

消费模式Push or Pull

RocketMQ消息订阅有两种模式,

  • 一种是Push模式(MQPushConsumer),即MQServer主动向消费端推送;
  • 一种是Pull模式 (MQPullConsumer), 即消费端在需要时,主动到MQ Server拉取。

但在具体实现时,Push和Pull模式本质都是采用消费端主动拉取的方式,即consumer轮询从broker拉取消息。

Push模式特点:

好处就是实时性高。不好处在于消费端的处理能力有限,当瞬间推送很多消息给消费端时,容易造成消费端的消息积压,严重时会压垮客户端。

Pull模式

好处就是主动权掌握在消费端自己手中,根据自己的处理能力量力而行。缺点就是控制Pull的频率较难。定时间隔太久担心影响时效性,间隔太短担心做太多“无用功”浪费资源。比较折中的办法就是长轮询 

Push模式与Pull模式的区别:

Push方式里,consumer把长轮询的动作封装了,并注册MessageListener监听器,取到消息后,唤醒MessageListener的consumeMessage()来消费,对用户而言,感觉消息是被推送过来的。
Pull方式里,取消息的过程需要用户自己主动调用,首先通过打算消费的Topic拿到MessageQueue的集合,遍历MessageQueue集合,然后针对每个MessageQueue批量取消息,一次取完后,记录该队列下一次要取的开始offset,直到取完了,再换另一个MessageQueue。

RocketMQ中的角色及相关术语

  • 消息模型(Message Model):

RocketMQ主要由 Producer、Broker、Consumer 三部分组成,其中Producer 负责生产消息,Consumer 负责消费消息,Broker 负责存储消息。Broker 在实际部署过程中对应一台服务器,
每个Broker 可以存储多个Topic的消息,每个Topic的消息也可以分片存储于不同的 Broker。Message Queue 用于存储消息的物理地址,每个Topic中的消息地址存储于多个 Message Queue 中。

  • Broker :消息中转角色,负责存储消息,转发消息,一般也称为 Server。在 JMS 规范中称为Provider。
  • Producer :  消息生产者,负责产生消息,一般由业务系统负责产生消息。
  • Consumer: 消息消费者,负责消费消息,一般是后台系统负责异步消费。
  • PushConsumer

        Consumer消费的一种类型,该模式下Broker收到数据后会主动推送给消费端。应用通常向
       Consumer对象注册一个Listener接口,一旦收到消息,Consumer对象立刻回调Listener接口方法。该消费模式一般实时性较高。

  • PullConsumer

       Consumer消费的一种类型,应用通常主动调用Consumer的拉消息方法从Broker服务器拉消息、 主动权由应用控制。一旦获取了批量消息,应用就会启动消费过程。

  • ProducerGroup:

       同一类Producer的集合,这类Producer发送同一类消息且发送逻辑一致。如果发送的是事务消息且原始生产者在发送之后崩溃,则Broker服务器会联系同一生产者组的其他生产者实例以提交或回溯消费。

  • ConsumerGroup:

      同一类Consumer的集合,这类Consumer通常消费同一类消息且消费逻辑一致。消费者组使得在消息消费方面,实现负载均衡和容错的目标变得非常容易。要注意的是,消费者组的消费者实例必须订阅完全相同的Topic。

      RocketMQ 支持两种消息模式:集群消费(Clustering)和广播消费(Broadcasting)

                广播消费:  消息也会 被 Consumer Group 中的每个 Consumer 都消费一次,  在 JMS 规范中,相当于 JMS Topic( publish/subscribe )模型

                集群消费: 一个 Consumer Group 中的 Consumer 实例平均分摊消费消息。例如某个 Topic 有 9 条消息,其 中一个 Consumer Group 有 3 个实例(可能是 3 个进程,或者3台机器),每个实例只消费其中的 3条消息。

  • 顺序消息:  消费消息的顺序要同发送消息的顺序一致,在RocketMQ 中主要指的是局部顺序,即一类消息为满足顺序性,必须Producer单线程顺序发送,且发送到同一个队列,这样Consumer 就可以按照Producer发送的顺序去消费消息。

普通顺序消息

顺序消息的一种,正常情况下可以保证完全的顺序消息,但是一旦发生通信异常,Broker 重启,
由于队列总数发生发化,哈希取模后定位的队列会发化,产生短暂的消息顺序不一致。 如果业务能容忍在集群异常情况(如某个Broker 宕机或者重启)下,消息短暂的乱序,使用普通顺序方式比较合适。

严格顺序消息

顺序消息的一种,无论正常异常情况都能保证顺序,但是牺牲了分布式 Failover特性,即Broker集群中只要有一台机器不可用,则整个集群都不可用,服务可用性大大降低。

如果服务器部署为同步双写模式,此缺陷可通过备机自动切换为主避免,不过仍然会存在几分钟的服务不可用。(依赖同步双写,主备自动切换,自动切换功能目前还未实现)

  • Message Queue: 在 RocketMQ 中,所有消息队列都是持久化的,长度无限的数据结构,所谓长度无限是指队列中的每个存储单元都是定长,访问其中的存储单元使用Offset来访问, offset 就是下标。
  •  标签(Tag): 为消息设置的标志,用于同一主题下区分不同类型的消息。 

RocketMQ API

DefaultMQProducer: 生产者的默认实现: 生产消息分同步发送和异步发送 

public class MyProduct {
    public static void main(String[] args) throws MQClientException, RemotingException, InterruptedException, MQBrokerException {
        // 在实例化生产者的同时,指定生产组名称
        DefaultMQProducer defaultMQProducer = new DefaultMQProducer("otter_metaq_group");
        // 指定NameServer 的地址
        defaultMQProducer.setNamesrvAddr("Rocketmq1.dev.dc.com:9876");
        // 启动生产者
        defaultMQProducer.start();
        // 创建消息,第一个参数是主题名称,第二个参数是tag标签 第三个参数是消息内容
        Message msg=new Message(
                "otter_test_myProduct",
                "test",
                "this is test prodcut".getBytes(StandardCharsets.UTF_8)
        );
        // 发送消息 同步发送
        final SendResult result = defaultMQProducer.send(msg);
        System.out.println(result);
        // 关闭生产者
        defaultMQProducer.shutdown();
    }
}
 
public class MyAsyncProducer {
    public static void main(String[] args) throws MQClientException, RemotingException, InterruptedException {
        // 在实例化生产者的同时,指定生产组名称
        DefaultMQProducer defaultMQProducer = new DefaultMQProducer("otter_metaq_group");
        // 指定NameServer 的地址
        defaultMQProducer.setNamesrvAddr("Rocketmq1.dev.pdc.com:9876");
        // 启动生产者
        defaultMQProducer.start();
        // 创建消息,第一个参数是主题名称,第二个参数是tag标签 第三个参数是消息内容
        for (int i = 0; i < 100; i++) {
            Message msg=new Message(
                    "otter_test_myProduct",
                    "test",
                    ("this is test myAsyncProducer i=:"+i).getBytes(StandardCharsets.UTF_8)
            );
			// 异步发送消息
            defaultMQProducer.send(msg, new SendCallback() {
                @Override
                public void onSuccess(SendResult sendResult) {
                    System.out.println("发送成功:" + sendResult);
                }

                @Override
                public void onException(Throwable throwable) {
                    System.out.println("发送失败:" + throwable.getMessage());
                }
            });
        }
        // 由于是异步发送消息,上面循环结束之后,消息可能还没收到broker的响应
        // 如果不sleep一会儿,就报错
        Thread.sleep(10_000);
        // 关闭生产者
        defaultMQProducer.shutdown();
    }
}
 
// 源码解析
public class SendResult {
    private SendStatus sendStatus; //发送状态结果: 是一个枚举类型
    private String msgId;
    private MessageQueue messageQueue;
    private long queueOffset;
}
// 发送状态结果
public enum SendStatus {
    SEND_OK,
    FLUSH_DISK_TIMEOUT,
    FLUSH_SLAVE_TIMEOUT,
    SLAVE_NOT_AVAILABLE;
    private SendStatus() {
    }
}

DefaultMQConsumer:消费者的默认实现: 消息的拉取和消息的推送

/**
 * @version 1.0
 * @description: 消息消费-主动拉取 :注意 主动拉取需要自己定义:拉取的频率
 * @date 2021-7-9 10:12
 */
public class MyPullConsumer {
    public static void main(String[] args) throws MQClientException, RemotingException, InterruptedException, MQBrokerException, UnsupportedEncodingException {
        // 1.创建拉取模式的消费者
        DefaultMQPullConsumer pullConsumer = new DefaultMQPullConsumer("comsumer_demo_01");
        // 2.指定链接的nameServer
        pullConsumer.setNamesrvAddr("Rocketmq1.dev.dc.com:9876");
        // 3.启动消费者
        pullConsumer.start();
        // 4.获取指定主题所有的消息队列集合
        Set<MessageQueue> msgQueue = pullConsumer.fetchSubscribeMessageQueues(
                "otter_test_myProduct");
        // 5.遍历该主题中的各个消息队列,进行消费
        for (MessageQueue messageQueue : msgQueue) {
            // 第一个参数是MessageQueue对象,代表了当前主题的一个消息队列
            // 第二个参数是一个表达式,对接收的消息按照tag进行过滤
            // 支持"tag1 || tag2 || tag3"或者 "*"类型的写法;null或者"*"表示不对消息进行tag过滤
            // 第三个参数是消息的偏移量,从这里开始消费
            // 第四个参数表示每次最多拉取多少条消息
            PullResult pullResult = pullConsumer.pull(
                    messageQueue,
                    "*",
                    0,
                    10);
            // 6.处理拉取结果
            final List<MessageExt> msgFoundList = pullResult.getMsgFoundList();
            if (msgFoundList == null) continue;
            for (MessageExt messageExt : msgFoundList) {
                System.out.println(messageExt);
                System.out.println(new String(messageExt.getBody(), "utf-8"));
            }
        }
        // 7.关闭消费者
        pullConsumer.shutdown();
    }
}

/**
 * @version 1.0
 * @description: 消费消息-推送模式
 * @date 2021-7-9 11:01
 */
public class MyPushConsumer {
    public static void main(String[] args) throws MQClientException {
        // 实例化推送消息消费者的对象,同时指定消费组名称
        DefaultMQPushConsumer pushComsumer = new DefaultMQPushConsumer("comsumer_demo_02");
        // 指定nameserver的地址
        pushComsumer.setNamesrvAddr("Rocketmq1.dev.dc.com:9876");
        // 订阅主题
        pushComsumer.subscribe("otter_test_myProduct","*");
        // 添加监听器,一但有消息推送过来,就进行消费
        pushComsumer.setMessageListener(new MessageListenerConcurrently() {
            // 编写消息消费逻辑 consumeMessage
            @Override
            public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgList, ConsumeConcurrentlyContext context) {
                // ConsumeConcurrentlyContext:当前消费者上下文对像, 该对象可以设置很多消息消费特性
                // 如获取当前消费的MQ队列,设置延时消息
                final MessageQueue messageQueue = context.getMessageQueue();
                // 遍历消息内容
                for (MessageExt msg : msgList) {
                    try {
                        System.out.println(new String(msg.getBody(), "utf-8"));
                    } catch (UnsupportedEncodingException e) {
                        e.printStackTrace();
                    }
                }
                // 返回消息消费状态
                return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
            }
        });
        // 启动消费者
        pushComsumer.start();
    }
}

//------------------------------------源码解析--------------------------
/**
 * 拉消息返回结果

 * @since 2013-7-24
 */
public class PullResult {
    private final PullStatus pullStatus;	// 拉取结果状态,是一个枚举
    private final long nextBeginOffset;
    private final long minOffset;
    private final long maxOffset;
    private List<MessageExt> msgFoundList; // 存放了消息内容
}
// 拉取结果状态
public enum PullStatus {
    FOUND,
    NO_NEW_MSG,
    NO_MATCHED_MSG,
    OFFSET_ILLEGAL;
    private PullStatus() {  }
}

基于拉取模式的消费者编写—可用作模板

import com.alibaba.rocketmq.client.consumer.DefaultMQPushConsumer;
import com.alibaba.rocketmq.client.consumer.listener.ConsumeConcurrentlyContext;
import com.alibaba.rocketmq.client.consumer.listener.ConsumeConcurrentlyStatus;
import com.alibaba.rocketmq.client.consumer.listener.MessageListenerConcurrently;
import com.alibaba.rocketmq.client.exception.MQClientException;
import com.alibaba.rocketmq.common.message.MessageExt;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import java.io.Serializable;
import java.util.List;

/**
 * BaseConsumer,订阅消息
 */
public abstract class BaseConsumer implements MessageListenerConcurrently {

    private static final Logger logger = LoggerFactory.getLogger(BaseConsumer.class);

    protected DefaultMQPushConsumer consumer;
    protected String nameServer;
    protected int minConsumeThread = 5;
    protected int maxConsumeThread = 5;
    protected String group;
    // 定时消息相关
    // 线上环境:messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m
    // 30m 40m 50m 1h 2h 6h
    // 开发环境:messageDelayLevel=1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m
    // 30m 40m 50m 1h 2h 6h 12h 1d
    private static final int[] DELAY_LEVELS = new int[]{3, 5, 9, 14, 15, 16, 17, 18, 19, 20, 21};
    protected int maxRetryCount = 10;

    /**
     * 初始化consumer
     */
    public void init() {
        if (StringUtils.isBlank(System.getProperty("public.rocketmq.domain.name"))) {
            System.setProperty("public.rocketmq.domain.name", nameServer);
        }
        consumer = new DefaultMQPushConsumer(getGroup());
        consumer.setNamesrvAddr(nameServer);
        consumer.setConsumeThreadMin(minConsumeThread);
        consumer.setConsumeThreadMax(maxConsumeThread);
        //可以不设置 设置后可以起多个 消费端
        consumer.setInstanceName(getInstanceName());
        try {
            //设置订阅的topic 设置订阅过滤表达式
            consumer.subscribe(getTopic(), getTags());
            consumer.registerMessageListener(this);
            consumer.start();
        } catch (MQClientException e) {
            logger.error("consumer start error!group={}", group, e);
        }
        logger.info("consumer start! group={}", getGroup());
    }

    /**
     * 销毁consumer
     */
    public void destroy() {
        if (consumer != null) {
            consumer.shutdown();
            logger.info("consumer shutdown! group={}", group);
        }
    }

    /**
     * 基类实现消息监听接口,加上打印metaq监控日志的方法
     */
    @Override
    public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs,
                                                    ConsumeConcurrentlyContext context) {
        long startTime = System.currentTimeMillis();
        if (msgs == null || msgs.size() < 1) {
            logger.error("receive empty msg!");
            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
        }
        final int reconsumeTimes = msgs.get(0).getReconsumeTimes();
        if (reconsumeTimes >= maxRetryCount) {
            logger.warn("reconsumeTimes >" + maxRetryCount + "msgs:" + msgs + "context:" + context);
            return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
        }
        context.setDelayLevelWhenNextConsume(getDelayLevelWhenNextConsume(reconsumeTimes));
        boolean ret = true;
        for (MessageExt message : msgs) {
            if (!doConsumeMessage(decodeMsg(message))) {
                ret = false;
            }
        }
        ConsumeConcurrentlyStatus status = ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
        if (!ret) {
            status = ConsumeConcurrentlyStatus.RECONSUME_LATER;
        }
//        logger.info("ConsumeConcurrentlyStatus:{}|cost:{}", status, System.currentTimeMillis() - startTime);
        return status;
    }

    /**
     * 根据重试次数设置重新消费延迟时间
     * 1s 10s 30s 2m 10m 30m 1h 2h 12h 1d
     *
     * @param reconsumeTimes 重试的次数
     * @return level级别
     */
    public int getDelayLevelWhenNextConsume(int reconsumeTimes) {
        if (reconsumeTimes >= DELAY_LEVELS.length) {
            return DELAY_LEVELS[DELAY_LEVELS.length - 1];
        }
        return DELAY_LEVELS[reconsumeTimes];
    }

    private Serializable decodeMsg(MessageExt msg) {
        if (msg == null) {
            return null;
        }
        //1.反序列化
        try {
            return HessianUtils.decode(msg.getBody());
        } catch (Exception e) {
            logger.warn("反序列化出错!" + e.getMessage(), e);
        }
        //反序列化异常的,直接转为String
        try {
            return new String(msg.getBody(), "UTF-8");
        } catch (Exception e) {
            logger.warn("decodeMsg msg.getBody() throw exception !", e);
        }
        return null;
    }

    public void setNameServer(String nameServer) {
        this.nameServer = nameServer;
    }

    public void setMinConsumeThread(int minConsumeThread) {
        this.minConsumeThread = minConsumeThread;
    }

    public void setMaxConsumeThread(int maxConsumeThread) {
        this.maxConsumeThread = maxConsumeThread;
    }

    public void setGroup(String group) {
        this.group = group;
    }

    public String getGroup() {
        return group + "_" + getTopic() + "_" + getTags();
    }

    public abstract String getTopic();

    public abstract String getTags();

    public abstract boolean doConsumeMessage(Serializable message);

    public String getInstanceName() {
        return "OtterBaseConsumer" + "_" + getTopic() + "_" + getTags();
    }

}

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值