Kafka

1、Kafka简介

Kafka是一种消息队列,主要用来处理大量数据状态下的消息队列,一般用来做日志的处理。既然是消息队列,那么Kafka也就拥有消息队列的相应的特性了。

消息队列的好处:

  • 解耦合

耦合的状态表示当你实现某个功能的时候,是直接接入当前接口,而利用消息队列,可以将相应的消息发送到消息队列,这样的话,如果接口出了问题,将不会影响到当前的功能。

解耦合

  •  异步处理

异步处理替代了之前的同步处理,异步处理不需要让流程走完就返回结果,可以将消息发送到消息队列中,然后返回结果,剩下让其他业务处理接口从消息队列中拉取消费处理即可

  • 流量削峰 

高流量的时候,使用消息队列作为中间件可以将流量的高峰保存在消息队列中,从而防止了系统的高请求,减轻服务器的请求处理压力。

1.1、Kafka消费模式

Kafka的消费模式主要有两种:一种是一对一的消费,也即点对点的通信,即一个发送一个接收。第二种为一对多的消费,即一个消息发送到消息队列,消费者根据消息队列的订阅拉取消息消费。

一对一

一对一消费模式

消息生产者发布消息到Queue队列中,通知消费者从队列中拉取消息进行消费。消息被消费之后则删除,Queue支持多个消费者,但对于一条消息而言,只有一个消费者可以消费,即一条消息只能被一个消费者消费。

一对多

一对多消费

这种模式也称为发布/订阅模式,即利用Topic存储消息,消息生产者将消息发布到Topic中,同时有多个消费者订阅此topic,消费者可以从中消费消息,注意发布到Topic中的消息会被多个消费者消费,消费者消费数据之后,数据不会被清除,Kafka会默认保留一段时间,然后再删除。 

1.2、Kafka的基础架构

Kafka的基础架构

Kafka像其他Mq一样,也有自己的基础架构,主要存在生产者Producer、Kafka集群Broker、消费者Consumer、注册消息Zookeeper 

  • Producer:消息生产者,向Kafka中发布消息的角色。
  • Consumer:消息消费者,即从Kafka中拉取消息消费的客户端。
  • Consumer Group:消费者组,消费者组则是一组中存在多个消费者,消费者消费Broker中当前Topic的不同分区中的消息,消费者组之间互不影响,所有的消费者都属于某个消费者组,即消费者组是逻辑上的一个订阅者。某一个分区中的消息只能够一个消费者组中的一个消费者所消费
  • Broker:经纪人,一台Kafka服务器就是一个Broker,一个集群由多个Broker组成,一个Broker可以容纳多个Topic。
  • Topic:主题,可以理解为一个队列,生产者和消费者都是面向一个Topic
  • Partition:分区,为了实现扩展性,一个非常大的Topic可以分布到多个Broker上,一个Topic可以分为多个Partition,每个Partition是一个有序的队列(分区有序,不能保证全局有序)
  • Replica:副本Replication,为保证集群中某个节点发生故障,节点上的Partition数据不丢失,Kafka可以正常的工作,Kafka提供了副本机制,一个Topic的每个分区有若干个副本,一个Leader和多个Follower
  • Leader:每个分区多个副本的主角色,生产者发送数据的对象,以及消费者消费数据的对象都是Leader。
  • Follower:每个分区多个副本的从角色,实时的从Leader中同步数据,保持和Leader数据的同步,Leader发生故障的时候,某个Follower会成为新的Leader。

上述一个Topic会产生多个分区Partition,分区中分为Leader和Follower,消息一般发送到Leader,Follower通过数据的同步与Leader保持同步,消费的话也是在Leader中发生消费,如果多个消费者,则分别消费Leader和各个Follower中的消息,当Leader发生故障的时候,某个Follower会成为主节点,此时会对齐消息的偏移量。

2、Kafka高级

2.1、工作流程

Kafka中消息是以topic进行分类的,Producer生产消息,Consumer消费消息,都是面向topic的。

