关于Kafka的二三事

学习Kafka首先要了解Kafka是干什么的?

Kafka是一个分布式、支持分区、多副本的消息系统,最大特点是实时处理大量数据以满足各种需求场景。它可以用于日志收集、消息系统、用户活动跟踪、运营指标等。Kafka是用Scala语言编写的,于2010年贡献给了Apache基金会并成为顶级开源项目。

👆这是较为官方的解释,简单来说,Kafka是专门用来传递数据的消息系统

Kafka主要应用于:

  • 日志收集
  • 消息系统
  • 用户活动跟踪
  • 运营指标

学习一项新技术之前,先来了解一下它的专业术语吧。

Broker消息中间件处理节点,⼀个Kafka节点就是⼀个broker,⼀个或者多个Broker可以组成⼀个Kafka集群。
Topic主题,也可理解为一个类别,Kafka根据Topic对消息进行分类,所以发布到Kafka的数据都需要指明分类。
Producer生产者,向Broker中发送消息的客户端。
Consumer消费者,从Broker中读取数据的客户端。
ConsumerGroup消费组,每一名消费者属于一特定消费组,一条消息可以由多个不同的消费组消费,但每个消费组里只能有一名消费者消费信息。
Partition分区,实际存储消息的地方,一个主题会被划分为多个分区,但官方在逐渐弱化此概念。

 好了,对Kfaka有了基本认识后,让我们来看看Kafka的具体操作吧!

一、Kafka的安装及基本使用

1、创建容器

这里选择使用docker方式安装Kafka。在安装Kafka之前,还应安装zookeeper与jdk(这里不再赘诉)。

请务必使用free -h 查看此时内存空间是否充足(>600MB),若内存空间不够,kafka将会运行失败或消费信息时报错!!!

ZooKeeper主要服务于分布式系统,可以用ZooKeeper来做:统一配置管理、统一命名服务、分布式锁、集群管理。 使用分布式系统就无法避免对节点管理的问题(需要实时感知节点的状态、对节点进行统一管理等等),而由于这些问题处理起来可能相对麻烦和提高了系统的复杂性,ZooKeeper作为一个能够通用解决这些问题的中间件就应运而生了。

docker run --name some-zookeeper -dit -p 2181:2181 zookeeper

需要开放2181端口,稍等15s查看zookeeper是否启动成功。

 开放9092端口。

docker run -d --name=kafka2 \
 -p 9092:9092 \
 -e ALLOW_PLAINTEXT_LISTENER=yes \
 -e KAFKA_CFG_ZOOKEEPER_CONNECT=152.136.165.17:2181 \
 -e KAFKA_BROKER_ID=6 \
 -e KAFKA_NODE_ID=6  \
 -e KAFKA_ENABLE_KRAFT=false \
 -e KAFKA_HEAP_OPTS="-Xmx180m -Xms180m" \
 -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://152.136.165.17:9092 \
 -e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092  \
 -e BITNAMI_DEBUG=true  \
 bitnami/kafka

此时等待半分钟查看kafka是否成功启动!

参数释义:

  • e KAFKA_BROKER_ID=6 在kafka集群中,每个kafka都有一个BROKER_ID来区分自己
  • e KAFKA_CFG_ZOOKEEPER_CONNECT=152.136.165.17:2181 kafka 配置zookeeper的连接地址
  • e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://152.136.165.17:9092 把kafka的地址端口注册给zookeeper
  • e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 配置kafka的监听端口 (是容器内部的端口)
  • e KAFKA_HEAP_OPTS=“-Xmx180m -Xms180m” 设置kafka占用的内存

2、创建主题Topic

 首先进入到kafka容器中

docker exec -it kafka2 bash
cd /opt/bitnami/kafka/bin

创建名为“test”,分区为1,备份因子为1的topic


./kafka-topics.sh --create --topic test --bootstrap-server 152.136.165.17:9092 --replication-factor 1 --partitions 1

查看一下是否创建成功?

./kafka-topics.sh --bootstrap-server 152.136.165.17:9092 --list

如果显示“test”,则表示创建成功。

3、发送消息

既然创建好topic了,不妨向topic里发送一条消息。

 ./kafka-console-producer.sh --broker-list 152.136.165.17:9092 --topic test

点击回车后,黑窗口会显示 > 图标,现在可以输入想要发送的消息就可以了,每次回车都可以输入一行新信息,ctrl+c退出。

4、消费信息

方法一:从最新消息开始消费,也是默认消费方式。

