Kafka 为什么能扛住百万级吞吐:零拷贝、页缓存与批量

举报
yd_254019812 发表于 2026/11/05 19:09:31 2026/11/05
【摘要】 一块普通 SSD,单机 3 万 TPS 写、10 万 TPS 读——这不是调优出来的,是架构设计把能省的拷贝全省了。 很多人背得出"Kafka 用零拷贝",但说不出零拷贝在哪一步省了什么、以及什么情况下它会失效。 结论先说 | 机制 | 省掉的是什么 | 收益量级 | |---|---|---| | 顺序追加写 | 磁头寻道 / 随机 IO | 顺序写比随机写快 2~3 个数量级 | | p...

一块普通 SSD,单机 3 万 TPS 写、10 万 TPS 读——这不是调优出来的,是架构设计把能省的拷贝全省了。

很多人背得出"Kafka 用零拷贝",但说不出零拷贝在哪一步省了什么、以及什么情况下它会失效。

结论先说

| 机制 | 省掉的是什么 | 收益量级 |

|---|---|---|

| 顺序追加写 | 磁头寻道 / 随机 IO | 顺序写比随机写快 2~3 个数量级 |

| page cache 优先 | JVM 堆内缓存与 GC | 堆只要几 GB,重启后缓存还在 |

| 零拷贝(sendfile) | 内核→用户态→内核的两次拷贝 | 消费路径 CPU 拷贝从 4 次降到 2 次 |

| 批量 + 压缩 | 网络包数量、磁盘字节数 | 压缩比 3~5 倍,TPS 提升数倍 |

| 分区并行 | 单点顺序瓶颈 | 吞吐随分区数横向扩展 |

反过来说:一旦开启 TLS、一旦需要消息格式转换、一旦跨集群复制,零拷贝就失效了——这是很多"云上 Kafka 吞吐腰斩"的真正原因。


一、存储布局:为什么顺序写就够了

topic: orders
└── partition 0
    ├── 00000000000000000000.log       ← 消息本体
    ├── 00000000000000000000.index     ← 偏移量稀疏索引
    ├── 00000000000000000000.timeindex ← 时间戳索引
    ├── 00000000000000001000.log       ← 下一个 segment(默认 1 GB)
    └── ...

三个设计要点:

1)每个 partition 是一个纯追加的日志。

写永远发生在文件末尾,不存在随机写。这是 Kafka 高吞吐的第一块基石——顺序 1 MB 写的磁盘带宽,比 4 KB 随机写高两个数量级。

2)segment 分段 + 稀疏索引。

.index 不是每条消息都记,而是每写入 4 KB(index.interval.bytes)记录一条"相对偏移量 → 物理位置"。查找时先二分 index 定位到大概位置,再从那个位置顺序扫几十条,命中目标。

十万条消息的索引文件可能只有几百 KB,完全能放进 page cache。

3)旧的 segment 直接删文件。

过期清理不是逐条删除(那会产生大量随机写),而是整个文件 unlink——O(1),瞬间完成。这是日志型存储相对 B+ 树的巨大优势。


二、page cache:Kafka 不用堆缓存消息

这是 Kafka 最反直觉的设计:消息不放在 JVM 堆里,全靠操作系统 page cache。

| 方案 | 优点 | 缺点 |

|---|---|---|

| 堆内缓存(如 RocketMQ 部分场景) | 可控、可预测 | 大堆 → GC 停顿;重启后缓存全丢 |

| page cache(Kafka) | 零 GC 压力、重启后缓存仍在(OS 不重启)、读写共享同一份缓存 | 受 OS 回收策略影响,不可精确控制 |

好处是连锁的:

  1. JVM 堆可以很小(6~8 GB 足够),GC 停顿从几百毫秒降到几毫秒;
  2. 生产者写入的页,消费者立刻能命中——热数据天然在缓存里;
  3. 重启 Kafka 进程,page cache 还在,不必"冷启动预热"。

代价是你要给 OS 留足内存。如果容器 limit 设成 8 GB 而 Kafka 堆就占了 6 GB,page cache 只剩 2 GB,吞吐会崩。

经验值:Kafka 容器内存 = 堆(4~6 GB)+ page cache(剩余全部)。K8s 里常见错误是 resources.limits.memory 给 4 GB,堆给 4 GB——page cache 直接为零。


三、零拷贝:省掉的是两次 CPU 拷贝

3.1 传统读文件发网络的路径

① DMA:  磁盘 → 内核缓冲区(page cache)
② CPU:  内核缓冲区 → 用户缓冲区          ← 应用 read()
③ CPU:  用户缓冲区 → socket 缓冲区        ← 应用 write()
④ DMA:  socket 缓冲区 → 网卡

4 次拷贝、4 次用户态/内核态切换,其中 ②③ 是纯 CPU 参与的内存拷贝。发一个 1 MB 的消息,CPU 要搬 2 MB。

