做汽车制造行业的系统,和做纯互联网产品有个特别大的不同:文件是真的又大又杂。整车CAD数模、碰撞仿真分析结果、总装车间摄像头录下的高清视频、供应商提交的检测报告与高清照片,动辄几百MB到几十GB。早些年我也按普通项目的惯性来做,前端放一个<input type="file">,后端用Spring MVC收一个MultipartFile,文件小倒还好,一到工厂车间现场传数模就极其惨烈:传半小时后超时、服务端直接OutOfMemoryError、浏览器标签页卡到无法点击,用户能把你吐槽到怀疑人生。
这篇不讲花哨的架构,就从一个JAVA后端开发者的实践角度出发,把汽车制造行业里大文件上传这件事拆开揉碎:为什么传统上传方式在这种场景下必死,分片上传、断点续传、秒传到底怎么落地,后端代码怎么写才不容易在合并阶段翻车,以及上线后哪些坑是我自己真金白银踩出来的。无论你面对的是PDM、MES、QMS还是供应链协同平台,这套设计思路基本都能复用。
1. 汽车工厂里的“重型附件”,和互联网产品根本不是一回事
1.1 你可能遇见的几类大文件
先捋一下汽车制造行业里,用户口中说的“大文件”都有哪些。我接触到的场景大致分三类。
第一类是研发和工程数据。PDM系统里的整车三维数模、零部件图纸、CAE仿真结果、碰撞测试视频,单个文件几GB很常见,而且这些文件往往要跨部门、跨工厂反复流转。第二类是生产和质量数据。总装车间产线监控视频、检测设备导出的原始数据、质量缺陷图片集,一次上线活动往往要上传几十GB。第三类是供应商协同和售后数据,比如零部件供应商提交的PPAP资料包,单个包可能塞了几百张高清图纸,压缩完仍然轻松超过2GB。
这些场景有三个共同特征:文件大、来源多、很多使用者所在的位置并不是核心机房旁边。跨厂区走专线时延迟和丢包不可避免,车间现场网络未必稳定,一旦断网,普通上传就会报废。
1.2 这类系统为什么不能用普通上传组件
普通上传框的逻辑是“一次性把整个文件塞进一个HTTP请求”。在互联网产品里,用户传个头像、证件照、几百KB的聊天附件,这套逻辑没有任何问题。但到了汽车制造这种“重型附件”场景,问题就会集中爆发:
- 文件大,占用连接时间长,中途任何抖动都可能让整个上传失败。
- HTTP请求断了之后没有“从断点继续”的概念,只能全部重来。
- 服务端需要处理大流量的multipart解析,容易把Tomcat线程池和JVM内存打满。
- 浏览器主线程一旦读取大文件并持续发送数据,页面交互会卡死。
所以我在设计初期就明确了一件事:汽车行业的大文件上传,本质上不是“把传输速率调高”,而是“把失败成本降下来”。失败后只需要重传没传完的小片段,而不是从头再来,这才是现场用户真正需要的体验。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 把整个文件塞进一次HTTP请求,JAVA后端为什么必死
2.1 Tomcat和Spring在处理大文件时都扛了什么
很多人一开始都以为,把Spring Boot的spring.servlet.multipart.max-file-size调成50GB,问题不就解决了吗?实际上这只是在把服务器往火坑里推。
Tomcat处理multipart请求时,底层会先把请求体解析出来。Spring Boot虽然会把临时文件放到磁盘目录,但整个请求从进入Tomcat到响应结束,会一直占用一个Tomcat线程和一个TCP连接。一个2GB的文件在千兆内网里,理论最快也要二三十秒,但真实生产环境往往只有几十兆的跨厂区带宽,传一两个小时都有可能。
Tomcat线程池是有限的,假设默认200个线程,只要来几个大文件上传,Tomcat线程就会被全部占满,其他所有人访问系统都开始排队。更关键的是,你能接受调整配置,但你能接受在一个2GB文件传到99%时,用户不小心关了页面,明天又得重头传一遍吗?
内存溢出问题往往还不是框架引起的,而是业务代码自己惹的祸。很多人拿到MultipartFile之后顺手就写byte[] data = file.getBytes(),或者用inputStream.readAllBytes(),相当于把一个几GB的文件整个加载进JVM堆内存。这个操作做一次,java.lang.OutOfMemoryError: Java heap space就离你不远了。哪怕框架已经把临时文件落到磁盘,只要你在代码里把流全部读进内存,框架也救不了你。
2.2 拆片的本质是降低“失败单位”
分片上传的核心思想其实很朴素:把一个2GB文件切成200个10MB的小块,逐块上传,让每一片都能独立重试。这样做之后,单次HTTP请求的体量很小,不再需要把Tomcat的multipart上限调成几十GB,也不再存在“一次连接占几个GB流量”的恐怖场景。
更重要的是,分片把整个上传过程从“一个长事务”变成了“多个独立可重试的小步骤”。某个分片传失败,前端只需要重试那一片。服务端也只需要管理一小片一小片的数据,内存占用非常平稳。
如果你非要问我,分片上传背后真正要解决的工程问题是什么?我自己的总结是:用磁盘空间换内存安全,用元数据记录换断点恢复能力,用可重试的原子单元换用户体验。
3. 分片切割放前端,Worker的任务是保证页面不卡死
3.1 文件在浏览器里怎么切成块
前端切片的API很简单,因为浏览器里的File对象继承自Blob,天然支持slice(start, end)。例如一个2GB文件,要切成10MB一片,总共会有Math.ceil(2 * 1024 * 1024 * 1024 / (10 * 1024 * 1024)) = 205片。
但问题来了:如果切好之后直接在主线程里用fetch一个接一个上传,浏览器的渲染线程会被持续占用,用户会看到页面按钮点了没反应、进度条一卡一卡。尤其是大文件需要读取文件内容并反复创建FormData,这个操作在主线程里跑,体验非常糟糕。
正确的做法是把上传调度放到Web Worker里。Worker没有DOM访问能力,但可以做网络请求和文件切片计算。主线程只需要把File对象和uploadId丢给Worker,Worker负责切片、并发调度、进度上报。主线程收到progress事件后,只是更新一下页面上的进度条,界面就不会再卡死。
3.2 分片大小和并发数怎么定
分片大小是第一个需要在设计阶段就定下来的参数。我的经验是:企业内网环境建议10MB到20MB一片,跨厂区或低带宽环境建议5MB左右。
分片设太小,比如1MB一片,2GB文件要被切成2000多片,数据库记录数会暴涨,服务端文件数量也会变得很难清理,反而拖慢性能。分片设太大,比如128MB一片,断点续传的粒度就太粗了,一断可能就要重传128MB,恢复成本太高。
并发数也不是越大越快。前端同时发出几十个请求,在后端看来就是几十个线程同时写磁盘,机械盘会直接IO打满,SSD也会出现大量随机写,最终结果就是所有分片都变慢。我自己常用的是3到5个并发,效果比较稳。
3.3 一个Web Worker内部的上传骨架
下面给一个简化版的Worker骨架,删掉了鉴权与复杂错误处理,只把核心调度结构列出来:
javascript复制// upload-worker.js
const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB
const CONCURRENCY = 4;
let file;
let uploadId;
let cursor = 0;
let running = 0;
self.onmessage = async function (e) {
file = e.data.file;
uploadId = e.data.uploadId;
const totalChunks = Math.ceil(file.size / CHUNK_SIZE);
await new Promise((resolvePromise) => {
function schedule() {
if (running >= CONCURRENCY || cursor >= totalChunks) {
if (running === 0 && cursor >= totalChunks) {
resolvePromise();
}
return;
}
const chunkIndex = cursor;
const start = chunkIndex * CHUNK_SIZE;
const chunk = file.slice(start, start + CHUNK_SIZE);
cursor++;
running++;
uploadChunk(chunkIndex, chunk)
.then(() => {
self.postMessage({ type: 'progress', uploadId, chunkIndex, totalChunks });
})
.catch(() => {
// 重试逻辑这里省略,实际需要指数退避
self.postMessage({ type: 'error', uploadId, chunkIndex });
})
.finally(() => {
running--;
schedule();
});
}
schedule();
});
};
function uploadChunk(chunkIndex, chunk) {
const formData = new FormData();
formData.append('uploadId', uploadId);
formData.append('index', chunkIndex);
formData.append('file', chunk);
return fetch('/api/upload/chunk', {
method: 'POST',
body: formData,
}).then((res) => {
if (!res.ok) {
throw new Error('chunk upload failed');
}
return res.json();
});
}
这个骨架在实际项目里还需要补上每次请求的重试次数、不同分片并发写入的幂等保护,以及Worker空闲时的资源释放。但核心逻辑已经足够说明问题:切片、并发、进度上报三件事都放到了后台线程里。
4. JAVA服务端的分片存储与记录:接口只需要两个半
4.1 建任务和查进度
前端切好文件后,不能直接猛传分片。第一件事是调用“创建上传任务”接口,后端返回一个全局唯一的uploadId,并记录文件名、文件总大小、总片数、每片大小等信息。
创建任务接口通常长这样:
java复制@PostMapping("/api/upload/create")
public Result<UploadCreateVO> create(@RequestBody UploadInitRequest req) {
String uploadId = UUID.randomUUID().toString().replace("-", "");
uploadTaskService.create(uploadId, req);
return Result.ok(new UploadCreateVO(uploadId));
}
uploadId是后面所有分片请求的主键。断点续传的目标,就是通过这个uploadId精确找到“哪些分片已经传过了”。
另一个接口是“查进度”:
java复制@GetMapping("/api/upload/progress")
public Result<ProgressVO> progress(@RequestParam("uploadId") String uploadId) {
List<Integer> uploadedIndexes = uploadChunkService.listUploadedIndexes(uploadId);
return Result.ok(new ProgressVO(uploadedIndexes));
}
前端在上传前可以先调一次进度接口,拿到已经上传成功的分片下标;然后把这些下标从待传队列里移除,只传剩下的分片。这一步就是断点续传的真正实现基础。
4.2 上传分片的完整处理逻辑
上传分片接口是核心中的核心。代码要解决的几个问题是:分片怎么写盘、重复上传怎么幂等、写入临时文件和正式分片文件之间怎么做原子切换。
先说分片大小需要同步配置:
properties复制spring.servlet.multipart.max-file-size=20MB
spring.servlet.multipart.max-request-size=25MB
注意,不需要把max-file-size调成50GB。因为我们已经把2GB文件切成10MB的小片,单个请求只需要传10MB,把限制调到20MB是为了留出multipart协议本身的开销余量。如果你把限制调成50GB,等于又给“直接用表单传整个大文件”留了一道后门,这是设计上的自相矛盾。
上传接口代码骨架如下:
java复制@PostMapping("/api/upload/chunk")
public Result<Void> uploadChunk(
@RequestParam("uploadId") String uploadId,
@RequestParam("index") int index,
@RequestParam("file") MultipartFile chunk) throws IOException {
Path chunkDir = Paths.get(uploadRoot).resolve(uploadId);
Files.createDirectories(chunkDir);
// 先写临时片段,完成后原子改名为正式片段
Path tmpFile = chunkDir.resolve("chunk_"
