Kafka 发 1 GiB 数据,用户态 CPU 只花 0.016 秒,总耗时反而慢了 11%

先说结论再解释:用 FileChannel.transferTo 把 1 GiB 缓存文件发出去,用户态 CPU 从 0.093 秒掉到 0.016 秒,降到六分之一。但墙钟时间从 0.60 秒变成 0.67 秒,慢了 11%。

零拷贝没有失效。这个测试环境里没有网卡。

Java Code Geeks 11 月 5 日那篇实测文章把这个矛盾原样贴出来了,作者自己也写:a result that surprised me。这个「意外」比任何一张性能对比表都有信息量,因为它标出了零拷贝的收益边界在哪。

一条消息从磁盘到网卡,中间被搬了几次

先回到最朴素的路径。broker 把一个日志文件发给消费者:

  1. 内核把文件读进 page cache:DMA,不占 CPU
  2. 应用把数据读进自己的缓冲区:一次 CPU 拷贝
  3. 应用把数据写进 socket 缓冲区:再一次 CPU 拷贝
  4. 网卡把 socket 缓冲区的数据发出去:DMA

Arpit Bhayani 的笔记数下来是 4 次拷贝;走 sendfile 是 2 次。

4 次里,第 2 和第 3 次是纯粹的浪费:数据从磁盘来,要去网卡,中间被搬到用户态转了一圈,而程序一眼都没看过它。

sendfile 干的事就是把这两次省掉:数据留在内核里,从 page cache 直接到网卡,靠 scatter-gather DMA 拼出去。

所以「零拷贝」这个叫法有误导性。它省的是 CPU 拷贝,不是拷贝本身。磁盘到内存那一次永远在,内存到网卡那一次也在,只是不再经过 CPU。

还有一笔常被漏掉的成本:上下文切换。传统 read 加 write 是两次系统调用,用户态和内核态来回四次。sendfile 一次调用,来回两次。数据量大的时候,切换本身的开销不比拷贝小。

page cache:Kafka 为什么不自己缓存

page cache 是内核拿空闲内存存最近读过的文件数据的那块地方。Kafka 直接用操作系统的,不自己建一套。

Confluent 的设计文档说得很直白:page cache 加 sendfile,跟得上进度的消费者直接从内存拿数据,磁盘几乎看不到读操作。

那个实验给了个量化的感觉。读一个 1 GiB 文件:

  • 先丢掉 page cache,冷读:0.93 秒
  • 紧接着再读两次:0.42 秒左右
  • 隔一会儿热读:0.18 秒、0.18 秒、0.26 秒

冷读和热读差大约两倍,一行缓存代码都没写。

Kafka 的日志结构刚好是 page cache 最喜欢的形状:不可变的分段文件、只追加、顺序写。读到最近的数据基本都在内存里。

这里有个设计上的取舍值得说清。生产端把消息攒成批、压缩、再整段写进日志段,消费端取的时候也是整段取。所以 broker 手里握着的是一个连续的字节区间,而不是一堆零散对象。连续区间才能交给 transferTo,零散对象只能一个个搬。

还有一层容易忽略的配合:压缩。Kafka 的压缩发生在生产端,broker 存的是压缩后的字节段。消费端不解压,broker 直接把压缩段发出去------这让零拷贝成立。如果 broker 需要解压再转发(比如做消息转换或降级兼容),字节就必须进用户态,transferTo 那条路立刻断掉。所以「零拷贝」不是 broker 的默认属性,它是「不做消息转换」这个约束换来的。

如果 Kafka 自己在 JVM 堆里缓存,会付三笔账:GC 压力、同一份数据在 page cache 和堆里存两遍、进程重启后缓存全丢。把缓存交给内核,这三笔都不用付。代价是缓存不受你控制------别的进程也能占用 page cache,内存紧张时内核会先丢它。

Java 里怎么用,以及一个必须套的循环

sendfile 在 Java 里的入口是 FileChannel.transferTo。最小可运行版本,JDK 17 就够:

java 复制代码
try (FileChannel in = FileChannel.open(src);
     FileChannel out = FileChannel.open(dst, StandardOpenOption.WRITE)) {
  long pos = 0, size = in.size();
  while (pos < size) {
    pos += in.transferTo(pos, size - pos, out);
  }
}

while 不能省。FileChannel 的文档明确写了:一次调用可能不会搬完所有请求的字节。作者在 JDK 17、21、25 上都跑过,都打印 copied 8388608 bytes,但那是 8 MiB 的小文件,一次就搬完了。真实场景下传输大文件,单次调用只搬一部分是常态。

这个坑有两个变体。一是不套循环,只搬了一半就以为完事;二是没判返回值,transferTo 返回 0 的时候循环退不出去。两种都会让你在线上排查半天。

