Spring Boot 下载 10GB 文件,我把 byte[] 改成 Range 断点续传,内存终于不再暴涨
- 2026-09-22 00:41:13

前段时间给后台加了一个文件下载接口。
最开始下载的东西都不大。
几十 KB 的 PDF。
几 MB 的 Excel。
偶尔有一个几十 MB 的压缩包。
所以最早的代码也很直接:
@GetMapping(”/files/{id}/download”)public ResponseEntity download(@PathVariable Long id)throws IOException {FileInfo file =fileService.findById(id);byte[] bytes =Files.readAllBytes(Path.of(file.getPath()));return ResponseEntity.ok().header(HttpHeaders.CONTENT_DISPOSITION,”attachment; filename=\””+ file.getName()+ ”\””).body(bytes);}
用了挺长时间。
没什么问题。
后来系统开始允许上传视频、安装包和模型文件。
文件慢慢变成:
300MB800MB2GB5GB
有一天测试直接塞进来一个:
10GB我看到这段:
Files.readAllBytes(...)就知道这个接口该重写了。
因为它真正表达的是:
先把整个文件读进 JVM 内存↓变成 byte[]↓再发给浏览器
下载一个 10GB 文件,先申请一个 10GB 左右的 byte[]?
这已经不是优化的问题了。
绝大多数 Spring Boot 服务根本扛不住。
最直接的修改当然是流式下载。
比如:
@GetMapping(”/files/{id}/download”)public void download(@PathVariable Long id,HttpServletResponse response)throws IOException {FileInfo file =fileService.findById(id);Path path =Path.of(file.getPath());response.setContentType(”application/octet-stream”);response.setHeader(HttpHeaders.CONTENT_DISPOSITION,”attachment; filename=\””+ file.getName()+ ”\””);try (InputStream input =Files.newInputStream(path);OutputStream output =response.getOutputStream()) {input.transferTo(output);}}
这样至少不会:
10GB 文件=10GB JVM Heap
文件是一边读、一边发。
内存基本稳定。
到这里,好像问题已经解决了。
但很快又碰到了第二个问题。
用户下载一个 8GB 文件。
已经下到:
7.6GB网络断了一下。
重新点击下载。
然后:
0%重新开始。
前面几个 GB 全白下了。
这个问题和前几天写的大文件上传其实挺像。
上传大文件的时候,最让用户难受的并不是:
5GB 很大而是:
传到 95% 失败以后又回到 0%
下载也是一样。
一个大文件接口如果不支持断点续传,只要网络稍微不稳定,用户体验就会非常差。
HTTP 本身其实早就解决了这个问题。
就是:
Range Request比如浏览器本地已经下载了:
104857600字节。
也就是大概 100MB。
它重新请求的时候,可以告诉服务器:
Range: bytes=104857600-意思就是:
前面我已经有了。从第 104857600 个字节继续给我。
服务器如果支持 Range,不再返回普通:
200 OK而会返回:
206 Partial Content同时带:
Accept-Ranges: bytes以及:
Content-Range:bytes 104857600-1073741823/1073741824
意思就是:
当前返回:104857600到1073741823整个文件:1073741824 字节
客户端把这一段接到原来的文件后面。
断点续传就完成了。
我原本准备自己解析:
request.getHeader(”Range”)然后开始算:
startendlength
结果翻 Spring MVC 文档的时候发现,这件事情 Spring 其实早就帮我们做了。
Spring MVC 当前原生支持 RFC 9110 的 Range Request。
只要 Controller 返回:
Resource或者:
ResponseEntitySpring MVC 可以自动解析客户端的 Range Header,并处理 Partial Content。需要注意的是,Resource 不能使用 InputStreamResource;如果返回 ResponseEntity,正常返回状态应该保持为 200,由 Spring 根据 Range 请求完成后续处理。
所以最后代码反而比刚才手写 OutputStream 更简单。
我把接口改成了这样:
@RestController@RequestMapping(”/api/files”)public class FileDownloadController {private final FileService fileService;public FileDownloadController(FileService fileService) {this.fileService = fileService;}@GetMapping(”/{id}/download”)public ResponseEntity download(@PathVariable Long id)throws IOException {FileInfo file =fileService.findById(id);Path path =Path.of(file.getStoragePath());if (!Files.exists(path)|| !Files.isRegularFile(path)) {throw new FileNotFoundException(”file not found”);}Resource resource =new FileSystemResource(path);return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).header(HttpHeaders.CONTENT_DISPOSITION,ContentDisposition.attachment().filename(file.getOriginalName(),StandardCharsets.UTF_8).build().toString()).header(HttpHeaders.ACCEPT_RANGES,”bytes”).body(resource);}}
没有:
byte[]没有:
Files.readAllBytes()甚至我自己都不用再:
input.transferTo(output)文件直接作为:
FileSystemResource交给 Spring MVC。
普通请求:
curl -v \http://localhost:8080/api/files/100/download
正常下载整个文件。
如果只要某一段:
curl -v \-H ”Range: bytes=1048576-2097151” \http://localhost:8080/api/files/100/download
服务器就会按 Range 返回对应的数据。
真正支持断点续传的下载工具,也可以直接使用类似:
curl -C - \-O \http://localhost:8080/api/files/100/download
中途停掉。
重新执行。
客户端会从已有的位置继续。
这里有一个细节特别容易写错。
就是:
InputStreamResource很多人做文件下载的时候会习惯写:
InputStreamResource resource =new InputStreamResource(Files.newInputStream(path));
然后:
return ResponseEntity.ok().body(resource);
普通下载可能没感觉。
但如果你要让 Spring MVC 自动处理 Range,不要这么干。
Spring 官方当前的 Range Request 文档明确说明:
Resource must not be an InputStreamResource因为 Range 请求要求可以按照指定位置访问资源,而 InputStreamResource 更像一次性的顺序输入流。
本地文件直接:
new FileSystemResource(path)更合适。
改完这个接口以后,我又发现以前的下载逻辑还有一个问题。
我们居然允许前端直接传文件路径。
类似:
GET /download?path=/data/files/a.zipController:
@GetMapping(”/download”)public Resource download(@RequestParam String path) {return new FileSystemResource(path);}
这种接口我现在基本不会留。
因为这意味着用户输入:
path最后直接变成服务器文件系统路径。
如果哪天路径校验漏掉:
../事情就麻烦了。
现在我的接口只接受:
fileId例如:
GET /api/files/100/download服务器自己查:
100↓数据库↓storagePath
类似:
/data/storage/2026/09/2e1f4f89-9adc-45dc-a1ef.bin
原始文件名:
产品演示视频.mp4只是文件 metadata。
客户端永远不能决定:
服务器到底读取哪个物理路径。还有权限问题。
大文件下载非常容易出现一种错误:
文件列表接口有权限。
下载接口反而没有。
比如:
GET /api/projects/10/files会检查:
当前用户是否属于项目 10但下载的时候:
GET /api/files/888/download只根据:
888查文件。
结果只要别人猜到 fileId,就可以把文件下载走。
所以我现在不会简单:
fileRepository.findById(id)而是把权限条件直接放进去。
例如:
public FileInfo findDownloadableFile(Long userId,Long fileId) {return fileRepository.findByIdAndUserId(fileId,userId).orElseThrow(() ->new FileNotFoundException());}
或者多租户系统:
tenantId+fileId
一起查。
不要先查出文件,再忘记做授权。
做完 Range 以后还有一个问题必须考虑:
文件在续传期间不能偷偷变。
假设用户昨天开始下载:
video.mp4下载了前 3GB。
今天重新继续。
结果服务器上的同一个文件路径已经被覆盖成另外一个版本。
客户端再从:
3GB继续拼。
最后得到的可能是:
前 3GB 是旧文件后 5GB 是新文件
文件直接损坏。
所以大文件系统里,我一般不会让一个存储对象被原地覆盖。
例如数据库记录:
file_id = 100sha256 = ...storage_key = ...
上传新版本以后:
创建新的 storage_key而不是覆盖:
/data/files/current.zip旧文件只有在确认没有引用以后再清理。
对于真正需要严格断点续传的客户端,还可以结合:
ETagIf-RangeLast-Modified
确认当前资源是不是之前那个版本。
思路其实和数据库乐观锁差不多:
我可以继续从 3GB 下载。但前提是:服务器上的这个文件还是我上次下载的那个文件。
做到这里,我又测试了一下内存。
原来的:
Files.readAllBytes()下载一个:
2GB的文件已经非常危险。
因为首先要分配巨大的连续 byte[]。
而流式文件响应的内存模型完全不一样:
磁盘↓小块读取/传输↓Socket↓客户端
文件有多大,并不代表 JVM Heap 里就需要存在同样大小的数据。
这也是大文件下载最应该先处理的问题。
不是先想:
怎么把 JVM 从 2GB 调到 16GB。而是先确认代码里有没有:
Files.readAllBytes(...)resource.getContentAsByteArray()ByteArrayOutputStream或者:
byte[] fileContent这种把整个文件物化到内存里的操作。
如果某些特殊场景确实需要自己控制传输范围,JDK 还有:
FileChannel.transferTo(...)比如:
try (FileChannel fileChannel =FileChannel.open(path,StandardOpenOption.READ);WritableByteChannel output =Channels.newChannel(response.getOutputStream())) {long position = start;long remaining = length;while (remaining > 0) {long transferred =fileChannel.transferTo(position,remaining,output);if (transferred <= 0) {break;}position += transferred;remaining -= transferred;}}
FileChannel.transferTo() 可以直接从文件指定位置向目标 Channel 传输数据,而且 JDK 文档明确说明,它在很多系统上可能比“读进用户态缓冲区再写出去”的普通循环高效得多;具体是否能利用更底层的直接传输优化,则取决于操作系统和目标 Channel。
不过普通 Spring MVC 文件下载,我现在不会一开始就手写这些东西。
Spring 已经能处理:
RangeResourceRegionPartial Content
就先用框架现成能力。
只有真的测出来:
吞吐量不够CPU 高特殊限速特殊存储协议
再往更底层走。
这里还有一个现实问题。
如果我的文件最终其实存在:
Amazon S3阿里云 OSS腾讯云 COSMinIO
我通常不会让:
用户↓Spring Boot↓对象存储
这样下载一个 10GB 文件。
因为这等于同样的 10GB 流量先进入应用服务器,再从应用服务器出去。
Java 服务变成了纯粹的数据搬运工。
对于这种场景,我更倾向于:
用户请求下载↓Spring Boot 做权限检查↓生成短期签名 URL↓客户端直接从对象存储/CDN 下载
例如有效:
5 分钟的临时 URL。
Spring Boot 负责:
你有没有资格下载?真正的文件流量让:
对象存储+CDN
承担。
如果有 100 个用户同时下载 5GB 文件,这个区别非常大。
什么时候应该让 Spring Boot 自己返回 Resource?
比如:
内部管理系统本地文件小规模下载必须经过应用权限控制文件暂时没有对象存储
完全没问题。
什么时候应该考虑对象存储直出?
比如:
高清视频安装包模型文件大型备份上百 GB 数据集大量并发下载公网用户
这时候 Java 最好的“下载优化”往往不是继续优化:
OutputStream而是根本不要让文件经过 Java。
还有一个经常被忽略的问题:
不要只限制上传,不限制下载。
比如同一个用户同时开:
20 个 10GB 下载即使 JVM 不 OOM,服务器:
磁盘 IO网络带宽连接数线程
一样会被占满。
所以真正上线以后,我通常还会加:
单用户并发下载数单租户并发数接口限流带宽限制下载审计
比如会员系统:
普通用户:同时 2 个下载企业用户:同时 10 个下载
这种限制不一定非得写在 Java 里。
Nginx、网关、CDN、对象存储很多时候做得更合适。
改完以后再回头看最开始的代码:
byte[] bytes =Files.readAllBytes(path);return ResponseEntity.ok().body(bytes);
它其实不是“错误代码”。
一个:
200KB的 PDF,用它完全可以。
真正的问题是,业务的数据规模变了,代码模型却没有跟着变。
文件从:
2MB变成:
10GB以后,如果我们还只是不断调整:
-Xmxserver.tomcat.max-swallow-sizenginx timeout其实都没有解决核心问题。
现在我的大文件下载流程大概是:
客户端↓请求 fileId↓鉴权↓查文件 metadata↓确认文件存在↓FileSystemResource↓Spring MVC 处理 Range↓206 Partial Content↓客户端断点续传
如果文件在对象存储:
客户端↓Spring Boot 鉴权↓生成短期签名 URL↓S3 / OSS / COS / MinIO↓客户端直接下载
两种方式都不会再让:
10GB 文件先变成一个:
byte[]躺进 JVM Heap。
前几天写大文件上传的时候,我最后说的是:
真正需要解决的,不是 Spring Boot 能不能接受一个 5GB 请求,而是上传到 95% 失败以后,用户要不要从头再来。
大文件下载其实也是一样。
真正的问题不是:
Spring Boot 能不能返回一个 10GB 文件?当然能。
真正的问题是:
下载到 9GB 网络断了以后,用户是不是还得重新下载那 9GB。
如果答案还是“是”,那这个大文件下载接口其实还没做完。



