Kafka源码调试(四):物理日志文件解析原理

1. kafka主题分区物理文件

  • 客户端启动的时候,初次发起 FindCoordinatorRequest (type=Group)请求查找 GroupCoordinator 会触发 Kafka 节点初始化内部主题 __consumer_offsets 以及默认 50 个分区的物理目录和文件。
  • 客户端首次发送事务消息之前,初次发起 FindCoordinatorRequest (type=Transaction)请求查找 TransactionCoordinator 会触发 Kafka 节点初始化内部主题 __transaction_state 以及默认 50 个分区的物理目录和文件。
  • 客户端启动的时候,主动向 Kafka 配置注册普通主题分区,也会触发 Kafka 节点初始化其每个分区的物理目录和文件
    • 例如下图的TRANSACTION-TOPIC-1TRANSACTION-TOPIC-2两个主题,它们都各只有一个分区,分区编号为0

物理日志文件目录布局如下所示:

在这里插入图片描述

进入其中一个分区目录,看看里面有什么

在这里插入图片描述

在主题分区初始化的时候,只会有一个日志分段(LogSegment),分段偏移量范围区间是[00000000000000000000,00000000000000000000),区间首部叫做“基准偏移量”,区间尾部会随着内容追加而增加,因为客户端还没有发送过消息,分区序列里一条消息都还没有,所以区间容量暂时为0。

每个 LogSegment 的文件都是以其“基准偏移量”命名的,例如初始分段的日志文件就以 00000000000000000000.log 命名。
以此类推,随着内容追加,00000000000000000000.log 文件大小超过 log.segment.bytes 阈值的时候,会关闭当前日志分段,生成新的日志分段。
新日志分段会将上一个分段的偏移量区间尾部作为它自己的基准偏移量,假设新日志分段的基准偏移量是 00000000000000000333,那么新日志分段的文件就是以 00000000000000000333.log 命名。
.log文件外,每个日志分段中的索引文件也是以基准偏移量作为命名,如 00000000000000000333.index 等。

在这里插入图片描述

当客户端发送一条消息到该分区的时候,会找到该分区的活跃分段(activeSegment),即分区目录内文件命名(基准偏移量)最大的那个。只有活跃分段是可写的,其余分段都是只读的。

Kafka会将消息往 activeSegment 的文件中追加,直到活跃分段的日志大小总量超过 log.segment.bytes 阈值而再次增加新的活跃分段。

2. 解析 Kafka 物理日志文件

Kafka 源码中有个类 kafka.tools.DumpLogSegments ,它可以将物理日志文件解析为二进制数据以及人眼可读字符。

具体参数,可以参考自带的测试类:kafka.tools.DumpLogSegmentsTest,如果只是为了内容查看,稍微简化一下,仅仅做简单输出:

// 日志文件位置
val logFile = s"F:\\tmp\\kafka-logs\\TRANSACTION-TOPIC-1-0\\00000000000000000000.log"

@Test
def one(): Unit = {
  val outContent = new ByteArrayOutputStream
  Console.withOut(outContent) {
    DumpLogSegments.main(Array("--print-data-log", "--deep-iteration", "--files", logFile))
  }
  println(outContent)
}

输出结果为:

Dumping F:\tmp\kafka-logs\TRANSACTION-TOPIC-1-0\00000000000000000000.log
Starting offset: 0
offset: 0 position: 0 CreateTime: 1677738738454 isvalid: true keysize: -1 valuesize: 41 magic: 2 compresscodec: NONE producerId: 1000 producerEpoch: 0 sequence: 0 isTransactional: true headerKeys: [spring.message.value.type] payload: 第一事件:这是发送kafka消息体
offset: 1 position: 153 CreateTime: 1677738738628 isvalid: true keysize: 4 valuesize: 6 magic: 2 compresscodec: NONE producerId: 1000 producerEpoch: 0 sequence: -1 isTransactional: true headerKeys: [] endTxnMarker: COMMIT coordinatorEpoch: 0

用命令 od -tx1 -Ad 00000000000000000000.log 以十六进制格式显示文件内容为:

提示:
该文件是发送过一条事务消息后的内容,总长度:230 bytes

         00 01 02 03 04 05 06 07   08 09 0A 0B 0C 0D 0E 0F

