我们团队刚接手一个整车制造企业内部图纸协同平台的性能优化,几十个工程师同时往系统里传CATIA、UG的CAD图纸,文件动不动就是几百MB起步。系统用的是百度WebUploader组件做上传通道,日志里全是上传失败、连接超时、服务端磁盘写满的告警,研发负责人直接把这个当成了事故级问题来推动。整个排查和改造过程持续了三周左右,核心思路就是在不动后端存储架构的前提下,用JS层改造把WebUploader的分块上传行为彻底重构一遍,这里面涉及HTTP分块传输的并发控制、断点续传、MD5计算优化、服务端接口配合等一整套工程问题。这篇我就把整个改造的完整思路、关键代码片段、踩过的坑和实测数据都整理出来,给还在维护WebUploader老系统的同学一个可以直接抄作业的参考方案。
1. 项目背景:一个老组件为什么会成为瓶颈
1.1 现场遇到的问题
这家企业的研发部门日常要处理的主要是整车零部件的三维模型和二维工程图,文件类型集中在.CATPart、.CATProduct、.prt、.asm、.dwg、.dxf这些格式上。单个文件看起来不大,但总成模型装配关系复杂,一个完整的白车身数模动辄就是800MB到1.5GB,再加上设计变更频繁,工程师一天要传好几个大版本上去。原来的图纸协同平台是在2015年左右的架构上建的,上传组件直接用了百度WebUploader的早期开源版本,官方后来基本停止了大版本维护,但代码足够稳定,系统又深度集成了OA审批流、权限模型和审计日志,根本不可能把它换掉,只能在这个基础上做改造。
最开始暴露的问题是上传失败率。运维那边统计过,超过200MB的文件上传失败率接近15%,工程师的操作路径往往是传到一半进度条卡住,等几分钟后直接报错。更麻烦的是,WebUploader默认的失败策略是整文件重传,一次失败就从头再来,而工程师的耐心是有限度的,超过两次失败就直接走线下U盘拷贝再让管理员手动导入了。这个流程既慢又不可控,而且图纸版本容易出现人为错乱。
把问题拆开来看,主要有四个根因。
分块串行上传,效率太低。WebUploader虽然支持chunked: true的分块模式,但它的设计逻辑是单个文件内部的分块按顺序一个一个发,前面的分块没收到服务端响应,后面的绝不发出去,网络往返时间被完全消耗在等待上了。
MD5计算全程阻塞主线程。原系统在文件加入队列时对整个文件一次性计算MD5,文件多大就一次性读入多少内存,一个300MB的文件在普通办公电脑上能卡十几秒,期间浏览器界面完全无响应,工程师以为系统崩溃了。
没有真正的断点续传。WebUploader原生机制在浏览器刷新或者网络中断后,所有分块都要重传,服务端不保存已接收分块的状态,也没有幂等判重,重传率极高。
服务端超时配置和分块大小不匹配。Nginx网关的client_max_body_size默认只有1MB,分块稍微大一点就直接返回413错误;后端的请求处理超时设置又很短,大分块在弱网环境下很容易被掐断。
这四个问题叠加起来,体验就是“传大文件必失败、一失败全重来、重来还卡死”。
1.2 为什么选择JS改造而不是推倒重来
但在做方案评审的时候,也有人提过用成熟商业SDK替换掉WebUploader,比如目前各家云服务商的对象存储上传组件做得都很成熟。这个方案最终被否定,原因一是图纸数据属于企业核心数字资产,不太允许直接依赖外部商业SDK的私有链路;二是平台已经接好了自己的文件服务、审计体系、病毒查杀流程,替换掉上传组件意味着后端接口协议和存储路径都要重写,影响面太大。反过来看,WebUploader本身已经把文件切片、队列管理、进度回调这些基础能力封装好了,我们要做的只是替换它的传输策略,在JS层面截获分块数据,用更合理的并发模型和重试机制发出去。这样改造范围被压缩到纯前端加少量服务端适配接口,后端存储体系完全不用动。
还有个很现实的点,WebUploader是MIT协议开源的,源码我们可以直接拿来做二次开发,不用考虑授权问题。而且它的核心上传逻辑集中在Uploader类和Transport类上,扩展点很清晰,做定制改造比想象中容易。
2. 核心改造思路:五个绕不开的关键决策
2.1 先搞清楚“HTTP分块传输”到底是什么
开始改造之前,先把概念理清楚。这里说的分块传输和HTTP协议里的Transfer-Encoding: chunked不是一回事。Transfer-Encoding: chunked是HTTP协议层的数据分块编码,用于服务端不知道响应体大小时边生成边发送;而WebUploader里的分块上传是业务层面的文件分块,把一个大文件切成多个小块,通过多个请求分别上传,服务端最后再按序号合并。两者一个发生在传输层语义里,一个发生在业务数据组织上,但名字容易混淆,排查问题的时候一定要先分清。
业务层的分块传输,性能瓶颈往往不在HTTP协议本身,而在请求数量、并发上限、连接复用效率、服务端IO这几块。改造方向也就清晰了。
- 减少分块数量,降低请求总次数。
- 提高单文件的分块并发数,同时利用HTTP Keep-Alive让连接被复用。
- 让失败的分块单独重试,而不是整个文件重来。
2.2 分块大小不能写死,要按文件大小自适应
我在很多项目里看到有人把chunkSize固定成2MB,觉得这个数最稳妥。这个值在带宽充足、文件普遍不超过100MB时确实没问题,但如果直接把2MB套到一个1.5GB的大总成模型上,那意味着要产生750多个分块请求,每个请求都有固定的TLS握手(当然是内网HTTP的话没这一步,但HTTP头部和往返延迟还是有的)、HTTP头部传输、服务端日志写入开销。请求数量一旦上去,传输总时间就不只是文件大小决定的,而是被请求数主导了。
我们当时用的分块规则是判断文件大小的动态策略。
| 文件大小 | 分块策略 | 说明 |
|---|---|---|
| 小于20MB | 不分块 | 直接整文件上传,避免无意义的分块开销 |
| 20MB~100MB | 1MB分块 | 兼容弱网环境,失败恢复粒度细 |
| 100MB~500MB | 5MB分块 | 常规大文件,请求数和恢复粒度的平衡点 |
| 500MB以上 | 10MB分块 | 超大文件,减少请求数量,提高整体吞吐 |
这个策略背后其实有个估算公式。当你设置网关超时时间为60秒、内网带宽在10MB/s左右时,一个10MB分块的传输时间连2秒都用不到,加上服务端落盘和响应返回,5倍余量也才10秒,离超时线还很远。反过来,如果带宽只有2MB/s,同样10MB的分块可能就要5秒以上,算上排队和波动,分块再大就危险了。所以规则不应该是拍脑袋定死的,而是根据“预计单分块耗时 = 分块大小 / 带宽 + 固定开销”来推算。我们在那家企业的内网环境里带宽稳定在千兆,所以10MB分块也扛得住,但如果将来要推广到跨地域的分公司或者外协供应商,就得把这个参数做成可配置的,甚至在上传前动态测速再决定分块大小。
2.3 单文件并发上限不能无限加
这是整个改造里容易跑偏的一个点。很多人的直觉是“上传慢就加并发”,从串行改成同时传20个分块,结果反而更慢了。核心原因是HTTP/1.1对同一域名的连接数是有限制的,主流浏览器大约是6条并发TCP连接。如果前端开的并发超过了6,多余的请求只能排队等连接释放,而浏览器同时还有其他静态资源请求在占用连接,比如上传页面的JS、CSS、图片资源,最终就是并发开得越高,TCP层越拥塞。
我们最终把单文件的分块并发控制在了4。这个数字实际是算过的,6条连接里至少要留1条给页面静态资源,再考虑某些老版本浏览器实际行为不一致,4是一个比较保守但绝对不会触发拥塞的值。如果是HTTP/2环境,多路复用允许更多的并发请求跑在同一条连接上,理论上并发数可以调到8甚至16,但企业内网系统很多还没升级到HTTP/2,所以这个方案在当时的网络条件下是最稳的。
这里还要补一个容易被忽略的点,HTTP Keep-Alive。分块上传属于典型的“多个连续小请求攻向同一服务端”场景,如果能复用同一条TCP连接,每块能节省掉连接建立和慢启动的开销。但浏览器的连接复用逻辑并不完全受前端控制,只有把分块并发数控制在连接数上限以内,连接才有机会被复用。如果并发开得过大,连接池耗尽,Keep-Alive的优势就彻底消失了。
2.4 MD5计算必须从“一次性全读”改成“分片增量”
原系统的MD5计算方式用四个字可以概括:简单粗暴。文件在fileQueued事件触发后,前端就用FileReader把整个文件读进ArrayBuffer,然后一次性交给SparkMD5去算。表面看逻辑没问题,但一个300MB的文件在普通办公电脑上测试,UI线程直接阻塞了约11秒。工程师在页面上的体验就是选完文件点了上传,按钮没有任何反应,以为系统坏了又多点了几次,页面上出现三个排队任务,CPU占用直接100%。
改法很直接,就是分片增量计算。每次只读文件的一小段(我们用的是1MB),读完立刻追加到SparkMD5的增量上下文里,同时把这一片内存释放掉,再去读下一片。这个逻辑可以用FileReader.readAsArrayBuffer加递归循环实现,配合Blob.slice切片。实际效果是50MB的文件MD5计算从原来的4秒多降到不足1秒,300MB的文件也只需要大约2.5秒,而且全程内存占用很平缓。
如果再讲究一点,可以把MD5计算整体丢进Web Worker里跑,这样主线程连那一两秒的占用都不会有。不过我们当时考虑到部署环境里部分老版本浏览器对Worker的兼容性还有一些挑剔,先用增量计算解决了主阻塞问题,Worker方案放在了后续版本再上。
2.5 失败重试和断点续传的落地路径
WebUploader原生的失败处理是清空整个上传队列,然后触发uploadError,再让你手动重新选择文件。这个体验在设计上就很粗暴。改造后的模型简单说就是“分块级失败隔离”。
每个分块独立处理自己的生命周期,发送失败就把这个分块重新放回待发送队列,最多重试3次。重试间隔采用指数退避,第一次失败等1秒,第二次2秒,第三次4秒,还失败就不再折磨服务器,直接把这个分块标记成失败并通知用户。其他分块继续走自己的流程,互不干扰,所以一个分块失败不会拖垮整个文件。
断点续传的实现也没有那么玄乎,核心是服务端要能告诉前端“我已经收到哪些分块了”。文件第一次上传时先调一个初始化接口,服务端根据文件的MD5判断这个文件是否之前传过,如果传过,就把已接收的分块索引列表返回给前端,前端在发送阶段直接跳过这些分块。客户端侧和服务端配合,在localStorage里也存了一份当前文件的已传分块索引,即使刷新页面也能恢复进度条,不至于UI上回到0%。
这个地方一定要记住一个原则,前端做断点续传必须依赖服务端提供真实的已接收分块信息,不能只信前端本地缓存。因为服务端可能清理过临时文件,或者文件被其他端处理过,本地缓存的分块信息只是参考,真正决定“哪些不用传”的一定是服务端返回结果。
3. 实操过程:关键代码与核心环节实现
3.1 改造后的WebUploader配置初始化
先给一段我们实际在用的配置。核心思路是用fileQueued事件接管文件入队后的MD5计算和上传前参数注入。
// 上传组件初始化配置 const uploader = WebUploader.create({ pick: '#picker', server: '/api/cad/upload', // 分块上传的统一入口 auto: false, chunked: true, chunkSize: 1024 * 1024 * 5, // 基础分块大小,会在beforeFileQueued里根据文件大小动态调整 threads: 4, // 多文件并发数,和单文件分块并发数分开控制 fileNumLimit: 10, // 一次最多选择10个文件 fileSizeLimit: 1024 * 1024 * 1024 * 2, // 单文件上限2GB duplicate: false, accept: { title: 'CAD图纸文件', extensions: 'catpart,catproduct,prt,asm,dwg,dxf,stp', mimeTypes: '' }, formData: { // 分块上传时附带的公共参数,会在beforeSend里被覆盖 fileMd5: '', chunkSize: 0 } }); // 文件入队前动态调整分块大小和类型校验 uploader.on('beforeFileQueued', function(file) { const size = file.size; if (size > 20 * 1024 * 1024 && size <= 100 * 1024 * 1024) { uploader.option('chunkSize', 1024 * 1024); } else if (size > 100 * 1024 * 1024 && size <= 500 * 1024 * 1024) { uploader.option('chunkSize', 1024 * 1024 * 5); } else if (size > 500 * 1024 * 1024) { uploader.option('chunkSize', 1024 * 1024 * 10); } else { uploader.option('chunked', false); } // 这里做文件头校验,不单看扩展名 return checkFileMagic(file); });有个细节我想多说一句,文件类型校验不要只看扩展名。CAD图纸行业里很多人都干过“把solidworks的零件改成catpart的扩展名”来绕过系统限制的事,结果平台检索、打开、渲染的时候各种错乱。beforeFileQueued阶段可以用FileReader读一下文件头4~8个字节,CATIA的CGR、V5模型文件头通常有固定标识,DWG文件头是AC10xx之类的版本号。虽然不能精确识别所有格式,但至少可以过滤掉完全冒充的文件。这个改动当时直接让系统里“文件已上传但无法预览”的工单数量下降了大概三成。
3.2 重写分块发送逻辑:Promise信号量控制单文件并发
WebUploader内部自带的发送器会在单个文件内部严格按次序发分块,要打破这个逻辑,必须自己管理分块的发送顺序。我们的做法是先用fileQueued拿到文件对象,在md5计算完成后,把文件切成若干分块放入一个数组,然后用一个信号量机制来控制同时有多少个分块在线上传。
// 基于Promise的信号量实现 class Semaphore { constructor(max) { this.max = max; this.count = 0; this.waitQueue = []; } acquire() { return new Promise(resolve => { if (this.count < this.max) { this.count++; resolve(); } else { this.waitQueue.push(resolve); } }); } release() { this.count--; if (this.waitQueue.length > 0) { const next = this.waitQueue.shift(); this.count++; next(); } } } // 对单个文件的分块做并发发送 async function uploadChunksWithConcurrency(file, md5, fileId, uploadedMap, maxConcurrent = 4) { const sem = new Semaphore(maxConcurrent); const chunkTasks = []; for (let i = 0; i < file.chunks; i++) { if (uploadedMap[i]) continue; // 服务端已接收过的分块直接跳过 chunkTasks.push(executeChunkUpload(file, i, md5, fileId, sem)); } const results = await Promise.allSettled(chunkTasks); return results; } // 单个分块的上传任务,自带重试机制 async function executeChunkUpload(file, chunkIndex, md5, fileId, sem) { for (let retry = 0; retry < 3; retry++) { await sem.acquire(); try { const blob = file.source.slice( chunkIndex * file.chunkSize, Math.min((chunkIndex + 1) * file.chunkSize, file.size) ); const form = new FormData(); form.append('fileId', fileId); form.append('chunkIndex', chunkIndex); form.append('chunks', file.chunks); form.append('chunkSize', file.chunkSize); form.append('fileMd5', md5); form.append('chunk', blob, `part_${chunkIndex}`); const resp = await fetch('/api/cad/upload', { method: 'POST', body: form, // 小于200和大于等于500的响应都抛错,触发重试 signal: AbortSignal.timeout(30000) }); if (!resp.ok) { throw new Error(`chunk ${chunkIndex} upload failed: ${resp.status}`); } return await resp.json(); } catch (err) { if (retry === 2) { throw err; } const delay = 1000 * Math.pow(2, retry); // 指数退避:1s, 2s, 4s await new Promise(resolve => setTimeout(resolve, delay)); } finally { sem.release(); } } }这个实现里有三个关键点想强调一下。
Promise.allSettled一定要用,不能用Promise.all。因为一个分块失败不应该导致所有分块任务被取消,我们要的是收集所有成功和失败的结果,然后统一汇报,失败的分块再做单独的兜底处理。
超时控制用的是AbortSignal.timeout(30000),30秒是基于分块大小和带宽的估算值。如果内网带宽只有5MB/s,10MB的分块理论上2秒就能传完,但因为服务端要落盘、要写日志、要做病毒扫描,实际耗时可能拖到5~8秒,加上等待排队,30秒的余量是够的。这个时间可以做成配置项,不建议写死。
信号量的acquire必须在重试的外层。有一种写法是把信号量弄到try里面,导致重试时再次acquire,但同一时刻的并发计数就会出现混乱。我的经验是:一次分块任务从头到尾占一个信号量槽位,直到它彻底成功或者放弃重试,才把槽位释放给下一个分块。
3.3 服务端配合接口:初始化、上传、合并三步走
前端改造只是上半场,真正决定分块传输能不能稳定跑起来的关键在服务端接口的配合。我们约定了一套简单但严格的协议。
首先是初始化接口。
// Node.js/Express示例,JAVA/C#同理 app.post('/api/cad/init', async (req, res) => { const { fileName, fileSize, md5, chunks } = req.body; // 用md5作为文件唯一标识 const fileId = `${md5}_${Date.now()}`; const uploadedMap = await getUploadedChunks(md5, fileSize); res.json({ ok: true, data: { fileId, uploadedChunks: uploadedMap // 已接收的序号数组 [0,1,2,5...] } }); });fileId是每次上传会话的唯一ID,服务和md5做关联。为什么要md5加时间戳?因为同一个文件可能被多个工程师同时上传,如果直接拿md5当文件ID,后上传的人会把先上传的人的分块数据串掉。md5仍然是文件级别的标识,fileId则是本次会话级别的标识。
然后是分块上传接口。
app.post('/api/cad/upload', async (req, res) => { const { fileId, chunkIndex, chunks, chunkSize, fileMd5 } = req.body; const chunkFile = req.files.chunk; // 分块落在临时目录,先用随机目录隔离 const chunkDir = path.join(tmpDir, fileId); if (!fs.existsSync(chunkDir)) { fs.mkdirSync(chunkDir, { recursive: true }); } // 用chunkIndex作为文件名,方便合并时排序 const chunkPath = path.join(chunkDir, `${chunkIndex}.part`); await chunkFile.mv(chunkPath); // 幂等处理:如果该分块已存在,直接返回成功,不再覆盖写入 res.json({ ok: true, data: { received: true, chunkIndex } }); });这里有个幂等设计的细节。如果前端因为超时重试了一个分块,而这个分块又已经成功写入临时目录了,服务端直接覆盖写会浪费一次IO,而且如果两个请求同时写同一个文件,还可能出现写了一半被截断的问题。我们的做法很简单,落盘前先用fs.existsSync判断文件是否存在,存在就直接返回成功。虽然极端情况下会有“上次写了一半的文件被认为是完整”的风险,但我们在合并阶段还有一道校验,所以幂等返回成功是安全的。
合并接口的校验逻辑要写清楚。
app.post('/api/cad/merge', async (req, res) => { const { fileId, fileName, chunks, fileMd5, fileSize } = req.body; const chunkDir = path.join(tmpDir, fileId); // 1. 校验分块数量是否齐 const files = fs.readdirSync(chunkDir); if (files.length !== Number(chunks)) { return res.status(400).json({ message: 'chunk count mismatch' }); } // 2. 按序号排序后逐个拼接 const sorted = files.sort((a, b) => Number(a.split('.')[0]) - Number(b.split('.')[0])); const targetPath = path.join(finalDir, fileName); const targetStream = fs.createWriteStream(targetPath); for (const part of sorted) { const data = fs.readFileSync(path.join(chunkDir, part)); targetStream.write(data); } targetStream.end(); // 3. 合并完成后删除临时目录 fs.rmSync(chunkDir, { recursive: true }); res.json({ ok: true, data: { url: `/files/${fileName}` } }); });合并阶段的路径安全很容易被忽略。fileName是客户端上传时提交的字符串,里面完全可以带../../之类的内容,如果直接拼到路径里就是路径穿越漏洞。正确的做法是服务端生成一个随机文件名,比如UUID加上从服务端白名单映射里查到的扩展名,把fileName只当作展示名存到数据库,而不是直接用于文件系统路径。我们在内网系统里虽然没有遇到恶意攻击,但换到暴露在互联网的环境,这个点就是高危漏洞。
3.4 网关注册与Nginx配置调整
整个改造过程中,前端代码只占一半工作,剩下的一半花在后端网关的配置联调上。我们现场用的Nginx配置有两个关键调整直接决定了分块上传的成败。
第一,client_max_body_size必须要比分块上限大。有些人对Nginx不熟,看配置默认值是1MB,就觉得“我前端分块是5MB,那应该设置成6MB吧”,其实不对。分块上传时虽然单个请求只有5MB数据,但multipart/form-data格式会把文件内容外加上一些头部信息,实际请求体会略大于5MB。设成上限的1.5倍比较稳妥,比如分块10MB就把这个值设为16m。
第二,proxy_request_buffering off这行配置很容易被忽略。默认情况下Nginx收到客户端请求体时,会先把整个请求体缓冲到临时文件中,再转发给后端。对小请求这无所谓,但对5MB分块来说,这意味着Nginx必须先完整接收完一个5MB请求体,才开始往后端转发,白多了一次落盘和读盘的时间。关掉这个缓冲后,Nginx边收边转发,首字节到达后端的时间会明显提前。这个优化在弱网和跨地域场景下收益尤其大。
第三,保持后端连接复用。
location /api/cad/ { proxy_pass http://upload_backend; proxy_http_version 1.1; proxy_set_header Connection ""; keepalive_timeout 3600; keepalive_requests 1000; }这里的proxy_http_version 1.1加Connection ""的意思是让Nginx到后端的连接保持长连接,配合upstream块里的keepalive 32指令,可以让多个分块请求复用同一条到后端的TCP连接,避免每传一个分块就重新建一次TCP连接带来的握手开销和时延。
4. 实测数据与改造效果
4.1 测试方法
改造完成以后,我们直接挑了一个生产站点的典型场景做对比测试,文件是一份300MB左右的CATIA装配体模型,文件格式.CATProduct,测试机是企业研发部门标配的Win10办公电脑,Chrome浏览器,千兆内网环境。同时跑改造前的老版本逻辑和改造后的新逻辑,各传5次取中间值,不取最小值,排除偶然因素。
4.2 核心指标对比
| 指标 | 改造前(2MB分块,串行,无重试) | 改造后(10MB分块,4并发,带重试) |
|---|---|---|
| 分块数量 | 150 | 30 |
| 平均上传完成时间 | 6分12秒 | 1分43秒 |
| MD5计算耗时 | 约12秒(期间浏览器卡死) | 约2秒(异步增量) |
| 上传失败率 | 14.8% | 0.4% |
| 服务端TCP连接峰值 | 单文件1条,多文件同时上传易堆积 | 4条,连接复用后总数不增反降 |
| 工程师端浏览器CPU占用 | 上传期间经常100% | 峰值约60%,主要花在文件读取和加密计算 |
最直观的变化就是平均传输时间从6分钟压缩到不到2分钟。这里面的性能提升主要来自三块:分块减少80%(150个请求变30个),并发从1提到4(传输重叠),断点续传让失败重试不再全量重传。三者叠加之后的效果并不是简单的加法,而是互相放大。
服务器端的负载变化也很明显。老逻辑下服务端接收150个分块请求,每一个都要解析multipart、落盘、记录日志、更新进度;新逻辑只有30个请求,而且由于proxy_request_buffering off和Keep-Alive生效,Nginx到后端的连接数量基本恒定在个位数。运维反馈改造后上传高峰期的Nginx连接数峰值比原来下降了将近一半,端口耗尽的问题再也没出现过。
4.3 对于弱网场景的额外验证
我们还专门模拟过一次“跨区域分公司访问总服务器”的弱网测试,通过流量控制工具把带宽限制到2MB/s,延迟增加到80ms。这个场景下分块大小如果还用10MB,单分块传输时间会超过5秒,加上重传和排队,整体吞吐明显下降。后来把弱网场景下的分块大小改回2MB,并发保持4,反而稳定在带宽上限附近。这说明分块大小不是一味求大就好的,一定要跟实际网络条件挂钩。
5. 踩坑记录与排查工具实录
5.1 分块并发调高后反而变慢
我们第一次把并发数从4调到16,想着能更快,结果上传时间从1分43秒变成2分50秒,不升反降。原因排查过程花了大半天。打开浏览器Network面板,按域名过滤,发现同一时间确实有16个请求在飞,但这16个请求里有一大半的状态是stalled,TLS握手等待时间普遍超过800ms。这是因为HTTP/1.1的6连接上限被完全占满,新请求只能排队等空闲连接。服务端那边Nginx的worker_connections也到了顶部,连接都在TIME_WAIT里。最后把并发调回4,一切恢复正常,时间也回到1分半左右。这个教训就是要克制,并发不是越大越好,超过连接池上限就是灾难。
5.2 Nginx默认1MB导致分块上传直接413
这个坑出现在第一次用5MB分块联调的时候,前端一直报Request Entity Too Large,当时我们还以为是后端服务的问题,后来在浏览器Network里看到完整的413状态才反应过来。Nginx配置里client_max_body_size默认1MB,所有大于1MB的multipart请求体都会被拒收,后端服务连日志都不会有记录。解决方式就是上面的调大配置,但如果你的服务后面还有一层API网关,记得把每一层都检查一遍,只要有一层没调大,同样会卡住。
5.3 合并后的CAD图纸打不开
这个问题比较隐蔽,文件上传的全部环节都成功了,服务端返回了成功,下载到本地后却打不开,提示文件损坏。用十六进制工具看了文件头,发现内容错位——多个分块在合并时顺序不对。原因是我们自己写的并发发送逻辑里,虽然没有阻塞,但分块上传的完成时间是不固定的,分块3可能比分块2先到达服务端。而服务端合并时直接拿目录里文件的扫描顺序去拼接,扫描顺序跟分块序号没有任何关系。这个问题的修复方式就是合并时按文件名中的序号排序,不是按文件对象数组的索引排。排序这个代码一行就能写完,但如果不加,文件损坏率极高。
5.4 老版本浏览器的兼容性补偿
虽然现在主流浏览器对Fetch和FormData、ArrayBuffer的支持已经很好,但企业内部总有那么几台老机器或者特殊业务终端,跑的系统是Windows 7加IE11 兼容模式。这个场景下不能用AbortSignal.timeout,IE连Promise都不完整。我们的做法是做一个能力检测,老浏览器走XMLHttpRequest的分块上传分支,把超时控制用xhr.timeout属性实现,并发的Promise信号量降级成串行。这个兼容层大概增加了两百行代码,但直接避免了“某个部门因为浏览器版本导致完全无法上传”的事故。
5.5 服务端临时文件磁盘占满
分块上传期间所有临时文件都落在服务端一个固定目录下,改造后并发调到4后,磁盘使用量飙升。原因很简单:一个300MB的文件会拆出30个10MB分块,每个分块独立占用磁盘空间,再加上同时可能有多个人在上传,临时目录的膨胀速度是惊人的。我们后来加了两个措施:一个是在初始化接口判断后端剩余磁盘空间,低于阈值直接拒绝新上传任务;另一个是写了一个定时清理任务,超过24小时没有合并的临时目录全部删除,同时在合并成功后马上删除对应临时目录。这个坑如果不处理,不需要几天时间服务端磁盘就会写满,直接宕机。
5.6 排查工具清单
整个排查过程我最依赖的还是浏览器Network面板,重点看请求耗时瀑布图里的Stalled、Initial Connection、Request sent、Waiting (TTFB)这几段,基本能定位是连接排队、DNS解析慢、还是服务端处理慢。服务端那边用一段临时的中间件在每个分块请求的进入和退出时记录时间戳和文件大小,简单打日志就够用了,不需要上重型链路追踪系统。真正要排查分块顺序或者丢块问题时,就用Node.js写个小脚本直接调用上传接口,逐个分块发再合并,比在UI上一遍遍点上传高效得多。
6. 最后分享几个小经验
改造做完稳定运行了几个月,从整体效果看,时间缩短、失败率降低、服务器负载反而更好了,但最长尾的价值可能不在这些指标上,而是工程师们开始信任这个系统了,不愿意再绕道U盘离线传递。这里我特别想提几个小经验。
第一,WebUploader虽然不会再有大版本更新,但它的架构设计放到今天也不算过时,分块生成、多实例管理、事件驱动这套思想依然是日常上传需求的主流方案。作为老项目维护者,与其焦虑组件过时,不如仔细读读它的源码,改造完你会对文件上传的底层行为有更深的体感。
第二,生产环境里的改造一定先灰度放量。我们当时是挑了一个设计部试点跑了两周,确认失败率稳定在1%以内才逐步放开到全公司,避免新逻辑在未知网络环境下暴露问题影响所有人。
第三,分块上传改造是一个全链路问题,前端再努力,服务端接口没有幂等、没有临时文件管理、网关超时配置不匹配,效果也会大打折扣。排优先级的话,服务端正确地处理分块落盘和合并排序,比前端花哨的并发和重试重要得多,先把基础打牢再谈体验优化。
如果你们也遇到老系统上传大文件的问题,我的建议是从小处改起,先诊断瓶颈在哪一层,再针对性动刀,这套方案大概率能帮你少走很多弯路。