ARTICLE · 1190804
下载一个 1.2G 的附件,2G 内存的接口当场 OOM:文件其实根本不用先装进内存
前阵子线上出了个挺离谱的故障。
系统的附件下载接口一直很稳——平时下载的都是几十 MB 的 PDF、Excel 和压缩包,跑了大半年没出过任何事。
直到有人上传了一份归档包。
大小:1.2 GB。
用户点了一下下载,浏览器转了十几秒,文件没下来,监控先报警了。堆内存从 600 MB 一路爬到 1.1 GB、1.5 GB、1.8 GB,然后开始频繁 Full GC,没过多久,日志里就是这个:
我第一反应是"不至于吧"。
文件明明已经躺在磁盘上了,数据该走的路应该是这么一条:磁盘 → 网络 → 浏览器。JVM 凭什么吃掉两个 G?
把 Controller 打开,答案就写在一行上。
01 问题不在 IO,在这一行的语义
出事接口的代码长这样,你大概也在哪个项目里见过:
小文件的时候这么写其实特别舒服:代码少、逻辑直白。一个 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,盯着堆看:
结论很直接:堆占用几乎和文件大小线性挂钩。因为这段代码的内存模型就是"文件多大,数组就多大",没有任何弹性。
所以我后来没有把 -Xmx2g 改成 -Xmx4g。这和我之前处理大 Excel 导出时踩的是同一个坑:
加大堆,只是把"1G 就挂"推迟成"2G 才挂",设计本身一点没变。
真正要改的只有一句话——不要让文件大小决定 JVM 的内存占用。
03 改法一:一边读,一边写
最普通的改法,也最有效。不要一次性读完,改成读一段、写一段:
这里最大的变化不是写法,而是数据路径彻底换了。
改造前是:磁盘 → 整个文件进入 byte[] → 整个数组留在 JVM → 再输出到 HTTP。
改造后是:磁盘 → 读一段 → 写到网络 → 继续读下一段。
JVM 不再需要同时拥有那 1.2 GB,只需要维护一个有限的缓冲区。文件是 10 MB 还是 10 GB,内存模型都不该跟着线性增长——这才是大文件下载该有的样子。
顺便说一句,别指望靠调大 bufferSize 解决问题。缓冲区从 8 KB 调到 1 MB,只是把"搬一次"变成"搬得顺一点",和"整个文件进堆"是两件完全不同的事。
04 改法二:让 StreamingResponseBody 来接
Spring MVC 本身就提供了 StreamingResponseBody,改完之后 Controller 只负责把响应头写清楚:
Spring MVC 对它的定位很直接:允许 Controller 直接往响应的 OutputStream 写数据,而且是异步处理模式,非常适合原始数据流——文件下载就是最典型的例子。
这么写还有一个额外好处:"读文件"和"写 HTTP"被自然地连成了一条,中间那个巨大的数组彻底不存在了。
05 光会流式还不够:断点续传
假设用户下载一个 3 GB 的文件,下到 2.4 GB 的时候 Wi-Fi 断了。如果接口只会"从第 0 个字节重新开始",用户恢复网络之后只能重新下 3 GB——这个体验当然很糟。
所以大文件下载还有一个功能非常重要:Range 请求。
浏览器或者下载器可以发这么一个请求,意思是"前面已经下这么多了,从这里继续给我":
服务器返回 206,只发剩下的部分。这就是平时说的断点续传。
好消息是,这个东西在 Spring MVC 里不一定要自己从头实现。只要 Controller 返回 Resource 或 ResponseEntity<Resource>,框架会透明处理符合 RFC 9110 的 Range 请求。
本地文件甚至可以写得更简单:
看起来比自己手写 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,看起来已经避开大数组了,但写成了这样:
这其实只是换了个姿势。最终还是整个文件进 JVM,而且某些情况下内存峰值更难看——因为 ByteArrayOutputStream 内部那个不断扩容的 buffer,和最后 toByteArray() 复制出来的那份数据,可能在同一时刻同时存在。
所以我现在排查大文件接口,不会只搜 Files.readAllBytes,而是把下面这几个都搜一遍:
Files.readAllBytes()InputStream.readAllBytes()byte[]ByteArrayOutputStreamMultipartFile.getBytes()
这些 API 本身都没错。真正的问题是:它对应的数据,最大可能有多大?
如果明确只有几 KB、几十 KB,随便用,代码简单最重要。但如果这个数据来自用户上传、附件、视频、压缩包、报表、数据库 BLOB 或者对象存储,就必须先问自己一句:它未来有没有可能变成几百 MB?
09 顺手补的一道闸:按大小分流
以前这个接口是"只要文件存在就允许下载",意味着理论上用户传了个 100 GB 的文件,Web 服务也得接着。
现在文件元数据里明确记录 fileSize 和 storageType,下载之前先判断:
不再让所有场景走同一条路。
其实后台系统很多功能一开始都容易犯同一个毛病。写代码的时候默认:文件不会太大、列表不会太长、Excel 不会超过几万行、一次请求不会有几十万条数据。刚上线时这些假设全部成立,三年以后——附件变成 2 GB、订单变成千万条、导出的 Excel 变成 80 万行,代码还是第一版。
10 最后
最后这个接口,我没有加 JVM 内存,没有换 WebFlux,也没有为了"下载一个文件"引什么复杂框架。只是把最开始那两行:
改掉了:
本地文件用 FileSystemResource需要自己控制输出的场景用 StreamingResponseBody对象存储的大文件返回短期签名 URL
从此接口不再随着文件变大、堆内存跟着变大。
回头看最开始那段代码,其实特别像很多后台系统第一版会写出来的东西。文件只有 5 MB 的时候,readAllBytes() 非常合理——如果产品明确规定"最大文件 20 MB",我甚至不会去改它。
真正的问题是:这个接口没有任何地方保证过"文件永远只有 20 MB"。 于是一次 1.2 GB 的附件,就把一个看起来非常普通的下载接口,变成了 JVM OOM。
所以现在看到 byte[] 去接文件内容,我都会顺手问一句:这个数组最大可能多大?如果答案是"不知道",那这段代码基本就该重新看一遍。
因为文件下载这件事,本质上是搬数据,不是持有数据。一个 5 GB 的文件从磁盘到浏览器,JVM 没必要先把这 5 GB 全部认识一遍。
它只需要:读一点,发一点,然后继续读。
如果这篇对你有帮助,点个在看和星标。后面还会继续写这类"上线时才暴露"的性能坑,以及我踩过的那些弯路。















































































