mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1817 字
5 分钟
Kafka 存储:零拷贝与页缓存
2026-03-21

Kafka 在普通硬件上实现了 200 万+ TPS——这个数字的秘密不是做了更多,而是做了更少。Kafka 不建索引、不做随机写、不在 Broker 端解压缩、甚至不维护消费者的消费状态。它只做一件事:把消息顺序追加到磁盘末尾。然后利用操作系统的页缓存零拷贝(sendfile)把数据直接从页缓存送到网卡,绕过 JVM 堆内存和用户态拷贝。这种”少即是多”的存储哲学,是 Kafka 性能的根基。

一、Kafka 存储设计哲学#

1.1 为什么 Kafka 这么快?#

Kafka 的高性能并非来自某种魔法,而是对操作系统 I/O 特性的深度利用。核心思想是:避免随机 I/O,最大化顺序 I/O

优化手段原理性能提升
顺序写入追加到日志末尾,无寻道开销比随机写快 6000 倍
页缓存利用 OS Page Cache,避免 JVM GC减少 GC 停顿
零拷贝sendfile() 系统调用,跳过用户态拷贝减少 2 次数据拷贝
批量压缩生产者端压缩,Broker 端不解压减少网络和磁盘 I/O
分批发送RecordBatch 批量写入减少 I/O 请求次数
graph LR subgraph "传统消息系统" P1["生产者"] -->|随机写入| DB["B-Tree 索引<br/>随机 I/O"] DB -->|随机读取| C1["消费者"] end subgraph "Kafka" P2["生产者"] -->|顺序追加| LOG["Append-Only Log<br/>顺序 I/O"] LOG -->|零拷贝| C2["消费者"] end style DB fill:#ffcdd2,stroke:#c62828 style LOG fill:#c8e6c9,stroke:#2e7d32

1.2 顺序写入 vs 随机写入#

理解 Kafka 性能的关键在于理解磁盘 I/O 的本质差异:

I/O 类型HDD 吞吐SSD 吞吐原因
顺序写入~200 MB/s~500 MB/s无寻道开销,预读友好
随机写入~0.1 MB/s~50 MB/s需要寻道/擦除
顺序读取~200 MB/s~500 MB/s预读生效
随机读取~0.1 MB/s~50 MB/s每次寻道
# 磁盘性能基准测试
# 顺序写入
dd if=/dev/zero of=/var/lib/kafka/data/test bs=1M count=1024 oflag=direct
# 随机写入(使用 fio)
fio --name=randwrite --ioengine=libaio --iodepth=16 \
--rw=randwrite --bs=4k --direct=1 --size=1G \
--numjobs=1 --runtime=60 --group_reporting
# 顺序写入(使用 fio)
fio --name=seqwrite --ioengine=libaio --iodepth=16 \
--rw=write --bs=1M --direct=1 --size=1G \
--numjobs=1 --runtime=60 --group_reporting
Note

Kafka 的设计者 Jay Kreps 曾指出:顺序写入磁盘的速度甚至可以超过随机写入内存。6 块 7200RPM SATA 硬盘组成的 RAID-5 阵列,顺序写入可达 600MB/s,而随机写入仅 100KB/s,差距 6000 倍。

二、日志段(Log Segment)#

2.1 Partition 的物理结构#

每个 Partition 在磁盘上对应一个目录,包含多个 Log Segment:

/var/lib/kafka/data/orders-0/
├── 00000000000000000000.log # 消息数据
├── 00000000000000000000.index # 偏移量索引
├── 00000000000000000000.timeindex # 时间戳索引
├── 00000000000005367851.log
├── 00000000000005367851.index
├── 00000000000005367851.timeindex
└── leader-epoch-checkpoint # Leader Epoch 检查点
文件类型作用大小控制
.log存储实际消息数据log.segment.bytes(默认 1GB)
.indexOffset → 物理位置稀疏索引每 4KB 一个索引项
.timeindex时间戳 → Offset 稀疏索引每 4KB 一个索引项
.txnindex事务索引(事务消息)按需创建
// Log Segment 的核心结构
public class LogSegment {
private final FileRecords log; // .log 文件
private final OffsetIndex offsetIndex; // .index 文件
private final TimeIndex timeIndex; // .timeindex 文件
private final long baseOffset; // 段的起始 Offset
// 追加消息
public void append(long offset, MemoryRecords records) {
// 1. 写入 .log 文件
log.append(records);
// 2. 更新偏移量索引(稀疏索引,每隔一定字节记录一条)
if (shouldCreateIndexEntry()) {
offsetIndex.append(offset, log.sizeInBytes());
}
// 3. 更新时间戳索引
timeIndex.maybeAppend(timestamp, offset);
}
// 查找消息
public FileRecords.FileChannelRecordSearchResult searchForOffset(
long targetOffset, int startingPosition) {
// 1. 二分查找 .index 定位到接近的物理位置
// 2. 从该位置扫描 .log 找到精确的 Offset
return offsetIndex.lookup(targetOffset);
}
}