0000000  00 00 00 00 00 00 00 00   00 00 00 8d 00 00 00 00  |
0000016  02 fd 2c 74 05 00 10 00   00 00 00 00 00 01 86 a1  |
0000032  05 83 16 00 00 01 86 a1   05 83 16 00 00 00 00 00  |
0000048  00 03 e8 00 00 00 00 00   00 00 00 00 01 b4 01 00  |
0000064  00 00 01 52 e7 ac ac e4   b8 80 e4 ba 8b e4 bb b6  | 一个 RecordBatch
0000080  ef bc 9a e8 bf 99 e6 98   af e5 8f 91 e9 80 81 6b  |
0000096  61 66 6b 61 e6 b6 88 e6   81 af e4 bd 93 02 32 73  |
0000112  70 72 69 6e 67 2e 6d 65   73 73 61 67 65 2e 76 61  |
0000128  6c 75 65 2e 74 79 70 65   20 6a 61 76 61 2e 6c 61  |
0000144  6e 67 2e 53 74 72 69 6e   67                       |

0000144                               00 00 00 00 00 00 00  |
0000160  01 00 00 00 42 00 00 00   00 02 f5 09 66 9f 00 30  |
0000176  00 00 00 00 00 00 01 86   a1 05 83 c4 00 00 01 86  | 下一个 RecordBatch
0000192  a1 05 83 c4 00 00 00 00   00 00 03 e8 00 00 ff ff  |
0000208  ff ff 00 00 00 01 20 00   00 00 08 00 00 00 01 0c  |
0000224  00 00 00 00 00 00 00                               |

目前人眼是看不懂这些十六进制数据的,我们接下来将模拟 Kafka 的解析程序,逐个字节进行解析,来探索日志格式是怎么组成的。

2.1. RecordBatch

笔记:
引用 《深入理解Kafka:核心设计与实践原理》 ——朱忠华 书中对于 RecordBatch 消息格式的组成示例图
在这里插入图片描述

笔记:
引用Kafka源码注释:org.apache.kafka.common.record.DefaultRecordBatch
magic 2 及以上版本的 RecordBatch 实现。模型如下:

RecordBatch =>
  BaseOffset => Int64
  Length => Int32
  PartitionLeaderEpoch => Int32
  Magic => Int8
  CRC => Uint32
  Attributes => Int16
  LastOffsetDelta => Int32 // also serves as LastSequenceDelta
  FirstTimestamp => Int64
  MaxTimestamp => Int64
  ProducerId => Int64
  ProducerEpoch => Int16
  BaseSequence => Int32
  Records => [Record]
field namebytes sizedatavaluedescribe
first offset8B00 00 00 00 00 00 00 000当前 RecordBatch 起始偏移量
length4B00 00 00 8d141(bytes)计算从 partition leader epoch 字段开始到末尾的长度,按照这个长度计算,将上面十六进制数据表格分割展示
partition leader epoch4B00 00 00 000当前分区分区 leader 节点的 epoch 版本号
magic1B022消息格式的版本号,2 代表 v2 版本,还有其他例如 0代表 v0 ,1 代表v1
crc324Bfd 2c 74 054247548933理解为类似签名码,确保数据安全
attributes2B00 100000 0000 000+1+0+0001. 低3位标表示压缩类型:000->NONE001->GZIP010->SNAPPY011->LZ4
2. 第4位表示时间戳类型:0->CreateTime1->LogAppendTime,由 broker 端参数 log.message.timestamp.type 配置
3. 第5位表示此 RecordBatch 是否处于事务中,0->非事务,1->事务
4. 第6位表示是否是控制消息(ControlBatch):0->非控制,1->控制,控制消息用来支持事务
5. 其余位未使用,保留
last offset delta4B00 00 00 000RecordBatch 中最后一个 Record 的 offset 与 first offset 的差值。用于 broker 确保 Record 组装的正确性。
first timestamp8B00 00 01 86 a1 05 83 161677738738454 = 2023/3/2 14:32:18.454RecordBatch 中第一条 Record 的时间戳
max timestamp8B00 00 01 86 a1 05 83 161677738738454 = 2023/3/2 14:32:18.454RecordBatch 最大的时间戳,一般情况下指最后一个 Record 的时间戳,用于确保 Record 组装的正确性
producer id8B00 00 00 00 00 00 03 e81,000PID,生产者id,用来支持幂等和事务
producer epoch2B00 000表示生产者版本,用于支持幂等和事务
first sequence4B00 00 00 000和 producer id 、producer epoch 一样,用来支持幂等和事务
records count4B00 00 00 011RecordBatch 中 Record 的个数,用于规定遍历次数

