一个分区到底存成什么
在 Kafka 里说「把消息写进 topic」,落到磁盘上并不是一个 topic.log 文件。一个 topic 有多个分区,每个分区有多个段(segment),每个段由几个文件组成。理解这层结构,才能解释后面所有的存储行为:为什么删数据是整段删、为什么索引能定位到字节、为什么崩溃后要重建索引、为什么磁盘占用不会立刻下降。
先给结论:
/tmp/kafka-logs/<topic>-<partition>/
00000000000000000000.log 记录本体,按 offset 顺序追加
00000000000000000000.index offset → 物理位置 的稀疏索引
00000000000000000000.timeindex timestamp → offset 的稀疏索引
00000000000000000020.log 下一个段,文件名是它的 base offset
00000000000000000020.index
00000000000000000020.timeindex
leader-epoch-checkpoint leader 任期检查点
partition.metadata 分区元数据(topic id 等)
文件名是 20 位、左补零的 base offset。看到 00000000000000000020.log,就知道这个段的第一条记录的 offset 是 20。段的命名不是随便定的,它让「按 offset 找段」变成一次文件名比较,而不需要读文件内容。
本节用真实写入把这条结构跑出来,然后回答三个问题:段什么时候滚动、索引为什么是稀疏的、一次读请求怎么从 offset 走到字节。
先造两个段
默认 segment.bytes 是 1 GB,正常写入很难在实验里滚出第二个段。把段大小调小、再把 segment.ms 调小,就能在几秒内看到滚动。
这里有一个真实的坑:segment.bytes 不能随便设得很小。本机先试了 16384:
docker exec mq-a1 /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server 127.0.0.1:9092 \
--create --topic a1.segtest --partitions 1 --replication-factor 1 \
--config segment.bytes=16384
Error while executing topic command : Invalid value 16384 for configuration segment.bytes: Value must be at least 1048576
最小值是 1 MB。这个下限不是拍脑袋:段太小会导致文件数暴涨、索引和文件句柄开销超过收益。所以实验用 1 MB:
docker exec mq-a1 /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server 127.0.0.1:9092 \
--create --topic a1.segments2 --partitions 1 --replication-factor 1 \
--config segment.bytes=1048576 --config segment.ms=5000
Created topic a1.segments2.
--describe 确认配置生效:
Topic: a1.segments2 TopicId: qHjrH_JMS1mGcVA2xfQk3A PartitionCount: 1 ReplicationFactor: 1 Configs: min.insync.replicas=1,segment.bytes=1048576,segment.ms=5000
先写 20 条 a 开头的记录,值长约 150 字节;等 8 秒,让 segment.ms=5000 触发滚动;再写 20 条 b 开头的记录:
sent key=a019 p=0 off=19 ts=1793247836724
sent key=b019 p=0 off=39 ts=1793247846970
本节涉及的等待时间与耗时都是单次运行,随机器调度波动,不作基准。
两次写入之间隔了 8 秒,超过了 segment.ms=5000,所以第二组的第一条落在新段里,base offset 是 20。看目录:
-rw-r--r-- 1 appuser appuser 8 Oct 29 04:23 00000000000000000000.index
-rw-r--r-- 1 appuser appuser 4480 Oct 29 04:23 00000000000000000000.log
-rw-r--r-- 1 appuser appuser 12 Oct 29 04:23 00000000000000000000.timeindex
-rw-r--r-- 1 appuser appuser 10485760 Oct 29 04:24 00000000000000000020.index
-rw-r--r-- 1 appuser appuser 4480 Oct 29 04:24 00000000000000000020.log
-rw-r--r-- 1 appuser appuser 56 Oct 29 04:24 00000000000000000020.snapshot
-rw-r--r-- 1 appuser appuser 10485756 Oct 29 04:24 00000000000000000020.timeindex
-rw-r--r-- 1 appuser appuser 8 Oct 29 04:23 leader-epoch-checkpoint
-rw-r--r-- 1 appuser appuser 43 Oct 29 04:23 partition.metadata
几个数字值得停下来看:
- 两个
.log都是 4480 字节,20 条 × 224 字节 = 4480,和记录大小对得上。 - 老段的
.index只有 8 字节,.timeindex只有 12 字节;新段的.index是 10 MB,.timeindex是 10 MB 少 4 字节。 - 新段多了一个
.snapshot文件。
先解释 10 MB:.index 和 .timeindex 是预分配的,大小由 log.index.size.max.bytes 决定,默认 10485760 字节。也就是说,段刚建好、一条记录都没有时,索引文件看起来就有 10 MB。这不是「索引很大」,而是「预留了这么大的空间」。段滚动、索引 flush 之后,老段的索引会被截断到实际使用的大小,所以老段只有 8 字节和 12 字节。
.snapshot 是生产者状态快照,用来在 broker 重启后恢复幂等生产者与事务的 PID/sequence 状态。它和记录数据无关,但在事务和幂等场景里必须存在。
段什么时候滚动
段滚动的条件不止一个。常见的四个:
| 条件 | 配置 | 说明 |
|---|---|---|
| 段大小超过阈值 | segment.bytes | 默认 1 GB;达到后新建段 |
| 段存在时间超过阈值 | segment.ms | 默认 7 天;到时新建段,即使没写满 |
| 索引文件写满 | log.index.size.max.bytes | 默认 10 MB;索引满了也会触发滚动 |
| 单条记录/批次过大 | max.message.bytes 等 | 放不进当前段时滚动 |
本机这次是第二个条件起作用:两次写入间隔 8 秒,segment.ms=5000,老段在第二次写入前就被封口了。第一条新记录的 offset 是 20,新段名就是 00000000000000000020。
滚动有一个重要副作用:只有封口的段才能被清理或压缩。活跃段(当前正在写的那个)不会被 compact,也不会被 retention 删除。2.3 节做 compact 实验时,必须先把活跃段滚出去,否则会看到「明明写了重复 key,怎么没被清理」——原因就是数据还在活跃段里。
索引不是每行都有
现在看最关键的一层:.index 到底存了什么。
用 kafka-dump-log.sh 直接读老段的索引:
docker exec mq-a1 /opt/kafka/bin/kafka-dump-log.sh \
--files /tmp/kafka-logs/a1.segments2-0/00000000000000000000.index
老段只有 20 条记录,输出很短。新段的索引更有代表性:
docker exec mq-a1 /opt/kafka/bin/kafka-dump-log.sh \
--files /tmp/kafka-logs/a1.segments2-0/00000000000000000020.index
Dumping /tmp/kafka-logs/a1.segments2-0/00000000000000000020.index
offset: 39 position: 4256
20 条记录,只有 1 个索引项。这不是索引漏了,而是它本来就稀疏。
索引的写入间隔由 log.index.interval.bytes 控制,本机从 broker 配置里读到的默认值是 4096:
log.index.interval.bytes=4096 sensitive=false synonyms={DEFAULT_CONFIG:log.index.interval.bytes=4096}
log.index.size.max.bytes=10485760 sensitive=false synonyms={DEFAULT_CONFIG:log.index.size.max.bytes=10485760}
含义是:每写入约 4 KB 的记录,才追加一个「offset → position」条目。新段的记录从 position 0 开始,每条 224 字节:
- 写到第 19 条(offset=38)时,position 是 4032,还没超过 4096,不建索引;
- 写到第 20 条(offset=39)时,position 是 4256,超过 4096,追加一个条目
offset=39, position=4256。
所以索引里只有 offset=39 这一项,而不是 offset=20 到 39 各一项。条目里的 position 是该记录在 .log 文件中的字节偏移,不是记录序号。
这个设计的取舍很直接:
| 索引密度 | 索引大小 | 查找时要扫描的记录数 |
|---|---|---|
| 每条记录一项 | 大,接近记录数 × 8 字节 | 0 |
| 每 4 KB 一项 | 小,约为日志大小的 8/4096 | 最多约 4 KB 的记录 |
稀疏索引省的是内存和磁盘,付出的是「找到索引项之后还要顺序扫一小段」。对 Kafka 的读路径来说,这段扫描是顺序读,成本远低于随机寻道,所以这个交换是划算的。
时间索引同理。kafka-dump-log.sh 读 .timeindex:
Dumping /tmp/kafka-logs/a1.segments2-0/00000000000000000020.timeindex
timestamp: 1793247846970 offset: 39
它存的是「时间戳 → offset」,用于按时间查找(比如 --offset 传时间、offsetsForTimes API)。同样是稀疏的,不是每条记录都有。
索引文件还有一个自检命令,用来在怀疑索引损坏时检查:
docker exec mq-a1 /opt/kafka/bin/kafka-dump-log.sh \
--files /tmp/kafka-logs/a1.segments2-0/00000000000000000000.log \
--index-sanity-check
它会重新扫描段文件,核对索引项指向的位置是否真的是一条记录的起始位置。崩溃恢复时,Kafka 也会做类似的事:如果索引缺失或损坏,可以按 .log 重建;2.3 节和 10.2 节会从不同角度提到重建。
段文件里的一条记录长什么样
索引指向的是 position,position 指向的是「批次」而不是「单条消息」。用 kafka-dump-log.sh 看第一个段的第一条:
baseOffset: 0 lastOffset: 0 count: 1 baseSequence: 0 lastSequence: 0 producerId: 3 producerEpoch: 0 partitionLeaderEpoch: 0 isTransactional: false isControl: false deleteHorizonMs: OptionalLong.empty position: 0 CreateTime: 1793247836476 size: 224 magic: 2 compresscodec: none crc: 2370500325 isvalid: true
| offset: 0 CreateTime: 1793247836476 keySize: 4 valueSize: 150 sequence: 0 headerKeys: [] key: a000 payload: xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx
第一行是批次头,第二行是批次里的记录。几个字段的含义:
| 字段 | 含义 | 什么时候会看 |
|---|---|---|
baseOffset / lastOffset | 批次覆盖的 offset 范围 | 定位、校验连续性 |
count | 批次里有几条记录 | 判断是否压缩批次 |
baseSequence / lastSequence | 幂等生产者的序列号 | 判断重复写入、恢复生产者状态 |
compresscodec | 批次级压缩算法 | 排查「压缩为什么没生效」 |
crc | 批次校验和 | 判断段文件是否损坏 |
magic: 2 | 消息格式版本 | 兼容性判断;4.x 使用 v2 |
position | 批次在 .log 中的字节位置 | 索引项指向的值 |
注意 count: 1:本机每发一条就 get() 一次,批次里只有一条。如果生产者连续 send 而不等待,linger.ms 与 batch.size 会让多条记录进入同一个批次,count 就会大于 1,compresscodec 的作用范围也是整个批次,而不是单条记录。第 3.1 节会把批次、linger 与压缩放在一起实测。
一次读请求的寻址过程
把上面的结构串起来,一次「从 offset=35 开始读」的请求大致这样走:
- 定位分区目录。根据 topic 与 partition 找到
/tmp/kafka-logs/a1.segments2-0/。 - 定位段。段文件名就是 base offset;在所有段里找「base offset ≤ 35 的最大那个」。这里是
00000000000000000020.log,因为 20 ≤ 35 < 40。 - 定位索引项。在
00000000000000000020.index里二分查找「offset ≤ 35 的最大索引项」。索引里只有offset=39,它大于 35,所以没有可用的索引项,从段的起始位置 0 开始扫。 - 顺序扫描。从 position 0 开始逐条读记录头,比较 offset,直到找到 35;然后从 35 开始返回,直到达到
fetch.max.bytes或分区末尾。 - 返回。把这段连续字节交给网络层发送;在 10.1 节会看到,这一段可以用零拷贝直接送给 socket,不需要在用户态复制。
如果索引更密,第 3 步会命中一个更接近 35 的 position,第 4 步的扫描距离就短。极端情况下索引里只有 base offset 一项,第 4 步就要从段头扫到目标,但仍然是一次顺序读。
这也解释了一个常见的容量问题:索引不是免费的,但它的大小相对日志很小。10 MB 的索引上限对应 1 GB 的段,比例约 1%。调大 log.index.interval.bytes 会让索引更小、扫描更长;调小则相反。默认 4096 是 Kafka 在两者之间选的折中,绝大多数场景不需要改。
为什么删除是整段删
理解了段的结构,2.3 节的保留策略就顺了。
Kafka 不做「删除 offset=3 这一条」的操作。原因不是没有 API,而是段文件是追加写、按连续字节组织的:从中间删掉一条,后面的所有 position 都要前移,索引全部失效,正在读这个段的消费者也会拿到错位的数据。这个代价远高于「等整个段过期,直接删文件」。
所以删除的单位是段:
cleanup.policy=delete:当段的最大时间戳早于retention.ms,或者总大小超过retention.bytes,整个段目录被删除。cleanup.policy=compact:重写段文件,保留每个 key 的最新值,丢掉旧值;被重写的段仍然是段,只是内容变少。
两种策略都要求段已经封口。活跃段永远不参与删除或压缩,这也是为什么 2.3 节的实验要先把活跃段滚出去。
文件布局里的其它成员
除了三个段文件,目录里还有两个容易忽略的文件:
| 文件 | 作用 | 什么时候重要 |
|---|---|---|
leader-epoch-checkpoint | 记录每个 leader 任期的起始 offset | leader 切换、副本截断时判断从哪里开始同步 |
partition.metadata | 分区元数据,包含 topic id | broker 重启、目录迁移、topic 重建时校验身份 |
partition.metadata 只有 43 字节,内容很短,但它决定了「这个目录属于哪个 topic」。如果手工把分区目录复制到另一个 broker,元数据对不上,broker 会拒绝加载。这也是为什么「直接拷贝日志目录」不是一种受支持的迁移方式。
leader-epoch-checkpoint 在单节点、无 leader 切换时看起来很安静;到了第 4 章的三节点故障实验,它就会变成「副本从哪里开始截断」的依据。第 4.1 节讲 LEO 与 HW 时会回到这个文件。
段大小怎么选
给一个实用的判断顺序:
- 默认 1 GB 通常是对的。它让段文件数量可控,删除时一次回收 1 GB,索引开销约 1%。
- 不要为了「快点看到滚动」把生产环境的段调小。段太小会让文件数、索引数、fd 数同时上升;实验里用 1 MB 是为了在几分钟内看到现象,不是为了生产。
segment.ms要和保留期一起看。如果retention.ms=1天,而segment.ms=7天,段可能因为时间没到而迟迟不封口,保留策略也就无法删除它。通常让segment.ms小于retention.ms。- 压缩 topic 要更关注活跃段。活跃段不压缩,如果写入量很小、段长期不滚,压缩就永远不会发生。可以调小
segment.ms或segment.bytes促使滚动,但要接受文件数上升。
第 10.2 节会讲段滚动、索引重建与崩溃恢复的细节;第 12.3 节会把「保留期 × 写入速率」换算成磁盘容量。本节只需要把物理结构记牢:分区是目录,段是文件组,索引是稀疏映射,删除以段为单位。
本节判据
回答四个问题:
00000000000000000020.log这个文件名里的 20 是什么意思?——这个段第一条记录的 offset,也就是 base offset。- 20 条记录为什么只有 1 个索引项?——索引按
log.index.interval.bytes(默认 4096 字节)稀疏写入,不是逐条记录建。 - 从 offset=35 读,为什么要先找段再找索引?——段按 base offset 排序,先定位段能缩小范围;索引再在段内把 offset 映射到字节位置。
- 为什么不能只删除一条记录?——段是连续追加的字节序列,中间删除会让后续 position 与索引全部失效;删除以段为单位。
下一节讲保留与清理:retention.ms 与 cleanup.policy 怎么决定段的生死,compact 为什么只保留每个 key 的最新值,墓碑记录又为什么会在 delete.retention.ms 之后连自己一起消失。
阅读导航:上一节 2.1 三个节点的最小集群 | 下一节 2.3 保留与清理
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。