./kafka-console-consumer.sh --bootstrap-server 152.136.165.17:9092 --topic test

方法二:从第一条消息开始消费。

./kafka-console-consumer.sh --bootstrap-server 152.136.165.17:9092 --from-beginning --topic test

注意:

  • 消息会被存储 存储指的是存储在磁盘中, 即便电脑关机重启, 让然让消费者去消费数据, 仍可以将关机前生产的数据 消费到.
  • 消息是顺序存储
  • 消息是有偏移量
  • 消费时可以指明偏移量进行消费

之前提到过消费组的概念,那如果一条消息想要被多个消费者消费应该怎么做呢?这里就涉及到了单播消息和多播消息的概念。

4.1 单播消息的实现

单播消息:一个消费组里只会有一个消费者能消费到某一个topic中的消息。可以创建多个消费者,这些消费者在同一个消费组中。

./kafka-console-consumer.sh --bootstrap-server 152.136.165.17:9092 --consumer-property group.id=testGroup --topic test
4.2 多播消息的实现

在多播模式下,可以使一条信息被多个消费者消费,需要让这些消费者处于不同的消费组里。

./kafka-console-consumer.sh --bootstrap-server 152.136.165.17:9092 --consumer-property group.id=testGroup1 --topic test
./kafka-console-consumer.sh --bootstrap-server 152.136.165.17:9092 --consumer-property group.id=testGroup2 --topic test

5、查看消费组信息

查看当前broker中有哪些消费组。

./kafka-consumer-groups.sh --bootstrap-server 152.136.165.17:9092 --list

查看某个消费组的详细信息。

./kafka-consumer-groups.sh --bootstrap-server 152.136.165.17:9092 --describe --group testGroup

  •  CURRENT-OFFSET :消费组当前以消费信息的偏移量。
  • LOG-END-OFFSET :主题对应分区消息的结束偏移量。
  • LAG :剩余未被消费的信息数量。

二、主题与分区的概念

 一个主题中的消息量是非常大的,因此可以通过分区的设置,来分布式(集群)存储这些消息。比如一个topic创建了 3 个分区。那么topic中的消息就会分别存放在这三个分区中。

查看当前topic中的分区信息。

./kafka-topics.sh --bootstrap-server 152.136.165.17:9092 --topic test1 --describe
  • replicas:当前副本存在的broker节点
  • leader:副本里的概念
    • 每个partition都有一个broker作为leader。
    • 消息发送方要把消息发给哪个broker?就看副本的leader是在哪个broker上面。副本里的leader专⻔用来接收消息。
    • 接收到消息,其他follower通过poll的方式来同步数据。
  • isr: 可以同步的broker节点和已同步的broker节点,存放在isr集合中。

通过分区的概念实现分布式存储与并行写。

三、Kafka集群及副本的概念

在Kafka集群中,使用一个zookeeper管理集群。在Kafka容器创建时,需要保证每个BrokerID都是不同的。

1、副本的概念

副本是对分区的备份。在集群中,不同的副本会被部署在不同的broker上。

进入到kafka2容器中, 进入到bin目录下, 执行如下命令

./kafka-topics.sh --create --topic my-replicated-topic --bootstrap-server 152.136.165.17:9092 --replication-factor 2 --partitions 2

./kafka-topics.sh --bootstrap-server 152.136.165.17:9092 --topic my-replicated-topic --describe
Topic: my-replicated-topic      TopicId: eJ0M58k5RR6MwImWeHBebQ PartitionCount: 2       ReplicationFactor: 2                                                 Configs:
        Topic: my-replicated-topic      Partition: 0    Leader: 3       Replicas: 3,2   Isr: 3,2
        Topic: my-replicated-topic      Partition: 1    Leader: 2       Replicas: 2,3   Isr: 2,3

通过kill掉leader后再查看主题情况

# kill掉leader
docker stop kafka3

# 查看topic情况
./kafka-topics.sh --bootstrap-server 152.136.165.17:9092 --topic my-replicated-topic --describe

##########
Topic: my-replicated-topic      TopicId: x61TtWHyTzCg1XlAcOQQ5w PartitionCount: 2       ReplicationFactor: 2    Configs: segment.bytes=1073741824
        Topic: my-replicated-topic      Partition: 0    Leader: 2       Replicas: 3,2   Isr: 2
        Topic: my-replicated-topic      Partition: 1    Leader: 2       Replicas: 2,3   Isr: 2

删除topic。

./kafka-topics.sh --bootstrap-server 152.136.165.17:9092 --delete --topic my-replicated-topic