2.1.1. Records

上面 RecordBatch 提到的字段全部都是固定长度的,而接下来 Records 部分大量采用了变长整型(Varints)。

笔记:
引用 《深入理解Kafka:核心设计与实践原理》 ——朱忠华 书中对于 Varints 的描述。

Varints 是使用一个或多个字节来序列化整数的一种方法。数值越小,其占用的字节数就越少。Varints 中的每个字节都有一个位于最高位的 msb 位(most significant bit),除最后一个字节外,其余 msb 位都设置为1,最后一个字节的 msb 位为 0
这个 msb 位标识其后的字节是否和当前字节一起来标识同一个整数。
除 msb 位外,剩余的 7 位用于存储数据本身,这种表示类型称为 Base 128。通常而言,一个字节 8 位可以表示 256 个值,所以称为 Base 256,而这里只能用 7 位表示,2 的 7 次方即 128。Varints 中采用的是小端字节序,即最小的字节放在最前面。

举个例子,比如数字 1,它只占一个字节,所以 msb 位为 0

0000 0001

再举一个复杂点的例子,比如数字 300

1010 1100 0000 0010

300 的二进制表示原本为 0000 0001 0010 1100 = 256 + 32 + 8 + 4 = 300,那么为什么 300 的变长表示为上面的这种形式?

首先去掉每个字节的 msb 位,表示如下:

1010 1100 0000 0010
    -> 000 0010 010 1100  (翻转)
    -> 000 0010 ++ 010 1100
    -> 0000 0001 0010 1100  = 256 + 32 + 8 + 4 = 300

Kafka 的 varint 和 varlong 算法的代码位置以及调用位置:

org.apache.kafka.common.utils.ByteUtils

org.apache.kafka.common.record.DefaultRecord#readFrom(java.nio.ByteBuffer, long, long, int, java.lang.Long)

package org.apache.kafka.common.utils;

/**
 * This classes exposes low-level methods for reading/writing from byte streams or buffers.
 */
public final class ByteUtils {

    // 省略其他方法......

    /**
     * Read an integer stored in variable-length format using zig-zag decoding from
     * <a href="http://code.google.com/apis/protocolbuffers/docs/encoding.html"> Google Protocol Buffers</a>.
     *
     * @param buffer The buffer to read from
     * @return The integer read
     *
     * @throws IllegalArgumentException if variable-length value does not terminate after 5 bytes have been read
     */
    public static int readVarint(ByteBuffer buffer) {
        int value = 0;
        int i = 0;
        // 每个字节
        int b;

        // 字节跟 0x80 (1000 0000) 进行按与运算,如果结果不等于 0 说明其后一个字节也是整数组成部分,且需要去除 msb 位,继续循环
        while (((b = buffer.get()) & 0x80) != 0) {
            // 1. 字节跟 0x7f (0111 1111) 进行按与运算,去除 msb 位的 1
            // 2. 将结果左移 i 位,等效于乘以 i 个 2,实现小端字节序的翻转
            // 3. `|=`意思是跟左边的 `value` 按位或运算后,再赋值给左边的 `value`,由于左移过后重合位都是 0 ,按位或运算保留相同位置的所有1,最终等效于即两数相加
            value |= (b & 0x7f) << i;

            // 左移参数,第1个字节不左移,第2个字节左移 7,以此类推第 n 个字节左移 7n。
            i += 7;

            // 由于是 32 位整数的编码,总长度有限制,体现在左移最大数为 28 = 7 * 4 ,即最多有 5 个字节来表示一个 32 位整数
            // 如果是 readVarlong 方法,唯一的不同就是这里 28 变成了63
            if (i > 28)
                throw illegalVarintException(value);
        }

        // 末位字节的 msb 位必然是 0,即跳出了上面循环,这一步处理末尾字节,省去了 msb 位去除,因为 msb 本身就是 0
        value |= b << i;

        // zig-zag 解码算法
        return (value >>> 1) ^ -(value & 1);
    }
}

