日志存储:Segment、Index 与零拷贝
上一篇看到了 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 | |
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 | |
ProduceRequest 中的消息以 RecordBatch 为单位组织。一个 RecordBatch 可以包含多条记录(record),这是 producer 端攒批(batching)的结果,将在第 03 篇展开。
写入过程的关键步骤:
- 校验 RecordBatch 的 CRC 校验和,确保网络传输没有损坏数据。
- 为 batch 中的每条 record 分配连续的 offset。Offset 分配是单调递增的,在 partition 级别保证唯一。
- 将整个 RecordBatch 的字节追加到 active segment 的
.log文件尾部。这是一次顺序写入。 - 在
.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 | |
"相对 offset"是该记录的 offset 减去 segment 的 base offset。这样 4 字节整数就足以表示一个 segment 内部的 offset 范围(一个 segment 默认最大 1 GB,按最小记录大小计算,4 字节整数足够覆盖)。
查找某个 offset 的过程:
1 | |
这个两级查找的时间复杂度是 O(log S + log I + K),其中 S 是 segment 数量,I 是 index 条目数量,K 是从 index 命中位置到目标 offset 的顺序扫描记录数。由于 K 受 index.interval.bytes 限制,通常很小。
时间索引
.timeindex 文件的结构与 .index 类似,但映射关系是时间戳到 offset:
1 | |
时间索引用于支持按时间戳查找消息(offsetsForTimes API),以及基于时间的 retention 策略。Consumer 可以通过 KafkaConsumer.offsetsForTimes() 方法找到某个时间戳之后的第一个 offset,然后从该 offset 开始消费——这在数据回放和故障恢复场景中非常有用。
零拷贝:sendfile 与 page cache
Fetch 请求的数据传输路径是 Kafka 高吞吐的关键。传统的数据传输需要四次数据复制:
1 | |
Kafka 使用 Java NIO 的 FileChannel.transferTo() 方法,底层对应 Linux 的 sendfile() 系统调用:
1 | |
零拷贝的核心在于:数据从 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 | |
这个设计让 Kafka 的读写性能高度依赖操作系统的 page cache 管理。在生产环境中,为 Kafka broker 预留足够的系统内存(不分配给 JVM 堆)是性能调优的关键——这些内存会被 OS 自动用作 page cache。
实验:写入消息并检查 segment 文件
以下实验向一个 topic 写入大量消息,然后用 kafka-dump-log 工具检查 segment 文件的内部结构。
创建 topic 并写入 10 万条消息:
1 | |
检查 partition 目录中的 segment 文件:
1 | |
输出类似:
1 | |
index 和 timeindex 文件预分配了固定大小(由 segment.index.bytes 控制,默认 10 MB),实际使用的空间在 segment 关闭时才会截断。
使用 kafka-dump-log 查看 segment 内部结构:
1 | |
输出展示每个 RecordBatch 的元信息和其中的 record:
1 | |
查看 index 文件的内容:
1 | |
输出展示稀疏索引条目——不是每个 offset 都有索引,只有间隔超过 index.interval.bytes 的位置才记录:
1 | |
模式提炼
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)的大小影响很大。
练习
-
在上面的实验中,修改
segment.bytes为不同的值(如 100 KB、10 MB),重新创建 topic 并写入相同数量的消息。观察 segment 文件的数量和大小如何变化。 -
使用
kafka-dump-log.sh --files xxx.index检查 index 文件的条目间隔。修改 topic 配置index.interval.bytes为不同的值,重新写入消息后对比 index 条目的密度变化。 -
查阅 Linux
sendfile()系统调用的 man page,理解其参数和返回值。思考在什么条件下 sendfile 会退化为普通的 read+write(提示:跨文件系统、加密传输)。
系列导航
参考资料
- Apache Kafka Documentation — Design: Log. https://kafka.apache.org/documentation/#design_filesystem
- Apache Kafka Documentation — Design: Efficiency (Zero-Copy). https://kafka.apache.org/documentation/#maximizingefficiency
- Linux man page: sendfile(2). https://man7.org/linux/man-pages/man2/sendfile.2.html
- Apache Kafka Source Code —
kafka.log.LogSegment,kafka.log.OffsetIndex. https://github.com/apache/kafka - Neha Narkhede, Gwen Shapira, Todd Palino. Kafka: The Definitive Guide. O’Reilly. Chapter 5: Kafka Internals — Log Storage.
