☰
WebUploader大文件上传JS改造:车企CAD图纸分块续传实践
2026/10/22 8:41:34 网站建设 项目流程

先说结论:这套方案我们已经在某整车制造企业的图纸协同平台上跑了一年多,原来传一个500MB的CATIA数模要分好几次、断断续续传半个下午,改造后一份1.2GB的整车外观数模基本稳定在15到20分钟传完,中途断网、电脑休眠都能接着续传,不用重新再来。这篇文章我把整个改造过程、关键代码和踩过的坑都整理出来,给同样被大文件上传折磨的兄弟们一个参考。

先说清楚背景。车企研发中心每天都在产生大量CAD图纸,一块车门内板、一套动力总成数模,随便都是几百MB起步,整车级别的数据上GB很正常。这些文件以前靠FTP、共享盘传,后来合规要求收紧,全部收口到统一的图纸管理平台上。平台前端用的是百度开源的WebUploader,后端是Java体系。WebUploader本身支持分块上传,但默认配置和原生交互逻辑用在CAD图纸这种场景上有几个很要命的问题:单块体积太小导致请求数量爆炸、断点续传形同虚设、并发控制过于保守、还有一堆HTTP层面的坑没人处理。这篇文章就围绕怎么通过JS改造把这些问题逐个解决展开,内容偏实践,随手就能抄。

1. 技术选型:为什么还是选WebUploader

1.1 选型背景与约束

很多新同学会问,2020年以后了,为什么还在用WebUploader这种老古董?换个现代的组件不行吗?

实际情况是,车企这种环境里有大量历史约束,不是说换就能换的:

第一,内网环境隔离。图纸平台部署在企业内网,部分园区之间还有严格的防火墙策略,外网常见的对象存储、云SDK全部不可用。文件必须通过自有服务端接收、落盘、归档。

第二,浏览器环境复杂。研发、工艺、质量、供应链各条线的人都在用这个系统,很多人电脑上的浏览器多年没升过级。像WebUploader这种自带Flash降级方案的组件,在这种环境里反而成了刚需。

第三,业务代码耦合深。平台里大量业务逻辑都挂在WebUploader的事件钩子上(上传成功后的图纸编号回填、版本关联、审批流触发等),全量换组件等于把上传模块重写一遍,风险太大。

所以在现有代码基础上做JS层面的深度改造,是成本最低、风险最可控的路径。

1.2 和其他方案对比

改造之前我其实也评估过几条路,这里把对比列出来,方便大家做判断:

方案优势劣势结论
原生XMLHttpRequest + 自研分片无依赖、可控性最强断点续传、并发控制、重试机制全部要自己写,工作量大不选
axios + 开源大文件上传组件生态好、代码量少内网无法拉取CDN依赖、部分组件不支持Flash降级、未适配老浏览器不选
云对象存储SDK直传上传速度快、功能全内网无法直连外网,数据合规不过关不选
WebUploader + JS深度改造兼容老环境、业务改动小、复用现有事件体系需要理解源码细节、部分能力要手写扩展选这个

选型逻辑很简单:先满足环境约束,再谈体验优化。WebUploader虽然老,但它的分块机制、事件模型、Flash降级设计都还在,而且源码结构清晰,值得在它基础上去做文章。

2. 分块上传的核心参数与HTTP传输模型

2.1 分块大小到底怎么定

这是整个改造里第一个要掰扯清楚的数字。

WebUploader默认的单块大小是4MB。这个值对几十MB的文档、表格没问题,但放到CAD图纸场景里就非常尴尬。一份800MB的数模,按4MB分块就是200个请求。请求数量大,网络往返开销大,服务端要处理的临时文件数量也多,任何一个分块出问题都会拖慢整体进度。

我把分块大小当成一个依赖网络带宽和文件体量的参数来算。

假设现场实测内网到服务器的有效带宽是100MB/s(千兆内网的理论值打七折),那单块8MB是比较合理的:

  • 8MB在100MB/s带宽下传输约80ms,加上HTTP握手、服务端落盘,单块总耗时约150ms;
  • 300个分块,3个并发,理论总耗时按倍数折算大约在1到3分钟级别,体验可接受;
  • 8MB单块在服务端合并时内存压力小,直接用流追加写入即可。

如果你所在的企业内网是万兆骨干,但终端网卡是千兆,还是要按千兆算。如果跨园区走专线,实际带宽可能只有20MB/s,这时候单块建议降到4MB,否则一个分块本身传输时间太长,中途断网重试的成本太高。