了解了变长整型(Varints)后,再来探索一下 Records 部分吧。

截除之前 RecordBatch 已经提及的部分,剩余的部分包含了 Reocords 和 Headers 两部分,暂时还分不清边界在哪,所以将剩余的部分全部展示出来方便比对:

         00 01 02 03 04 05 06 07   08 09 0A 0B 0C 0D 0E 0F

0000048                                           b4 01 00    | length , attributes
0000064  00 00 01 52                                          | timestampDelta , offsetDelta , keyLength , valueLength

0000064              e7 ac ac e4   b8 80 e4 ba 8b e4 bb b6    |         
0000080  ef bc 9a e8 bf 99 e6 98   af e5 8f 91 e9 80 81 6b    | value ==(UTF-8)==> "第一事件:这是发送kafka消息体"
0000096  61 66 6b 61 e6 b6 88 e6   81 af e4 bd 93             |

0000096                                           02          | headers count

0000096                                              32 73    |
0000112  70 72 69 6e 67 2e 6d 65   73 73 61 67 65 2e 76 61    | headers
0000128  6c 75 65 2e 74 79 70 65   20 6a 61 76 61 2e 6c 61    |
0000144  6e 67 2e 53 74 72 69 6e   67                  

说明:
变长整型的计算过程人类心算和笔算都比较难实现的,以下 varint 和 varlong 的计算过程表达,只有第一个字段写的比较具体,剩余其他的简化一下,只写个结果,读者请知悉

field namebytes sizedatavaluedescribe
lengthvarintb4 01zig-zag(varint(10110100 00000001)) = zig-zag(180) = 90消息总长度(从attributes开始),按照这个长度对上面十六进制数据表格分割展示
attributes1B000弃用,但还是在消息格式中占据 1 Bytes 的大小,以备未来的格式扩展
timestamp deltavarlong000时间戳增量。通常一个 timestamp 需要占用 8 个字节,如果像这里一样保存与 RecordBatch 的其实时间戳的差值,则可以进一步节省占用的字节数。
timestampfirst timestamp + timestamp delta = 1677738738454 = 2023/3/2 14:32:18.454根据增量计算实际时间戳
offset deltavarint000位移增量。保存与 RecordBatch 起始位移的差值,可以节省占用的字节数
offsetfirst offset + timestamp delta = 0根据增量计算实际偏移量
key lengthvarint01-1下个字段 key 长度,负数代表空
key-1(空)record key
key lengthvarint5241下个字段 value 长度
value41[68,109)=(UTF-8解码)==> 第一事件:这是发送kafka消息体record value
headers countvarint021接下来 headers 总数,用于规定遍历次数
2.1.1.1. headers

以下是 headers 字段内容

         00 01 02 03 04 05 06 07   08 09 0A 0B 0C 0D 0E 0F

0000096                                              32 73    | key length
0000112  70 72 69 6e 67 2e 6d 65   73 73 61 67 65 2e 76 61    | key
0000128  6c 75 65 2e 74 79 70 65                              |

0000128                            20                         | value length 负数代表空
0000128                               6a 61 76 61 2e 6c 61    | 未知数据
0000144  6e 67 2e 53 74 72 69 6e   67      
field namebytes sizedatavaluedescribe
header key lengthvarint3225下个字段 header key 的长度
header key25[111,136)=(UTF-8解码)==> spring.message.value.typeheader key
header value lengthvarint20-58下个字段 header value 的长度,负数代表空
header value-58(空)header key

3. 根据 Kafka 源码,再实现一目了然的日志文件解码过程