三组实测,和一栏最容易被看错的数据

三种方式发同一个 1 GiB 已缓存文件,走 loopback socket。每项是两轮、每轮十次传输的平均值。

方式 墙钟/GiB 用户态 CPU 内核态 CPU
Stream + 堆数组 0.60 s 0.093 s 0.50 s
Channel + 直接缓冲区 0.60 s 0.030 s 0.56 s
transferTo 0.67 s 0.016 s 0.64 s

用户态那一栏严格符合预期:0.016 秒,是堆数组版本的六分之一。程序不再碰这些字节了。

内核态那一栏反着走:0.50 秒涨到 0.64 秒。省下来的用户态时间,一部分被内核吃掉了。

墙钟时间没有改善,反而多了 0.07 秒。

这个结果很容易被拿来当「零拷贝没用」的证据,那就读反了。loopback 上没有网卡、没有真实 DMA、收发在同一个进程还共用一个 CPU。零拷贝省的是「CPU 搬运字节」这件事,而这个环境里搬运的成本被内核自身的调度开销盖过去了。

中间那行也值得看一眼。直接缓冲区(direct buffer)的用户态 CPU 是 0.030 秒,堆数组是 0.093 秒,差三倍。原因一样:堆数组要先从堆里拷到临时的直接缓冲区才能交给内核,直接缓冲区省掉那一跳。它比 transferTo 多一次内核到用户态的拷贝,所以还是不如零拷贝干净。

作者的态度是诚实的:他没 profile 内核,所以说不清为什么慢 11%,只留了一句结论------去真实网络上量,别在 loopback 上许诺加速。

机制:Kafka 里这条路到底长什么样

上面是 Java 层面的通用机制。Kafka 的 Fetch 响应具体怎么走,2026 年 9 月的一个 PR 把完整调用链写出来了:

bash 复制代码
Selector.pollSelectionKeys
  → KafkaChannel.write
  → NetworkSend.writeTo
  → MultiRecordsSend
  → DefaultRecordsSend
  → FileRecords.writeTo
  → {Plaintext, Ssl}TransportLayer.transferFrom
  → FileChannel.transferTo

关键分叉在最后两行。FileRecords.writeTo 把 FileChannel、偏移量和长度直接交给 TransportLayer,不经过任何 ByteBuffer,数据不进 JVM 堆。接下来看是哪个实现:

PlaintextTransportLayer.transferFrom 直接委托给 FileChannel.transferTo,底层就是 sendfile。

SslTransportLayer.transferFrom 是另一条路:

java 复制代码
fileChannel.read(fileChannelBuffer, pos)
sslEngine.wrap(src, netWriteBuffer)
socketChannel.write(buf)

先读进缓冲区,再加密,再写进 socket。整个过程一次 transferTo 都没调。

这件事 2022 年就有人指出过。KAFKA-13799 的标题就叫「改进 Kafka 零拷贝的文档」,正文写得很清楚:明文层用了 transferTo,SSL 层没有;两层都用 page cache,但 SSL 层不实现零拷贝。这个 issue 在 3.3.0 修进文档。

所以「Kafka 用零拷贝」这句话有个前提:明文监听器。开了 TLS,page cache 还在,零拷贝没了。

加密必须在用户态做,这是 sendfile 的硬限制,不是 Kafka 的疏忽。要加密就得让 CPU 看过每一个字节,那两次拷贝就躲不掉。

这件事对容量规划有直接影响。一个全 mTLS 的集群,broker 的 CPU 消耗跟「字节数」成正比,而不是跟「请求数」成正比。加大 socket 缓冲区、调大 replica.fetch.max.bytes 这些常规手段,省的是系统调用次数,省不掉加密那部分。

一个 2026 年的补丁,说明这条路径有多脆

同一条调用链上还有个更麻烦的问题。

KAFKA-16304 的描述:Fetch 响应走零拷贝,读日志的动作发生在网络线程里。磁盘坏了和客户端跑了,在这条链上都会冒出一个裸的 java.io.IOException,Selector 一律按「连接断开」处理,记在 DEBUG 级别。

后果很具体:一个日志文件的尾部局部不可读,这个分区就读不出来了,而集群仍然认为 broker 是健康的。

复现用的是 4.3.1 发行版,单节点 KRaft,两个日志目录,其中一个在 dm-dust 设备上,把某个分段的尾部标成不可读,然后发一个大 Fetch 跨进那段尾部。抓到的系统调用是:

bash 复制代码
sys_sendfile64(out_fd: 0xb3, in_fd: 0xaf, offset: 0xc8ffdea8, count: 0xd737a)
sys_sendfile64 -> 0xfffffffffffffffb        // -EIO

