Segment 与存储结构
定义与作用
Segment(日志段)是 Kafka 在磁盘上存储消息的物理单元。每个 Partition 在磁盘上对应一个目录,目录内包含多个 Segment 文件。Segment 解决了"无限追加的日志文件"的工程瓶颈——若不切分,单个文件会无限增长,导致文件系统难以管理、清理操作无法高效执行。
Segment 之于 Partition,如同 logrotate 之于 syslog:将连续的日志流切分为固定大小的物理文件,便于管理和清理。
核心原理
Partition 的磁盘布局
每个 Segment 由三种文件组成:
| 文件类型 | 命名规则 | 内容 |
|---|---|---|
.log | 以起始 Offset 命名(20位数字左补零) | 消息数据 |
.index | 同前缀 | Offset → 物理位置的稀疏索引 |
.timeindex | 同前缀 | 时间戳 → Offset 的映射 |
当前正在写入的 Segment 称为 Active Segment,只有它接受写入。当 Active Segment 大小达到 log.segment.bytes(默认 1GB)或时间达到 log.roll.ms(默认 7 天),Kafka 会关闭旧 Segment,创建新的 Active Segment。
索引文件的工作原理
索引是稀疏的(默认每 4KB 写入一条索引),而非为每条消息建索引。查找 Offset=55 的消息时:
- 在
.index中找到 ≤55 的最大索引项(Offset=50, 位置=4872) - 从日志文件的位置 4872 开始顺序扫描,直到找到 Offset=55
完整示例
示例一:直接观察磁盘上的 Segment 文件
操作前环境:Topic segment-demo 已创建(1 分区),持续写入消息。
步骤:
# 1. 查看 Partition 存储目录
ls -lh /tmp/kraft-combined-logs/segment-demo-0/
# total 32M
# 00000000000000000000.index 10485760
# 00000000000000000000.log 20971520 ← Active Segment
# 00000000000000000000.timeindex 10485760
# leader-epoch-checkpoint
# 2. 修改 log.segment.bytes 为较小值(仅用于演示观察)
bin/kafka-configs.sh --bootstrap-server localhost:9092 \
--entity-type topics --entity-name segment-demo \
--alter --add-config segment.bytes=1048576
# (1MB, 需要重启或等待 Segment Roll)
# 3. 使用 kafka-dump-log.sh 查看 Segment 内容
bin/kafka-dump-log.sh \
--files /tmp/kraft-combined-logs/segment-demo-0/00000000000000000000.log \
--print-data-log
# baseOffset: 0 lastOffset: 0 ...
# baseOffset: 1 lastOffset: 1 ...
操作后对比:
| 时机 | 文件列表 |
|---|---|
| Segment Roll 前 | 00000000000000000000.log(一个文件,持续增长) |
| Segment Roll 后 | 00000000000000000000.log(只读)+ 00000000000000000150.log(Active Segment,150 为新起始 Offset) |
示例二:时间戳索引的实际应用
场景:需要找到 2024 年 6 月 1 日 10:30 之后的所有消息,Consumer 应从哪个 Offset 开始消费。
步骤:
# 1. 将时间戳转换为 Unix 毫秒
date -d "2024-06-01 10:30:00" +%s000
# 1717235400000
# 2. 使用 GetOffsetShell 按时间戳查找 Offset
bin/kafka-run-class.sh kafka.tools.GetOffsetShell \
--topic segment-demo \
--time 1717235400000 \
--bootstrap-server localhost:9092
# segment-demo:0:15234
# 3. 时间索引查找过程(内部机制,无需手动执行):
# .timeindex → 找到 ≤ 1717235400000 的最大时间戳对应的 Offset
# .index → 找到该 Offset 在 .log 中的物理位置
# .log → 顺序扫描到精确位置
操作前后对比:
| 方式 | 结果 | 查找方式 |
|---|---|---|
| 按时间 | Offset=15234 | 时间戳索引(O(1) 二分查找 + 少量顺序扫描) |
| 无索引 | 需要从头扫描 15K+ 条消息 | O(n) 全表扫描 |
消息格式(Record Batch)
Kafka 在磁盘上存储的单元不是单条消息,而是 Record Batch(消息批次):
Record Batch 结构:
┌─────────────────────┐
│ Base Offset (8B) │ ← 批次中第一条消息的 Offset
│ Batch Length (4B) │ ← 批次总字节数
│ Partition Leader... │
│ Magic (1B) │ ← v2 消息格式
│ CRC (4B) │
│ Attributes (2B) │ ← 压缩类型、时间戳类型
│ Last Offset Delta │
│ Base Timestamp (8B) │
│ Max Timestamp (8B) │
│ Producer ID (8B) │ ← 幂等生产者
│ Producer Epoch (2B) │
│ Base Sequence (4B) │
│ Records Count (4B) │
├─────────────────────┤
│ Record 1 │
│ Record 2 │
│ ... │
└─────────────────────┘
关键要点:Producer 压缩的是 Record Batch 层级,而非单条消息层级。这就是"端到端压缩"能获得高压缩比的原因。
易错场景
易错 1:log.segment.bytes 设置过大
场景:将 log.segment.bytes 设为 10GB,以为能减少文件数。
后果:
- Segment 文件过大,日志清理(Log Cleaner)无法及时删除过期数据
- 崩溃恢复时需重建过大的 Segment 索引,启动时间变长
- 无法利用 Segment 粒度的清理策略
建议:保持默认 1GB,或在 512MB-2GB 之间调整。
易错 2:忘记 log.dirs 可以配置多目录
场景:单 Broker 有 4 块 SSD,但只配置了 log.dirs=/data1/kafka,其他三块磁盘闲置。
正确做法:log.dirs=/data1/kafka,/data2/kafka,/data3/kafka,/data4/kafka,Kafka 会将不同 Partition 的 Segment 分布到不同磁盘上,实现磁盘级别的并行 I/O。
面试高频考点
Q:为什么 Kafka 的索引是稀疏的而不是密集的?
A:
- 内存效率:密集索引(每条消息一个条目)在 TB 级数据下会消耗大量内存。稀疏索引将索引大小控制在数据量的约 1%
- 顺序 I/O 的优势:找到索引位置后,顺序扫描一段数据(最多 4KB)的开销极低,不需要精确定位到每条消息
- 设计哲学:Kafka 始终优先选择"顺序扫描 + 小内存"而非"精确随机访问 + 大内存",这与 Page Cache 和顺序 I/O 的设计一脉相承