最近在做一个内网项目,需求一句话就能说完:浏览器里上传卫星视频文件,单文件少说几个GB,多则上百GB,断网、断电、刷新浏览器都不能让用户从头来过,还得适配从IE老古董到最新Chrome的全套浏览器。刚开始我天真地以为这就是给WebUploader加两行配置的事,真上手才发现,原生WebUploader在“超大附件+跨浏览器+断点续传”这个组合拳面前,撑不过三个回合。
这篇文章把我改造WebUploader、实现跨浏览器超大附件分片断点续传插件的完整过程写下来,包括方案取舍、源码改动点、服务端协议设计,以及十几次实测才发现的坑。如果你正在做政企、军工、广电类的上传需求,这篇应该能帮你少走不少弯路。
1. 为什么原生WebUploader扛不住卫星视频:需求场景与能力边界
1.1 卫星视频上传场景的真实形态
先说清楚这个需求到底长什么样。卫星视频和普通视频文件不一样,格式五花八门,TS、MXF、MP4都有,码率高,单文件动辄5GB起步,我这边接到的文件最大的到了180GB。这些文件从采集设备拷贝到办公电脑,再通过浏览器上传到数据管理平台,中间还有一系列人工整理动作,用户不太可能用FTP或者专用客户端,浏览器上传是他们唯一接受的操作方式。
客户端环境的复杂度也超出预期。明明已经是2024年了,工作站里依然是Windows 7 + IE11占了一大片,另外一批新机器是Win10 + Chrome/Edge。网络条件看着是内网千兆,但经过多层交换设备和安全网关之后,实际有效带宽只有几十Mbps到几百Mbps,而且并不稳定,断流是常态。
这个场景下,用户的核心诉求只有三个:第一,能传超大的文件;第二,传一半断了能接着传,而不是从头再来;第三,不管用什么浏览器,操作方式一致。这三个诉求,恰好全部踩在原生WebUploader的能力边界之外。
1.2 原生WebUploader的真实能力边界
WebUploader在中小文件上传场景里确实好用,但它的定位决定了它没有为超大文件做专门优化。我翻了源码,几个关键限制非常明确:
fileSizeLimit默认值是2GB,超过直接拦截,必须在初始化配置里改成足够大的值;- 默认分片大小
chunkSize是5MB,对于GB级文件会产生海量分片请求,服务端根本扛不住; - 它所谓的“分片上传”只解决了“把大文件切成小块传”的问题,没有提供断点续传能力,页面一刷新,之前传的分片全部作废;
- 没有秒传概念,同一个文件重复上传,依然全量重新传一遍;
- 对超大文件的并发控制和内存管理没有专门优化,线程数开高了容易把浏览器搞崩。
我举个具体的数:一个50GB的文件,用默认的5MB分片,会产生10240个分片请求。服务端每收到一个分片就要打开文件、写入数据、关闭文件,一万多个分片排队合并,光文件句柄的开销就能把服务拖垮。
所以结论很直接:原生WebUploader只能当一个基础框架用,真正解决超大附件上传,必须在它之上做大量改造。
2. 改造前的方案设计:分片策略、并发参数和服务端协议怎么定
2.1 分片大小不是拍脑袋定的,是一步步算出来的
分片大小是这次改造里最重要的一个参数。分片太小,请求数量爆炸;分片太大,一个分片传半天,断了重传的成本也高。我根据内网环境的实测数据推算:单连接的有效传输速度大概在7.5MB/s到30MB/s之间,取一个中等偏保守的值10MB/s来算。
结合“单个分片在网络上的传输时间控制在5到10秒”这个目标,分片大小取50MB比较合适。10MB/s的单连接速度下,一个50MB分片需要5秒左右,配合4个并发,整体吞吐可以做到接近30MB/s。50GB的文件切成1000个分片,服务端合并时的排序开销完全可控。
同时要注意一个细节:Blob.slice切割出来的分片大小并不总是精确等于设定的chunkSize,最后一个分片通常会小于设定值,处理时不能写死长度,必须用实际的分片长度去计算和处理。
2.2 并发数受制于浏览器连接池
并发数不建议无脑调高。HTTP/1.1协议下,浏览器对同一域名的并发连接数限制一般是6个,其中还包括页面本身加载资源的连接。如果并发开8个,页面其他请求就只能排队等待,上传反而变慢,还会出现资源加载超时的现象。
我最后把threads配置成4,再加上页面本身的请求,正好能卡在6个连接的临界值以内。如果是HTTP/2协议环境,可以放宽到8,但为了兼顾老系统,保持4最稳妥。
2.3 服务端协议重新设计:一个接口拆成四个
原生WebUploader只接收一个server上传地址,断点续传做不到。改造的第一步,就是把上传链路拆成四个接口:
| 接口 | 方法 | 作用 | 关键参数 |
|---|---|---|---|
| /api/upload/init | POST | 初始化上传,建立上传任务 | fileName, fileSize, fileMd5 |
| /api/upload/status | GET | 查询已上传的分片列表 | fileMd5 |
| /api/upload/chunk | POST | 上传单个分片 | uploadId, chunkIndex, chunkMd5, file |
| /api/upload/merge | POST | 触发分片合并与整体校验 | uploadId, totalChunks |
这个协议设计的关键是幂等性。同一个chunkIndex允许重复提交,服务端通过chunkMd5判断这个分片是否已经存在,存在就直接返回成功,不重复写盘。有了幂等性,前端在断网重试时就可以放心大胆地重发分片,不用担心产生脏数据。
另一个关键点:文件指纹fileMd5必须在初始化时传过去,服务端用它来关联同一个文件的多次上传尝试。后面接续上传时,前端拿到同样的fileMd5去查status接口,就能知道哪些分片已经传过了。
2.4 为什么还是选WebUploader,而不是重写一个上传组件
方案评估的时候,我也认真考虑过Resumable.js、Plupload,以及完全基于XMLHttpRequest自己写一套,但最后仍然选了WebUploader作为底座。
最大的原因是WebUploader把HTML5和Flash两套运行时统一成了一组API。这个能力在当前的项目场景里是稀缺的,因为还要兼容老IE浏览器。Resumable.js只支持HTML5,老浏览器直接没法用;Plupload虽然支持Flash,但架构偏底层,分片续传还是得自己写一堆逻辑。与其从零开始踩一遍浏览器兼容的坑,不如把WebUploader当作一个经过验证的运行时抽象层,在其上做业务增强。
封装方式上,我没有把改造逻辑散落在页面代码里,而是封装成一个独立的SatVideoUploader类,对外暴露init、upload、cancel、retry、getProgress几个方法,内部管理WebUploader实例。这样页面侧调用很简单,将来换底座或者扩展新能力也容易。
3. 断点续传与秒传的实现链路:指纹、跳过逻辑和服务端状态查询
3.1 文件指纹计算:全量还是抽样,这是个安全取舍
断点续传的前提是能唯一标识一个文件。文件名不靠谱,同一个文件在多次上传时可能被重命名,或者不同用户手里的文件名字完全不一样。我用的是MD5指纹。
计算方式上有一个安全取舍。全量MD5非常可靠,但50GB的文件逐字节读完再计算,即使放进Web Worker后台处理,耗时也可能达到5到10分钟;抽样MD5只取头、中、尾几个分片的数据计算,几秒钟就能出结果,但理论上存在碰撞风险。
军工场景对这种完整性校验要求很高,我最终采用了一个折中方案:前端全量计算MD5作为主指纹,同时在服务端合并完成后对整文件计算SM3摘要做最终比对。SM3是国家密码算法,合规性更好,但前端JS计算结果太慢,所以让服务端来做这一步,前端用MD5只是用来做断点续传的关联查询。
计算过程放在Web Worker里,避免阻塞浏览器主线程。代码大概长这样:
// md5-worker.js importScripts('/static/js/spark-md5.min.js'); self.onmessage = function(e) { var file = e.data.file; var chunkSize = 10 * 1024 * 1024; var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; var chunks = Math.ceil(file.size / chunkSize); var spark = new SparkMD5.ArrayBuffer(); var currentChunk = 0; var reader = new FileReader(); reader.onload = function(event) { spark.append(event.target.result); currentChunk++; if (currentChunk < chunks) { loadNext(); } else { self.postMessage({ md5: spark.end() }); } }; function loadNext() { var start = currentChunk * chunkSize; var end = Math.min(start + chunkSize, file.size); reader.readAsArrayBuffer(blobSlice.call(file, start, end)); } loadNext(); };主线程的调用逻辑需要注意一个点:在Web Worker里不能直接传File对象给postMessage,某些浏览器会报错。我的做法是把File对象传过去,因为结构化的克隆算法实际上是支持File的,但在老版本浏览器上表现不一致,稳妥起见我在主线程先把文件的size和name取出来传给Worker,然后在Worker里通过file对象操作。实测下来,这个方案在Chrome和Firefox里都能正常工作。
3.2 查询已完成分片并跳过:续传的核心逻辑
拿到MD5之后,前端调用/api/upload/status接口,服务端返回该文件已完成的分片索引数组。前端要做的事情很明确:把已完成的分片状态标记为done,这样WebUploader在遍历分片时就会自动跳过它们。
这里有一个流程控制上的关键细节。MD5计算是异步的,耗时可能几分钟,而WebUploader默认会在文件进入队列后自动开始上传。如果不做控制,会出现“分片都传了一半了MD5还没算完”的竞态。我的解决办法是初始化时设置auto: false,等待MD5计算完成、状态查询返回之后,再手动调用uploader.upload()。
跳过逻辑的代码实现:
uploader.on('fileQueued', function(file) { // 暂停队列,等待指纹计算完成 uploader.stop(true); computeFileMd5(file).then(function(md5) { file.__md5 = md5; return fetch('/api/upload/status?fileMd5=' + md5); }).then(function(res) { return res.json(); }).then(function(data) { var doneChunks = data.doneChunks || []; doneChunks.forEach(function(index) { var chunk = file._chunks[index]; if (chunk) { chunk.state = 'done'; file.uploadedChunks++; } }); // 全部传完就直接触发合并 if (file.uploadedChunks >= file._chunks.length) { triggerMerge(file); return; } uploader.upload(file); }); });file._chunks是WebUploader内部的分片数组,每个分片对象有一个state属性,取值为pending、uploading、done、error。把已上传的分片状态直接改成done,是最简单可靠的跳过方式,不需要去动WebUploader的上传主循环,后续版本升级也相对不敏感。
3.3 秒传的实现:所有的分片都在,就不传了
秒传其实是断点续传的一个特殊情形:当status接口返回的已完成分片数等于总分片数时,说明这个文件之前已经完整传过了,前端不需要再传任何一个分片,直接调用merge接口就行。
这里要注意的是,服务端必须对这种情况做防御:即使前端错误地把所有分片都重传一遍,服务端根据chunkMd5去重,也不会产生重复数据。最终合并时以文件指纹为粒度加锁,同一时刻只允许一个合并任务操作同一个文件,避免并发覆盖。
3.4 进度计算:让用户看到真实的百分比
进度条是用户感知断点续传价值最直观的东西。WebUploader的uploadProgress事件只返回当前文件的上传进度,但这个进度没有考虑“已经跳过的分片”。如果不做处理,用户会看到进度条从0开始,到了某个位置突然跳到100%,体验非常割裂。
我的处理是重写进度计算逻辑:
uploader.on('uploadProgress', function(file, percentage) { var skipCount = file._chunks.filter(function(c) { return c.state === 'done'; }).length; var totalChunks = file._chunks.length; var realPercentage = skipCount / totalChunks + (percentage * (totalChunks - skipCount) / totalChunks); updateProgressBar(realPercentage); });合并阶段也要单独处理。50GB文件的合并可能需要几分钟,前端在调用merge接口后,改用轮询方式查询合并任务状态,界面显示“服务端正在合并文件”,并提供已合并的分片数或百分比,避免用户误以为卡死了。
4. 跨浏览器兼容层:HTML5运行时和Flash回退的细节屏蔽
4.1 WebUploader的运行时切换机制
WebUploader内部通过Runtime机制屏蔽浏览器差异。它会先检测浏览器能力,如果支持FileReader、Blob.slice、FormData这些HTML5 API,就启用HTML5运行时;否则看有没有Flash插件,有就回退到Flash运行时;两者都没有,直接提示不支持。
这个机制本身很成熟,但在超大文件场景下,两个运行时表现出来的能力差异非常大。如果只是当成一个黑盒来用,会踩到很多隐蔽的坑。
| 差异点 | HTML5运行时 | Flash运行时 |
|---|---|---|
| 并发上传 | 支持多线程并发 | 单线程串行 |
| 超大文件支持 | 支持64位大小,200GB没问题 | 对4GB以上文件读取不稳定 |
| 跨域请求 | 支持CORS | 不支持,跨域直接失败 |
| 分片读取 | 原生Blob.slice,稳定 | 由Flash模拟实现,性能差 |
| 文件信息获取 | 完整,包含size和type | 部分浏览器只给size,type为空 |
4.2 兼容层要处理的具体差异点
Flash运行时最烦人的问题是并发。如果初始化配置里写死threads: 4,在Flash模式下WebUploader会自动把并发降到1,这个不需要额外处理,但你要有心理预期:老浏览器上的上传速度会非常慢,用户需要耐心等。
跨域问题更棘手。Flash上传有跨域限制,如果前端页面和上传接口不在同一个域,在HTML5模式下通过CORS配置能解决,但在Flash模式下请求根本发不出去。客户端环境里页面系统和文件存储服务有可能做了域隔离,我的处理方法是前置一个Nginx反向代理,把/api/upload/*代理到实际的上传服务,让浏览器始终请求同源的地址。
还有一个隐蔽的兼容点:老IE环境里,文件的name属性可能不包含扩展名。比如用户选了一个video_20240101.ts,但file.name返回的是video_20240101,没有.ts后缀。处理办法是在beforeFileQueued事件里,根据文件的type字段(视频格式通常能识别出来)补全扩展名,或者干脆不依赖前端文件名,由服务端根据上传时的originalName参数做处理。
4.3 Flash模式在超大文件上的使用边界
这里必须说一个底层的技术事实:Flash运行时在读取大于4GB的文件时,经常出现读取失败或读取到错误数据的情况,这是Flash播放器自身的限制,不是WebUploader能解决的。
所以我在插件里加了一个判断逻辑:单文件大于4GB,且当前运行在Flash模式下时,弹窗提示用户此文件建议使用Chrome或Edge浏览器上传,并提供普通上传之外的备选方案。这不是推卸责任,而是避免用户花一两个小时传到一半,Flash读取崩溃导致前功尽弃。产品层面接受这个降级策略。
4.4 中文文件名和编码问题的处理
中文文件名是另一个高频坑。老浏览器在提交文件时,中文文件名用本地编码传输,服务端如果不统一编码格式,会出现乱码,严重的直接导致分片关联失败。
我的处理是在前端把文件名做一次URL编码,放进formData一起提交,服务端拿到后再做URL解码:
uploader.on('uploadBeforeSend', function(block, data) { data.originalName = encodeURIComponent(block.file.name); data.uploadId = uploadId; data.chunkIndex = block.chunk; data.chunkMd5 = block.file.__chunkMd5s[block.chunk]; });服务端在merge阶段用uploadId关联之前存储的元信息,而不是直接用文件名作为拼接依据。这样即使文件名里有特殊字符,也不会影响分片合并。
5. 军工场景的可靠性与合规加固:校验、加密和审计日志
5.1 分片级校验与整文件校验的双层保障
超大文件传输最怕一件事:文件传完了,但数据在传输过程中发生了静默损坏。网络层的TCP校验能挡掉一部分,但应用层的数据完整性校验必须有。
我在设计里做了两层校验。第一层是分片级:每个分片在上传前,前端用SparkMD5计算这个分片的MD5值,随请求一起提交;服务端收到分片后先算一次MD5,不一致就直接拒绝,并返回错误码让前端重传这个分片。第二层是整文件级:所有分片合并完成后,服务端对完整文件计算SM3摘要,和前端计算的文件MD5做交叉校验,不一致就把文件移到隔离目录,禁止进入正式存储。
双层校验的代价是额外的CPU和IO开销,但在军工行业的合规审计要求面前,这个代价是必须付出的。
5.2 传输链路与临时数据的安全隔离
传输链路上,前端到服务端之间通过HTTPS加密,内部网络再经过加密网关。分片上传的临时文件存放在专门的隔离目录,操作系统权限只开放给上传服务本身,业务系统其他模块无权访问。合并校验完成后,文件才被移动到正式的存储区域。
前端本地不持久化任何文件内容,只保存分片索引和文件指纹。localStorage里存的也是脱敏后的信息,不包含文件实际数据。这样即使浏览器被其他网站拿到,也无法通过本地残留数据还原已经上传的文件内容。
5.3 审计日志与重试策略
军工项目的运维审计要求每个关键动作都可追溯。我在上传链路的每个关键节点埋了点:
- 文件入队时间、操作用户、文件大小;
- 指纹计算完成时间、MD5值;
- 每个分片的上传开始、成功、失败时间线;
- 合并任务的开始、结束、结果、耗时;
- 失败重试的记录和最终状态。
这些日志统一结构化输出,方便后续接入审计平台。
重试策略上,分片上传失败后做指数退避重试:第一次间隔2秒,第二次间隔4秒,最多重试5次。WebUploader自带的重试机制比较简单,默认失败就直接标记错误,我通过监听uploadError事件接管了重试逻辑,重试耗尽后再弹出明确的错误提示。
6. 实测中的坑:并发调优、Flash短板的真实数据
6.1 并发线程数不是越大越好
我在测试环境做了一组并发对比实验,测试条件是:服务端百兆内网、客户端千兆接入、50GB测试文件、50MB分片、HTML5运行时。
| threads | 平均上传速度 | 浏览器内存占用 | 稳定性 |
|---|---|---|---|
| 3 | 22MB/s | 450MB | 稳定 |
| 4 | 28MB/s | 520MB | 稳定 |
| 6 | 30MB/s | 680MB | 偶发卡顿 |
| 8 | 28MB/s | 850MB | 内存告警,页面响应迟缓 |
结论很明显:线程数从4调到8,速度几乎没有提升,内存却涨了60%以上,因为每个分片读取时都会在内存里保留一个ArrayBuffer副本。4并发是这个场景下的甜点位。
6.2 服务端合并接口的耗时问题
第一次联调时,50GB文件的合并操作直接在HTTP请求里同步执行,结果前端等了120秒后连接超时,服务端合并任务被中断,下一次调用时又从头开始合并。
这个问题的解法是异步化:merge接口收到请求后,立即返回一个taskId,合并过程在后台单独跑,前端通过轮询/api/upload/merge/status?taskId=xx获取合并进度。合并完成后再通知前端跳转下一步。这样即使轮询断掉,也不影响后台合并任务继续执行。
6.3 localStorage容量告警和索引丢失问题
断点续传的已上传分片索引,我最开始放在localStorage里。后来发现两个问题:第一,存储空间有5MB的上限,分片索引数组几百个就占一半空间了;第二,多个文件同时上传时,localStorage的key是全局共享的,不同文件之间会互相覆盖。
解决方案是:localStorage的key用upload_progress_{fileMd5}的格式,避免冲突;考虑到5MB上限,只存储小于200字节的摘要信息,分片索引用压缩后的二进制字符串存储。如果文件分片数实在太多,超过存储容量的临界值,就降级为“刷新后从最后一个分片继续传”,虽然做不到精确跳过,但总比完全从零开始强。
6.4 匹配文件类型与分片重传的边界条件
最后补一个容易被忽略的边界:用户在上传过程中手动刷新了页面,重新选择同一个文件后,fileMd5没有变,status接口正常跳过了已完成分片。但如果用户选的是另一个同名文件、大小也相同、只是内容变了,这时两个文件的MD5不一样,前端会当成一个新文件上传,服务端需要根据fileMd5和fileSize的组合来区分,初始化时把这两个参数都传过去。
整个项目做完,我的体会是:WebUploader只是一个起点,真正做好大文件断点续传,功夫在前端之外。分片策略、服务端协议、兼容降级、校验审计这些设计好了,插件才能在复杂的真实环境里站得住。最后再分享一个小细节:不要只盯着“上传完成”这个结果,把每个文件的上传耗时、重传次数、断点恢复次数都记录成指标,后期调优全靠这些数据说话。