2、broker、主题、分区、副本

  • kafka集群中由多个broker组成
  • 一个broker中存放一个topic的不同partition——副本

注意: 副本的数量不能超过集群节点的数量

向集群中的某个topic发送数据, 集群会首先计算出来这条数据归哪个patition存储, 确定了patition后, 存储到这个分区对应的leader中,随后,其他副本节点会将这条新的数据同步到自己的kafka副本中, 下面这张图演示的是, 3个broker, 一个topic1, topic1有2个分区, 3个副本

3、kafka集群消息的发送

创建topic

./kafka-topics.sh --create --topic my-replicated-topic --bootstrap-server 152.136.165.17:9092 --replication-factor 2 --partitions 2

发送数据

./kafka-console-producer.sh --broker-list 152.136.165.17:9092 --topic my-replicated-topic

4、kafka集群消息的消费

./kafka-console-consumer.sh --bootstrap-server 152.136.165.17:9092 --from-beginning --topic my-replicated-topic

5、关于分区消费组消费者的细节

./kafka-console-consumer.sh --bootstrap-server 152.136.165.17:9093 --from-beginning --topic my-replicated-topic --consumer-property group.id=testGroup1

./kafka-consumer-groups.sh --bootstrap-server 152.136.165.17:9092 --describe --group testGroup1

##运行结果
Consumer group 'testGroup1' has no active members.

GROUP           TOPIC               PARTITION  CURRENT-OFFSET  LOG-END-OFFSET  LAG             CONSUMER-ID     HOST            CLIENT-ID
testGroup1      my-replicated-topic 0          3               3               0               -               -               -
testGroup1      my-replicated-topic 1          0               0               0               -               -               -

 

图中Kafka集群有两个broker,每个broker中有多个partition。一个partition只能被一个消费组里的某一个消费者消费,从而保证消费顺序。Kafka只在partition的范围内保证消息消费的局部顺序性,不能在同一个topic中的多个partition中保证总的消费顺序性。一个消费者可以消费多个partition。

消费组中消费者的数量不能比一个topic中的partition数量多,否则多出来的消费者消费不到消息。

6、kafka集群创建topic中的分区和副本选择基本概念

7个broker组成的集群, 创建一个topic, 设置7个分区是最优选择, 如果选择5个分区, 也是可以的, 但是两台机器没有起到作用; 如果选择10个分区, 也是可以的, 但是会有三台机器会做双份工作, 有两个leader。

设置7个副本是最优选择, 如果选择5个副本, 也是可以的, 但是又两台机器没有起到备份作用; 如果选择10个副本呢? 是不可以的, 创建逻辑上就存在问题。所以副本数量不能超过broker数量!

四、Kafka的Java客户端-生产者

1、引入依赖

kafka的maven依赖需要和docker安装的kafka版本对应上。

    <dependencies>
        <dependency>
            <groupId>org.apache.kafka</groupId>
            <artifactId>kafka_2.12</artifactId>
            <version>3.5.0</version>
        </dependency>

        <!-- <https://mvnrepository.com/artifact/com.fasterxml.jackson.core/jackson-databind> -->
        <dependency>
            <groupId>com.fasterxml.jackson.core</groupId>
            <artifactId>jackson-databind</artifactId>
            <version>2.13.3</version>
        </dependency>

        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>1.18.24</version>
        </dependency>
    </dependencies>

2、生产者发送消息的基本实现

#### //消息的发送方
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;

import java.util.Properties;
import java.util.concurrent.ExecutionException;

public class MyProducer {

    private final static String TOPIC_NAME = "my-replicated-topic";

    public static void main(String[] args) throws ExecutionException, InterruptedException, JsonProcessingException {
        Properties props = new Properties();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "152.136.165.17:9092");
//把发送的key从字符串序列化为字节数组
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
//把发送消息value从字符串序列化为字节数组
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());

        Producer<String, String> producer = new KafkaProducer<>(props);

        Order order = new Order("123123", "订单12");

        ObjectMapper objectMapper = new ObjectMapper();
        ProducerRecord<String, String> producerRecord = new ProducerRecord<>(TOPIC_NAME, "订单", objectMapper.writeValueAsString(order));
        RecordMetadata metadata = producer.send(producerRecord).get();
//=====阻塞=======
        System.out.println("同步方式发送消息结果:" + "topic-" + metadata.topic() + "|partition-" + metadata.partition() + "|offset-" + metadata.offset());
    }
}

