Kafka消息队列中消费者的实现

1. 消费者与消费组:

在消息队列系统中,消费者客户端(Consumer)负责订阅Kafka中的主题(Topic),并且从订阅的主题上拉取消息。

与其他一些消息中间件不同的是:在Kafka的消费理念中还有一层 “消费组”(Consumer Group)的概念,每个消费者都有一个对应的消费组。当消息发布到主题后,只会投递给订阅它的每个消费组中的一个消费者。

1.1 关于消费组的几个概念:

  1. 一个分区只能属于一个主题,一个主题可以有多个分区;
  2. 基于默认的分区策略,同一个消费组内的所有消费者会均分这一主题的分区。
    例如:一个主题有4个分区 P1、P2、P3、P4,如果订阅这个主题的消费组A1中只有一个消费者a1,则a1将同时接受4个分区的消息;如果A1中添加一个消费者a2,则a1消费P1、P2中的消息,a2负责消费P3、P4中的消息;
  3. 如果:一个消费组中的消费者数量 > 消费组所订阅的主题中的分区数,这将导致多余的消费者处于空闲状态,不存在多个消费者共享消费同一个分区的可能,所以消费者不是越多越好;
  4. 引入“消费组”概念的优势:消费者与消费组这种模型可以让整体的消费能力具备 横向伸缩性,我们可以增加(或减少)消费者的个数来提高(或降低)整体的消费能力;
  5. 以上分配逻辑都是基于默认的分区分配策略进行分析的,分区策略可以修改,通过消费者客户端参数。

1.2 消息投递模式:

对于消息中间件而言,一般有两种投递模式:

  1. 点对点模式(P2P):
    当一个Topic主题只有一个消费组订阅,此时不论这个消费组中有多少个消费者,同一主题的消息只会被均衡的投递给某一个消费者进行消费,即每条消息只会被一个消费者处理,这就相当于点对点模式的应用;
  2. 发布/订阅模式(Pub/Sub)。
    当一个Topic主题被多个消费组订阅,此时同一个主题上的消息会发送给所有的消费组,即每条消息会被多个消费者处理,这就相当于发布订阅模式的应用。

2. 客户端开发:

一个正常的消费逻辑需要具备以下几个步骤:

  1. 配置消费者客户端参数及创建相应的消费者实例;
  2. 订阅主题;
  3. 拉取消息并消费;
  4. 提交消费位移;
  5. 关闭消费者实例。

2.1 必要的参数配置:

在创建真正的消费者实例之前,需要做相应的参数配置,例如设置 消费者所属的消费组的名称、连接地址等。

  1. bootstrap.servers
    该参数与生产者客户端中的同名参数作用相同,用于指定连接Kafka集群所需的broker地址清单,具体内容形式为: host1:port1, host2:port2 (一个消费者可以连接多个broker,一个消费者可以订阅一个或多个主题);
  2. group.id
    消费者隶属的消费组名称,默认值为一个空格。

librdkafka中用于设置消费者客户端配置参数的接口是:

RdKafka::Conf *m_config = RdKafka::Conf::create(RdKafka::Conf::CONF_GLOBAL);
RdKafka::Conf *m_configTopic = RdKafka::Conf::create(RdKafka::Conf::CONF_TOPIC);

RdKafka::Conf::ConfResult 	result;
std::string					error_str;

result = m_config->set("booststrap.servers", "127.0.0.1::9092", error_str);

2.2 订阅主题与分区:

在创建好消费者之后,就可以为该消费者订阅主题相关的主题了。 一个消费者可以订阅 一个 或 多个 主题。

并且,消费者不仅可以订阅主题,还可以直接订阅某些主题中的特定分区。

与订阅相对应的操作,就是“取消订阅”.

如果事先不知道主题中有多少个分区,可以通过接口函数查询主题的元数据信息(librdkafka中查询主题的分区个数的方法待补充)。

2.2.1 订阅主题的三种方法:

  1. 集合订阅:
    所谓“集合”,是指vector的形式,一个消费者可以同时订阅一个或多个主题,例如:
int main() {
    string brokers = "127.0.0.1:9092";
    vector<string> topics;
    topics.push_back("topic-demo-1");
    topics.push_back("topic-demo-2");	//以vector的形式传入,同时订阅两个topic
    string group = "demoGroup";

	KafkaConsumer consumer(brokers, group, topics, RdKafka::Topic::OFFSET_BEGINNING);	//构造KafkaConsumer类型对象,保存topics:vector<string> m_topicVector = topics;

	consumer.pullMessage(); //拉取消息前先订阅主题:m_consumer->subscribe(m_topicVector);
}
  1. 正则表达式订阅:
    例如:(java实现)
consumer.subscribe(Pattern.compile("topic-.*"));
  1. 指定分区的订阅:
RdKafka::ErrorCode
RdKafka::KafkaConsumerImpl::assign(const vector<TopicPartition*> &partitions) {
	rd_kafka_assign();
	...
}

//TopicPartition 是一个用来描述分区的类:
class TopicPartition {
public:
	static TopicPartition *create(const string topic, int partition, int64_t offset);
	virtual const string &topic() const = 0;	//returns topic name
	virtual int partition() const = 0;	//returns partition id
	virtual int64_t offset() const = 0;	//returns offset
	virtual void set_offset(int64_t offset) = 0;
	
};

class TopicPartitionImpl : public TopicPartition {
public:
	string 		topic_;
	int 		partition_;
	int64_t 	offset_;	
};

这三种状态是互斥的,在一个消费者中只能使用其中的一种,否则会报出异常。

2.2.2 subscribe与 assign两种订阅方式的区别:

  1. 通过 subscribe() 方法订阅主题具有消费者“自动再均衡” 的功能:
    在多个消费者的情况下可以根据分区分配策略自动分配各个消费者与分区的关系。当消费组内的消费者增加或减少时,分区分配关系会自动调整,以实现消费负载均衡及故障自动转移;
  2. 通过assign() 方法订阅分区时,是 不具备消费自动均衡的功能的,从assign() 方法的参数中就可以看出端倪。

2.4 消息消费:

对于消息队列组件来说,一般对于消息的消费有两种模式:推模式和拉模式

推模式是服务端主动将消息推送给消费者,而拉模式是消费者主动向服务端发起请求来拉取消息(实际上是向broker请求消息)。

Kafka中的消费是基于拉模式的。

Kafka中的消息消费是一个不断 “轮询” 的过程,消费者所要做的就是重复的调用 poll() 方法,而 poll() 方法返回的是所订阅的主题(分区)上的一组消息。

librdkafka中关于poll方法的C++接口:

class KafkaConsumer : public virtual Handle {

};

class Handle {
public:
	virtual int poll(int timeout_ms) = 0;
};

2.5 位移提交:

2.5.1 什么是位移:

在Kafka的分区和消费者端都有“offset”(位移)的概念:

  1. 对于Kafka的分区而言,它的每条消息都有唯一的offset,用来表示在分区中对应的位置;
  2. 对于消费者而言,它也有一个offset概念,消费者使用offset来表示消费到分区中某个消息所在的位置

在消费者每次调用poll() 方法时,服务端会返回给消费者还没有消费过的消息集,要做到这一点,就需要服务端记录上一次消费时的消息位移。
并且服务端必须对消息位移做持久化,以应对消费者宕机重连造成的重复消费或增加新的消费者发生再均衡时通知新的消费者从已有消息位移处开始消费。

在旧消费者客户端中,消费位移是存储在ZooKeeper中;而在新消费者客户端中,消费位移是存储在Kafka内部的主题 __consumer_offsets 中。

2.5.2 什么是位移提交:

Kafka中把消费位移存储起来(持久化)的动作称为“提交”。
消费者在消费完消息之后需要执行消费位移的提交(消费者将 “消息位移” 提交给服务端)。

在这里插入图片描述

如图所示,假设上次消费者拉取了 [2, 7] 消息偏移量从 2 到 7 的消息,那么下次消费者提交的“消息位移”就是8,它表示下一条需要拉取的消息的位置。

2.5.2.2 位移提交的时机对消息消费的影响:

消费者向服务端提交位移的时机对消息消费有很大影响,如果提交过完则有可能会造成“重复消费”,而提交过早又可能会造成“消息丢失”:

  1. 提交位移过早造成的“消息丢失”:
    所谓的“提交位移过早”,是指在拉取到消息后立即进行位移提交。
    当前一次poll() 操作所拉取的消息集为 [x+2, x+7],x+2 代表上一次提交的消费位移,说明已经完成了 x+1 之前(包括x+1在内)的所有消息的消费,令x+5表示当前正在处理的位置,如果拉取到消息后立刻进行位移提交,即提交 x+8,那么当前消费 x+5 的时候如果出现异常,下次重新拉取时消息将从 x+8 开始,也就是说造成 x+5 到 x+7 之间的消息未能被消费,导致消息丢失。
  2. 提交位移过晚造成的“重复消费”:
    所谓的“提交位移过晚”,处理完所有消息后再进行位移提交。
    还是上面的例子,如果采用处理完所有消息后再进行位移提交的策略,当处理到 x+5 消息出现异常时,在故障恢复之后将重新拉取从 x+2 开始的消息,造成 x+2 到 x+4 之间的消息重复处理。

2.5.2.3 自动提交:

Kafka中默认的消费位移提交方式是 自动提交

由消费者客户端参数 enable.auto.commit 设置,默认为true。

默认自动提交不是每消费一条消息就提交一次(这样会消耗过多性能),而是 “定期提交” , 提交周期的长度由消费者客户端参数 auto.commit.interval.ms 设置,默认为 5 ms

即在默认情况下:
消费者每间隔 5 ms 将拉取到的 每个分区中的 最大的消息位移 进行提交。
注意是提交“拉取到的”最大消息位移,而不是“处理完毕的”消息位移,因此可能存在有些消息还未处理。

//KafkaConsumer:
enable.auto.commit 			= true;		//默认开启Kafka消费位移的自动提交
auto.commit.interval.ms 	= 5;		//默认值自动提交周期为5ms

自动提交的动作是在 poll() 方法的逻辑里完成的
在每次真正向服务端发起拉取请求之前会检查是否可以进行位移提交,如果可以,则提交上一次 poll() 返回的位移。

自动提交 同样会带来潜在的 “重复消费” 和 “消息丢失” 的问题。

2.5.2.4 手动提交:

自动提交位移的方式会带来潜在的“重复消费”和“消息丢失”的问题,而这类问题依靠消息队列是无法解决的,无论Kafka、RabbitMQ等消息队列都存在重复消费和消息丢失的问题,这需要开发的应用程序进行保证

因此,Kafka中引入了“手动位移提交”的方式,这样可以使得开发人员对消费位移的管理控制更加灵活。

很多时候并不是说拉取到消息就算消费完成,而是需要将消息写入数据库、写入本地缓存,或者是更加复杂的业务处理。 在这些场景下,所有的业务处理完成才能认为消费被成功消费,手动的提交方式可以让开发人员根据程序的逻辑在合适的地方进行位移提交。

开启手动提交功能的前提是消费者客户端自动提交参数设置为 false:

enable.auto.commit = false;		//shell脚本的设置方式

librdkafka中的C++接口设置方式:

RdKafka::Conf *m_config = RdKafka::Conf::create(RdKafka::Conf::CONF_GLOBAL);

string errorStr;
RdKafka::Conf::ConfResult errorCode = m_config->set("enable.auto.commit", false, errorStr);

手动提交又可细分为 “同步提交”“异步提交” 两种类型,对应于librdkafka中的方法:

//同步提交:
RdKafka::KafkaConsumer::commitSync();	

//异步提交
RdKafka::KafkaConsumer::commitAsync();	
2.5.2.4.1 同步手动提交:
  1. 方法一:处理完一次poll()方法的返回结果后,进行一次同步提交:
//java伪代码实现:
while(isRunning.get()) {
    ConsumerRecords<string, string> records = consumer.poll(1000);
    for(ConsumerRecord<string, string> record : records) {
		//for循环中逐条处理poll()发回的消息集合
	}
	//将消息全部处理完成后,进行同步位移提交
	consumer.commitSync();	
}
  1. 方法二:处理多次poll()方法返回的结果,在达到阈值后批量提交:
    在方法一中,每处理完一次poll()返回的消息集之后就进行一次同步位移提交,这种方式可能会导致提交过于频繁。
    对其采用的优化方法是在同步提交前加一个阈值判断,实现“批量处理 + 批量提交”,达到消息处理数量的阈值之后再进行提交,以此达到减少位移提交次数的目的。