2.2 稀疏索引设计#

Kafka 使用稀疏索引而非稠密索引,这是性能与内存的权衡:

索引类型内存占用查找速度适用场景
稠密索引高(每条消息一个索引项)O(1)内存足够,需要精确查找
稀疏索引低(每隔 N 字节一个索引项)O(1) 索引 + O(N) 扫描内存有限,允许少量扫描
# 索引相关配置
# 索引间隔字节(越小越精确,但内存占用越大)
log.index.interval.bytes=4096
# 索引文件最大大小
log.index.size.max.bytes=10485760
# 段大小
log.segment.bytes=1073741824
# 段滚动时间(即使未满也滚动)
log.roll.hours=168
# 查看 Segment 详情
kafka-log-dirs --bootstrap-server localhost:9092 \
--describe --topic-list orders

2.3 日志清理策略#

Kafka 提供两种日志清理策略来控制磁盘使用:

策略原理适用场景配置
Delete删除旧 Segment日志、指标等时序数据log.cleanup.policy=delete
Compact保留每个 Key 的最新值变更日志、状态快照log.cleanup.policy=compact
graph TB subgraph "Delete 策略" D1["Segment 0<br/>Offset 0-999"] --> D2["Segment 1<br/>Offset 1000-1999"] D2 --> D3["Segment 2<br/>Offset 2000-2999<br/>(最新)"] D1 -.->|超过保留期删除| DX[" 已删除"] end subgraph "Compact 策略" C1["Key=A v1<br/>Key=B v1<br/>Key=A v2<br/>Key=C v1<br/>Key=B v2"] C2["Key=A v2<br/>Key=B v2<br/>Key=C v1<br/>(保留最新值)"] C1 -->|压缩| C2 end
# Delete 策略配置
log.cleanup.policy=delete
log.retention.hours=168 # 保留 7 天
log.retention.bytes=1073741824 # 每个分区最大 1GB
log.retention.check.interval.ms=300000 # 每 5 分钟检查一次
# Compact 策略配置
log.cleanup.policy=compact
log.cleaner.min.compaction.lag.ms=0
log.cleaner.max.compaction.lag.ms=9223372036854775807
log.cleaner.dedupe.buffer.size=134217728
log.cleaner.threads=1
log.cleaner.backoff.ms=15000
# 同时使用两种策略
log.cleanup.policy=delete,compact

三、页缓存(Page Cache)#

3.1 为什么不用应用层缓存?#

Kafka 故意不使用 JVM 堆内存缓存数据,而是依赖操作系统的 Page Cache:

方案优点缺点
JVM 堆缓存应用层控制精确GC 停顿、对象开销大、重启丢失
OS Page Cache无 GC、自动管理、重启不丢应用层控制弱
graph TB subgraph "传统方案:JVM 堆缓存" P1["生产者"] -->|写入| JVM1["JVM 堆内存<br/>(对象开销 ~2x)"] JVM1 -->|GC 停顿| DISK1["磁盘"] DISK1 -->|读取| JVM1 JVM1 -->|返回| C1["消费者"] end subgraph "Kafka 方案:Page Cache" P2["生产者"] -->|写入| PC["OS Page Cache<br/>(零拷贝)"] PC -->|异步刷盘| DISK2["磁盘"] DISK2 -->|预读| PC PC -->|零拷贝| C2["消费者"] end style JVM1 fill:#ffcdd2,stroke:#c62828 style PC fill:#c8e6c9,stroke:#2e7d32

3.2 Page Cache 工作原理#

