大文件下载内存溢出防护:拒绝全量加载,零拷贝流式输出抗住万级并发!
做文件下载功能的同学肯定都遇到过这个问题:用户下载一个大文件,结果服务器内存飙升,最后 OOM 直接崩溃。特别是在处理视频、备份文件、日志压缩包等大文件时,这个问题尤为突出。
我之前就遇到过这样一个案例:一个用户反馈下载一个 5GB 的视频备份文件时,服务器直接宕机了。排查后发现,代码里居然是这样写的:
@GetMapping("/download/{fileId}")publicbyte[] 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. 核心设计思想
我们的方案核心是三个关键技术:
-
零拷贝(Zero-Copy):利用 Linux 的 sendfile 系统调用,跳过用户态到内核态的拷贝 -
流式处理(Streaming):边读边写,不在内存中保留完整文件内容 -
异步非阻塞(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 的 @ResponseBody 和 InputStreamResource:
核心逻辑伪代码:1. Controller 返回类型定义为 Resource2. 使用 InputStreamResource 包装文件输入流3. 设置 Content-Length 和 Content-Disposition 头4. Spring 会自动使用流式输出
这种方案:
-
✅ 简单,改动小 -
✅ 内存占用固定 -
⚠️ 没有零拷贝,还是有内核到用户的拷贝 -
⚠️ 同步阻塞,占用线程
实战方案二:StreamingResponseBody(Servlet 3.1+)
Servlet 3.1 提供了 StreamingResponseBody,支持异步流式响应:
核心逻辑伪代码:1. Controller 方法返回 StreamingResponseBody2. 使用 @Async 注解让方法异步执行3. 在方法体内边读文件边写入响应输出流4. OutputStream 实时写出到客户端
这种方案:
-
✅ 异步非阻塞,释放线程 -
✅ 内存占用固定 -
⚠️ 没有零拷贝 -
✅ 支持大并发
实战方案三:FileCopyUtils + 缓冲流
这是 Spring 提供的工具类,封装好了流式复制的逻辑:
核心逻辑伪代码:1. 获取文件的 InputStream2. 获取响应的 OutputStream3. 使用缓冲流包装(BufferedInputStream/BufferedOutputStream)4. FileCopyUtils.copy() 边读边写
这种方案:
-
✅ 代码简洁 -
✅ 内存占用可控(缓冲区大小可调) -
⚠️ 同步阻塞 -
✅ 兼容性最好
实战方案四:ResponseEntity with StreamingResponseBody
结合 Spring 的 ResponseEntity,可以更灵活地控制响应头和状态码:
核心逻辑伪代码:1. 构建 ResponseEntity<StreamingResponseBody>2. 设置 Content-Type、Content-Length、Content-Disposition3. 返回 StreamingResponseBody 对象4. 在回调中执行流式复制
实战方案五:WebAsyncTask 超时控制
在异步基础上增加超时控制,防止客户端断开连接后还在无效读取:
核心逻辑伪代码:1. 创建 WebAsyncTask 对象2. 设置超时回调(超时后取消任务)3. 设置超时时间(比如 30 分钟)4. 配置错误回调(异常处理)5. 返回 WebAsyncTask 给 Spring MVC
大文件下载的最佳实践
1. 断点续传支持
对于超大文件,断点续传是必须的:
请求头:Range: bytes=0-4999999 // 请求前5MBContent-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缓冲区,单位Byteshttp:multipart:max-file-size:10GB# 最大支持文件大小max-request-size:10GB
效果对比
|
|
|
|
|
|
|---|---|---|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
总结
大文件下载的核心原则:
-
永远不要一次性加载:不管文件多大,都要流式处理 -
固定大小的缓冲区:内存占用与文件大小无关 -
选择合适的方案: -
小文件(<100MB):直接流式响应 -
大文件(>100MB):StreamingResponseBody + 异步 -
超大文件(>1GB):考虑零拷贝 + CDN -
做好资源清理:finally 块中确保流被关闭 -
考虑断点续传:对用户友好,也能减少重复下载
记住:内存是有限的,流是永续的。用流式思维写代码,才能应对各种大小的文件。
源码获取
文章已同步至小程序博客栏目,需要源码的请关注小程序博客。
公众号:服务端技术精选
小程序码:


夜雨聆风