上传上限放到 1GB,为什么仍然 OOM:附件复用与流式传输实践
组件提示上传成功,点击确定却发生 Java 堆溢出,问题藏在二次上传和 multipart 内存编码里。本文从异常栈还原链路,再用附件 ID 复用、磁盘中转和流式发送控制资源占用。
🎧 文章导读
“上传成功”之后,才发生第二次上传
用户选择大文件后,组件显示上传成功;点击确定,页面却提示系统繁忙,素材列表也找不到新记录。这不是矛盾,而是页面把两个阶段压缩成了一个词。
旧链路中,组件先将文件上传到附件服务。点击确定时,前端又把原始文件放进 FormData 发给业务服务,业务服务再通过 Feign multipart 转发一次,之后才发起审批、保存素材。
异常栈正好落在第二次转发的编码阶段:
1 | java.lang.OutOfMemoryError: Java heap space |
这段证据把问题收敛到 multipart 内存编码。调用还没走到后面的流程和入库,因此这次提交没有产生新素材记录。
图 1:复用第一次上传得到的附件 ID,避免业务提交再次携带文件。
同时间段还有其他消息监听与设备同步错误,但没有匹配请求标识,不能把它们都归到这次上传。HTTP 200 也只说明 HTTP 层正常返回,仍需看业务响应体。
接收上限解决不了整文件缓冲
前期曾把前端、网关及后端允许的文件大小放宽到 1GB。这个配置解决的是“请求会不会被拒绝”,不是“后端处理时会占多少堆”。
multipart 编码使用 ByteArrayOutputStream 时,文件可能被完整装进堆,扩容还伴随复制。业务本身、并发请求和其他缓冲也要占内存,所以不能用“文件小于 -Xmx”推导它一定安全。
更麻烦的是审批后的设备同步:旧链路把下载内容转成内存中的 MultipartFile,再发给集成服务。只修新增入口,会把同一类 OOM 留到审批通过之后。
| 阶段 | 真正需要核对的事 |
|---|---|
| 前端与网关 | 文件限制还是整个请求体限制 |
| multipart 编码 | 是否聚合成完整字节数组 |
| HTTP 客户端 | 是否缓冲请求或响应体 |
| 日志拦截器 | 是否读取正文后重新构造缓冲 |
| 审批后同步 | 是否再次下载、转换、转发整文件 |
历史上的流式预览提交也被检查过。它没有修改本次上传方法,因此不能因为提交名称里有“大文件”,就把它当成上传 OOM 的直接原因。本次保留了预览能力,修正实际命中的上传链路。
新增素材:提交附件引用,并验证元数据
新页面第一次上传拿到附件 ID,点击确定时只提交附件 ID 与素材信息。下面是通用示意,具体字段以业务接口为准:
1 | { |
后端不能因为拿到一个 ID,就认为它是用户有权使用的素材。此次复用既有附件查询接口,检查当前上传人、业务类型、受控路径前缀和大小,使用服务端元数据保存素材,不信任浏览器自报的路径或文件大小。
原文件参数仍保留以兼容旧客户端。但这也意味着旧页面继续走旧提交形式时,仍可能进入二进制编码链路;发布不能只更新后端就宣称新增入口已经彻底避开风险。
另外,文件大小在新增时直接写入素材记录,避免依赖设备同步成功之后才回填。提示语也按阶段区分:文件上传完成、素材提交审批、审批后可见。失败后保留已上传附件和重试机会,避免用户重复传输大文件。
设备同步:用磁盘承接容量,用流传递内容
审批通过后,业务服务只向集成服务发送系统内受控素材路径。集成服务把内容下载到临时文件,再以文件资源流式上传设备平台。
这里的“路径”不是允许用户任意指定外网 URL。它必须限定在系统可验证的素材来源,避免把接口变成任意地址下载器。
图 2:小缓冲承担传输,临时磁盘承担文件容量;日志也必须避免整包读取。
对于本次所处的 Spring 5.x 客户端配置,可参考如下方式关闭请求体缓冲;Spring 5.3.39 API 文档说明了该开关的用途。其他版本和 HTTP 客户端应检查各自实际行为。
1 | SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); |
这段代码只展示关键配置,不是完整上传器。若外挂读取整个请求体的日志拦截器,或者把文件先变成 byte[],仍会破坏流式目标。本次为下载使用独立客户端及 NoOpLogger,避免 Feign FULL 日志重新缓冲完整响应。
临时文件的收尾也要保留原始异常。清理失败应记录路径与告警,不能盖住上传失败的原因,更不能把已经上传成功改成失败:
1 | try { |
磁盘中转把容量压力从 Java 堆转到了磁盘。每个活动同步都可能占一份完整文件;多实例和多任务下必须按并发计算空间,不能只看单实例线程数。
验证要让文件比堆大,还要检查隐藏缓冲
这次大文件验证在 -Xmx128m 下,用生成的 1GB 数据流完成下载落盘,再上传到本地模拟设备接口,核对传输量与临时文件清理。测试环境同时开启全局 Feign FULL 日志,检查独立下载客户端没有重新缓冲完整响应。
这是比“小文件上传成功”更有针对性的验证:它能暴露整文件入堆的实现。但它没有证明真实设备的转码吞吐,也不代表所有并发场景都安全。
根据原始记录,最终相关后端 13 个测试、前端 11 个测试及改动文件 ESLint 通过;发布与真实设备验证仍待进行。本篇没有再次执行 1GB 传输测试。
上线还要核对两个容易忽略的配置:一是 multipart 有额外字段与边界,完整请求体上限应高于单文件上限;二是外层等待时间要覆盖下载、上传与设备处理全过程。不能未经核实,就把已有较长超时改成一个看似合理的较短值。
本次推荐顺序是先发布集成服务,再业务服务,最后前端;附件服务需已具备原来的流式下载能力。发布后分别验收新增、审批、设备同步与预览,而不是只看到“文件上传完成”就结束。
这次修复留下的原则很具体:先减少不必要的文件传输,再确认每一段都没有整包缓冲。 大文件能力取决于完整链路的资源使用方式,不是一个上传上限配置。
延伸阅读
- 命令发送成功,数据却没回来:一次设备同步故障与补录设计
- 把 WebSocket 长期密钥移出前端:短期票据改造与上线闭环
- 从一条告警到真正闭环:规则、站内信、工单与权限的整合实践
- 8.7 秒到 231 毫秒:保留统计口径的 MySQL 窗口函数优化
本文根据 2026 年 9 月 22 日至 10 月 22 日的工程笔记整理。账号、凭证、地址和业务样本已脱敏;通用示例已在正文标明,历史验证结果以原记录为准。