上传上限放到 1GB,为什么仍然 OOM:附件复用与流式传输实践

组件提示上传成功,点击确定却发生 Java 堆溢出,问题藏在二次上传和 multipart 内存编码里。本文从异常栈还原链路,再用附件 ID 复用、磁盘中转和流式发送控制资源占用。

🎧 文章导读

“上传成功”之后,才发生第二次上传

用户选择大文件后,组件显示上传成功;点击确定,页面却提示系统繁忙,素材列表也找不到新记录。这不是矛盾,而是页面把两个阶段压缩成了一个词。

旧链路中,组件先将文件上传到附件服务。点击确定时,前端又把原始文件放进 FormData 发给业务服务,业务服务再通过 Feign multipart 转发一次,之后才发起审批、保存素材。

异常栈正好落在第二次转发的编码阶段:

1
2
3
4
5
6
7
java.lang.OutOfMemoryError: Java heap space
at java.util.Arrays.copyOf(...)
at java.io.ByteArrayOutputStream.grow(...)
at feign.form.MultipartFormContentProcessor.process(...)
at feign.form.spring.SpringFormEncoder.encode(...)
at ...AttachmentClient.uploadFile(...)
at ...MaterialService.addMaterial(...)

这段证据把问题收敛到 multipart 内存编码。调用还没走到后面的流程和入库,因此这次提交没有产生新素材记录。

二次传输与附件复用
图 1:复用第一次上传得到的附件 ID,避免业务提交再次携带文件。

同时间段还有其他消息监听与设备同步错误,但没有匹配请求标识,不能把它们都归到这次上传。HTTP 200 也只说明 HTTP 层正常返回,仍需看业务响应体。

接收上限解决不了整文件缓冲

前期曾把前端、网关及后端允许的文件大小放宽到 1GB。这个配置解决的是“请求会不会被拒绝”,不是“后端处理时会占多少堆”。

multipart 编码使用 ByteArrayOutputStream 时,文件可能被完整装进堆,扩容还伴随复制。业务本身、并发请求和其他缓冲也要占内存,所以不能用“文件小于 -Xmx”推导它一定安全。

更麻烦的是审批后的设备同步:旧链路把下载内容转成内存中的 MultipartFile,再发给集成服务。只修新增入口,会把同一类 OOM 留到审批通过之后。

阶段 真正需要核对的事
前端与网关 文件限制还是整个请求体限制
multipart 编码 是否聚合成完整字节数组
HTTP 客户端 是否缓冲请求或响应体
日志拦截器 是否读取正文后重新构造缓冲
审批后同步 是否再次下载、转换、转发整文件

历史上的流式预览提交也被检查过。它没有修改本次上传方法,因此不能因为提交名称里有“大文件”,就把它当成上传 OOM 的直接原因。本次保留了预览能力,修正实际命中的上传链路。

新增素材:提交附件引用,并验证元数据

新页面第一次上传拿到附件 ID,点击确定时只提交附件 ID 与素材信息。下面是通用示意,具体字段以业务接口为准:

1
2
3
4
5
6
{
"attachmentId": "example-file-id",
"name": "大厅宣传视频",
"type": "VIDEO",
"vendor": "example-vendor"
}

后端不能因为拿到一个 ID,就认为它是用户有权使用的素材。此次复用既有附件查询接口,检查当前上传人、业务类型、受控路径前缀和大小,使用服务端元数据保存素材,不信任浏览器自报的路径或文件大小。

原文件参数仍保留以兼容旧客户端。但这也意味着旧页面继续走旧提交形式时,仍可能进入二进制编码链路;发布不能只更新后端就宣称新增入口已经彻底避开风险。

另外,文件大小在新增时直接写入素材记录,避免依赖设备同步成功之后才回填。提示语也按阶段区分:文件上传完成、素材提交审批、审批后可见。失败后保留已上传附件和重试机会,避免用户重复传输大文件。

设备同步:用磁盘承接容量,用流传递内容

审批通过后,业务服务只向集成服务发送系统内受控素材路径。集成服务把内容下载到临时文件,再以文件资源流式上传设备平台。

这里的“路径”不是允许用户任意指定外网 URL。它必须限定在系统可验证的素材来源,避免把接口变成任意地址下载器。

流式传输的资源边界
图 2:小缓冲承担传输,临时磁盘承担文件容量;日志也必须避免整包读取。

对于本次所处的 Spring 5.x 客户端配置,可参考如下方式关闭请求体缓冲;Spring 5.3.39 API 文档说明了该开关的用途。其他版本和 HTTP 客户端应检查各自实际行为。

1
2
3
4
5
6
7
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setBufferRequestBody(false);

RestTemplate client = new RestTemplate(factory);
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
body.add("file", new FileSystemResource(tempFile));
// 补齐 multipart 请求头、鉴权与业务参数后,再调用上传接口。

这段代码只展示关键配置,不是完整上传器。若外挂读取整个请求体的日志拦截器,或者把文件先变成 byte[],仍会破坏流式目标。本次为下载使用独立客户端及 NoOpLogger,避免 Feign FULL 日志重新缓冲完整响应。

临时文件的收尾也要保留原始异常。清理失败应记录路径与告警,不能盖住上传失败的原因,更不能把已经上传成功改成失败:

1
2
3
4
5
6
7
8
9
10
try {
downloadToFile(sourcePath, tempFile);
uploadFromFile(tempFile);
} finally {
try {
Files.deleteIfExists(tempFile.toPath());
} catch (IOException cleanupError) {
log.warn("Temporary file cleanup failed: {}", tempFile, cleanupError);
}
}

磁盘中转把容量压力从 Java 堆转到了磁盘。每个活动同步都可能占一份完整文件;多实例和多任务下必须按并发计算空间,不能只看单实例线程数。

验证要让文件比堆大,还要检查隐藏缓冲

这次大文件验证在 -Xmx128m 下,用生成的 1GB 数据流完成下载落盘,再上传到本地模拟设备接口,核对传输量与临时文件清理。测试环境同时开启全局 Feign FULL 日志,检查独立下载客户端没有重新缓冲完整响应。

这是比“小文件上传成功”更有针对性的验证:它能暴露整文件入堆的实现。但它没有证明真实设备的转码吞吐,也不代表所有并发场景都安全。

根据原始记录,最终相关后端 13 个测试、前端 11 个测试及改动文件 ESLint 通过;发布与真实设备验证仍待进行。本篇没有再次执行 1GB 传输测试。

上线还要核对两个容易忽略的配置:一是 multipart 有额外字段与边界,完整请求体上限应高于单文件上限;二是外层等待时间要覆盖下载、上传与设备处理全过程。不能未经核实,就把已有较长超时改成一个看似合理的较短值。

本次推荐顺序是先发布集成服务,再业务服务,最后前端;附件服务需已具备原来的流式下载能力。发布后分别验收新增、审批、设备同步与预览,而不是只看到“文件上传完成”就结束。

这次修复留下的原则很具体:先减少不必要的文件传输,再确认每一段都没有整包缓冲。 大文件能力取决于完整链路的资源使用方式,不是一个上传上限配置。

延伸阅读


本文根据 2026 年 9 月 22 日至 10 月 22 日的工程笔记整理。账号、凭证、地址和业务样本已脱敏;通用示例已在正文标明,历史验证结果以原记录为准。