Kafka工作流程

Topic是逻辑上的改变,Partition是物理上的概念,每个Partition对应着一个log文件,该log文件中存储的就是producer生产的数据,topic=N*partition;partition=log

Producer生产的数据会被不断的追加到该log文件的末端,且每条数据都有自己的offset,consumer组中的每个consumer,都会实时记录自己消费到了哪个offset,以便出错恢复的时候,可以从上次的位置继续消费。

流程:Producer => Topic(Log with offset)=> Consumer

2.2、文件储存

Kafka文件存储也是通过本地落盘的方式存储的,主要是通过相应的log与index等文件保存具体的消息文件。

文件存储

生产者不断的向log文件追加消息文件,为了防止log文件过大导致定位效率低下,Kafka的log文件以1G为一个分界点,当.log文件大小超过1G的时候,此时会创建一个新的.log文件,同时为了快速定位大文件中消息位置,Kafka采取了分片和索引的机制来加速定位。

在kafka的存储log的地方,即文件的地方,会存在消费的偏移量以及具体的分区信息,分区信息的话主要包括.index.log文件组成,

log文件 分区目的是为了备份,所以同一个分区存储在不同的broker上,即当third-2存在当前机器kafka01上,实际上再kafka03中也有这个分区的文件(副本),分区中包含副本,即一个分区可以设置多个副本,副本中有一个是leader,其余为follower。

index文件与log文件结构示意图

如果.log文件超出大小,则会产生新的.log文件。如下所示

00000000000000000000.index
00000000000000000000.log
00000000000000170410.index
00000000000000170410.log
00000000000000239430.index
00000000000000239430.log

 此时如何快速定位数据,步骤:

.index文件存储的消息的offset+真实的起始偏移量。.log中存放的是真实的数据。

  •  首先通过二分查找.index文件到查找到当前消息具体的偏移,如上图所示,查找为2,发现第二个文件为6,则定位到一个文件中。
  • 然后通过第一个.index文件通过seek定位元素的位置3,定位到之后获取起始偏移量+当前文件大小=总的偏移量。
  • 获取到总的偏移量之后,直接定位到.log文件即可快速获得当前消息大小。

 2.3、生产者分区策略

 分区的原因

  • 方便在集群中扩展:每个partition通过调整以适应它所在的机器,而一个Topic又可以有多个partition组成,因此整个集群可以适应适合的数据
  • 可以提高并发:以Partition为单位进行读写。类似于多路。

分区的原则

  • 指明partition(这里的指明是指第几个分区)的情况下,直接将指明的值作为partition的值
  • 没有指明partition的情况下,但是存在值key,此时将key的hash值与topic的partition总数进行取余得到partition值
  • 值与partition均无的情况下,第一次调用时随机生成一个整数,后面每次调用在这个整数上自增,将这个值与topic可用的partition总数取余得到partition值,即round-robin算法。

2.4、生产者ISR

为保证producer发送的数据能够可靠的发送到指定的topic中,topic的每个partition收到producer发送的数据后,都需要向producer发送ackacknowledgement,如果producer收到ack就会进行下一轮的发送,否则重新发送数据

消息发送示意图

发送ack的时机

确保有follower与leader同步完成,leader在发送ack,这样可以保证在leader挂掉之后,follower中可以选出新的leader(主要是确保follower中数据不丢失)

follower同步完成多少才发送ack

  • 半数以上的follower同步完成,即可发送ack
  • 全部的follower同步完成,才可以发送ack

3、消费者分区分配策略

消费方式

consumer采用pull拉的方式来从broker中读取数据。push推的模式很难适应消费速率不同的消费者,因为消息发送率是由broker决定的,它的目标是尽可能以最快的速度传递消息,但是这样容易造成consumer来不及处理消息,典型的表现就是拒绝服务以及网络拥塞。而pull方式则可以让consumer根据自己的消费处理能力以适当的速度消费消息。

