Java大文件上传:用流式处理避免服务器崩溃
引言
你肯定见过那样的教程:一个 @PostMapping 端点,一个 MultipartFile 参数,一次 transferTo() 调用。五分钟就搞定了。在本地跑得飞快。
然后你第一个 500MB 的上传打到生产环境,Pod 就因 OutOfMemoryError 崩溃了。
问题不在你的代码——而在于大多数教程里一个被写死的假设:文件足够小,小到在你的 handler 运行之前,就可以完全缓冲在内存里或磁盘上。在真实场景中——媒体平台、文档管理、医疗、金融科技——这个假设很快就会崩塌。
而且 WebFlux 版教程往往也好不到哪去。它只是把缓冲挪到了一个不那么显眼的地方。
这是关于在 Java 中构建生产级上传管道的短系列文章的第一部分。本文我们将覆盖:
- 为什么
MultipartFile会在你碰到它之前就把整个文件缓冲完毕 - 为什么 WebFlux 中的
@RequestPart做的事情一模一样,包括那个人人都在照抄的版本 - 唯一真正能做流式处理的 WebFlux API,以及正确使用它需要付出什么代价
第二部分将从这里继续:把这条流存入 S3,仅凭少量字节(而非整个文件)来检测文件类型,以及在规模化场景下安全地运行杀毒扫描——其中还包括一个在你把上传端点暴露到公网之前就该了解的故障模式(一个看似无害的文件,却能拖垮整台服务器)。它还会在 Servlet 技术栈上把这套流程再走一遍,照顾到许多无法迁移到 WebFlux 的团队——所以如果你用的是 Spring MVC,请跟着我读下去。
为什么 MultipartFile 是个陷阱
Spring 的 MultipartFile(以及它的各种等价物)看起来很便利,但在底层,Servlet 容器会在你的 controller 方法被调用之前,就把整个请求体缓冲起来——要么放在内存里,要么放在临时文件里。
对于一个 2GB 的视频上传,这意味着:
- 每次并发上传都会占用 2GB 的堆内存或磁盘
- 在收到完整文件之前,无法向存储进行流式写出
- 在你付出全部 I/O 代价之前,你自己的校验根本没有机会运行
要弄清楚你实际得到的是哪一种,因为它们的失败方式不同:
- **
file-size-threshold: 0(Tomcat 的默认值)**会把整个请求体写入临时文件。十个并发的 2GB 上传,就是 20GB 你未曾预料到的临时磁盘占用,而且直到最后一个字节落地之前,什么都到不了你的存储层。 - 把阈值设得高于文件大小则会把它留在堆里。这种版本会直接杀死 JVM——顺便连带所有其他正在处理中的请求。
在继续之前,先做一个诚实的说明:容器确实可以依据它自身的限制提前拒绝——Tomcat 会在解析过程中执行 max-file-size。你失去的并不是所有拒绝能力,而是你自己的拒绝能力:没有配额检查,没有 content-type 策略,也没有任何持久化,直到最后一个字节被写入临时文件。第二部分讲的就是这些校验,以低成本方式完成。
全局视角:本系列将走向何方
在深入 WebFlux 的具体细节之前,先看看我们在整个系列中要构建的完整管道长什么样——这样下文的 WebFlux 部分在上下文中才说得通:
贯穿每一步的共同主线:只从网络读取流入的字节恰好一次,绝不需要在整个文件都躺在内存或磁盘上之后才能行动。本文的一切都在讲第 1 步——首先从传入请求中拿到一条真正的流。那正是第二部分构建存储与分析的基础。
大多数 WebFlux 教程搞错的部分
下面这个版本你很可能见过,而它并不做流式处理:
看起来没问题。Flux<DataBuffer> 是响应式的分块流,类型本身就写着“stream”,那它肯定是在流式处理。并非如此,而且 Spring 在自己的文档里就说了:
要以流式方式解析 multipart 数据,你可以使用由
PartEventHttpMessageReader返回的Flux<PartEvent>,而不是使用@RequestPart,因为后者意味着按名称对各个部分进行类似Map的访问,因此要求完整解析 multipart 数据。
这就是全部的诀窍,说得很直白:@RequestPart 的意思是“按名字找到这个 part”,而你无法在一个还未读完的请求体里按名字去查找 part。所以 Spring 会先把整个东西读完。
这对你可能想用的每一种签名意味着什么:
这四种里有三种值得细看,因为它们各自以不同的方式失败。
**FilePart 是一个由临时文件支撑的 MultipartFile。正是这一种让人栽跟头,因为 FilePart 看起来像是那个现代、响应式的答案。其实不是:当你的 handler 被调用时,这个 part 已经被完整接收了——小于 maxInMemorySize 就放在内存里,否则写入磁盘上某个 .multipart 临时文件。Spring Boot 自己的配置参考就承认了这一点,它把这个临时文件设置描述为“用于存储大于 maxInMemorySize 的文件 part 的目录。在使用 PartEvent 流式支持时被忽略”——这个设置之所以存在,正是因为 @RequestPart 会在你看到 part 之前就把它们写到磁盘上。
**Flux<DataBuffer> 版本会大张旗鼓地失败,而且让人摸不着头脑。**裸的 DataBuffer 没有响应式适配器,于是解码器退回它的单值路径,把你的“流”拼成一个复合缓冲区,受 spring.http.codecs.max-in-memory-size 限制。在默认的 256KB 下,一个 1MB 的上传会返回 HTTP 413——没人会指望从一个没有配置任何大小限制的端点那里得到这个。把这个限制调高来“修复”它,你就给自己买来了每次上传都在堆里完整复制一份:OOM,只不过多了几个步骤。
**而且 DataBufferUtils.join() 也帮不上忙。**常见的“务实桥接”写法是:
两个问题。join() 会把每一个缓冲区聚合成一个复合缓冲区,内存随文件大小增长——而这正是我们想避免的东西。而且 .block() 会在 handler 被调用的那个 Netty 事件循环线程上运行,所以它根本走不到那一步:
这不是一个调优问题。这是一个“这段代码根本没法运行”的问题。
Spring Boot:正确地实现 WebFlux 响应式 Multipart
真正做流式处理的 API 是 @RequestBody Flux<PartEvent>:你不是拿到一个按名字索引的 part 映射,而是随着请求体被解析,拿到一条扁平的事件流。一个表单字段产生一个 FormPartEvent;一个文件产生一个或多个 FilePartEvent,每个都携带一个缓冲区。每个 part 的最后一个事件会设置 isLast(),正是这一点让你能在不缓冲的情况下把事件流重新拆分回 part。
服务按顺序、一次处理一个 part:
这里面有两个细节是承重的。
windowUntil(PartEvent::isLast) 把扁平的事件流拆成一个个 part 窗口;然后 concatMap 一次处理一个,于是请求体没有任何可以堆积的地方。而且由于它只有一趟扫描,part 的顺序很重要:metadata 必须在 file 之前发送,因为不存在第二趟扫描来重新查找它。这是一个真实的约束,而非风格选择——这就是不缓冲的代价。
另一个细节,一旦搞错就会泄漏内存。FilePartEvent 的内容缓冲区必须被消费、转发或释放——每一个都要——而框架不会替你完成。
流式写入存储
存储后端接收文件 part 的缓冲区,并在它们到达时写入:
这里有四处值得点名,因为每一处在朴素版本里都是一个 bug:
- **
DataBufferUtils.write不会释放它写入的缓冲区——而是重新发射它们。**订阅者必须负责释放。漏掉那一行,池化的堆外内存就会在多次上传中稳定泄漏,直到进程死亡。这是手写流式代码里最常见的单一缺陷。 - **当流失败时,已写入的字节仍留在磁盘上。**部分文件会在出错和取消时(客户端断开)被删除,而真正的文件只会通过原子重命名出现——所以一个写到一半的上传,绝不会以调用方所获得的那个 key 显现。
- 大小上限是在流中途强制执行的,这就是为什么超大上传会被早早拒绝,而不是在全量传输之后。这个权衡很诚实,也值得了解:当你中途放弃上传时,客户端可能在你的错误响应体到达之前就看到连接断开。对廉价的东西要在请求体开始前拒绝;如果调用方需要干净的响应,就先排空再报错。
- 每块数据在写入前都会被拷贝到堆上,因为把 Netty 池化的直接缓冲区直接交给文件通道并不具备可移植性。在 Windows 上配合当前的 JDK,它会在 JDK 内部抛出
UnsupportedOperationException: ByteBuffer derived from closeable shared sessions not supported——通道需要一个缓冲区并不具备的稳定地址。每块一次 memcpy,到处都能跑。
背压是免费获得的,也值得理解:DataBufferUtils.write 只在上一次写入完成后才请求下一个缓冲区,而这种需求会沿解析器一路回传到 socket。慢磁盘会拖慢客户端,而不是把你的堆填满。
curl 的形态
因为 part 是按顺序处理的,而且带显式 content type 的 JSON part 在 Spring 看来并不算“表单字段”,所以请求长这样:
元数据作为普通字段,放在最前。文件在后。配套仓库里的端点也接受强类型形式,但普通字段才是你要的。
它真的在流式处理吗?用测量代替断言
像“内存由分块大小决定,而非文件大小”这样的断言,理应拿出证据,所以配套仓库附带了两个测试——换作是我,在看到它们之前是不会相信这一切的。
**在上传完成之前,字节就已经落盘。**测试客户端发送一个分块,然后拒绝发送剩余部分,直到它观察到服务端出现了一个非空的 .part 文件。缓冲式实现绝无可能通过这个测试——它在请求体完成之前永远不会写出任何东西,客户端就会一直等下去,于是测试因超时而失败,而不是因为错误的原因侥幸通过。在流式路径上,它观察到磁盘上已有约 59KB,而剩余的 4MB 尚未发送。
**一个比堆大好几倍的文件。**应用以独立进程运行,硬性设置 -Xmx256m,测试实时生成并上传 512MB——任何地方都没有巨大的临时文件——然后校验落盘内容的 SHA-256:
同样的字节,同样的堆,只是换了端点。整篇论证都浓缩在这一张表里。
我最初搞错的两件事
两件都是通过测量发现的,而且两件都留在仓库的提交历史里,而非悄悄修补掉:
**决定吞吐量的是分块大小,而不是写入批处理。**Reactor Netty 的 HTTP 解码器默认每次给应用 8KB,所以一次 512MB 的上传会以约 65,000 个独立 part 事件的形式到达。把解码器的 maxChunkSize 提高到 256KB,把它降到约 8,200 个事件,同一次上传从约 29s 缩短到约 14.5s——而内存占用毫无变化,因为缓冲区仍然是一次消费一个。(做个参照:同一台机器上裸磁盘写入吞吐约 81MB/s,所以到那个点你受限于磁盘和哈希计算,而非流式设计。)
**一个更聪明的批处理算子同样是不正确的。**每次写入聚合若干分块,对耗时的影响不到 5%,而且结果并不安全:bufferTimeout 依据一个独立于下游需求的定时器发射,而写入器一次只请求一次写入。在负载下它会产生 OverflowException: Could not emit buffer due to lack of requests。测试套件漏掉了它,因为它依赖时序;一个手动的 curl 循环把它抓了出来。它被移除了,剩下的路径严格由需求驱动——也正是这一点让背压变得真实。
关于限制的说明
如果你从本文只带走一条配置教训,就带走这一条:在 WebFlux 里,max-in-memory-size 与你所能接受的上传大小毫无关系。
spring.webflux.multipart.max-in-memory-size—— 对@RequestPart而言,它是 part 溢写到磁盘的阈值。对PartEvent而言,它只约束表单字段。它并不约束文件。spring.webflux.multipart.max-disk-usage-per-part—— 这才是流式 part 的真正上限,在字节仍在到达时执行。spring.http.codecs.max-in-memory-size—— 约束聚合式解码,包括上面@RequestPart Flux<DataBuffer>的情形。- 你自己的限制,在字节流过时检查——正是它让你能返回有用的错误,而不是框架的泛化拒绝。
如果你在用 Boot 4,还有一点值得知道:spring.codec.max-in-memory-size 已经不存在了。现在叫 spring.http.codecs.max-in-memory-size,而每一个还在用旧名字的教程,其实是在静默地配置着空无一物。## 接下来
接下来是上述所有内容的 Servlet 技术栈版本——request.getInputStream()、让 multipart 解析器不接触你的上传、有界缓冲区,以及防止快速客户端耗尽慢速磁盘的线程池策略。如果你使用 Spring MVC,并且读到这里时心想“这些都不适用于我的应用”,那这一篇就是为你写的:你不必迁移到 WebFlux 才能停止缓冲上传。
之后,这些上传所接入的流水线:
- 存储:通过分片上传将相同的字节流式传输到 S3,仍然不进行缓冲——使用
AsyncRequestBody.fromPublisher(),而不是退回到InputStream桥接。 - 类型检测:只读取约 500 字节而不是整个文件,来决定是否接受它;以及为什么将 Apache Tika 作为嵌入式库运行在面对恶意输入时会成为隐患(以及
tika-server如何解决这个问题)。 - 杀毒:限制 ClamAV 扫描范围,使 100GB 的上传不会拖垮扫描器;以及对于大到无法完整扫描的文件该怎么办。
结论
朴素的文件上传在文件大小或并发量增长到超出缓冲所能吸收的范围之前,都能正常工作。WebFlux 版本的朴素做法更加隐蔽,因为 @RequestPart 和 FilePart 看起来都像流式 API,却会悄悄先解析整个请求体——FilePart 是一个由临时文件支持的 MultipartFile,只是披着响应式的外衣。真正实现流式处理的 API 是 @RequestBody Flux<PartEvent>,但它也对你有所要求:按顺序处理各个 part,释放你接触到的每一个缓冲区,在传输中途失败时自行清理,并在字节仍在流动时执行你自己的限制。
做到这一点,512MB 上传占用的堆内存就与 5MB 上传相同——这是本系列其他所有内容赖以构建的基础。
完整源代码(包括验证本文每项结论的测试)位于 GitHub:denis111/spring-boot-large-file-upload
觉得有用?接下来:为无法迁移到 WebFlux 的人,在 Servlet 技术栈上实现同样的模式——随后是流式存储到 S3、tika-server,以及有界杀毒扫描。