几个月前在一家做央企信息化的公司做技术支持,碰到个特别典型的问题:用户往系统里传一个 800M 的勘察压缩包,传了快半小时,界面卡死,最后报“网络连接被重置”。后端日志里躺着 java.lang.OutOfMemoryError: insufficient memory,Tomcat 线程池被打满,前端页面上传控件提示“不能装载”之类的一堆废话。折腾了几天后我把这套上传逻辑整体推倒重做,才意识到问题根本不在某一行代码,而是整条链路的方案选错了。
这篇文章就是想把那段复盘沉淀下来。如果你也负责 Java Web 系统,且经常要接收几百 M 甚至几个 G 的文件,比如工程图纸、审计底稿、影像资料、ERP 批量导入数据,那这套思路基本可以直接抄。重点会放在:为什么普通一次性上传在大型组织内网里走不通,分片上传该怎么拆,Java 后端怎么写才不会 OutOfMemoryError,断点续传、秒传、临时文件回收这些细节怎么落地。
先把这个项目的环境交代清楚。单位内部有一套老的 Java 管理系统,部署在 Tomcat 上,用户主要用 Windows 办公电脑,浏览器有些是 Chrome 内核的国产浏览器,但打开系统默认进入 IE 兼容模式。部门之间通过办公网互访,出口有统一的接入网关,Web 应用前面还有一层 Nginx 做转发。这套组合是很多央企系统里很常见的拓扑,也是大文件上传最容易出问题的拓扑。
1. 从一次真实的崩溃开始:一份800M的压缩包把老系统弄瘫了
1.1 崩溃现场发生了什么
最初这个系统的上传功能是一个很普通的 HTML <input type="file"> 加一个 Servlet 接口。前端把整个文件放到 FormData 里一次性提交,后端拿到的直接是 MultipartFile,然后调用 getBytes() 把整个文件读成 byte[],再往服务器磁盘写。处理小文件没问题,但一旦出现几百兆的大文件,很快就出事了。
页面表现是:点击上传后浏览器转圈,进度条不走,等十几分钟后弹出一个错误。服务端表现是:JVM 老年代内存持续上涨,GC 后内存回收不动,紧接着出现 insufficient memory,Tomcat 的线程池里好多个线程都卡在文件上传的读取操作上。用户重试两次后,整个系统响应都变慢了。
当时我第一反应是“内存不够就加内存”。把应用的 JVM 参数从 -Xmx1g 调到 -Xmx4g,重启后确实好了一阵,但再传大文件时问题依旧。后来才意识到,服务器的物理内存毕竟是有限的,不可能靠堆大小去硬扛“把整个文件加载进内存”这种写法。真正该做的是:让文件内容以流的形式经过 JVM,而不是在内存里留下一整份拷贝。
1.2 问题延伸:前端控件依赖后患无穷
排查过程中还翻出一个历史遗留因素。该系统之前用过一种基于浏览器插件的大文件上传控件,控件通过 ActiveX 或者客户端的某些组件机制去读写本地文件。这类控件的典型问题是跟浏览器绑定得很死,要求老版本内核,而且换浏览器或调整安全策略后经常出现“不能装载”“请设置浏览器”之类让人摸不着头脑的提示。
既然新方案要彻底替换掉对控件的依赖,我最初的思路就比较明确了:不能再用插件,不能依赖浏览器特有接口,全部用标准 HTML5 的文件 API 配合 HTTP 请求来做。这种方式天然兼容现代浏览器,也不受控件安装环境影响。只要浏览器支持 File.slice(),我们就能在 JS 里自己把大文件切成小片段,逐个上传。这样即使用户的浏览器是老版本内核,只要支持最基本的 ES5 语法和 XHR 对象,依然能用。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 被忽略的环境约束:央企网络和浏览器其实决定了方案选型
2.1 网络不是“带宽不够”,而是“连接不可靠”
很多人以为内网带宽够大,大文件传输不应该有问题。实际上内网环境有很多隐性限制。接入层网关往往对单个 HTTP 连接的空闲时间有限制,一旦超过一定时间没有数据流动,连接就会被掐断;Nginx 转发层默认也可能设置了读取超时时间;部分中间件还会在请求体过大时直接返回 413 或干脆断开。这就意味着一个 800M 文件如果按一整 Request 传,只要中间有几十秒的抖动、网络闪断,整个传输就失败了,用户必须从头再来。
分片上传的核心价值就在这里:把一个大任务拆成很多个可以独立成功的小任务。每一片都是几十秒内能传完的小请求,即使某一片失败,只需要重传那一片,而不是整个文件。这就像搬家时不会把一卡车书一次性扛上楼,而是分成一箱一箱地搬,哪箱碎了只需补哪箱。
2.2 浏览器环境决定了你不能随便用新特性
央企总部和下属单位用的浏览器并不统一。有人用很老的 IE,有人用国产浏览器兼容模式,有人用 Chrome。但有个共同点:都不会因为你做一个页面就同意给所有终端统一升级。
所以前端的实现要尽量稳,用到的最核心能力是 File 对象的 slice() 方法。这个 API 在 IE10 之后就有支持,国产浏览器的兼容模式一般也能处理。数据传输不走 WebSocket,只用普通的 XHR 或者 fetch()。后端甚至不需要关心前端用的什么框架,只要把 HTTP 接口约定清楚就行。
另外一个额外好处是,用 HTTP 分片传输可以绕过很多老控件对本地文件的独占式读取。用户不再需要安装任何客户端,只要浏览器允许选择文件,后续的上传过程对就是一个普通的 HTTP PUT 或 POST 请求。用户层面的操作难度显著降低了。
2.3 现代方案对 Java 服务器意味着什么
后端服务通常部署在虚拟机上,内存资源是限定的。与其把整个文件读入内存再写给文件系统,不如把磁盘作为中转,内存只做通道。也就是说,应该把 “上传文件” 这件事从“读一块大内存,再落盘”改成“一小段一小段地读,读到多少写多少”,让内存占用始终是一个固定的小值。这个思想会对后面的代码结构产生决定性影响。
3. 基于HTTP分片重构整个上传链路:前端Worker、后端流式与合并技巧
3.1 链路设计:一次上传就是由一对小任务组成的
完整重做以后,我把上传过程拆成几个阶段:
- 前端读取文件基本信息,生成一个唯一的
fileId,这个 ID 是后续所有分片共用的任务标记。 - 前端用
File.slice()把文件按固定大小切片。 - 每个分片发一个请求到后端的接收接口,请求中携带
fileId和当前分片序号。 - 后端把每个分片先写到临时目录下的独立
.part文件。 - 所有分片都上传成功后,前端调用合并接口。
- 后端按分片序号把
.part文件顺序拼接为一个完整文件,可以做完整性校验,然后通知业务系统使用。
分片大小怎么定?我当时在 5M 到 20M 之间做了实测。通用经验是 5M 或 10M 比较合适。如果分片太大,单片传输时间过长,一旦失败重传的代价也大;如果分片太小,比如几十 K 一片,一个 1G 文件会产生上万个请求,服务端光处理 HTTP 协议头就累得够呛,而且频繁的小文件写入对磁盘也不友好。网速较慢的远程用户建议用小一点的片,比如 2M 到 5M;内网高速环境下用 10M 或 20M 单片的效率更高。
3.2 前端用 Worker 处理切片,避免页面卡死
早期我写的前端是在主线程里直接遍历所有分片并逐片上传,结果文件稍大一点,页面滚动都卡。原因是大文件的读取、切片和上传过程都会占用大量 JS 执行时间与网络线程,UI 渲染自然就没法及时响应。
后来改成 Web Worker 方案。大文件的读取和上传逻辑全部塞进 Worker 线程,主线程只负责告诉 Worker “这个文件要传了,传完把结果告诉我”。Worker 内部负责切片、逐个上传、计数,以及失败重试。这样主线程的事件循环不会被长时间阻塞,用户在页面上还能看进度、操作其他按钮,体验好了很多。
这里有个实现细节:在 Worker 里不能直接访问 DOM,但可以接收 File 对象,因为 File 是结构化克隆支持的。分片之后每个切片还是 Blob 类型,可以直接作为请求体发送。我当时的伪代码大致是这样的:
javascript复制// upload.worker.js
self.onmessage = async (event) => {
const { file, fileId, chunkSize, totalChunks } = event.data;
for (let i = 0; i < totalChunks; i++) {
const start = i * chunkSize;
const end = Math.min(file.size, start + chunkSize);
const blob = file.slice(start, end);
const ok = await uploadOneChunk(fileId, i, blob);
if (!ok) {
// 单片失败就重试,连续失败超过3次则终止任务
postMessage({ type: 'failed', index: i });
return;
}
postMessage({ type: 'progress', index: i, total: totalChunks });
}
postMessage({ type: 'done' });
};
async function uploadOneChunk(fileId, index, blob) {
try {
const resp = await fetch('/upload/chunk?fileId=' + fileId + '&index=' + index, {
method: 'PUT',
headers: { 'Content-Type': 'application/octet-stream' },
body: blob // Blob 可以当作 Request body 直接传
});
return resp.ok;
} catch (e) {
return false;
}
}
主线程那边通过 new Worker('upload.worker.js') 创建线程,把 file 等参数 postMessage 过去即可。Worker 回调消息后更新页面的进度条。为了避免传完大量分片以后内存堆积,每个分片上传完成后不再保留该 Blob 的引用,让浏览器自动回收。
3.3 后端逐个接收二进制分片并落盘
后端接收接口的设计有几个关键点。首先请求体不要走 multipart/form-data,而是用 PUT application/octet-stream,直接把每个分片的原始字节作为请求体。这样省掉了 multipart 解析的开销,后端代码也简单得多。其次,接口不返回大页面,只返回成功或失败以及必要的排查信息。
java复制@PutMapping("/upload/chunk")
public ResponseEntity<Void> uploadChunk(
@RequestParam("fileId") String fileId,
@RequestParam("index") int index,
HttpServletRequest request) throws IOException {
Path tmpDir = Path.of(UPLOAD_TMP_ROOT, fileId);
Files.createDirectories(tmpDir);
Path target = tmpDir.resolve(index + ".part");
try (InputStream in = request.getInputStream();
FileOutputStream out = new FileOutputStream(target.toFile())) {
byte[] buffer = new byte[64 * 1024];
int len;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
}
return ResponseEntity.ok().build();
}
这份代码里没有出现请求大小相关参数,也没有一次性把所有字节放到内存里。读取缓冲区只有 64KB,无论单片是 5M 还是 20M,服务端占用的堆内存都是恒定的。写入用的是 FileOutputStream,数据从网络流到磁盘,JVM 内存只是短暂经过。
上传完成前不要用文件名拼接磁盘路径,只使用 fileId 作为目录名。原因是文件名是用户输入,如果里面混有特殊字符,极容易产生路径穿越或文件覆盖问题。fileId 我们使用 UUID 或时间戳加随机数生成,天然安全。
3.4 合并分片的技巧:用FileChannel而不是Byte数组拼接
所有分片传完以后,会有一个合并动作。合并接口把临时目录下所有 .part 文件按序号重新拼接成完整文件。
最笨的写法是遍历所有分片,每个都 Files.readAllBytes() 之后拼接成一个巨大 byte[],再一次性写出去。这种代码对大文件简直是自杀,1G 文件合并时瞬间要分配 1G 以上的堆内存。正确做法是使用 FileChannel.transferTo() 或普通的流拷贝,让内核或操作系统的文件系统直接参与数据搬运,JVM 只做协调:
java复制@PostMapping("/upload/merge")
public ResponseEntity<String> merge(
@RequestParam("fileId") String fileId,
@RequestParam("fileName") String fileName) throws IOException {
Path tmpDir = Path.of(UPLOAD_TMP_ROOT, fileId);
int total = (int) Files.list(tmpDir).filter(p -> p.toString().endsWith(".part")).count();
Path realDir = Path.of(UPLOAD_ROOT);
Files.createDirectories(realDir);
Path target = realDir.resolve(fileName);
try (FileChannel out = FileChannel.open(target,
StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
for (int i = 0; i < total; i++) {
Path part = tmpDir.resolve(i + ".part");
try (FileChannel in = FileChannel.open(part, StandardOpenOption.READ)) {
long position = 0;
while (position < in.size()) {
long moved = in.transferTo(position, in.size() - position, out);
position += moved;
}
}
}
}
return ResponseEntity.ok(target.toString());
}
为什么用 transferTo() 而不是 byte[]?因为 transferTo() 可能走操作系统的零拷贝或更底层的优化,尽量减少数据在用户态和内核态之间来回复制。就算操作系统不支持零拷贝,Java 内部也会自动退回基于缓冲的流式拷贝,内存占用同样可控。同时注意,transferTo() 的一次调用不保证把整个文件搬完,需要循环确认返回值,这个容易忽略,我早期因为没写 while 循环丢过后半段数据。
4. 断点续传与秒传:来自“断网重传”的真实诉求
4.1 分片上传只是第一步,用户真正需要的是“不用从头再来”
很多大文件系统做到分片上传就停了,但实际使用时,用户传到 70% 网线被人碰了一下,前端一刷新,还是得重新选文件。这时才知道断点续传有多重要。
断点续传的设计其实不复杂。前端每次上传前问一次后端“这个 fileId 已经收到哪些分片了”,后端返回一个已收到的分片序号集合。前端本地记录待上传的完整分片列表,传输过程中每上传一片就更新本地状态。刷新页面后,前端重新根据文件算出同样的 fileId,然后调用进度查询接口,把已传过的片跳过,只传缺失的片。
4.2 fileId的生成策略决定了续传能不能命中
fileId 最好由文件内容而不是随机数生成。只有在用户选择同一个文件、且文件内容没变的情况下,才能复用同一个 fileId 实现续传。
我的做法是取文件名、文件大小和修改时间拼一个字符串,再算一个哈希值作为 fileId。更严谨的做法是读文件头加尾的一小段内容参与哈希,以降低不同文件被误判为同一文件的概率。如果一开始就用随机 UUID 当 fileId,刷新页面后根本无法重新关联已经传过的分片。
后端查询进度的接口可以设计成:
text复制GET /upload/progress?fileId=xxx
返回已接收的分片序号列表:
json复制{
"fileId": "xxx",
"receivedChunks": [0,1,2,5,6,8]
}
前端拿到这个列表后,把缺失的 3、4、7 等分片重新上传即可。文件全部传完后,合并接口只执行一次,合并成功后删除临时目录。如果删除前又收到重复的合并请求,要返回一个幂等结果,不能把同一个目标文件写两遍。
4.3 秒传不是只能算全文件MD5
很多场景里用户上传的文件别人已经传过了。比如同一个集团要求下级单位上传同一份标准模板,不同部门之间反复传递同一份项目文档。如果每次都完整传一遍,纯属浪费带宽。
秒传的思路是:在上传最开始,客户端先提供一个文件级指纹;后端根据指纹在文件索引表里查找,如果发现同一个哈希对应的文件已经存在,就直接把该记录关联给当前用户,不再执行上传。
但是大文件的 MD5 计算很消耗时间,一个 2G 文件算 SHA-256 可能要让页面等大半天。所以在工程上我推荐“先传分片,合并后再校验”的方案,而不是一上来就算全文件哈希:
- 每个分片上传时在 Header 或参数里带上分片的 MD5,后端接收后计算本地分片 MD5,不一致则立刻返回失败,让前端重传该分片。
- 合并完成后,对最终文件做一次整体哈希,作为文件的唯一标识存入数据库,生成秒传索引。
这样就避开了上传前长时间卡在算哈希的问题。合并后的整体哈希计算也要用流式计算,而不是先把整个文件读进内存。用 DigestInputStream 包住文件流,计算过程中边读边哈希,JVM 堆内只有缓冲区那一小段数据。
5. 从OutOfMemoryError谈起:最容易踩的五个Java写法与参数调优
5.1 最容易触发OOM的两种做法
java.lang.OutOfMemoryError: insufficient memory 是热词,面试常问,真实项目更常见。结合这类上传场景,我发现最容易触发的两种写法:
一是把 MultipartFile 或 Servlet 输入流整个转成 byte[]。代码看起来爽,一行 file.getBytes() 或 IOUtils.toByteArray(inputStream) 就把整个文件都塞进了堆内存。如果同时有 5 个人各传 1G 文件,堆里瞬间多出 5G 数据,立刻废掉。
二是在 Base64 编码的接口里传大文件。前端把文件转成 Base64 字符串后拼在 JSON 里提交,后端再 Base64 解码还原。Base64 会让数据膨胀约三分之一,额外增加编码和解码的内存开销。对于几十 K 的小头像无所谓,对于几百 M 的文件就是灾难。
除此之外,合并时用 Files.readAllBytes() 拼接多个分片、一个线程内分配几十 M 的 ByteBuffer.allocateDirect()、或者在循环里反复读取同一批分片却不释放引用,这些都是我实际见过的问题。
5.2 上传组件和Web容器的几个关键参数
很多老项目用 Apache Commons FileUpload 或 MultipartResolver,这本身没问题,但默认配置里有个“内存阈值”参数,如果文件小于阈值就放内存,大于阈值才写磁盘。默认值往往是 1KB 或 10KB,对大文件来说影响不大;可一旦有人把阈值调成几十 M,且没有正确设置临时目录,并发上传时就会把内存挤爆。
Spring Boot 项目需要在配置里显式指定:
properties复制spring.servlet.multipart.max-file-size=2GB
spring.servlet.multipart.max-request-size=3GB
spring.servlet.multipart.file-size-threshold=1MB
spring.servlet.multipart.location=/data/upload_tmp
但在换成直接收二进制分片的方案后,max-file-size 和 max-request-size 已经不是重点了,因为每个请求体只有几 M。这是这种方案的额外好处:不必为了支持 1G 文件,把整个服务器对请求体的大小限制放开到 1G 以上。如果仍然用传统方式一次性提交,这几个参数才是刚需。
Tomcat 还有两个容易忽略的参数,一个是 maxPostSize,默认 2M,处理 form POST 时会限制请求体大小;另一个是 maxSwallowSize,如果客户端发了超大 body,服务端拒绝后是不是要强行把剩余数据读完才能复用到连接。参数怎么调取决于版本,重点是要理解:这些限制是容器层面保护机制,但它们不会区分“普通表单”和“大文件上传”,方案重构后能让这些限制不再成为瓶颈。
5.3 内存溢出后的快速定位手段
如果线上已经出现 OutOfMemoryError,可以在启动参数加 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump,让 JVM 在崩溃前自动导出堆快照。然后用 MAT 等工具看哪个对象占用了大量堆内存。正常大文件上传项目里,内存里不应该出现几百 M 的 byte[] 或 ArrayList<byte[]>。如果分析结果里能看到大量 byte[] 且数量跟请求线程数成正比,十有八九是哪个环节把整个文件读进内存了。
另外注意,某些老系统里 Tomcat 本身的 maxThreads 设置过高,例如默认 200 个线程,每个线程如果都在做文件流读写,线程栈、缓冲区、临时对象加起来也会让内存压力非常大。上传接口通常不是 CPU 密集任务,200 个线程并发跑到文件 IO 上,反而会因为频繁切换和磁盘排队拖垮整个应用。我当时把 maxThreads 调到并发峰值的一点五倍左右,配合队列容量,效果比盲目加堆内存好得多。
6. 多用户同时上传时的文件管理细节:临时片、孤儿文件与回收
6.1 临时分片的目录隔离策略
让多个用户同时上传大文件时,一个非常大的隐患是不同用户的文件可能产生同样的 fileId。尤其是当 fileId 由“文件名+大小+修改时间”生成时,不同用户传同一个同名同大小文件是大概率事件。
因此后端存储不能直接把所有 .part 文件都塞进同一个目录。我的策略是三层目录:
text复制/data/upload_tmp/{yyyyMM}/{userId}/{fileId}/{index}.part
/data/upload/{yyyyMM}/{realFileName}
userId 来自登录会话,fileId 由前端上传前计算。这样即使两个不同用户同时传同一个文件,也会进入各自独立的临时目录,不会互相覆盖。合并完成后再把最终文件移动到公共上传目录,并在数据库里记录文件所属的用户和业务单号。
6.2 定时清理过期临时分片
分片上传有一个天然缺点:客户端可能传到一半就关掉了浏览器,临时目录里残留一堆 .part 文件。如果不清理,磁盘迟早被占满。
我建议对每个任务记录一个 lastUpdateTime,每次上传或查询进度时更新。后台定时任务定期扫描临时目录,把超过 24 小时、48 小时没有新动作的目录整体删除。删除时只删该 fileId 对应的临时目录,不影响已完成合并的文件。
这里有个经验:很多团队会忘记处理临时目录的剩余空间,最后运维发现 /data 满了,导致整个应用宕机。为了保险,临时目录和正式文件目录应该分开挂载,避免二者互相拖累。还可以通过监控脚本对磁盘阈值发出告警,一旦临时目录占用超过 80%,就开始优先清理最老的过期任务。
6.3 避免并发合并和重复上传的坑
前端做完所有分片上传后会发合并请求。如果网络抖动,用户多点了一次“完成”按钮,或者上传进度查询和合并接口并发触发,后端有可能收到两个相同的合并请求。解决办法是加一个任务状态位。在数据库或者分布式缓存里记录 fileId 的状态,从 “UPLOADING” 到 “MERGING”,再到 “DONE”。合并接口一开始就尝试把状态从 “UPLOADING” 改成 “MERGING”,修改成功的请求才执行合并,其他请求直接返回“正在合并”。这样即使并发重试,最终文件也只会写一次。
对于已经合并完成的重复上传请求,也不要直接报错。如果系统判断目标文件的哈希和记录一致,直接返回已有的文件路径即可,这也是“秒传”的兜底逻辑。
实际运维中我还发现一类隐蔽问题:某些杀毒软件或桌管系统会实时扫描大文件的读写过程,导致临时 .part 文件在被写入的同时又被其他进程打开读一下,这时 transferTo() 循环可能会出现文件占用冲突。解决办法是临时文件上不要套用复杂的权限或审计目录,合并完尽快移到正式目录。正式目录再交给杀毒软件扫描,既保证安全又不影响上传链路本身。
最后说个自己的操作习惯。每次做完这类优化,我都会模拟几个极端场景验证代码,而不是只看功能能不能跑通:断网后重传一半的分片,两个用户同时上传同名同大小的文件,同一文件重复传两次验证秒传,某个分片损坏时校验能否拦截。这套验证做下来,系统上线后能省下大量半夜接到告警去机房救火的时间。