pull模式不足在于如果Kafka中没有数据,消费者可能会陷入循环之中 (因为消费者类似监听状态获取数据消费的),一直返回空数据,针对这一点,Kafka的消费者在消费数据时会传入一个时长参数timeout,如果当前没有数据可供消费,consumer会等待一段时间之后再返回,时长为timeout。

3.1、分区分配策略

一个consumer group中有多个consumer,一个topic有多个partition,所以必然会涉及到partition的分配问题,即确定那个partition由那个consumer消费的问题。

Kafka的两种分配策略:

  • round-robin循环
  • range

Round-Robin

主要采用轮询的方式分配所有的分区,该策略主要实现的步骤:

假设存在三个topic:t0/t1/t2,分别拥有1/2/3个分区,共有6个分区,分别为t0-0/t1-0/t1-1/t2-0/t2-1/t2-2,这里假设我们有三个Consumer,C0、C1、C2,订阅情况为C0:t0,C1:t0、t1,C2:t0/t1/t2。

此时round-robin采取的分配方式,则是按照分区的字典对分区和消费者进行排序,然后对分区进行循环遍历,遇到自己订阅的则消费,否则向下轮询下一个消费者。即按照分区轮询消费者,继而消息被消费。

分区在循环遍历消费者,自己被当前消费者订阅,则消息与消费者共同向下(消息被消费),否则消费者向下消息继续遍历(消息没有被消费)。轮询的方式会导致每个Consumer所承载的分区数量不一致,从而导致各个Consumer压力不均。上面的C2因为订阅的比较多,导致承受的压力也相对较大。

Range

Range的重分配策略,首先计算各个Consumer将会承载的分区数量,然后将指定数量的分区分配给该Consumer。假设存在两个Consumer,C0和C1,两个Topic,t0和t1,这两个Topic分别都有三个分区,那么总共的分区有6个,t0-0,t0-1,t0-2,t1-0,t1-1,t1-2。分配方式如下:

  • range按照topic一次进行分配,即消费者遍历topic,t0,含有三个分区,同时有两个订阅了该topic的消费者,将这些分区和消费者按照字典序排列。
  • 按照平均分配的方式计算每个Consumer会得到多少个分区,如果没有除尽,多出来的分区则按照字典序挨个分配给消费者。按照此方式以此分配每一个topic给订阅的消费者,最后完成topic分区的分配。
     

Range策略

按照range的方式进行分配,本质上是以此遍历每个topic,然后将这些topic按照其订阅的consumer数进行平均分配,多出来的则按照consumer的字典序挨个分配,这种方式会导致在前面的consumer得到更多的分区,导致各个consumer的压力不均衡。

3.2、消费者offset的存储 

由于Consumer在消费过程中可能会出现断电宕机等故障,Consumer恢复以后,需要从故障前的位置继续消费,所以Consumer需要实时记录自己消费到了那个offset,以便故障恢复后继续消费。

zookeeper节点存储数据详细信息

Kafka0.9版本之前,consumer默认将offset保存在zookeeper中,从0.9版本之后,consumer默认将offset保存在kafka一个内置的topic中,该topic为__consumer_offsets 

4、Producer API

4.1、消息发送流程

Kafka 的 producer 发送信息采用的是异步发送的方式。在消息发送的过程中,涉及到两个线程,一个是 main 线程,一个是 Sender 线程,以及一个线程共享变量—— RecordAccumulator 。main 线程将消息发送给 RecordAccumulator,Sender 线程不断从 RecordAccumulator 中拉取消息发送到 Kafka broker。

img

 4.2、异步发送 API

  • 导入依赖
<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
    <version>2.2.6.RELEASE</version>
</dependency>
<dependency>
    <groupId>org.apache.kafka</groupId>
    <artifactId>kafka-clients</artifactId>
    <version>2.1.0</version>
</dependency>
  • 不带回调函数的 API

