Kafka 存储机制
Kafka 的核心不是把消息暂存在内存里,而是把消息持久化为磁盘上的追加日志。每个 Topic 被拆成多个 Partition,每个 Partition 在 Broker 上对应一个日志目录,消息按 Offset 递增追加写入。
存储层级
log.dirs=/data/kafka-logs
└── orders-0/ # Topic=orders, Partition=0
├── 00000000000000000000.log
├── 00000000000000000000.index
├── 00000000000000000000.timeindex
├── 00000000000001000000.log
├── 00000000000001000000.index
└── leader-epoch-checkpoint| 层级 | 说明 |
|---|---|
| Topic | 逻辑消息分类 |
| Partition | Topic 的物理分片,也是顺序性和并行度的基本单位 |
| Log | 一个 Partition 对应一条追加日志 |
| Segment | Log 被切分成多个段文件,便于滚动、删除和查找 |
| Record Batch | Producer 批量发送后的消息批次,Kafka 的实际写入单位 |
Segment 文件
Kafka 不会把一个 Partition 写成单个巨大文件,而是拆成多个 Segment。每个 Segment 的文件名是该段的起始 Offset。
| 文件 | 作用 |
|---|---|
.log | 保存实际消息数据 |
.index | Offset 到物理位置的稀疏索引 |
.timeindex | 时间戳到 Offset 的稀疏索引 |
.txnindex | 事务索引,用于过滤未提交或已中止事务 |
leader-epoch-checkpoint | Leader Epoch 与 Offset 映射,用于副本截断和故障恢复 |
Segment 滚动
常见滚动条件:
| 配置 | 说明 |
|---|---|
log.segment.bytes | Segment 达到指定大小后滚动 |
log.roll.ms / log.roll.hours | Segment 达到指定时间后滚动 |
log.index.size.max.bytes | 索引文件达到上限后滚动 |
只有当前活跃 Segment 会继续追加写入,旧 Segment 变为只读,后续可被清理或压缩。
Offset 与索引
Kafka 的 Offset 是 Partition 内的逻辑编号,不是文件位置。读取某个 Offset 时,Kafka 需要把逻辑 Offset 转成 .log 文件里的物理位置。
目标 Offset = 10023
│
├── 找到 baseOffset <= 10023 的 Segment
│ 例如 00000000000001000000.log
│
├── 在 .index 中二分查找相近 Offset
│ relativeOffset=23 -> position=4096
│
└── 从 .log 的 position 顺序扫描到目标消息Kafka 的索引是稀疏索引,不是每条消息都有索引项。这样可以减少索引文件大小,查找时先定位到附近位置,再顺序扫描少量数据。
Page Cache
Kafka 依赖操作系统 Page Cache,而不是在 JVM 堆里缓存大量消息。
Producer 写入
│
├── Broker 追加写入文件
├── 数据先进入 OS Page Cache
└── 由操作系统异步刷盘
Consumer 读取
│
├── 优先从 Page Cache 命中
└── 未命中时再从磁盘读取这种设计的好处:
- 避免 JVM 堆缓存导致 GC 压力过大
- 让操作系统统一管理缓存淘汰和预读
- 顺序写入配合 Page Cache 可以获得接近内存的读写性能
- Broker 重启后 Page Cache 可以被操作系统逐步重新预热
刷盘机制
Kafka 默认不在每条消息写入后强制 fsync,而是依赖操作系统刷盘。数据可靠性主要由副本机制保证,而不是单机同步刷盘保证。
| 配置 | 说明 |
|---|---|
log.flush.interval.messages | 每写入多少条消息强制刷盘 |
log.flush.interval.ms | 间隔多久强制刷盘 |
acks=all | 等待 ISR 副本确认,提升集群级持久性 |
min.insync.replicas | 限制最少同步副本数 |
生产环境通常不建议频繁强制刷盘,否则会显著降低吞吐。更常见的做法是使用 replication.factor=3、acks=all、min.insync.replicas=2。
日志保留
Kafka 的消息消费后不会立即删除,而是按保留策略清理。
| 策略 | 配置 | 说明 |
|---|---|---|
| 按时间 | log.retention.ms / log.retention.hours | 超过保留时间后删除旧 Segment |
| 按大小 | log.retention.bytes | Partition 日志总大小超过阈值后删除旧 Segment |
| 删除策略 | cleanup.policy=delete | 默认策略,直接删除过期 Segment |
| 压缩策略 | cleanup.policy=compact | 按 Key 保留最新值,适合状态类 Topic |
Delete 与 Compact
delete 适合普通事件流,例如订单事件、日志、埋点。消息超过保留时间后整段删除。
compact 适合保存“当前状态”,例如用户配置、设备状态、维表数据。对于相同 Key,Kafka 最终只保留最新值。
Key=user-1, value=v1
Key=user-2, value=v1
Key=user-1, value=v2
Log Compaction 后:
Key=user-2, value=v1
Key=user-1, value=v2写入路径
Producer
│
├── 批量压缩 Record Batch
├── 发送到 Partition Leader
│
▼
Broker Leader
│
├── 校验请求和权限
├── 追加写入 active Segment
├── 更新 LEO
├── 等待 Follower 拉取并进入 ISR 确认
└── 推进 HW 后返回 ACK关键点:
- 写入是追加模式,避免随机写
- Broker 按 Partition 顺序写入,因此 Partition 内天然有序
- Producer 批量发送,Broker 批量落盘
- HW 之前的数据才对 Consumer 可见
读取路径
Consumer Fetch(offset=N)
│
├── Broker 根据 Offset 定位 Segment
├── 使用 .index 找到物理位置
├── 从 .log 顺序读取 Record Batch
└── 返回不超过 HW 的消息Consumer 读取受两个 Offset 影响:
| Offset | 说明 |
|---|---|
| LEO | 当前副本写到的最新位置 |
| HW | 已被 ISR 确认的最高位置,普通 Consumer 最多读到这里 |
事务场景下,如果 isolation.level=read_committed,Consumer 还会受到 LSO(Last Stable Offset)限制,避免读到未提交事务。