分块大小的本质是在“请求数量”和“单请求失败成本”之间取平衡。记住这个原则,不同带宽环境就知道怎么调了。

2.2 并发数与服务端承载能力

WebUploader默认的并发数是1,也就是一个分块传完才传下一个。这个设置对服务器压力小,但对大文件来说是灾难。一份文件传半天,大部分时间都浪费在等待上了。

改造时我把并发调到3,上限锁死在5。

为什么不直接开10个并发?因为要照顾服务端。车企图纸平台不是专门为上传设计的,后台还要跑图纸解析、版本对比、水印渲染这些吃CPU的任务。Tomcat默认线程池一般是200,如果同时有10个人各开5个并发上传,瞬间就是50个上传请求进来,再加上其他业务接口,Tomcat线程池很容易被打满,连带着别的功能一起变卡。

确定并发数的逻辑是:并发数 × 同时上传人数 × 单块耗时 = 服务端每秒要处理的请求数。让这个数字控制在Tomcat线程池的20%以内,就不会影响核心业务。我们当时按20人同时传大文件、每人3并发算,每秒新增请求峰值大约6到8个,完全在可接受范围内。

2.3 分块上传和HTTP分块传输是两个概念

这里必须把概念说清楚,不然排查问题时会走弯路。

WebUploader做的“分块”,是应用层把文件切成多段,每段作为独立的一次HTTP POST请求发出去。服务端接收到所有分块后,再按序号合并成完整文件。这是业务层面的切分。

HTTP协议里的Transfer-Encoding: chunked则完全不同。它指的是HTTP响应体(少数情况下是请求体)被拆成多个chunk在一条TCP连接上流式传输,传输结束后连接关闭或复用。这是协议层面的机制。

这两个东西容易混淆是因为中文都叫“分块”,但实际上互相独立。WebUploader的分块请求,每个请求本身依然是完整的HTTP消息,可以使用Content-Length明确标记本块大小,不需要依赖协议层的chunked。理解这一点,后面调Nginx、调网关就不会一头雾水。

3. JS改造WebUploader的完整实践

3.1 初始化配置与关键配置项解读

先看一套我实际在用的初始化配置。这份配置基于WebUploader 0.1.5版本,JS改造后用于生产环境。

