记一次得物面试

消息0丢失,Kafka如何实现?

kafka的消息的发送总流程:三个大的阶段

一条消息从生产到被消费,将会经历三个大阶段:

  • 生产阶段,Producer 新建消息,而后经过网络将消息投递给 MQ Broker

  • 存储阶段,消息将会存储在 Broker 端磁盘中

  • 消息阶段, Consumer 将会从 Broker 拉取消息

以上任一阶段, 都可能会丢失消息,只要这三个阶段0丢失,就能够完全解决消息丢失的问题。

第一阶段:生产阶段如何实现0丢失方式

从架构视角来说, kafka 生产者之所以会丢消息,和Producer 的高吞吐架构有关。

首先,看看Producer 的高吞吐架构

Producer 的高吞吐架构:异步发送 + 批量发送。

首先一个大的模式是:Producer发送消息采用的是异步发送的方式。 

在消息异步发送的过程中,涉及到了两个线程和一个队列:

  • 业务线程 和 Sender线程

  • 以及一个消息累积器 : RecordAccumulator。

图片

Kafka Producer SDK会创建了一个消息累积器 RecordAccumulator,里边使用 双端队列 缓存消息, 业务线程 将消息加入到 RecordAccumulator ,业务线程就返回了。

这就是业务发送的妙处, 注意, 业务线程就返回了,但是底层的发送工作,还没开始。

谁来负责底层发送呢?Sender线程。

Sender线程不断从 RecordAccumulator 中拉取消息,负责发送到Kafka broker。

图片

这回我们明白了, 原来机关在这里:

  • kafka在发送消息时,是由底层的SEND线程进行消息的批量发送,不是由业务代码线程执行发送的。

  • 业务代码线程执行完send方法后,就返回了。

package org.example;

import org.apache.kafka.clients.producer.*;
import org.apache.kafka.common.serialization.StringSerializer;

import java.util.Properties;

public class KafkaProducerExample {
    public static void main(String[] args) {

        Producer<String, String> producer = getProducer();

        // Kafka 主题名称
        String topic = "mytopic";

        // 发送消息
        String message = "Hello, Kafka!";
        ProducerRecord<String, String> record = new ProducerRecord<>(topic, message);
        producer.send(record);

        // 关闭 Kafka 生产者
        producer.close();
    }

    private static Producer<String, String> getProducer() {
        Properties properties = getProperties();
        // 创建 Kafka 生产者
        Producer<String, String> producer = new KafkaProducer<>(properties);
        return producer;
    }

    private static Properties getProperties() {
        // Kafka 服务器地址和端口
        String bootstrapServers = "localhost:9092";
        // Kafka 生产者配置
        Properties properties = new Properties();
        properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers);
        properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class.getName());
        return properties;
    }
}

由于是异步发送,那么消息到底发送给broker侧没有?通过send方法其实是无法知道的。上面的代码,创建消息之后就开始发送消息了,并且不能同步的拿到发送结果。

回头看看核心代码:

ProducerRecord<String, String> record = new ProducerRecord<>(topic, message);
producer.send(record);

如果要想知道发现的结果,怎么办呢?可以通过send方法的Future实例去实现:

Future<RecordMetadata> future = producer.send(record);

上面的代码,Producer#send方法是一个异步方法,即它会立即返回一个Future对象,而不会等待消息发送完成。

拿到future之后,可以使用Future对象来异步处理发送结果,例如等待发送完成或注册回调函数来处理结果。

如何使用这个futrue对象呢?具体的办法是,给Future去注册回调函数处理结果,这样可以实现非阻塞的方式处理发送完成的回调。

Future去注册回调函数处理结果,下面是一个示例代码:

future.addCallback(new ListenableFutureCallback<RecordMetadata>() {
    @Override
    public void onSuccess(RecordMetadata metadata) {
        System.out.println("消息发送成功,分区:" + metadata.partition() + ",偏移量:" + metadata.offset());
    }

    @Override
    public void onFailure(Throwable ex) {
        System.err.println("消息发送失败:" + ex.getMessage());
    }
});

还没有其他的方法, 获取发送的处理异步结果呢?

其实,Producer#send方法有两个重载版本, 具体如下:

Future<RecordMetadata> send(ProducerRecord<K, V> record)
Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback)

所以,除了前面的单参数的版本, 实际上还有一个两个参数的版本:

Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback)

通过这个版本, 大家可以注册回调函数的方式,完成发送结果的处理。通过回调函数版本,更好的实现非阻塞的方式处理发送完成的回调。

参考的代码如下:

   producer.send(record, new Callback() {
            @Override
            public void onCompletion(RecordMetadata metadata, Exception exception) {
                if (exception != null) {
                    System.err.println("Error sending message: " + exception.getMessage());
                } else {
                    System.out.println("Message sent successfully! Topic: " + metadata.topic() +
                            ", Partition: " + metadata.partition() + ", Offset: " + metadata.offset());
                }
            }
        });

上面的例子,通过这个callback回调函数版本的send方法,如果Producer底层的send线程发送给broker端成功/(失败的)了,都可以回调callback函数,通知上层业务应用。

一般来说,大家在callback函数里,根据回调函数的参数,就能知道消息是否发送成功了,如果发送失败了,那么我们还可以在callback函数里重试。

Producer(生产者)保证消息不丢失的方法:

如果要保证 Producer(生产者)0 丢失, 策略是什么?

  • Producer一板斧:设置最高可靠的、最为严格的发送确认机制

  • Producer二板斧:设置严格的消息重试机制,比如增加重试次数

  • Producer三板斧:本地消息表+定时扫描

图片

Producer第一板斧:设置最高可靠的、最为严格的发送确认机制

Producer可以使用Kafka的acks参数来配置发送确认机制。这个acks参数用来指定分区中必须要有多少个副本收到这条消息,之后生产者才会认为这条消息是成功写入的。通过设置合适的acks参数值,Producer可以在消息发送后等待Broker的确认。

确认机制提供了不同级别的可靠性保证,包括:

  • acks=0:Producer在发送消息后不会等待Broker的确认,这可能导致消息丢失风险。

  • acks=1:Producer在发送消息后等待Broker的确认,确保至少将消息写入到Leader副本中。

  • acks=all或acks=-1:Producer在发送消息后等待Broker的确认,确保将消息写入到所有ISR(In-Sync Replicas)副本中。这提供了最高的可靠性保证。

