这是《Kafka 零基础实战指南》的独立章节版。本章从概念、实操和生产排查三个视角展开,代码块保留了原书可直接运行的版本。 Kafka 的存储设计是它高性能的根基。本章拆开磁盘目录,看看一个分区到底由哪些文件组成、消息如何查找、数据如何过期删除。
10.1 磁盘目录结构
每个分区在每个 Broker 上对应一个目录:<topic>-<partition>。进入第 4 章实验的数据目录,你会看到:
/tmp/kraft-1/order-events-0/
00000000000000000000.log
00000000000000000000.index
00000000000000000000.timeindex
leader-epoch-checkpoint
partition.metadata
.lock
不同后缀的职责:
| 文件 | 作用 |
|---|---|
.log |
真正的消息数据,追加写入 |
.index |
位移稀疏索引,offset -> 物理位置 |
.timeindex |
时间戳稀疏索引,timestamp -> offset |
leader-epoch-checkpoint |
记录 leader 纪元变更,用于副本一致性(第 11 章) |
partition.metadata |
分区版本信息 |
文件名就是该 segment 的起始 offset,固定 20 位数字。例如 00000000000000005000.log 表示这个文件从 offset 5000 开始。
10.2 Segment 分段
分区日志不会无限追加在单个文件里,而是切成多个 segment:
00000000000000000000.log offset [0, 3762)
0000000000000000003762.log offset [3762, 8911)
0000000000000000008911.log offset [8911, ...) <- 活跃段
滚动(roll)出新的活跃 segment 由以下条件触发,任一满足即可:
| 配置 | 默认值 | 含义 |
|---|---|---|
log.segment.bytes |
1GB | 单段大小 |
log.roll.ms / log.roll.hours |
7 天 | 段存活时长 |
log.index.size.max.bytes |
10MB | 索引文件大小上限 |
log.roll.jitter.ms |
0 | 抖动,避免所有分区同时滚动造成 IO 尖峰 |
只对活跃段追加数据;非活跃段是只读的,可以被安全共享、索引、删除。分段是“按时间/大小删除数据”和“快速定位 offset”的基础。
10.3 稀疏索引
.index 不是每条消息一条记录,而是每隔 log.index.interval.bytes(默认 4KB)日志数据才记一条:
index 文件内容(示意):
relative offset | physical position
0 | 0
37 | 4200
75 | 8410
112 | 12650
查找 offset=90 的过程:
- 在 index 中二分找到“小于等于 90 的最大条目”:75;
- 从物理位置 8410 开始顺序扫描
.log; - 最多扫描约
log.index.interval.bytes(4KB)就能命中目标。
这就是稀疏索引 + 顺序扫描的组合:索引文件极小(可整个映射进内存),查找代价常数级可控。B 树那样的稠密索引在这里完全没有必要。
.timeindex 同理,记录 timestamp -> offset,支撑“按时间查找”(消费者的 offsetsForTimes 就靠它)。
10.4 零拷贝与页缓存
消费 fetch 请求的读路径:
传统方式:
磁盘 -> 内核页缓存 -> 用户空间(Kafka进程) -> Socket 缓冲 -> 网卡
sendfile 零拷贝:
磁盘 -> 内核页缓存 -------------直接经 sendfile------------> 网卡
配合页缓存,热门分区被反复消费时,数据大概率已在内存,磁盘根本不参与。这也是 Kafka 建议 给页缓存留足内存、不要把大 JVM 堆开满 的原因(第 17 章)。
注意两点零拷贝不生效的情况:
- 消息需要服务端转换格式(老版本 V0/V1 消息发给新客户端时会走普通读路径);
- 启用了某些加密通道时,数据必须进入用户空间加解密。
10.5 刷盘策略
Kafka 依赖副本机制 + 页缓存保证可靠性,而不是 fsync 每条消息:
| 配置 | 默认 | 含义 |
|---|---|---|
log.flush.interval.messages |
Long.MaxValue | 多少条消息后强制刷盘 |
log.flush.interval.ms |
null | 多久刷一次 |
log.flush.offset.checkpoint.interval.ms |
60000 | 恢复检查点写入频率 |
官方建议:交给操作系统异步刷盘(默认即可),用多副本容错。每条 fsync 会把吞吐打骨折,而单机刷盘也无法对抗整机断电,真正的安全性来自“多副本分布在多台机器”。
10.6 数据删除与日志压缩
delete 模式
后台线程定期检查非活跃 segment:
- 整段中所有消息都超过
retention.ms,或段整体超过大小预算,删除整个文件; - 删除粒度是 segment 整体,不是单条消息。所以“7 天保留”意味着最老的一批段整体过期,而不是每条消息精确 7 天。
compact 模式
后台 Cleaner 选择“脏比例”超标的分区,重写日志:
清理前:
k1:v1 k2:v1 k1:v2 k3:v1 k1:v3 k2:v2
清理后:
k2:v1 k3:v1 k1:v3 k2:v2 (每 key 保留最新,保留原始顺序)
关键点:
- key 为空的消息直接丢;
- 保留每个 key 最后一条,且保留其在原日志中的相对顺序;
- 清理是异步的、批量的,
min.cleanable.dirty.ratio(默认 0.5)控制触发时机。
10.7 V2 消息格式
0.11 起默认 V2 格式,核心思想是以 batch 为单位存储元数据:
RecordBatch:
baseOffset, lastOffsetDelta, partitionLeaderEpoch
magic(2), CRC, attributes(压缩/时间戳类型/事务/控制批)
producerId, producerEpoch, baseSequence <- 幂等与事务
records[]:
length, attributes
timestampDelta, offsetDelta <- 增量编码
keyLen, key
valueLen, value
headers[] <- 变长头
好处:
- offset、timestamp 存增量(varint),体积大幅下降;
- CRC 与压缩都作用在 batch 级,减少重复校验与解压开销;
producerId/epoch/sequence让 broker 能识别并去重重试消息(第 14 章)。
用 kafka-dump-log.sh 观察一个 zstd 压缩的日志文件,能直接看到这些字段的实际值。
10.8 动手实验
- 建一个 topic,把
log.segment.bytes调成 1024 字节(topic 级配置); - 发送几十条消息,观察目录里很快出现多个 segment;
- 用
kafka-dump-log.sh --files <某个.index>看索引条目(不需要--print-data-log); - 把
retention.ms设成 60000,等一分钟后观察旧 segment 消失; - 创建 compact topic,发送同 key 多版本消息,观察清理后只剩最新值。
这些实验能把你刚学的概念全部落到文件层面。
本章小结
- 分区 = 目录,segment = 滚动的日志文件,文件名是起始 offset;
.index/.timeindex是稀疏索引,查找 = 二分索引 + 小范围顺序扫描;- 活跃段可写,历史段只读;删除以 segment 为单位;
- 可靠性来自多副本而非每条 fsync,页缓存 + 零拷贝是读性能关键;
- V2 格式以 batch 为中心,为增量编码、压缩与幂等打下基础。
思考题
- 为什么索引不做成每条消息一个条目?这样查找不是更快吗?
retention.ms=86400000(1 天)时,一条消息实际最短存活多久?最长呢?- 如果把
log.segment.bytes调得非常小(如 1KB),会带来哪些问题?