源码参考:org.apache.kafka.common.record.DefaultRecordBatch

    @Test
    public void one() throws IOException {
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS");

        final File logFile = new File("F:\\tmp\\kafka-logs\\TRANSACTION-TOPIC-1-0\\00000000000000000000.log");
        try (FileRecords fileRecords = FileRecords.open(logFile)) {

            // 将整个日志文件从头开始读取
            FileLogInputStream logInputStream = new FileLogInputStream(fileRecords.channel(), 0, fileRecords.sizeInBytes());

            // 按照 DefaultRecordBatch.LENGTH_OFFSET 偏移量字段计算 RecordBatch 边界,并逐个取出
            while (true) {
                FileLogInputStream.FileChannelRecordBatch batch = logInputStream.nextBatch();
                if (batch == null) {
                    break;
                }
                // 将文件以字节形式写入内存 buffer
                ByteBuffer buffer = ByteBuffer.allocate(2048);
                batch.writeTo(buffer);
                buffer.flip();

                // 构造 RecordBatch 对象,这个对象封装的方法,会按照特定的偏移量从 buffer 中取出特定值
                DefaultRecordBatch recordBatch = new DefaultRecordBatch(buffer);

                final long baseOffset = recordBatch.baseOffset();
                System.out.printf("======start=======first offset = %s%n", baseOffset);
                System.out.printf("length = %s%n", buffer.getInt(DefaultRecordBatch.LENGTH_OFFSET));  // 根据字段偏移量取
                System.out.printf("partition leader epoch = %s%n", recordBatch.partitionLeaderEpoch());
                System.out.printf("magic = %s%n", recordBatch.magic());
                System.out.printf("crc32 = %s%n", recordBatch.checksum());
                System.out.printf("attributes.isControlBatch = %s%n", recordBatch.isControlBatch());
                System.out.printf("attributes.isTransactional = %s%n", recordBatch.isTransactional());
                System.out.printf("attributes.compressionType = %s%n", recordBatch.compressionType());
                System.out.printf("attributes.timestampType = %s%n", recordBatch.timestampType());
                System.out.printf("last offset delta = %s%n",
                    buffer.getInt(DefaultRecordBatch.LAST_OFFSET_DELTA_OFFSET));  // 根据字段偏移量取

                final long firstTimestamp = buffer.getLong(DefaultRecordBatch.FIRST_TIMESTAMP_OFFSET);  // 根据字段偏移量取
                System.out.printf("first timestamp = %s = %s%n", firstTimestamp,
                    dateFormat.format(new Date(firstTimestamp)));

                System.out.printf("max timestamp = %s = %s%n", recordBatch.maxTimestamp(),
                    dateFormat.format(new Date(recordBatch.maxTimestamp())));
                System.out.printf("producer id %s%n", recordBatch.producerId());
                System.out.printf("producer epoch %s%n", recordBatch.producerEpoch());
                System.out.printf("first sequence %s%n", recordBatch.baseSequence());
                final Integer recordsCount = recordBatch.countOrNull();
                System.out.printf("records count %s%n", recordsCount);

                // 将游标位置设置为 records 字段
                buffer.position(DefaultRecordBatch.RECORDS_OFFSET);

                // 按照 records 数量遍历每个 record
                for (int i = 0; i < recordsCount; i++) {
                    System.out.printf("------start------- record %d%n", i);
                    System.out.printf("record%d -> length = %s%n", i, ByteUtils.readVarint(buffer));
                    System.out.printf("record%d -> attributes = %s%n", i, buffer.get());

                    final long timestampDelta = ByteUtils.readVarlong(buffer);
                    System.out.printf("record%d -> timestamp delta = %s%n", i, timestampDelta);
                    System.out.printf("record%d -> timestamp = first timestamp + timestamp delta = %s%n", i, firstTimestamp + timestampDelta);

                    int offsetDelta = ByteUtils.readVarint(buffer);
                    System.out.printf("record%d -> offset delta = %s%n", i, offsetDelta);
                    System.out.printf("record%d -> offset = baseOffset + timestamp delta = %s%n", i, baseOffset + offsetDelta);

                    ByteBuffer key = null;
                    int keySize = ByteUtils.readVarint(buffer);
                    System.out.printf("record%d -> key length = %s%n", i, keySize);
                    if (keySize >= 0) {
                        key = buffer.slice();
                        key.limit(keySize);
                        System.out.printf("record%d -> key = %s%n", i, Utils.utf8(key, keySize));
                        buffer.position(buffer.position() + keySize);
                    } else {
                        System.out.printf("record%d -> key = %n", i);
                    }

                    ByteBuffer value = null;
                    int valueSize = ByteUtils.readVarint(buffer);
                    System.out.printf("record%d -> value length = %s%n", i, valueSize);
                    if (valueSize >= 0) {
                        // 从当前位置创建新的缓冲区,value区间首部
                        value = buffer.slice();
                        // 设置新缓冲区限制位,value区间尾部
                        value.limit(valueSize);
                        System.out.printf("record%d -> value = %s%n", i, Utils.utf8(value, valueSize));
                        buffer.position(buffer.position() + valueSize);
                    } else {
                        System.out.printf("record%d -> value = %n", i);
                    }

                    // 根据 headers count,遍历解析每个 header
                    int numHeaders = ByteUtils.readVarint(buffer);
                    System.out.printf("record%d -> headers count = %s%n", i, numHeaders);
                    if (numHeaders == 0){
                        System.out.printf("record%d -> headers%d = []%n", i, 0);
                    } else {
                        for (int ii = 0; ii < numHeaders; ii++) {

                            int headerKeySize = ByteUtils.readVarint(buffer);
                            System.out.printf("record%d -> headers%d -> header key length = %s%n", i, ii, headerKeySize);
                            String headerKey = Utils.utf8(buffer, headerKeySize);
                            System.out.printf("record%d -> headers%d -> header key = %s%n", i, ii, headerKey);
                            ByteBuffer headerValue = null;

                            int headerValueSize = ByteUtils.readVarint(buffer);
                            System.out.printf("record%d -> headers%d -> header value length = %s%n", i, ii, headerValueSize);
                            if (headerValueSize >= 0) {
                                headerValue = buffer.slice();
                                headerValue.limit(headerValueSize);
                                System.out.printf("record%d -> headers%d -> header value = %s%n", i, ii, Utils.utf8(headerValue, headerValueSize));
                                buffer.position(buffer.position() + headerValueSize);
                            } else {
                                System.out.printf("record%d -> headers%d -> header value = %n", i, ii);
                            }
                        }
                    }
                    System.out.printf("------end------- record %d%n", i);
                }
                System.out.printf("======end=======first offset = %s%n", baseOffset);
            }
        }
    }

