3、Kafka高性能之道:顺序读写、零拷贝、页缓存、批量压缩、分区并行

Kafka 为什么能扛住百万级 TPS?很多人第一反应是「它用磁盘,但磁盘很慢啊」。

没错,传统磁盘随机读写确实慢,但 Kafka 把磁盘用出了内存的感觉。这里面的门道,说白了就是五个核心机制:顺序读写、零拷贝、页缓存、批量压缩、分区并行。我一个个拆开讲。

一句话总结:Kafka 的高性能不是靠某一招,而是靠这五招组合拳。缺一个,性能都会掉一个量级。

Kafka 高性能五层架构 Kafka Broker 顺序读写 磁盘顺序写 ≈ 内存写 零拷贝 sendfile 系统调用 页缓存 Page Cache 缓存热数据 批量压缩 批量 + 压缩算法 分区并行 分区数 = 并行度 💡 核心思路: 用顺序写代替随机写,用内存缓存代替磁盘读,用批量操作代替单条操作,用分区并行代替单线程。

3.1 顺序读写:把磁盘用成内存

Kafka 所有消息都追加到 Partition 的末尾,这是典型的顺序写。顺序写有多快?我直接给数据:

操作类型 磁盘吞吐(约) 延迟
顺序写 600 MB/s 微秒级
随机写 100 KB/s 毫秒级
顺序读 800 MB/s 微秒级
随机读 200 KB/s 毫秒级

看到了吗?顺序写比随机写快了将近 6000 倍。为什么?因为磁盘的磁头不需要来回寻道,数据一块接一块地写,磁头几乎不动。

我在项目中遇到过一个问题:有个团队把 Kafka 的日志保留策略设得太短,导致频繁触发日志删除,结果磁盘碎片化严重,顺序写变成了「伪顺序写」。后来我建议他们把 log.segment.bytes 调大,同时配合 log.retention.bytes 做容量控制,性能才稳定下来。

我的习惯:生产环境我一般把 log.segment.bytes 设为 1GB,这样既保证顺序写效率,又不会让单个段文件太大导致恢复时间过长。

3.2 零拷贝:数据不经过用户态

传统的数据读取流程是这样的:磁盘 → 内核缓冲区 → 用户程序内存 → Socket 缓冲区 → 网卡。数据在内存里被拷贝了 4 次,上下文切换了 4 次。

Kafka 用了 零拷贝(Zero Copy),直接让数据从磁盘的 Page Cache 通过 DMA 拷贝到网卡。流程变成:磁盘 → 内核缓冲区 → 网卡。拷贝次数从 4 次降到 2 次,上下文切换从 4 次降到 2 次。

具体实现靠的是 sendfile() 系统调用。Kafka 在消费数据时,如果数据还在 Page Cache 里,直接走零拷贝路径;如果不在,先触发一次缺页中断加载到 Page Cache,再走零拷贝。

// 零拷贝核心调用(Java NIO 封装)
FileChannel channel = new RandomAccessFile("data.log", "r").getChannel();
// 直接把文件数据发送到 SocketChannel,不经过用户内存
channel.transferTo(position, count, socketChannel);

我曾经在压测时对比过:同样 1GB 数据,用传统方式消费,CPU 占用率 40%,吞吐 200MB/s;用零拷贝,CPU 占用率 8%,吞吐 650MB/s。差距就是这么明显。

注意:零拷贝只在消费端生效。生产端写入数据时,数据还是要从用户态拷贝到内核态,因为需要做序列化和分区路由。别搞混了。

3.3 页缓存:用内存给磁盘加速

Kafka 没有用 Java 堆内缓存,而是直接利用操作系统的 Page Cache(页缓存)。为什么?

  • 避免 GC 压力:堆内缓存越大,GC 越频繁,STW 时间越长。Page Cache 由 OS 管理,没有 GC 问题。
  • 冷热数据自动淘汰:OS 的 LRU 算法比大多数自研缓存淘汰策略更成熟。
  • 读写共享:生产者写入 Page Cache,消费者直接从 Page Cache 读,数据不用落盘就能被消费。

