大文件下载内存溢出防护:拒绝全量加载,零拷贝流式输出抗住万级并发

朋友公司的文件服务,一到月底报表下载高峰期就崩。运维排查发现每次下载一个 200MB 的 Excel,后端代码里居然是 byte[] data = file.readAllBytes()——把整个文件加载到堆内存里再往外写。10 个人同时下载,就是 10 个 200MB 的数组在堆里,JVM 直接 OOM。重启后又崩,加内存也撑不住——因为下载峰值不是你能通过加内存解决的线性问题。

大文件下载的 OOM 问题本质就一句话:你把整个文件搬进了 JVM 堆里,但 JVM 堆不是为文件 IO 设计的。 文件应该从磁盘流到网卡,中间经过你的应用,但不要在你的堆里停留。

今天聊聊怎么用流式传输和零拷贝,让 X GB 的文件下载跟 X KB 的一样轻松。


错误姿势:全量加载

最常见的错误写法:

@GetMapping("/download")
public ResponseEntity<byte[]> download(String filePath) {
    byte[] data = Files.readAllBytes(Path.of(filePath));  // ❌ 全量加载到堆
    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_TYPE, "application/octet-stream")
            .body(data);
}

一个 500MB 的文件 → 500MB 堆内存 → 10 个并发 → 5GB 堆内存。JVM 最大堆设 4GB 就直接 OOM。而且即使堆够大,GC 也会频繁触发,下载速度跟着崩。


正确姿势:流式输出

Spring 提供了 ResourceStreamingResponseBody 两种流式输出方式:

@GetMapping("/download")
public ResponseEntity<Resource> download(String filePath) {
    Path path = Path.of(filePath);
    Resource resource = new FileSystemResource(path);
    return ResponseEntity.ok()
            .header(HttpHeaders.CONTENT_TYPE, "application/octet-stream")
            .header(HttpHeaders.CONTENT_LENGTH, String.valueOf(resource.contentLength()))
            .body(resource);
}

FileSystemResource 内部用的是 FileInputStream,Spring 会把它包装成 InputStreamResource,然后用 StreamUtils.copy(in, out) 流式输出。数据是从磁盘读到 Socket 缓冲区,不经过堆内存。

每个请求的内存占用从一个 500MB 的 byte 数组,变成了一个 8KB 的 buffer(copy 操作使用的小缓冲区)。100 个并发同时下载,堆内存占用不到 10MB。


进阶:零拷贝

流式传输解决的是"不在堆里缓存"的问题。但数据路径还是:磁盘 → 内核缓冲区 → 用户态缓冲区 → Socket 缓冲区 → 网卡。中间有一次内核态到用户态的拷贝。

零拷贝(FileChannel.transferTo 或 Linux 的 sendfile)可以省掉这次拷贝:

传统路径:磁盘 → 内核 buf → 用户 buf → Socket buf → 网卡
零拷贝:  磁盘 → 内核 buf ──→ Socket buf → 网卡
                          DMA 直接传输

Java 里实现零拷贝:

@GetMapping("/download")
public void download(String filePath, HttpServletResponse response) throws Exception {
    File file = new File(filePath);
    response.setContentType("application/octet-stream");
    response.setContentLengthLong(file.length());

    try (FileInputStream fis = new FileInputStream(file);
         FileChannel channel = fis.getChannel()) {

        WritableByteChannel out = Channels.newChannel(response.getOutputStream());
        channel.transferTo(0, file.length(), out);
        // transferTo 底层调的是 sendfile,数据不经过用户态
    }
}

channel.transferTo() 在 Linux 上直接调用 sendfile64 系统调用。数据从磁盘 DMA 到内核缓冲区,再从内核缓冲区 DMA 到网卡。全程 CPU 不碰数据,只发指令。一个 500MB 的文件下载,CPU 只有个位数的开销。


需要关注的点

大文件下载别用 Tomcat 默认线程池

流式输出确实不占堆内存,但它会长时间占用一个 Tomcat 线程。如果 100 个人同时在下载 1GB 的文件,你基本上需要 100 个 Tomcat 线程被长期占用。

解决办法是给下载接口单独配一个线程池,或者用异步 Servlet(AsyncContext),把下载任务从主线程池中剥离出去。

断点续传

大文件下载得支持断点续传。HTTP 的 Range 头就是干这个的:

@GetMapping("/download")
public ResponseEntity<Resource> download(String filePath,
        @RequestHeader(value = "Range", required = false) String range) {
    // 解析 Range: bytes=10240-20480
    // 只读取指定范围的文件内容
}

文件大于 1GB 时,用户网络断开再重连的概率很高。不支持的 Range,用户每断一次就得从头下,浪费流量和时间。

下载限速

单个下载请求可以限制速度,防止网卡被一个大文件下载占满。InputStreamread(byte[], off, len)Thread.sleep() 配合,简单有效。


总结

大文件下载的核心就两条:流式传输(不在堆里缓存)+ 零拷贝(不进用户态)。

流式传输用 FileSystemResourceStreamingResponseBody,每个请求的内存占用从全量文件大小变成 8KB 缓冲区。100 个并发毫无压力。

零拷贝用 FileChannel.transferTo(),一个系统调用搞定磁盘到网卡的数据搬运,CPU 几乎不动。

再加上断点续传和下载限速,一个文件服务轻轻松松扛住万级并发下载。


有用的话转给还在用 byte[] readAllBytes 下载文件的同事。


标题:大文件下载内存溢出防护:拒绝全量加载,零拷贝流式输出抗住万级并发
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/06/19/1781787337997.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消