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

做文件下载功能的同学肯定都遇到过这个问题:用户下载一个大文件,结果服务器内存飙升,最后 OOM 直接崩溃。特别是在处理视频、备份文件、日志压缩包等大文件时,这个问题尤为突出。

我之前就遇到过这样一个案例:一个用户反馈下载一个 5GB 的视频备份文件时,服务器直接宕机了。排查后发现,代码里居然是这样写的:

@GetMapping("/download/{fileId}")
public byte[] download(@PathVariable Long fileId) {
    File file = fileService.getFile(fileId);
    return Files.readAllBytes(file.toPath()); // 一次性加载到内存!
}

这就是典型的"小文件思维"写大文件代码的案例。在低并发场景下可能没问题,但一旦并发上来,内存就会爆掉。

今天我们就来聊聊大文件下载的正确姿势,让你的系统轻松抗住万级并发。

大文件下载的内存问题根源

1. 传统方式的致命缺陷

很多开发者习惯用 Files.readAllBytes() 或者 FileInputStream.readAllBytes() 来读取文件内容。这种方式在小文件场景下没问题,但面对大文件就是灾难:

问题场景:5GB 文件 + 100 并发 = 500GB 内存占用!

2. 为什么传统方式会导致 OOM

传统的下载方式是这样的:

用户请求 → 服务器读取整个文件到内存 → 内存copy到响应缓冲区 → 返回给用户

当文件很大时,内存中会同时存在:

  • 文件内容的原始字节数组
  • 响应缓冲区的副本
  • 可能还有 GC 之前的旧对象

这就是所谓的"内存倍增"问题,实际内存占用可能是文件大小的 2-3 倍。

3. 并发请求的雪崩效应

假设单个请求处理 1GB 文件需要 500MB 内存:

  • 10 并发 = 5GB 内存
  • 100 并发 = 50GB 内存
  • 1000 并发 = 500GB 内存

如果没有流式处理,内存占用会随着并发数线性增长,很快就会把服务器打爆。

解决方案:零拷贝 + 流式输出

1. 核心设计思想

我们的方案核心是三个关键技术:

  1. 零拷贝(Zero-Copy):利用 Linux 的 sendfile 系统调用,跳过用户态到内核态的拷贝
  2. 流式处理(Streaming):边读边写,不在内存中保留完整文件内容
  3. 异步非阻塞(Async):使用 NIO 或者 Servlet 3.0 的异步特性,释放线程资源

架构图如下:

┌─────────────────────────────────────────────────────────────────┐
│                        请求处理流程                              │
├─────────────────────────────────────────────────────────────────┤
│                                                                 │
│  用户请求 ──→ Filter(权限校验) ──→ Controller ──→ Service    │
│                                                      │          │
│                                                      ▼          │
│                                              ┌─────────────┐    │
│                                              │  读取文件   │    │
│                                              │  (流式读)   │    │
│                                              └─────────────┘    │
│                                                      │          │
│                                                      ▼          │
│                                              ┌─────────────┐    │
│                                              │ sendfile    │    │
│                                              │ (零拷贝)    │    │
│                                              └─────────────┘    │
│                                                      │          │
│                                                      ▼          │
│                                              返回给用户           │
│                                                                 │
└─────────────────────────────────────────────────────────────────┘

2. 为什么零拷贝能省内存?

传统的 I/O 操作需要 4 次拷贝:

1. 磁盘 → 内核缓冲区(read())
2. 内核缓冲区 → 用户缓冲区(应用程序读取)
3. 用户缓冲区 → Socket缓冲区(write())
4. Socket缓冲区 → 网卡(硬件传输)

零拷贝(sendfile)只需要 2 次拷贝:

1. 磁盘 → 内核缓冲区(DMA 拷贝,操作系统完成)
2. 内核缓冲区 → 网卡(硬件直接读取,CPU 不参与)

而且,整个过程用户态代码不需要任何内存操作,文件内容根本不会进入用户空间!

3. 流式处理的关键

流式处理的核心思想是"边读边发":

伪代码示意:

function download(file):
    buffer = allocate(8KB)  // 固定大小的缓冲区
    while hasMoreData(file):
        data = readFromFile(file, buffer)  // 读取一块数据
        writeToResponse(data)  // 发送这块数据
        recycle(buffer)  // 复用缓冲区
    close(file)

关键点:

  • 固定大小缓冲区:不管文件多大,内存占用始终固定(比如 8KB)
  • 边读边发:不需要等整个文件读完才开始发送
  • 缓冲区复用:读完一块就发送,发送完就复用这块内存

4. 异步处理的优势

传统的同步处理是这样的:

请求1 → 线程A → 读取10GB文件 → 返回 → 线程A释放
请求2 → 等待...(因为线程A被占用)

异步处理是这样的:

请求1 → 线程A → 开始读取 → 线程A释放(去处理其他请求)
         ↓
       异步读取中...
         ↓
       读取完成 → 线程B → 发送数据 → 返回

线程资源得到了充分利用,可以处理更多并发请求。

实战方案一:Spring MVC 原始流式响应

最简单的方案,直接利用 Spring MVC 的 @ResponseBodyInputStreamResource

核心逻辑伪代码:

1. Controller 返回类型定义为 Resource
2. 使用 InputStreamResource 包装文件输入流
3. 设置 Content-Length 和 Content-Disposition 头
4. Spring 会自动使用流式输出

