第 14 章

日志存储引擎:为什么 Kafka 这么快

第14章:日志存储引擎:为什么 Kafka 这么快

导读:Kafka 为什么这么快?存储引擎的核心设计决策是什么?

本章核心问题:Kafka 为什么这么快?存储引擎的核心设计决策是什么?

读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

Kafka 的极致性能并非来自某项单一技术,而是多个彼此协同的设计决策的叠加效果:顺序写磁盘、操作系统页缓存、零拷贝传输和内存映射索引。理解这些机制,是优化 Kafka 部署和排查性能问题的前提。

顺序写为什么快:物理存储的本质

HDD 的物理特性

传统机械硬盘(HDD)的性能由三个因素决定:

  1. 寻道时间(Seek Time):磁头移动到目标磁道的时间,约 3-15ms
  2. 旋转延迟(Rotational Latency):等待目标扇区旋转到磁头下方,7200rpm 磁盘约 4ms
  3. 传输时间(Transfer Time):实际读写数据的时间

随机 I/O:每次操作都需要寻道 + 旋转延迟,合计约 7-20ms,折合 ~100-200 IOPS,实际吞吐量约 0.1 MB/s(随机 4K 块)

顺序 I/O:磁头无需移动,数据连续排布,吞吐量可达 100-200 MB/s,提升 1000 倍

SSD 的情况

SSD 没有机械运动,随机 I/O 延迟大幅降低(SATA SSD 约 0.1ms,NVMe SSD 约 0.02ms),但顺序访问相较随机访问仍有优势:

Kafka 的设计选择:通过日志追加写(append-only log)保证对磁盘的访问始终是顺序写入,无论使用 HDD 还是 SSD 都能获得最优的磁盘吞吐量。

零拷贝:sendfile() 的魔法

传统数据传输路径(没有零拷贝时)

假设消费者从 Broker 拉取数据,没有零拷贝时的数据路径:

1. Broker 调用 read() 将数据从磁盘读入 OS 页缓存
2. 操作系统将数据从页缓存复制到 Kafka 进程的 JVM 堆(用户空间缓冲区)
3. Kafka 的 Java 代码对数据进行处理(如有)
4. Kafka 调用 write() 将数据从 JVM 堆复制到内核 Socket 发送缓冲区
5. 操作系统通过网卡 DMA 将数据从 Socket 缓冲区发送出去

内存复制次数:2次(页缓存→用户空间,用户空间→Socket缓冲区)
模式切换次数:4次(read进内核、read出内核、write进内核、write出内核)

sendfile() 系统调用:数据不经过用户空间

sendfile() 是 Linux 提供的专门用于文件到网络传输的系统调用:

1. Broker 调用 sendfile(socket_fd, file_fd, offset, length)
2. 内核直接将数据从页缓存 DMA 传输到网卡发送缓冲区
   (完全在内核空间内完成)

内存复制次数:0次(有支持 scatter-gather 的网卡时)
模式切换次数:2次(进内核、出内核)
传统传输:
磁盘 → [DMA] → 页缓存 → [CPU复制] → 用户空间 → [CPU复制] → Socket缓冲区 → [DMA] → 网卡

零拷贝:
磁盘 → [DMA] → 页缓存 → [DMA] → 网卡
       ↑ 直接跳过用户空间,2次内存复制变为0次 ↑

Kafka 的 Java 实现通过 FileChannel.transferTo() 方法使用零拷贝,该方法在 Linux 上映射到 sendfile()

// kafka/core/src/main/scala/kafka/log/LogSegment.scala(概念性展示)
// 实际的零拷贝传输
def transferTo(fileChannel: FileChannel, position: Long, count: Long,
               socketChannel: GatheringByteChannel): Long = {
    // FileChannel.transferTo() → Java NIO → Linux sendfile()
    // 数据直接从文件(页缓存)传输到 Socket,不经过 JVM 堆
    fileChannel.transferTo(position, count, socketChannel)
}

零拷贝的限制:当消费者启用了消息级压缩时(端到端压缩),Broker 需要解压、检查消息、再压缩,此时必须将数据读入用户空间,零拷贝不适用。这也是为什么在高吞吐场景下推荐在 Producer 端压缩(Producer → Broker 传输时压缩),而非在 Broker 端重新压缩。