KafkaProducer: 需要一个生产者对象,用来发送数据。
ProducerConfig: 获取所需一系类配置参数。
ProducerRecord: 每条数据都要封装成一个 ProducerRecord 对象。

public class MyProducer {
    public static void main(String[] args) throws ExecutionException, InterruptedException{
        String server = "162.14.109.33:9092";
        // 1.创建kafka生产者的配置信息
        Properties properties = new Properties();
        // 2.指定连接的Kafka集群
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,server);
        // 3.ACK应答级别
        //properties.put("acks", "all");
        properties.put(ProducerConfig.ACKS_CONFIG, "all");
        // 4.重试次数
        properties.put("retries", 0);
        // 5.批次大小
        properties.put("batch.size", 16384);
        // 6.等待时间
        properties.put("linger.ms", 10000);
        // 7.RecordAccumulator 缓冲区大小
        properties.put("buffer.memory", 33554432);
        // 8.key,value的序列化
        properties.put("key.serializer",
"org.apache.kafka.common.serialization.StringSerializer");
        properties.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        // 9.创建生产者对象
        KafkaProducer<String, String> producer = new KafkaProducer<>(properties);
        // 10.发送数据

        //异步
//这个生产者写一条消息的时候,先是写到某个缓冲区,
// 这个缓冲区里的数据还没写到broker集群里的某个分区的时候,
// 它就返回到client去了。虽然效率快,但是不能保证消息一定被发送出去了。

        producer.send(new ProducerRecord<>("test2", "fmy","这是生产者异步发送的消息!"));


//同步
//这个生产者写一条消息的时候,它就立马发送到某个分区去。
// follower还需要从leader拉取消息到本地,follower再向leader发送确认,
// leader再向客户端发送确认。由于这一套流程之后,客户端才能得到确认,所以很慢。
//        Future<RecordMetadata> demo = producer.send(new ProducerRecord<>("demo", "neu", "这里是生产者同步发送的消息!"));
//        RecordMetadata recordMetadata = demo.get();
//        System.out.println("得到ack");
        // 11. 关闭资源
        producer.close();

    }
}
  • 带回调函数的 API

回调函数会在 producer 收到 ack 时调用,为异步调用,该方法有两个参数,分别是 RecordMetadata 和 Exception,如果 Exception 为 null,说明消息发送成功,如果 Exception 不为 null,说明消息发送失败。

public class CallBackProducer {
    public static void main(String[] args) {
        String server = "162.14.109.33:9092";
        // 1.创建配置信息
        Properties properties = new Properties();
        properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, server);
        properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer");
        properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer");
        // 2.创建生产者对象
        KafkaProducer<String, String> producer = new KafkaProducer<>(properties);
        // 3.发送数据
        producer.send(new ProducerRecord<>("test2", "fmy","这是带回调方法的生产者发送的消息!"), (metadata, exception) -> {
            if (exception == null) {
                System.out.println("元数据分区:"+metadata.partition() + ",偏移量:" + metadata.offset());
            } else {
                exception.printStackTrace();
            }
        });
        // 4.关闭资源
        producer.close();
    }
}
  • 自定义分区器
public class MyPartitioner implements Partitioner
{
    @Override
    public int partition(String s, Object o, byte[] bytes, Object o1, byte[] bytes1, Cluster cluster)
    {
        return 1;
    }

    @Override
    public void close()
    {

    }

    @Override
    public void configure(Map<String, ?> map)
    {

    }
}

在生产者中加入自定义分区器

public class PartitionProducer{
    public static void main(String[] args) {
        String server = "162.14.109.33:9092";
        // 1.创建配置信息
        Properties properties = new Properties();
        properties.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, server);
        properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer");
        properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringSerializer");
        // 添加分区器
        properties.put(ProducerConfig.PARTITIONER_CLASS_CONFIG, "com.fmy.kafka.config.MyPartitioner");

        // 2.创建生产者对象
        KafkaProducer<String, String> producer = new KafkaProducer<>(properties);

