kafka之一----概念结构

https://kafka.apache.org/
https://blog.csdn.net/liuyu973971883/article/details/109036572
https://blog.csdn.net/u013256816/article/details/80300225

1、概念

Kafka是由Apache软件基金会开发的一个开源流处理平台,由Scala和Java编写。Kafka是一种高吞吐量的分布式发布订阅消息系统

linkedin的首席架构师jay kreps,非常喜欢franz kafka(小说家),并且觉得kafka这个名字很酷,因此取了个和消息传递系统完全不相干的名称kafka

2、组成结构

在这里插入图片描述

组成

  • Producer: 负责发布消息到Kafka broker(将数据发布到所选择的topic中)
  • Broker:和 RabbitMQ 一样,Kafka 也是通过 Broker 来组织的。多个 Broker 就组成了一个 Kafka 集群。

一般来说,在一个 kafka 集群中,每个机器上都只部署一个实例,这个机器就可以看成一个broker,每个 Broker 就是 kafka 的一个代理节点(服务节点),Broker 在 zookeeper 的协调下完成 Kafka 集群协作的同时,也承担着消息的存储。

  • Consumer: 消息消费者,向Kafka broker读取消息的客户端,发布到topic中的每条记录将被分配到订阅消费组中的其中一个消费者

  • Consumer Group: 每个Consumer属于一个特定的Consumer Group(可为每个Consumer指定group ID)

  • Zookeeper: Kafka通过Zookeeper保存管理集群配置、元数据,选举leader,以及在 Consumer Group发生变化时进行rebalance

社区后面会移除对ZooKeeper的依赖。具体的原理是在Kafka内部实现一个基于Raft的Controller集群

  • Topic:每条发布到Kafka集群的消息的一个逻辑概念的分类,这个被称为Topic。每个Topic包含一个或多个Partition

逻辑概念的分类,所以同一个Topic的消息可能保存于一个broker上,也可能保存在多个broker上,不同Topic的消息分开存储

  • Partition:分区,Topic 物理上的分组,partition 中的每条消息都会被分配一个有序的 id(offset),所以每个partition 是一个有序的队列。每个分区包含多个segment

当一个Topic中消息过多时,会对Topic进行分区处理,把消息分到不同的Partition中;
对数据进行分区,分而治之,不同的Consumer消费不同分区的数据,并行操作,加快数据处理的速度。

  • Segment:分段,Partition内存数据持久化生成的文件,每个Partition有多个分段,由.log(存内容)和.index(存索引搜索使用)文件构成

2.1 Consumer Group

消费组:多个消费者,这些消费者共用一个group.id,一个组内的所有消费者共同协作,完成对订阅的主题的所有分区进行消费。其中一个主题中的一个分区只能由一个消费者消费。

介绍下消息引擎模型:

  • 点对点的模型,每消费一个消息之后,被消费的消息就会被删除
  • 发布订阅模型,支持多个消费者消费同一个消息队列

kafka使用一种消费者组机制,就同时实现了传统消息引擎系统的两大模型:

  • 如果所有的消费者实例在同一个消费组中,那就是点对点模型,消息记录会负载均衡到消费组中的每一个消费者实例,如果实例多于分区数,多余的实例将永远不会工作,除非有其他实例挂掉,所以消费者组的消费者实例数最好等于该消费者组订阅的主题中的分区数
  • 如果所有的消费者实例在不同的消费组中,那就是发布订阅模型,则会将每条消息记录广播到所有的消费组或消费者进程中