这种方案:

  • ✅ 简单,改动小
  • ✅ 内存占用固定
  • ⚠️ 没有零拷贝,还是有内核到用户的拷贝
  • ⚠️ 同步阻塞,占用线程

实战方案二:StreamingResponseBody(Servlet 3.1+)

Servlet 3.1 提供了 StreamingResponseBody,支持异步流式响应:

核心逻辑伪代码:

1. Controller 方法返回 StreamingResponseBody
2. 使用 @Async 注解让方法异步执行
3. 在方法体内边读文件边写入响应输出流
4. OutputStream 实时写出到客户端

这种方案:

  • ✅ 异步非阻塞,释放线程
  • ✅ 内存占用固定
  • ⚠️ 没有零拷贝
  • ✅ 支持大并发

实战方案三:FileCopyUtils + 缓冲流

这是 Spring 提供的工具类,封装好了流式复制的逻辑:

核心逻辑伪代码:

1. 获取文件的 InputStream
2. 获取响应的 OutputStream
3. 使用缓冲流包装(BufferedInputStream/BufferedOutputStream)
4. FileCopyUtils.copy() 边读边写

这种方案:

  • ✅ 代码简洁
  • ✅ 内存占用可控(缓冲区大小可调)
  • ⚠️ 同步阻塞
  • ✅ 兼容性最好

实战方案四:ResponseEntity with StreamingResponseBody

结合 Spring 的 ResponseEntity,可以更灵活地控制响应头和状态码:

核心逻辑伪代码:

1. 构建 ResponseEntity<StreamingResponseBody>
2. 设置 Content-Type、Content-Length、Content-Disposition
3. 返回 StreamingResponseBody 对象
4. 在回调中执行流式复制

实战方案五:WebAsyncTask 超时控制

在异步基础上增加超时控制,防止客户端断开连接后还在无效读取:

核心逻辑伪代码:

1. 创建 WebAsyncTask 对象
2. 设置超时回调(超时后取消任务)
3. 设置超时时间(比如 30 分钟)
4. 配置错误回调(异常处理)
5. 返回 WebAsyncTask 给 Spring MVC

大文件下载的最佳实践

1. 断点续传支持

对于超大文件,断点续传是必须的:

请求头:
Range: bytes=0-4999999    // 请求前5MB
Content-Range: bytes 0-4999999/10000000  // 响应头,表示总大小

核心实现:

  • 读取请求头中的 Range 参数
  • 计算起始位置和结束位置
  • 使用 RandomAccessFile 跳到指定位置读取
  • 返回 206 Partial Content 状态码

2. 文件压缩流

如果需要传输多个文件,可以先压缩成 ZIP 再下载:

核心逻辑伪代码:

1. 创建 ZipOutputStream 包装响应输出流
2. 遍历需要下载的文件列表
3. 对每个文件:
   - putNextEntry() 开始新条目
   - 边读边写到 ZipOutputStream
   - closeEntry() 关闭当前条目
4. 完成压缩

3. CDN 预取 + 减少服务器压力

对于热门文件,可以利用 CDN:

┌─────────┐    ┌─────────┐    ┌─────────┐    ┌─────────┐
│  用户   │───→│   CDN   │───→│  源站   │───→│  文件   │
│         │◀──│ (缓存)  │◀──│         │◀──│  存储   │
└─────────┘    └─────────┘    └─────────┘    └─────────┘
   首次请求 → CDN回源 → 缓存到CDN → 返回给用户
   后续请求 → CDN直接返回(不经过源站)

4. 资源清理

不管下载成功还是失败,都要确保资源被正确关闭:

伪代码 - try-finally 保证资源释放:

function download(file):
    inputStream = null
    outputStream = null
    try:
        inputStream = openFile(file)
        outputStream = getResponseOutputStream()
        copyStream(inputStream, outputStream)
    finally:
        closeQuietly(inputStream)
        closeQuietly(outputStream)

配置参数建议

根据服务器配置调整以下参数:

server:
  tomcat:
    max-threads: 200  # 根据并发需求调整
    connection-timeout: 20000  # 20秒

spring:
  servlet:
    stream:
      buffer-size: 8192  # 8KB缓冲区,单位Bytes
      
  http:
    multipart:
      max-file-size: 10GB  # 最大支持文件大小
      max-request-size: 10GB

效果对比

方案内存占用并发能力复杂度适用场景
传统全量加载O(n) 文件大小❌ 不推荐
原始流式响应O(1) 固定buffer小文件优先
StreamingResponseBodyO(1) 固定buffer大文件推荐
零拷贝(sendfile)O(1) 接近0极高极超大文件
CDN 加速O(1) 接近0极高热点文件

总结

大文件下载的核心原则:

  1. 永远不要一次性加载:不管文件多大,都要流式处理
  2. 固定大小的缓冲区:内存占用与文件大小无关
  3. 选择合适的方案
    • 小文件(<100MB):直接流式响应
    • 大文件(>100MB):StreamingResponseBody + 异步
    • 超大文件(>1GB):考虑零拷贝 + CDN
  4. 做好资源清理:finally 块中确保流被关闭
  5. 考虑断点续传:对用户友好,也能减少重复下载

记住:内存是有限的,流是永续的。用流式思维写代码,才能应对各种大小的文件。


源码获取

文章已同步至小程序博客栏目,需要源码的请关注小程序博客。

公众号:服务端技术精选

小程序码:


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

取消