3、发送消息到指定分区上

ProducerRecord<String, String> producerRecord = new ProducerRecord<String, String>(TOPIC_NAME, 0 , "555", objectMapper.writeValueAsString(order));

4、未指定分区,则会通过业务key的hash运算,算出消息往哪个分区上发

//未指定发送分区,具体发送的分区计算公式:hash(key)%partitionNum
ProducerRecord<String, String> producerRecord = new ProducerRecord<String, String>(TOPIC_NAME, order.getOrderId().toString(), objectMapper.writeValueAsString(order));

5、同步发送

生产者同步发消息,在收到kafka的ack告知发送成功之前一直处于阻塞状态

//等待消息发送成功的同步阻塞方法
RecordMetadata metadata = producer.send(producerRecord).get();
System.out.println("同步方式发送消息结果:" + "topic-" +metadata.topic() + "|partition-"+ metadata.partition() + "|offset-" +metadata.offset());

6、异步发消息

生产者发消息,发送完后不用等待broker给回复,直接执行下面的业务逻辑。

可以提供callback,让broker异步的调用callback,告知生产者,消息发送的结果

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;

import java.util.Arrays;
import java.util.Properties;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.TimeUnit;

public class MyProducer {

    private final static String TOPIC_NAME = "my-replicated-topic";

    public static void main(String[] args) throws ExecutionException, InterruptedException, JsonProcessingException {
        Properties props = new Properties();
        props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "152.136.165.17:9092");
//把发送的key从字符串序列化为字节数组
        props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
//把发送消息value从字符串序列化为字节数组
        props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());

        Producer<String, String> producer = new KafkaProducer<>(props);

        Order order = new Order("123123", "订单12");

        ObjectMapper objectMapper = new ObjectMapper();
        ProducerRecord<String, String> producerRecord = new ProducerRecord<>(TOPIC_NAME, "订单", objectMapper.writeValueAsString(order));
        producer.send(producerRecord, new Callback() {
            @Override
            public void onCompletion(RecordMetadata metadata, Exception exception) {
                if (exception != null) {
                    System.err.println("发送消息失败:" + Arrays.toString(exception.getStackTrace()));
                }
                if (metadata != null) {
                    System.out.println("异步方式发送消息结果:" + "topic-" +metadata.topic() + "|partition-"+ metadata.partition() + "|offset-" + metadata.offset());
                }
            }
        });

        TimeUnit.SECONDS.sleep(5);

    }
}

7、关于生产者的ack参数配置

在同步发消息的场景下:生产者发到broker上后,ack会有 3 种不同的选择:

  • ( 1 )acks=0: 表示producer不需要等待任何broker确认收到消息的回复,就可以继续发送下一条消息。性能最高,但是最容易丢消息。
  • ( 2 )acks=1: 至少要等待leader已经成功将数据写入本地log,但是不需要等待所有follower是否成功写入。就可以继续发送下一条消息。这种情况下,如果follower没有成功备份数据,而此时leader又挂掉,则消息会丢失。
  • ( 3 )acks=-1或all: 需要等待 min.insync.replicas(默认为 1 ,推荐配置大于等于2) 这个参数配置的副本个数都成功写入日志,这种策略会保证只要有一个备份存活就不会丢失数据。这是最强的数据保证。一般除非是金融级别,或跟钱打交道的场景才会使用这种配置。

code:

props.put(ProducerConfig.ACKS_CONFIG, "1");

8、其他一些细节

  • 发送会默认会重试 3 次,每次间隔100ms
  • 发送的消息会先进入到本地缓冲区(32mb),kakfa会跑一个线程,该线程去缓冲区中取16k的数据,发送到kafka,如果到 10 毫秒数据没取满16k,也会发送一次。 (微批处理)

五、Java客户端-消费者

1、消费者消费消息的基本实现

import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.common.serialization.StringDeserializer;

import java.time.Duration;
import java.util.Arrays;
import java.util.Properties;

public class MyConsumer {
    private final static String TOPIC_NAME = "my-replicated-topic";
    private final static String CONSUMER_GROUP_NAME = "testtGroup";

    public static void main(String[] args) {
        Properties props = new Properties();
        props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "152.136.165.17:9092");
// 消费分组名
        props.put(ConsumerConfig.GROUP_ID_CONFIG, CONSUMER_GROUP_NAME);
        props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
        props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName());
