第三章 Consumer开发

一 Consumer概览

kafka消费者是从Kafka读取消息的应用。旧版本consumer采用scala编写,由于存在设计缺陷,在0.9.0.0版本中正式推出了采用java新版本的consumer。

新旧版本对比

 语言API主要类
新版本Javaorg.apache.kafka.clients.consumer.*kafkaConsumer
旧版本Scalakafka.consumer.*ZookeeperConsumerConnection
    

出了Consumer版本外,Consumer还存在着分类:

  • 消费者组
  • 独立消费者

 

1 消费者组(Consumer Group)

消费者使用一个消费者组来标记自己,topic的每条消息都只会被发送到每个订阅它的消费者组的一个消费者实例上。

  • 一个Consumer Group可能有若干个Consumer;
  • 对于同一个Group而言,topic的每条消息只能发送到Group下的一个Consumer实例上;
  • topic消息可以被发送到多个Group组中。

Kafka支持基于队列和发布/订阅的两种消息引擎模型,它是通过Consumer Group实现的对这两种模型的支持。

  • 所有Consumer实例都属于相同Group,实现基于消息队列的模型。每条消息只会被一个Consumer实例处理;
  • Consumer实例都属于不同的Group,实现基于发布/订阅的模型。

为什么需要Consumer Group?

Consumer Group是用于实现高伸缩性、高容错性的Consumer机制,组内多个Consumer实例可以同时读取Kafka消息,而且一旦有某个Consumer挂了,Consumer Group会立即将已崩溃的Consumer负责的分区转交给其他Consumer消费者,从而保证整个Group可以继续工作,不丢失数据,这个过程被称为重平衡(rebalance)。

 

Kafka目前只提供单个分区内的消息顺序,而不维护全局的消息顺序,因此如果要实现topic全局的消息读取顺序,就只能通过让每个Consumer Group下只包含一个Consumer实例来间接实现。

Consumer Group的含义和特点:

  • 可以有一个或多个Consumer;
  • group.id唯一标识一个Consumer Group;
  • 对某个Group而言,订阅topic的每个分区只能分配该Group下的一个Consumer实例。

2 Consumer的offset

(1) offset

位移:每个Consumer实例都会为它消费的分区维护属于自己的位置信息来记录当前消费了多少条消息。

目前很多消息引擎把消费端offset保存在服务端。这样好处是实现简单,但是也会产生很多问题:

  • broker变成有状态,增加了同步成本,影响伸缩性;
  • 需要引入应答机制确认消费成功;
  • 由于要保存许多Consumer的Offset,故必然引入复杂的数据结构,从而造成资源浪费。

Kafka选择让Consumer Group保存Offset,只需要简单地保存在一个长整型数据就可以了,同时Kafka Consumer还引入了检查点机制(checkpoint)定期对offset进行持久化,从而简化了应答机制的实现。

 (2) 位移提交

Consumer客户端需要定期地向Kafka集群汇报自己的消费数据的进度,这一过程被成为位移提交。

旧版本中Consumer将位移提交给Zookeeper。新版本Consumer提交到Kafka的一个内部topic(_consumer_offset_)。

(3) _consumer_offset

用于保存位移的内部topic。

(4)消费者组重平衡(rebalance)

它只对消费者组有效。它本质是一种协议,规定了一个Consumer Group下所有Consumer如何达成一致来分配topic所有分区。

(5) 构建Consumer

  • 构建Properties对象,指定bootstrap.servers、key、deserializes、value.deserializer、group.id值;
  • 构建kafkaConsumer对象;
  • 调KafkaConsumer.subscribe()方法订阅Consumer Group感兴趣topic列表;
  • 循环调用KafkaConsumer.poll()方法获取封装在ConsumerRecord的topic消息;
  • 处理获取到的ConsumerRecord对象;
  • 关闭KafkaConsumer。
import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;

import java.util.Arrays;
import java.util.Properties;