修法是在 FileRecords 里重读一次出错的区间:还读得出来,说明是 socket 的问题,原异常照抛;读不出来,抛 KafkaStorageException,带上文件名和位置。位置放在 FileRecords 而不是两个 transport 层,是因为只有那里同时知道文件名和偏移。

修前修后的对比,同一场景跑两遍:

指标 修前 修后
sendfile64 返回 -EIO 162 168
日志里的 KafkaStorageException 0 168
DEBUG ... disconnected 170 8
指名到不可读文件的日志行 3 171

162 次「网络断开」里,162 次其实是盘故障挂着网络的标签。

这条补丁跟性能无关,但它是零拷贝代价的最好注脚:数据不进用户态,错误也就没有机会在用户态被识别和分类。

一个常被误读的前提

「消费者从 page cache 拿数据」有个前提:消费者跟得上。

消费滞后几小时、要读老数据的时候,page cache 里没有那些段,broker 得从磁盘读。这时零拷贝还在(数据仍然不经过用户态),但 page cache 那一层没了,瓶颈从 CPU 变成 IO。所以两组数字不能混着用:0.016 秒是缓存命中的用户态开销,冷读的 0.93 秒是另一回事。

看命中情况不用装工具,iostat -x 里磁盘读速率接近 0 而网卡在跑,就是全在吃缓存。反过来说,如果磁盘读一直很高,先查消费者滞后,别急着加 broker。

对谁有影响

跑 mTLS 集群的运维。 broker 的用户态 CPU 会比按流量估算的高一截,因为加密把所有字节拉回了用户态。同一条 10 Gbps 的链路,明文和 TLS 的 CPU 账单不是一个量级。

做跨机房复制的架构师。 跨域复制基本都走 TLS。零拷贝那条路径省不下来,带宽成本要按 CPU 核数算,不能只按网卡算。云上更直观:出网流量按 GB 计费,CPU 按核时计费,零拷贝省掉的是后者,而后者在你开 TLS 的那天就还回去了。

写 Java 的。 transferTo 只适合「字节不需要看」的场景:发文件、发日志段、做代理转发。要改数据、要压缩、要加密、要算校验和,全得回到用户态,别指望零拷贝。

做压测的。 别在 loopback 上验证零拷贝收益。没有网卡 offload,省下的用户态时间会被内核开销吃掉,结论会反过来。

读源码的。 想确认自己集群走的是哪条路,直接看 TransportLayer 的实现类就行。开了 ssl.keystore.location,就是 SSL 那条。

边界:三个数字都不能当承诺

一台机器、一个 CPU、loopback、收发在同一个进程。没有 NIC offload,没有真实 DMA。

作者没 profile 内核,「为什么慢 11%」没有定论。可能是调度开销或缓冲区大小,也可能只是测量噪声。

0.016 秒是用户态 CPU,不是总成本。把用户态和内核态加起来,transferTo 反而更高。

磁盘那一组也别当基准。作者用的是虚拟磁盘,宿主机可能也在缓存,真实机械盘或网络盘的冷读会比 0.93 秒差得多。

最后一条:这里的 1 GiB 是单个大文件。Kafka 的真实负载是几百万个中小消息混在一起,每个 Fetch 请求搬的是几段不连续的日志区间。transferTo 能省的那两次拷贝还在,但每次调用的固定开销会被放大。这一点文章没测。

收尾:今天能验证的一件事

在开了 TLS 的 broker 上挂一个 bpftrace,数 sys_sendfile64 的调用次数。

如果接近 0,说明零拷贝路径确实一次都没走,你看到的吞吐全是用户态拷贝撑起来的。同一套脚本挂到明文端口上,应该能看到稳定调用。两个数一对比,就能判断该不该为 TLS 多留 CPU 预算,而不是靠猜。

来源

相关推荐
shenlongproxy12 分钟前
代理IP故障演练怎么做:注入怎么分档、恢复怎么判定
后端
wt952712 分钟前
VMware 虚拟机磁盘扩容实战文档(Ubuntu 22.04 + LVM)
linux·后端
王中阳Go13 分钟前
预算给够了,时间花错了地方:三跳链路给了 27 秒,实际只用掉 9 秒
人工智能·后端·性能优化
用户3949950846318 分钟前
文件变了,inotify 为什么没反应
后端
陈随易23 分钟前
神级 Skill,让 AI 用 HTML 代替 Markdown 回复
前端·后端·程序员
Gopher_HBo23 分钟前
go tool trace 使用指南(Go 1.24)
后端
一条Go24 分钟前
能力套餐:菜单、权限、开通范围怎么拆成三层而不打架
后端·github·saas
dpharness24 分钟前
我给 DSH 装记忆插件,踩了五个坑,其中一个让我丢了一条记录
后端
就像楞次定律24 分钟前
【Linux 学习记之二】C++ 构建体系与底层链接机制的深度透视(2)
linux·后端