# 查看 Page Cache 使用情况
vmtouch /var/lib/kafka/data/orders-0/
# 输出示例:
# Files: 6
# Directories: 1
# Resident Pages: 245760/262144 960M/1G 93.75%
# Elapsed: 0.003432 seconds
# 手动释放 Page Cache(仅用于测试!)
echo 1 > /proc/sys/vm/drop_caches
# Kafka 相关的 OS 调优
# 增大文件描述符限制
ulimit -n 100000
# 调整脏页刷回策略
# 脏页占内存百分比达到此值时触发刷回
sysctl vm.dirty_background_ratio=5
# 脏页占内存百分比达到此值时阻塞写入
sysctl vm.dirty_ratio=80
# 调整 swappiness(尽量不用 swap)
sysctl vm.swappiness=1
// Kafka 的文件写入使用 FileChannel
// 数据写入 Page Cache,由 OS 决定何时刷盘
FileChannel channel = FileChannel.open(path,
StandardOpenOption.CREATE, StandardOpenOption.READ, StandardOpenOption.WRITE);
// 写入数据(进入 Page Cache)
channel.write(buffer);
// 强制刷盘(fsync)—— Kafka 默认不主动调用
// 仅在特定配置下触发
channel.force(true); // metadata = true 表示同时刷元数据
// Kafka 刷盘配置
// log.flush.interval.messages=10000 # 每 10000 条消息刷盘
// log.flush.interval.ms=1000 # 每 1000ms 刷盘
// 注意:通常不需要手动配置刷盘,依赖 OS 的刷回策略即可
Warning

不要过度调优 log.flush.interval 参数。频繁 fsync 会严重影响性能。Kafka 的设计依赖副本机制而非单机刷盘来保证数据持久性——只要 ISR 中的副本都写入 Page Cache,即使单机宕机也不会丢数据。

3.3 预读(Readahead)#

操作系统会自动预读顺序访问的文件,这对 Kafka 的消费场景非常友好:

# 查看预读设置
blockdev --getra /dev/sda1
# 设置预读大小(单位:512 字节扇区)
# 256 表示预读 128KB
blockdev --setra 256 /dev/sda1
# 对于 Kafka 消费场景,较大的预读值可以提升性能
# 推荐 256-4096(128KB-2MB)
blockdev --setra 4096 /dev/sda1

四、零拷贝(Zero-Copy)#

4.1 传统数据拷贝#

传统方式读取文件并发送到网络需要 4 次数据拷贝:

sequenceDiagram participant App as 应用程序 participant User as 用户态缓冲区 participant Kernel as 内核态缓冲区 participant Socket as Socket 缓冲区 participant NIC as 网卡 Note over App,NIC: 传统方式:4 次拷贝 + 4 次上下文切换 App->>Kernel: read() 系统调用 Kernel->>Kernel: 1. 磁盘 → 内核缓冲区 (DMA) Kernel->>User: 2. 内核缓冲区 → 用户缓冲区 (CPU) User->>Kernel: write() 系统调用 Kernel->>Socket: 3. 用户缓冲区 → Socket 缓冲区 (CPU) Socket->>NIC: 4. Socket 缓冲区 → 网卡 (DMA)

4.2 零拷贝:sendfile()#

Kafka 使用 sendfile() 系统调用,跳过用户态的数据拷贝:

sequenceDiagram participant App as 应用程序 participant Kernel as 内核缓冲区 participant Socket as Socket 缓冲区 participant NIC as 网卡 Note over App,NIC: 零拷贝:2 次拷贝 + 2 次上下文切换 App->>Kernel: sendfile() 系统调用 Kernel->>Kernel: 1. 磁盘 → 内核缓冲区 (DMA) Kernel->>NIC: 2. 内核缓冲区 → 网卡 (DMA)<br/>带 scatter-gather
方式拷贝次数上下文切换CPU 参与
传统 read+write442 次 CPU 拷贝
mmap + write341 次 CPU 拷贝
sendfile220 次 CPU 拷贝
sendfile + SG-DMA220 次 CPU 拷贝
// Kafka 的零拷贝实现
// Kafka 使用 Java 的 FileChannel.transferTo() → 底层调用 sendfile()
// 消费者请求处理(简化版)
public class FetchResponse {
public void writeTo(GatheringByteChannel channel) {
// 零拷贝发送日志数据
for (FileRecords fileRecords : records) {
fileRecords.writeTo(channel, 0, fileRecords.sizeInBytes());
// 底层调用:
// fileChannel.transferTo(position, count, socketChannel)
// → Linux sendfile() 系统调用
}
}
}
// FileChannel.transferTo 的零拷贝实现
// Linux 2.4+ 支持 scatter-gather DMA
// 数据从磁盘直接到网卡,不经过 CPU
FileChannel srcChannel = new FileInputStream(logFile).getChannel();
FileChannel destChannel = new FileOutputStream(socket).getChannel();
srcChannel.transferTo(0, srcChannel.size(), destChannel);
# 验证零拷贝是否生效
# 使用 strace 追踪系统调用
strace -e trace=sendfile -p $(pgrep -f kafka) 2>&1 | head -20
# 使用 perf 监控 sendfile 调用
perf stat -e 'syscalls:sys_enter_sendfile' -p $(pgrep -f kafka) sleep 10

五、消息压缩#

5.1 压缩策略#

Kafka 支持在生产者端压缩消息,Broker 端不解压,消费者端解压:

压缩算法压缩率CPU 开销适用场景
none1:1网络带宽充足
gzip带宽受限、冷数据
snappy热数据、低延迟
lz4最低热数据、最低延迟
zstdKafka 2.1+,综合最优
// 生产者压缩配置
Properties props = new Properties();
props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");
// 批量发送配置(与压缩配合)
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 16384); // 16KB 批次
props.put(ProducerConfig.LINGER_MS_CONFIG, 5); // 等待 5ms 凑批
KafkaProducer<String, String> producer = new KafkaProducer<>(props);
// 压缩发生在 RecordBatch 层面
// 多条消息被压缩为一个 RecordBatch
// Broker 存储和传输的都是压缩后的数据
// 消费者收到后才解压
// 压缩效果对比
// 原始数据:1000 条 JSON 消息,约 500KB
// gzip:约 50KB(10:1)
// snappy:约 150KB(3.3:1)
// lz4:约 160KB(3.1:1)
// zstd:约 80KB(6.25:1)

5.2 压缩层级#

graph TB subgraph "生产者" R1["Record 1"] R2["Record 2"] R3["Record 3"] R1 --> BATCH["RecordBatch<br/>(压缩单元)"] R2 --> BATCH R3 --> BATCH BATCH -->|压缩| CB["Compressed<br/>RecordBatch"] end subgraph "Broker" CB -->|存储| LOG["Log Segment<br/>(不解压)"] end subgraph "消费者" LOG -->|传输| CB2["Compressed<br/>RecordBatch"] CB2 -->|解压| R4["Record 1"] CB2 -->|解压| R5["Record 2"] CB2 -->|解压| R6["Record 3"] end
Note

Kafka 的压缩发生在 RecordBatch 级别,而非单条消息级别。这意味着 linger.msbatch.size 的配置直接影响压缩效果——更大的批次意味着更多消息一起压缩,压缩率更高。

六、日志段管理#

6.1 Segment 生命周期#

// Segment 的生命周期管理
public class Log {
private final ConcurrentMap<Long, LogSegment> segments;
// 滚动到新 Segment
public void roll(OptionalLong nextOffset) {
// 1. 刷新当前 Segment
if (activeSegment != null) {
activeSegment.flush();
}
// 2. 创建新 Segment
long newOffset = nextOffset.orElseGet(() ->
activeSegment != null ? activeSegment.readNextOffset() : 0);
LogSegment newSegment = LogSegment.open(dir, newOffset, config);
// 3. 添加到 segments 集合
segments.put(newOffset, newSegment);
}
// 删除旧 Segment
public void deleteRetentionMsBreachedSegments() {
// 1. 找到超过保留期的 Segment
// 2. 从 segments 集合中移除
// 3. 异步删除文件
}
}

6.2 日志清理器(Log Cleaner)#

Compact 策略的清理过程:

# Compact 清理过程
# 1. 选择最脏的 Segment(重复 Key 比例最高)
# 2. 将 Segment 分成 clean 和 dirty 两部分
# 3. 遍历 dirty 部分,保留每个 Key 的最新值
# 4. 将清理后的数据写入新 Segment
# 5. 替换旧 Segment
# Compact 相关配置
log.cleaner.enable=true
log.cleaner.threads=2
log.cleaner.dedupe.buffer.size=134217728 # 去重缓冲区 128MB
log.cleaner.io.buffer.load.factor=0.9
log.cleaner.backoff.ms=15000
log.cleaner.min.cleanable.dirty.ratio=0.5 # dirty 比例超过 50% 才清理

七、生产环境存储优化#

7.1 磁盘规划#

场景磁盘类型RAID文件系统挂载选项
低延迟交易NVMe SSD无(JBOD)XFSnoatime,nodiratime
大数据管道SATA HDDRAID-10XFSnoatime,nodiratime
混合负载SSD+HDDJBODXFSnoatime,nodiratime
# XFS 文件系统创建和挂载
mkfs.xfs -f /dev/sdb1
mount -o noatime,nodiratime /dev/sdb1 /var/lib/kafka/data
# /etc/fstab 持久化
/dev/sdb1 /var/lib/kafka/data xfs noatime,nodiratime 0 2
# JBOD 配置(多磁盘)
# server.properties
log.dirs=/disk1/kafka,/disk2/kafka,/disk3/kafka,/disk4/kafka
# 磁盘空间监控
df -h /var/lib/kafka/data/
du -sh /var/lib/kafka/data/*/

7.2 性能调优参数#

# Broker 存储相关配置
# 段大小
log.segment.bytes=1073741824 # 1GB
# 刷盘策略(通常不需要修改)
# log.flush.interval.messages=10000
# log.flush.interval.ms=1000
# 保留策略
log.retention.hours=168 # 7 天
log.retention.bytes=-1 # 不限制大小
log.retention.check.interval.ms=300000
# 清理策略
log.cleanup.policy=delete
log.cleaner.enable=true
# 文件描述符
# /etc/security/limits.conf
# kafka soft nofile 100000
# kafka hard nofile 100000

7.3 监控指标#

指标含义告警阈值
LogFlushRateAndTimeMs刷盘频率和耗时> 100ms
BytesInPerSec写入速率接近磁盘上限
BytesOutPerSec读取速率接近网络上限
SizePartition 数据大小> 80% 磁盘容量
SegmentsSegment 数量过多影响性能
// 监控磁盘使用
// JMX MBean: kafka.log:type=Log,name=Size,topic=*,partition=*
// JMX MBean: kafka.log:type=LogManager,name=LogFlushRateAndTimeMs
// 使用 kafka-log-dirs 查看磁盘使用
kafka-log-dirs --bootstrap-server localhost:9092 --describe

八、存储架构对比#

维度KafkaRabbitMQRocketMQPulsar
存储模型Append-Only Log内存 + 持久化CommitLog + ConsumeQueueBookKeeper Ledger
索引方式稀疏索引无索引(按序消费)稠密索引Ledger 索引
缓存策略OS Page CacheBroker 内存OS Page Cache分层缓存
零拷贝sendfilemmap + sendfileBookKeeper 分层
压缩生产者端生产者端Broker 端
清理策略Delete/CompactTTL/Queue 长度定时删除Ledger 滚动

九、总结#

上一章剖析了Kafka 架构与分区机制。

维度关键要点
顺序写入Kafka 性能的基石——追加写入避免寻道,比随机写快数千倍
页缓存利用 OS Page Cache 避免 JVM GC,重启后缓存仍有效
零拷贝sendfile() 跳过用户态拷贝,减少 CPU 开销和上下文切换
压缩生产者端压缩、Broker 端透传、消费者端解压,减少网络和磁盘 I/O
日志段稀疏索引平衡内存与查找速度,Delete/Compact 两种清理策略
调优不要过度调优 fsync,依赖副本而非单机刷盘保证持久性
Tip

Kafka 存储设计的核心哲学是”把操作系统当朋友”——利用 Page Cache 而非自建缓存,利用 sendfile 而非手动拷贝,利用顺序 I/O 而非随机 I/O。理解这些底层原理,才能做出正确的调优决策。

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

Kafka 存储:零拷贝与页缓存
https://blog.souloss.com/posts/messaging/kafka-storage/
作者
Souloss
发布于
2026-03-21
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时