FindCoordinatorRequest
(type=Group)请求查找 GroupCoordinator
会触发 Kafka 节点初始化内部主题 __consumer_offsets
以及默认 50
个分区的物理目录和文件。FindCoordinatorRequest
(type=Transaction)请求查找 TransactionCoordinator
会触发 Kafka 节点初始化内部主题 __transaction_state
以及默认 50
个分区的物理目录和文件。TRANSACTION-TOPIC-1
和TRANSACTION-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
阈值而再次增加新的活跃分段。
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 的解析程序,逐个字节进行解析,来探索日志格式是怎么组成的。
笔记:
引用 《深入理解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 name | bytes size | data | value | describe |
---|---|---|---|---|
first offset | 8B | 00 00 00 00 00 00 00 00 |
0 |
当前 RecordBatch 起始偏移量 |
length | 4B | 00 00 00 8d |
141 (bytes) |
计算从 partition leader epoch 字段开始到末尾的长度,按照这个长度计算,将上面十六进制数据表格分割展示 |
partition leader epoch | 4B | 00 00 00 00 |
0 |
当前分区分区 leader 节点的 epoch 版本号 |
magic | 1B | 02 |
2 |
消息格式的版本号,2 代表 v2 版本,还有其他例如 0 代表 v0 ,1 代表v1 |
crc32 | 4B | fd 2c 74 05 |
4247548933 |
理解为类似签名码,确保数据安全 |
attributes | 2B | 00 10 |
0000 0000 000 +1 +0 +000 |
1. 低3位标表示压缩类型:000 ->NONE ,001 ->GZIP ,010 ->SNAPPY ,011 ->LZ4 2. 第4位表示时间戳类型: 0 ->CreateTime ,1 ->LogAppendTime ,由 broker 端参数 log.message.timestamp.type 配置 3. 第5位表示此 RecordBatch 是否处于事务中, 0 ->非事务,1 ->事务 4. 第6位表示是否是控制消息( ControlBatch ):0 ->非控制,1 ->控制,控制消息用来支持事务 5. 其余位未使用,保留 |
last offset delta | 4B | 00 00 00 00 |
0 |
RecordBatch 中最后一个 Record 的 offset 与 first offset 的差值。用于 broker 确保 Record 组装的正确性。 |
first timestamp | 8B | 00 00 01 86 a1 05 83 16 |
1677738738454 = 2023/3/2 14:32:18.454 |
RecordBatch 中第一条 Record 的时间戳 |
max timestamp | 8B | 00 00 01 86 a1 05 83 16 |
1677738738454 = 2023/3/2 14:32:18.454 |
RecordBatch 最大的时间戳,一般情况下指最后一个 Record 的时间戳,用于确保 Record 组装的正确性 |
producer id | 8B | 00 00 00 00 00 00 03 e8 |
1,000 |
PID,生产者id,用来支持幂等和事务 |
producer epoch | 2B | 00 00 |
0 |
表示生产者版本,用于支持幂等和事务 |
first sequence | 4B | 00 00 00 00 |
0 |
和 producer id 、producer epoch 一样,用来支持幂等和事务 |
records count | 4B | 00 00 00 01 |
1 |
RecordBatch 中 Record 的个数,用于规定遍历次数 |
上面 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
* Google Protocol Buffers.
*
* @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 name | bytes size | data | value | describe |
---|---|---|---|---|
length | varint | b4 01 |
zig-zag(varint(10110100 00000001 )) = zig-zag(180 ) = 90 |
消息总长度(从attributes 开始),按照这个长度对上面十六进制数据表格分割展示 |
attributes | 1B | 00 |
0 |
弃用,但还是在消息格式中占据 1 Bytes 的大小,以备未来的格式扩展 |
timestamp delta | varlong | 00 |
0 |
时间戳增量。通常一个 timestamp 需要占用 8 个字节,如果像这里一样保存与 RecordBatch 的其实时间戳的差值,则可以进一步节省占用的字节数。 |
timestamp | first timestamp + timestamp delta = 1677738738454 = 2023/3/2 14:32:18.454 |
根据增量计算实际时间戳 | ||
offset delta | varint | 00 |
0 |
位移增量。保存与 RecordBatch 起始位移的差值,可以节省占用的字节数 |
offset | first offset + timestamp delta = 0 |
根据增量计算实际偏移量 | ||
key length | varint | 01 |
-1 |
下个字段 key 长度,负数代表空 |
key | -1 (空) |
record key | ||
key length | varint | 52 |
41 |
下个字段 value 长度 |
value | 41 |
[68,109) |
=(UTF-8解码)==> 第一事件:这是发送kafka消息体 |
record value |
headers count | varint | 02 |
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 name | bytes size | data | value | describe |
---|---|---|---|---|
header key length | varint | 32 |
25 |
下个字段 header key 的长度 |
header key | 25 |
[111,136) |
=(UTF-8解码)==> spring.message.value.type |
header key |
header value length | varint | 20 |
-58 |
下个字段 header value 的长度,负数代表空 |
header value | -58 (空) |
header key |
源码参考: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