mmap:索引文件的内存映射访问

Kafka 的索引文件(.index.timeindex)通过 mmap(内存映射文件)访问:

// 概念性代码(索引文件的 mmap 访问)
MappedByteBuffer indexBuffer = new RandomAccessFile(indexFile, "rw")
    .getChannel()
    .map(FileChannel.MapMode.READ_WRITE, 0, indexFile.length());

// 直接在内存中做二分查找,无需 read() 系统调用
int targetRelativeOffset = (int)(targetOffset - baseOffset);
int lo = 0, hi = (indexBuffer.position() / 8) - 1;
while (lo <= hi) {
    int mid = (lo + hi) >>> 1;
    int relativeOffset = indexBuffer.getInt(mid * 8);     // 直接内存访问
    int physicalPosition = indexBuffer.getInt(mid * 8 + 4);
    if (relativeOffset < targetRelativeOffset) lo = mid + 1;
    else if (relativeOffset > targetRelativeOffset) hi = mid - 1;
    else return physicalPosition;  // 精确命中
}
return indexBuffer.getInt(hi * 8 + 4);  // 返回最近索引条目的物理位置

mmap 的优势在于:索引文件的访问无需 read()/write() 系统调用,直接以内存指针访问,操作系统负责页面换入/换出,整个索引对应用层来说就像一个 byte 数组。


Level 2 · 它是怎么运行的(3-5年经验)

页缓存:Kafka 为什么不管理自己的内存缓存

操作系统页缓存的工作原理

Linux 的页缓存(Page Cache)是内核级别的磁盘 I/O 缓冲区:

写操作路径:
应用程序写入 → 写入页缓存(内存)→ 立即返回
                     ↓(异步)
              pdflush/kworker 将脏页写回磁盘

读操作路径:
应用程序读取 → 检查页缓存 → 命中:直接返回内存数据(无磁盘 I/O)
                            → 未命中:从磁盘读入,放入页缓存,返回

对于 Kafka 这样的消息系统,写入的消息往往在短时间内被消费(消费者通常跟得上生产者)。这意味着消费者从页缓存中读取数据的概率极高,实质上是从内存中消费,而非磁盘

为什么 Kafka 选择依赖 OS 页缓存而非 JVM 堆

方案1:自管理 JVM 堆缓存(Kafka 未采用的方案)

方案2:依赖 OS 页缓存(Kafka 实际采用的方案)

# 查看 Kafka 使用的页缓存情况
# 安装 pcstat 工具
pcstat /data/kafka/orders-0/00000000000020971520.log

# 输出示例:
# +---------------------------------------------------------+----------------+------------+-----------+---------+
# | Name                                                    | Size (bytes)   | Pages      | Cached    | Percent |
# |---------------------------------------------------------+----------------+------------+-----------+---------|
# | /data/kafka/orders-0/00000000000020971520.log           | 567,705,600    | 138600     | 138542    | 99.958% |
# +---------------------------------------------------------+----------------+------------+-----------+---------+
# → 接近 100% 的活跃段在页缓存中,消费者实际上是在读内存!

这也是 Kafka 官方建议将 JVM 堆大小限制在 4-8GB,将余下内存留给操作系统页缓存的根本原因。对于一台 64GB 内存的 Broker 节点,典型配置是 -Xmx6g,剩余 ~56GB 全部供页缓存使用。

启动时日志恢复

Kafka Broker 在每次启动时,都会对最后一个 LogSegment(活跃段)执行恢复流程,因为 Broker 崩溃时最后一段可能存在不完整的 RecordBatch(写入了一半后崩溃):

恢复流程

  1. 从段的最后一个完整 RecordBatch 开始验证
  2. 对每个 RecordBatch 计算 CRC32 并与存储的 CRC 比对
  3. 发现 CRC 不匹配时,将文件截断到最后一个完整 RecordBatch 之后的位置
  4. 重建 .index.timeindex(从头扫描 .log 文件重新生成)
# 查看 Kafka 启动日志中的恢复信息
grep "Recovering unflushed segment" /var/log/kafka/server.log
# [2024-04-26 08:00:01] INFO Recovering unflushed segment 20971520 in
#   log /data/kafka/orders-0 (kafka.log.Log)