        // 3.发送数据
        producer.send(new ProducerRecord<>("test2", "fmy","这是带回调方法的生产者发送的消息!"), (RecordMetadata metadata, Exception exception)-> {
            if (exception == null) {
                System.out.println("已收到ack,这里是回调方法");
                System.out.println("元数据分区:"+metadata.partition() + ",偏移量:" + metadata.offset());
            } else {
                exception.printStackTrace();
            }
        });

        // 4.关闭资源
        producer.close();
    }
}

4.3、同步发送 API

同步发送的意思是,一条消息发送后,会阻塞当前线程,直至返回 ack。由于 send 方法返回的是一个 Future 对象,根据 Future 对象的特点,我们也可以实现同步发送的效果,只需在调用 Future 对象的 get 方法即可。

//异步
//这个生产者写一条消息的时候,先是写到某个缓冲区,
// 这个缓冲区里的数据还没写到broker集群里的某个分区的时候,
// 它就返回到client去了。虽然效率快,但是不能保证消息一定被发送出去了。
//        producer.send(new ProducerRecord<>("test2", "fmy","这是生产者异步发送的消息!"));

//同步
//这个生产者写一条消息的时候,它就立马发送到某个分区去。
// follower还需要从leader拉取消息到本地,follower再向leader发送确认,
// leader再向客户端发送确认。由于这一套流程之后,客户端才能得到确认,所以很慢。
        Future<RecordMetadata> demo = producer.send(
new ProducerRecord<>("demo", "neu", "这里是生产者同步发送的消息!"));
        RecordMetadata recordMetadata = demo.get();

5、Consumer API

5.1、自动提交 offset

KafkaConsumer: 需要创建一个消费者对象,用来消费数据。
ConsumerConfig: 获取所需的一些列配置参数。
ConsumerRecord: 每条数据都要封装成一个 ConsumerRecord 对象。

public class MyConsumer{
    public static void main(String[] args) {
        String server = "162.14.109.33:9092";
        /* 1.创建消费者配置信息 */

        Properties properties = new Properties();
        /* 2.给配置信息赋值 */

        /* 连接的集群 */
        properties.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG,server);

//        /* 开启自动提交 */
        properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, true);
        /* 自动提交的延时 */
        properties.put(ConsumerConfig.AUTO_COMMIT_INTERVAL_MS_CONFIG, "1000");

        /* 关闭自动提交 */
//        properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);

        /* key,value的反序列化 */
        properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
        properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, "org.apache.kafka.common.serialization.StringDeserializer");
        /* 消费者组 */
        properties.put(ConsumerConfig.GROUP_ID_CONFIG, "bigData");

        /* 3.创建消费者 */
        KafkaConsumer<String, String> consumer = new KafkaConsumer<>(properties);

        /* 4.订阅主题 */
        consumer.subscribe(Collections.singletonList("test2"));

        /* 5.获取数据 */
        while (true) {
            ConsumerRecords<String, String> consumerRecords = consumer.poll(Duration.ofMillis(100));
            /* 解析并打印consumerRecords */
            for (ConsumerRecord<String, String> consumerRecord : consumerRecords) {
                System.out.println("分区"+consumerRecord.partition()+"偏移量:"+consumerRecord.offset());
                System.out.println("key:"+consumerRecord.key() + ",value:" + consumerRecord.value());
            }


            /* 同步提交,当前线程会阻塞直到 offset 提交成功 */
//            consumer.commitSync();


            /* 异步提交 */
//            consumer.commitAsync((Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception)-> {
//                if (exception != null) {
//                    System.err.println("Commit failed for" + offsets);
//                }
//            });
        }
    }
}

5.2、手动提交 offset

虽然自动提交 offset 十分简便,但由于其是基于时间提交的,开发人员难以把握 offset 提交的时机。因此 Kafka 提供了手动提交 offset 的 API。

