Files
cs-note/hhs/MQ/04-存储引擎/9-Kafka-存储设计.md
T
2026-06-08 23:08:57 +08:00

370 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [MQ]
create time: 2026-05-24 19:52
---
# Kafka 存储设计
## 概述
Kafka 的高吞吐能力不仅来自"顺序写盘"和"零拷贝"等通用优化,更源于其精心设计的存储结构:Partition Log 的分段存储、稀疏索引、日志压缩,以及内部定时任务调度所用的时间轮。本文深入 Kafka 存储层的每一个关键设计,帮你理解"为什么 Kafka 这么快"。
## 正文
### Partition Log 的整体结构
Kafka 的存储层次可以概括为:**Topic → Partition → Segment**。每个 Partition 是一个有序的、不可变的消息序列,底层由多个 Segment 文件组成。
一个 Partition 目录下的典型文件结构:
```
partition-0/
├── 00000000000000000000.log # 第一个 Segment 的消息数据
├── 00000000000000000000.index # 稀疏偏移索引
├── 00000000000000000000.timeindex # 时间戳索引
├── 00000000000000523840.log # 第二个 Segment(起始 offset = 523840)
├── 00000000000000523840.index
├── 00000000000000523840.timeindex
└── ...
```
文件名是该 Segment 的 **base offset**(起始偏移量)。当 Segment 达到配置的大小或时间阈值时,Kafka 会"滚动"出一个新的 Segment。
### 消息物理格式:RecordBatch 与 Record
`.log` 文件中存储的不是裸消息,而是经过精心设计的二进制格式。Kafka 0.11+ 引入了 **RecordBatch** 结构——一个 Batch 包含多条 Record,整体写入 `.log` 文件:
```mermaid
graph LR
subgraph "RecordBatch"
BH["Batch Header<br/>BaseOffset + Length + ..."]
R1["Record 0"]
R2["Record 1"]
R3["Record 2"]
end
BH --> R1 --> R2 --> R3
style BH fill:#4A90D9,color:#fff
```
**RecordBatch Header** 的关键字段:
| 字段 | 大小 | 说明 |
|------|------|------|
| `BaseOffset` | 8B | 该 Batch 的起始 offset |
| `Length` | 4B | 整个 Batch 的字节长度 |
| `PartitionLeaderEpoch` | 4B | 用于检测 Leader 切换,防止过期数据写入 |
| `Magic` | 1B | 格式版本号(v2 = 0x02) |
| `CRC` | 4B | 整个 Batch 的校验和(CRC-32C),检测数据损坏 |
| `MaxTimestamp` | 8B | Batch 中最大的消息时间戳,用于基于时间的索引和清理 |
| `LastOffsetDelta` | 4B | 最后一条 Record 相对 BaseOffset 的偏移 |
| `RecordCount` | 2B | Batch 中 Record 的数量 |
每条 **Record** 的结构更为紧凑:
```
┌──────────────────────────────────────────┐
│ Length (varint) │
│ Attributes (1B) │
│ Timestamp Delta (varint) │ ← 相对于 Batch 的 MaxTimestamp
│ Offset Delta (varint) │ ← 相对于 Batch 的 BaseOffset
│ Key Length (varint) + Key │
│ Value Length (varint) + Value │
│ Headers Count + Headers[] │
└──────────────────────────────────────────┘
```
> [!question] 思考
> 为什么 Kafka 要把多条消息打包成 RecordBatch,而不是每条消息独立存储?提示:想想网络传输、磁盘 I/O 和 CRC 校验三个维度。
两个关键设计思想:
1. **增量编码**:每条 Record 的 offset 和 timestamp 只存**相对于 Batch 头的增量**(varint 编码),而非绝对值。消息越密集,增量越小,varint 编码字节数越少——典型场景下一条 Record 的元数据开销只有 5-6 字节。
2. **整体验校验**:CRC-32C 覆盖整个 Batch 而非单条消息,减少了校验和的存储和计算开销,同时仍然能检测出任何位置的数据损坏。
```mermaid
graph TD
T["Topic: orders"] --> P0["Partition 0"]
T --> P1["Partition 1"]
T --> P2["Partition 2"]
P0 --> S1["Segment 0<br/>base offset = 0"]
P0 --> S2["Segment 1<br/>base offset = 523840"]
P0 --> S3["Segment 2<br/>base offset = 1048576"]
S1 --> L1["00000.log"]
S1 --> I1["00000.index"]
S1 --> TI1["00000.timeindex"]
S2 --> L2["523840.log"]
S2 --> I2["523840.index"]
S2 --> TI2["523840.timeindex"]
style T fill:#4A90D9,color:#fff
style P0 fill:#6EC1E0,color:#fff
style P1 fill:#6EC1E0,color:#fff
style P2 fill:#6EC1E0,color:#fff
```
> [!question] 思考
> 为什么不把整个 Partition 写成一个巨大的日志文件,而要拆分成多个 Segment?
### 分段存储策略
Segment 滚动的触发条件有两个(满足任一即滚动):
- **按大小**:默认 1GB(`log.segment.bytes`)
- **按时间**:默认 7 天(`log.roll.ms` / `log.roll.hours`)
分段的好处是多方面的:
1. **快速定位**:消费者只需根据 offset 找到对应 Segment,再在小文件内查找,而不是在一个 TB 级大文件中扫描。
2. **高效清理**:过期数据直接删除整个 Segment 文件,无需逐条标记删除或做 compaction。
3. **并行 I/O**:不同 Segment 可以被不同的消费者线程并发读取。
### 稀疏索引(Sparse Index)
Kafka 不是每条消息都建索引——那会带来巨大的存储和维护开销。它采用**稀疏索引**:每隔一定字节(默认 4KB,由 `log.index.interval.bytes` 控制)才记录一条索引项,格式为 `offset → 文件物理位置`。
当消费者需要查找某个 offset 的消息时,流程如下:
1. 在 `.index` 文件中用**二分查找**找到不大于目标 offset 的最近索引项。
2. 从该索引项指向的物理位置开始,在 `.log` 文件中**顺序扫描**直到找到目标 offset。
因为索引本身是有序的,二分查找效率为 O(log N);而顺序扫描的范围通常只有几条消息(4KB 内),开销极小。这种设计用极少的索引空间换来了接近 O(1) 的查找性能。
**索引加载机制**:`.index` 文件通过 **mmap** 映射到内存——Broker 启动或打开 Segment 时,并不读取整个索引文件,而是将其映射到虚拟地址空间。首次访问某页时触发缺页中断加载数据,之后的查找操作完全在内存中完成。索引文件默认最大 10MB(`log.index.size.max.bytes`),对于 1GB 的 Segment 只占约 1% 的额外空间。
**时间戳索引(`.timeindex`)**:与 `.index` 按 offset 建索引不同,`.timeindex` 记录的是 `timestamp → offset` 的映射。当消费者使用 `offsetsForTimes()` API(即"从某个时间点开始消费")时,Kafka 先在 `.timeindex` 中二分查找目标时间戳对应的 offset,再通过 `.index` 定位物理位置。`MaxTimestamp` 字段(RecordBatch Header 中)使得整个 Batch 只需一个时间戳条目,索引开销极低。
### 日志压缩(Log Compaction)
普通的消息保留策略是按时间或大小删除过期 Segment。但 Kafka 还提供了另一种策略——**Log Compaction**:保留每个 Key 的最后一条消息,删除之前的旧版本。
工作原理:
- Log Compaction 由 Cleaner 线程在后台执行。
- Cleaner 为每个 Key 维护一个"最新 offset"映射,只保留最新消息。
- 清理后的 Segment 中,每个 Key 只有一条记录。
- Consumer 从头消费时,仍然能拿到每个 Key 的最新状态。
**典型场景**:变更数据捕获(CDC)、状态快照同步。比如数据库的一张用户表,每次更新都发一条消息到 Kafka(Key = userId),下游系统通过 Log Compaction 拿到的就是每个用户的最新状态。
**Tombstone 记录**:如果想在 Compacted Topic 中**删除**某个 Key 的所有记录,Producer 需要发送一条 `value = null` 的消息(称为 Tombstone)。Cleaner 会先保留这条 Tombstone 一段时间(由 `log.cleaner.delete.retention.ms` 控制,默认 24 小时),之后才彻底清除该 Key 的所有痕迹。如果直接不发 Tombstone 而只是停止写入,Compaction 会永远保留该 Key 的最后一条有效消息。
**关键配置**:`log.cleaner.min.compaction.lag.ms`(默认 0)控制一条消息写入后至少保留多久才能被 Compaction 清理。这在"先写入后立即回读"的场景中非常重要——防止刚写入的消息还没被消费者读到就被清理了。
> [!question] 思考
> Log Compaction 保证的是"每个 Key 至少保留最新一条",但如果有两个 Key 相同的消息几乎同时到达,Compaction 会保留哪条?
### 数据保留策略(Retention Policy)
除了 Log Compaction,Kafka 最常用的过期数据清理方式是**基于时间和大小的删除策略**。每个 Topic 可以独立配置:
- **`log.retention.hours`**(默认 168 小时 = 7 天):消息保留的最大时间。超过该时间的 Segment 会被标记删除。
- **`log.retention.bytes`**(默认 -1,即不限制):每个 Partition 的最大保留大小。超出时从最旧的 Segment 开始删除。
两者是**或**的关系——任一条件触发就会执行清理。
Topic 的清理行为由 **`cleanup.policy`** 控制,有三个取值:
| 值 | 行为 | 适用场景 |
|---|---|---|
| `delete`(默认) | 按时间和大小删除过期 Segment | 大多数业务消息 |
| `compact` | 保留每个 Key 的最新消息 | CDC、状态快照 |
| `compact,delete` | 先 Compaction 再按时间删除 | 需要 Compaction 但也想限制历史深度 |
### `__consumer_offsets`:Consumer Offset 的存储
消费者提交的 offset 存在哪里?Kafka 把它存在一个**内置 Topic** `__consumer_offsets` 中(默认 50 个 Partition)。这个 Topic 有两个有趣的特性:
1. **使用 Compaction 策略**:每个 Key(格式为 `groupId + topic + partition`)只保留最新的 offset 值,历史提交自动被清理。
2. **内部 Topic,不对外暴露**:消费者通过 `OffsetCommit` / `OffsetFetch` 请求间接读写它,不需要直接 produce/consume。
**Partition 路由**:一个消费者组的 offset 存储在哪个 Partition 中?Kafka 对 `groupId` 做哈希取模:`hash(groupId) % 50`。这保证了同一消费者组的所有 Topic-Partition 的 offset 都集中在同一个 `__consumer_offsets` Partition 上,便于事务性地批量提交和拉取。
> [!question] 思考
> 如果 `__consumer_offsets` 使用 `delete` 策略而不是 `compact`,会导致什么问题?
> [!tip] 答案
> 如果使用 `delete` 策略,当消费者组长时间不活跃(超过 `offsets.retention.minutes`,默认 10080 分钟 = 7 天),其提交的 offset 记录会被删除。当该消费者组重新上线时,找不到之前的消费位置,只能根据 `auto.offset.reset` 策略从头消费(`earliest`)或跳到最新(`latest`)——前者导致大量重复消费,后者导致消息丢失。`compact` 策略保证每个 Key 的最新 offset 永远不会被删除,除非该消费者组被显式废弃。
### 时间轮(Timing Wheel)
Kafka 内部有大量的定时任务:延迟消息、会话过期、日志清理等。如果每个定时任务都开一个 goroutine(或 Java 的 Timer),任务数多了之后,调度开销会非常大。
Kafka 使用**层级时间轮(Hierarchical Timing Wheel)**来高效管理定时任务:
- 时间轮是一个环形数组,每个槽位(slot)代表一个时间区间。
- 新任务根据到期时间插入对应槽位。
- 时钟每推进一个 tick,处理当前槽位的所有任务。
- 当低层时间轮溢出时(即任务的到期时间超出了当前层的总跨度),任务会被"提升"到上层时间轮的某个槽位;随着时钟推进,上层的任务到期时会被重新分配到下层的精确槽位中。
时间轮的插入和删除都是 O(1) 操作——新任务只需计算目标槽位(`currentTime / tickDuration % wheelSize`),直接链表插入;远优于优先队列的 O(log N)。
```mermaid
graph TD
TW["时间轮"] --> L1["第一层: 每槽位 1ms, 共 20 槽位"]
TW --> L2["第二层: 每槽位 20ms, 共 20 槽位"]
TW --> L3["第三层: 每槽位 400ms, 共 20 槽位"]
L1 --> SLOT1["slot 0: 任务 A"]
L1 --> SLOT2["slot 5: 任务 B"]
L2 --> SLOT3["slot 3: 任务 C"]
style TW fill:#4A90D9,color:#fff
style L1 fill:#6EC1E0,color:#fff
style L2 fill:#F5A623,color:#fff
style L3 fill:#D0021B,color:#fff
```
### 页缓存与 Sendfile:读写路径中的内核级优化
Kafka 的读写路径深度依赖 Linux 内核的两个能力:
**写入路径**:Producer 发来的消息写入 Page Cache(内存),由内核异步刷盘。应用层不调用 `fsync`,写入延迟极低。多个 Partition 的写入共享 Page Cache,操作系统会自动管理缓存淘汰。
**读取路径**:Consumer 拉取消息时,Kafka 通过 `sendfile` 系统调用直接将 Page Cache 中的数据传输到网卡,**数据完全不经过用户空间**。这意味着:
- 没有用户态/内核态的上下文切换
- 没有内存拷贝
- 大量 Consumer 并发拉取时,CPU 开销几乎不增长
这就是为什么 Kafka 在普通硬件上就能达到百万级 TPS 的核心秘密——它把操作系统的缓存和网络能力用到了极致。
> [!question] 思考
> 如果 Kafka Broker 的内存足够大,Page Cache 能缓存大量数据。但如果消费者需要回溯到很早的消息(不在 Page Cache 中),会发生什么?性能会下降多少?
### Clean Shutdown 与 Recovery
Kafka 默认采用**异步刷盘**(`log.flush.interval.messages` 和 `log.flush.interval.ms` 控制),这意味着 Page Cache 中可能还有未落盘的数据。Broker 关闭或崩溃时的恢复机制至关重要:
**Clean Shutdown**(正常关闭):Broker 收到 SIGTERM 时,会:
1. 停止接受新的 Produce/Fetch 请求
2. 将所有 Active Segment 的 Page Cache 数据 **flush 到磁盘**
3. 写入 `recovery-point-offset-checkpoint` 文件(记录每个 Partition 已成功刷盘的最新 offset)
4. 关闭所有文件句柄
**Unclean Recovery**(崩溃恢复):如果 Broker 异常终止(SIGKILL、断电),恢复时需要:
1. 读取上次保存的 `recovery-point-offset-checkpoint`
2. 对每个 Partition,从 checkpoint 记录的 offset 开始,逐条验证 RecordBatch 的 CRC-32C 校验和
3. 截断最后一个 CRC 校验失败的 RecordBatch 之后的所有数据
4. 重建 `.index` 和 `.timeindex`(从 `.log` 文件扫描生成)
> [!tip] 实用建议
> 在生产环境中,如果 `unclean.leader.election.enable=false`(默认),那么当一个 Partition 的所有同步副本(ISR)都宕机时,该 Partition 不会自动恢复服务。虽然这保证了数据一致性,但会导致分区不可用。需要在**数据安全**和**可用性**之间做出权衡。
### Go 代码:简化版 Segment 文件的读写
下面展示一个简化版的 Segment 文件实现,包含日志文件和稀疏索引的写入与查找。
```go
type Segment struct {
baseOffset int64
logFile *os.File
indexFile *os.File
index []indexEntry // 内存中的稀疏索引
writePos int64 // 当前写入位置(避免每次调用 stat.Size())
nextOffset int64 // 下一条消息的 offset
}
type indexEntry struct {
offset int64 // 消息的逻辑偏移量
position int32 // 消息在 .log 文件中的物理位置
}
// Write 追加一条消息到 Segment
func (s *Segment) Write(data []byte) error {
pos := s.writePos
// Length-Prefix 编码写入日志文件: [4字节长度][数据]
header := make([]byte, 4+len(data))
binary.BigEndian.PutUint32(header[:4], uint32(len(data)))
copy(header[4:], data)
if _, err := s.logFile.Write(header); err != nil {
return err
}
s.writePos += int64(4 + len(data))
// 稀疏索引:每隔一定字节写入一条索引项
// 简化演示:每隔 4 条消息写一条索引(实际 Kafka 按字节间隔)
if len(s.index) == 0 || s.nextOffset-s.index[len(s.index)-1].offset >= 4 {
entry := indexEntry{offset: s.nextOffset, position: int32(pos)}
s.index = append(s.index, entry)
// 同步写入 .index 文件(每条 12 字节 = 8 offset + 4 position)
buf := make([]byte, 12)
binary.BigEndian.PutUint64(buf[:8], uint64(s.nextOffset))
binary.BigEndian.PutUint32(buf[8:], uint32(pos))
s.indexFile.Write(buf)
}
s.nextOffset++
return nil
}
// Read 根据 offset 查找并读取消息
func (s *Segment) Read(offset int64) ([]byte, error) {
// 第一步:二分查找稀疏索引,找到不大于目标 offset 的最近索引项
idx := sort.Search(len(s.index), func(i int) bool {
return s.index[i].offset > offset
}) - 1
var startPos int32
var scanOffset int64
if idx >= 0 {
startPos = s.index[idx].position
scanOffset = s.index[idx].offset // 从索引记录的 offset 开始扫描
} else {
scanOffset = s.baseOffset // 没找到索引则从头开始
}
// 第二步:从索引位置开始顺序扫描 .log 文件,逐条对比 offset
pos := int64(startPos)
for scanOffset <= offset {
header := make([]byte, 4)
if _, err := s.logFile.ReadAt(header, pos); err != nil {
return nil, err
}
length := binary.BigEndian.Uint32(header)
if scanOffset == offset {
data := make([]byte, length)
s.logFile.ReadAt(data, pos+4)
return data, nil
}
pos += int64(4 + length) // 跳到下一条消息
scanOffset++
}
return nil, fmt.Errorf("offset %d not found", offset)
}
```
**核心要点**:写入时不是每条消息都建索引(稀疏索引),读取时先二分查找索引再顺序扫描——用极小的索引空间和极少的扫描范围实现高效定位。
> [!question] 思考
> Kafka 的 Segment 为什么要同时维护 .log 和 .index 两个文件?只用 .log 行不行?
答案是:**理论上行,但实践中代价太大**。如果只用 .log,消费者查找某个 offset 的消息时,必须从 Segment 头部开始顺序扫描,时间复杂度为 O(N)。在消息量巨大的场景下(单 Segment 可达 1GB、数百万条消息),这会严重拖慢消费速度。稀疏索引将查找降为 O(log N) 的二分 + 少量顺序扫描,代价只是每个 Segment 多几 KB 的索引文件,性价比极高。此外,`.timeindex` 文件支持按时间戳查找消息(用于"从某时刻开始消费"的场景),仅靠 `.log` 文件无法高效实现。
### 存储相关配置参数速查
| 参数 | 默认值 | 说明 |
|------|--------|------|
| `log.segment.bytes` | 1 GB | 单个 Segment 的最大大小,达到后滚动新 Segment |
| `log.roll.ms` / `log.roll.hours` | 7 天 | 即使 Segment 未满,超过该时间也会滚动 |
| `log.index.size.max.bytes` | 10 MB | 单个 `.index` 文件的最大大小 |
| `log.index.interval.bytes` | 4 KB | 稀疏索引的写入间隔(每隔多少字节写一条索引) |
| `log.retention.hours` | 168 (7天) | 消息最大保留时间 |
| `log.retention.bytes` | -1 (不限) | 每个 Partition 的最大保留大小 |
| `cleanup.policy` | delete | Topic 级清理策略:delete / compact / compact,delete |
| `log.cleaner.min.compaction.lag.ms` | 0 | 消息写入后至少保留多久才能被 Compaction |
| `log.cleaner.delete.retention.ms` | 24 小时 | Tombstone 记录的保留时间 |
| `offsets.retention.minutes` | 10080 (7天) | Consumer Group 不活跃后 offset 的保留时间 |
## 关联笔记
- [[04-存储引擎/8-MQ-存储引擎设计|MQ 存储引擎设计]]
- [[04-存储引擎/10-RocketMQ-CommitLog|RocketMQ CommitLog]]
- [[07-主流MQ对比/22-Kafka|Kafka]]
- [[10-监控与运维/40-MQ-性能调优|MQ 性能调优]]
- [[12-架构与实战/50-MQ-设计与实现|MQ 设计与实现]]