KafkaNotes

第 10 章:存储机制:日志、分段与索引

zjc 于 2026-01-10 发布

这是《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 的过程:

  1. 在 index 中二分找到“小于等于 90 的最大条目”:75;
  2. 从物理位置 8410 开始顺序扫描 .log
  3. 最多扫描约 log.index.interval.bytes(4KB)就能命中目标。

这就是稀疏索引 + 顺序扫描的组合:索引文件极小(可整个映射进内存),查找代价常数级可控。B 树那样的稠密索引在这里完全没有必要。

.timeindex 同理,记录 timestamp -> offset,支撑“按时间查找”(消费者的 offsetsForTimes 就靠它)。

10.4 零拷贝与页缓存

消费 fetch 请求的读路径:

传统方式:
磁盘 -> 内核页缓存 -> 用户空间(Kafka进程) -> Socket 缓冲 -> 网卡

sendfile 零拷贝:
磁盘 -> 内核页缓存 -------------直接经 sendfile------------> 网卡

配合页缓存,热门分区被反复消费时,数据大概率已在内存,磁盘根本不参与。这也是 Kafka 建议 给页缓存留足内存、不要把大 JVM 堆开满 的原因(第 17 章)。

注意两点零拷贝不生效的情况:

  1. 消息需要服务端转换格式(老版本 V0/V1 消息发给新客户端时会走普通读路径);
  2. 启用了某些加密通道时,数据必须进入用户空间加解密。

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:

compact 模式

后台 Cleaner 选择“脏比例”超标的分区,重写日志:

清理前:
  k1:v1  k2:v1  k1:v2  k3:v1  k1:v3  k2:v2
清理后:
  k2:v1  k3:v1  k1:v3  k2:v2   (每 key 保留最新,保留原始顺序)

关键点:

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[]                                 <- 变长头

好处:

  1. offset、timestamp 存增量(varint),体积大幅下降;
  2. CRC 与压缩都作用在 batch 级,减少重复校验与解压开销;
  3. producerId/epoch/sequence 让 broker 能识别并去重重试消息(第 14 章)。

kafka-dump-log.sh 观察一个 zstd 压缩的日志文件,能直接看到这些字段的实际值。

10.8 动手实验

  1. 建一个 topic,把 log.segment.bytes 调成 1024 字节(topic 级配置);
  2. 发送几十条消息,观察目录里很快出现多个 segment;
  3. kafka-dump-log.sh --files <某个.index> 看索引条目(不需要 --print-data-log);
  4. retention.ms 设成 60000,等一分钟后观察旧 segment 消失;
  5. 创建 compact topic,发送同 key 多版本消息,观察清理后只剩最新值。

这些实验能把你刚学的概念全部落到文件层面。

本章小结

思考题

  1. 为什么索引不做成每条消息一个条目?这样查找不是更快吗?
  2. retention.ms=86400000(1 天)时,一条消息实际最短存活多久?最长呢?
  3. 如果把 log.segment.bytes 调得非常小(如 1KB),会带来哪些问题?