先说结论再解释:用 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 把一个日志文件发给消费者:
- 内核把文件读进 page cache:DMA,不占 CPU
- 应用把数据读进自己的缓冲区:一次 CPU 拷贝
- 应用把数据写进 socket 缓冲区:再一次 CPU 拷贝
- 网卡把 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 就够:
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 把完整调用链写出来了:
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 是另一条路:
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 跨进那段尾部。抓到的系统调用是:
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 预算,而不是靠猜。
来源
- Eleftheria Drosopoulou, "Zero-Copy and the Page Cache: Why Kafka Is Fast, Using FileChannel.transferTo", Java Code Geeks, 2026-11-05 — www.javacodegeeks.com/2026/11/zer…
- KAFKA-13799, "Improve documentation for Kafka zero-copy"(明文层与 SSL 层的分叉)— issues.apache.org/jira/browse…
- KAFKA-16304 PR #23445(Fetch 零拷贝路径上磁盘故障与网络断连的区分)— github.com/apache/kafk…
- Confluent, Kafka Design — Efficient Data Transfer(page cache 与 sendfile)— docs.confluent.io/kafka/desig…
- Arpit Bhayani, zero-copy writes(4 次拷贝与 2 次拷贝的计数)— arpitbhayani.me/notes/zero-…
sendfile(2)man page — man7.org/linux/man-p…FileChannel.transferToAPI 文档(可能不搬完所有字节)— docs.oracle.com/en/java/jav…