上一篇看到了 Kafka 集群的全景:broker 存储日志,controller 管理元数据,topic 通过 partition 映射到磁盘目录。这一篇进入单个 partition 内部,看日志文件到底怎么组织。

一个 partition 不是一个巨大的文件。更准确的说法是:一个 partition 是一组按 offset 范围切分的 segment 文件,每个 segment 由数据文件、offset 索引文件和时间索引文件组成。

本文只抓一个问题:一条 produce 请求写入的字节如何变成磁盘上的 segment 文件,以及 fetch 请求如何通过零拷贝把这些字节送到网络上。

Segment 文件:日志的物理切片

一个 partition 的日志目录中,segment 文件按 base offset 命名。Base offset 是该 segment 中第一条记录的 offset,用 20 位数字左补零表示。

1
2
3
4
5
6
7
8
9
10
partition 目录: orders-0/
├── 00000000000000000000.logsegment 0 的数据文件
├── 00000000000000000000.index ← segment 0offset 索引
├── 00000000000000000000.timeindex ← segment 0 的时间索引
├── 00000000000000005000.logsegment 1 (base offset = 5000)
├── 00000000000000005000.index
├── 00000000000000005000.timeindex
├── 00000000000000010000.log ← active segment (当前写入点)
├── 00000000000000010000.index
└── 00000000000000010000.timeindex

Segment 滚动的触发条件由以下参数控制(取最先满足的条件):

  • log.segment.bytes:单个 segment 文件的大小上限,默认 1 GB。
  • log.roll.ms / log.roll.hours:segment 的最大存活时间,默认 7 天。

当 active segment 达到任一条件时,Kafka 关闭当前 segment(使其变为只读),创建一个新的 segment 文件作为 active segment。只有 active segment 接受写入,已关闭的 segment 不可变。

这个设计的直接好处是:segment 的不可变性使得旧 segment 可以被安全地读取、复制和删除,不需要与写入路径加锁协调。

从 Produce 请求到磁盘字节

一条 produce 请求到达 leader broker 后,经过以下路径写入磁盘:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Producer                     Leader Broker
│ │
│── ProduceRequest ──→ │
│ (topic, partition, │
│ RecordBatch) │
│ │
│ ┌────────┴────────┐
│ │ 1. 校验 CRC │
│ │ 2. 分配 offset
│ │ 3. 追加到 active │
│ │ segment 的 │
│ │ .log 文件 │
│ │ 4. 更新 .index
│ │ 和 .timeindex │
│ └────────┬────────┘
│ │
│←── ProduceResponse ── │
│ (base offset, │
log append time) │

ProduceRequest 中的消息以 RecordBatch 为单位组织。一个 RecordBatch 可以包含多条记录(record),这是 producer 端攒批(batching)的结果,将在第 03 篇展开。

写入过程的关键步骤:

  1. 校验 RecordBatch 的 CRC 校验和,确保网络传输没有损坏数据。
  2. 为 batch 中的每条 record 分配连续的 offset。Offset 分配是单调递增的,在 partition 级别保证唯一。
  3. 将整个 RecordBatch 的字节追加到 active segment 的 .log 文件尾部。这是一次顺序写入。
  4. .index 文件中记录 offset 到文件物理位置(position)的映射。在 .timeindex 文件中记录时间戳到 offset 的映射。

写入操作默认不立即调用 fsync。Kafka 依赖操作系统的 page cache 来缓冲写入,由 OS 决定何时将脏页刷到磁盘。这个设计选择用持久性的微小风险换取写入吞吐的大幅提升——如果 broker 进程崩溃但 OS 没有崩溃,page cache 中的数据仍然会被刷盘;如果 OS 也崩溃,副本机制(replication)提供保护。

Offset 索引:稀疏索引与二分查找

.index 文件不为每一条记录建索引。它是一个稀疏索引(sparse index),每隔一定字节量(由 index.interval.bytes 控制,默认 4096 字节)在 index 中插入一个条目。

每个 index 条目是一个 8 字节的定长记录:

1
2
3
┌──────────────────┬──────────────────┐
│ 相对 offset (4B) │ 物理位置 (4B) │
└──────────────────┴──────────────────┘

"相对 offset"是该记录的 offset 减去 segment 的 base offset。这样 4 字节整数就足以表示一个 segment 内部的 offset 范围(一个 segment 默认最大 1 GB,按最小记录大小计算,4 字节整数足够覆盖)。

查找某个 offset 的过程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
目标: 读取 offset = 7500 的记录

1. 定位 segment:
已知 segment 列表: [0, 5000, 10000]
7500 >= 5000 7500 < 10000
目标在 segment 00000000000000005000

2. 在该 segment .index 中二分查找:
index 条目: [(0, pos=0), (500, pos=4096), (1000, pos=8192), ...]
相对 offset = 7500 - 5000 = 2500
找到 <= 2500 的最大条目: (2500, pos=20480)
.log 文件的 position 20480 开始

3. position 20480 开始顺序扫描:
读取每条 record offset,直到找到 offset = 7500

这个两级查找的时间复杂度是 O(log S + log I + K),其中 S 是 segment 数量,I 是 index 条目数量,K 是从 index 命中位置到目标 offset 的顺序扫描记录数。由于 K 受 index.interval.bytes 限制,通常很小。

时间索引

.timeindex 文件的结构与 .index 类似,但映射关系是时间戳到 offset:

1
2
3
┌──────────────────┬──────────────────┐
│ 时间戳 (8B) │ 相对 offset (4B) │
└──────────────────┴──────────────────┘

时间索引用于支持按时间戳查找消息(offsetsForTimes API),以及基于时间的 retention 策略。Consumer 可以通过 KafkaConsumer.offsetsForTimes() 方法找到某个时间戳之后的第一个 offset,然后从该 offset 开始消费——这在数据回放和故障恢复场景中非常有用。

零拷贝:sendfile 与 page cache

Fetch 请求的数据传输路径是 Kafka 高吞吐的关键。传统的数据传输需要四次数据复制:

1
2
3
4
传统路径 (read + write):
磁盘 → 内核 page cache → 用户态缓冲区 → 内核 socket 缓冲区 → 网卡

4 次数据复制,2 次上下文切换(内核态 ↔ 用户态)

Kafka 使用 Java NIO 的 FileChannel.transferTo() 方法,底层对应 Linux 的 sendfile() 系统调用:

1
2
3
4
5
零拷贝路径 (sendfile):
磁盘 → 内核 page cache → 网卡 (DMA 直传)

2 次数据复制(如果网卡支持 scatter-gather DMA,可以减少到 1 次)
0 次用户态复制

零拷贝的核心在于:数据从 page cache 直接传到网卡缓冲区,不经过 Kafka broker 的 JVM 堆内存。这意味着:

  • 数据传输不受 GC 影响。大量数据在内核态完成传输,JVM 堆没有分配任何临时对象。
  • broker 的内存消耗与数据吞吐量不成正比。即使传输 GB 级别的数据,broker 的堆内存使用量几乎不变。

Kafka broker 不维护自己的消息缓存或 buffer pool。缓存完全依赖操作系统的 page cache。当 consumer 的消费速度跟得上 producer 的写入速度时,fetch 请求读取的数据很可能仍然在 page cache 中——这意味着 fetch 请求甚至不触发磁盘 I/O,直接从内存中零拷贝到网络。

1
2
3
4
5
6
写入路径:                      读取路径:
Producer → Broker Broker → Consumer
→ append to .log ← sendfile() from .log
→ 数据进入 page cache ← 如果数据仍在 page cache:
直接从内存到网卡,
不触发磁盘 I/O

这个设计让 Kafka 的读写性能高度依赖操作系统的 page cache 管理。在生产环境中,为 Kafka broker 预留足够的系统内存(不分配给 JVM 堆)是性能调优的关键——这些内存会被 OS 自动用作 page cache。

实验:写入消息并检查 segment 文件

以下实验向一个 topic 写入大量消息,然后用 kafka-dump-log 工具检查 segment 文件的内部结构。

