kafka基础介绍

kafka基础介绍

kafka官方文档:http://kafka.apache.org/documentation.html#introduction

github: https://github.com/apache/kafka

中文文档: https://kafka.apachecn.org/documentation.html

Kafka常用命令:https://blog.csdn.net/qq_27399407/article/details/118966702

kafka 安装部署:https://blog.csdn.net/qq_27399407/article/details/118892240

kafka的发展史

Linkedln 2003年在美国创立,号称全球最大的职业社交网站,18年的时候用户就5.6亿了。做为一家社交企业,Linkedln 有非常多的IT系统,每天要收集很多实时生成的数据,比如用户活跃度、 用户的页面和可情况, 用户搜索内容等等。

image-20210918110359165

最开始这些数据的传输都是点对点的传输, 但是随着应用程序的增多, 指标的细分,数据量不断增长, 这种方式的效率也越来越差(参考: 2019 年10 月, 每天处理的消息是7 万亿条)。

image-20210918111641044

后来设计了设计了—个集中式的数据通道,大家统—通过这个数据通道来交互数据。

image-20210918112135731

这个通道最开始是使用ActiveMq来做的,但是经常会出现消息阻塞和服务不可用的情况,所以Linkedln就自己开发了一个消息引擎,就是kafka.

kafka 2010年开源,11年捐献给apache.我们现在说的kafka都是 apache kafka

kafka应用场景

消息传递

消息传递就是发送数据,做为HTTP、TCP或者RPC的替代方案。可以实现异步、解耦、 削峰 。因为 kafka 的吞吐量更高,在大规模消息系统中更有优势。

网站活动跟踪

把用户活动数据发布到管道中,可以用来做监控、实时处理、报表等。比如社交网站、购物网站用户的行为跟踪,依赖这些数据实现精准的推荐。

日志聚合

常见的ELK的日志系统

应用指标监控

运维数据的监控,cpu、内存、磁盘等使用情况

数据收集+流计算

数据集成指的是把 Kafka 的数据导入 Hadoop、HBase 等离线数据仓库,实现数据分析。

流计算;

kafka架构分析

image-20210918120744881

解读:

  • 有三台broker,
  • 两个topic, topicA topicB
  • topicA 有2个分区,三个副本,topicB 有一个分区,3个副本
  • 红框代表的是分区的的leader节点,黑框表示的是follower节点。同一个topic不同分区的leader节点会尽可能的分配到不同的broker上
  • 蓝色实线表示生产者写消息,青色实线表示消费者读消息。读写都是针对分区的leader节点。虚线表示分区follower节点从leader 节点同步数据
  • 两个消费者组,consumer group0 包含三个消费者,消费topicA的消息。其中三个消费者,consumer0消费第一个分区,consumer1消费第二个分区。consumer2 没有分区可以消费
  • consumer group1包含一个消费者,消费topicB的消息。
  • 若consumer group1 订阅的是topicA的消息的话,那么consumer0会同时消费 topicA的两个分区。

Broker

kafka是做为一个消息中间件是帮我们存储和转发消息的,kafka的服务端就叫做Broker。默认是9092端口。生产者和消费者都需要和broker建立链接才能实现消息的收发。消费者获取消息的方式是pull

控制器Controller

kafka集群中有且只有一个broker 作为Controller。 利用zk在几个Broker中选出来一个作为Controller(这里利用了zk的临时节点、watch机制、节点唯一的特性)

Controller的职责

  • 监听Broker变化
  • 监听topic变化
  • 监听partition变化
  • 获取和管理Broker、 Topic、 Partition的信息
  • 管理Partition的主从信息

Producer

生产者的负载均衡

生产者直接发送数据到主分区的服务器上,不需要经过任何中间路由。 为了让生产者实现这个功能,所有的 kafka 服务器节点都能响应这样的元数据请求: 哪些服务器是活着的,主题的哪些分区是主分区,分配在哪个服务器上,这样生产者就能适当地直接发送它的请求到服务器上。

客户端控制消息发送数据到哪个分区,这个可以实现随机的负载均衡方式,或者使用一些特定语义的分区函数。 我们有提供特定分区的接口让用于根据指定的键值进行hash分区(当然也有选项可以重写分区函数),例如,如果使用用户ID作为key,则用户相关的所有数据都会被分发到同一个分区上。 这允许消费者在消费数据时做一些特定的本地化处理。这样的分区风格经常被设计用于一些本地处理比较敏感的消费者。

