---
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
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
base offset = 0"]
P0 --> S2["Segment 1
base offset = 523840"]
P0 --> S3["Segment 2
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 设计与实现]]