AI 资讯 知识库 后端技术操作系统内功 · 第 6 篇|零拷贝:sendfile 与 Kafka 高吞吐的底层密码

操作系统内功 · 第 6 篇|零拷贝:sendfile 与 Kafka 高吞吐的底层密码

Kafka 的官方设计文档里藏着一句话:消费速率可以逼近网卡极限,从缓存服务时磁盘完全没有读活动。一台普通服务器、一份几百 GB 的日志,凭什么能做到这一点?答案不是更快的盘,而是减少数据搬动——数据在内核与用户态之间每多搬一次,带宽就折损一次、CPU 就多烧一度。「零拷贝(zero copy)」不是零次内存写,而是消除 CPU 参与的数据搬运。本篇把一条文件发送路径的完整拷贝账单算清楚,看 sendfile 如何把 4 次拷贝压到 2 次 DMA,以及 Kafka、nginx 是怎么把它用在刀刃上的。

一、传统路径:一次文件发送的完整账单

把磁盘上的文件发给网卡,最朴素的写法是 read() 加 write()。系统调用的账单是这样的:read 把数据从磁盘经 DMA 拷进内核的页缓存(第 1 次拷贝),再从页缓存拷到用户缓冲区(第 2 次,CPU 搬);write 把数据从用户缓冲区拷进内核 socket 发送缓冲(第 3 次,CPU 搬),最后 DMA 拷到网卡协议引擎(第 4 次)。两次系统调用、四次拷贝——其中 CPU 亲自搬了两次,每次都要把数据完整读进寄存器再写出去,同时挤占缓存、打断流水线。Kafka 设计文档把这笔账概括为「four copies and two system calls」,IBM 二十年前的经典文章补上了另一半:还有四次用户态与内核态之间的上下文切换。数据明明只是「路过」应用,却让应用全程搬砖。把隐性的账也算上:四次上下文切换是四次进出内核的固定开销,上一篇算过 TLB 失守时这笔还要翻几番;CPU 拷贝还有缓存税——几百 MB 数据流经用户态,会把缓存里真正有用的热数据全部冲掉,回来后一切重新预热。这就是 cache pollution,它解释了为什么「单次拷贝很快」依然不能白拷。

graph LR
  D[磁盘] -->|1 DMA| P[页缓存]
  P -->|2 CPU| U[用户缓冲区]
  U -->|3 CPU| S[socket 发送缓冲]
  S -->|4 DMA| N[网卡]
  style U fill:#fee
  style P fill:#dfd

看清这份账单就看清了优化的全部空间:第 2、3 次拷贝是 CPU 干的,而数据从用户态「借道」一遭什么价值都没产生——应用没有改它,只是转手。要快,就把这两次「无价值搬运」从账单上划掉。

二、sendfile:把搬运搬进内核

Linux 2.2 给出了直接解法:sendfile() 一个系统调用完成「文件到 socket」的发送,数据全程留在内核态——用户缓冲区从链路上消失。man 页的表述很直白:拷贝在内核内完成,比 read 加 write 高效。这一步砍掉了两次上下文切换和一次 CPU 拷贝,账单变成三次拷贝:DMA 进页缓存、CPU 从页缓存拷到 socket 缓冲、DMA 出网卡。注意这里有个流传很广的讹误——「sendfile 就是两次拷贝」,准确的说法是:配合 scatter-gather DMA(Linux 2.4 起、网卡支持 gather)才是两档。SG-DMA 让网卡直接从页缓存的散页收集数据,socket 缓冲里只附加一份描述符(数据位置与长度),CPU 拷贝彻底归零:账单定格在两次 DMA、零次 CPU 搬运、零次上下文切换。这才是「零拷贝」的完全体,也是 man 页提醒「传输期间不得修改文件内容」的原因——数据没有副本,改了就乱了。

sendfile 也有边界,man 页写得清楚:入参 fd 必须支持 mmap 类操作(不能是 socket,所以它是「文件发往网络」的专用通道);单次上限约 2GB(0x7ffff000);它不是可移植接口,跨平台要留后路。Java 把它包成了 FileChannel.transferTo(),Linux 上路由到 sendfile——IBM 当年实测比 read 加 write 快约 65%,这个数字让整个 Java 中间件圈开了眼。send 方向还有个兄弟选项 MSG_ZEROCOPY(内核 4.14 起):应用缓冲区不拷贝、以页引用直接挂进发送路径,适合 MB 级大包;代价是页被内核锁定到发送完成、应用必须异步收割——天下没有免费的零拷贝,只是把账单换了张纸。

三、splice 与 mmap:两个变体

零拷贝不是只有 sendfile 一张面孔。splice(Linux 2.6.17)走的是管道抽象:在 fd 与 pipe 之间移动数据,靠页引用而非页拷贝——内核只是把「数据在哪」这个事实在管道里传递,真正复制发生在 pipe 两端按需发生。它比 sendfile 通用(任意 fd 组合,2.6.31 前要求一端是 pipe),代价是要用好它得理解管道缓冲的水位语义,工程上不如 sendfile 顺手。顺带认识它的搭档 vmsplice:反方向,把用户内存以页引用「接进」管道,两者合用能拼出完整的用户态零拷贝管线——不过复杂度高,实践中 sendfile 覆盖了九成需求。mmap + write 则是应用层的省钱方案:把文件映射进地址空间(上一篇的缺页机制直接复用),省掉「页缓存到用户缓冲」的拷贝,但仍有两次系统调用、首次缺页成本,文件被并发截断还会吃 SIGBUS。取舍口诀:sendfile 管转发(不改内容)、mmap 管访问(要读写内容)、splice 管拼接(任意管道组合)——Kafka 的索引文件用 mmap 随机访问、消息文件用 sendfile 转发,同一套日志两副拳法。

