这一节和 2.2 的分工
2.2 节回答的是「一个分区的目录长什么样」:00000000000000000000.log 是记录本体,.index 是稀疏索引,.timeindex 是时间索引。那是静态结构。
这一节回答动态问题:
- 段文件在什么条件下滚动,滚动的边界为什么不是一个整齐的字节数;
- 索引文件为什么会「先占 10 MB 再缩到 536 字节」;
- broker 崩溃之后,哪些东西需要重做,索引是不是每次都要重建;
- 重启日志里的哪几行能证明本次恢复走的是「干净关闭」还是「崩溃恢复」这条路径。
实验用 1 MB 的段来做,因为默认 1 GB 的段在这个实验里永远不会滚。
滚动条件不止一个
Kafka 的段滚动有四类触发条件,运维时经常只记得第一类:
| 条件 | 配置 | 默认值 | 说明 |
|---|---|---|---|
| 段大小超过阈值 | segment.bytes | 1073741824(1 GB) | 追加到放不下下一批时滚动 |
| 段存在时间超过阈值 | segment.ms | 604800000(7 天) | 到期后下一次写入触发滚动,即使没写满 |
| 索引文件写满 | log.index.size.max.bytes | 10485760(10 MB) | 索引满了同样触发滚动 |
| 单条记录/批次放不下 | max.message.bytes 相关 | — | 当前段剩余空间不足时滚动 |
本机从生效配置里读到的值(/opt/kafka/logs/server.log,2026-10-29):
log.segment.bytes = 1073741824
log.index.interval.bytes = 4096
log.index.size.max.bytes = 10485760
log.retention.check.interval.ms = 300000
segment.bytes 有一个硬下限。2.2 节试过 16384,被拒绝:
Invalid value 16384 for configuration segment.bytes: Value must be at least 1048576
所以实验里最小的可观测段是 1 MB。
实测:4.5 MB 数据滚出 5 个段
建一个 1 MB 段、60 秒时间阈值的单分区主题:
docker exec mq-b5 /opt/kafka/bin/kafka-topics.sh \
--bootstrap-server 127.0.0.1:9092 \
--create --topic b5.roll --partitions 1 --replication-factor 1 \
--config segment.bytes=1048576 --config segment.ms=60000
Topic: b5.roll TopicId: 6JWBhUp3T--LE9T7JqtXrw PartitionCount: 1 ReplicationFactor: 1 Configs: min.insync.replicas=1,segment.bytes=1048576,segment.ms=60000
写入 3000 条、每条约 1.5 KB 的记录,总共 4,521,000 字节:
awk 'BEGIN{for(i=0;i<3000;i++){d="";for(j=0;j<1500;j++)d=d"z";printf "%05d %s\n", i, d}}' > /tmp/p3.txt
/opt/kafka/bin/kafka-console-producer.sh \
--bootstrap-server 127.0.0.1:9092 --topic b5.roll < /tmp/p3.txt
写完等 4 秒再看目录(过滤掉了 .snapshot):
-rw-r--r-- 1 appuser appuser 536 00000000000000000000.index
-rw-r--r-- 1 appuser appuser 1034348 00000000000000000000.log
-rw-r--r-- 1 appuser appuser 468 00000000000000000000.timeindex
-rw-r--r-- 1 appuser appuser 536 00000000000000000680.index
-rw-r--r-- 1 appuser appuser 1034348 00000000000000000680.log
-rw-r--r-- 1 appuser appuser 408 00000000000000000680.timeindex
-rw-r--r-- 1 appuser appuser 536 00000000000000001360.index
-rw-r--r-- 1 appuser appuser 1034348 00000000000000001360.log
-rw-r--r-- 1 appuser appuser 168 00000000000000001360.timeindex
-rw-r--r-- 1 appuser appuser 536 00000000000000002040.index
-rw-r--r-- 1 appuser appuser 1034348 00000000000000002040.log
-rw-r--r-- 1 appuser appuser 84 00000000000000002040.timeindex
-rw-r--r-- 1 appuser appuser 10485760 00000000000000002720.index
-rw-r--r-- 1 appuser appuser 425908 00000000000000002720.log
-rw-r--r-- 1 appuser appuser 10485756 00000000000000002720.timeindex
从这段 ls -l 里能读出四件事。
第一:段名就是 base offset
段文件名是 00000000000000000680,意思是这个段的第一条记录 offset 是 680。5 个段的 base offset 是 0 / 680 / 1360 / 2040 / 2720,间隔都是 680。
为什么是 680 而不是别的数?因为生产者按 16 KB 组批,本机每批 10 条(1507 字节 × 10 ≈ 15 KB),680 = 68 批 × 10 条。段在批次边界上滚动,不会把一批拆开跨段。这也是为什么下面会看到「68 个批次」这个整齐的数。
第二:封口段小于 1 MB
四个封口段的 .log 都是 1,034,348 字节,比 segment.bytes=1048576 小了约 14 KB。原因就是上一条:写第 68 批之前发现剩余空间放不下这一批,于是先滚动再写。所以实际段大小会略小于阈值(缓冲区里有更大批次时也可能略超),它不是精确的截断点。
第三:索引文件的大小差异是「预分配」
封口段的 .index 只有 536 字节,活跃段(2720)的 .index 是 10,485,760 字节——正好等于 log.index.size.max.bytes。这不是活跃段的索引真的有 10 MB,而是新段创建时按上限预分配,滚动静默时截断到实际大小。
.timeindex 同理:封口段 84–468 字节,活跃段 10,485,756 字节(比 .index 少 4 字节,因为每条时间索引项少存一个字段)。
这个行为对容量估算有直接影响:每个活跃段会额外占用约 20 MB 的索引预分配空间(10 MB + 10 MB),在小磁盘上不能忽略。10.3 节会把这一项算进去。
第四:日志里能抓到滚动动作
同一次写入在 server.log 里留下四行:
Rolled new log segment at offset 680 in 9 ms. (org.apache.kafka.storage.internals.log.LocalLog)
Rolled new log segment at offset 1360 in 2 ms. (org.apache.kafka.storage.internals.log.LocalLog)
Rolled new log segment at offset 2040 in 1 ms. (org.apache.kafka.storage.internals.log.LocalLog)
Rolled new log segment at offset 2720 in 0 ms. (org.apache.kafka.storage.internals.log.LocalLog)
第一次滚动 9 ms,后面 0–2 ms。差异通常来自首次创建段时预分配两个 10 MB 索引文件的元数据开销,后面的段省掉了这部分(单次运行,随调度波动,不作基准)。
三个文件各自的职责
| 文件 | 内容 | 何时写 | 丢了会怎样 |
|---|---|---|---|
.log | 批次本体,按 offset 追加 | 每次写入 | 数据真的丢了 |
.index | offset → 文件内字节位置(稀疏) | 每约 4 KB 追加一次,按批次边界落点 | 可重建;重建前读该段要全段扫描 |
.timeindex | timestamp → offset(稀疏) | 同上 | 可重建;按时间查找退化为线性扫描 |
.snapshot | 生产者 PID/sequence 状态 | 段滚动、定期 | 从更早的快照重放恢复,可能变慢 |
关键在于:只有 .log 是不可再生的。索引是从 .log 派生的,所以它是「缓存」而不是「数据」。理解这一点,才能理解为什么 Kafka 敢在索引缺失时直接重建。
稀疏索引的真实粒度
log.index.interval.bytes=4096 容易让人以为「每 4 KB 一条索引」。实际粒度是批次:索引项只在批次边界上写,而且累计字节数超过 interval 才写一条。
本机把段 680 的 .index 和 .log 都 dump 出来数了一下:
索引项: 67
批次数: 68(每批 10 条,共 680 条记录)
首项: offset: 699 position: 15211
次项: offset: 709 position: 30422
末项: offset: 1359 position: 1019137
67 个索引项对应 68 个批次,几乎是一批一条。原因是每批约 15 KB,早就超过了 4096 字节的阈值,所以每个批次边界都满足「该建索引了」。
如果每批只有 200 字节,那么大约 20 批才会产生一条索引。也就是说:
索引密度 ≈ log.index.interval.bytes / 批次大小 (向上取整到批次边界)
这就是「稀疏」的实际含义:稠密与稀疏不是由记录数决定的,是由批次大小与 interval 的比值决定的。第 3.1 节把批次调小,索引就会明显变稀。
崩溃恢复:先分清两条路径
Kafka 在正常关闭时会往每个日志目录写一个「干净关闭」标记文件(.kafka_cleanshutdown)。启动时先看这个标记,决定走哪条路;读完之后标记会被删掉,所以 broker 运行起来以后在目录里是看不到它的。本机两次重启的日志如下。
路径 A:干净关闭
docker restart(发 SIGTERM,broker 走受控关闭)之后:
INFO Skipping recovery of 2 logs from /tmp/kafka-logs since clean shutdown file was found (kafka.log.LogManager)
INFO Completed load of Log(dir=/tmp/kafka-logs/b5.roll-0, topicId=6JWBhUp3T--LE9T7JqtXrw, topic=b5.roll, partition=0,
highWatermark=0, lastStableOffset=0, logStartOffset=0, logEndOffset=3000) with 5 segments,
local-log-start-offset 0 and log-end-offset 3000 in 11ms (1/2 completed in /tmp/kafka-logs) (kafka.log.LogManager)
两行说明:没有做段恢复(Skipping recovery),只是把 5 个段加载进内存,耗时 11 ms,logEndOffset 是 3000,数据完整。
路径 B:崩溃
docker kill(SIGKILL,不给清理机会)之后:
INFO Recovering 2 logs from /tmp/kafka-logs since no clean shutdown file was found (kafka.log.LogManager)
INFO [LogLoader partition=b5.roll-0] Recovering unflushed segment 2720. 0 recovered for b5.roll-0.
INFO Completed load of Log(... b5.roll ...) with 5 segments, ... log-end-offset 3000 in 26ms
三行说明:
- 找不到干净关闭标记,所以进入恢复流程;
- 只恢复活跃段(2720),封口段不动;
0 recovered表示活跃段末尾没有需要截断的残缺批次——因为进程是被杀死的,最后一次写入要么整批落地,要么根本没落,没有「写了一半」的批次。
加载耗时 26 ms,比干净关闭的 11 ms 多,多出来的部分就是活跃段的扫描与校验(单次运行,不作基准)。
注意一个容易误解的现象:重启后 describe 里 highWatermark=0,而 logEndOffset=3000。单副本主题没有 follower 来确认,HW 要等一轮 leader 初始化才推进:
High watermark set to LogOffsetMetadata(offset=3000, ...) for the first time for epoch 4 ...
HW 是「已提交」的水位,不是「已写入」的水位,重启瞬间两者会短暂分离。消费者在 HW 追上之前读不到最后一段数据,这也是「重启后消费暂停几秒」的常见原因。
实测:删掉索引,看它重建
上面两条路径都没有重建索引——因为索引文件还在。真正验证「索引可从 .log 重建」,得把索引弄没。
做法:broker 运行中删掉段 680 的 .index(Linux 上删已打开文件不影响正在运行的进程),然后 docker kill 制造崩溃,再启动。
rm -f /tmp/kafka-logs/b5.roll-0/00000000000000000680.index
docker kill mq-b5
docker start mq-b5
启动后 server.log 里出现这一行(原文,含 ERROR 级别):
ERROR [LogLoader partition=b5.roll-0, dir=/tmp/kafka-logs] Could not find offset index file
corresponding to log file /tmp/kafka-logs/b5.roll-0/00000000000000000680.log,
recovering segment and rebuilding index files... (org.apache.kafka.storage.internals.log.LogLoader)
紧接着是重建结果:
INFO [LogLoader partition=b5.roll-0] Reloading from producer snapshot and rebuilding producer state from offset 680
INFO [LogLoader partition=b5.roll-0] Producer state recovery took 12ms for snapshot load and 0ms for segment recovery from offset 680
重建出来的文件:
ls -l /tmp/kafka-logs/b5.roll-0/00000000000000000680.index
# -rw-r--r-- 1 appuser appuser 536 Oct 29 04:25 .../00000000000000000680.index
kafka-dump-log.sh --files .../00000000000000000680.index
# offset: 699 position: 15211
# offset: 709 position: 30422
# offset: 719 position: 45633
536 字节,内容和删除前完全一致(首项 699/15211、次项 709/30422)。数据侧 kafka-get-offsets.sh 仍然返回 b5.roll:0:3000。
这就是「索引是派生数据」的完整证据链:删掉 → 崩溃 → 启动时发现缺失 → 扫描 .log 重建 → 内容逐字节对得上。
重建的代价
重建要顺序扫一遍整个段。本机 1 MB 的段,从 ERROR 行(04:25:14,030)到该分区恢复完成(04:25:14,050)约 20 ms。真实环境里段是 1 GB,重建一个段就是顺序读 1 GB——按 200 MB/s 粗算约 5 秒,这只是算术推演,不是本机数据;一个分区若有几十个段全都缺索引,就是分钟级。
所以运维上有一条硬规矩:不要手工删索引文件来「清理空间」。索引占的比例很小(10 MB 上限对应 1 GB 段,约 1%),删了不但省不了多少,还会在下次启动时把恢复时间放大几十倍。
一个辅助脚本:按文件名读 base offset
排查段结构时,经常需要把目录里的段按 offset 列出来。用一个十几行的 Java 程序就够:
import java.io.IOException;
import java.nio.file.*;
import java.util.*;
public class SegmentList {
public static void main(String[] args) throws IOException {
Path dir = Path.of(args[0]);
try (DirectoryStream<Path> ds = Files.newDirectoryStream(dir, "*.log")) {
List<Long> bases = new ArrayList<>();
for (Path p : ds) {
String name = p.getFileName().toString();
bases.add(Long.parseLong(name.substring(0, name.indexOf('.'))));
}
Collections.sort(bases);
for (long b : bases) {
Path log = dir.resolve(String.format("%020d.log", b));
System.out.printf("base=%d size=%d%n", b, Files.size(log));
}
}
}
}
它只做一件事:把 20 位文件名解析成 base offset,再按 offset 排序输出。段名是定长的、左补零的,所以字符串比较和数值比较的结果一致——这也是 Kafka 用这种命名方式的原因之一。
恢复期间客户端看到什么
把恢复过程按时间排一下:
| 阶段 | broker 状态 | 客户端表现 |
|---|---|---|
| 启动、加载段 | 未就绪 | 连接被拒或元数据请求超时 |
| 加载完成 | STARTING → RECOVERY | 元数据可用,但副本可能未上线 |
| HW 初始化 | RECOVERY → RUNNING | 消费者能拉取,但只能读到 HW 之前 |
| 完全就绪 | RUNNING | 正常读写 |
日志里对应的三行:
The broker has caught up. Transitioning from STARTING to RECOVERY. (BrokerLifecycleManager)
The broker has been unfenced. Transitioning from RECOVERY to RUNNING. (BrokerLifecycleManager)
本机这个单节点、只有两个业务主题的集群,从容器启动到 RUNNING 只用了约 2.1 秒(StartedAt=04:31:50.610,RUNNING 日志时间 04:31:52.747,单次运行)。其中业务日志加载只占 11 ms;剩下的是 JVM 启动与 KRaft 元数据日志的加载选举。集群越大、分区越多,这段时间会显著变长,不能用这个数字外推。
参数怎么定
segment.bytes保持默认 1 GB。段太小会让文件数、索引数、fd 数一起上涨,恢复时要打开和校验的段也更多。本机用 1 MB 只是为了在几秒内看到滚动。segment.ms要小于retention.ms。否则数据过期了,段却因为时间没到而不封口,保留策略删不掉它。默认 7 天对默认 7 天保留期是危险的临界值。- 不要动
log.index.interval.bytes。4096 是索引大小与扫描长度之间的折中;调大省不了多少空间,调小会让索引膨胀。 - 监控段数量。单个分区段数持续增长(比如超过几百),通常意味着
segment.bytes太小或者保留期太长,两者都会拖慢恢复。
本节判据
回答四个问题:
- 段滚动的边界为什么不是一个整数字节? 因为滚动发生在批次边界,放不下下一批就滚,所以实际大小会略小于(偶尔略大于)
segment.bytes。 - 活跃段的索引为什么显示 10 MB? 新段创建时按
log.index.size.max.bytes预分配,滚动封口后截断到实际大小。 - 崩溃后哪些段需要恢复? 只有活跃段;封口段已经完整,不参与恢复。
- 怎么证明索引是从
.log重建的? 删掉.index后崩溃重启,日志出现Could not find offset index file ... rebuilding index files,重建后的索引内容与删除前逐项一致。
下一节把视角拉高到磁盘与文件描述符:一个集群到底要多少容量、多少 fd,num.io.threads 和磁盘数怎么配,JBOD 与 RAID 各自在赌什么。
阅读导航:上一节 10.1 顺序写、页缓存与零拷贝 | 下一节 10.3 磁盘与文件描述符规划
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。