public class ConsumerTest {

    public static void main(String[] args) {
        String topic = "mytopic1";
        String groupId = "test-group";

        Properties props = new Properties();
        props.put("bootstrap.servers","192.168.25.70:9092");//必须指定
        props.put("group.id",groupId);//必须指定
        props.put("enable.auto.commit","true");
        props.put("auto.commit.interval.ms","1000");
        props.put("auto.offset.reset","earliest");//从最早的消息开始读取
        props.put("key.deserializer","org.apache.kafka.common.serialization.StringDeserializer");//必须指定
        props.put("value.deserializer","org.apache.kafka.common.serialization.StringDeserializer");//必须指定
        KafkaConsumer<String,String> consumer = new KafkaConsumer<String, String>(props);
        consumer.subscribe(Arrays.asList(topic));//订阅topic
        try {
            while (true){
                ConsumerRecords<String, String> records = consumer.poll(1000);
                for (ConsumerRecord<String,String> record: records) {
                    System.out.printf("offset=%d , key=%s, value=%s%n", record.offset(), record.key(), record.value());
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            consumer.close();
        }
    }
}

Consumer获取消息使用KafkaConsumer.poll()方法从订阅topic并行地获取多个分区的消息。

新版本中poll()方法使用类似linux Select I/O机制-----所有相关事件(包括rebalance、获取消息)都发生在一个事件循环(event loop)中。这样Consumer端只使用一个线程就能够完成所有类型的I/O操作。poll()方法如果拿到过多数据时才可以返回,否则就需要进入阻塞态,等拿到数据或超时返回。

(6) kafka-console-consumer脚本

  • --bootstrap.server:指定broker列表;
  • --topic:指定消费的topic;
  • --from-beginning:是否指定从头消费。

4 Consumer的主要参数

  • session.timeout.ms:新版本中coordinator检测失败事件;
  • max.poll.interval.ms:消息处理逻辑的最大时间;
  • auto.offset.reset:可能取值:earliest:指定从最早的位移开始消费,比一定是0;latest:指定从最新处位移开始消费;none:指定如果未发现位移信息或位移越界,则抛出异常。
  • enable.auto.commit:指定Consumer是否自动提交位移;
  • fetch.max.bytes:指定了Consumer端单次获取数据的最大字节数;
  • max.poll.records:该参数控制单次poll调用返回的最大消息数;
  • heartbeat.interval.ms:心跳间隔时间。为什么需要设置这个参数?当coordinator决定开始新一轮rebalance时,它会将这个决定以REBALANCE_IN_PROGRESS异常形式塞进Consumer心跳请求的response中,这样其他成员拿到response后才能知道它需要重新加入group。这个值必须小于session.timeout.ms。
  • connections.max.idle.ms:是否关闭空闲连接,-1不关闭。

5 订阅topic列表

  • 手动指定topic列表
  • 基于正则表达式订阅topic

6 消息轮询

poll内部原理

目前:新版Consumer是一个多线程或者说是一个双线程的java进程,创建KafkaConsumer的线程被称为用户主线程,同时Consumer在后台创建一个心跳线程,该线程被成为后台心跳线程,KafkaConusmer的poll方法在用户主线程中运行。

poll的未来版本:采用类似linux I/O模型的poll或select等,使用一个线程来同时管理多个socket连接,即同时与多个broker通信实现消息的并行获取。

新版本的Consumer不是线程安全的。

poll使用方法:

  • 当Consumer要定期执行其他子任务,推荐poll(较小超时h时间)+运行标识布尔变量的方式;
  • 当Consumer不需要定期执行子任务,推荐poll(Integer.MAX_VALUE)+捕获wakeupException的方式。

7 位移管理

(1)Consumer位移

Consumer端需要为每个它需要读取的分区保存消费进度,即分区中当前最新消费消息的位置。

offset对Consumer重要,它是实现消息交付语义保证的基石,常见的3种消息交付语义:

  • 最多一次处理语义:消息可能丢失但不会重复处理;
  • 最少一次处理:消息不会丢失,但可能被处理多次;
  • 精确一次:消息一定会被处理且只会被处理一次。

0.11.0.0版本正式支持事务及精确一次处理语义。

(2) 新版本中Consumer位移管理

Consumer会在Kafka集群的所有broker中选择一个broker作为consumer group的coordinator,用于实现组成员管理,消费分配方案制定以提交位移等。

当消费者首次启动,由于没有初始位移信息,coordinator必须为其确定初始位移(auto.offset.reset)。

当Consumer运行了一段时间,它必须提交自己位移值,当Consumer崩溃时,它所负责分区会被分配给其他Consumer。所以一定要在其他Consumer读取这些分区前,就做好位移提交工作,否则会出现消息重复消费。

Consumer提交位移的主要机制是通过向所属的coordinator发送位移提交请求来实现每个位移提交请求都会往_consumer_offset_对应分区上追加写入一条消息。

(3) 自动提交和手动提交位移

auto.commit.interval.ms设置自动提交间隔。

自动提交减低开发成本,用户不必亲自处理位移提交,劣势是用户不能细粒度地处理位移提交。

enable.auto.commit=false关闭手动提交

8 重平衡(rebalance)

(1) rebalance概览

rebalance是一组协议,它规定了一个Consumer Group是如何达成一致来分配订阅topic的所有分区的。

新版本consumer使用Kafka内置的组协调协议(group coordinator protocol)。对于每个组而言,kafka的某个broker会被选举为组协调者。coordinator负责对组的状态进行管理,它的主要职责是当新成员到达时,促成组内所有成员达成新的分区分配方案,即coordinator负责执行rebalance操作。

(2) rebalance触发条件

  • 组成员变化
  • 组订阅topic数发生变更
  • 组订阅topic的分区数发生变更

(3) rebalance分区分配

consumer group下所有consumer都会参与分区分配。

kafka新版本中consumer提供了3中分配策略:

  • range策略:基于范围思想。它将单个topic的所有分区按照顺序排列,然后把这些分区划分成固定大小的分区段,并依次分配给每个consumer。
  • round-robin策略:把所有topic的所有分区按照顺序摆开,然后轮询式的分配给各个consumer。
  • stickey策略:有效地避免了上述两种策略完全无视历史分配方案的缺陷,采用了‘有黏性’的策略对所有Consumer实例进行分配,可以避免极端情况下的数据倾斜并且在两次rebalance间最大限度地维持了之前的分配方案。

partition.assignment.strategy设置分配策略

(4) rebalance generation

为了更好地隔离每次rebalance上的数据,更好保护consumer group,特别是方式无效offset提交,新版本Consumer设计了rebalance generation用于标识某次rebalance。

(5)rebalance协议

新版本kafka提供了下面5个协议来处理rebalance相关事宜。

  • JoinGroup请求:consumer请求加入组;
  • SyncGroup请求:group leader把分配方案同步更新到组内所有成员内;
  • Heartbeat请求:Consumer定期向coordinator汇报心跳表明自己依然活着;
  • leaveGroup请求:consumer主动通知coordinator该consumer即将离组;
  • DescribeGroup请求:查看组的所有信息,包括成员信息、协议信息,分配方案以及订阅信息等,该请求类型供管理员使用,coordinator不使用该请求执行rebalance。

(6)rebalance流程

(7)rebalance监听器

kafka支持用户把位移提交外部存储中,比如数据库。但是需要使用rebalance监听器,且用户使用的是consumer group。

9 解序列化

(1) 默认的解序列化器

  • ByteArrayDeserializer
  • ByteBufferDeserializer
  • BytesDeserializer
  • DoubleDeserializer
  • IntegerDeserializer
  • LongDeserializer
  • StringDeserializer

(2) 自定义解序列化器

实现Deserializer接口。

import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.kafka.common.header.Headers;
import org.apache.kafka.common.serialization.Deserializer;

import java.io.IOException;
import java.util.Map;

public class MyDeserializer implements Deserializer {
    private ObjectMapper objectMapper;
    @Override
    public void configure(Map configs, boolean isKey) {
        objectMapper = new ObjectMapper();
    }

    @Override
    public Object deserialize(String s, byte[] bytes) {
        return null;
    }

    @Override
    public Object deserialize(String topic, Headers headers, byte[] data) {
        Object object = null;
        try {
            object = objectMapper.readValue(data, Object.class);
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            return object;
        }
    }

    @Override
    public void close() {

    }
}

10 多线程消费实现

(1)每个线程维护一个KafkaConsumer

ConsumerRunable类

public class ConsumerRunnable implements Runnable {

    private final KafkaConsumer<String,String> consumer;

    public ConsumerRunnable(String brokerList,String groupId, String topic){
        Properties props = new Properties();
        props.put("bootstrap.servers",brokerList);
        props.put("group.id",groupId);
        props.put("enable.auto.commit","true");
        props.put("auto.commit.interval.ms","1000");
        props.put("session.timeout.ms","30000");
        props.put("key.deserializer","org.apache.kafka.common.serialization.StringDeserializer");
        props.put("value.deserializer","org.apache.kafka.common.serialization.StringDeserializer");
        this.consumer = new KafkaConsumer<String, String>(props);
        consumer.subscribe(Arrays.asList(topic));
    }

    @Override
    public void run() {
        while (true){
            ConsumerRecords<String, String> records = consumer.poll(200);
            for (ConsumerRecord<String,String> record : records) {
                System.out.println(Thread.currentThread().getName() + " consumed " + record.partition() +
                        " th message with offset " + record.offset());
            }
        }
    }
}

ConsumerGroup类

import java.util.ArrayList;
import java.util.List;

public class ConsumerGroup {
    private List<ConsumerRunnable> consumers;

    public ConsumerGroup() {
    }

    public ConsumerGroup(int consumerNum, String groupId, String topic, String brokerList){
        consumers = new ArrayList<>(consumerNum);
        for (int i = 0; i < consumerNum; ++i){
            ConsumerRunnable consumerThread = new ConsumerRunnable(brokerList,groupId,topic);
            consumers.add(consumerThread);
        }
    }

    public void execute(){
        for (ConsumerRunnable task : consumers) {
            new Thread(task).start();
        }
    }
}

ConsumerMain类

public class ConsumerMain {
    public static void main(String[] args) {
        String brokerList = "192.168.25.70:9092,192.168.25.70:9093,192.168.25.70:9094";
        String groupId = "testGroup1";
        String topic = "paul";
        int consumerNum = 3;
        ConsumerGroup consumerGroup = new ConsumerGroup(consumerNum,groupId,topic,brokerList);
        consumerGroup.execute();
    }
}

(2) 单KafkaConsumer实例+多worker线程

ConsumerThreadHandler类

import org.apache.kafka.clients.consumer.ConsumerRebalanceListener;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.KafkaConsumer;
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.errors.WakeupException;

import java.time.Duration;
import java.util.*;
import java.util.concurrent.*;

public class ConsumerThreadHandler<K,V> {
    private final KafkaConsumer<K,V> consumer;
    private ExecutorService executors;
    private final Map<TopicPartition, OffsetAndMetadata> offsets = new HashMap<>();

    public ConsumerThreadHandler(String brokerList, String groupId, String topic){
        Properties props = new Properties();
        props.put("bootstrap.servers",brokerList);
        props.put("group.id",groupId);
        props.put("enable.auto.commit","true");
        props.put("auto.offset.reset","earlist");
        props.put("key.deserializer","org.apache.kafka.common.serialization.ByteArrayDeserializer");
        props.put("value.deserializer","org.apache.kafka.common.serialization.ByteArrayDDeserializer");
        consumer = new KafkaConsumer<K, V>(props);
        consumer.subscribe(Arrays.asList(topic), new ConsumerRebalanceListener() {
            @Override
            public void onPartitionsRevoked(Collection<TopicPartition> collection) {
                consumer.commitSync(offsets);
            }

            @Override
            public void onPartitionsAssigned(Collection<TopicPartition> collection) {
                offsets.clear();
            }
        });
    }

    public void consume(int threadNum){
        executors = new ThreadPoolExecutor(threadNum,threadNum,0L, TimeUnit.MILLISECONDS,
                new ArrayBlockingQueue<Runnable>(1000),new ThreadPoolExecutor.CallerRunsPolicy());
        try {
            while (true){
                ConsumerRecords<K,V> records = consumer.poll(1000L);
                if (!records.isEmpty()){
                    executors.submit(new ConsumerWorker<>(records,offsets));
                }
                commitOffsets();
            }
        } catch (WakeupException e) {
            e.printStackTrace();
        } finally {
            commitOffsets();
            consumer.close();
        }
    }

    private void commitOffsets() {
        Map<TopicPartition, OffsetAndMetadata> unmodfiedMap;
        synchronized (offsets){
            if (offsets.isEmpty()){
                return;
            }
            unmodfiedMap = Collections.unmodifiableMap(new HashMap<>(offsets));
            offsets.clear();
        }
        consumer.commitSync(unmodfiedMap);
    }

    public void close(){
        consumer.wakeup();
        executors.shutdown();
    }
}

ConsumerWorker类

import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.apache.kafka.clients.consumer.ConsumerRecords;
import org.apache.kafka.clients.consumer.OffsetAndMetadata;
import org.apache.kafka.common.TopicPartition;

import java.util.List;
import java.util.Map;

public class ConsumerWorker<K,V> implements Runnable {
    private final ConsumerRecords<K,V> records;
    private final Map<TopicPartition, OffsetAndMetadata> offsets;
    public  ConsumerWorker(ConsumerRecords<K, V> record, Map<TopicPartition, OffsetAndMetadata> offsets) {
        this.records = record;
        this.offsets = offsets;
    }

    @Override
    public void run() {
        for (TopicPartition partition : records.partitions()){
            List<ConsumerRecord<K,V>> partitionRecords = records.records(partition);
            for (ConsumerRecord<K,V> record : partitionRecords){
                System.out.println(String.format("topic=%s, partition=%d, offset = %d",record.topic(),
                        record.partition(), record.offset()));
            }
            long lastOffset = partitionRecords.get(partitionRecords.size()-1).offset();
            synchronized (offsets){
                if (!offsets.containsKey(partition)){
                    offsets.put(partition, new OffsetAndMetadata(lastOffset+1));
                } else {
                    long curr = offsets.get(partition).offset();
                    if (curr <= lastOffset + 1){
                        offsets.put(partition, new OffsetAndMetadata(lastOffset + 1));
                    }
                }
            }
        }
    }
}

ConsumerThreadMain类

public class ConsumerThreadMain {
    public static void main(String[] args) {
        String brokerList = "192.168.25.70:9092,192.168.25.70:9093,192.168.25.70:9094";
        String groupId = "testGroup1";
        String topic = "paul";
        final ConsumerThreadHandler<byte[],byte[]> handler = new ConsumerThreadHandler<>(brokerList, groupId,topic);
        final int cpuCount = Runtime.getRuntime().availableProcessors();
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                handler.consume(cpuCount);
            }
        };
        new Thread(runnable).start();

        try {
            Thread.sleep(20000L);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("Starting to close the consumer....");
        handler.close();
    }
}

(3) 两种方法对比