3.2 sendfile 的路径

① DMA:  磁盘 → page cache
② CPU:  page cache → socket 缓冲区(附带长度/偏移,不搬数据)
③ DMA:  socket 缓冲区 → 网卡(scatter-gather DMA)

Linux 2.4+ 的 sendfile 配合网卡的 scatter-gather DMA,真正需要 CPU 搬运的次数是 0(所谓"两次拷贝"指的是 DMA 拷贝,不消耗 CPU)。

Java 的入口是:

// FileChannel.transferTo 底层就是 sendfile
fileChannel.transferTo(position, count, socketChannel);

Kafka 的 FileRecords.writeTo() 最终调用的就是它。

3.3 省了多少

| 路径 | CPU 拷贝次数 | 上下文切换 |

|---|---|---|

| 传统 read + write | 2 | 4 |

| sendfile | 0 | 2 |

在 10 Gbps 网卡上跑满带宽时,这个差别就是"CPU 30%"和"CPU 90%"的差别。


四、零拷贝什么时候失效(重点)

这是本文最该记住的一节。

| 场景 | 为什么失效 | 后果 |

|---|---|---|

| 开启 TLS/SSL | 数据必须在内核外加密,sendfile 无法参与 | 退化为用户态拷贝 + 加解密,吞吐掉 30%~50% |

| 消息格式转换(v0/v1 → v2) | Broker 需要重新编码消息 | 必须读进用户态 |

| 日志压缩/重压缩(compression.type 不一致) | Broker 要解压再压 | 同上 |

| 跨集群复制(MirrorMaker) | 消费再生产,走两遍完整路径 | 双倍开销 |

| 消费端设置 fetch.min.bytes 很小 | 每次只取几百字节,零拷贝的摊销优势消失 | 吞吐低 |

所以:

Kafka 的高吞吐是"零拷贝 + 批量"共同作用的结果。任何一个被打破,另一个也会跟着失效。

云厂商托管的 Kafka 默认强制 TLS,你在本地测出 100 万 TPS,上云后只有 30 万——先怀疑这个。


五、批量与压缩:把"次数"降下来

顺序写和零拷贝解决的是"单次成本",批量解决的是"次数"。

5.1 生产者端

# 攒够 16 KB 或等 5 ms 就发
batch.size=16384
linger.ms=5

# 压缩:lz4 速度快,zstd 压缩比高
compression.type=lz4

# 单次请求最多攒多少(默认 16 MB,0.11 前是 1 MB)
max.request.size=1048576
buffer.memory=33554432

linger.ms 是吞吐与延迟的唯一旋钮:

| linger.ms | 行为 | 适用 |

|---|---|---|

| 0(默认) | 有多少发多少 | 极低延迟,吞吐差 |

| 5~20 | 攒小批 | 大多数场景的最佳点 |

| 100+ | 大批 | 离线同步、日志采集 |

把 linger.ms 从 0 改到 5,吞吐往往能翻 3~5 倍,而 P99 延迟只增加个位数毫秒。

5.2 压缩发生在哪

Producer 压缩 → Broker 原样存(不解压)→ Consumer 解压

Broker 不参与压缩/解压(除非 compression.type 与生产端不一致,被迫重压)。这就是为什么压缩能同时省磁盘、省网络、省 Broker CPU。

| 算法 | 压缩比 | CPU | 建议 |

|---|---|---|---|

| none | 1x | 0 | 不推荐 |

| lz4 | 3x | 低 | 默认首选 |

| zstd | 4~6x | 中 | 磁盘紧张、跨机房传输 |

| gzip | 5x | 高 | 已不推荐 |

5.3 消费端

fetch.min.bytes=1048576     # 一次至少给我 1 MB,别挤牙膏
fetch.max.wait.ms=500       # 最多等 500 ms
max.partition.fetch.bytes=1048576

fetch.min.bytes 设得太小(默认 1 字节),Broker 会"有多少给多少",零拷贝的摊销全没了,消费者还会陷入大量小请求。


六、网络层:三组线程的分工

   ┌──────────┐   新连接    ┌────────────┐   请求    ┌──────────────────┐
   │ Acceptor │ ─────────→ │ Processor  │ ───────→ │ RequestHandler   │
   │  (1 个)  │            │  (N 个)    │          │ Pool (M 个线程)  │
   └──────────┘            └────────────┘          └──────────────────┘
                                  ↑                        │
                                  └──── 响应队列 ←──────────┘

| 角色 | 数量 | 职责 |

|---|---|---|

| Acceptor | 1(每 listener) | 只做 accept() |

| Processor(网络线程) | num.network.threads,默认 3 | 读写 socket、组装请求、零拷贝发送响应 |

| RequestHandler(业务线程) | num.io.threads,默认 8 | 处理 produce/fetch,读写日志文件 |