# 若有截断发生
grep "Truncating segment" /var/log/kafka/server.log
# [2024-04-26 08:00:02] INFO Truncating segment 20971520 to 567705444 bytes
#   in log /data/kafka/orders-0 (kafka.log.Log)

log.recovery.threads.per.data.dir(默认 1)控制每个数据目录并行恢复的线程数。对于有大量分区的 Broker,可以适当增加此值以加速启动。


Level 3 · 规范怎么定义的(资深)

LogSegment 文件结构:分区目录里到底有什么

Kafka 的每个分区对应磁盘上的一个目录,目录内包含多组文件,每组文件构成一个 LogSegment(日志段)

# 查看一个真实的分区目录内容
ls -lh /data/kafka/orders-0/

# 典型输出(一个忙碌的生产分区):
-rw-r--r-- 1 kafka kafka 1.0G Apr 26 00:00 00000000000000000000.log
-rw-r--r-- 1 kafka kafka  10M Apr 26 00:00 00000000000000000000.index
-rw-r--r-- 1 kafka kafka  10M Apr 26 00:00 00000000000000000000.timeindex
-rw-r--r-- 1 kafka kafka    0 Apr 26 00:00 00000000000000000000.txnindex
-rw-r--r-- 1 kafka kafka 1.0G Apr 26 01:00 00000000000010485760.log
-rw-r--r-- 1 kafka kafka  10M Apr 26 01:00 00000000000010485760.index
-rw-r--r-- 1 kafka kafka  10M Apr 26 01:00 00000000000010485760.timeindex
-rw-r--r-- 1 kafka kafka    0 Apr 26 01:00 00000000000010485760.txnindex
-rw-r--r-- 1 kafka kafka 542M Apr 26 10:33 00000000000020971520.log  ← 活跃段
-rw-r--r-- 1 kafka kafka   5M Apr 26 10:33 00000000000020971520.index
-rw-r--r-- 1 kafka kafka   5M Apr 26 10:33 00000000000020971520.timeindex
-rw-r--r-- 1 kafka kafka    0 Apr 26 10:33 00000000000020971520.txnindex
-rw-r--r-- 1 kafka kafka   0  Apr 26 10:33 leader-epoch-checkpoint
-rw-r--r-- 1 kafka kafka  60  Apr 26 10:33 partition.metadata

文件名中的 20 位数字是该段第一条消息的 base offset

.log 文件:实际的消息数据

.log 文件是追加写入的消息存储。每条消息(在 Kafka 内部称为 Record)按如下格式存储(RecordBatch 格式,Kafka 0.11+ 的 Magic V2):

RecordBatch:
├── baseOffset (8 bytes)         - 批次第一条记录的 offset
├── batchLength (4 bytes)        - 批次总长度
├── partitionLeaderEpoch (4 bytes) - Leader 纪元(用于一致性检查)
├── magic (1 byte)               - 格式版本,当前为 2
├── crc (4 bytes)                - 批次 CRC32 校验
├── attributes (2 bytes)         - 压缩类型、时间戳类型等标志
├── lastOffsetDelta (4 bytes)    - 最后一条记录相对 baseOffset 的偏移差
├── baseTimestamp (8 bytes)      - 批次第一条记录的时间戳
├── maxTimestamp (8 bytes)       - 批次中最大时间戳
├── producerId (8 bytes)         - 幂等/事务生产者 ID
├── producerEpoch (2 bytes)      - 生产者 Epoch(防止僵尸写)
├── baseSequence (4 bytes)       - 幂等序列号起始值
├── recordsCount (4 bytes)       - 批次内记录数量
└── Records[]:                   - 记录数组
    ├── length (varint)
    ├── attributes (int8)
    ├── timestampDelta (varint)  - 相对 baseTimestamp 的差值(节省空间)
    ├── offsetDelta (varint)     - 相对 baseOffset 的差值
    ├── keyLength (varint)
    ├── key (bytes)
    ├── valueLength (varint)
    ├── value (bytes)
    └── headers[]                - 可选的 KV 元数据头

使用 varint(变长整数)编码时间戳差值和 offset 差值,使得批次内的记录存储极为紧凑。

.index 文件:稀疏 offset 索引