 优点缺点
方法1(每个线程维护专属KafkaConsumer)实现简单:速度较快,因为无线程间交互开销;方便位移管理;易于维护分区间的消息消费顺序Socket连接开销大;Consumer数受限于topic分区数,扩展性差;broker端处理负载高(因为发往broker的请求数多);rebalance可能性增大
方法2(全局consumer+多worker线程)消息获取与处理解耦:可独立扩展consumer数和worker数,伸缩性好实现负载:难于维护分区内的消息顺序;处理链路变长,导致位移管理困难;worker线程异常可能导致消费数据丢失。

11 独立Consumer

  • 进程自己维护分区状态,那么它就可以固定消费某些分区而不用担心消费状态丢失的问题;
  • 如果进程本身已经是高可用且能够自动重启恢复错误(比如使用YARN和Mesos等容器调度框架),那么它就不需要让Kafka来帮它完成错误检测和状态恢复。
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.PartitionInfo;
import org.apache.kafka.common.TopicPartition;
import org.apache.kafka.common.errors.WakeupException;

import java.util.ArrayList;
import java.util.List;
import java.util.Properties;

public class StandloneConsumer {
    public static void main(String[] args) {
        Properties props = new Properties();
        props.put("bootstrap.servers","192.168.25.70:9092");//必须指定
        props.put("enable.auto.commit","false");
        props.put("auto.offset.reset","earliest");//从最早的消息开始读取
        props.put("key.deserializer","org.apache.kafka.common.serialization.StringDeserializer");//必须指定
        props.put("value.deserializer","org.apache.kafka.common.serialization.StringDeserializer");//必须指定
        KafkaConsumer<String,String> consumer = new KafkaConsumer<String, String>(props);
        List<TopicPartition> partitions = new ArrayList<>();
        List<PartitionInfo> allPartitions = consumer.partitionsFor("paul");
        if (allPartitions != null && !allPartitions.isEmpty()){
            for (PartitionInfo partitionInfo : allPartitions){
                partitions.add(new TopicPartition(partitionInfo.topic(),partitionInfo.partition()));
            }
            consumer.assign(partitions);
        }
        try {
            while (true){
                ConsumerRecords<String,String> records = consumer.poll(Long.MAX_VALUE);
                for (ConsumerRecord<String,String> record : records){
                    System.out.println(String.format("topic=%s,partition=%d,offset=%d",record.topic(),
                            record.partition(),record.offset()));
                }
                consumer.commitSync();
            }
        }catch (WakeupException e){

        }finally {
            consumer.commitSync();
            consumer.close();
        }
    }
}

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
您好!您这里提到的 "java consumer" 是否指的是 Kafka 消费者(consumer)的 Java 实现呢?如果是的话,Kafka 提供了官方的 Java 客户端库,可以方便地使用 Java 编写 Kafka 消费者。以下是一个简单的 Kafka 消费者示例代码: ```java import org.apache.kafka.clients.consumer.Consumer; import org.apache.kafka.clients.consumer.ConsumerConfig; 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.Collections; import java.util.Properties; public class MyKafkaConsumer { public static void main(String[] args) { // 配置 Kafka 消费者属性 Properties props = new Properties(); props.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "localhost:9092"); props.put(ConsumerConfig.GROUP_ID_CONFIG, "my-group"); props.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); props.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class.getName()); // 创建 Kafka 消费者实例 Consumer<String, String> consumer = new KafkaConsumer<>(props); // 订阅主题 consumer.subscribe(Collections.singletonList("my-topic")); // 消费消息 while (true) { ConsumerRecords<String, String> records = consumer.poll(Duration.ofMillis(100)); for (ConsumerRecord<String, String> record : records) { System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(), record.key(), record.value()); } } } } ``` 以上代码中,我们使用 KafkaConsumer 类创建了一个 Kafka 消费者实例,并订阅了名为 "my-topic" 的主题。在 while 循环中,我们使用 poll() 方法从 Kafka 集群中拉取消息,然后遍历 ConsumerRecords 对象,逐条消费消息。 希望这个简单的示例能够帮助您了解如何使用 Java 编写 Kafka 消费者。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值