也可以自定义分区策略 实现接口,org.apache.kafka.clients.producer.Partitioner,重写partition() 方法即可。

消息的异步发送

批处理是提升性能的一个主要驱动,为了允许批量处理,kafka 生产者会尝试在内存中汇总数据,并用一次请求批次提交信息。 批处理,不仅仅可以配置指定的消息数量,也可以指定等待特定的延迟时间(如64k 或10ms),这允许汇总更多的数据后再发送,在服务器端也会减少更多的IO操作。 该缓冲是可配置的,并给出了一个机制,通过权衡少量额外的延迟时间获取更好的吞吐量。

 // 多少条数据发送一次,默认16K
 props.put("batch.size",16384);
 // 批量发送的等待时间
 props.put("linger.ms",5);
 // 客户端缓冲区大小,默认32M,满了也会触发消息发送
 props.put("buffer.memory",33554432);

生产者发送消息的流程

生产者端主要有两个线程协调运行。main线程和sender线程。 main线程通过拦截器、序列化器、分区器。最终将消息对象 producerBatch 放入RecordAppendResult中,由sender线程进行批量发送(参考kafka官方客户端 kafka-clients-2.6.0)

image-20211013185055426

kafkaProducer 构造方法中会创建一个Sender对象并启动一个线程。

 KafkaProducer(Map<String, Object> configs,
                  Serializer<K> keySerializer,
                  Serializer<V> valueSerializer,
                  ProducerMetadata metadata,
                  KafkaClient kafkaClient,
                  ProducerInterceptors<K, V> interceptors,
                  Time time) {
      
   
                        ...........
            this.sender = newSender(logContext, kafkaClient, this.metadata);
            String ioThreadName = NETWORK_THREAD_PREFIX + " | " + clientId;
            this.ioThread = new KafkaThread(ioThreadName, this.sender, true);
            this.ioThread.start();
           
        } catch (Throwable t) {
            close(Duration.ofMillis(0), true);
            throw new KafkaException("Failed to construct kafka producer", t);
        }
    }

doSend方法

 private Future<RecordMetadata> doSend(ProducerRecord<K, V> record, Callback callback) {
        TopicPartition tp = null;
        try {
            throwIfProducerClosed();
            // first make sure the metadata for the topic is available
           
           // key序列化
            byte[] serializedKey;
            try {
                serializedKey = keySerializer.serialize(record.topic(), record.headers(), record.key());
            } catch (ClassCastException cce) {
               ..........
            }
            byte[] serializedValue;
            try {
                serializedValue = valueSerializer.serialize(record.topic(), record.headers(), record.value());
            } catch (ClassCastException cce) {
                .......
            }
          
           // 计算分区
            int partition = partition(record, serializedKey, serializedValue, cluster);
            tp = new TopicPartition(record.topic(), partition);

            ..........

            // 放入消息累加器 RecordAppendResult
            RecordAccumulator.RecordAppendResult result = accumulator.append(tp, timestamp, serializedKey,
                    serializedValue, headers, interceptCallback, remainingWaitMs, true, nowMs);

          
            // 到达时机就唤醒sender线程发送
            if (result.batchIsFull || result.newBatchCreated) {
                log.trace("Waking up the sender since topic {} partition {} is either full or getting a new batch", record.topic(), partition);
                this.sender.wakeup();
            }
            return result.future;
            // handling exceptions and record the errors;
            // for API exceptions return them in the future,
            // for other exceptions throw directly
        } catch (ApiException e) {
          .......
        }
    }

拦截器

拦截器类似Spring的interceptor,可以组成拦截器链,可以实现再消息发送过程中实现一些操作。

比如这个场景:对kafka消息按条进行计费,就可以通过拦截器来实现。

KafkaProducer.send()方法中调用

    @Override
    public Future<RecordMetadata> send(ProducerRecord<K, V> record, Callback callback) {
        // intercept the record, which can be potentially modified; this method does not throw exceptions
        ProducerRecord<K, V> interceptedRecord = this.interceptors.onSend(record);
        return doSend(interceptedRecord, callback);
    }