.index 文件是稀疏索引(Sparse Index),并非每条消息都有索引条目,而是每写入约 4KB(由 log.index.interval.bytes 控制,默认 4096)的消息数据,才添加一个索引条目。

每个索引条目固定 8 字节:

索引条目示例(base offset = 10485760):

相对offset=0,       物理位置=0
相对offset=157,     物理位置=4096
相对offset=314,     物理位置=8192
相对offset=471,     物理位置=12288
...

按 offset 查找消息的流程

  1. 对所有 LogSegment 的 base offset 做二分查找,确定目标 offset 在哪个段
  2. 对该段的 .index 文件做二分查找,找到最接近目标 offset 的索引条目
  3. 从索引条目指向的物理位置开始,顺序扫描 .log 文件,直到找到精确的目标 offset

最坏情况下,第三步需要扫描最多 4KB 的日志数据(稀疏索引的代价),但这在顺序读取中几乎不构成性能问题。

.timeindex 文件:时间戳索引

.timeindex 文件的结构与 .index 类似,但索引键是时间戳而非 offset:

时间戳索引条目(12 字节):
- timestamp(8 字节):消息时间戳
- 对应的相对 offset(4 字节)

时间戳索引用于支持按时间查找(--reset-offsets --to-datetime)以及时间戳保留策略(log.retention.hours)。

# 使用 kafka-dump-log.sh 验证文件内容
kafka-dump-log.sh \
  --files /data/kafka/orders-0/00000000000000000000.index \
  --index-sanity-check

# 输出示例:
# Dumping /data/kafka/orders-0/00000000000000000000.index
# offset: 0 position: 0
# offset: 157 position: 4096
# offset: 314 position: 8192
# ...

# 查看实际消息内容
kafka-dump-log.sh \
  --files /data/kafka/orders-0/00000000000000000000.log \
  --print-data-log | head -20

.txnindex 文件:事务中止索引

.txnindex 文件记录已中止的事务(Aborted Transaction)的 offset 范围。当消费者配置 isolation.level=read_committed 时,需要查询此文件以跳过中止事务的消息。

若不使用事务,该文件大小为 0(如上述 ls 输出所示)。

关键配置与磁盘策略

配置项 默认值 说明
log.segment.bytes 1073741824 (1GB) 单个 .log 段的最大大小
log.index.size.max.bytes 10485760 (10MB) 索引文件最大大小
log.index.interval.bytes 4096 索引条目写入间隔(字节)
log.flush.interval.messages Long.MAX_VALUE 达到此消息数才 fsync(默认依赖 OS)
log.flush.interval.ms Long.MAX_VALUE 达到此时间间隔才 fsync(默认依赖 OS)

关于 fsync 配置的重要说明:Kafka 默认不强制 fsync,依赖操作系统的页缓存管理。这意味着在 Broker 崩溃时可能丢失最后几条未落盘的消息(取决于 OS 刷脏页的频率)。

依靠副本机制min.insync.replicas >= 2 + acks=all)来保证数据持久性,远比在每条消息后 fsync 更高效。fsync 会将顺序写的吞吐优势几乎完全抵消。在两个或多个副本都在内存/磁盘中保存了数据的情况下,单个 Broker 的页缓存数据丢失不会导致消息丢失。


Level 4 · 边界与陷阱(所有人)

以下是与"日志存储引擎:为什么 Kafka 这么快"相关的常见边界问题和生产陷阱:

陷阱一:忽略默认配置的隐含假设。 许多 Kafka 参数的默认值是为通用场景设计的,在特定业务场景下可能导致性能问题或数据风险。上线前务必逐一审查与本章主题相关的配置项,确认其是否符合业务需求。

陷阱二:缺乏端到端的验证。 理论理解和生产实践之间往往存在差距。建议在预发布环境中构建完整的测试场景,覆盖正常路径和异常路径(如节点宕机、网络分区、磁盘满),验证系统行为是否符合预期。

陷阱三:监控盲区。 本章涉及的机制如果缺乏对应的监控指标和告警规则,问题往往在造成业务影响后才被发现。建议将关键指标纳入监控体系,设置合理的告警阈值。

本章评分
4.6  / 5  (21 评分)

💬 留言讨论