创建 topic 并写入 10 万条消息:

1
2
3
4
5
6
7
8
9
# 创建 topic,设置较小的 segment 大小以便观察 segment 滚动
bin/kafka-topics.sh --create --topic storage-demo \
--partitions 1 --replication-factor 1 \
--config segment.bytes=1048576 \
--bootstrap-server localhost:9092

# 生成 100000 条消息并写入
seq 1 100000 | bin/kafka-console-producer.sh \
--topic storage-demo --bootstrap-server localhost:9092

检查 partition 目录中的 segment 文件:

1
ls -lh /tmp/kafka-logs-0/storage-demo-0/

输出类似:

1
2
3
4
5
6
7
8
9
-rw-r--r--  1 kafka  staff  1.0M  00000000000000000000.log
-rw-r--r-- 1 kafka staff 10M 00000000000000000000.index
-rw-r--r-- 1 kafka staff 10M 00000000000000000000.timeindex
-rw-r--r-- 1 kafka staff 1.0M 00000000000000065536.log
-rw-r--r-- 1 kafka staff 10M 00000000000000065536.index
-rw-r--r-- 1 kafka staff 10M 00000000000000065536.timeindex
-rw-r--r-- 1 kafka staff 524K 00000000000000131072.log ← active segment
-rw-r--r-- 1 kafka staff 10M 00000000000000131072.index
-rw-r--r-- 1 kafka staff 10M 00000000000000131072.timeindex

index 和 timeindex 文件预分配了固定大小(由 segment.index.bytes 控制,默认 10 MB),实际使用的空间在 segment 关闭时才会截断。

使用 kafka-dump-log 查看 segment 内部结构:

1
2
bin/kafka-dump-log.sh --files /tmp/kafka-logs-0/storage-demo-0/00000000000000000000.log \
--print-data-log | head -20

输出展示每个 RecordBatch 的元信息和其中的 record:

1
2
3
4
5
6
7
Dumping /tmp/kafka-logs-0/storage-demo-0/00000000000000000000.log
Starting offset: 0
baseOffset: 0 lastOffset: 249 count: 250 ... position: 0 ...
| offset: 0 ... payload: 1
| offset: 1 ... payload: 2
| offset: 2 ... payload: 3
...

查看 index 文件的内容:

1
bin/kafka-dump-log.sh --files /tmp/kafka-logs-0/storage-demo-0/00000000000000000000.index

输出展示稀疏索引条目——不是每个 offset 都有索引,只有间隔超过 index.interval.bytes 的位置才记录:

1
2
3
4
5
Dumping /tmp/kafka-logs-0/storage-demo-0/00000000000000000000.index
offset: 250 position: 4144
offset: 500 position: 8288
offset: 750 position: 12432
...

模式提炼

Kafka 的 segment 存储是日志结构存储(log-structured storage)的一种实现。这个模式的核心特征:

  • 写入只追加到当前活跃文件的尾部,是纯顺序 I/O。
  • 文件按时间或大小切片,旧文件不可变。
  • 读取通过索引结构定位,避免全文件扫描。
  • 空间回收通过删除整个旧文件实现,不需要在文件内部做 compaction。

与 LSM-tree(如 RocksDB、LevelDB)的区别:LSM-tree 的 SSTable 文件也是不可变的,但 LSM-tree 需要 compaction 过程来合并不同层级的文件、消除重复 key。Kafka 的 segment 文件之间没有 key 重叠——每个 segment 负责一段连续的 offset 范围,不需要合并。(Kafka 的 log compaction 功能是另一回事,将在第 10 篇展开。)

工程迁移表

维度 Kafka segment RocketMQ MappedFile 数据库 WAL segment
文件组织 每个 partition 独立的 segment 文件 所有 topic 共享一个 CommitLog,按固定大小切片 按 LSN 或时间切片的日志文件
命名规则 base offset(20 位数字) 起始物理偏移量 LSN 或序号
索引方式 稀疏 offset 索引 + 时间索引 ConsumeQueue(每个 topic-partition 一个索引文件) 无独立索引(顺序回放)
写入方式 FileChannel.write() / page cache mmap (MappedByteBuffer) 取决于数据库实现
读取传输 sendfile() 零拷贝 mmap 读取 + 网络写入 顺序回放
空间回收 按 retention 删除整个 segment 按过期时间删除整个 MappedFile checkpoint 后截断