根据上面的 00000000000000000000.log 内容,解析结果如下所示:

======start=======first offset = 0
length = 141
partition leader epoch = 0
magic = 2
crc32 = 4247548933
attributes.isControlBatch = false
attributes.isTransactional = true
attributes.compressionType = NONE
attributes.timestampType = CreateTime
last offset delta = 0
first timestamp = 1677738738454 = 2023-03-02 14:32:18.454
max timestamp = 1677738738454 = 2023-03-02 14:32:18.454
producer id 1000
producer epoch 0
first sequence 0
records count 1
------start------- record 0
record0 -> length = 90
record0 -> attributes = 0
record0 -> timestamp delta = 0
record0 -> timestamp = first timestamp + timestamp delta = 1677738738454
record0 -> offset delta = 0
record0 -> offset = baseOffset + timestamp delta = 0
record0 -> key length = -1
record0 -> key = 
record0 -> value length = 41
record0 -> value = 第一事件:这是发送kafka消息体
record0 -> headers count = 1
record0 -> headers0 -> header key length = 25
record0 -> headers0 -> header key = spring.message.value.type
record0 -> headers0 -> header value length = -58
record0 -> headers0 -> header value = 
------end------- record 0
======end=======first offset = 0
======start=======first offset = 1
length = 66
partition leader epoch = 0
magic = 2
crc32 = 4111034015
attributes.isControlBatch = true
attributes.isTransactional = true
attributes.compressionType = NONE
attributes.timestampType = CreateTime
last offset delta = 0
first timestamp = 1677738738628 = 2023-03-02 14:32:18.628
max timestamp = 1677738738628 = 2023-03-02 14:32:18.628
producer id 1000
producer epoch 0
first sequence -1
records count 1
------start------- record 0
record0 -> length = 16
record0 -> attributes = 0
record0 -> timestamp delta = 0
record0 -> timestamp = first timestamp + timestamp delta = 1677738738628
record0 -> offset delta = 0
record0 -> offset = baseOffset + timestamp delta = 1
record0 -> key length = 4
record0 -> key =    
record0 -> value length = 6
record0 -> value =       
record0 -> headers count = 0
record0 -> headers0 = []
------end------- record 0
======end=======first offset = 1
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值