ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

WebUploader大文件上传优化:分块并发与断点续传改造实践

2026/10/2 19:11:46 拓冰建站 浏览量
WebUploader大文件上传优化:分块并发与断点续传改造实践 我们团队刚接手一个整车制造企业内部图纸协同平台的性能优化几十个工程师同时往系统里传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~100MB1MB分块兼容弱网环境失败恢复粒度细100MB~500MB5MB分块常规大文件请求数和恢复粒度的平衡点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/s10MB的分块理论上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并发带重试分块数量15030平均上传完成时间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个请求里有一大半的状态是stalledTLS握手等待时间普遍超过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.timeoutIE连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%以内才逐步放开到全公司避免新逻辑在未知网络环境下暴露问题影响所有人。第三分块上传改造是一个全链路问题前端再努力服务端接口没有幂等、没有临时文件管理、网关超时配置不匹配效果也会大打折扣。排优先级的话服务端正确地处理分块落盘和合并排序比前端花哨的并发和重试重要得多先把基础打牢再谈体验优化。如果你们也遇到老系统上传大文件的问题我的建议是从小处改起先诊断瓶颈在哪一层再针对性动刀这套方案大概率能帮你少走很多弯路。