//创建一个消费者的客户端
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
// 消费者订阅主题列表
        consumer.subscribe(Arrays.asList(TOPIC_NAME));

        while (true) {
            /*
             * poll() API 是拉取消息的⻓轮询
             */
            ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(1000));
            for (ConsumerRecord<String, String> record : records) {
                System.out.printf("收到消息:partition = %d,offset = %d, key =%s, value = %s%n", record.partition(), record.offset(), record.key(), record.value());
            }
        }
    }
}

2、自动提交offset

  • 设置自动提交参数 - 默认
// 是否自动提交offset,默认就是true
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "true");
// 自动提交offset的间隔时间
props.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000");

消费者poll到消息后默认情况下,会自动向broker的_consumer_offsets主题提交当前主题-分区消费的偏移量。

自动提交会丢消息: 因为如果消费者还没消费完poll下来的消息就自动提交了偏移量,那么此时消费者挂了,于是下一个消费者会从已提交的offset的下一个位置开始消费消息。之前未被消费的消息就丢失掉了。

./kafka-consumer-groups.sh --bootstrap-server 152.136.165.17:9092 --describe --group test

3、手动提交offset

当程序代码出现异常的时候, 出现异常的数据会因为没有提交偏移量, 在下一次的拉取中被重新拉取到处理

  • 设置手动提交参数
props.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, "false");

如果你在使用手动提交偏移量的方式进行消费,并且之前已经提交过偏移量了(也是之前的kafka的服务端的当前offset中已经有了偏移量了) ,那么下一次消费时会从已有的那次偏移量开始拉取数据, 从而造成数据的重复消费。

如果你在使用手动提交偏移量的方式进行消费,并且没有提交偏移量,则会根据消费配置 (默认的消费配置是从最新拉取, 也可以设定成从头拉取)进行拉取数据 这是因为Kafka在进行消费时会根据消费者组、分区、偏移量来判断哪些消息需要被消费,如果没有提交偏移量,那么Kafka就会认为你是一个新的消费者,新的消费者则会依据究竟是从头拉取还是从最新拉取来进行数据的消费

./kafka-consumer-groups.sh --bootstrap-server 152.136.165.17:9092 --describe --group testGroup2

当提交模式设置为手动提交,因为还没做过提交,所以kafka上没有offset值,所以依据属性auto.offset.reset,默认值 latest,它会让消费者从最后的offset开始消费;

在消费完消息后进行手动提交

  • 手动同步提交
            if (records.count() > 0 ) {
                //业务处理代码
// 手动同步提交offset,当前线程会阻塞直到offset提交成功
// 一般使用同步提交,因为提交之后一般也没有什么逻辑代码了
                consumer.commitSync();
            }
  • 手动异步提交
            if (records.count() > 0 ) {
                // 手动异步提交offset,当前线程提交offset不会阻塞,可以继续处理后面的程序逻辑

                //处理逻辑

                consumer.commitAsync((offsets, exception) -> {
                    if (exception != null) {
                        System.err.println("Commit failed for " + offsets);
                        System.err.println("Commit failed exception: " +exception.getStackTrace());
                    } else {
                        System.out.println("提交当前偏移");
                    }
                });
            }

4、消费者poll消息的过程(了解)

  • 消费者建立了与broker之间的⻓连接,开始poll消息。
  • 默认一次poll 500条消息
props.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500 );

可以根据消费速度的快慢来设置,因为如果两次poll的时间如果超出了30s的时间间隔,kafka会认为其消费能力过弱,将其踢出消费组。将分区分配给其他消费者

可以通过这个值进行设置:

props.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 30 * 1000 );

如果每隔1s内没有poll到任何消息,则继续去poll消息,循环往复,直到poll到消息。如果超出了1s,则此次⻓轮询结束。

ConsumerRecords<String, String> records =consumer.poll(Duration.ofMillis( 1000 ));

消费者发送心跳的时间间隔

props.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, 1000 );

kafka如果超过 10 秒没有收到消费者的心跳,则会把消费者踢出消费组,进行rebalance,把分区分配给其他消费者。

props.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 10 * 1000 );

5、新消费组的消费偏移量

当消费主题的是一个新的消费组,或者指定offset的消费方式,offset不存在,那么应该如何消费?

  • latest(默认) :只消费自己启动之后发送到主题的消息
  • earliest:第一次从头开始消费,以后按照消费offset记录继续消费,这个需要区别于consumer.seekToBeginning(每次都从头开始消费)

至此,你已经掌握了基本的Kafka操作!👏

最后,8月18号,EVERGLOW回归创翻KPOP!👑👑👑👑👑👑

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值