夜雨聆风学习资料网

ARTICLE · 1190804

下载一个 1.2G 的附件,2G 内存的接口当场 OOM:文件其实根本不用先装进内存

下载一个 1.2G 的附件,2G 内存的接口当场 OOM:文件其实根本不用先装进内存

前阵子线上出了个挺离谱的故障。

系统的附件下载接口一直很稳——平时下载的都是几十 MB 的 PDF、Excel 和压缩包,跑了大半年没出过任何事。

直到有人上传了一份归档包。

大小:1.2 GB。

用户点了一下下载,浏览器转了十几秒,文件没下来,监控先报警了。堆内存从 600 MB 一路爬到 1.1 GB、1.5 GB、1.8 GB,然后开始频繁 Full GC,没过多久,日志里就是这个:

LOGapp.log
[Full GC (Ergonomics)  1786M->1791M(2048M), 3.921 secs]
[Full GC (Ergonomics)  1790M->1791M(2048M), 4.106 secs]
java.lang.OutOfMemoryError: Java heap space

我第一反应是"不至于吧"。

文件明明已经躺在磁盘上了,数据该走的路应该是这么一条:磁盘 → 网络 → 浏览器。JVM 凭什么吃掉两个 G?

把 Controller 打开,答案就写在一行上。

01 问题不在 IO,在这一行的语义

出事接口的代码长这样,你大概也在哪个项目里见过:

JavaFileDownloadController.java  (改造前)
@GetMapping("/files/{id}/download")
publicResponseEntity<byte[]> download(@PathVariableLongid) throwsIOException {
FileInfofile = fileService.findById(id);
// 把整个文件一次性读进堆内存
byte[] bytes = Files.readAllBytes(Paths.get(file.getFilePath()));
returnResponseEntity.ok()
            .header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=" + file.getFileName())
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .body(bytes);
}

小文件的时候这么写其实特别舒服:代码少、逻辑直白。一个 8 MB 的文件读进来、返回、请求结束,你根本感觉不到任何异常。

但当文件变成 1.2 GB,这句话的含义就完全变了。它不再是"下载一个 1.2G 的文件",而是:

先在 JVM 堆里造一个装得下 1.2G 数据的 byte[],然后才开始响应。

也就是说,在浏览器收到第一个字节之前,服务器已经准备好把整个文件吞进内存。问题一下就清楚了。

而且真实占用还不止文件本身。Spring 还要处理 HTTP Response 的缓冲、消息转换器、以及其他并发请求的业务对象。所以一个 -Xmx2g 的服务,并不意味着它能安全地 readAllBytes 一个 1.5 GB 的文件。

更别说线上不可能只有一个用户下载。两个人同时下载 1 GB 的文件,光两个数组就已经非常危险了。

02 我做了个对照实验,然后放弃了"加内存"

怀疑归怀疑,还是拿数据说话。我把下载的文件依次换成 100 MB、300 MB、600 MB、1 GB,盯着堆看:

LOG堆内存观测记录
文件大小       下载前堆占用     下载中的堆峰值
 100 MB           612 MB          ~ 715 MB
 300 MB           608 MB          ~ 914 MB
 600 MB           615 MB          ~ 1.24 GB
   1 GB           610 MB          ~ 1.62 GB

结论很直接:堆占用几乎和文件大小线性挂钩。因为这段代码的内存模型就是"文件多大,数组就多大",没有任何弹性。

所以我后来没有把 -Xmx2g 改成 -Xmx4g。这和我之前处理大 Excel 导出时踩的是同一个坑:

加大堆,只是把"1G 就挂"推迟成"2G 才挂",设计本身一点没变。

真正要改的只有一句话——不要让文件大小决定 JVM 的内存占用。

03 改法一:一边读,一边写

最普通的改法,也最有效。不要一次性读完,改成读一段、写一段:

JavaFileDownloadController.java
@GetMapping("/files/{id}/download")
publicvoiddownload(@PathVariableLongid,
HttpServletResponseresponse) throwsIOException {
FileInfofile = fileService.findById(id);
Pathpath = Paths.get(file.getFilePath());
response.setContentType(MediaType.APPLICATION_OCTET_STREAM_VALUE);
response.setContentLengthLong(Files.size(path));
response.setHeader(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=" + file.getFileName());
// 读一点、写一点,JVM 里只留一个有限的缓冲区
try (InputStreamin = Files.newInputStream(path);
OutputStreamout = response.getOutputStream()) {
in.transferTo(out);
    }
}

这里最大的变化不是写法,而是数据路径彻底换了。

改造前是:磁盘 → 整个文件进入 byte[] → 整个数组留在 JVM → 再输出到 HTTP。

改造后是:磁盘 → 读一段 → 写到网络 → 继续读下一段。

JVM 不再需要同时拥有那 1.2 GB,只需要维护一个有限的缓冲区。文件是 10 MB 还是 10 GB,内存模型都不该跟着线性增长——这才是大文件下载该有的样子。

顺便说一句,别指望靠调大 bufferSize 解决问题。缓冲区从 8 KB 调到 1 MB,只是把"搬一次"变成"搬得顺一点",和"整个文件进堆"是两件完全不同的事。

04 改法二:让 StreamingResponseBody 来接

Spring MVC 本身就提供了 StreamingResponseBody,改完之后 Controller 只负责把响应头写清楚:

JavaFileDownloadController.java
@GetMapping("/files/{id}/download")
publicResponseEntity<StreamingResponseBody> download(@PathVariableLongid)
throwsIOException {
FileInfofile = fileService.findById(id);
Pathpath = Paths.get(file.getFilePath());
StreamingResponseBodybody = out -> {
try (InputStreamin = Files.newInputStream(path)) {
in.transferTo(out);
        }
    };
returnResponseEntity.ok()
            .contentLength(Files.size(path))
            .header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=" + file.getFileName())
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .body(body);
}

Spring MVC 对它的定位很直接:允许 Controller 直接往响应的 OutputStream 写数据,而且是异步处理模式,非常适合原始数据流——文件下载就是最典型的例子。

这么写还有一个额外好处:"读文件"和"写 HTTP"被自然地连成了一条,中间那个巨大的数组彻底不存在了。

05 光会流式还不够:断点续传

假设用户下载一个 3 GB 的文件,下到 2.4 GB 的时候 Wi-Fi 断了。如果接口只会"从第 0 个字节重新开始",用户恢复网络之后只能重新下 3 GB——这个体验当然很糟。

所以大文件下载还有一个功能非常重要:Range 请求。

浏览器或者下载器可以发这么一个请求,意思是"前面已经下这么多了,从这里继续给我":

HTTP断点续传
GET /files/9/download
Range: bytes=2576980377-
HTTP/1.1 206 Partial Content
Content-Range: bytes 2576980377-3221225471/3221225472
Content-Length: 644245094

服务器返回 206,只发剩下的部分。这就是平时说的断点续传。

好消息是,这个东西在 Spring MVC 里不一定要自己从头实现。只要 Controller 返回 Resource 或 ResponseEntity<Resource>,框架会透明处理符合 RFC 9110 的 Range 请求。

本地文件甚至可以写得更简单:

JavaFileDownloadController.java
@GetMapping("/files/{id}/download")
publicResponseEntity<Resource> download(@PathVariableLongid)
throwsIOException {
FileInfofile = fileService.findById(id);
Pathpath = Paths.get(file.getFilePath());
// Resource 里装的是"文件句柄",不是文件的全部内容
// 本地文件请用 FileSystemResource:InputStreamResource 不支持 Range
Resourceresource = newFileSystemResource(path);
returnResponseEntity.ok()
            .contentLength(Files.size(path))
            .header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=" + file.getFileName())
            .contentType(MediaType.APPLICATION_OCTET_STREAM)
            .body(resource);
}

看起来比自己手写 InputStream、OutputStream 都简单,而且这时候 body 里装的是文件句柄,不是文件内容。

这里有个容易被忽略的细节:如果你希望 Spring 自动处理 Range 请求,不要随便返回 InputStreamResource。 官方文档特别注明过,Range 支持要求这个 Resource 不能是InputStreamResource。本地文件场景直接用 FileSystemResource 更合适。

06 换个战场:瓶颈从"堆"变成了"通道"

做到这一步,单个下载已经没什么问题了。但压测又发现了新的现象:

1 个人下载 3 GB:正常。10 个人同时下载:也还行。突然来 100 个人同时下大文件,服务又开始变差。

这时候已经和 JVM 堆没关系了,被占满的是另外一批资源:

  • 磁盘 I/O
  • 网络带宽
  • Servlet 请求线程
  • 并发连接数

而且很多项目的文件根本不在应用服务器上,而是在 MinIO、S3、OSS、COS 这类对象存储里。于是很容易顺手写出这种架构:

浏览器 → Spring Boot → 对象存储

数据回来的时候,路径就变成:对象存储 → Spring Boot → 浏览器。

一个 5 GB 的文件,应用什么业务都没做,只当了一次 5 GB 的中转站——服务器要同时承担 5 GB 的入站流量和 5 GB 的出站流量。100 个人同时下载,这台机器基本就是在专门搬字节。

07 我的处理:按场景分流,而不是一刀切

后来我把这类下载按"文件在哪儿、要不要动态处理"分成了几类。

需要权限校验、但文件本身不敏感的附件,改成先校验、再发一个短期地址:

浏览器 → Spring Boot(校验权限)→ 返回 5 分钟有效的签名 URL → 浏览器直连对象存储

应用不再搬文件本体,只回答一个问题:"这个用户能不能下载?"校验通过就发地址,真正的数据链路变成浏览器对对象存储——应用服务器基本不再受文件大小影响。

这个改造,比优化 bufferSize 有意义得多。

但也不是所有文件都适合直连对象存储。 敏感报表、内部文件、实时生成的文件,以及需要动态水印或下载审计的场景,仍然要经过业务服务——那就继续流式转发。

所以最终方案不是"所有下载都换成 StreamingResponseBody",而是按场景选:

  • 本地文件 → Resource / 流式输出
  • 对象存储里的普通附件 → 短期签名 URL 直传
  • 需要动态处理的文件 → 业务服务流式转发

分流,而不是一刀切。

08 换个姿势,踩的其实是同一个坑

排查过程中还发现一个很常见的变体:代码里没有 readAllBytes,看起来已经避开大数组了,但写成了这样:

JavaFilePreviewService.java
publicbyte[] preview(Longid) throwsIOException {
FileInfofile = fileService.findById(id);
ByteArrayOutputStreamout = newByteArrayOutputStream();
try (InputStreamin = Files.newInputStream(Paths.get(file.getFilePath()))) {
in.transferTo(out);      // 内部 buffer 一路扩容,直到装下整个文件
    }
returnout.toByteArray();    // 再复制一份出来
}

这其实只是换了个姿势。最终还是整个文件进 JVM,而且某些情况下内存峰值更难看——因为 ByteArrayOutputStream 内部那个不断扩容的 buffer,和最后 toByteArray() 复制出来的那份数据,可能在同一时刻同时存在。

所以我现在排查大文件接口,不会只搜 Files.readAllBytes,而是把下面这几个都搜一遍:

  • Files.readAllBytes()
  • InputStream.readAllBytes()
  • byte[]
  • ByteArrayOutputStream
  • MultipartFile.getBytes()

这些 API 本身都没错。真正的问题是:它对应的数据,最大可能有多大?

如果明确只有几 KB、几十 KB,随便用,代码简单最重要。但如果这个数据来自用户上传、附件、视频、压缩包、报表、数据库 BLOB 或者对象存储,就必须先问自己一句:它未来有没有可能变成几百 MB?

09 顺手补的一道闸:按大小分流

以前这个接口是"只要文件存在就允许下载",意味着理论上用户传了个 100 GB 的文件,Web 服务也得接着。

现在文件元数据里明确记录 fileSize 和 storageType,下载之前先判断:

JavaFileDownloadController.java
// 小文件由应用返回,大文件直接去对象存储
if (file.getFileSize() > DIRECT_DOWNLOAD_THRESHOLD) {
returnredirectToObjectStorage(file);   // 302 到短期签名 URL
}
returnstreamLocally(file);

不再让所有场景走同一条路。

其实后台系统很多功能一开始都容易犯同一个毛病。写代码的时候默认:文件不会太大、列表不会太长、Excel 不会超过几万行、一次请求不会有几十万条数据。刚上线时这些假设全部成立,三年以后——附件变成 2 GB、订单变成千万条、导出的 Excel 变成 80 万行,代码还是第一版。

10 最后

最后这个接口,我没有加 JVM 内存,没有换 WebFlux,也没有为了"下载一个文件"引什么复杂框架。只是把最开始那两行:

JavaFileDownloadController.java  (改造前)
byte[] bytes = Files.readAllBytes(path);
returnResponseEntity.ok(bytes);

改掉了:

  • 本地文件用 FileSystemResource
  • 需要自己控制输出的场景用 StreamingResponseBody
  • 对象存储的大文件返回短期签名 URL

从此接口不再随着文件变大、堆内存跟着变大。

回头看最开始那段代码,其实特别像很多后台系统第一版会写出来的东西。文件只有 5 MB 的时候,readAllBytes() 非常合理——如果产品明确规定"最大文件 20 MB",我甚至不会去改它。

真正的问题是:这个接口没有任何地方保证过"文件永远只有 20 MB"。 于是一次 1.2 GB 的附件,就把一个看起来非常普通的下载接口,变成了 JVM OOM。

所以现在看到 byte[] 去接文件内容,我都会顺手问一句:这个数组最大可能多大?如果答案是"不知道",那这段代码基本就该重新看一遍。

因为文件下载这件事,本质上是搬数据,不是持有数据。一个 5 GB 的文件从磁盘到浏览器,JVM 没必要先把这 5 GB 全部认识一遍。

它只需要:读一点,发一点,然后继续读。

如果这篇对你有帮助,点个在看和星标。后面还会继续写这类"上线时才暴露"的性能坑,以及我踩过的那些弯路。

相关学习资料