系列目录
- 从单线程到线程池:云盘转 Java 后的第一堂并发课
- 线程池不是 new 出来就完事:参数、队列与快慢接口隔离
- 数据库连接池与 Spring 声明式事务:把 Node.js 的坑填上
- ConcurrentHashMap 与锁:文件元数据的并发读写
- Future 与 CountDownLatch:一个接口聚合一堆下游
- 用 Kafka 做异步化与削峰:从热点上报到全局事务
- JVM 内存与 GC:一次线上 Full GC 排查记录
- 日志不是越多越好:logback 实践与线上问题定位
- 高性能 IO:流对流的失败率、NIO 与零拷贝(本篇)
已有 Node.js、C# 等后端经验后,我预判 Java 项目会遇到文件传输、线程占用和链路可靠性的问题,因此在项目需要处理这类需求前补了 NIO、文件传输和零拷贝相关知识。后续评估 IM 消息文件方案时,这些准备提供了分析依据。
IM 消息文件实现 曾否决「java 服务流对流」方案,原文写道:「基本上每 3 万次请求会有 1~2 次失败,因为关键节点太多,稳定性是乘法关系」。本文补充两部分:如何理解和估算这个失败率,以及流对流路径可采用的 IO 优化。IO 优化会减少特定路径上的数据复制和上下文切换;链路设计还需要评估新增代理、连接和资源占用带来的故障面。
流对流方案与失败率
IM 融合需要将带鉴权和有效期的动态链接放入大象协议要求的静态链接。方案 2 的处理流程是:请求到达 java 服务后,java 完成鉴权和文件定位;java 从 mss 读取响应的 InputStream,持续写入面向客户端的 OutputStream。流量路径为:
业务 nginx → java 服务 → mss nginx → swift 集群
否决该方案时,依据之一是流对流代理约每 3 万次请求出现 1~2 次失败。这个数字来自云盘非核心链路的实测:一个文件预览代理采用相同方式运行数月,记录的失败率在万分之零点几。将该比例用于图片消息每天数千万条的估算,会得到每天数百到数千次失败请求。这个估算以前提为请求量、失败定义和流量分布与实测场景接近。是否会表现为「图片裂了」,还取决于重试、降级和客户端展示逻辑。
java 代理是否比 nginx 转发更容易受资源限制,取决于实现和部署配置。使用阻塞 Servlet API 且未启用异步处理时,一个转发请求通常会占用:
- 一个 Jetty 工作线程。线程会在上游读取或下游写入阻塞时持续占用。上游慢或客户端慢速读取会减少线程池可处理的并发请求数。
- 一条到上游的 HTTP/TCP 连接或连接池中的一个连接。新建连接会引入建连和慢启动成本;复用连接仍需要处理读写超时、对端关闭和连接池耗尽。
- 一块应用缓冲区。例如
new byte[8192]。仅计算这块缓冲,1,000 个并发请求约占 8 MB;更大的缓冲、对象分配和排队数据会继续增加堆压力。 - 两段独立的 TCP 传输。客户端到 java 与 java 到 mss 分别执行拥塞控制和流量控制。客户端慢速读取时,java 需要等待下游可写,线程和上游连接的占用时间随之增加。
因此,java 代理的可用性不能只按一个独立节点估算。线程池饱和、连接超时、内存压力和垃圾回收停顿都可能导致失败或超时。预览代理当时增加了超时和失败告警;接下来需要确认数据在文件、内核和网络设备之间的传输路径。
从文件到网卡的数据路径
以服务端从本地文件读取数据并通过 socket 发送给客户端为例。使用 InputStream 读取、OutputStream 写入时,常见 Linux 路径可概括为:
- DMA 将数据从磁盘读入内核的 page cache;
- CPU 将数据从 page cache 拷贝到用户态缓冲区,例如 JVM 堆中的
byte[]; - CPU 将用户态缓冲区的数据拷贝到内核的 socket 发送缓冲区;
- DMA 将数据从 socket 发送缓冲区传送到网卡。
这条简化路径包含 4 次数据移动,其中 2 次由 CPU 拷贝。一次 read 和一次 write 会发生两次系统调用;将进入内核和返回用户态分别计数时,对应 4 次用户态与内核态切换。实际路径还受文件系统、内核版本、网卡驱动和 socket 状态影响。对于普通 Java 堆缓冲区,内核会直接在该用户缓冲区与内核缓冲区之间复制数据;每次 read 和 write 不会必然额外产生一次「本地内存到 JVM 堆」的独立拷贝。
FileChannel 配合 ByteBuffer.allocateDirect 使用直接缓冲区。直接缓冲区可以避免某些 JNI 或通道实现在堆缓冲区与临时原生缓冲区之间的额外中转,但文件数据仍会进入用户态,再写入 socket。因此,直接缓冲区通常不改变上述 4 次数据移动的基本结构。
FileChannel.transferTo 提供了另一条文件到通道的传输接口。当目标是 SocketChannel 时,JDK 7 在部分 Linux 实现和满足条件的运行环境下可以使用 sendfile(2) 等内核路径,文件数据无需复制到用户态。常见的 sendfile 路径为:
- DMA:磁盘 → page cache;
- CPU:page cache → socket 发送缓冲区;
- DMA:socket 发送缓冲区 → 网卡。
按该路径计算,数据移动次数为 3 次,用户代码只需发起传输系统调用。是否能进一步避免第 2 次 CPU 数据复制,取决于内核、协议栈和网卡能否将 page cache 页面以 scatter gather 方式交给设备 DMA;内核仍需处理页面引用和描述符,不能将其概括为完全没有 CPU 工作。通常所说的零拷贝,指避免文件内容复制到用户态,不表示没有数据移动或 CPU 参与。
transferTo 对文件到 socket 的传输可能有优势。文件到文件的实现、内核能力和 JDK 版本差异较大,不能假定一定使用零拷贝路径,也不能将 transferTo 作为本地文件复制的固定性能方案。需要在目标 JDK、操作系统和文件系统上测量。
当时验证的对照实验
为验证这项预判,当时写了一个对照实验:本地启动 socket 接收端,接收后直接丢弃;发送端分别用手动缓冲循环和 transferTo 发送同一个大文件,比较耗时和 CPU。JDK 7 下可直接编译运行:
public class ZeroCopyBench {
public static void main(String[] args) throws Exception {
File file = new File(args[0]); // 一个几百 MB 的测试文件
int port = Integer.parseInt(args[1]); // 接收端端口
long t1 = sendManual(file, port); // 方式一:手动缓冲循环
long t2 = sendTransfer(file, port); // 方式二:transferTo
System.out.printf("manual=%dms, transferTo=%dms%n", t1, t2);
}
static long sendManual(File file, int port) throws IOException {
long start = System.currentTimeMillis();
SocketChannel socket = SocketChannel.open(new InetSocketAddress("127.0.0.1", port));
FileChannel in = new FileInputStream(file).getChannel();
ByteBuffer buf = ByteBuffer.allocateDirect(64 * 1024);
while (in.read(buf) != -1) {
buf.flip();
while (buf.hasRemaining()) {
socket.write(buf);
}
buf.clear();
}
in.close();
socket.close();
return System.currentTimeMillis() - start;
}
static long sendTransfer(File file, int port) throws IOException {
long start = System.currentTimeMillis();
SocketChannel socket = SocketChannel.open(new InetSocketAddress("127.0.0.1", port));
FileChannel in = new FileInputStream(file).getChannel();
long pos = 0, size = in.size();
while (pos < size) {
// 单次调用可能只传一部分(受 socket 缓冲区限制),必须循环
pos += in.transferTo(pos, size - pos, socket);
}
in.close();
socket.close();
return System.currentTimeMillis() - start;
}
}接收端可使用 nc -l <port> > /dev/null(mac 上参数略有不同)。代码使用 allocateDirect,避免手动缓冲实现可能产生的临时原生缓冲中转;transferTo 返回实际传输的字节数,可能小于请求长度,调用方需要据此推进位置。示例的 socket 是阻塞模式。不同平台上 transferTo 也可能暂时返回 0;生产代码应避免在这种情况下无条件忙循环,并按通道模式、平台限制和超时策略处理。
实验结果采用模糊化描述:对于几百 MB 文件,transferTo 耗时约为手动循环的六到七成,使用 time 观察到 sys 占比下降;几 MB 的小文件中,差异不明显。这个结果只说明当时的机器、JDK、内核和 loopback 条件下,文件传输的用户态复制成本会影响结果。小文件的总耗时还会受连接建立、协议处理和调度影响,无法据此得到固定的文件大小阈值。是否使用 transferTo 应结合文件大小分布、CPU 使用率和目标环境的实测决定。
实验有明确限制:loopback 网卡主要在内存中传输,绝对数值不能代表生产环境;磁盘性能、真实网络、客户端慢速读取和 TLS 等因素都可能改变收益。该实验适合比较两种实现的相对行为,不能单独证明生产吞吐或端到端延迟。
串联链路的成功率估算
回到开头的数字。每 3 万次请求失败 1~2 次,对应请求成功率约为 99.993%~99.997%。将业务 nginx、java 服务、mss nginx、swift 集群建模为 4 个独立且成功率相同的阶段,每个阶段成功率为 p,端到端成功率为 p⁴。将上述范围反推,p 约为 99.9983%~99.9992%。这是独立性和同质性假设下的估算,不是对各组件实际可用性的测量。网络故障、流量突增和共享依赖常使多个阶段的失败相关,此时不能简单相乘。
在相同的独立假设下,4 个阶段的示例为:
- 每阶段 99.998%,端到端约为 99.992%,每 3 万次约失败 2.4 次;
- 每阶段 99.99%,端到端约为 99.96%,每 3 万次约失败 12 次;
- 每阶段 99.9%,端到端约为 99.6%,每 3 万次约失败 120 次。
当单阶段失败率较低且彼此独立时,端到端失败率可近似为各阶段失败率之和;4 个成功率相近的阶段约为单阶段失败率的 4 倍。单阶段故障率升高会相应增加端到端失败率,增长比例取决于具体成功率,不能按「小数点退一位」概括。
java 代理还包含线程池、上游连接、应用缓冲和垃圾回收等共享资源,无法仅以独立串联组件估算其等效成功率。降低该路径的失败率可从超时、限流、连接管理和内存控制入手;移除不需要的数据转发阶段会减少待管理的资源和潜在故障面。NIO 和零拷贝只优化特定的数据传输路径,不能替代链路可靠性设计。
使用范围与路径调整
基于上述分析,云盘做了两件事。
非核心链路(预览代理这类):流对流继续用,并增加保护。 上游连接改为池化复用,减少每个请求新建连接;读写设置明确超时;单请求缓冲设置上限并分块传输,避免大文件和高并发同时增加堆占用;失败计数进入监控,以便按统一口径观察失败率。部分本地文件直接发送到 socket 的场景使用了 transferTo。这些措施降低资源耗尽和无限等待的风险,实际效果仍取决于超时值、连接池上限和流量特征。
核心链路(IM 文件流转):从数据路径移除 java 转发。 鉴权和寻址由 nginx 层的 lua 完成,文件由 nginx 的 proxy_pass 代理,java 服务仅保留鉴权接口等控制职责。相关方案见 IM 消息文件实现。这项调整减少了 java 转发占用的线程、连接和应用缓冲,但 nginx 代理、mss 和 swift 仍属于端到端路径。故障域减少程度需要按实际请求路径、重试策略和监控口径确认,不能仅按组件数量断言为「四跳变两跳」。
当时的学习边界
Netty 调研过,未上生产。 2016 年 Netty 4 已经成熟。其事件驱动模型适合大量连接场景,并提供文件传输相关支持。当时阅读了文档和部分源码,也写过 demo 验证思路,但没有引入生产环境。云盘没有长连接和自定义协议的场景;内嵌 Jetty 的阻塞模型在隔离做好后能够满足当时需求(第 2 篇)。服务端人力只有一两人,引入异步框架需要补足线程模型、内存泄漏和堆外内存等排障经验。IM 文件链路已通过 nginx 与 lua 调整数据路径,缺少引入 Netty 的直接收益。当时对 Netty 的了解限于阅读和 demo,没有生产使用经验。
下一篇记录压测:压测与容量评估:IM 融合前要扛多少流量?。IM 融合评审时被问到「这套东西能扛多少量」,当时无法回答,之后补了相关验证。
参考资料
- JDK 7
FileChannelJavadoc(transferTo的语义与限制) - Linux
sendfile(2)man page - IM 消息文件实现(被否决方案的背景与最终 nginx+lua 方案)