只要不更改group.id,每次重新消费kafka,都是从上次消费结束的地方继续开始

  • 如何从最新的offset开始消费,将group.id换成新的名字
  • 如何从最早的offset位置消费,将group.id换成新的名字,设置properties.setProperty(“auto.offset.reset”, "earliest”)

2.2 offset

offset记录groupid的消费情况,只要不更改group.id,每次重新消费kafka都是从上次消费结束的offset继续开始

低版本offset保存在zk中,路径:/consumers/[group_id]/offsets/[topic]/[broker_id-partition_id]
–> offset_counter_value。但是由于zookeeper这类框架其实并不适合进行频繁的写更新。而Consumer Group的位移更新却是一个非常频繁的更新操作。

新版本中offset由broker维护,offset信息由一个特殊的topic :“ __consumer_offsets”来保存,offset以消息形式发送到该topic并保存在broker中。这样consumer提交offset时,只需连接到broker,不用访问zk,避免了zk节点更新瓶颈。

每个group的offset都将发送到该分区的leader broker,broker中存在一个offset manager 实例负责接收处理offset提交请求,并返回提交操作结果。

broker消息保存目录在配置文件server.properties中:log.dirs=/usr/local/var/lib/kafka-logs
该目录下以__consumer_offsets开头的目录,用于存放offset:__consumer_offsets-{{value}}
offset的存储位置获取方式: Utils.abs(groupId.hashCode) % numPartitions
(offsets.topic.num.partitions参数决定,默认值即50)

consumer_offsets

一个特殊的topic,offset以消息形式发送到该topic并保存在broker中

  1. 该主题是负责注册消费者以及保存位移值
  2. 该主题也是保存消费者元数据
查看命令
kafka-console-consumer --bootstrap-server localhost:9092 --topic __consumer_offsets --partition {{value}} --from-beginning --formatter 'kafka.coordinator.group.GroupMetadataManager$OffsetsMessageFormatter'

2.3 Partition

在这里插入图片描述

Topic 只是逻辑上的概念,而分区才是 Topic 借以实现的实体。在每个topic中有0到多个partition,每个partition为一个分区。kafka分区命名规则为topic的名称+有序序号,这个序号从0开始依次增加

作用

  • 方便扩展
    一个topic由一个或者多个partition构成,而每个节点中通常可以存储多个partition,这样就方便分区存储与移动,也就增加其扩展性。同时也可以增加其topic的数据量

  • 高并发高吞吐
    当一条消息被发送到 broker 之前,都会根据分区规则选择存储到哪个具体的分区,最为合理的规则是让消息可以均匀的分配到不同的分区中,这样,多个机器共同承担一个 Topic 的读写,同一个机器上,多个分区 log 文件同时承担他们所对应的 Topic 的读写,就可以让整个集群的 IO 性能大幅提升。

  • 高可用
    每一个分区都会在以配置的服务器上进行备份,确保容错性。每个分区都有一台server作为leader(每个分区都有各自的leader,不一定在同一个borker server上)。leader处理一切对分区的读写请求,而follwers只需被动的同步leader上的数据。当leader宕机了,followers中选举新的eader,实现故障的自动转移和容灾,保障整个集群的高可用状态

2.4 Segment

创建分区时,默认会生成一个segment file,kafka默认每个segment file的大小是1G。
当生产者往partition中存储数据时,内存中存不下了,就会往segment file里面刷新。在存储数据时,会先生成一个segment file,当这个segment file到1G之后,再生成第二个segment file,以此类推。每个segment file对应两个文件,

  • 以.log结尾的数据文件
  • 以.index结尾的索引文件。

在服务器 上,每个partition是一个目录,每个segment是分区目录下的一个文件。
segment每个名字有20个字符,不够用0填充。每个名字从0开始命名,下一个segment file文件的名字就是,上一个segment file中最后一条消息的索引值。

在这里插入图片描述

在.index文件中,存储的是 key-value格式的,key代表在.log中按顺序开始第n条消息,value代表该消息的位置偏移。.index中存放的消息索引是一个稀疏索引列表,不是对每条消息都做记录,它是每隔一些消息记录一次,避免占用太多内存。即使消息不在index记录中,在已有的记录中进行二分查找,范围也大大缩小了。

segment删除

在这里插入图片描述

2.5 Message

不同版本的消息格式

一个segment data file由许多message组成,一个message物理结构具体如下:

v2版本中消息集谓之为Record Batch,而不是先前的Message Set,其内部包含了一条或者多条消息。在消息压缩的情形下,Record Batch Header部分(从first offset到records count字段)是不被压缩的,而被压缩的是records字段中的所有内容

在这里插入图片描述
参考了Protocol Buffer而引入了变长整型(Varints)和ZigZag编码,目的是节省空间

Varints是使用一个或多个字节来序列化整数的一种方法,数值越小,其所占用的字节数就越少。ZigZag编码以一种锯齿形(zig-zags)的方式来回穿梭于正负整数之间,以使得带符号整数映射为无符号整数,这样可以使得绝对值较小的负数仍然享有较小的Varints编码值

RecordBatch
first offset当前RecordBatch起始位移
length计算从 partition leader epoch 字段开始到末尾的长度
partition leder epoch分区leader 任期
magic消息格式的版本号,对v2版本而言,magic 等于 2
crc32crc32 校验值
attributes消息属性,占用了2个字节:低3位表示压缩格式,0 表示NONE, 1 表示GZIP, 2 表示SNAPPY, 3 表示LZ4;第4位表示时间戳类型;第5位表示此RecordBatch 是否处于 事务中, 0 表示非事务,1表示事务;第6位表示是否是 控制消息 (ControlBatch), 0 表示非控制消息,1表示是控制消息,控制消息用来支持事务功能;其余位保留
last offset deltaRecordBatch 中最后一个 Record 的 offset 与 first offset 的差值。被 broker 用来确保 RecordBatch 中 Record 组装的正确性
first timestampRecordBatch 中第一条 Record 的时间戳
max timestampRecord 中最大的时间戳,一般情况下是指最后一个 Record 的时间戳,和 last offset delta 的作用一样,用来确保消息组装的正确性
producer idPID, 每个新的生产者初始化时候生成的全局唯一id,不对外暴露,用来支持幂等和事务
producer epoch和pid一样使用,用来支持幂等和事务
first sequence和pid、producer epoch一样使用,用来支持幂等和事务
records countRecordBatch 中 Record 的个数
Record
length消息总长度
attributes历史字段,新版本未使用,未来可扩展
timestamp delta时间戳增量。通常一个 timestamp 需要占用8个字节,如果像这里一样保存 与 RecordBatch 的起始时间戳的差值,则可以进一步节省占用的字节数
offset delta位移增量。保存与 RecordBatch 起始位移的差值,可以节省占用的字节数
key length消息的 key 的长度。如果为-1, 标识没有设置 key, 即 key == null
key可选,如果没有 key 则无此字段
value length实际消息体的长度。如果为 -1 ,则标识消息为空
value可选,消息体,可为空,比如 tombstone 消息
headers countheard个数
heards用来支持应用级别的扩展

2.6 MetaData元数据

服务器端元数据

服务器端元数据缓存数据来源于Controller

这些信息保存在Controller和Zookeeper上,Controller会从ZooKeeper中获取最新的元数据并缓存在自己的内存,broker上的元数据由Controller进行管理更新,不直接从ZooKeeper获取

在这里插入图片描述

客户端元数据

新版本保存在Kafka的内部主题consumer_offsets中,由集群中名为Coordinator的组件进行管理

  • 消费者的注册信息
  • 消费者位移信息

每个 broker 都会缓存所有主题的分区副本信息,客户端会定期发送发送元数据请求进行缓存,之后直接将读写请求发送给对应的分区leader副本所在的broker

定时刷新元数据的时间间隔配置 metadata.max.age.ms
如果在定时请求的时间间隔内发生的分区副本的选举,则意味着原来缓存的信息可能已经过时了,此时还有可能会收到 Not a Leader for Partition 的错误响应,这种情况下客户端会再次求发出元数据请求,然后刷新本地缓存

在这里插入图片描述

3、Controllr控制器组件

Controllr控制器组件

4、Replica副本

副本就是备份,kafka的副本机制目的是高可用,提供数据冗余,即使部分机器出现故障,系统仍然可以提供服务。

  • 在kafka中一个主题下面可以有多个分区(partition),每个分区(partition)可以有多个副本,
  • 一个Broker上可能有多个不同分区的副本,同一个分区下的所有副本保存有相同的消息序列。
  • 副本包含一个leader和多个follower节点,leader节点负责接收消息和消费消息(读写一致,无主从同步延迟或数据幻读问题),follower既不提供读写服务仅仅用于同步leader副本的消息,follower副本的唯一作用就是当leader副本出现问题时,通过ZooKeeper 提供的监控功能实时感知并从follower选举出leader。
  • 每个 leader 副本分区都维护了 ISR、OSR 两个集合

AR(Assigned Replicas)

分区中的所有副本(leader和follower)统称为AR。所有消息会先发送到leader副本,然后follower副本才能从leader中拉取消息进行同步。但是在同步期间,follower对于leader而言会有一定程度的滞后,这个时候follower和leader并非完全同步状态

OSR(Out Sync Replicas)

超过阈值时间没有和leader进行同步的follower副本,如果 OSR 中的某个 follower 副本分区进行了消息的拉取,那么他就会被移到 ISR 中

在默认情况下,当leader副本发生故障时,只有在ISR集合中的follower副本才有资格被选举为新leader,而OSR中的副本只有在unclean.leader.election.enable=true时才可参与进行不完全的leader选举。

ISR(In Sync Replicas)

与leader保持完全同步的副本。如果某个在ISR中的follower副本落后于leader副本太多,则会被从ISR中移除,否则如果完全同步,会从OSR中移至ISR集合

副本ISR同步阈值时间replica.lag.time.max.ms,默认10s

最少同步副本min.insync.replicas,代表 ISR列表中至少要有几个可用副本,当可用副本数量小于该值时,就认为整个分区处于不可用状态,写入数据将抛异常org.apache.kafka.common.errors.NotEnoughReplicasExceptoin

显然通过ISR,kafka需要的冗余度较低,可以容忍的失败数比较高。假设某个topic有f+1个副本,kafka可以容忍f个服务器不可用;
相比于少数服从多数的一致性算法,少数服从多数的算法冗余度较高,容忍f个服务不可用,需要2f+1个副本

ISR的动态扩缩

Kafka在启动的时候会开启两个任务,

  • 一个任务用来定期地检查是否需要缩减或者扩大ISR集合

这个周期是replica.lag.time.max.ms的一半,默认5000ms,当检测到ISR集合中有失效副本时,就会收缩ISR集合,当检查到OSR中有Follower的HighWatermark追赶上Leader时,就会扩充ISR

当ISR集合发生变更的时候还会将变更后的记录缓存到isrChangeSet中

  • 另外一个任务会周期性地检查这个Set,如果发现这个Set中有ISR集合的变更记录,那么它会在zk中持久化一个节点

Controllr在这个节点的路径上注册了一个Watcher,所以它就能够感知到ISR的变化,并向它所管理的broker发送更新元数据的请求,最后删除该路径下已经处理过的节点。

Unclean Leader

副本lead选举按顺序搜索 AR 列表,并把第一个同时满足以下两个条件的副本作为 Leader:

  • 该副本是存活状态,即副本所在的 Broker 依然在运行中;
  • 该副本在 ISR 列表中。

在 ISR 列表为空的情况下,是选不出leader的;Broker 端参数 unclean.leader.election.enable 的默认值为 false表示禁止 Unclean Leader 选举,如果改成true或者是由 AdminClient 发起的 Leader 选举,则进行 Unclean Leader 选举,表示在 ISR 列表为空的情况下,Kafka 选择一个非 ISR 副本作为新的 Leader,但这样会存在丢失数据的风险

LEO(log end offset)

日志末端的位移(log end offset),标识当前日志文件中下一条待写入的消息的offset

每个 kafka 副本对象都有两个重要的属性:LEO 和 HW。注意是所有的副本(leader + Follower)
注意这里是还未写入到kafka中的,ISR集合中的每个副本都会维护自身的LEO,且HW==LEO

在这里插入图片描述

HW(High Watermark)

高水位值(High Watermark)

  • 定义消息可见性,标识了一个特定的消息偏移量(offset),消费者只能拉取到这个水位 offset 之前的消息,注意位移值等于高水位的消息也属于未提交消息,不可消费。
  • 帮助Kafka完成副本同步

使用HW高水位来进行日志的截断,某些情况下会导致数据的丢失和数据不一致问题。

logStartOffset

对应的 LeaderEpoch 第一条写入消息的偏移量,相当于上一任 LeaderEpoch 的 LEO 值
在这里插入图片描述

一般情况下,日志文件的起始偏移量 logStartOffset 等于第一个日志分段的 baseOffset;logStartOffset 的值可以通过 DeleteRecordsRequest 请求(比如使用 KafkaAdminClient 的 deleteRecords()方法、使用 kafka-delete-records.sh 脚本、日志的清理和截断等操作进行修改

LW (Low Watermark)

低水位 指在AR broker节点中所有拥有的最小的logStartOffset值,最低水位线之前的所有日志是可以被清理掉

分布式一致性系统中清理过旧数据一般有2种方式

  1. 快照机制
    如 Zookeeper(ZAB 协议),etcd(raft协议),都实现了快照机制。它们的存储引擎会定时的进行全量快照,并且记录下快照对应的日志位置,将这个位置作为最低水位线,日志文件的最新log id 小于快照位置,可以被清理掉
  2. 基于时间的最低水位线
    kafka 默认保留 7 天的 log,RocketMQ 默认保留 3 天的 commit log。

5、容灾和恢复机制

在 Kafka 集群中,所有的分区分为 leader 副本和 follower 副本,他们共同组成了 AR(Assigned Replicas),
而 leader 副本与所有保持一定程度同步的 follower 副本则组成 ISR(In-Sync Replicas),
剩下的 follower 副本则是超过阈值时间没有进行过同步的副本,他们组成了 OSR(Out-of-Sync Replicas)

所有的消息总是会由 leader 副本分区进行处理,而 follower 副本分区则需要定时从 leader 副本分区进行拉取。

每个 leader 副本分区都维护了 ISR、OSR 两个集合
一旦 ISR 中的某个副本超过了阈值时间没有进行拉取操作,那么这个副本就会从 ISR 中被移到 OSR 中,
相反,如果 OSR 中的某个 follower 副本分区进行了消息的拉取,那么他就会被移到 ISR 中。

当故障发生时,默认情况下只有 ISR 中的副本才有资格被选为新的 leader 副本,通过将参数 unclean.leader.election.enable 设置为 true 可以改变这个默认行为,从而让 OSR 也有资格被选为新的 leader,不过通常不建议这么做,因为这意味着故障恢复中可能丢失更多的数据。

在正常情况下,消息一旦被 leader 副本处理完成就会立即返回 Producer 发送消息成功,而只有 ISR 中所有 follower 副本都已经完成这条消息的拉取,这条消息才能够被 Consumer 进行消费,此处的 leader 副本中存储的最新消息 offset 就是“高水位线”,默认情况下,故障发生时,只靠高低水位线判断消息是有可能丢失的。具体看副本同步

副本同步

和很多其他存储系统一样,follower 副本分区的数据相对于 leader 分区可能存在一定的滞后,因此在 Broker 故障自动转以后,可能会造成部分消息的丢失。

Rebalace(重平衡)

每个consumer group会从kafka broker中选出一个作为组协调者(group coordinator)。coordinator负责对整个consumer group的状态进行管理,当有触发rebalance的条件发生时,促使生成新的分区分配方案。

rebalance表示为再平衡。是一组协议,规定了一个 consumer group 如何分配订阅 topic的所有分区的。故rebalance是相对于consumer group而言,对应的offsets消费位移也是相对于consumer group 而言。

触发条件

  • 组成员数发生变化。比如新增消费实例或者离开组或者实例崩溃。
  • 组订阅的topic主题数发生变更。一般发生再比如基于正则表达式订阅topic情况,当新创建的topic匹配这个规则触发
  • 组订阅主题的分区数发生变更。

分区分配策略

range

基于每个主题的分区分配,每个主题都进行平均分配

举个栗子:
consumer: c1、c2,
topic: t1、t2,
每个topic下有3个分区:t1p0、t1p1、t1p2、t2p0、t2p1、t2p2
可能分配为:
c1:t1p0、t1p1、t2p0、t2p1 c2:t1p2、t2p2

round-robin

基于全部主题的分区轮询平均分配,默认策略

举个栗子:
consumer: c1、c2,
topic: t1、t2,
每个topic下有3个分区:t1p0、t1p1、t1p2、t2p0、t2p1、t2p2
可能分配为:
c1:t1p0、t1p1、t2p0
c2:t1p2、t2p1、t2p2

sticky

最新的粘性分配策略,让目前的分配尽可能保持不变,尽可能少的挪动来实现重平衡

举个栗子:
consumer: c1、c2, c3
topic: t1、t2,t3,t4
每个topic下有2个分区:t1p0、t1p1、t2p0、t2p1、t3p0、t3p1、t4p0、t4p1
可能分配为:
c1:t1p0、t2p0、t3p0
c2:t1p1、t2p1、t4p1
c3: t3p1、t4p0
加入c1挂了,可能分配为
c2: t1p1、t2p1、t4p1、t1p0
c3: t3p1、t4p0、t2p0、t3p0

过程

  1. 选择组协调器

每个consumer group都会选择一个broker作为自己的组协调器coordinator(消费的offset要提交到__consumer_offsets的哪个分区,这个分区leader对应的broker就是这个consumer group的coordinator),组协调器提供有关消费者组的元数据信息(分配和偏移量)、负责监控这个消费组里的所有消费者的心跳判断是否宕机,然后开启消费者rebalance。

  1. 加入消费组 JOIN GROUP

此阶段的消费者会向 GroupCoordinator 发送 JoinGroupRequest 请求并处理响应。GroupCoordinator 从一个consumer group中选择第一个加入group的consumer作为leader,把consumer group情况发送给这个leader,接着这个leader会负责制定分区方案。

  1. SYNC GROUP

consumer leader通过给GroupCoordinator发送SyncGroupRequest,接着GroupCoordinator就把分区方案下发给各个consumer,他们会根据指定分区的leader broker进行网络连接以及消息消费。

影响

1、在Rebalance期间,所有的实例都会停止消费直到Rebalance完成。这就导致Rebalance过程中无法提供服务。
2、组协调器以心跳的方式判断消费者是否可用,若消费者消费消息耗时严重或者宕机则可能导致获取不到心跳,被踢出消费者组导致Rebalance,若是自动提交时,对应的offset就可能未提交,导致重平衡后仍从已上次已消费的offset进行消费,从而重复消费。

6、性能分析

Kafka 高性能原因分析

  • 顺序写入:顺序写入与随机写入速度相差高达6000倍
  • 批量处理:使用消息累加器仅多个消息批量发送,既节省带宽有提高了发送速度
  • 消息压缩:kafka支持队消息压缩,支持格式有:gzip、snapply、lz4,可以使用compression.type配置
  • 页缓存:在消息发送后,并没有等到消息写入磁盘后才返回,而是到page Cache中就返回。page Cache与文件系统的写入由操作系统自动完成
  • 零拷贝(zero-copy):Kafka两个重要过程都使用了零拷贝技术,且都是操作系统层面的狭义零拷贝,一是Producer生产的数据存到broker,二是 Consumer从broker读取数据。

7、如何保证数据有序性

一些场景需要保证多个消息的消费顺序,比如订单,但在kafka中一个消息可能被发到多个partition中多个线程处理,被多个消费者消费,无法保证消息的消费顺序

解决方案:将需要顺序消费的消息发送的时候设置将某个topic发送到指定的partition(也可以根据key的hash与分区进行运算),则在partition中的消息也是有序的,消费的时候将一组同hash的key放到同一个queue中保证同一个消费者下的同一个线程对此queue进行消费。

总结:一个producer->一个partition->一个queue->一个comsumer->一个线程
当对于需要顺序消费的消息数量大的时候,无法保证吞吐量

8、如何保证数据可靠性

  • 多副本机制,副本数越多可靠性越强,但过多副本也会带来性能的下降,最小副本数为3,满足半数以上理论。
  • 消息生产过程的ack机制
  • 参数unclean.leader.election.enable可控制只让ISR列表中的副本成为leader,数据不丢失
  • 生产重试机制。可设置重试次数和两次重试之间的间隔,通过适当调大重试间隔来提高生产成功率
  • 消费端的位移提交控制。消费端可以自主选择自动提交位移还是自动提交位移
  • 消费回溯。对漏掉的消息进行回读,进一步提高了可靠性
  • 基于消息偏移量回溯:重置offset,消费者会从该offset之后开始消费
  • 基于时间点的消息回溯:基于kafka存储消息的文件格式
    例如我们要回溯2020-4-12 09:00:00的之后消息:
    • 1.首先换算成时间戳为1586653200000
    • 2.根据时间戳1586653200000在时间索引中找到不大于1586653200000的最大的偏移量
    • 3.找了偏移量,按照偏移量索引讲解的步骤,逐一去查找,即可找到对应的消息position
    • 4.通过position定位了消息,获取消息的生成时间,比1586653200000进行比对,然后按顺序逐渐和后面的消息一一进行时间戳比对,如果前一个消息的时间戳<1586653200000 & 后一个消息的时间戳 > 1586653200000 。 那么这个位置是就是消息回溯点,拿到消息的offset,对消费者消费记录的offset进行重置,那么整个回溯就完成了
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值