十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

WebUploader改造实践:大文件分片断点续传方案全解析

WebUploader改造实践:大文件分片断点续传方案全解析 上个月接到一个让人头疼的改造需求内部系统要上传几十GB的卫星视频单文件二三十GB是常事网络环境是专网带宽不算差但链路偶尔会抖一断网就要从头传业务方骂了半个月。我当时的方案是拿WebUploader开刀——这个百度开源的老组件虽然维护不积极了但它的分片上传、队列管理、事件钩子体系非常成熟网上资料也多拿来做超大附件的跨浏览器分片断点续传底座远比从零写一个上传组件靠谱。这篇文章把整个改造过程梳理一遍从选型逻辑、分片参数设计到前后端断点续传协议、跨浏览器兼容细节再到高可靠场景下的校验和审计加固全部是实践过的方案你照着搭也能跑通。1. 为什么拿WebUploader开刀选型逻辑与原生短板1.1 在众多上传方案里为什么还是它市面上做文件上传的方案其实不少原生XMLHttpRequest、Axios、Plupload、filepond、uppy各有各的亮点。但我在评估了一圈之后还是选择了WebUploader原因有几个。第一个是它的事件体系足够细。WebUploader把上传生命周期拆得很开文件入队前、入队后、开始上传前、发分片前、分片进度、整体进度、成功、失败、重试每个环节都有钩子。这对改造特别重要因为断点续传的核心工作就是在合适的时机拦截并注入额外逻辑钩子越细改造越省力。比如uploadBeforeSend这个钩子可以在每个分片发出前动态加参数续传信息的传递就靠它。第二个是它原生就支持分片。chunked: true这个开关一打开WebUploader内部会把大文件切成block序列逐个上传并且自带基础的并发控制。这意味着我不用从头实现分片读取、队列调度这些脏活累活。第三个是它内部已经做了HTML5与Flash双通道的封装。虽然现在Flash已经退出历史舞台但WebUploader在HTML5通道上的实现是稳定可靠的File.slice、FormData、XHR Level 2这些兼容细节它在底层都处理过了。老一辈组件踩过的浏览器兼容坑基本都填平了。第四个是它没有复杂的外围依赖。WebUploader核心就一个JS文件加一个CSS不依赖框架无论是jQuery项目还是Vue、React项目都能低成本接进去。在政企和涉密内网这类环境里前端技术栈通常很杂一个无框架依赖的组件兼容性最好。1.2 原生能力盘点能用但离断点续传还差关键一步把WebUploader原生能力和项目需求拉个清单对比问题就很清楚了。能力维度WebUploader原生情况项目需求差距分片上传支持chunked:true即可开启必须支持基本满足分片大小配置支持chunkSize参数按网络环境动态配置满足并发控制支持threads参数控制服务端压力满足失败重试有retry机制需要可控重试次数需要增强跨浏览器HTML5Flash双通道现代浏览器为主Flash通道需要废弃断点续传不支持无已传分片查询核心需求差距很大秒传不支持有的话体验更好需要自建数据完整性校验仅依赖HTTP传输分片级校验需要自建上传进度精确性有整体进度但续传场景不准真实反映整体进度需要修正核心差距就在断点续传。原生WebUploader的chunked只是把文件切成块发送但如果传了一半断了重新选择同一个文件它会从头开始。因为WebUploader本身没有能力知道服务端哪些分片已经落盘了。这个问题不解决超大附件的上传体验就永远是噩梦。1.3 改造前的边界确认哪些事不该让前端组件干我习惯在动手前先划清楚责任边界不然写着写着就变成前端什么都做后端什么都不管最后接口一团乱。WebUploader作为一个前端组件它该管的是文件读取、分片切割、请求发送、进度反馈、失败重试、用户交互。它不该管的是文件存储、分片落盘、分片合并、文件去重、权限校验、操作审计。这些必须由后端系统提供接口和存储能力。所以在整个改造方案里前端只是把分片状态这件事处理好真正的数据可靠性和完整性依赖一套清晰的前后端交互协议。这也是我接下来要讲的重点——协议设计。2. 分片策略不是拍脑袋参数设计的计算逻辑2.1 分片大小怎么定算一笔数学账分片大小的选择直接决定了上传的稳定性和速度。太小了请求数量爆炸服务端压力大太大了一次失败重传的成本高而且浏览器和服务端的内存压力也大。我一般用一个简单的公式来估算总分片数 文件大小 / 分片大小。以10GB文件为例分片大小总分片数单分片失败重传成本适用场景2MB5120低但请求数过多公网弱网环境5MB2048中公网常规10MB1024中高内网/专网20MB512高高带宽内网我这个项目跑的专网带宽在100Mbps以上业务方希望大文件能尽快传完我最后选了10MB分片。10GB文件就是1024个请求专网环境下并发3个几分钟就能传完。还有一个隐性因素很多服务端网关、HTTP服务器对单个请求体大小有限制比如Nginx默认client_max_body_size是1MBTomcat默认是2MB。分片大小定得太大会直接被网关挡掉太小又浪费请求。10MB是一个比较折中的值。当然如果你的服务端限制了单请求大小分片必须跟着它走。2.2 并发参数threads不是越大越好WebUploader的threads参数控制同时上传的分片数量默认是3。很多人觉得并发越高越快其实要分场景看。在服务器端每个分片请求都要写临时文件。如果并发太高磁盘面临大量随机写IO反而会成为瓶颈。特别是机械硬盘做存储的服务器10个并发同时写每个都写一点磁盘磁头来回跳整体吞吐还不如3个并发顺序写。我在测试环境做过对比threads3和threads10对最终耗时的影响非常小但服务器负载差异很大。所以生产环境我建议稳定用3最多5。另外并发数和分片大小的组合也要一起考虑。如果分片小、并发高那单位时间内的请求数就非常密集容易触发后端接口的限流策略。10MB分片配合3并发对大多数系统来说都是比较温和的节奏。2.3 文件指纹与任务ID续传的身份证断点续传的前提是服务端得知道你正在上传的这个文件和上次那个是一个文件。所以我给每个文件生成一个唯一标识这里有两个层面的东西。第一层是文件标识fileKey在文件入队时计算。我采用的组合是文件名 文件大小 最后修改时间做一个哈希字符串。为什么不用MD5因为对几十GB的文件算MD5即使采用增量算法在浏览器端也要好几分钟而且会大量占用CPU。用文件元数据组合成的key虽然理论上存在碰撞可能同名、同大小、同修改时间的文件被判定为同一文件但在实际业务场景中概率极低够用了。第二层是任务IDtaskId由后端在创建上传任务时返回。前端在文件入队后、真正开始上传前先调一次初始化接口后端在服务端建立一条上传任务记录返回一个唯一ID。后面每个分片都携带这个taskId服务端就知道该往哪个临时目录里写。这两层标识配合使用fileKey用来判断这个文件以前传没传过秒传和续传taskId用来定位当前这次上传任务的状态。3. 断点续传协议设计前端与后端的四次握手3.1 上传前的初始化握手断点续传不能一上来就传分片得先让前后端对一下暗号。我的流程是这样的文件入队后前端先调/api/upload/init接口带上文件名、文件大小、分片大小、fileKey。后端做三件事判断这个文件是否已经完整上传过。如果是直接返回completed: true前端走秒传流程用户几秒内看到上传成功。判断这个文件是否传过一部分。如果是返回已有的taskId以及已上传分片的索引列表。如果是新文件创建一条新的上传任务返回新的taskId和空的分片列表。前端收到响应后把taskId挂在file对象上把已上传分片索引存成一个Set然后在真正上传前将对应的block标记为已完成。这一套操作下来续传的底子就铺好了。初始化接口的交互逻辑// 文件入队后触发 uploader.on(fileQueued, function(file) { const fileKey [ file.name, file.size, file.lastModifiedDate ? file.lastModifiedDate.getTime() : ].join(_); // 缓存fileKey到文件对象上 file.__fileKey fileKey; // 调初始化接口 uploadInit({ fileName: file.name, fileSize: file.size, chunkSize: uploader.options.chunkSize, fileKey: fileKey }).then(function(res) { file.__taskInfo res.data; if (res.data.completed) { // 服务端已经有完整文件直接标记完成 uploader.trigger(uploadSuccess, file, { skipped: true }); } else { // 记录已上传分片准备续传 file.__uploadedChunks new Set(res.data.uploadedChunks || []); } }); });3.2 在uploadBeforeSend里植入分片状态uploadBeforeSend是WebUploader里最关键的改造点。它会在每个分片请求发出前触发允许我往请求数据里追加自定义字段。我在这里把taskId、分片索引、分片校验值全部塞进去。uploader.on(uploadBeforeSend, function(block, data) { const file block.file; // 携带任务ID和分片索引 data.taskId file.__taskInfo.taskId; data.chunkIndex block.chunk; data.fileKey file.__fileKey; // 分片级MD5用于服务端校验分片完整性 // block.data是当前分片的Blob可以交给spark-md5计算 if (block.data block.data.size 0) { data.chunkMd5 computedChunkMd5(block.data); // 异步计算结果存入block.__md5 } });这里有个细节chunkMd5的计算是异步的直接同步写在uploadBeforeSend里可能拿不到值。实践中我在block对象上挂一个扩展属性在分片上传前提前用spark-md5计算好uploadBeforeSend触发时直接读缓存。计算分片MD5比整个文件MD5快得多10MB分片也就几十毫秒的事。3.3 续传判断跳过已上传分片的心法前端真正跳过已上传分片需要动到WebUploader内部的block状态机。WebUploader在开始上传时会遍历file.blocks数组找到status为pending的block逐个发送。所以只要把已上传分片对应的block状态改成doneWebUploader就会自动跳过它。function markChunksAsDone(file, uploadedChunkIndexes) { if (!file.blocks || uploadedChunkIndexes.length 0) return; uploadedChunkIndexes.forEach(function(index) { const block file.blocks[index]; if (block) { block.status done; block.percent 1; } }); }block.percent也要一并置为1否则WebUploader在计算整体进度时会因为这个块的percent是0而出错。这个技巧我在WebUploader 0.1.5版本上验证过跳过之后上传流程不受影响续传开始后很快就能完成剩余分片。同时我在初始化接口返回结构里把uploadedChunks设计为一个数组而不是布尔值这样前端可以精确知道哪些分片缺失。服务端在分片落盘时也要做幂等处理同一个taskId 同一个chunkIndex如果分片文件已存在直接返回成功不要重复写盘。这层幂等是最后的兜底防止前端状态机出问题时重复传输。3.4 合并与完整性验证收尾环节不能省最后一个分片传完后前端调/api/upload/merge接口后端把临时目录里的分片按顺序合并成完整文件。合并完成后服务端对完整文件算一次SHA-256或MD5返回给前端展示。既然分片已经传完了为什么还要在合并时再做一次全文件校验因为分片MD5只能证明每个分片数据在传输过程中没有被破坏但合并过程本身也可能出问题——比如某个分片缺失只是前端误判了、服务端磁盘写入顺序错乱、合并程序本身有bug。全文件校验是最后一道关卡能发现这些隐蔽问题。merge接口的响应结构我习惯这样设计{ code: 0, data: { taskId: task_20250101120000_abc123, fileId: file_001234, fileMd5: a3f5c8d1e2b0..., fileSize: 10737418240, costTime: 320 } }前端拿到fileId后可以把上传状态更新为已完成展示文件MD5给用户留档。审计人员以后查文件完整性时也有据可依。3.5 失败重试控制次数比无限重试更重要大文件传输过程中分片失败是常态。网络抖动、服务端临时报错、网关超时任何一个都可能让某个分片失败。我在uploadError事件里做了可控重试uploader.on(uploadError, function(file, reason) { const retryCount file.__retryCount || 0; if (retryCount 5) { file.__retryCount retryCount 1; // 指数退避1s、2s、4s、8s、16s const delay 1000 * Math.pow(2, retryCount - 1); setTimeout(function() { uploader.retry(file); }, delay); } else { // 超过重试上限停住提示用户手动介入 uploader.trigger(uploadOutOfRetry, file); showErrorDialog(文件 file.name 上传失败请检查网络后点击重试); } });重试次数控制在5次配合指数退避既不会在弱网环境下疯狂刷请求也不会一失败就放弃。超过上限后不再自动重试而是把控制权交回给用户因为有些问题比如服务端磁盘满了、存储服务挂了靠重试解决不了需要人工处理。4. 跨浏览器兼容的关键战场Flash退场后的现代方案4.1 废弃Flash通道老组件的断舍离WebUploader的原始设计是HTML5与Flash双通道检测到浏览器不支持HTML5时自动降级到Flash。但Flash早在2020年底就停止维护了主流浏览器默认禁用甚至移除了Flash插件。所以在改造时我直接把Flash通道掐掉了强制走HTML5。const uploader WebUploader.create({ swf: /static/Uploader.swf, // 保留配置项但不实际使用 html5: true, flash: false, // ... });一定会有读者问那老的IE浏览器怎么办我的建议是别再花精力兼容了。在政企和涉密内网环境里前端能做的兼容努力是有限的如果业务方仍然要求支持IE11那应该推动业务方升级浏览器而不是逼自己在一个已经停止维护的上传组件上继续做IE兼容。把有限的开发资源投入到现代浏览器体验优化上投入产出比高得多。4.2 现代浏览器差异处理Safari、Chrome、Edge的实战对比现代浏览器的跨浏览器问题主要集中在这几个方面File.slice的兼容性。WebUploader内部会判断浏览器支持的是File.prototype.slice还是File.prototype.webkitSlice或File.prototype.mozSlice这个它已经处理了。但要注意如果你们对WebUploader做了升级或者自己写分片读取逻辑一定要按照标准API来别依赖已经被废弃的webkitSlice。FormData中Blob文件的文件名问题。Safari在向FormData追加Blob时filename属性可能丢失服务端通过multipart解析时拿到的文件名会变成blob而不是原始文件名。解决方法是服务端在解析分片时不要依赖表单里的文件名而是以chunkIndex和taskId为准来命名分片文件。前端也不要把分片文件名用于业务逻辑。Safari的内存限制。Safari对单个标签页的内存使用有更严格的限制当长时间上传超大文件时如果不及时释放Blob引用和ArrayBuffer很容易触发页面崩溃。我在实现里会在每个分片上传完成后显式释放对分片Blob的引用。大文件读取的内存管理。这是跨浏览器场景下最容易踩的坑。用Blob.slice(start, end)读取分片数据时浏览器只会把这一小段数据载入内存这是Blob天然的优势。但要注意千万不要把整个文件转成ArrayBuffer或Base64再去分片——一个10GB的文件直接被FileReader.readAsArrayBuffer读到内存里页面必崩。正确做法是始终用file.slice()生成子Blob再交给FormData发送让浏览器自己去处理流式读取。内存释放的参考写法uploader.on(uploadSuccess, function(file, response) { // 清理已上传完成的分片数据引用 if (file.blocks) { file.blocks.forEach(function(block) { block.data null; }); } });4.3 断网与页面刷新的跨浏览器恢复策略跨浏览器兼容不止是API差异还包括那些所有浏览器都会遇到的极端场景断网、用户误关页面、电脑睡眠唤醒。这几个场景的处理方式我一起说一下。断网自动暂停。浏览器提供navigator.onLine和online/offline事件。我在改造中监听了offline事件一旦断网立即调用uploader.stop(true)暂停上传同时给用户明确的提示恢复网络后自动调用uploader.upload()继续上传。这样避免了断网期间大量请求打出去然后全部超时的浪费。window.addEventListener(offline, function() { if (uploader) { uploader.stop(true); showToast(网络已断开上传已暂停); } }); window.addEventListener(online, function() { if (uploader currentFile) { showToast(网络已恢复继续上传); uploader.upload(currentFile); } });页面刷新后的任务恢复。用户传了80%不小心关了标签页重新打开后应该能接着传。我的做法是在fileQueued时把文件的fileKey、taskId、初始化接口返回的uploadedChunks存到sessionStorage里。刷新后如果页面恢复文件选择区域可以提示用户重新选择同一个文件然后前端用fileKey再次调用init接口服务端返回已上传分片列表继续传剩余部分。这套方案不强依赖本地存储因为就算sessionStorage被清空了只要用户重新选择同一个文件服务端的fileKey去重逻辑仍然能识别出这是个续传任务。5. 高可靠性场景下的加固经验校验、审计、异常恢复5.1 数据完整性分片校验和全文件校验双重保险卫星视频这类数据丢失一个字节都可能造成整段数据不可用所以完整性校验是改造中的重头戏。我在协议里做了双重校验分片级校验每个分片在uploadBeforeSend时计算MD5随请求一起传到服务端。服务端落盘后计算落盘文件的MD5两边一致才返回成功。如果不一致直接返回失败前端重试。文件级校验所有分片合并完成后服务端对完整文件计算SHA-256返回给前端展示。分片级MD5和文件级SHA-256配合既能快速定位到是哪个分片出了问题又能最终验证整个文件的完整性。这里有一个取舍分片MD5计算虽然快但也不建议对每个分片都做否则前端CPU占用会比较高。我在测试中发现10MB分片在普通PC上算MD5大约需要20-50ms对比网络传输的耗时这开销是可以接受的。5.2 传输安全与审计追踪内网环境也不能裸奔很多内网项目容易有个误区都觉得内网安全不需要加密不需要鉴权。实际上对于卫星视频这类高价值数据传输链路和操作审计一个都不能少。前端侧我做了三件事接口鉴权。所有上传相关接口都要求Authorization头携带tokentoken在用户登录后获取有效期内有效。这个token不只是身份认证还用来做权限校验——判断当前用户是否有上传权限、上传的文件类型和大小是否在授权范围内。参数签名防篡改。在uploadBeforeSend里我用taskId chunkIndex 时间戳生成一个签名参数sign服务端用同样的算法验证。这样即使有人拿到了请求地址也没法伪造分片内容。操作审计日志。前端在上传启动、暂停、续传、失败重试、完成这几个关键节点会把操作记录以异步请求的方式上报到审计系统。记录内容包括文件标识、文件大小、总分片数、已传分片数、上传耗时、浏览器UA、IP地址。如果以后出现数据完整性问题可以通过审计日志回溯整个上传过程。审计日志上报的关键代码function reportAudit(action, file, extraData) { fetch(/api/audit/upload, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ action: action, fileKey: file.__fileKey, taskId: file.__taskInfo ? file.__taskInfo.taskId : , fileName: file.name, fileSize: file.size, timestamp: Date.now(), extra: extraData || {} }) }).catch(function() { // 审计日志上报失败不影响主流程但要在控制台留痕 console.warn([audit] 上报失败, action, file.name); }); }5.3 服务端异常场景磁盘满、临时目录污染、幂等问题前端做得再好服务端不给力照样翻车。我在联调过程中遇到过几个典型的服务端异常这里一并记录下来给大家提个醒。服务端临时磁盘满。超大文件上传时所有分片都写在临时目录几十GB的临时文件很容易把磁盘塞满。我在前端能做的是监控merge接口返回的错误码如果服务端返回DISK_FULL之类的业务错误立刻停止重试并给出明确提示而不是让用户傻傻地等待。临时分片文件清理策略。服务端需要定期清理超过一定时间未完成合并的临时分片。这个工作前端做不了但作为整体方案的一部分我会在技术方案文档里明确给后端提这个要求。否则传了一半放弃的任务临时文件会永远留在磁盘上最终把存储空间耗光。分片重复上传的幂等处理。前面提到过服务端必须做分片幂等对同一个taskId和chunkIndex如果分片文件已存在直接返回成功。这个逻辑不能省因为前端的markChunksAsDone可能会因为各种原因失效比如用户清除了浏览器缓存导致本地状态丢失只能重新选择文件从头再传一遍这时候服务端如果能识别出已存在的分片就能避免重复写盘。5.4 实测数据一个20GB文件的上传过程复盘最后贴一组实测数据展示改造后的效果。测试环境专网带宽100Mbps分片大小10MB并发数3文件大小20GB总分片2048个。环节数据首次上传耗时约32分钟中途模拟断网1次自动暂停恢复后续传耗时约4分钟已传82%重传分片数0校验通过无需重传最终文件完整性校验SHA-256一致审计日志完整度全流程有记录这个结果让业务方满意也让整个团队意识到断点续传体验的核心不只是能续这个功能本身更是靠一套完整的状态协议和校验机制撑起来的。这次改造下来我最大的体会是老组件不等于烂组件关键看你会不会用它的钩子机制做扩展。WebUploader的源码我后来抽空通读了一遍发现它虽然维护停滞但内部设计并不落伍尤其block状态机和事件钩子这套东西放在今天依然能打。如果你也遇到类似的大文件上传需求与其自己从零撸一个上传组件不如先评估一下手头现有的组件能不能像这样改造。很多时候选对底座比造一个新轮子省心得多。最后再分享一个小技巧如果你们的内网环境支持HTTP/2一定把上传接口也走HTTP/2。HTTP/2的多路复用机制对分片上传的并发性能提升非常明显同样的并发数在HTTP/2下的实际吞吐比HTTP/1.1高出一大截。我在这次改造的后半段才切到HTTP/2切换后整体上传耗时又缩短了约15%这算是性价比很高的优化手段了。
返回列表