一个"文件下载"接口从"读进内存再写出去"改成 FileChannel.transferTo,CPU 从 40% 降到 8%。三个月后加了一个需求:"下载时先算一下文件的 sha256 校验值"——于是又在应用里读了一遍文件,零拷贝的收益立刻消失一半。
零拷贝(sendfile)的本质是"让数据不经过用户态":内核直接把"文件页"送到"socket 缓冲"。这个前提决定了"它能用在哪些场景":静态文件分发、大附件下载、视频切片——而"要处理数据"的场景一律不适用。
而它有个前提:应用不需要看数据——一旦"要读、要算、要改",就得把数据"拉回用户态"。
1. 传统路径与零拷贝的差别
| 路径 | 拷贝次数 |
|---|---|
| 传统(read+write) | 4 次(磁盘→内核→用户→内核→网卡缓冲) |
sendfile |
2 次(磁盘→内核页缓存→网卡) |
少掉的两次是"用户态进出"——这是 CPU 与内存带宽的双重节省。这个"双重"体现在两个指标上:CPU 时间(少两次拷贝指令)与内存带宽(少两次整块读写)——于是"高带宽文件服务"能"多用几张网卡"。
2. "零拷贝"失效的三种情形
| 情形 | 原因 |
|---|---|
| 要"计算校验值/加密" | 必须读数据 |
| 要"改写内容"(如加头) | 必须读+写 |
| 目标不是 socket(如写另一个文件) | 平台/接口限制 |
第一项是"最常被产品需求打破"的:"下载前校验"看起来是个小需求,而它把"零拷贝"变成"多读一遍"。
3. "既要校验又要零拷贝"的三种做法
| 做法 | 代价 |
|---|---|
| 先算哈希再传(两遍 IO) | 慢(一遍磁盘读) |
边传边算(DigestOutputStream) |
也"进了用户态"(等于放弃零拷贝) |
| 事前一并算好(入库时存哈希) | 最快(需改数据模型) |
第三项是"正解":**哈希"在写入时算一次、存下来"**第三项的"代价"是"数据模型改动":要加一列 sha256、并且"写入路径"要算一次——而它带来"下载时零成本校验",是"一次投入、长期受益"。
——于是"下载时只查表",零拷贝保住。
// 三种"边传边算"的写法(都放弃零拷贝,但比"两遍 IO"好)
try (var in = Files.newInputStream(p);
var digest = new DigestInputStream(in, MessageDigest.getInstance("SHA-256"));
var out = response.getOutputStream()) {
in.transferTo(out); // 一次遍历,边读边算
}
"循环 transferTo"是"大文件"的必需写法,而它只有四行:
// 大文件的正确写法(每次最多 8MB,直到搬完)
long pos = 0, size = Files.size(path), CHUNK = 8L << 20;
try (var ch = FileChannel.open(path, READ)) {
while (pos < size) {
long n = ch.transferTo(pos, Math.min(CHUNK, size - pos), socketChannel);
if (n <= 0) break;
pos += n;
}
}
// 判据:pos == size 才算"完整发送",否则要记日志告警
4. "transferTo"的三个限制
| 限制 | 说明 |
|---|---|
| 单次上限(约 2GB) | 需"循环调用" |
| 只在"文件 → socket"最优 | 其它组合不保证 |
| 平台支持 | 部分平台退化为"普通拷贝" |
第一项的"表现"很微妙:响应"成功返回",而客户端"只收到一半"——因为"部分传输"在 HTTP 层"看起来正常"(Content-Length 若没设,客户端会"以为结束")。
第一项是"真实坑":transferTo 一次最多搬"约 2GB"(受 size_t/实现限制)——于是"大于 2GB 的文件"要"循环调用",否则"只发了一半"。
"页缓存"这件事决定"第一次下载"与"后面几次"的差别:
// 三种"缓存状态"下的路径
首次读(冷):磁盘 → 页缓存 → 网卡(仍 2 次,但磁盘慢)
二次读(热):页缓存 → 网卡(免磁盘)
大文件(> 内存):反复"冷读",收益只剩"少两次 CPU 拷贝"
// 判据:文件大小 vs 可用内存——"能装下"才有"热"的可能
5. "零拷贝"在生产里的三个前提
| 前提 | 说明 |
|---|---|
| 文件在"页缓存"或"磁盘直读" | 冷文件仍要"磁盘→内核" |
| 不做 TLS 时最省 | TLS 要"用户态加解密" |
| 不被代理二次拷贝 | 中间层可能"读一遍" |
第二项是"最容易被忽略"的:**HTTPS 下"内核不能替你加密"**第二项的"量化"是"TLS 让零拷贝收益从 ~70% 降到 ~20%":你仍然省了"一次用户态往返",但"加解密"要在用户态做——于是"大文件走 HTTPS"的收益主要来自"省一次拷贝",而不是"完全不进用户态"。
——于是"零拷贝"退化成"内核到用户态、加密、再发"。所以"零拷贝"在"HTTP + 静态大文件"上收益最大。
"CPU 没降"要先"分段量",否则会"改错位置":
// 三处"看一眼"(不必上 profiler)
top -H -p <pid> # 线程级:是"下载线程"忙,还是"加密线程"忙
cat /proc/<pid>/stat | awk '{print "us="$14" sy="$15}' # 用户态/内核态时间
ss -ti | head # 连接级:是否有"重传/窗口小"
// 判据:us 高 → 应用在算;sy 高 → 内核在搬;两者都低 → 瓶颈在别处
6. 排错:五种"零拷贝相关问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| CPU 没降 | 走了 TLS / 中间层 | 分段测量 |
| 文件只传了一半 | 超过单次上限 | 循环 transferTo |
| 内存仍高 | 有"读入内存"的分支 | 查调用链 |
| 大文件慢 | 冷页缓存 | 预热/直接 IO |
| 校验改造后变慢 | 两遍 IO | 存哈希 |
"中间层"这条最容易被漏:反向代理/网关会"读一遍再写",于是"源站零拷贝"在"整条链路"上并不零拷贝:
// 三种链路(同一文件)
客户端 ← 源站(零拷贝:2 次) → 收益最大
客户端 ← nginx ← 源站(nginx 也零拷贝:每跳 2 次) → 仍然好
客户端 ← nginx ← 源站(nginx "读进内存改写":多 2 次) → 收益被吃掉
// 判据:链路上"有没有哪一跳"必须看数据
7. 边界:什么情况"普通读写"更好
| 场景 | 为什么 | 做法 |
|---|---|---|
| 小文件(< 100KB) | 拷贝成本可忽略 | 普通读写 |
| 需要加密/压缩 | 必须过用户态 | 普通读写 |
| 要"流式处理"(转码) | 数据要改 | 普通读写 |
| 目标不是 socket | 零拷贝不适用 | 普通读写 |
最后一条与"验收"有关的边界:"零拷贝是否生效"要"分段量":"内核态 CPU 占比"(si)与"用户态"(us)分开看——us 下降而 si 上升是"正常";两者都没动,说明"根本没走零拷贝"。
判断口径一句话:"这份数据,应用"要不要看"。不看(纯转发),零拷贝收益最大;要看(校验、加密、转码、加水印),它就是"一次额外的用户态往返",收益归零。
再补一个"反例"经验:"用零拷贝优化"最常见的失败,是"以为自己在零拷贝、其实没有"——中间件(如某些 web 容器)会"先读进 ByteBuffer",于是"你调了 transferTo,而框架又拷贝了一次"。所以"优化前后"必须"量 us 与 sy",而不是"看代码里有没有那个方法调用"。
而"量过再改"这件事,本身就是"性能优化"的通用纪律:先证明"原来没生效",再谈"改完有没有用"。
8. 自测
- 传统路径与
sendfile的拷贝次数差在哪两次?省下的是什么? - "下载前校验"为什么会让零拷贝失效?三种做法里哪一种"保住零拷贝"?
transferTo的"单次上限"会造成什么现象?怎么解决?- 为什么"HTTPS 下零拷贝收益变小"?它退化成什么?
- "小文件"为什么不必零拷贝?
- 判断"该不该零拷贝"的那个问题是什么?