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 请求次数 |
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_reportingKafka 的设计者 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) |
.index | Offset → 物理位置稀疏索引 | 每 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 orders2.3 日志清理策略
Kafka 提供两种日志清理策略来控制磁盘使用:
| 策略 | 原理 | 适用场景 | 配置 |
|---|---|---|---|
| Delete | 删除旧 Segment | 日志、指标等时序数据 | log.cleanup.policy=delete |
| Compact | 保留每个 Key 的最新值 | 变更日志、状态快照 | log.cleanup.policy=compact |
# Delete 策略配置log.cleanup.policy=deletelog.retention.hours=168 # 保留 7 天log.retention.bytes=1073741824 # 每个分区最大 1GBlog.retention.check.interval.ms=300000 # 每 5 分钟检查一次
# Compact 策略配置log.cleanup.policy=compactlog.cleaner.min.compaction.lag.ms=0log.cleaner.max.compaction.lag.ms=9223372036854775807log.cleaner.dedupe.buffer.size=134217728log.cleaner.threads=1log.cleaner.backoff.ms=15000
# 同时使用两种策略log.cleanup.policy=delete,compact三、页缓存(Page Cache)
3.1 为什么不用应用层缓存?
Kafka 故意不使用 JVM 堆内存缓存数据,而是依赖操作系统的 Page Cache:
| 方案 | 优点 | 缺点 |
|---|---|---|
| JVM 堆缓存 | 应用层控制精确 | GC 停顿、对象开销大、重启丢失 |
| OS Page Cache | 无 GC、自动管理、重启不丢 | 应用层控制弱 |
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 的刷回策略即可不要过度调优 log.flush.interval 参数。频繁 fsync 会严重影响性能。Kafka 的设计依赖副本机制而非单机刷盘来保证数据持久性——只要 ISR 中的副本都写入 Page Cache,即使单机宕机也不会丢数据。
3.3 预读(Readahead)
操作系统会自动预读顺序访问的文件,这对 Kafka 的消费场景非常友好:
# 查看预读设置blockdev --getra /dev/sda1
# 设置预读大小(单位:512 字节扇区)# 256 表示预读 128KBblockdev --setra 256 /dev/sda1
# 对于 Kafka 消费场景,较大的预读值可以提升性能# 推荐 256-4096(128KB-2MB)blockdev --setra 4096 /dev/sda1四、零拷贝(Zero-Copy)
4.1 传统数据拷贝
传统方式读取文件并发送到网络需要 4 次数据拷贝:
4.2 零拷贝:sendfile()
Kafka 使用 sendfile() 系统调用,跳过用户态的数据拷贝:
| 方式 | 拷贝次数 | 上下文切换 | CPU 参与 |
|---|---|---|---|
| 传统 read+write | 4 | 4 | 2 次 CPU 拷贝 |
| mmap + write | 3 | 4 | 1 次 CPU 拷贝 |
| sendfile | 2 | 2 | 0 次 CPU 拷贝 |
| sendfile + SG-DMA | 2 | 2 | 0 次 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// 数据从磁盘直接到网卡,不经过 CPUFileChannel 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 开销 | 适用场景 |
|---|---|---|---|
| none | 1:1 | 无 | 网络带宽充足 |
| gzip | 高 | 高 | 带宽受限、冷数据 |
| snappy | 中 | 低 | 热数据、低延迟 |
| lz4 | 中 | 最低 | 热数据、最低延迟 |
| zstd | 高 | 中 | Kafka 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 压缩层级
Kafka 的压缩发生在 RecordBatch 级别,而非单条消息级别。这意味着 linger.ms 和 batch.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=truelog.cleaner.threads=2log.cleaner.dedupe.buffer.size=134217728 # 去重缓冲区 128MBlog.cleaner.io.buffer.load.factor=0.9log.cleaner.backoff.ms=15000log.cleaner.min.cleanable.dirty.ratio=0.5 # dirty 比例超过 50% 才清理七、生产环境存储优化
7.1 磁盘规划
| 场景 | 磁盘类型 | RAID | 文件系统 | 挂载选项 |
|---|---|---|---|---|
| 低延迟交易 | NVMe SSD | 无(JBOD) | XFS | noatime,nodiratime |
| 大数据管道 | SATA HDD | RAID-10 | XFS | noatime,nodiratime |
| 混合负载 | SSD+HDD | JBOD | XFS | noatime,nodiratime |
# XFS 文件系统创建和挂载mkfs.xfs -f /dev/sdb1mount -o noatime,nodiratime /dev/sdb1 /var/lib/kafka/data
# /etc/fstab 持久化/dev/sdb1 /var/lib/kafka/data xfs noatime,nodiratime 0 2
# JBOD 配置(多磁盘)# server.propertieslog.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=deletelog.cleaner.enable=true
# 文件描述符# /etc/security/limits.conf# kafka soft nofile 100000# kafka hard nofile 1000007.3 监控指标
| 指标 | 含义 | 告警阈值 |
|---|---|---|
LogFlushRateAndTimeMs | 刷盘频率和耗时 | > 100ms |
BytesInPerSec | 写入速率 | 接近磁盘上限 |
BytesOutPerSec | 读取速率 | 接近网络上限 |
Size | Partition 数据大小 | > 80% 磁盘容量 |
Segments | Segment 数量 | 过多影响性能 |
// 监控磁盘使用// 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八、存储架构对比
| 维度 | Kafka | RabbitMQ | RocketMQ | Pulsar |
|---|---|---|---|---|
| 存储模型 | Append-Only Log | 内存 + 持久化 | CommitLog + ConsumeQueue | BookKeeper Ledger |
| 索引方式 | 稀疏索引 | 无索引(按序消费) | 稠密索引 | Ledger 索引 |
| 缓存策略 | OS Page Cache | Broker 内存 | OS Page Cache | 分层缓存 |
| 零拷贝 | sendfile | 无 | mmap + sendfile | BookKeeper 分层 |
| 压缩 | 生产者端 | 无 | 生产者端 | Broker 端 |
| 清理策略 | Delete/Compact | TTL/Queue 长度 | 定时删除 | Ledger 滚动 |
九、总结
上一章剖析了Kafka 架构与分区机制。
| 维度 | 关键要点 |
|---|---|
| 顺序写入 | Kafka 性能的基石——追加写入避免寻道,比随机写快数千倍 |
| 页缓存 | 利用 OS Page Cache 避免 JVM GC,重启后缓存仍有效 |
| 零拷贝 | sendfile() 跳过用户态拷贝,减少 CPU 开销和上下文切换 |
| 压缩 | 生产者端压缩、Broker 端透传、消费者端解压,减少网络和磁盘 I/O |
| 日志段 | 稀疏索引平衡内存与查找速度,Delete/Compact 两种清理策略 |
| 调优 | 不要过度调优 fsync,依赖副本而非单机刷盘保证持久性 |
Kafka 存储设计的核心哲学是”把操作系统当朋友”——利用 Page Cache 而非自建缓存,利用 sendfile 而非手动拷贝,利用顺序 I/O 而非随机 I/O。理解这些底层原理,才能做出正确的调优决策。
支持与分享
如果这篇文章对你有帮助,欢迎支持作者或分享给更多人
部分信息可能已经过时