由于acks 是生产者客户端中一个非常重要的参数,它涉及消息的可靠性和吞吐量之间的权衡,所以非常重要。

下面对于acks 参数的3种类型的值(都是字符串类型), 更加详细的介绍一下。

  • acks = 1。默认值即为1。生产者发送消息之后,只要分区的 leader 副本成功写入消息,那么它就会收到来自服务端的成功响应。如果消息无法写入 leader 副本,比如在 leader 副本崩溃、重新选举新的 leader 副本的过程中,那么生产者就会收到一个错误的响应,为了避免消息丢失,生产者可以选择重发消息。如果消息写入 leader 副本并返回成功响应给生产者,且在被其他 follower 副本拉取之前 leader 副本崩溃,那么此时消息还是会丢失,因为新选举的 leader 副本中并没有这条对应的消息。acks 设置为1,是消息可靠性和吞吐量之间的折中方案。

  • acks = 0。生产者发送消息之后不需要等待任何服务端的响应。如果在消息从发送到写入 Kafka 的过程中出现某些异常,导致 Kafka 并没有收到这条消息,那么生产者也无从得知,消息也就丢失了。在其他配置环境相同的情况下,acks 设置为0可以达到最大的吞吐量。

  • acks = -1 或 acks = all。生产者在消息发送之后,需要等待 ISR 中的所有副本都成功写入消息之后才能够收到来自服务端的成功响应。在其他配置环境相同的情况下,acks 设置为 -1/all可以达到最强的可靠性。但这并不意味着消息就一定可靠,因为ISR中可能只有 leader 副本,这样就退化成了 acks=1 的情况。要获得更高的消息可靠性需要配合 min.insync.replicas 等参数的联动。

注意 acks 参数配置的值是一个字符串类型,而不是整数类型。举个例子,将 acks 参数设置为0,需要采用下面这两种形式:

properties.put("acks", "0");
# 或者
properties.put(ProducerConfig.ACKS_CONFIG, "0");

设置最高可靠的发送确认机制,通过设置acks参数来控制消息的确认方式。

acks参数可以设置为"all"或者 -1,表示要求所有副本都确认消息,这样可以最大程度地保证消息的可靠性。

properties.put(ProducerConfig.ACKS_CONFIG, "all");
# 或者
properties.put(ProducerConfig.ACKS_CONFIG, "-1");

Producer第二板斧:设置严格的消息重试机制,包括增加重试次数

很多消息,因为临时的网络问题或Broker故障而丢失。通过消息重试机制,可以保证消息不会因为临时的网络问题或Broker故障而丢失。

消息在从生产者发出到成功写入服务器之前可能发生一些临时性的异常,比如网络抖动、leader 副本的选举等,这种异常往往是可以自行恢复的,生产者可以通过配置 retries 大于0的值,以此通过内部重试来恢复而不是一味地将异常抛给生产者的应用程序。

所以,如果发送失败,Producer可以重新发送消息,直到成功或达到最大重试次数。

消息重试机制涉及到两个参数:

  • retries

  • retry.backoff.ms

下面有个例子(这里重试配置了 10 次,重试10 次之后没有答复,就会抛出异常,并且,下面的例子每一次重试直接的时间间隔是1秒):

图片

retries 参数用来配置生产者重试的次数,默认值为0,也就是说,默认情况下,在发生异常的时候不进行任何重试动作。

retries 参数设置之后,如果重试达到设定的retries 次数,那么生产者就会放弃重试并返回异常。

当然,retries 重试还和另一个参数 retry.backoff.ms 有关,这个参数的默认值为100,它用来设定两次重试之间的时间间隔,默认为100ms,避免无效的频繁重试。

在配置 retries 和 retry.backoff.ms 之前,最好先估算一下可能的异常恢复时间,这样可以设定总的重试时间大于这个异常恢复时间,以此来避免生产者过早地放弃重试。

Producer第三板斧:本地消息表+定时扫描

前面两板斧,并不能保证100%的0丢失。因为 broker端是异步落盘机制,异步落盘待会详细分析。总之,异步落盘就是broker 消息没有落盘,就返回结果的。

虽然第一板斧设置了最为严格的确认机制,在这里,一个极端情况:哪怕全部的broker 返回了确认结果, 消息也不一定落盘和被投递出去,如果broker 集体断电,还是丢了。

所以说:仅仅依靠回调函数的、设置最高可靠的确认机制,设置最重的重试策略,还是不能保证消息一定被consumer消费的。

另外,callback函数也是不可靠的。比如,刚好遇到在执行回调函数时,jvm OOM/ jvm假死的情况;那么回调函数是不能被执行的。

如何实现极端严格的场景下消息0丢失?

可以和本地消息表事务机制类似,采用 本地消息表+定时扫描 的架构方案。

大概流程如下图

图片

1、设计一个本地消息表,可以存储在DB里,或者其它存储引擎里,用户保存消息的消费状态

2、Producer 发送消息之前,首先保证消息的发生状态,并且初始化为待发送;

3、如果消费者(如库存服务)完成的消费,则通过RPC,调用Producer 去更新一下消息状态;

4、Producer 利用定时任务扫描 过期的消息(比如10分钟到期),再次进行发送。

本地消息表+定时扫描 的架构方案 ,是业务层通过额外的机制来保证消息数据发送的完整性,是一种很重的方案。这个方案的两个特点:

  • CP 不是 AP,性能低

  • 需要 做好幂等性设计

CP 不是 AP的 需要权衡,参见:

一张图总结架构设计的40个黄金法则

幂等性方案,请参见:

最系统的幂等性方案:一锁二判三更新

第二阶段:Broker端保证消息不丢失的方法:

正常情况下,只要 Broker 在正常运行,就不会出现丢失消息的问题。但是如果 Broker 出现了故障,比如进程死掉了或者服务器宕机了,还是可能会丢失消息的。

如果确保万无一失,实现Broker端保证消息不丢失,有两板斧:

  • Broker端第一板斧:设置严格的副本同步机制

  • Broker端第二板斧:设置严格的消息刷盘机制