//java伪代码实现:
int minBatchSize = 200;		//阈值
List<ConsumerRecord> buffer = new ArrayList<>();	//缓存buffer,对poll返回的消息结果先进行缓存,达到阈值之后再进行统一的批量处理和批量位移提交

while(isRunning.get()) {
    ConsumerRecords<string, string> records = consumer.poll(1000);
    for(ConsumerRecord<string, string> record : records) {
        buffer.add(record);	//先将poll返回的消息结果缓存起来,暂不处理
    }
	if(buffer.size() >= minBatchSize) {
        //do some logical processing with buffer
        //批量处理后再提交,减少了位移提交的次数
        consumer.commitSync();
        buffer.clear();
    }
}

同步提交同样存在“重复消费”的问题,但解决了“消息丢失”的问题。

librdkafka中 commitSync() 方法有四种不同的重载方法:

//1. 不含参:
ErrorCode KafkaConsumer::commitSync();	

//2. 带偏移量:
ErrorCode KafkaConsumer::commitSync(vector<TopicPartition*> &offsets);	

//3. 带回调函数,在同步提交完成后回调此函数:
ErrorCode KafkaConsumer::commitSync(OffsetCommitCb *offset_commit_cb);	

//4. 带偏移量和回调函数:
ErrorCode KafkaConsumer::commitSync(vector<TopicPartition*> &offsets, OffsetCommitCb *offset_commit_cb);
2.5.2.4.2 异步手动提交:

与 commitSync() 方法相反,异步提交的方式 commitAsync() 在执行的时候不会阻塞消费者线程,可能在提交消费位移的结果还未返回之前就开始了新一次的拉取操作。

librdkafka中 commitAsync() 方法有三种重载方法:

//1. 不含参:
ErrorCode KafkaConsumer::commitAsync();

//2. 基于消息Message 对单个TopicPartiton进行提交:
ErrorCode KafkaConsumer::commitAsync(Message *message);

//3. 
ErrorCode KafkaConsumer::commitAsync(vector<TopicPartition*> &offsets);

对于异步提交时出错的处理:

最直接的方法是 重试,但这种方法存在问题,例如第一次提交位移为x失败,第二次提交位移为x+y成功,如果使用 失败重试机制,第一次提交的重试在第二次新提交的后面发生,则位移又被重置为x,造成重复消费的问题。所以重试机制需要考虑重试的间隔、次数以及与下一次新提交之间的关系,实现起来比较复杂。

解决上述问题的办法是设置一个 递增的序号 来维护异步提交的顺序,每次位移提交后增加序号的值,下一次提交时判断提交的值与序号的大小关系,比关系大时才提交。

一般情况下,位移提交失败的情况很少发生,不进行重试也没有关系,后面的提交也会有成功的,引入重试机制反而增加了代码逻辑的复杂度。不进行重试的代价就是会增加重复消费的概率。

2.6 控制或关闭消费:

KafkaConsumer提供了对消费速度进行控制的方法,在有些应用场景下我们可能需要暂停某些分区的消费而先消费其他分区,当达到一定条件时再恢复这些分区的消费。

KafkaConsumer中提供 pause() 方法 和 resume() 方法来分别实现暂停某些分区在拉取操作时返回数据给客户端和恢复某些分区向客户端返回数据的操作。

这两个操作的具体定义如下:

public void pause(Collection<TopicPartition> partitions);
public void resume(Collection<TopicPartition> partitions);

(在librdkafka中暂时没找到这两个相关的函数)

2.7 指定位移消费:

有以下3种情况可能会导致消费者查找不到所记录的消费位移:
(1)当一个新消费组建立时;
(2)当消费组内的一个新消费者订阅了一个新的主题;
(3)当 __consumer_offsets 主题中有关这个消费组的位移信息过期而被删除后。

(如果一个消费者宕机后重连之前订阅过的主题,则可以通过 __consumer_offsets 中的记录找到之前的消费位移)

在Kafka中当消费者查找不到所记录的消费位移时,就会根据消费者客户端参数 auto.offset.reset 的配置来决定从何处开始进行消费。
这个参数的默认值是 latest,即从分区末尾开始消费消息;除此之外可以将其设置为 earlist,即从分区起始处开始消费;另外可以设置为 none,表示出现查不到消费位移情况时既不从分区末尾也不从分区起始位置消费,此时会报出异常。