手动提交 offset 的方法有两种:分别是 commitSync(同步提交) 和 commitAsync(异步提交)。两者的相同点是,都会将本次拉取的一批数据最高的偏移量提交。不同点是,commitSync 阻塞当前线程,一直到提交成功,并且会自动失败重试;而 commitAsync 则没有失败重试机制,故有可能提交失败。

  • 同步提交 offset
/* 同步提交,当前线程会阻塞直到offset 提交成功 */
consumer.commitSync();
  • 异步提交 offset
/* 异步提交 */
consumer.commitAsync((Map<TopicPartition, OffsetAndMetadata> offsets, Exception exception)-> {
    if (exception != null) {
       System.err.println("Commit failed for" + offsets);
    }
});
  • 数据漏消费和重复消费分析

无论是同步提交还是异步提交 offset,都有可能会造成数据漏消费或重复消费。先提交 offset 后消费,有可能造成数据的漏消费;先消费后提交 offset,有可能造成数据的重复消费。

6、SpringBoot集成kafka

6.1、配置Maven依赖

<dependency>
    <groupId>org.springframework.kafka</groupId>
    <artifactId>spring-kafka</artifactId>
</dependency>

6.2、yml配置

spring:

    kafka:
        # kafka服务器地址(可以多个)
        bootstrap-servers: localhost:9092
        producer:
            # key/value的序列化
            key-serializer: org.apache.kafka.common.serialization.IntegerSerializer
            value-serializer: org.apache.kafka.common.serialization.StringSerializer
            # 返回数据形式
            # acks: all
            # 批量抓取
            batch-size: 65536
            # 缓存容量
            buffer-memory: 524288
            # 服务器地址
            bootstrap-servers: localhost:9092
        consumer:
            # key/value的反序列化
            key-deserializer: org.apache.kafka.common.serialization.IntegerDeserializer
            value-deserializer: org.apache.kafka.common.serialization.StringDeserializer
            # 指定一个默认的组名
            group-id: kafka2
            # earliest:当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,从头开始消费
            # latest:当各分区下有已提交的offset时,从提交的offset开始消费;无提交的offset时,消费新产生的该分区下的数据
            # none:topic各分区都存在已提交的offset时,从offset后开始消费;只要有一个分区不存在已提交的offset,则抛出异常
            auto-offset-reset: earliest

6.3、生产者

  1. 创建一个生产者对象kafkaProducer
  2. 调用send反射消息(ProducerRecor,封装是key-value键值对)
  3. 调用Future.get()表示获取服务器的响应
  4. 关闭生产者
package com.kafka.demo.controller;
import org.apache.kafka.clients.producer.RecordMetadata;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.support.SendResult;
import org.springframework.util.concurrent.ListenableFuture;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.concurrent.ExecutionException;

@RestController
public class KafkaSyncProducerController {

    @Autowired
    private KafkaTemplate<Integer, String> template;

    @RequestMapping("send/sync/{massage}")
    public String send(@PathVariable String massage) {
        final ListenableFuture<SendResult<Integer, String>> future = this.template.send("test1", 0, 0, massage);

        try {
            final SendResult<Integer, String> sendResult = future.get();
            final RecordMetadata metadata = sendResult.getRecordMetadata();

            System.out.println(metadata.topic() + "\t" + metadata.partition() + "\t" + metadata.offset());
        } catch (InterruptedException e) {
            e.printStackTrace();
        } catch (ExecutionException e) {
            e.printStackTrace();
        }

        return "success";
    }
}

6.4、消费者

package com.kafka.demo.consumer;

import org.apache.kafka.clients.consumer.ConsumerRecord;
import org.springframework.kafka.annotation.KafkaListener;
import org.springframework.stereotype.Component;

@Component
public class KafkaConsumer {
    @KafkaListener(topics = "test1")
    public void onMassage(ConsumerRecord<Integer, String> record) {
        System.out.println("收到的消息"
                + "\t" + record.topic()
                + "\t" + record.partition()
                + "\t" + record.offset()
                + "\t" + record.key()
                + "\t" + record.value());
    }
}

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值