06-12-A-Kafka存储深水区与源码解析详解
️ 关键词:RecordBatch · 日志段恢复 · snapshot · LogCleaner · 日志压缩 · 时间索引 · Tiered Storage · 副本拉取线程 · LogSegment 滚动 · 文件句柄
📌 导读 :09 篇讲了 Kafka 存储的"骨架"(Partition/Segment/稀疏索引),本篇下潜到源码级深水区------RecordBatch 的二进制格式(消息在磁盘上到底长什么样)、日志段的滚动与恢复(.snapshot 文件的作用)、LogCleaner 的两种清理策略(delete vs compact)、时间索引的实现与"按时间查 Offset"、副本拉取的线程模型(Fetcher 线程池)、Tiered Storage 分层存储(3.6+ 冷数据下沉 S3)。这一篇对标《Kafka 权威指南》的存储章节+Kafka 源码的 log 模块,是"从会用 API 到懂磁盘上每个字节"的分水岭。看完本篇你能做到:被问"Kafka 消息在磁盘上的格式"能画出 RecordBatch 结构,被问"compact 和 delete 清理有什么区别"能讲出墓碑消息,被问"Broker 重启怎么恢复日志"能讲出 snapshot 截断机制。
📑 目录
- 06-12-A-Kafka存储深水区与源码解析详解
-
- [📖 术语速查表(每个词都用人话解释)](#📖 术语速查表(每个词都用人话解释))
- 一、RecordBatch:消息在磁盘上的真实格式
-
- [1.1 Batch 结构(v2 格式,0.11+)](#1.1 Batch 结构(v2 格式,0.11+))
- [1.2 三个设计精髓](#1.2 三个设计精髓)
- [二、日志段生命周期:滚动、恢复与 snapshot](#二、日志段生命周期:滚动、恢复与 snapshot)
-
- [2.1 Segment 滚动时机](#2.1 Segment 滚动时机)
- [2.2 崩溃恢复与 .snapshot](#2.2 崩溃恢复与 .snapshot)
- [三、LogCleaner:delete 与 compact 两种清理](#三、LogCleaner:delete 与 compact 两种清理)
-
- [3.1 delete(默认):按时间/大小删整段](#3.1 delete(默认):按时间/大小删整段)
- [3.2 compact:同 Key 只留最新值](#3.2 compact:同 Key 只留最新值)
- 四、时间索引与副本拉取
-
- [4.1 按时间查 Offset 的两级索引](#4.1 按时间查 Offset 的两级索引)
- [4.2 Follower 副本拉取的线程模型](#4.2 Follower 副本拉取的线程模型)
- [五、Tiered Storage:分层存储(3.6+)](#五、Tiered Storage:分层存储(3.6+))
-
- [5.1 架构](#5.1 架构)
- [5.2 价值与限制](#5.2 价值与限制)
- [六、跑一遍:观察 RecordBatch 与 compact 行为](#六、跑一遍:观察 RecordBatch 与 compact 行为)
-
- [6.1 解析磁盘上的 RecordBatch](#6.1 解析磁盘上的 RecordBatch)
- [6.2 触发 compact 观察清理](#6.2 触发 compact 观察清理)
- 七、总结
-
- [7.1 一张图回顾全文](#7.1 一张图回顾全文)
- [7.2 核心要点浓缩(十二条)](#7.2 核心要点浓缩(十二条))
📖 术语速查表(每个词都用人话解释)
| 术语 | 一句话白话解释 |
|---|---|
| RecordBatch | 消息批次的二进制格式------一次发送的多条消息打包成一个 Batch 存储(头部+CRC+Record 数组) |
| Record | Batch 内的单条消息------变长整数编码(varint)+ 相对 Offset/时间戳(省空间) |
| magic | 消息格式版本号(v0/v1/v2)------v2(0.11+)才有事务/幂等支持 |
| .snapshot 文件 | 恢复点快照 ------记录"已刷盘的 Offset",重启恢复时从 snapshot 开始校验而不是全扫 |
| 日志段滚动(roll) | 活跃 Segment 达到大小/时间阈值 → 切新 Segment(log.roll.hours / log.segment.bytes) |
| LogCleaner | 日志清理线程------delete(按时间/大小删整段)或 compact(同 Key 只留最新) |
| 日志压缩(Compaction) | compact 策略------每个 Key 只保留最新值(墓碑消息 value=null 表示删除) |
| 墓碑消息(Tombstone) | value=null 的消息------compact 时标记"这个 Key 删了",保留 delete.retention.ms 后彻底清除 |
| 时间索引(.timeindex) | 时间戳 → Offset 的稀疏索引------"查某时间的消息"先查时间索引再查偏移索引 |
| Fetcher 线程 | Follower 副本的拉取线程池(num.replica.fetchers)------从 Leader 拉数据同步 |
| Tiered Storage | 3.6+ 分层存储------冷 Segment 上传对象存储(S3/OSS),本地只留热数据 |
| 文件句柄 | 每个 Segment 3 个文件(.log/.index/.timeindex)------Partition 多 = 句柄多 = ulimit 要调大 |
一、RecordBatch:消息在磁盘上的真实格式
1.1 Batch 结构(v2 格式,0.11+)
text
RecordBatch(一次 send 的批次,磁盘存储的基本单位):
┌────────────────────┬─────────┬──────────────────────────────────┐
│ baseOffset │ 8 字节 │ 批次起始 Offset
│ batchLength │ 4 字节 │ 批次总长度
│ partitionLeaderEpoch│ 4 字节 │ Leader 纪元(故障恢复截断用)
│ magic │ 1 字节 │ 格式版本(v2)
│ crc │ 4 字节 │ 校验(防位翻转)
│ attributes │ 2 字节 │ 压缩类型/时间戳类型/事务标志
│ lastOffsetDelta │ 4 字节 │ 批内最后一条的 Offset 增量
│ baseTimestamp │ 8 字节 │ 批次基准时间戳
│ maxTimestamp │ 8 字节 │ 最大时间戳
│ producerId │ 8 字节 │ 幂等 Producer 的 PID
│ producerEpoch │ 2 字节 │ Producer 纪元
│ baseSequence │ 4 字节 │ 序列号起点(幂等去重用)
│ recordsCount │ 4 字节 │ 批内消息条数
│ records │ 变长 │ Record 数组 ↓
└────────────────────┴─────────┴──────────────────────────────────┘
Record(单条消息,极致省空间):
┌──────────────────┬────────────────────────────────────┐
│ length │ varint 变长整数(小数字 1 字节) │
│ attributes │ 1 字节 │
│ timestampDelta │ varint------相对 baseTimestamp 的增量 │
│ offsetDelta │ varint------相对 baseOffset 的增量 │
│ keyLength+key │ varint+字节 │
│ valueLength+value│ varint+字节(压缩在这层生效) │
│ headers │ varint 头数组 │
└──────────────────┴────────────────────────────────────┘
1.2 三个设计精髓
- 相对编码(delta) :批内消息的 Offset/时间戳存"相对基准的增量"(varint)------一批 1000 条连续 Offset 的消息,每条的 offsetDelta 都是 1(1 字节 vs 绝对值 8 字节 )------批量+相对编码是 Kafka 存储紧凑的根源;
- 压缩在 Batch 层 :attributes 记录压缩类型,records 数组整体压缩------Broker 存储/转发都不解压(09 篇端到端压缩),Consumer 拉走整批解压;
- 幂等/事务字段内嵌 :producerId+baseSequence 就是幂等去重的依据(10 篇 EOS),事务标志在 attributes 里------协议格式为语义服务。
对比 RocketMQ :RocketMQ 单条消息独立存储(CommitLog 每条带完整头),Kafka 按批存储(批头共享+批内相对编码)------Kafka 的格式为"高吞吐批量"优化,RocketMQ 为"单条随机读(ConsumeQueue 索引)"优化,格式差异源于读写模型差异(09 篇 2.4)。
二、日志段生命周期:滚动、恢复与 snapshot
2.1 Segment 滚动时机
| 触发条件 | 参数 | 默认 |
|---|---|---|
| 大小达到 | log.segment.bytes | 1GB |
| 时间达到(首条消息年龄) | log.roll.hours | 7 天(segment.ms) |
| 索引文件满 | log.index.size.max.bytes | 10MB(.index 写满强制滚动) |
| 手动 | kafka-log-dirs.sh / Admin API | --- |
小流量 Topic 的坑 :消息太少,1GB 永远写不满、7 天才滚动------活跃 Segment 巨大但稀疏,恢复慢、删除不及时 (要等整段过期)。解法:segment.ms 调小(如 1 小时)强制按时间滚动。
2.2 崩溃恢复与 .snapshot
#mermaid-svg-sW5XOzvCzLOSJGqb{font-family:Microsoft YaHei;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-sW5XOzvCzLOSJGqb .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-sW5XOzvCzLOSJGqb .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-sW5XOzvCzLOSJGqb .error-icon{fill:hsl(220.5882352941, 100%, 98.3333333333%);}#mermaid-svg-sW5XOzvCzLOSJGqb .error-text{fill:rgb(8.5000000002, 5.7500000001, 0);stroke:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-sW5XOzvCzLOSJGqb .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-sW5XOzvCzLOSJGqb .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-sW5XOzvCzLOSJGqb .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-sW5XOzvCzLOSJGqb .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-sW5XOzvCzLOSJGqb .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-sW5XOzvCzLOSJGqb .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-sW5XOzvCzLOSJGqb .marker{fill:#0b0b0b;stroke:#0b0b0b;}#mermaid-svg-sW5XOzvCzLOSJGqb .marker.cross{stroke:#0b0b0b;}#mermaid-svg-sW5XOzvCzLOSJGqb svg{font-family:Microsoft YaHei;font-size:16px;}#mermaid-svg-sW5XOzvCzLOSJGqb p{margin:0;}#mermaid-svg-sW5XOzvCzLOSJGqb .label{font-family:Microsoft YaHei;color:#333;}#mermaid-svg-sW5XOzvCzLOSJGqb .cluster-label text{fill:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-sW5XOzvCzLOSJGqb .cluster-label span{color:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-sW5XOzvCzLOSJGqb .cluster-label span p{background-color:transparent;}#mermaid-svg-sW5XOzvCzLOSJGqb .label text,#mermaid-svg-sW5XOzvCzLOSJGqb span{fill:#333;color:#333;}#mermaid-svg-sW5XOzvCzLOSJGqb .node rect,#mermaid-svg-sW5XOzvCzLOSJGqb .node circle,#mermaid-svg-sW5XOzvCzLOSJGqb .node ellipse,#mermaid-svg-sW5XOzvCzLOSJGqb .node polygon,#mermaid-svg-sW5XOzvCzLOSJGqb .node path{fill:#fff4dd;stroke:hsl(40.5882352941, 60%, 83.3333333333%);stroke-width:1px;}#mermaid-svg-sW5XOzvCzLOSJGqb .rough-node .label text,#mermaid-svg-sW5XOzvCzLOSJGqb .node .label text,#mermaid-svg-sW5XOzvCzLOSJGqb .image-shape .label,#mermaid-svg-sW5XOzvCzLOSJGqb .icon-shape .label{text-anchor:middle;}#mermaid-svg-sW5XOzvCzLOSJGqb .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-sW5XOzvCzLOSJGqb .rough-node .label,#mermaid-svg-sW5XOzvCzLOSJGqb .node .label,#mermaid-svg-sW5XOzvCzLOSJGqb .image-shape .label,#mermaid-svg-sW5XOzvCzLOSJGqb .icon-shape .label{text-align:center;}#mermaid-svg-sW5XOzvCzLOSJGqb .node.clickable{cursor:pointer;}#mermaid-svg-sW5XOzvCzLOSJGqb .root .anchor path{fill:#0b0b0b!important;stroke-width:0;stroke:#0b0b0b;}#mermaid-svg-sW5XOzvCzLOSJGqb .arrowheadPath{fill:#0b0b0b;}#mermaid-svg-sW5XOzvCzLOSJGqb .edgePath .path{stroke:#0b0b0b;stroke-width:2.0px;}#mermaid-svg-sW5XOzvCzLOSJGqb .flowchart-link{stroke:#0b0b0b;fill:none;}#mermaid-svg-sW5XOzvCzLOSJGqb .edgeLabel{background-color:hsl(-79.4117647059, 100%, 93.3333333333%);text-align:center;}#mermaid-svg-sW5XOzvCzLOSJGqb .edgeLabel p{background-color:hsl(-79.4117647059, 100%, 93.3333333333%);}#mermaid-svg-sW5XOzvCzLOSJGqb .edgeLabel rect{opacity:0.5;background-color:hsl(-79.4117647059, 100%, 93.3333333333%);fill:hsl(-79.4117647059, 100%, 93.3333333333%);}#mermaid-svg-sW5XOzvCzLOSJGqb .labelBkg{background-color:rgba(243.9999999999, 220.9999999998, 255, 0.5);}#mermaid-svg-sW5XOzvCzLOSJGqb .cluster rect{fill:hsl(220.5882352941, 100%, 98.3333333333%);stroke:hsl(220.5882352941, 60%, 88.3333333333%);stroke-width:1px;}#mermaid-svg-sW5XOzvCzLOSJGqb .cluster text{fill:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-sW5XOzvCzLOSJGqb .cluster span{color:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-sW5XOzvCzLOSJGqb div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:Microsoft YaHei;font-size:12px;background:hsl(220.5882352941, 100%, 98.3333333333%);border:1px solid hsl(220.5882352941, 60%, 88.3333333333%);border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-sW5XOzvCzLOSJGqb .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-sW5XOzvCzLOSJGqb rect.text{fill:none;stroke-width:0;}#mermaid-svg-sW5XOzvCzLOSJGqb .icon-shape,#mermaid-svg-sW5XOzvCzLOSJGqb .image-shape{background-color:hsl(-79.4117647059, 100%, 93.3333333333%);text-align:center;}#mermaid-svg-sW5XOzvCzLOSJGqb .icon-shape p,#mermaid-svg-sW5XOzvCzLOSJGqb .image-shape p{background-color:hsl(-79.4117647059, 100%, 93.3333333333%);padding:2px;}#mermaid-svg-sW5XOzvCzLOSJGqb .icon-shape .label rect,#mermaid-svg-sW5XOzvCzLOSJGqb .image-shape .label rect{opacity:0.5;background-color:hsl(-79.4117647059, 100%, 93.3333333333%);fill:hsl(-79.4117647059, 100%, 93.3333333333%);}#mermaid-svg-sW5XOzvCzLOSJGqb .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-sW5XOzvCzLOSJGqb .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-sW5XOzvCzLOSJGqb :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
Broker 启动 / Partition 加载。
① 读
recovery-point-offset-checkpoint。
snapshot:已刷盘到哪个 Offset。
② 从 recovery point 开始,
扫活跃 Segment 的 RecordBatch。
③ 逐批校验:
CRC + magic +
partitionLeaderEpoch。
校验通过?
是 → 继续下一批,
推进 validOffset。
否 → ④ 截断。
validOffset 之后的
损坏数据全部丢弃。
⑤ 重建 .index / .timeindex。
索引是内存映射,可重建。
⑥ 更新 LEO = validOffset。
Follower 还要按 Leader Epoch
截断(防数据分叉)。
Leader Epoch 的深意 (KIP-101,比 HW 截断更精确):旧版本 Follower 重启按 HW 截断------可能截掉已提交的数据或留下分叉数据 ;Leader Epoch 记录"每任 Leader 的起始 Offset",Follower 恢复时向 Leader 查询"我的 epoch 结束于哪"精确截断------这是"HW 截断丢数据"bug 的根治方案(面试深水区考点)。
对比 RocketMQ 恢复 (06-04-A 篇 4.2):RocketMQ 用 abort 哨兵+倒数第 3 个文件起扫;Kafka 用 snapshot(recovery-point)精确起点------Kafka 恢复范围更小(只扫未刷盘段),RocketMQ 更保守(多扫 2 个文件)。
三、LogCleaner:delete 与 compact 两种清理
3.1 delete(默认):按时间/大小删整段
text
log.retention.hours=168(7 天) / log.retention.bytes(按大小)
→ LogCleaner 定期扫描:整个 Segment 的最大时间戳 < 保留期 → 直接删文件
------删除是"整段删",O(1) 操作(这就是 Segment 分段的运维价值)
3.2 compact:同 Key 只留最新值
#mermaid-svg-doZE2OuPrd8hn12c{font-family:Microsoft YaHei;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-doZE2OuPrd8hn12c .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-doZE2OuPrd8hn12c .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-doZE2OuPrd8hn12c .error-icon{fill:hsl(220.5882352941, 100%, 98.3333333333%);}#mermaid-svg-doZE2OuPrd8hn12c .error-text{fill:rgb(8.5000000002, 5.7500000001, 0);stroke:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-doZE2OuPrd8hn12c .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-doZE2OuPrd8hn12c .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-doZE2OuPrd8hn12c .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-doZE2OuPrd8hn12c .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-doZE2OuPrd8hn12c .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-doZE2OuPrd8hn12c .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-doZE2OuPrd8hn12c .marker{fill:#0b0b0b;stroke:#0b0b0b;}#mermaid-svg-doZE2OuPrd8hn12c .marker.cross{stroke:#0b0b0b;}#mermaid-svg-doZE2OuPrd8hn12c svg{font-family:Microsoft YaHei;font-size:16px;}#mermaid-svg-doZE2OuPrd8hn12c p{margin:0;}#mermaid-svg-doZE2OuPrd8hn12c .label{font-family:Microsoft YaHei;color:#333;}#mermaid-svg-doZE2OuPrd8hn12c .cluster-label text{fill:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-doZE2OuPrd8hn12c .cluster-label span{color:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-doZE2OuPrd8hn12c .cluster-label span p{background-color:transparent;}#mermaid-svg-doZE2OuPrd8hn12c .label text,#mermaid-svg-doZE2OuPrd8hn12c span{fill:#333;color:#333;}#mermaid-svg-doZE2OuPrd8hn12c .node rect,#mermaid-svg-doZE2OuPrd8hn12c .node circle,#mermaid-svg-doZE2OuPrd8hn12c .node ellipse,#mermaid-svg-doZE2OuPrd8hn12c .node polygon,#mermaid-svg-doZE2OuPrd8hn12c .node path{fill:#fff4dd;stroke:hsl(40.5882352941, 60%, 83.3333333333%);stroke-width:1px;}#mermaid-svg-doZE2OuPrd8hn12c .rough-node .label text,#mermaid-svg-doZE2OuPrd8hn12c .node .label text,#mermaid-svg-doZE2OuPrd8hn12c .image-shape .label,#mermaid-svg-doZE2OuPrd8hn12c .icon-shape .label{text-anchor:middle;}#mermaid-svg-doZE2OuPrd8hn12c .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-doZE2OuPrd8hn12c .rough-node .label,#mermaid-svg-doZE2OuPrd8hn12c .node .label,#mermaid-svg-doZE2OuPrd8hn12c .image-shape .label,#mermaid-svg-doZE2OuPrd8hn12c .icon-shape .label{text-align:center;}#mermaid-svg-doZE2OuPrd8hn12c .node.clickable{cursor:pointer;}#mermaid-svg-doZE2OuPrd8hn12c .root .anchor path{fill:#0b0b0b!important;stroke-width:0;stroke:#0b0b0b;}#mermaid-svg-doZE2OuPrd8hn12c .arrowheadPath{fill:#0b0b0b;}#mermaid-svg-doZE2OuPrd8hn12c .edgePath .path{stroke:#0b0b0b;stroke-width:2.0px;}#mermaid-svg-doZE2OuPrd8hn12c .flowchart-link{stroke:#0b0b0b;fill:none;}#mermaid-svg-doZE2OuPrd8hn12c .edgeLabel{background-color:hsl(-79.4117647059, 100%, 93.3333333333%);text-align:center;}#mermaid-svg-doZE2OuPrd8hn12c .edgeLabel p{background-color:hsl(-79.4117647059, 100%, 93.3333333333%);}#mermaid-svg-doZE2OuPrd8hn12c .edgeLabel rect{opacity:0.5;background-color:hsl(-79.4117647059, 100%, 93.3333333333%);fill:hsl(-79.4117647059, 100%, 93.3333333333%);}#mermaid-svg-doZE2OuPrd8hn12c .labelBkg{background-color:rgba(243.9999999999, 220.9999999998, 255, 0.5);}#mermaid-svg-doZE2OuPrd8hn12c .cluster rect{fill:hsl(220.5882352941, 100%, 98.3333333333%);stroke:hsl(220.5882352941, 60%, 88.3333333333%);stroke-width:1px;}#mermaid-svg-doZE2OuPrd8hn12c .cluster text{fill:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-doZE2OuPrd8hn12c .cluster span{color:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-doZE2OuPrd8hn12c div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:Microsoft YaHei;font-size:12px;background:hsl(220.5882352941, 100%, 98.3333333333%);border:1px solid hsl(220.5882352941, 60%, 88.3333333333%);border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-doZE2OuPrd8hn12c .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-doZE2OuPrd8hn12c rect.text{fill:none;stroke-width:0;}#mermaid-svg-doZE2OuPrd8hn12c .icon-shape,#mermaid-svg-doZE2OuPrd8hn12c .image-shape{background-color:hsl(-79.4117647059, 100%, 93.3333333333%);text-align:center;}#mermaid-svg-doZE2OuPrd8hn12c .icon-shape p,#mermaid-svg-doZE2OuPrd8hn12c .image-shape p{background-color:hsl(-79.4117647059, 100%, 93.3333333333%);padding:2px;}#mermaid-svg-doZE2OuPrd8hn12c .icon-shape .label rect,#mermaid-svg-doZE2OuPrd8hn12c .image-shape .label rect{opacity:0.5;background-color:hsl(-79.4117647059, 100%, 93.3333333333%);fill:hsl(-79.4117647059, 100%, 93.3333333333%);}#mermaid-svg-doZE2OuPrd8hn12c .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-doZE2OuPrd8hn12c .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-doZE2OuPrd8hn12c :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} cleanup.policy=compact 的 Topic
(如 __consumer_offsets / 用户画像变更流)
① 构建 OffsetMap:
扫日志,Key → 最新 Offset
(内存哈希表,分段处理)
② 清理:从头扫 Segment
Key 的 Offset < OffsetMap 里的最新值
→ 这条是旧版本,跳过不复制
③ 重写:保留的消息写入新 Segment
------同 Key 只剩最新值+墓碑
④ 墓碑消息(value=null)
保留 delete.retention.ms(默认 24h)
后彻底删除------给下游'看到删除'的窗口
compact 的适用与陷阱:
| 适用 | 陷阱 |
|---|---|
| 状态类数据:Key=用户ID,Value=最新画像------Topic 当"可回放的 KV 表"用 | 无 Key 的消息不参与 compact(Key=null 全保留) |
| __consumer_offsets(位点就是 compact 的) | compact 不保证"立即"清理(后台低优先级线程,脏比例达 min.cleanable.ratio 才动) |
| CDC 流(同主键只留最新状态) | 消费端仍可能读到同 Key 多个版本(清理前的旧数据)------消费逻辑要幂等覆盖 |
一句话 :delete 把日志当"流水"(过期即弃),compact 把日志当"KV 表的变更日志"(同 Key 留最新)------__consumer_offsets 用 compact 正是"位点=Key 的最新值"的天然表达。
四、时间索引与副本拉取
4.1 按时间查 Offset 的两级索引
text
需求:kafka-consumer-groups --to-datetime 重置位点 / 消费"昨天 10 点开始"的数据
查找 timestamp=T 的 Offset:
① 二分 .timeindex 文件:找 ≤T 的最大条目 → 得到近似 Offset
(时间索引也是稀疏的:log.index.interval.bytes 每 4KB 一条)
② 从近似 Offset 起顺序扫 .log:找第一条 timestamp ≥ T 的消息
③ 返回其 Offset
注意:时间戳单调性假设------同 Partition 内消息时间大体递增
(乱序时间戳会导致定位偏差,Producer 端时间不可信时用 Broker 时间:
message.timestamp.type=LogAppendTime)
4.2 Follower 副本拉取的线程模型
text
Leader 端:
├── 处理 Fetch 请求的线程 = num.network.threads + num.io.threads(和普通 Consumer 共用)
└── Follower 的 Fetch 请求带 fetchOffset → Leader 从该 Offset 读日志返回(零拷贝路径)
Follower 端:
├── num.replica.fetchers 个拉取线程(默认 1)
│ ------每个线程负责一部分 Partition 的同步(按 Partition 分配)
├── 拉回的数据直接追加写本地日志(顺序写)
└── 更新自己的 LEO → 上报 Leader(Leader 据此推进 ISR/HW)
调优:副本多/跨机房延迟大 → num.replica.fetchers 调到 2~4(并行拉取追得上 Leader)
跨机房 → replica.fetch.max.bytes 调大(一次多拉点,摊薄 RTT)
Follower 拉取 = 一个"特殊的 Consumer" :走同样的 Fetch 协议、同样的零拷贝路径------Kafka 的副本同步没有专用协议,复用消费协议(设计简洁性的典范,对比 RocketMQ 主从专用的同步通道)。
五、Tiered Storage:分层存储(3.6+)
5.1 架构
text
传统:所有 Segment 在本地磁盘 → 保留期受磁盘容量限制(7 天常见)
分层:
├── 热层(本地磁盘):最近 N 小时的 Segment ------ 读写走本地,性能不变
├── 冷层(S3/OSS/HDFS):老 Segment 由 TieredStorageManager 异步上传
└── 读取冷数据:Broker 透明代理(Consumer 无感知,延迟略高)
配置:
topic 级:remote.storage.enable=true + local.retention.ms=2h(本地留 2 小时)
集群级:remote.log.storage.system.enable=true + 实现 RemoteStorageManager 插件
5.2 价值与限制
| 价值 | 限制 |
|---|---|
| 保留期从"磁盘决定"变"对象存储决定"------留 1 年成本降 10 倍 | 3.6 尝鲜/3.9+ 才生产可用(早期 bug 多) |
| 磁盘容量规划解耦(本地只留热数据) | compact Topic 不支持(只支持 delete) |
| 回溯消费老数据不用扩磁盘 | 冷读延迟高(对象存储 RT),不适合频繁回溯 |
| Broker 扩容/迁移更快(数据在对象存储) | 需要实现/选择 RemoteStorageManager 插件 |
与 RocketMQ 分层存储对比 (06-04-A 篇 5.2):思路完全一致(热本地+冷对象存储)------2023 年后"MQ+对象存储分层"成为行业标配,根源都是"消息保留期需求增长 vs 磁盘成本"的矛盾(21-A 篇对象存储)。
六、跑一遍:观察 RecordBatch 与 compact 行为
6.1 解析磁盘上的 RecordBatch
shell
# ① 生产几条同 Key 消息到 compact Topic
bin/kafka-topics.sh --bootstrap-server localhost:9092 --create \
--topic user-profile --partitions 1 --replication-factor 1 \
--config cleanup.policy=compact --config min.cleanable.dirty.ratio=0.01 \
--config min.compaction.lag.ms=0
bin/kafka-console-producer.sh --bootstrap-server localhost:9092 \
--topic user-profile --property parse.key=true --property key.separator=:
> user1001:{"level":1,"city":"北京"}
> user1001:{"level":2,"city":"北京"}
> user1002:{"level":1,"city":"上海"}
> user1001:{"level":3,"city":"深圳"}
# ② 用 DumpLogSegments 解析 .log(看 RecordBatch 真实结构)
bin/kafka-dump-log.sh --files /tmp/kraft-combined-logs/user-profile-0/00000000000000000000.log \
--print-data-log
② 的输出(能看到 Batch 头字段与批内消息):
text
Dumping /tmp/kraft-combined-logs/user-profile-0/00000000000000000000.log
Starting offset: 0
baseOffset: 0 lastOffset: 0 count: 1 baseSequence: -1 lastSequence: -1
producerId: -1 producerEpoch: -1 partitionLeaderEpoch: 0 isTransactional: false
isControl: false deleteHorizonMs: OptionalLong.empty position: 0 CreateTime: 1704067200000
size: 142 magic: 2 compresscodec: none crc: 3148422967 isvalid: true
| offset: 0 CreateTime: 1704067200000 keySize: 8 valueSize: 28
key: user1001 payload: {"level":1,"city":"北京"}
baseOffset: 1 lastOffset: 3 count: 3 baseSequence: -1 ...
| offset: 1 key: user1001 payload: {"level":2,"city":"北京"}
| offset: 2 key: user1002 payload: {"level":1,"city":"上海"}
| offset: 3 key: user1001 payload: {"level":3,"city":"深圳"}
观察点 :magic: 2(v2 格式)、producerId: -1(非幂等 Producer)、count: 3(第 2 批攒了 3 条 ------RecordAccumulator 攒批的磁盘证据)、crc/isvalid(恢复时的校验依据,2.2 节)。
6.2 触发 compact 观察清理
shell
# ③ 强制触发清理(测试用:调低脏比例阈值后等 LogCleaner 周期,或直接重启触发)
# 生产上 LogCleaner 后台自动跑,这里用最小配置加速(建 Topic 时已设 0.01)
# ④ 清理后消费全部消息
bin/kafka-console-consumer.sh --bootstrap-server localhost:9092 \
--topic user-profile --from-beginning --property print.key=true \
--property print.offset=true --timeout-ms 5000
④ 的输出(user1001 的 level=1/2 旧版本被清理,只剩最新值):
text
user1002 {"level":1,"city":"上海"} ← offset 2(保留)
user1001 {"level":3,"city":"深圳"} ← offset 3(user1001 的最新值)
# offset 0/1 的 user1001 旧版本已被 compact 清除
Processed 2 messages
💡 对照理解 :②里
count:3是"批量发送"在磁盘上的形态(10 篇 RecordAccumulator),④里"同 Key 只剩最新"是 compact 的效果------Topic 从"流水账"变成了"可回放的 KV 快照" 。注意 ④ 的 Offset 不连续(0/1 没了)------compact 不重排 Offset ,Consumer 的位点语义不变(这就是"消费端要容忍 Offset 空洞"的原因)。用这个 Topic 做状态恢复时,新 Consumer 从头读一遍就得到"每个用户的最新画像"------KTable(15 篇 Kafka Streams)的底层就是这个机制。
七、总结
7.1 一张图回顾全文
#mermaid-svg-ZNvrsEXKuuM2C0Y0{font-family:Microsoft YaHei;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .error-icon{fill:hsl(220.5882352941, 100%, 98.3333333333%);}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .error-text{fill:rgb(8.5000000002, 5.7500000001, 0);stroke:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .marker{fill:#0b0b0b;stroke:#0b0b0b;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .marker.cross{stroke:#0b0b0b;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 svg{font-family:Microsoft YaHei;font-size:16px;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 p{margin:0;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .label{font-family:Microsoft YaHei;color:#333;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .cluster-label text{fill:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .cluster-label span{color:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .cluster-label span p{background-color:transparent;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .label text,#mermaid-svg-ZNvrsEXKuuM2C0Y0 span{fill:#333;color:#333;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .node rect,#mermaid-svg-ZNvrsEXKuuM2C0Y0 .node circle,#mermaid-svg-ZNvrsEXKuuM2C0Y0 .node ellipse,#mermaid-svg-ZNvrsEXKuuM2C0Y0 .node polygon,#mermaid-svg-ZNvrsEXKuuM2C0Y0 .node path{fill:#fff4dd;stroke:hsl(40.5882352941, 60%, 83.3333333333%);stroke-width:1px;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .rough-node .label text,#mermaid-svg-ZNvrsEXKuuM2C0Y0 .node .label text,#mermaid-svg-ZNvrsEXKuuM2C0Y0 .image-shape .label,#mermaid-svg-ZNvrsEXKuuM2C0Y0 .icon-shape .label{text-anchor:middle;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .rough-node .label,#mermaid-svg-ZNvrsEXKuuM2C0Y0 .node .label,#mermaid-svg-ZNvrsEXKuuM2C0Y0 .image-shape .label,#mermaid-svg-ZNvrsEXKuuM2C0Y0 .icon-shape .label{text-align:center;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .node.clickable{cursor:pointer;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .root .anchor path{fill:#0b0b0b!important;stroke-width:0;stroke:#0b0b0b;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .arrowheadPath{fill:#0b0b0b;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .edgePath .path{stroke:#0b0b0b;stroke-width:2.0px;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .flowchart-link{stroke:#0b0b0b;fill:none;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .edgeLabel{background-color:hsl(-79.4117647059, 100%, 93.3333333333%);text-align:center;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .edgeLabel p{background-color:hsl(-79.4117647059, 100%, 93.3333333333%);}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .edgeLabel rect{opacity:0.5;background-color:hsl(-79.4117647059, 100%, 93.3333333333%);fill:hsl(-79.4117647059, 100%, 93.3333333333%);}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .labelBkg{background-color:rgba(243.9999999999, 220.9999999998, 255, 0.5);}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .cluster rect{fill:hsl(220.5882352941, 100%, 98.3333333333%);stroke:hsl(220.5882352941, 60%, 88.3333333333%);stroke-width:1px;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .cluster text{fill:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .cluster span{color:rgb(8.5000000002, 5.7500000001, 0);}#mermaid-svg-ZNvrsEXKuuM2C0Y0 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:Microsoft YaHei;font-size:12px;background:hsl(220.5882352941, 100%, 98.3333333333%);border:1px solid hsl(220.5882352941, 60%, 88.3333333333%);border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 rect.text{fill:none;stroke-width:0;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .icon-shape,#mermaid-svg-ZNvrsEXKuuM2C0Y0 .image-shape{background-color:hsl(-79.4117647059, 100%, 93.3333333333%);text-align:center;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .icon-shape p,#mermaid-svg-ZNvrsEXKuuM2C0Y0 .image-shape p{background-color:hsl(-79.4117647059, 100%, 93.3333333333%);padding:2px;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .icon-shape .label rect,#mermaid-svg-ZNvrsEXKuuM2C0Y0 .image-shape .label rect{opacity:0.5;background-color:hsl(-79.4117647059, 100%, 93.3333333333%);fill:hsl(-79.4117647059, 100%, 93.3333333333%);}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ZNvrsEXKuuM2C0Y0 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Kafka 存储深水区
磁盘格式。
RecordBatch:批头共享+批内相对编码
varint+delta 极致省空间
压缩在 Batch 层(Broker 不解压)
幂等/事务字段内嵌协议。
段生命周期。
滚动:大小/时间/索引满三触发
恢复:snapshot 精确起点+CRC 校验
Leader Epoch 精确截断(根治 HW 截断 bug)
小流量 Topic 调 segment.ms。
清理策略。
delete:整段删(流水语义)
compact:同 Key 留最新+墓碑(KV 语义)
__consumer_offsets 就是 compact
compact 不重排 Offset(有空洞)。
索引与分层。
时间索引两级定位(timeindex→index→log)
Follower 拉取复用消费协议(无专用通道)
Tiered Storage:冷段下沉 S3
保留期成本降 10 倍(compact 不支持)。
7.2 核心要点浓缩(十二条)
- RecordBatch 是存储基本单位 :批头(baseOffset/CRC/producerId/baseSequence)+ Record 数组------一次 send 的批次原样落盘。
- 相对编码省空间 :批内 Offset/时间戳存 delta(varint)------连续 Offset 每条只占 1 字节,批量+相对编码是存储紧凑的根源。
- 压缩在 Batch 层 :records 整体压缩,attributes 记类型------Broker 存/转发不解压(零拷贝与端到端压缩的协议基础)。
- 格式对比 :Kafka 按批存(为吞吐),RocketMQ 单条存(为 ConsumeQueue 随机读)------格式差异源于读写模型差异。
- Segment 滚动三触发 :大小(1GB)、时间(segment.ms)、索引文件满(10MB)------小流量 Topic 要调小 segment.ms(防稀疏大段)。
- 崩溃恢复 :recovery-point snapshot 精确起点 → CRC+magic+LeaderEpoch 校验 → 截断损坏段 → 重建索引------对比 RocketMQ 的 abort+倒数第 3 文件,Kafka 恢复范围更小。
- Leader Epoch :记录每任 Leader 起始 Offset,Follower 恢复时精确截断------根治"按 HW 截断丢数据/分叉"的经典 bug(面试深水区)。
- delete vs compact :delete 整段删(流水语义),compact 同 Key 留最新+墓碑(KV 语义)------__consumer_offsets 就是 compact Topic。
- compact 三陷阱 :无 Key 消息不清理、清理不即时(后台低优先级)、消费端会读到旧版本------Offset 有空洞,消费逻辑要幂等覆盖。
- 时间索引 :.timeindex 稀疏索引 → 近似 Offset → 顺序扫精确------乱序时间戳场景用 LogAppendTime(Broker 时间)。
- Follower 拉取复用消费协议 :num.replica.fetchers 个线程按 Partition 拉取------副本同步没有专用协议(跨机房调大 fetchers+fetch.max.bytes)。
- Tiered Storage(3.6+) :冷 Segment 下沉 S3/OSS,本地留热数据------保留期成本降 10 倍;compact Topic 不支持,3.9+ 才生产可用。
📌 最后一句话 :Kafka 存储深水区的统一视角是"日志即数据库 "------RecordBatch 是日志的记录格式,snapshot+Leader Epoch 是日志的恢复协议,compact 是"把日志当 KV 表"的物化视图,Tiered Storage 是日志的冷热分层。Kafka 没有"表",只有"日志+日志上的各种玩法"------理解这一点,KTable、Exactly-Once、位点管理这些"上层特性"就都变成了日志的自然推论。这也是它和 RocketMQ(消息即消息)、RabbitMQ(队列即队列)在存储哲学上的根本分野。
📌 配套阅读:
如果这篇文章对你有帮助,欢迎点赞、收藏、关注!