可以通过如下代码指定。

    // 添加拦截器
    List<String> interceptors = new ArrayList<>();
    interceptors.add("com.pengxincheng.interceptor.ChargingInterceptor");
    props.put(ProducerConfig.INTERCEPTOR_CLASSES_CONFIG, interceptors);

序列化

为了把对象的状态信息转化为可存储或传输的形式,都需要进行序列化。

org.apache.kafka.common.serialization.Serializer。kafka中针对不同的数据类型提供了如下的序列化工具

image-20211013191523343

除了自带的序列化方法,也可以使用JSON、Thrift、Protobuf等、或者自定义序列化器。通过实现org.apache.kafka.common.serialization.Serializer接口即可以自定义序列化器。

分区分配策略

用于确定一条消息究竟要发送到哪个分区。

org.apache.kafka.clients.producer.Partitioner kafka提供以下三种分区分配策略

image-20211013194648298

假如消息对象上指定了分区,则发送到指定分区。若没有指定,则安照指定的分区分配策略。

  • DefaultPartitioner : 默认策略。如果消息键为空消息发送的分区先保持粘连(也就是先向同一个分区发送);如果当前 batchlinger已满或者 ms时已经发送,那么新的消息会发给另外的分区(选择策略是轮询)。若消息键不为空,对键进行散列,然后根据散列值把消息映射到对应的分区上

  • RoundRobinPartitioner: 轮询策略

  • UniformStickyPartitioner:(消息无key时默认策略)当存在无key的序列消息时,我们消息发送的分区优先保持粘连,如果当前分区下的batch已经满了或者 linger.ms延迟时间已到开始发送,就会重新启动一个新的分区。

消息发送的分区保持粘连,个人理解是为了减少客户端于服务端的交互次数,从而提高性能

自定义分区

实现org.apache.kafka.clients.producer.Partitioner接口,在配置中设置实现的类prop.put(“partitioner.class”, Xxxx.class);

消息收集器

RecordAccumulator就是producer的缓存,Kafka借助RecordAccumulator实现消息批量发送,从而提高网络性能。具体就是RecordAccumulator内部维护了一个ConcurrentMap, ConcurrentMap<TopicPartition, Deque> batches,发往同一个topic的分区消息放在一个双端队列中,形成一个消息组,这样sender线程就能将这个组的消息批量发送。

Ack应答机制

producer 发送消息时,有失败重试的机制。如何确定消息发送成功了呢?kafka提供了三种应答机制。可通过 prop.put(“acks”, “1”)设置;

  1. acks = 0:

    客户端只要把消息发送出去,不管那条数据有没有在哪怕Partition Leader上落到磁盘,就不管他了,直接认为这个消息发送成功。

    如果broker故障会导致数据丢失。

  2. acks=1(默认配置):

    只要Partition Leader接收到消息而且写入本地磁盘了,就认为成功了,不管其他的Follower有没有同步过去这条消息了。

    如果在Follower同步成功之前,leader故障,会丢失数据。

  3. acks=all

    Partition Leader接收到消息之后,还必须要求ISR列表里跟Leader保持同步的那些Follower都要把消息同步过去,才能认为这条消息是写入成功了。

    如果在Follower完成同步之后,Broker发送ack之前,leader发生故障。生产者未收到ack,若配置了消息重发,会导致数据重复。

Consumer

kafka consumer 通过一个 fetch请求来获取他想要消费的partition。consumer的每次请求都在log中指定了对应的offset,并接收从该位置的一大块儿数据。因此,consumer 对于该位置的控制就显得极为重要,并且可以在需要的时候通过回退到该位置再次消费对应的数据。

push VS pull

一般来说消费者获取消息又两种方式。puhs pull

push: 只要有消息到达broker直接推送给消费者

pull: 消费者请求broker获取消息

kafka采用了大多数的消息系统所共享的方式:即 producer 把数据 push 到 broker,然后 consumer 从 broker 中 pull 数据。

push-based 模式的缺点:当消费速率低于生产速率时,consumer 往往会不堪重负,