四、Kafka:把零拷贝用在刀刃上

Kafka 是把零拷贝上升为架构哲学的典范,它的设计文档把逻辑摊开讲得很透。第一层是依赖 Page Cache 而非自建缓存:JVM 堆内对象开销大、GC 让大缓存变慢,而操作系统对磁盘缓存的优化持续多年且重启后缓存依然温热——32GB 的机器上约有 28–30GB 页缓存可用,还不用付 GC 税。第二层是顺序写:追加式持久日志把随机写变成线性写,官方引用的量级对比是线性写约 600MB/s 对随机写每秒 10 万次——差了三个数量级。第三层才是零拷贝:消费路径用 sendfile,「数据进页缓存恰好一次,之后每次消费直接复用」——生产者写入的页、消费者读走的页、操作系统缓存的页,是同一份内存,生产与消费在页缓存上会师,磁盘读几乎归零。消费者回放积压历史时尤其受益:只要数据还温在页缓存里,回放几 GB 的旧日志也只是内存搬运——Kafka「重放历史快」的口碑一半来自这里。顺带记一个现实约束:SSL 与 sendfile 天然冲突——加密必须在数据出门前改内容,用户态加密意味着数据先要回到应用手里,零拷贝的前提就不存在了。开启 TLS 的集群吞吐掉一截是机理使然而非调优不力,容量预算要按这个口径做。三层叠加起来,Kafka 的吞吐才有了「普通机器顶到网卡极限」的底气。这也是上一篇结尾那句判词的实证:对 Kafka 这类应用,把内存留给 Page Cache 比自建缓存更便宜。

五、nginx 与 TLS 时代的现实约束

nginx 是零拷贝的另一个标杆用户,但默认配置很保守:sendfile 默认 off,开启时官方建议配合 tcp_nopush(TCP_CORK 语义,把响应头与首个数据块合成一个包再发),而启用 directio 的位置会自动禁用 sendfile——小文件走 sendfile 吃缓存、大文件走 directio 绕缓存,两套机制各管一段。为什么保守?因为 sendfile 的收益依赖「数据在页缓存里且不改内容」,应用场景稍有变化就要重算账。

真正的转折是 TLS。sendfile 的前提是「内核态数据原样出门」,而 HTTPS 要求应用层(或至少协议层)加密——加密改了数据,零拷贝的前提碎了,TLS 流量长期与 sendfile 无缘。解药是 kTLS(内核 TLS):把对称加密下沉到内核,密钥握手仍在用户态、数据加密在内核内完成,sendfile 在加密世界里复活。这条路线的巅峰是 Netflix 的 Open Connect CDN:在 FreeBSD 上用 sendfile + kTLS 加网卡 TLS 卸载,单台服务器推到 400Gb/s(2021 年),次年 800Gb/s——注意这是 FreeBSD 的战绩而非 Linux,但它验证了「零拷贝 + 内核卸载」路线的天花板。Linux 侧 kTLS 与 sendfile 的支持在近年内核也已成熟,HTTPS 场景重新纳入射程。

六、工程落地清单

  1. 先确认你的数据只是「路过」。转发、分发、镜像类负载零拷贝收益最大;要加工内容的场景收益减半。
  2. Java 服务检查 transferTo/sendfile 通道。Netty 的 FileRegion、Kafka 的 consumer 路径默认走 sendfile,别在中间插一道自检加密破坏它。
  3. nginx 按文件大小分路。sendfile on + tcp_nopush on 管小文件,大文件配 directio;别用 aio sendfile 的老参数组合硬凑。
  4. HTTPS 要零拷贝先上 kTLS。确认内核版本与 nginx/网关支持,否则 TLS 流量老老实实走用户态。
  5. 给 Page Cache 留内存。像 Kafka 一样把文件服务的性能押在页缓存上,JVM 侧反而要控制堆大小。
  6. 量对了再优化。拷贝账单要看火焰图与 strace -c 的系统调用占比——CPU 在 read/write 里占大头才有零拷贝的戏。
  7. 压测前先 warmup。冷启动的消费速率低可能是冷缓存与缺页,预热后的数字才反映零拷贝的真实水平。

结语

零拷贝的哲学一句话:数据不增值的搬运都是税。sendfile 划掉了用户态这条弯路,SG-DMA 划掉了 CPU 的最后一次搬运,Kafka 用「页缓存会师」把生产、消费、缓存合成一份内存,kTLS 又把零拷贝带回了加密时代。回看这条线,所有优化都在做同一件事:重新划分「谁必须碰数据」。下一篇我们把镜头拉到网络协议栈本身——三次握手、TIME_WAIT 与 BBR:当数据真的要出门,TCP 是怎么管这条路的。

参考资料

读者留言

COMMENTS 暂无
仅本站原创文章开放留言 · 请勿留下手机号、邮箱等个人信息

还没有留言,来说第一句?