var uploader = WebUploader.create({ // 上传控件指向 pick: '#filePicker', // 文件选择后自动上传 auto: true, // 分块上传开关,这是整个改造的核心 chunked: true, // 单块大小,8MB,按带宽实测调整 chunkSize: 8 * 1024 * 1024, // 并发数,锁死在3,不盲目调高 threads: 3, // 队列中的文件数限制 fileNumLimit: 5, // 单文件大小限制,这里放开到5GB fileSizeLimit: 5 * 1024 * 1024 * 1024, // 上传体积限制 duplicate: true, // 上传地址,指向后端接收分块的接口 server: '/api/cad/upload/chunk', // 表单额外参数,和业务关联 formData: { module: 'cad-drawing', sourceSystem: 'pdm' }, // 接受的文件类型 accept: { title: 'CAD图纸', extensions: 'dwg,dxf,catpart,catproduct,stp,igs,prt,asm,step', mimeTypes: 'application/octet-stream' } });

这里有几个配置项要额外解释:

chunked: true是分块上传的总开关,不开的话WebUploader会把整个文件塞进一个请求里,几GB的文件直接超时,前面所有优化都无从谈起。

threads: 3是并发数。上文已经算过,这里不再赘述。

server指向的是接收分块的接口,这个接口要做的事是:接收单个分块、校验大小、落盘到临时目录、返回分块接收成功的标识。它和最后合并文件的接口是分离的。

还有一个隐藏配置容易被忽略:formData里的参数会跟随每个分块请求一起提交。模块标识这些建议加上,后端打日志时能快速区分来源,排查问题不用到处翻。

3.2 断点续传与秒传的改造实现

WebUploader自带的“断点续传”其实很弱——它只是在上传中断后重启时,根据已上传的列表跳过一部分分块。但浏览器刷新、电脑重启之后,内存里的状态全部丢失,照样要重传。真正的断点续传必须把上传进度持久化到服务端,刷新后通过文件指纹探测哪些分块已经存在,只传缺失的部分。

改造思路是引入文件MD5值作为唯一标识。

分两步走:

第一步,文件选好后,先计算MD5。CAD图纸动辄几百MB,在主线程里算MD5会卡死页面,必须用Web Worker配合spark-md5库。核心逻辑如下:

// worker.js importScripts('/static/spark-md5.min.js'); self.onmessage = function (e) { var file = e.data.file; var chunkSize = 8 * 1024 * 1024; var chunks = Math.ceil(file.size / chunkSize); var spark = new SparkMD5.ArrayBuffer(); var currentChunk = 0; var fileReader = new FileReader(); function loadNext() { var start = currentChunk * chunkSize; var end = Math.min(start + chunkSize, file.size); fileReader.readAsArrayBuffer(file.slice(start, end)); } fileReader.onload = function (e) { spark.append(e.target.result); currentChunk++; if (currentChunk < chunks) { // 报告进度 self.postMessage({ progress: Math.round(currentChunk / chunks * 100) }); loadNext(); } else { self.postMessage({ md5: spark.end() }); } }; fileReader.onerror = function () { self.postMessage({ error: 'read failed' }); }; loadNext(); };

主线程这边收到MD5后,先请求后端接口判断该文件是否已存在:

uploader.on('fileQueued', function (file) { // 触发MD5计算,拿到结果后走秒传或续传逻辑 computeFileMD5(file).then(function (md5) { file.md5 = md5; return $.ajax({ url: '/api/cad/upload/check', method: 'POST', data: { md5: md5, fileName: file.name, fileSize: file.size } }); }).then(function (res) { if (res.data.status === 'completed') { // 文件已完整存在,直接标记为上传成功,实现秒传 uploader.skipFile(file); uploader.trigger('uploadFinished', file); } else if (res.data.status === 'partial') { // 已有一部分分块,设置上传起始chunk uploader.option('formData', { md5: file.md5, chunkIndex: res.data.completedChunks }); } }); });

这里有个细节:skipFile是WebUploader自带的跳过文件逻辑,置为成功后要手动触发后续事件,否则业务方监听的上传成功钩子不会执行。

第二步,每个分块请求都要携带MD5和当前分块序号。后端根据这两个参数,把分块写到临时目录/{md5}/{chunkIndex}下。下次同一文件上传时,后端扫描目录就知道哪些分块已在,返回给前端,前端只从缺失块开始传。

实际跑起来,断点续传的体验完全取决于后端的响应速度。MD5计算和已传分块探测最好控制在2秒以内,否则前端排队等待时用户会以为系统死了。我们把探测接口的临时目录索引放在内存缓存里,每秒批量刷新一次磁盘扫描结果,响应时间从800ms压到50ms左右。

3.3 服务端接收与合并逻辑配合

JS改造再完美,服务端不配合也是白搭。这里给一份后端接收分块、合并文件的伪代码参考(以Java系为主,思路通用)。

// 接收单个分块 @PostMapping("/api/cad/upload/chunk") public Result receiveChunk(@RequestParam("file") MultipartFile file, @RequestParam("md5") String md5, @RequestParam("chunkIndex") Integer chunkIndex) { // 1. 校验分块大小是否匹配(合理区间:1MB ~ 16MB) if (file.getSize() < 1024 * 1024 || file.getSize() > 16 * 1024 * 1024) { return Result.error("分块大小非法"); } // 2. 分块落盘到临时目录 Path chunkDir = Paths.get("/data/cad_upload_temp", md5); Files.createDirectories(chunkDir); Path chunkFile = chunkDir.resolve(chunkIndex.toString()); file.transferTo(chunkFile); return Result.success("分块接收成功"); } // 合并文件(所有分块上传完后调用) @PostMapping("/api/cad/upload/merge") public Result mergeChunks(@RequestParam("md5") String md5, @RequestParam("fileName") String fileName) { Path chunkDir = Paths.get("/data/cad_upload_temp", md5); Path targetFile = Paths.get("/data/cad_final", fileName); // 按分块序号升序遍历,逐个追加写入目标文件 try (OutputStream os = Files.newOutputStream(targetFile)) { for (int i = 0; i < getTotalChunks(chunkDir); i++) { Path chunk = chunkDir.resolve(String.valueOf(i)); Files.copy(chunk, os); } } // 删除临时目录 deleteRecursively(chunkDir); return Result.success("合并成功"); }

合并部分有几个重要注意点:

一是必须按序号顺序追加写入,不能并行写。并行写会导致文件乱序,整个文件损坏。我们最早用多线程并发写目标文件的不同偏移量,结果合并出来的数模文件一打开就报错,排查了半天才发现是写入顺序错乱。

二是合并过程要用流式处理,不能一次性读入内存。如果后端代码写得粗放,Files.readAllBytes把1GB的文件全读进JVM堆,内存直接爆掉。用Files.copy(chunk, os)这类流式API,内存占用始终是常量级。

三是合并完成后必须做完整性校验。最简单的方式是合并前后对比文件大小,更严格的项目里会再算一次合并后文件的MD5,跟前端传入的MD5比对。这一步在图纸场景里很重要,CAD文件损坏一次,下游的设计评审、工艺分析全都会出错。

4. 上线前必须处理好的HTTP细节

4.1 Nginx与网关参数调整

分块上传改造完成,并不代表整个链路就通了。HTTP层还有一堆参数要跟着调,否则上线第一天就会遇到各种莫名其妙的失败。

首先是Nginx的client_max_body_size。很多人以为分块上传后文件不再整体经过Nginx,这个参数就不用管了。但有一些边缘情况:小于分块大小的文件(比如几十KB的图纸说明文档)会走整包上传路径,如果这个值设得太小(默认1MB),小文件上传直接413。我们统一调成client_max_body_size 100m,覆盖“不分块的小文件”和“分块后单块8MB”两种情况,留足余量。

其次是proxy_request_buffering。这是个大坑。Nginx默认会把上游请求体先缓存到磁盘再转发给后端。分块上传时,如果Nginx配置了请求体缓冲,每个分块都要先写完磁盘再转发,相当于多了一次落盘和读取,上传速度直接慢一半。对分块接口做如下调整:

location /api/cad/upload/ { proxy_request_buffering off; client_max_body_size 100m; client_body_buffer_size 16m; proxy_read_timeout 300s; proxy_send_timeout 300s; }

proxy_read_timeout和proxy_send_timeout也要调大。如果某个分块在慢速链路上传输时间超过默认60秒,Nginx会直接掐断连接,前端报错重试。改成300秒后基本够用。

网关层(如果前面还有API网关)要同步放行。很多网关默认对Path、Method做了路由校验,分块请求的方法、路径要提前加白名单。我们当时漏了这个配置,上线后分块接口全部404,排查了一整天才发现是网关路由表没更新。

4.2 跨域与预检请求

图纸平台如果前后端分离,域名不同,就会涉及跨域。WebUploader的请求走XHR,跨域时浏览器会先发OPTIONS预检。

处理方案的要点是后端统一返回:

Access-Control-Allow-Origin: * Access-Control-Allow-Methods: POST, OPTIONS Access-Control-Allow-Headers: Content-Type, X-Requested-With, Accept Access-Control-Max-Age: 3600

Access-Control-Max-Age这个参数容易被忽略。它用来缓存预检结果,设置3600秒,浏览器在一小时内不会针对同域请求重复发OPTIONS,减少一次往返。对并发分块上传来说,少一次预检就少一次等待,积少成多,体感提升明显。

还有一点要注意:跨域配置要区分正式环境和测试环境,不能图省事用通配符把所有接口都放开。图纸数据在车企属于核心资产,CORS配置得越细越好。我们只对平台前端的固定域名放开跨域权限,其他来源一律拒绝。

4.3 大文件上传的异常处理与重试策略

文件越大,传输过程中出现临时网络抖动的概率越高。JS这层必须有明确的失败重试策略,否则一个分块失败,整个上传就卡住了。

WebUploader自带的失败处理比较简陋,失败后只是把文件标记为error。改造后的做法是:

uploader.on('uploadError', function (file, reason) { // 单个分块失败,记录分块序号,等待重试 var retryCount = file.retryCount || 0; if (retryCount < 3) { file.retryCount = retryCount + 1; // 延迟2秒重传该分块 setTimeout(function () { uploader.retry(file); }, 2000); } else { // 超过重试次数,提示用户手动重试 this.trigger('uploadFailed', file, reason); } });

重试间隔用2秒,避免重试请求打爆服务器。另外要区分“网络错误”和“服务器5xx错误”:网络错误重试有价值,服务器5xx说明后端可能已经过载,这时候重试只会雪上加霜。我们的处理是5xx错误直接放弃本块,等队列里其他分块传完后整体重试。

HTTP状态码层面也要做好区分。分块接口在接收成功时统一返回200,业务校验失败返回400,临时目录读写异常返回500。前端依据状态码走不同的处理分支,而不是一刀切重试。

5. 常见故障与排查实录

5.1 高频问题速查表

这套系统上线后,我整理了一份高频问题排查表,基本都是实际踩过的坑:

故障现象可能原因排查与处理
上传到一半连接被重置,Nginx日志报upstream closedproxy_request_buffering未关闭,代理缓冲导致连接长时间占用被回收调整Nginx参数并reload,见4.1
小文件上传报413client_max_body_size值过小调大到100m,覆盖整包上传场景
分块接口跨域报错,浏览器控制台出现CORS错误网关或后端未返回跨域响应头检查Access-Control-Allow-Origin配置及OPTIONS预检处理
浏览器开好几个并发,上传速度反而下降并发超过服务端连接槽位,或上行带宽饱和将threads降到3,分块大小降到4MB试压
合并后文件损坏,打不开合并时并发写导致顺序错乱,或未按索引顺序合并改成单线程顺序追加写入,合并后校验MD5
传完所有分块点击合并,接口报404网关白名单未放行合并接口更新网关路由,增加merge接口
上传过程中浏览器主线程卡死MD5计算未使用Web Worker把MD5计算移到worker,见3.2
断点续传不生效,刷新后还是从头传前端未保存MD5,或后端未扫描已传分块确保每个分块请求都携带md5参数,后端做chunk探测
上传后服务端临时目录占用磁盘过大上传中断后临时文件未清理增加定时清理任务,删除超过24小时未合并的临时目录
同一个文件多人同时传,出现文件互相覆盖合并时未考虑文件命名冲突合并文件名附加md5前缀或时间戳,避免覆盖

5.2 一个典型的“幽灵失败”排查过程

这里分享一个非常典型的排查经历。上线后有一段时间,经常有人反馈说“上传到90%就卡住不动了”,而且不是所有人都会遇到,只在某个园区办公网段出现。

一开始以为是前端并发或者分块大小的问题,调整参数后没有改善。后来抓包发现,卡住的时候浏览器都在等一个慢响应——某个分块请求发出去后,服务器迟迟不回包。

沿着这个线索查下去,发现是办公网段的防火墙对长连接有闲置回收机制。WebUploader在传输过程中会复用HTTP连接,连接闲置超过一定时间就被防火墙掐了,客户端和服务端都不知道,于是出现“半开连接”,请求永远得不到响应。

解决办法是给XHR请求强制设置短连接头,分块之间不复用连接,或者在后端加Keep-Alive心跳检测。最终我们选择了在Nginx层对上传接口的keepalive_timeout做针对性调整,配合前端主动断连重试的逻辑,这个问题才算彻底解决。

这个案例给我的教训是:内网环境中的中间设备(防火墙、负载均衡)比公网更不可控,很多“看起来是应用层问题”的现象,根源其实在网络设备策略上。遇到诡异故障,一定要尽早抓包,不要只盯着代码层面猜。

6. 改造后的效果与经验心得

改造完成后,我们做了一轮实际数据对比。同一份1.2GB的整车数模文件:

维度改造前改造后
分块大小4MB8MB
请求数量307个154个
并发数13
断点续传刷新后要重传刷新后秒级续传
秒传(重复上传)不支持支持
实测完成时间45~70分钟15~20分钟
失败率约15%小于1%

这些数字看着直观,但我想说的是,分块大小、并发数这些参数从来不是固定的,要结合企业内网的实测带宽、服务端承载能力和业务并发量来调。我们这边千兆内网、Tomcat 200线程、日常同时上传人数不超过30,这套参数适用。如果你的环境是万兆内网或者服务端有专门的存储集群,完全可以把并发提到5、分块提到16MB,性能还能再上一个台阶。

最后分享几个实操层面的心得。

第一,WebUploader的代码确实老,但它的核心分块逻辑很稳,不要全盘推翻重写,在其事件钩子上扩展是最省力的。我们改造后的核心代码量其实不大,大部分时间都花在调参和踩HTTP的坑上。

第二,大文件上传调试一定要模拟弱网环境。浏览器开发者工具里限速到1Mbps,再配合随机断网,能暴露出一堆正常情况下发现不了的问题。我们产品上线前专门用这套方式做了三天的极限测试,把重试逻辑和断点续传的边角情况都打了一遍。

第三,给服务端写清楚临时文件清理机制,越早越好。分块方案天然会产生大量半成品文件,如果不做定期清理,硬盘被占满是迟早的事。我们配了一个每天凌晨跑的定时任务,把超过24小时未合并的临时目录全部清掉,同时保留最近三天的上传日志和MD5索引,方便追溯异常文件。

第四,建议把上传进度对接进业务系统。光有技术指标不够,一线工程师看的是“我还要等多久”。我们把分块进度换算成剩余时间和剩余大小展示到界面上,研发同事反馈说体感提升很大——等得明明白白,比干等强多了。

这套改造方案本身不复杂,难的是把HTTP链路里每一层的配置都校准。如果你手头也在搞类似的车企大文件上传系统,希望能少走一些弯路。有问题可以在评论区留言交流。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询