pull-based 模式的优点:consumer 速率落后于 producer 时,可以在适当的时间赶上来,并不会使consumer超载甚至宕机。第二个好处在于,它可以大批量生产要发送给 consumer 的数据。而 push-based 系统必须选择立即发送请求或者积累更多的数据,然后在不知道下游的 consumer 能否立即处理它的情况下发送这些数据。如果系统调整为低延迟状态,这就会导致一次只发送一条消息,以至于传输的数据不再被缓冲,这种方式是极度浪费的。 而 pull-based 的设计修复了该问题,因为 consumer 总是将所有可用的(或者达到配置的最大长度)消息 pull 到 log 当前位置的后面,从而使得数据能够得到最佳的处理而不会引入不必要的延迟。

pull-based 系统的不足之处在于:如果 broker 中没有数据,consumer 可能会在一个紧密的循环中结束轮询,实际上 busy-waiting 直到数据到来。为了避免 busy-waiting,我们在 pull 请求中加入参数,使得 consumer 在一定时间中阻塞等待,直到数据到来

Consumer Offset

前面说过Partition的消息是顺序写入的,读取之后不会被删除。如果消费者挂了,或者下一次读取,或者要从某个特定位置读取消息,会不会出现重复消费的情况呢?

答案是不会的。因为消息是由顺序的,kafka对消息进行编号用来标识唯一一条消息。这个编号就叫做offset (偏移量)

这个consumer 和partition间的偏移量就保存在日志目录下的 __consumer_offsets-02下面。

image-20211015141337260

确定消费者足在哪个分区下

Math.abs(groupID.hashCode()) % 50(默认值)

如果增加了一个新的消费者组的话应该从哪里消费?

auto.offset.reset = earliest
  • latest : 从最新的消息开始消费。历史消息是不能消费的
  • earliest 从最早的消息开始消费。可以消费到历史数据
  • none. 如果Consumer group在服务端找不到offset就会报错

offset 提交

消费者可以选择手动或者自动提交

enable.auto.commit     
auto.commit.interval.ms

一个控制是否开启自动提交,默认 true. 另一个控制自动提交的频率 默认5

关闭自动提交后,需要手动提交offset 调用 consumer.commitSync(); 或consumer.commitAsync();

分区分配策略

RangeAssignor 默认策略

RangeAssignor策略的原理是按照消费者总数和分区总数进行整除运算来获得一个跨度,然后将分区按照跨度进行平均分配,以保证分区尽可能均匀地分配给所有的消费者。对于每一个topic,RangeAssignor策略会将消费组内所有订阅这个topic的消费者按照名称的字典序排序,然后为每个消费者划分固定的分区范围,如果不够平均分配,那么字典序靠前的消费者会被多分配一个分区。

假设n=分区数/消费者数量,m=分区数%消费者数量,那么前m个消费者每个分配n+1个分区,后面的(消费者数量-m)个消费者每个分配n个分区。

为了更加通俗的讲解RangeAssignor策略,我们不妨再举一些示例。假设消费组内有2个消费者C0和C1,都订阅了主题t0和t1,并且每个主题都有4个分区,那么所订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t0p3、t1p0、t1p1、t1p2、t1p3。最终的分配结果为:

消费者C0:t0p0、t0p1、t1p0、t1p1消费者C1:t0p2、t0p3、t1p2、t1p3

这样分配的很均匀,那么此种分配策略能够一直保持这种良好的特性呢?我们再来看下另外一种情况。假设上面例子中2个主题都只有3个分区,那么所订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t1p0、t1p1、t1p2。最终的分配结果为:

消费者C0:t0p0、t0p1、t1p0、t1p1消费者C1:t0p2、t1p2

RoundRobinAssignor

RoundRobinAssignor策略的原理是将消费组内所有消费者以及消费者所订阅的所有topic的partition按照字典序排序,然后通过轮询方式逐个将分区以此分配给每个消费者。RoundRobinAssignor策略对应的partition.assignment.strategy参数值为:org.apache.kafka.clients.consumer.RoundRobinAssignor。

如果同一个消费组内所有的消费者的订阅信息都是相同的,那么RoundRobinAssignor策略的分区分配会是均匀的。举例,假设消费组中有2个消费者C0和C1,都订阅了主题t0和t1,并且每个主题都有3个分区,那么所订阅的所有分区可以标识为:t0p0、t0p1、t0p2、t1p0、t1p1、t1p2。最终的分配结果为:

消费者C0:t0p0、t0p2、t1p1消费者C1:t0p1、t1p0、t1p2

如果同一个消费组内的消费者所订阅的信息是不相同的,那么在执行分区分配的时候就不是完全的轮询分配,有可能会导致分区分配的不均匀。如果某个消费者没有订阅消费组内的某个topic,那么在分配分区的时候此消费者将分配不到这个topic的任何分区。

举例,假设消费组内有3个消费者C0、C1和C2,它们共订阅了3个主题:t0、t1、t2,这3个主题分别有1、2、3个分区,即整个消费组订阅了t0p0、t1p0、t1p1、t2p0、t2p1、t2p2这6个分区。具体而言,消费者C0订阅的是主题t0,消费者C1订阅的是主题t0和t1,消费者C2订阅的是主题t0、t1和t2,那么最终的分配结果为:

消费者C0:t0p0消费者C1:t1p0消费者C2:t1p1、t2p0、t2p1、t2p2

StickyAssignor

两个目的

  1. 分区的分配要尽可能的均匀;
  2. reblance时,分区的分配尽可能的与上次分配的保持相同。

当两者发生冲突时,第一个目标优先于第二个目标。鉴于这两个目标,StickyAssignor策略的具体实现要比RangeAssignor和RoundRobinAssignor这两种分配策略要复杂很多。

我们举例来看一下StickyAssignor策略的实际效果。
假设消费组内有3个消费者:C0、C1和C2,它们都订阅了4个主题:t0、t1、t2、t3,并且每个主题有2个分区,也就是说整个消费组订阅了t0p0、t0p1、t1p0、t1p1、t2p0、t2p1、t3p0、t3p1这8个分区。最终的分配结果如下:

消费者C0:t0p0、t1p1、t3p0消费者C1:t0p1、t2p0、t3p1消费者C2:t1p0、t2p1

这样初看上去似乎与采用RoundRobinAssignor策略所分配的结果相同,但事实是否真的如此呢?再假设此时消费者C1脱离了消费组,那么消费组就会执行再平衡操作,进而消费分区会重新分配。如果采用RoundRobinAssignor策略,那么此时的分配结果如下:

消费者C0:t0p0、t1p0、t2p0、t3p0消费者C2:t0p1、t1p1、t2p1、t3p1

如分配结果所示,RoundRobinAssignor策略会按照消费者C0和C2进行重新轮询分配。而如果此时使用的是StickyAssignor策略,那么分配结果为:

消费者C0:t0p0、t1p1、t3p0、t2p0消费者C2:t1p0、t2p1、t0p1、t3p1

可以看到分配结果中保留了上一次分配中对于消费者C0和C2的所有分配结果,并将原来消费者C1的“负担”分配给了剩余的两个消费者C0和C2,最终C0和C2的分配还保持了均衡。

如果发生分区重分配,那么对于同一个分区而言有可能之前的消费者和新指派的消费者不是同一个,对于之前消费者进行到一半的处理还要在新指派的消费者中再次复现一遍,这显然很浪费系统资源。StickyAssignor策略如同其名称中的“sticky”一样,让分配策略具备一定的“粘性”,尽可能地让前后两次分配相同,进而减少系统资源的损耗以及其它异常情况的发生。

Topic

生产者和消费者是通过队列关联起来的,生产随着发送消息,消费者拉取消息都需要指定队列

在kafka中这个队列叫做Topic是一个逻辑概念。一个生产者可以发送消息到多个topic。一个消费者,也可以从多个topic获取消息(一般不建议这么做).

image-20210820173144660

Partition

如果一个topic的消息太多会带来两个问题,一是不方便横向扩展。比如想要在集群中把数据分散到不同机器上实现扩展,而是不是通过升级硬件做到,如果一个topic的消息无法在物理上拆分到多个机器的时候,这个是做不到的。第二个是性能问额,大量客户端都操作同一个topic,在高并发的情况下性能会大大下降

kafka引入了partition(分区)的概念。一个topic可以划分成多个分区,分区在创建topic时指定,一个topic至少有一个分区。若不指定,走默认的分区数,默认是一个。在server.propertise中可配置

num.partitions=1

partition思想上类似分库分表,实现横向扩展和负载的目的。

假设topic有三个分区,生产者依次发送9条消息(这里并非是批量发送,单次发送9条),对消息编号。