常见误解

误解一:“Kafka 用 mmap 读取消息。”

Kafka broker 的消息读取路径使用 FileChannel.transferTo()(对应 sendfile 系统调用),不使用 mmap。RocketMQ 使用 mmap(MappedByteBuffer)进行读写,这是两者在存储层的重要区别。Kafka 的 index 文件使用 mmap 方式加载到内存,但数据文件(.log)的读取走 sendfile。

误解二:“索引存储每个 offset 的位置。”

Kafka 的 offset 索引是稀疏的,不为每条记录建索引条目。索引条目的密度由 index.interval.bytes(默认 4096 字节)控制——每当累计写入的字节数超过这个阈值时,才插入一个新的索引条目。这意味着查找一个特定 offset 时,先通过二分查找定位到最近的索引条目,然后从该位置开始顺序扫描若干条记录。

误解三:“Kafka 的高性能来自内存数据库式的设计。”

Kafka broker 没有维护任何应用层的消息缓存。数据直接写入文件系统(由 page cache 缓冲),读取时通过 sendfile 直接从文件系统传到网络。Kafka 的性能模型是"让操作系统做它最擅长的事"——顺序 I/O、page cache 管理、零拷贝传输。JVM 堆的大小对 Kafka 的吞吐量影响很小,系统空闲内存(用于 page cache)的大小影响很大。

练习

  1. 在上面的实验中,修改 segment.bytes 为不同的值(如 100 KB、10 MB),重新创建 topic 并写入相同数量的消息。观察 segment 文件的数量和大小如何变化。

  2. 使用 kafka-dump-log.sh --files xxx.index 检查 index 文件的条目间隔。修改 topic 配置 index.interval.bytes 为不同的值,重新写入消息后对比 index 条目的密度变化。

  3. 查阅 Linux sendfile() 系统调用的 man page,理解其参数和返回值。思考在什么条件下 sendfile 会退化为普通的 read+write(提示:跨文件系统、加密传输)。

系列导航

序号 主题
00 导读:为什么 Kafka 的核心是一根日志
01 架构总览:Broker、Controller 与元数据管理
02 日志存储:Segment、Index 与零拷贝
03 Producer 内部机制:攒批、分区与 acks
04 幂等 Producer 与序列号:消息不重不丢的第一层
05 Consumer Group 协议:分配、重平衡与静态成员
06 Offset 管理:提交、重置与消费语义
07 副本与 ISR:高可用的代价和折中
08 Controller 与 KRaft:从 ZooKeeper 到内置共识
09 Exactly-Once 与事务:跨 partition 的原子写入
10 日志压缩:把 topic 当 KV 表用
11 Kafka Streams:在日志之上构建流处理
12 Kafka Connect:标准化的数据管道
13 Schema Registry 与数据治理:给消息加上契约
14 性能模型:吞吐、延迟与调优思路
15 安全体系:认证、授权与加密
16 生产运维:集群扩缩、监控指标与故障排查
17 Kafka 与 RocketMQ:两种消息系统的设计选择

参考资料

  1. Apache Kafka Documentation — Design: Log. https://kafka.apache.org/documentation/#design_filesystem
  2. Apache Kafka Documentation — Design: Efficiency (Zero-Copy). https://kafka.apache.org/documentation/#maximizingefficiency
  3. Linux man page: sendfile(2). https://man7.org/linux/man-pages/man2/sendfile.2.html
  4. Apache Kafka Source Code — kafka.log.LogSegment, kafka.log.OffsetIndex. https://github.com/apache/kafka
  5. Neha Narkhede, Gwen Shapira, Todd Palino. Kafka: The Definitive Guide. O’Reilly. Chapter 5: Kafka Internals — Log Storage.