图片

Broker端第一板斧:设置严格的副本同步机制

kafka应对此种情况,建议是通过多副本机制来解决的,核心思想也挺简单的:如果数据保存在一台机器上你觉得可靠性不够,那么我就把相同的数据保存到多台机器上,某台机器宕机了可以由其它机器提供相同的服务和数据。

要想达到上面效果,有三个关键参数需要配置

  • 第1:在broker端 配置 min.insync.replicas参数设置至少为2 此参数代表了 上面的“大多数”副本。为2表示除了写入leader分区外,还需要写入到一个follower 分区副本里,broker端才会应答给生产端消息写入成功。此参数设置需要搭配第一个参数使用。

  • 第2:在broker端配置 replicator.factor参数至少3 此参数表示:topic每个分区的副本数。如果配置为2,表示每个分区只有2个副本,在加上第二个参数消息写入时至少写入2个分区副本,则整个写入逻辑就表示集群中topic的分区副本不能有一个宕机。如果配置为3,则topic的每个分区副本数为3,再加上第二个参数min.insync.replicas为2,即每次,只需要写入2个分区副本即可,另外一个宕机也不影响,在保证了消息不丢的情况下,也能提高分区的可用性;只是有点费空间,毕竟多保存了一份相同的数据到另外一台机器上。

  • 第3:unclean.leader.election.enable 此参数表示:没有和leader分区保持数据同步的副本分区是否也能参与leader分区的选举,建议设置为false,不允许。如果允许,这这些落后的副本分区竞选为leader分区后,则之前leader分区已保存的最新数据就有丢失的风险。注意在0.11版本之前默认为TRUE。

所以,通过如下配置来保证Broker消息可靠性:

  • default.replication.factor:设置为大于等于3,保证一个partition中至少有两个Replica,并且replication.factor > min.insync.replicas

  • min.insync.replicas:设置为大于等于2,保证ISR中至少有两个Replica

  • unclean.leader.election.enable=false,那么就意味着非ISR中的副本不能够参与选举,避免脏Leader。

图片

Kafka的ISR机制可自动动态调整同步复制的Replica,将慢(可能是暂时的慢)Follower踢出ISR,将同步赶上的Follower拉回ISR,避免最慢的Follower拖慢整体速度,最大限度地兼顾了可靠性和可用性。

Broker端第二板斧:设置严格的消息刷盘机制

无论是kafka、Rocketmq、还是MySQL,为了提升底层IO的写入性能,都会用到操作系统的 Page Cache 技术。注意,这里的 Page Cache 是操作系统提供的缓存机制。

我们的kafka、Rocketmq、MySQL程序 在读写磁盘文件时,其实操作的都是内存,然后由操作系统决定什么时候将 Page Cache 里的数据真正刷入磁盘。如果 Page Cache 内存中数据还未刷入磁盘,而我们的服务器宕机了,这个时候还是会丢消息的。

刷盘的方式有同步刷盘和异步刷盘两种。

  • 同步刷盘指的是:生产者消息发过来时,只有持久化到磁盘,RocketMQ、kafka的存储端Broker才返回一个成功的ACK响应,这就是同步刷盘。它保证消息不丢失,但是影响了性能。

  • 异步刷盘指的是:消息写入PageCache缓存,就返回一个成功的ACK响应,不管消息有没有落盘,就返回一个成功的ACK响应。这样提高了MQ的性能,但是如果这时候机器断电了,就会丢失消息。

同步刷盘和异步刷盘的区别如下:

  • 同步刷盘:当数据写如到内存中之后立刻刷盘(同步),在保证刷盘成功的前提下响应client。

  • 异步刷盘:数据写入内存后,直接响应client。异步将内存中的数据持久化到磁盘上。

同步刷盘和异步输盘的优劣:

  • 同步刷盘保证了数据的可靠性,保证数据不会丢失。

  • 同步刷盘效率较低,因为client获取响应需要等待刷盘时间,为了提升效率,通常采用批量刷盘的方式,每次刷盘将会flush内存中的所有数据。(若底层的存储为mmap,则每次刷盘将刷新所有的dirty页)

  • 异步刷盘不能保证数据的可靠性.

  • 异步刷盘可以提高系统的吞吐量.

  • 常见的异步刷盘方式有两种,分别是定时刷盘和触发式刷盘。定时刷盘可设置为如每1s刷新一次内存.触发刷盘为当内存中数据到达一定的值,会触发异步刷盘程序进行刷盘。

Broker端第二板斧:设置严格的消息刷盘机制,设置为Kafka同步刷盘。

如何设置Kafka同步刷盘?

网上有一种说法,kafka不支持同步刷盘,这种说法,实际上是错的。

可以通过参数的配置变成同步刷盘

log.flush.interval.messages  //page cache里边多少条消息刷盘1次 ,默认值 LONG.MAX_VALUE
log.flush.interval.ms  //隔多长时间刷盘1次,默认值 LONG.MAX_VALUE
log.flush.scheduler.interval.ms //周期性的刷盘,缺省3000,即3s。

源码里边,有这些参数的注释:

public static final String FLUSH_MESSAGES_INTERVAL_CONFIG = "flush.messages";
public static final String FLUSH_MESSAGES_INTERVAL_DOC = "This setting allows specifying an interval at " +
    "which we will force an fsync of data written to the log  多少消息刷盘. For example if this was set to 1 we would fsync after every message  设置为1 就是1条消息就刷盘,也就是同步刷盘模式; if it were 5 we would fsync after every five messages. " +
    "In general we recommend you not set this and use replication for durability and allow the " +
    "operating system's background flush capabilities as it is more efficient. This setting can " +
    "be overridden on a per-topic basis (see <a href=\"#topicconfigs\">the per-topic configuration section</a>).";

public static final String FLUSH_MS_CONFIG = "flush.ms";
public static final String FLUSH_MS_DOC = "This setting allows specifying a time interval at which we will " +
    "force an fsync of data written to the log. For example if this was set to 1000 " +
    "we would fsync after 1000 ms had passed. In general we recommend you not set " +
    "this and use replication for durability and allow the operating system's background " +
    "flush capabilities as it is more efficient.";