第一个分区存 1、4、7, 第二个分区存2、5、8, 第三个分区存 3、6、9

每一个partition都有一个物理目录,这个目录可以在server.propertise中指定

Replica

如果partition的数据只存储一份,在网络或者硬件故障的时候,该分区的数据就无法读取或者无法恢复了

kafka在0.8版本增加了副本机制

每个partiton可以有若干个副本(replica),副本必须分配在不同的brocker上面,也就是说,创建topic指定副本数量的时候不能超过brocker的数量。若超过了则会创建失败。

服务端可通过:offsets. topic.replication.factor 设置默认的副本数量

image-20210913152851618

图中红色框表示leader副本,黑色框表示follower副本。

生产者和消费者都的写和读都是针对leader副本的。follower为什么不提供读的服务呢?

由于kafka的使用场景决定,其读取数据时更关注数据的一致性。从leader读取和写入可以保证所有客户端都得到相同的数据,否则可能存在一些在ISR中注册的节点(replication-factor大于min.insync.replicas),因未来得及更新副本而无法提供的数据。

Replica 在broker 中如何分布?

image-20211014205451356

  1. 副本因子不能大于broker的个数。大于就没意义了
  2. 第一个分区的一个副本(编号为0的分区)的位置是从brokerList中随机选择的
  3. 其余分区的第一个副本,相对于第一个分区的第一个副本依次向后移
  4. 确定了分区的第一个副本后,分区的其他副本按照第一个副本一次后移
  5. 通常情况下第一个副本就是分区的leader. (像重启之类的就比较复杂了,有兴趣的可以研究下源码)

这样的好处就是,尽量将leader分区均匀的分布到各个Broker上。

ISR

isr 的全称是:In-Sync Replicas

是一个副本的列表,里面存储的都是能跟leader 数据一致的副本,确定一个副本在isr列表中,有2个判断条件

  • 根据副本和leader 的交互时间差,如果大于某个时间差 就认定这个副本不行了,就把此副本从isr 中剔除,此时间差根据

    配置参数rerplica.lag.time.max.ms=10000 决定 (单位ms)

  • 根据leader 和副本的信息条数差值决定是否从isr 中剔除此副本,此信息条数差值根据配置参数rerplica.lag.max.messages=4000 决定 (单位条)

isr 中的副本删除或者增加 都是通过一个周期调度来管理的

Kafka Broker故障,分区leader选择时,只有在isr列表中的副本才有资格参与选举。

Segment

image-20210918112916877

为了解决单个 log文件过大导致检索效率变低的问题,kafka引入了 segment 。将pattition在做一个切分,切分出来的就叫做segment.

在磁盘上,每个磁盘上有一个log文件和2个index文件组成。

.log是日志文件,在一个segment中,日志是追加写入的,满足一定条件就会切分出新的日志文件,产生一个新的segment

一是大小,二是消息时间戳和当前系统时间戳的差值,三是触发偏移量索引文件或时间戳索引文件分段字节限额

log.segment.bytes = 1073741824 (1G)log.roll.hours = 168  (7天)log.index.size.max.bytes = 10485760 (10MB)

Consumer Group

image-20210918115120507

当存在多个消费者时如何管理,kafka引入了消费者组(Consumer Group)。代码中通过group id配置

消费同一个topic的消费者不一定是同一个消费者组。只有groupId相同的消费者才是一个消费者组。

同一个Group的消费者不能消费相同的partition

Kafka API

类别作用
Producer API用于应用将数据发送到kafka的topic中
Consumer API用于应用从kafka的topic中读取数据流
Stream API用于从来源topic到目标topic转化数据流
Connect API用于持续的从源系统输入数据到kafka
Admin API用于检测topic、broker以及其他kafka实例。于kafka自带的脚本命令作用类似

这里我们只讨论下平时用的比较多的生产者和消费者API

生产者和消费者API都是在这个包下

<dependency>    <groupId>org.apache.kafka</groupId>    <artifactId>kafka-clients</artifactId>    <version>2.6.0</version></dependency>

选择版本时尽量保证客户端的版本和服务端的版本保持一致

如果是SpringBoot集成的话可以参考下图

image-20211012145742211

参考地址: https://spring.io/projects/spring-kafka

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值