《Java 消息中间件·Kafka 内核与客户端》10.2 段滚动、索引重建与恢复

把 segment.bytes 调到 1 MB 后写入 4.5 MB 数据,观察段文件如何按 680 条一批滚出 5 个段;删除一个 .index 再模拟崩溃重启,抓住 Could not find offset index file 与 rebuilding index files 两行日志,验证索引确实是从 .log 重建的。

这一节和 2.2 的分工

2.2 节回答的是「一个分区的目录长什么样」:00000000000000000000.log 是记录本体,.index 是稀疏索引,.timeindex 是时间索引。那是静态结构。

这一节回答动态问题:

  • 段文件在什么条件下滚动,滚动的边界为什么不是一个整齐的字节数;
  • 索引文件为什么会「先占 10 MB 再缩到 536 字节」;
  • broker 崩溃之后,哪些东西需要重做,索引是不是每次都要重建;
  • 重启日志里的哪几行能证明本次恢复走的是「干净关闭」还是「崩溃恢复」这条路径。

实验用 1 MB 的段来做,因为默认 1 GB 的段在这个实验里永远不会滚。

滚动条件不止一个

Kafka 的段滚动有四类触发条件,运维时经常只记得第一类:

条件配置默认值说明
段大小超过阈值segment.bytes1073741824(1 GB)追加到放不下下一批时滚动
段存在时间超过阈值segment.ms604800000(7 天)到期后下一次写入触发滚动,即使没写满
索引文件写满log.index.size.max.bytes10485760(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 追加每次写入数据真的丢了
.indexoffset → 文件内字节位置(稀疏)每约 4 KB 追加一次,按批次边界落点可重建;重建前读该段要全段扫描
.timeindextimestamp → 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 元数据日志的加载选举。集群越大、分区越多,这段时间会显著变长,不能用这个数字外推。

参数怎么定

  1. segment.bytes 保持默认 1 GB。段太小会让文件数、索引数、fd 数一起上涨,恢复时要打开和校验的段也更多。本机用 1 MB 只是为了在几秒内看到滚动。
  2. segment.ms 要小于 retention.ms。否则数据过期了,段却因为时间没到而不封口,保留策略删不掉它。默认 7 天对默认 7 天保留期是危险的临界值。
  3. 不要动 log.index.interval.bytes。4096 是索引大小与扫描长度之间的折中;调大省不了多少空间,调小会让索引膨胀。
  4. 监控段数量。单个分区段数持续增长(比如超过几百),通常意味着 segment.bytes 太小或者保留期太长,两者都会拖慢恢复。

本节判据

回答四个问题:

  1. 段滚动的边界为什么不是一个整数字节? 因为滚动发生在批次边界,放不下下一批就滚,所以实际大小会略小于(偶尔略大于)segment.bytes。
  2. 活跃段的索引为什么显示 10 MB? 新段创建时按 log.index.size.max.bytes 预分配,滚动封口后截断到实际大小。
  3. 崩溃后哪些段需要恢复? 只有活跃段;封口段已经完整,不参与恢复。
  4. 怎么证明索引是从 .log 重建的? 删掉 .index 后崩溃重启,日志出现 Could not find offset index file ... rebuilding index files,重建后的索引内容与删除前逐项一致。

下一节把视角拉高到磁盘与文件描述符:一个集群到底要多少容量、多少 fd,num.io.threads 和磁盘数怎么配,JBOD 与 RAID 各自在赌什么。

阅读导航:上一节 10.1 顺序写、页缓存与零拷贝 | 下一节 10.3 磁盘与文件描述符规划

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收