如果要同步刷盘,可以使用下面的配置:

# 当达到下面的消息数量时,会将数据flush到日志文件中。默认10000
#log.flush.interval.messages=10000
# 当达到下面的时间(ms)时,执行一次强制的flush操作。interval.ms和interval.messages无论哪个达到,都会flush。默认3000ms
#log.flush.interval.ms=1000
# 检查是否需要将日志flush的时间间隔
log.flush.scheduler.interval.ms = 3000

设置了同步刷盘后,在掉电的情况下,数据是不丢失了,但是,kafka 吞吐量降低到了 500tps 以下。

图片

而异步刷盘, 吞吐量 的单节点在 10000W以上,差异巨大的。

注意,同步刷盘,性能 足足相差20倍。

图片

第三阶段:Consumer(消费者)保证消息不丢失的方法:

如果要保证 Consumer(消费者)0 丢失, Consumer 端的策略是什么?

这个比较简单:消费成功之后,手动ACK提交消费位移(位点)。

这一招分为两步:

  • 设置enable.auto.commit 为 false

  • commitSync() 和 commitAsync() 组合使用进行手动提交

图片

首先,看看什么是Consumer 位移

Consumer 程序有个“位移”(/位点)的概念,表示的是这个 Consumer 当前消费到的 Topic Partion分区的位置。

下面这张图来自于官网,它清晰地展示了 Consumer 端的位移数据。

图片

enable.auto.commit=false 关闭自动提交位移,消息处理完成之后再提交offset

consumer端需要为每个它要读取的分区保存消费进度,即分区中当前消费消息的位置,该位置称为位移(offset)。每个Consumer Group独立维护offset,互不干扰,不存在线程安全问题。kafka中的consumer group中使用一个map来保存其订阅的topic所属分区的offset:

图片

实际上,这里的位移值通常是下一条待消费的消息的位置,因为位移是从0开始的,所以位移为N的消息其实是第N+1条消息。在consumer中有如下位置信息:

图片

  • 上次提交位移:consumer最后一次提交的offset值;

  • 当前位置:consumer已经读取,但尚未提交时的位置;

  • 水位:也称为高水位,代表consumer是否可读。对于处于水位以下(水位左侧)的所有消息,consumer是可以读取的,水位以上(水位又侧)的消息consumer不读取;

  • 日志最新位移:也称日志终端位移,表示了某个分区副本当前保存消息对应的最大位移值。

consumer需要定期向Kafka提交自己的位置信息,这一过程称为位移提交(offset commit)。

consumer提交的对象,叫做 coordinator。

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

如何选择coordinator,依据就是kafka的内置topic(_consumer_offsets)。内置_consumer_offsets 的topic与普通topic一样,配置多个分区,每个分区有多个副本,它存在的唯一目的就是保存consumer提交的位移。

当消费者组首次启动的时候,由于没有初始的位移信息,coordinator需要为其确定初始位移值,这就是consumer参数 auto.offset.reset 的作用,通常情况下,consumer要么从最开始位移开始读取。

当cosumer运行一段时间之后,就需要提交自己的位移信息,如果consumer奔溃或者被关闭,它负责的分区就会被分配给其他consumer,因此一定要在其他consumer读取这些分区前,就做好位移提交,否则会出现重复消费。

consumer提交位移的主要机制,也是发消息实现的。具体来说,是通过向所属的coordinator发送位移提交请求消息来实现的。

每个位移提交请求都会向_consumer_offsets对应分区写入一条消息,消息的key是group.id,topic和分区的元组,value是位移值。

如果consumer为同一个group的同一个topic分区提交了多次位移,那么只有最新的那次提交的位移值是有效的,其余几次提交的位移值都已经过期,Kafka通过压实(compact)策略来处理这种消息使用模式,

consumer提交位移,有两大模式:

  1. 自动提交位移:Consumer可以选择启用自动提交位移的功能。当Consumer成功处理一批消息后,它会自动提交当前位移,标记为已消费。这样即使Consumer发生故障,它可以使用已提交的位移来恢复并继续消费之前未处理的消息。

  2. 手动提交位移:Consumer还可以选择手动提交位移的方式。在消费一批消息后,Consumer可以显式地提交位移,以确保处理的消息被正确记录。这样可以避免重复消费和位移丢失的问题。

Consumer 端有个参数 enable.auto.commit(默认值就是 true),把它设置为 true , 就是自动提交位移的。

自动提交的参考代码如下:

Properties props = new Properties();
props.put("bootstrap.servers", "localhost:9092");
props.put("group.id", "test");
props.put("enable.auto.commit", "true");
props.put("auto.commit.interval.ms", "2000");
props.put("key.deserializer", "org.apache.kafka.common.serialization.StringDeserialprops.put("value.deserializer", "org.apache.kafka.common.serialization.StringDeseriKafkaConsumer<String, String> consumer = new KafkaConsumer<>(props);
consumer.subscribe(Arrays.asList("foo", "bar"));
while(true) {
	ConsumerRecords<String, String> records = consumer.poll(100);
	for (ConsumerRecord<String, String> record : records)
	System.out.printf("offset = %d, key = %s, value = %s%n", record.offset(),
}

和自动提交配合的参数,还有一个 auto.commit.interval.ms。它的默认值是 5 秒,表明 Kafka 每 5 秒会为你自动提交一次位移。

高并发场景,一定是多线程异步消费消息,自动提交就不管有没有消费成功, 位点都提交了,所以为了保证0丢失,消费者 Consumer 程序不要开启自动提交位移,而是要应用程序手动提交位移。

开启手动提交位移的方法就是设置enable.auto.commit 为 false。

但是,仅仅设置它为 false 还不够,这个配置只是告诉Kafka Consumer 不要自动提交位移而已,应用程序还需要调用相应的 API 手动提交位移。

手动提交位移的 API,一个最简单的是 同步提交位移,KafkaConsumer#commitSync()。该方法会提交KafkaConsumer#poll() 返回的最新位移。

下面这段代码展示了 commitSync() 的使用方法:

下面是手动提交位移的例子:

while(true) {
	ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
	process(records);
	//处理消息
	try {
		consumer.commitSync();
	}
	catch (CommitFailedException e) {
		handle(e);
		//处理提交失败异常
	}
}

可见,调用 consumer.commitSync() 方法的时机,是在你处理完了 poll() 方法返回的所有消息之后。

KafkaConsumer#commitSync() 它是一个同步操作,即该方法会一直等待,直到位移被成功提交才会返回。如果提交过程中出现异常,该方法会将异常信息抛出。

除了同步提交,Kafka 社区为手动提交位移提供了另一个异步 API 方法:KafkaConsumer#commitAsync()。

异步提交的优势:调用 commitAsync() 之后,它会立即返回,不会阻塞,因此不会影响 Consumer 应用的 TPS。

异步往往配合了回调,Kafka 提供了回调函数(callback),回调用于处理提交之后的逻辑,比如记录日志或处理异常等。

下面这段代码展示了调用 commitAsync() 的方法:

while(true) {
	ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
	process(records);
	//处理消息
	consumer.commitAsync((offsets, exception) -> {
		if(exception != null)
		  handle(exception);
	}
	);

如何又能保证 提交的高性能,又能重复利用 commitSync 的自动重试来规避那些瞬时错误(比如网络的瞬时抖动,Broker 端 GC 等)呢?

答案是:commitSync() 和 commitAsync() 组合使用。

它展示的是如何将两个 API 方法commitSync() 和 commitAsync() 组合使用进行手动提交。

try{
	while(true) {
		ConsumerRecords<String, String> records = consumer.poll(Duration.ofSeconds(1));
		process(records);
		//处理消息
		commitAysnc();
		//使用异步提交规避阻塞
	}
}
catch(Exception e) {
	handle(e);
	//处理异常
}
finally{
	try{
		consumer.commitSync();
		//最后一次提交使用同步阻塞式提交
	}
	finally{
		consumer.close();
    }
}

Kafak 0丢失的最佳实践

  1. 生产端:不要使用 producer.send(msg),而要使用 producer.send(msg, callback)。

    记住,一定要使用带有回调通知的 send 方法。

  2. 生产端:设置 acks = all 设置最高可靠的、最为严格的发送确认机制。acks 设置成 all,则表明所有副本 Broker 都要接收到消息,该消息才算是“已提交”。这是最高等级的“已提交”定义。

  3. 生产端:设置 retries 为一个较大的值如10,设置严格的消息重试机制,包括增加重试次数。当出现网络的瞬时抖动时,消息发送可能会失败,retries 较大,能够自动重试消息发送,避免消息丢失。

  4. Broker 端设置 unclean.leader.election.enable = false。它控制的是哪些 Broker 有资格竞选分区的 Leader。如果一个 Broker 落后原先的 Leader 太多,那么它一旦成为新的 Leader,必然会造成消息的丢失。故一般都要将该参数设置成 false,即不允许这种情况的发生。

  5. Broker 端设置 replication.factor >= 3。这里想表述的是,最好将消息多保存几份,毕竟目前防止消息丢失的主要机制就是冗余。

  6. Broker 端设置 min.insync.replicas > 1。控制的是消息至少要被写入到多少个副本才算是“已提交”。设置成大于 1 可以提升消息持久性。在实际环境中千万不要使用默认值 1。

  7. Broker 端设置 replication.factor > min.insync.replicas。如果两者相等,那么只要有一个副本挂机,整个分区就无法正常工作了。我们不仅要改善消息的持久性,防止数据丢失,还要在不降低可用性的基础上完成。推荐设置成 replication.factor = min.insync.replicas + 1。

  8. Consumer 端 确保消息消费完成再提交。Consumer 端有个参数 enable.auto.commit 设置成 false,并采用将两个 API 方法commitSync() 和 commitAsync() 组合使用进行手动提交位移的方式。这对于单 Consumer 多线程处理的场景而言是至关重要的。

  9. 业务维度的的0丢失架构, 采用 本地消息表+定时扫描 架构方案,实现业务维度的 0丢失,100%可靠性。

说说OOM三大场景和解决方案?

首先,说说什么是OOM?

OOM 全称 “Out Of Memory”,表示内存耗尽。

官方说明:Thrown when the Java Virtual Machine cannot allocate an object because it is out of memory, and no more memory could be made available by the garbage collector.

当 JVM 因为没有足够的内存来为对象分配空间,并且垃圾回收器也已经没有空间可供回收时,就会抛出这个错误。(注:非exception,已经严重到不足以被应用处理)。

为什么会出现 OOM,一般由这些问题引起

  1. 分配过少:JVM 初始化内存小,业务使用了大量内存;或者不同 JVM 区域分配内存不合理

  2. 内存泄漏:某一个对象被频繁申请,不用了之后却没有被释放,发生内存泄漏,导致内存耗尽

内存泄漏:申请使用完的内存没有释放,导致虚拟机不能再次使用该内存,此时这段内存就泄露了。因为申请者不用了,而又不能被虚拟机分配给别人用

内存溢出:申请的内存超出了 JVM 能提供的内存大小,此时称之为溢出

内存泄漏持续存在,最后一定会溢出,两者是因果关系

Java OOM的三大核心场景

图片

场景一、堆内存OOM

OOM的场景和解决方案

图片

分析方法通常有两种:

  • 类型一:在线分析,这个属于轻量级的分析:

  • 类型二:离线分析,这个属于重量级的分析:

类型一:在线OOM分析,这个属于轻量级的分析:

在线OOM分析,包括两种方法:

在线分析方法一:使用 jmap 分析TOP N对象

jmap(Java Memory Map)是jdk自带的java内存映像工具,使用jmap能够系统运行时的内存信息,同时能够将内存dump下来,分析内存泄露的问题。

  • 第一步:jmap 查看进程中占用资源最大的前N个对象,

  • 第二步:知道哪个对象消耗内存了,再去定位代码就不难了。然后 导出 快照文件 jmap -dump:live,format=b,file=文件路径/文件名 pid

这里我们使用它 -dump 选项,将内存信息dump到服务器某个地方,然后传到本地使用内存分析工具MAT进行内存分析。

jmap -dump:live,format=b,file=文件路径/文件名 pid

live:就是只dump 活着的对象 format=b 使用二进制 file= 快照文件保存路径

在线分析方法二:使用 Arthas 在线分析OOM

使用 Arthas 火焰图,分析TOP N对象 和调用堆栈

类型二:离线OOM分析,这个属于轻量级的分析:

第一步:使用Java内存快照工具:jmap 生成堆转储快照(一般称为headdump或dump文件)。

或者从服务器copy OOM自动dump出来的dump文件。

下面来一份JDK8的JVM参数默认配置

-Xms2g -Xmx2g (按不同容器,4G及以下建议为50%,6G以上,建议设置为70%)
-XX:MetaspaceSize=128m
-XX:MaxMetaspaceSize=512m
-Xss256k
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
-XX:AutoBoxCacheMax=20000
-XX:+HeapDumpOnOutOfMemoryError (当JVM发生OOM时,自动生成DUMP文件)
-XX:HeapDumpPath=/usr/local/logs/gc/
-XX:ErrorFile=/usr/local/logs/gc/hs_err_%p.log (当JVM发生崩溃时,自动生成错误日志)
-XX:+PrintGCApplicationStoppedTime
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:/usr/local/heap-dump/
  • -XX:+HeapDumpOnOutOfMemoryError

    发生OutOfMemoryError异常时,进行堆的Dump,这样就可以获取异常时的内存快照。

  • -XX:HeapDumpPath=/usr/local/heap-dump/

配置HeapDump的路径,方便我们管理,这里我们配置为/usr/local/heap-dump/。

JVM相关的启动参数 给出一些实战经验,让工作中更加从容:

  1. 调优参数务必加上-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=,发生OOM让JVM自动dump出内存,方便后续分析问题解决问题

  2. 堆内存不要设置的特别大,因为你设置的特别大,发生OOM时生成的dump文件就特别大,不好分析。建议不超过8G。

  3. 想主动dump出JVM内存,有挺多方式,但不管哪种方式,主动dump内存会引发STW,请线上压力最小的时间段操作。

    即通过arthas提供的命令heapdump主动dump出JVM的内存,这个操作会引发FGC,背后是STW,操作时请选择好时机。

第2步:导入到jvisualvm进行分析

场景二:元空间(MetaSpace) OOM

什么是元空间(MetaSpace)

JDK8 HotSpot JVM 将移除永久区,使用本地内存来存储类元数据信息并称之为:元空间(Metaspace);这与Oracle JRockit 和IBM JVM’s很相似,如下图所示

图片

这意味着不会再有java.lang.OutOfMemoryError: PermGen 问题,也不再需要你进行调优及监控内存空间的使用。在默认情况下,这些改变是透明的,接下来我们的展示将使你知道仍然要关注类元数据内存的占用。

请一定要牢记,元空间在直接内存,但是没有  消除类和类加载器导致的内存泄漏

由于永久代PermGen 空间被移除。所以,JVM 8的参数:PermSize 和 MaxPermSize 会被忽略并给出警告(如果在启用时设置了这两个参数)。

元空间是方法区在HotSpot JVM 中的实现,方法区主要用于存储类的信息、常量池、方法数据、方法代码等。方法区逻辑上属于堆的一部分,但是为了与堆进行区分,通常又叫“非堆”。

元空间的本质和永久代类似,都是对JVM规范中方法区的实现。

不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。理论上取决于32位/64位系统可虚拟的内存大小,可见也不是无限制的,需要配置参数。

元空间(Metaspace) 垃圾回收,会对僵死的类及类加载器的垃圾回收会进行回收,元空间(Metaspace) 垃圾回收的时机是,在元数据使用达到“MaxMetaspaceSize”参数的设定值时进行。

元空间(Metaspace) 容量

默认情况下,类元数据只受可用的本地内存限制(容量取决于是32位或是64位操作系统的可用虚拟内存大小)。

一般情况下避免 MetaSpace 耗尽内存,都会设置一个 MaxMetaSpaceSize参数,MaxMetaspaceSize用于限制本地内存分配给类元数据的大小。如果没有指定这个参数,元空间会在运行时根据需要动态调整。

动态的调整会造成元空间数据的复制和GC的消耗,为了避免弹性伸缩带来的额外 GC 消耗,我们会将-XX:MetaSpaceSize和-XX:MaxMetaSpaceSize两个值设置为固定的,但是这样也会导致在空间不够的时候无法扩容,然后频繁地触发 GC,最终 OOM。

在运行过程中,如果实际大小小于这个值,JVM 就会通过 -XX:MinMetaspaceFreeRatio 和 -XX:MaxMetaspaceFreeRatio 两个参数动态控制整个 MetaSpace 的大小。监控和调整元空间对于减小垃圾回收频率和减少延时是很有必要的。

持续的元空间垃圾回收说明,可能存在类、类加载器导致的内存泄漏或是大小设置不合适。

元空间(Metaspace) OOM现象

JVM 在启动后或者某个时间点开始,MetaSpace 的已使用大小在持续增长,同时每次 GC 也无法释放,调大 MetaSpace 空间也无法彻底解决

元空间(Metaspace) OOM 原因

核心原因:生成大量动态类

比如 spring 的 BeanUtils 的拷贝对象,json 的序列化大量使用反射,而反射在大量使用时,因为使用了缓存的原因,导致 ClassLoader 和它引用的 Class 等对象不能被回收,反射(包括上面提到的 spring 的 BeanUtils 的拷贝对象,json 的序列化),而反射在大量使用时,因为使用了缓存的原因,导致  ClassLoader 和它引用的 Class 等对象不能被回收。

如何定位和解决

分析dump文件,一般会在日志中发现了“Metaspace OOM”的提示

元空间(Metaspace) OOM 解决办法

  • 减少代码中,使用反射的情况,或者对反射进行优化。

  • 测试出服务实例的能力上限,进行服务的过载保护比如(限流等),防止突发流量将服务

场景三:堆外内存 OOM

堆外内存 OOM 现象

  • 现象1:Java 进程的 RES 甚至超过了 -Xmx 的大小

  • 现象2:Java 进程假死

Java 进程的 RES 甚至超过了 -Xmx 的大小 怎么看?通过 top 命令发现 Java 进程的 RES 甚至超过了 -Xmx 的大小。出现这些现象时,基本可以确定是出现了堆外内存泄漏。

使用top命令查看内存和cpu占用高的java进程,使用下面的命令:

top -c -p $(pgrep -d',' -f java)

图片

top命令查看进程信息, 主要的字段含义如下:

  • PID:进程的标识符。

  • USER:运行进程的用户名。

  • PR(优先级):进程的优先级。

  • NI(Nice值):进程的优先级调整值。

  • VIRT(虚拟内存):进程使用的虚拟内存大小。

  • RES(常驻内存):进程实际使用的物理内存大小。

  • SHR(共享内存):进程共享的内存大小。

  • %CPU:进程占用 CPU 的使用率。

  • %MEM:进程占用内存的使用率。

  • TIME+:进程的累计 CPU 时间。

top命令的res表示实际占用的内存,RES(Resident Set Size)是用来表示进程占用的物理内存的指标之一,它的单位是KB(千字节)。

具体地说,RES是指当前进程正在使用的物理内存大小,它包括了进程自身和它所拥有的子进程使用的内存,但不包括被共享的内存和被交换到磁盘上的内存。

res可能比xmx设置的要大, 因为统计内容不同

  • xmx只是堆内存(包括新生代(eden,from,to),老年代)

  • res范围更广,还包括metaDate,堆外内存等

堆外内存 OOM 原因

JVM 的堆外内存泄漏,主要有两种的原因:

  • 通过 UnSafe#allocateMemory,ByteBuffer#allocateDirect 主动申请了堆外内存而没有释放,常见于 NIO、Netty 等相关组件。

  • 代码中有通过 JNI 调用 Native Code 申请的内存没有释放。

堆外内存解决OOM对策

  • 进行线上指标监控

    通过反射获取堆外内存的指标,并且通过在线Prometheus+grafana进行采集和 监控,如果堆外内存一直增长,就大概率泄漏

  • 内存泄漏检测进行检测,然后根据找到泄漏的内存,进行Netty引用计数的清零

    一般泄漏都发生在最后一次使用后忘记调用释放方法造成

    通过Netty自带内存泄漏检测工具,配合压力测试,进行内存泄露检测, 解决OOM之后再上线。

秒杀Redis分段锁,如何设计?

问题场景:热点库存扣减问题

秒杀场景,有一个难度的问题:热点库存扣减问题。

  • 既要保证不发生超卖

  • 又要保证高并发

如果解决这个高难度的问题呢? 答案就是使用redis 分段锁。

分布式锁的作用

比如说在一个分布式系统中,存在客户端多个用户,同时通过多个业务微服务,发起一个数据修改。如果没有分布式锁机制保证,在那多台机器上的多个服务可能进行并发修改操作,导致数据修改的不一致,出现脏读脏写,这就会造成问题。

而分布式锁机制就是为了解决类似这类问题,保证多个服务之间互斥的访问共享资源,如果一个服务抢占了分布式锁,其他服务没获取到锁,就不进行后续操作。

图片

上图中,客户端1的服务抢占了分布式锁,可以去扣减库存。其他服务没获取到分布式锁,就不进行后续操作。

什么是分布式锁?

  • 当在分布式模型下,数据只有一份(或有限制),此时需要利用锁的技术控制某一时刻修改数据的进程数。

  • 用一个状态值表示锁,对锁的占用和释放通过状态值来标识。

分布式锁的条件:

  • 互斥性。在任意时刻,只有一个客户端能持有锁。

  • 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。

  • 具有容错性。只要大部分的 Redis 节点正常运行,客户端就可以加锁和解锁。

  • 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。

普通的分布式锁如何实现?

分布式锁的实现由很多种,文件锁、数据库、redis等等,比较多;分布式锁常见的多种实现方式:

  1. 数据库悲观锁;

  2. 数据库乐观锁;

  3. 基于Redis的分布式锁;

  4. 基于ZooKeeper的分布式锁。

在实践中,还是redis做分布式锁性能会高一些

图片

图片

普通分布式锁的性能问题

分布式锁一旦加了之后,对同一个商品的下单请求,会导致所有下单操作,都必须对同一个商品key加分布式锁。

假设某个场景,一个商品1分钟6000订单,每秒的 600个下单操作,假设加锁之后,释放锁之前,查库存 -> 创建订单 -> 扣减库存,每个IO操作100ms,大概300毫秒。具体如下图:

图片

可以再进行一下优化,将 创建订单 + 扣减库存  并发执行,将两个100ms 减少为一个100ms,这既是空间换时间的思想,大概200毫秒。

图片

将创建订单+扣减库存批量执行,减少一次IO,也是大概200毫秒。也就是单个商品而言,只有 5 QPS。

假设一个商品sku的数量是10000,10秒内秒杀完,也就是单个商品而言,需要 单商品 100 QPS,如何应对一个商品的 100qps秒杀。甚至,如果单商品需要  1000qps秒杀呢?

答案是,使用 分段加锁。

分段加锁的思想来源

LongAdder  的实现思想,可以用于 Redis分布式锁 作为性能提升的参考设计方案,将   Redis分布式锁 优化为  Redis分段锁。

图片

使用Redis分段锁提升秒杀的并发性能

假设一个商品1分钟6000订单,每秒的 600个下单操作,假设加锁之后,释放锁之前,查库存 -> 创建订单 -> 扣减库存,经过优化,每个IO操作100ms,大概200毫秒,一秒钟5个订单。

如何提高性能呢?空间换时间

为了达到每秒600个订单,可以将锁分成  600 /5 =120 个段,反过来, 每个段1秒可以操作5次, 120个段,合起来,及时每秒操作600次。

进行抢夺锁的,如果申请到一个具体的段呢?

  • 随机路由法

  • hash取模法

如果是用随机路由算法,可以将请求随机到一个分段, 如果不行,就轮询下一个分段,具体的流程,大致如下:

图片

这个是一个理论的时间预估,没有扣除 尝试下一个分段的 时间,   另外,实际上的性能, 会比理论上差,从咱们实操案例的测试结果,也可以证明这点。

随机路由法的问题:不同分端之间,可能库存消耗不均,导致部分用户无法扣减库存,反复进行重试,拖慢系统性能。

如何进一步优化:hash取模法。

使用hash取模法,减少库存消耗不均和无效重试

由于秒杀场景的分布式锁,实际上是为了防止超卖, 和库存是强相关的。所以,可以结合库存,把秒杀的分布式锁进行改进。

第一步:把redis 的分段方式进行演进,额外增加一个总库存分段锁,用于分配存储剩余的总库存。采用多批次少量分配的思路,通过定时任务,从总库存向分段库存中迁移库存。

第二步:使用hash取模法,把用户路由到某一个分段,如果分段里边的库存耗光了,就去访问剩余的总库存。

图片

库存动态迁移

为了防止分段多库存耗光,大家都去抢占总库存锁。采用多批次少量分配的思路,通过定时任务,从总库存向分段库存中迁移库存。

图片

至此, hash取模法的分段锁设计方案,已经完美实现。

系统的最佳线程数,怎么确定?

线程使用的两个核心规范

首先看编程规范中, 有两个很重要的,与线程有关的需要强制执行的规范:

规范一:【强制】线程资源必须通过线程池提供,不允许在应用中自行显式创建线程。

说明:Java线程的创建非常昂贵,需要JVM和OS(操作系统)配合完成大量的工作:

1)消耗内存资源:必须为线程堆栈分配和初始化大量内存块,其中包含至少1MB的栈内存。

2)消耗CPU资源:需要进行系统调用,以便在OS(操作系统)中创建和注册内核线程,大量内核线程调度会导致CPU上下文过度切换。

所以,Java高并发应用频繁创建和销毁线程的操作将是非常低效的,而且是不被编程规范所允许的。

如何降低Java线程的创建成本?必须使用到线程池。使用线程池的好处是减少在创建和销毁线程上所消耗的时间以及系统资源的开销,解决资源不足的问题。如果不使用线程池,有可能造成系统创建大量同类线程而导致消耗完内存或者“过度切换”的问题。

规范二:【强制】线程池不允许使用Executors去创建快捷线程池 ,而是通过ThreadPoolExecutor的方式,这样的处理方式让写的同学更加明确线程池的运行规则,规避资源耗尽的风险。

说明:Executors返回的线程池对象的弊端如下:

  • FixedThreadPool和SingleThreadPool: 允许的请求队列长度为Integer.MAX_VALUE,可能会堆积大量的请求,从而导致OOM。

  • CachedThreadPool和ScheduledThreadPool: 允许的创建线程数量为Integer.MAX_VALUE,可能会创建大量的线程,从而导致OOM。

通过以上规范,说明我们应用中,需要用自定义线程池。然而,由于构造一个线程池竟然有7个参数

图片

7个重要参数中,最为重要的三个是:核心,最大线程数量, BlockingQueue。前两个参数和线程数量有关系, 后一个和内存资源消耗有关。

线程数设置太少或者阻塞队列太小, 会导致大量任务被拒绝,抛出RejectedExecutionException,触发线上的接口降级,用户体验很差。

二线程数设置太多或者阻塞队列太长,会导致资源消费高而有效负荷很小, 特别是阻塞队列设置过长,会导致频繁FullGC,甚至OOM。

如何确定系统的最佳线程数?

如何确定系统的最佳线程数,大体上分三步:

第一步,理论预估;

第二步,压测验证;

第三步,监控调整。

图片

这是给大家归纳的,最为理想的:可监控/可弹性的 线程池模式

step1: 完成线程数的理论预估 (设计阶段)

首先,按照任务类型对线程池进行分类, 分为三类,具体如下图:

图片

第一类:IO 密集型线程池线程数预估

线程数就是 CPU的核数的2倍。

图片

第二类:CPU密集线程池线程数预估

CPU密集型任务并行执行的数量应当等于CPU的核心数, 线程数就是 CPU的核数

图片

第三类:混合型线程池线程数预估

混合型线程池线程数预估, 参考下面的的公式:

最佳线程数 = ((线程等待时间 + 线程 CPU 时间) / 线程 CPU 时间 ) * CPU 核数

图片

step2: 完成线程数的压测验证 (测试阶段)

过少的线程会造成任务拒绝,业务降级。过多的线程会造成,额外的内存开销CPU开销,甚至会导致OOM。所以,合理的线程池线程数,才是王道。在设计阶段完成了step1的线程数的理论预估之后, 那么我们的理论值就出来了。如何做验证呢?这里需要 压测。

根据公式:

服务器端最佳线程数量=((线程等待时间+线程cpu时间)/线程cpu时间) * cpu数量

前面线程等待时间,线程cpu时间都是 预估的 ,都是要验证的。

首先通过用户慢慢递增来进行性能压测,观察QPS。持续大的增加用户数, 压测出最大的吞吐量。然后再 收集 最大的吞吐量场景的 线程等待时间,线程cpu时间, 再计算出最佳线程数。

step3: 完成线程数的线上调整 (生产阶段)

压测的场景,是有限的。而线上的业务, 是复杂的,多样的。

由于系统运行过程中存在的不确定性,很难一劳永逸地规划一个合理的线程数。

所以,需要进行生产阶段线程数的两个目标:

  • 可监控预警

  • 可在线调整

图片

第一个维度:可监控预警

图片

第二个维度:可在线调整

图片

参数的在线动态调整:结合Nacos 实现动态化线程池

优秀的动态化线程池轮子,主要有:

  • Hippo4J

  • dynamic-tp

如果线上使用,可以使用这些轮子项目。

1.结合Nacos 实现动态化线程池架构

结合Nacos 实现动态化线程池的参数在线调整,架构如下:

图片

2.Nacos 上的配置

图片

3.线程池配置和nacos配置变更监听

图片

4.线程池配置的动态刷新

图片

图片

5.LinkedBlockingQueue 实现resize

LinkedBlockingQueue 不支持 resize, 需要重新定制。自定义可以扩容的 LinkedBlockingQueue ,结构如下:

图片

这里采用的是读写锁,对capacity 的设置,进行线程安全 保护:

图片

读写锁的使用如下:

图片

通过对capacity的安全修改,以达到动态扩展目的。其他代码和LinkedBlockingQueue代码一致。

在线监控预警:结合PGA实现Metric采集和预警

先把架构图画出来,大致如下:

图片

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值