2.2.7.1 seek() 方法:

auto.offset.reset 参数只能在找不到消费位移或者位移越界时的情况粗粒度的从头或末尾开始消费,有时我们需要一种更细粒度的掌控,可以让我们从特定的位移处开始拉取消息,而KafkaConsumer中的 seek() 方法刚好提供了这个功能:

ErrorCode Kafka::Consumer::seek(Topic *topic, int32_t partition, int64_t offset, int timeout_us);

seek() 方法中的参数 partition 表示分区,offset参数指定从分区的哪个位置开始消费。

注意:
调用 seek()方法之前需要先调用 poll() 方法。这是因为seek() 方法只能重置消费者分配到的分区的消费位置,而分区的分配是在poll() 方法的调用过程中实现的。

seek() 方法为我们提供了 从特定位置读取消息 的能力,我们可以通过这个方法来向前跳过若干消息,也可以通过这个方法来向后回溯若干消息,这样为消息的消费提供了很大的灵活性。
seek() 方法也为我们提供了将消费位移保存在外部存储介质中的能力,还可以配合再均衡监听器来提供更加精准的消费能力。

2.8 再均衡:

再均衡是指分区的所属权从一个消费者转移到另一个消费者的行为,它为消费组具体高可用性和伸缩性提供了保障,使我们可以既方便又安全的 删除消费组内的消费者或 往消费组内添加消费者。

不过在再均衡发生期间,消费组内的消费者是无法读取消息的,也就是说,在再均衡的发生期间的这一小段时间内,消费组会变得不可用。

另外,当一个分区被重新分配给另一个消费者时,消费者当前的状态也会丢失。比如消费者消费完某个分区中的一部分消息时还没有来得及提交消费位移就发生了再均衡操作,之后这个分区又被分配给了消费组内的另一个消费者,原来被消费完的那部分消息就会被再次消费一次,即发生了重复消费。因此,一般情况下应避免不必要的再均衡的发生。

再均衡监听器 ConsumerRebalanceListener:
再均衡监听器用来设定发生再均衡动作前后的一些准备或收尾工作。
ConsumerRebalanceListener是一个接口(一个类),其中包含2个方法:

void onPartitionsRevoked(Collection<TopicPartition> partitions);

void onPartitionsAssigned(Collection<TopicPartition> partitions);

(1)onPartitionsRevoked 方法会在 再均衡开始之前 和 消费者停止读取消息之后被调用,可以通过这个回调方法来处理消费位移的提交,以此来避免一些不必要的重复消费现象的发生。
参数partitions 表示再均衡前所分配到的分区。

(2)onPartitionsAssigned 方法会在重新分配分区之后和消费者开始读取消费之前被调用。
参数partitions 表示再均衡后所分配到的分区。

librdkafka中关于 “再均衡监听器” 的回调接口类是:

class RebalanceCb {
public:
	virtual void rebalance_cb(RdKafka::KafkaConsumer *consumer, RdKafka::ErrorCode err, vector<TopicPartition*> &partitions) = 0;

	virtual ~RebalanceCb() { }
};

class RebalanceCb 是一个虚基类,为开发者提供了在再均衡发生前后注册一些回调方法的接口,使用方法为:

class MyRebalanceCb : public RdKafka::RebalanceCb {
public:
    void rebalance_cb(RdKafka::KafkaConsumer *consumer, RdKafka::ErrorCode err, vector<TopicPartition*> &partitions) {
		if(err == RdKafka::ERR_ASSIGN_PARTITIONS) {
			consumer->assign(partitions);
		}
		else if(err == RdKafka::ERR_REVOKE_PARTITIONS) {
			consumer->unassign();
		}
		else {
			consumer->unassign();
		}
	}
};

(↑↑↑ 这部分的用法再结合librdkafka源码中的注释进行补充 ↑↑↑)

2.2.9 消费者拦截器:

生产者有拦截器,对应的消费者也有相应的拦截器。
消费者拦截器主要在 消费到消息 或 在提交消费位移时 进行一些定制化的操作。

(↑↑↑ 这个应该就是librdkafka中注册的回调函数,后面再补充 ↑↑↑)

2.2.10 多线程实现:

KafkaProducer是线程安全的,然而KafkaConsumer却是非线程安全的。 Kafka定义了一个 acquire() 方法,用来检测当前是否只有一个线程在操作,如果有其他线程正在操作则会抛出异常。

acquire() 方法和我们通常所说的锁(Lock)不同,它不会造成阻塞等待,可以将其看做一个轻量级锁,它仅通过线程操作计数标记的方式来检测线程是否发生了并发操作,以此保证只有一个线程在操作。
acquire()方法 和 release()方法 成对出现,表示相应的加锁和解锁操作。

虽然KafkaConsumer是非线程安全的,但某些场景下必须使用多线程进行消费处理。
例如当生产者生产消息的速度持续大于消费者消费消息的速度时,就需要采用多线程消息消费,以避免消息积压在消息队列中,因Kafka的过期删除机制而导致消息在被消费之前就被清理删除。

多线程的目的就是为了提高整理的消费能力。

多线程的实现方式有多种:

(1)第一种也是最常见的方式:线程封闭,即为每个线程实例化一个KafkaConsumer对象:

一个线程对应一个KafkaConsumer(即一个消费者客户端)实例,我们称之为 “消费线程”
一个消费线程可以消费一个或多个分区中的消息,所有的消费线程都隶属于同一个消费组。
这种实现方式的并发度受限于分区的实际个数,当消费线程的个数大于分区数时,就会有部分消费线程一直处于空闲状态而发挥不了实际作用。

在这里插入图片描述

(2)第二种方式是:多个消费线程同时消费同一个分区,这个通过 assign() 和 seek() 方法实现,这样可以打破原有的消费线程的个数不能超过分区数的限制,进一步提高消费能力,但是这种方式对于 位移提交 和 顺序控制 的处理就会变得非常复杂,实际应用中使用的极少。

一般而言,分区是消费线程划分的最小单位。

2.2.11 重要的消费者参数:

待补充


3. 使用librdkafka的C++接口实现消费者的简单流程:


//创建消费者客户端实例:

RdKafka::Conf *m_config = RdKafka::Conf::create(RdKafka::Conf::CONF_GLOBAL);
RdKafka::Conf *m_configTopic = RdKafka::Conf::create(RdKafka::Conf::CONF_TOPIC);

RdKafka::Conf::ConfResult 	result;
std::string					error_str;

result = m_config->set("booststrap.servers", "127.0.0.1:9092", error_str);
result = m_config->set("group.id", "consumer_group_demo", error_str);		//必须设置消费组名

m_event_cb = new ConsumerEventCb;
result = m_config->set("event_cb", m_event_cb, error_str);
m_reblance_cb = new ConsumerRebalanceCb;
result = m_config->set("rebalance_cb", m_rebalance_cb, error_str);

RdKafka::KafkaConsumer *m_consumer = RdKafka::KafkaConsumer::create(m_config, error_str);	//根据配置创建消费者客户端



//订阅主题:

std::vector<std::string> topics;
std::string topic_demo = "opic_demo";
topics.push_back(topic_str);

RdKafka::ErrorCode error_code = m_consumer->subscribe(topics);



//主动从broker上拉取消息:

RdKafka::Message *m_message = m_consumer->consume(5000);	//超过5000ms若仍未拉取到消息则返回ERR_TIMED_OUT




//处理(消费)拉取到的消息:

switch(m_message->err()) {
	case RdKafka::ERR_TIMED_OUT:	//consume(timeout)超时未拉取到消息
		break;
	case RdKafka::ERR_NO_ERROR:		//正确的拉取到了消息:
		RdKafka::MessageTimestamp ts = m_message->timestamp();
		int msg_bytes = static_cast<int>(m_message->len());
		const char* msg_data = static_cast<const char*>(m_message->payload());
		std::cout << "recv message content : " << msg_data << ", len : " << msg_bytes << std::endl;
		break;
	case RdKafka::ERR_PARTITION_EOF:
		//last message
		break;
	case RdKafka::ERR_UNKNOWN_TOPIC:
	case RdKafka::ERR_UNKNOWN_PARTITION:
		//consume failed
		break;
	default:
		//other errors
}




//关闭消费者客户端:

m_consumer->close();
delete m_config;
delete m_configTopic;
delete m_consumer;

RdKafka::wait_destroyed(5000);		//清理资源

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值