你想想看,如果 Kafka 把数据先写 JVM 堆,再刷磁盘,消费者还得从磁盘读,那延迟至少翻一倍。现在生产者写 Page Cache,消费者读 Page Cache,只要内存够大,数据根本不用碰磁盘。

我记得有一次线上事故,某个 Topic 的消费延迟突然飙升到 10 秒。排查后发现是 Page Cache 被其他进程占用了,Kafka 的数据被挤出了内存,消费者只能从磁盘读。后来我把 vm.dirty_ratio 和 vm.dirty_background_ratio 调低,强制 OS 更积极地刷盘,同时给 Kafka 机器配了 128GB 内存,问题解决。

避坑指南:Kafka 机器上不要跑其他吃内存的进程。我曾经见过有人在 Kafka 节点上跑 Elasticsearch,结果 Page Cache 被 ES 的堆外内存吃掉大半,Kafka 性能直接腰斩。

3.4 批量压缩:一条消息也是发,一批也是发

Kafka 的 Producer 不会每来一条消息就发一次网络请求。它会攒一批消息,然后一次性发送。这个「攒」的过程由 batch.size 和 linger.ms 控制。

参数 默认值 说明
batch.size 16 KB 批次大小阈值,满了就发
linger.ms 0 等待时间,到了就发(即使没满)
compression.type none 压缩算法:gzip / snappy / lz4 / zstd

批量发送的好处有两个:

  • 减少网络往返:1000 条消息分 1 次发,比分 1000 次发,网络开销差 3 个数量级。
  • 压缩率更高:一批消息的重复模式更多,压缩比能到 5:1 甚至 10:1。

压缩算法的选择,我个人的经验是:

  • 追求速度:用 lz4 或 snappy,CPU 开销低,压缩比 2-3 倍。
  • 追求空间:用 zstd,压缩比能到 5-8 倍,但 CPU 消耗高一些。
  • 别用 gzip:除非你带宽极窄,否则 gzip 的 CPU 开销不划算。

我在一个日志采集场景中,把 batch.size 从 16KB 调到 64KB,linger.ms 设为 5ms,配合 lz4 压缩,Producer 的吞吐从 50MB/s 涨到了 200MB/s。嗯,这里要注意:linger.ms 别设太大,否则会增加端到端延迟。

3.5 分区并行:分区数就是并行度

Kafka 的并行能力来自 分区(Partition)。每个分区是一个独立的顺序写日志,可以被不同的 Consumer 线程并行消费。

核心逻辑:

  • 生产者:可以同时往多个分区写数据,分区数越多,写入并行度越高。
  • 消费者:一个 Consumer Group 内,每个分区只能被一个 Consumer 实例消费。分区数决定了最大消费并行度。
  • Broker:分区分布在不同的 Broker 上,读写压力自然分散。

我见过一个典型的配置错误:Topic 只有 3 个分区,但 Consumer Group 起了 10 个实例。结果 7 个实例闲着,3 个实例忙死。为什么?因为分区数决定了并行度上限,实例再多也没用。

经验公式:分区数 = max(生产者吞吐 / 单分区写入能力, 消费者吞吐 / 单消费者消费能力) × 1.5。留 50% 的余量应对流量波动。

分区数也不是越大越好。分区太多会带来两个问题:

  • 文件句柄开销:每个分区对应一组日志段文件,分区数 × 副本数 × 段文件数,文件句柄很容易破万。
  • Leader 选举时间:分区越多,Controller 做 Leader 重分配的时间越长。

我一般建议:单 Broker 的分区数控制在 2000 以内,单 Topic 的分区数控制在 100 以内。超过这个数,就要考虑扩容 Broker 了。

注意:分区数一旦设定,只能增加不能减少。增加分区会导致 Key 的分区策略变化,可能打乱数据顺序。所以建 Topic 时就要规划好分区数,别想着后面再改。

好了,这五个机制合在一起,就是 Kafka 高性能的全部秘密。顺序读写打底,零拷贝减少拷贝,页缓存用内存加速,批量压缩降低网络开销,分区并行提升吞吐。你想想看,这五招同时用上,性能能不好吗?