关键点:零拷贝发生在 Processor 线程——它拿到 FileRecords 后直接 transferTo,完全绕过 RequestHandler。这也意味着 Processor 线程数不需要很多,因为它的工作几乎全是 IO 等待。

调优建议:

num.network.threads=6        # 中等规模够用,别超过 CPU 核数
num.io.threads=16            # 磁盘多、分区多时提高
queued.max.requests=500      # 请求队列上限,打满说明 io 线程不够

kafka.network:type=RequestChannel,name=RequestQueueSize 持续高位 = io 线程不够。


七、副本同步也走零拷贝

follower 拉取 leader 数据的路径:

Follower 发 FetchRequest
   → Leader 的 Processor 线程
   → 从本地 segment 读取(命中 page cache 或磁盘)
   → transferTo → 网络 → Follower
   → Follower 顺序追加写自己的 .log

leader 的读和 follower 的读共用同一份 page cache,所以副本数从 1 增加到 3,leader 的磁盘读并不会涨 3 倍。这是 Kafka 敢在普通机器上做三副本的底气。

但也带来一个问题:如果 follower 落后太多(超出 page cache 范围),leader 就要从磁盘读,那台机器的 IO 会突然飙高,进而影响所有 follower。所以 replica.lag.time.max.ms 和 num.replica.fetchers 要配好:

num.replica.fetchers=4          # 加大并行度,避免单 fetcher 追不上
replica.fetch.max.bytes=1048576
replica.lag.time.max.ms=30000

八、性能参数总表

| 参数 | 默认 | 建议 | 影响 |

|---|---|---|---|

| log.segment.bytes | 1 GB | 1 GB | 太小 → 文件多、频繁滚动;太大 → 清理粒度粗 |

| log.retention.hours | 168 | 按业务 | 保留时长决定磁盘需求 |

| index.interval.bytes | 4096 | 4096 | 索引密度,影响查找 |

| num.network.threads | 3 | 6~8 | 网络处理 |

| num.io.threads | 8 | 16~32 | 磁盘处理 |

| num.replica.fetchers | 1 | 4 | 副本同步并行度 |

| socket.send.buffer.bytes | 100 KB | 1 MB | 高带宽网络必调 |

| socket.receive.buffer.bytes | 100 KB | 1 MB | 同上 |

| log.flush.interval.messages | Long.MAX | 不要改 | 靠 OS 刷盘 + 副本冗余,别自己 fsync |

最后一条特别说明:Kafka 不靠 fsync 保证可靠性,靠副本。 把 log.flush.interval.messages 调小会让性能断崖下跌,而可靠性提升有限——正确做法是 acks=all + min.insync.replicas=2。


九、排障:吞吐掉了先看这五处

| 现象 | 排查点 | 命令 |

|---|---|---|

| 写入慢但磁盘不忙 | 是否开了 TLS;linger.ms 是否 0 | 看 producer 指标 batch-size-avg |

| 消费慢、磁盘读高 | page cache 不足(堆给太大?) | free -h、iostat -x 1 |

| 抖动、P99 飙高 | 是否在滚动 segment;GC | kafka-log-dirs.sh、GC 日志 |

| ISR 频繁收缩 | num.replica.fetchers 太小 / 网络带宽不够 | kafka-topics.sh --describe |

| CPU 高但吞吐低 | 压缩算法太重(gzip);或 Broker 在做重压 | 看 compression.type 是否一致 |

一个常见误区:给 Kafka 加 JVM 堆。堆越大,GC 停顿越长(G1 在 32 GB 以上要格外小心),而 Kafka 根本不需要大堆——要加的是 page cache,也就是给 OS 留内存。


十、小结

Kafka 的性能来自四个"省":

  1. 省寻道——顺序追加,只有尾部写;
  2. 省 GC——消息不进堆,靠 page cache;
  3. 省 CPU 拷贝——sendfile 让数据不进用户态;
  4. 省次数——批量 + 压缩,把 N 次小 IO 变 1 次大 IO。

而它的软肋同样明确:TLS、格式转换、跨集群复制会破坏第 3 条,堆给太大或容器内存限制会破坏第 2 条。理解了这层因果关系,调参就不再是背参数,而是"看哪一条被破坏了"。


系列内链

  • 前置:[07-消息队列/01-Kafka架构与副本机制](01-Kafka架构与副本机制.md)
  • 关联:[07-消息队列/02-Kafka消息丢失与重复消费](02-Kafka消息丢失与重复消费.md)——acks 与 min.insync.replicas 的可靠性权衡
  • 关联:[07-消息队列/06-消息积压的处理方案](06-消息积压的处理方案.md)——积压时的扩容顺序
  • 关联:[09-Linux基础/..](../09-Linux基础/01-文件系统与目录结构.md)——page cache 与脏页回写
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。