ARTICLE DETAIL

资讯详情

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

基于WebUploader的卫星视频超大文件分片断点续传插件改造实践

基于WebUploader的卫星视频超大文件分片断点续传插件改造实践 做军工行业项目的时候我是真被卫星视频上传这事折磨过。外场设备录好的卫星视频动辄几十个GB编码格式五花八门H.265、MPEG-TS、甚至一些设备直接输出MXF这种专业封装想从工控机回传到后方机房结果一看内网环境简直是一锅大杂烩有老掉牙的IE有各种国产双核浏览器有麒麟系统下的定制浏览器版本跨度十几年。普通上传组件一传就断断完又从头传传到第二天一看进度条还在原地踏步。后来我下定决心用JS对WebUploader做了一次系统性改造把它做成了一套跑在浏览器里的超大附件分片断点续传插件。这套方案解决了三个核心痛点超大文件怎么切、断了怎么续、不同浏览器怎么兼容。今天就把这次改造的完整思路、核心代码和踩坑记录整理出来给正在被大文件上传折磨的同行一个能直接参考的方案。1. 需求拆解与改造思路为什么非要用WebUploader做底座1.1 卫星视频上传场景的“三个魔鬼细节”第一个细节是文件超大。卫星视频不是普通短视频分辨率高、帧率高、编码复杂加上原始素材往往不允许做有损压缩必须原样回传所以单文件动不动就是几十GB。我见过最大的一个片段一小时的原始记录数据解封装出来有60多GB。这个体量传统表单上传根本扛不住浏览器处理大文件时连内存都保不住更别提服务端接收时的压力。第二个细节是跨浏览器。这个领域的内网环境跟互联网完全是两码事终端浏览器从来不是开发者能决定的。有的岗位还在用IE8有的单位要求全替换成国产化终端跑着基于Chromium内核的定制浏览器还有一部分老旧设备走的是IE内核兼容模式。Flash早就被安全策略禁掉了HTML5在不同内核上的实现又有细微差别。这意味着插件从设计第一天起就不能假设“用户会用Chrome”。第三个细节是断点续传。外场和后方之间的链路质量差带宽波动大传着传着断网是家常便饭。做过大文件传输的人都知道传了80%断掉比一开始就断了还让人崩溃。而且卫星视频数据完整性要求极高任何一个分片丢失或者改动都可能导致整个文件无法解码所以不能只靠“传个大概”必须做到分片级别的校验和续传。1.2 WebUploader的定位与改造边界既然需求这么硬我一开始也想过从零写一个上传组件。但评估完工作量就放弃了分片算法、并发控制、队列管理、进度回调、错误重试这些基础工程量大且容易出bug完全没有必要重复造轮子。WebUploader虽然是百度开源的老项目但它有一个非常突出的优势它把底层能力抽成了Runtime对外暴露了一整套事件钩子机制。分片上传、MD5计算、并发控制这些基础能力全是现成的而且支持HTML5和Flash双运行时切换。我做的工作不是重写而是做二次开发把改造重点集中在三块断点续传的业务逻辑、跨浏览器降级策略和数据完整性校验。说得直白一点WebUploader负责“文件怎么切、怎么传”扩展代码负责“哪些分片不用传、传完怎么校验、出了问题怎么补救”。这个边界能让整个插件保持很强的可控性也让我能在不影响核心上传流程的前提下快速迭代。2. 核心细节解析分片策略、MD5指纹与断点续传的“三重校验”2.1 分片大小怎么定分片大小是整个插件最基础也最关键的一个参数。它不是拍脑袋定的需要综合评估带宽、服务端处理能力、单请求超时时间和浏览器内存占用几个因素。我先说结论常规场景建议设成5MB弱网环境降到2MB。为什么是这个数假设链路带宽是20Mbps5MB的分片大约需要2秒传完这个时长对HTTP请求来说非常友好既不容易触发服务端网关超时单分片失败重传的成本也可控。如果分片设得太小比如1MB一个10GB的文件会拆成超过一万个分片请求数量剧增服务端日志、连接切换、落盘操作的IO开销会被瞬间放大。如果分片设得太大比如50MB网络稍有抖动一个分片传很久然后失败重传成本又太高断点续传的粒度就失去意义了。代码里可以这样动态设置// 文件超过2GB走5MB分片否则用2MB分片 var chunkSize file.size 2 * 1024 * 1024 * 1024 ? 5 * 1024 * 1024 : 2 * 1024 * 1024;这个逻辑虽然不复杂但背后有一个容易被忽略的点分片读取并不是一次性把整个文件读进内存的。Blob.slice切出来的是文件数据的一个引用范围我基于这个特性做增量读取再把数据喂给加密或者校验逻辑。很多人在大文件上传时把页面搞崩就是因为在MD5计算阶段一次性读取了整块大文件内存直接爆掉。2.2 文件指纹与“秒传”背后的逻辑断点续传不能只靠文件名加文件大小判断这是我在实际项目中踩过坑之后才彻底想明白的。一开始我觉得同一个文件名字一样大小一样肯定就是同一个呗。但卫星视频这场景太特殊了同一个文件名、同样大小的文件内容可能已经被运维人员手动修复过关键帧也可能在拷贝过程中产生了坏块如果不做内容级校验服务端就可能把错的文件当成已上传导致后面合并出来的视频根本打不开。所以必须用内容指纹。我用的是spark-md5这个库它支持增量计算可以把文件按分片一块一块喂进去最后生成一个全文件的哈希值。这个哈希值有三个用途唯一关联上传记录、实现“秒传”、作为最终完整性校验的基准。所谓“秒传”就是前端先把整个文件的MD5算出来发到服务端查一下这个文件是不是已经完整存在。如果存在前端直接跳过整个上传流程把当前任务标记为成功几秒钟内完成“上传”。这个功能在卫星视频场景里特别实用因为外场经常要重复回传同一段测试片段有了秒传后面再传同源数据几乎是零等待。2.3 断点续传的“三层校验”设计断点续传听起来简单无非就是跳过已传的分片但我在路由设计上做了三层校验每一层对应不同的容错目标。第一层是全文件MD5校验。上传开始之前先拿全文件哈希去服务端确认如果文件已经完整存在直接秒传如果存在上传中的任务就返回已收到的分片列表前端把列表缓存下来用来做跳过判断。第二层是分片级MD5校验。每个分片在发送前也会计算一个小MD5随着表单一起提交到服务端服务端接收完成后校验分片数据是否完整确认无误才落盘。这样做能捕捉到传输过程中比较隐蔽的数据损坏比如网络设备导致的数据包重写、磁盘坏道引起的写入偏差。第三层是分片序号加总大小校验。服务端合并文件时必须按分片序号严格排序合并完成后比对总大小和全文件MD5任何一个分片缺失或内容不对立即返回明确错误码前端收到后自动重传缺失分片。这三层校验组合起来形成一个比较完整的闭环前端判断、服务端分片落盘、合并前整体校验每一层都在前面一层的基础上增加了一道保险。3. 实操过程WebUploader改造的关键代码与配置3.1 初始化配置chunked、chunkSize、threads这些参数怎么调改造的第一步是正确初始化WebUploader。我直接贴一份生产环境可用的配置然后逐个参数讲为什么这么设var uploader new WebUploader.Uploader({ swf: vendor/webuploader/Uploader.swf, server: /api/upload/chunk, pick: #picker, accept: { title: 卫星视频文件, extensions: mp4,mov,avi,ts,mxf,m2ts }, threads: 3, chunked: true, chunkSize: 5 * 1024 * 1024, fileVal: file, compress: false, duplicate: true, runtimeOrder: html5,flash });threads参数是并发上传数我建议设在3到5之间。并发数太小带宽利用率上不去并发数太大比如10个并发同时传10个分片服务端线程池和磁盘IO会被瞬间打满而且弱网环境下大量超时重试反而会降低整体速度。实测下来3个并发配合5MB分片在常见内网带宽下表现最稳。chunked必须设为true这是分片上传的总开关。chunkSize对应我们前面分析的分片大小。compress必须设为false否则WebUploader会对图片或视频做前端压缩处理对原始卫星视频来说这是不可接受的破坏操作。duplicate设为true是为了允许选择同名文件因为有些场景确实会用相同文件名传不同内容不能一竿子打死。runtimeOrder表示运行时优先级。正常环境下永远走html5老浏览器自动降级到flash。这个数组顺序不能反因为HTML5运行时对文件API的原生支持是现在和未来的主流。3.2 文件指纹计算实现用spark-md5计算全文件MD5的代码网上有很多版本但不少是一把梭把整个文件读完再算对大文件完全不可用。我这里的版本是增量式的var SparkMD5 require(spark-md5); var blobSlice File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; function computeFileMD5(file) { var chunkSize 5 * 1024 * 1024; var chunks Math.ceil(file.size / chunkSize); var currentChunk 0; var spark new SparkMD5.ArrayBuffer(); var reader new FileReader(); return new Promise(function (resolve, reject) { function loadNext() { var start currentChunk * chunkSize; var end start chunkSize file.size ? file.size : start chunkSize; reader.readAsArrayBuffer(blobSlice.call(file, start, end)); } reader.onload function (e) { spark.append(e.target.result); currentChunk; if (currentChunk chunks) { loadNext(); } else { resolve(spark.end()); } }; reader.onerror function (e) { reject(e); }; loadNext(); }); }这段代码的核心思路是每次只把5MB数据读进内存算完立即释放交给下一段内存占用稳定在很低的水位。一个1GB的文件用增量式计算在普通工控机上大约需要几秒到十几秒不等取决于CPU性能但页面全程流畅不会出现假死。有一个使用技巧MD5计算过程中要给用户展示进度不要干等。可以把currentChunk除以chunks作为进度回传给上传界面这样用户在等待时知道系统在干活而不是以为卡死了。3.3 断点续传插件开发before-send-file加before-send加skip回传WebUploader的断点续传核心逻辑其实是围绕两个事件钩子展开的before-send-file和before-send。前者在准备上传某个文件的第一个分片之前触发后者在发送每一个分片之前触发。利用这两个钩子我把断点续传逻辑这样实现var fileHash ; uploader.on(before-send-file, function (file) { var deferred WebUploader.Deferred(); computeFileMD5(file.source).then(function (hash) { fileHash hash; file.md5 hash; $.ajax({ url: /api/upload/status, data: { md5: hash }, method: GET, dataType: json }).then(function (res) { if (res.uploaded) { // 全文件已经传过直接秒传 file.uploaded true; deferred.resolve(); } else { // 保存服务端返回的已传分片列表 file.uploadedChunks res.uploadedChunks || []; deferred.resolve(); } }).fail(function () { // 查询失败时不应该阻断上传让分片正常重传 file.uploadedChunks []; deferred.resolve(); }); }); return deferred.promise(); }); uploader.on(before-send, function (block) { var file block.file; if (file.uploaded) { return; // 秒传场景直接放行 } var deferred WebUploader.Deferred(); // 如果服务端返回的已传分片列表里包含当前分片序号则跳过 if (file.uploadedChunks file.uploadedChunks.indexOf(block.chunk) 0) { // 这里返回skip字符串是WebUploader约定的跳过分片信号 deferred.reject(skip); return deferred.promise(); } // 正常上传分片动态携带分片元信息 uploader.options.formData { md5: fileHash, chunk: block.chunk, chunks: block.chunks, chunkSize: block.end - block.start }; deferred.resolve(); return deferred.promise(); });这里有个非常关键的小细节deferred.reject(skip)注意返回的参数是字符串skip而不是简单的reject。WebUploader的内部实现会对这个特定的返回值做判断遇到skip就会跳过当前分片的上传直接进入下一个分片。很多人在改造时在这里踩坑参数写错或者类型不对导致跳过分片逻辑完全不生效。服务端的设计也要配套。我总共搭建了三个接口状态查询接口GET /api/upload/status接收分片接口POST /api/upload/chunk触发合并接口POST /api/upload/merge。其中状态查询接口返回的内容结构大致是这样的{ uploaded: false, uploadedChunks: [0, 1, 2, 5, 6] }这个返回说明文件没有完整上传过但分片0、1、2、5、6已经收到过。前端拿到这个列表后下一次续传就只传缺失的分片3、4以及后面的分片。3.4 服务端分片落盘和合并的注意事项服务端虽然是后话但合并逻辑的质量直接决定前端插件好不好用。这里我强调三个点。第一接收分片时必须以md5作为目录维度、以chunk序号作为文件名把分片物理隔离存储。目录结构类似/tmp/upload/{md5}/{chunkIndex}。这样多个分片无论按什么顺序到达最终合并时都能按序号直接读取天然支持乱序到达。第二合并时不要把所有分片一次性读入内存再拼接。用一个512KB的缓冲流从分片0开始按顺序循环读取写入到目标文件避免大文件合并时服务端内存OOM。合并完成后删除临时分片目录和分片索引记录。第三合并完成后计算整个目标文件的MD5和前端传过来的全文件MD5做比对。只要不一致就要返回明确的错误码前端收到这个错误码后自动开启全量重传或按缺失分片重传。这一步是质量保证的最后一道关卡绝对不能省。4. 跨浏览器兼容的设计与实现4.1 runtimeOrder与Flash/HTML5降级策略WebUploader在跨浏览器上最核心的设计就是Runtime抽象。同一个上传逻辑HTML5运行时底层用XMLHttpRequest加FormData加BlobFlash运行时底层用Flash上传组件对外暴露的API完全一致。所以只要初始化配置里runtimeOrder写清楚了组件本身就已经具备多浏览器适配的基础。但在军工内网这个环境里Flash的兼容性有很现实的问题大量安全策略明确禁用Flash而且新版浏览器已经完全移除Flash支持。所以我把降级策略分了三档第一档是标准HTML5环境走WebUploader原生的html5运行时第二档是老的IE内核环境能加载Flash就加载Flash兜底第三档是两者都不支持的环境页面给出明确提示引导用户换用其他终端而不是默默地在页面上加载一个根本用不了的控件。这里有一个小的实现细节初始化的时候我加了运行时探测逻辑通过特性检测判断当前浏览器支持哪些能力如果只支持Flash运行时且失败就主动在界面上提示“当前环境不支持断点续传上传请使用Chrome、Edge或安装补丁后的国产化浏览器”。这类提示对用户非常友好能省掉大量远程排查的时间。4.2 国产化浏览器与内核适配军工和政府行业现在的终端环境越来越统一越来越多的单位换成了国产化操作系统加国产浏览器。实际跑下来麒麟系统配奇安信浏览器、360安全浏览器这些绝大多数版本底层都是Chromium内核HTML5支持度其实不错WebUploader的html5运行时可以直接跑。真正的坑在于一些早期版本的国产浏览器内核比较旧对File API的支持存在兼容问题或者某些双核浏览器在兼容模式下默认走IE内核导致分片上传异常。我的处理原则是不看UA只看能力。在页面加载后用几行代码做特性检测var isSupportHtml5Upload ( window.File window.Blob window.FileReader window.FormData !!(Blob.prototype.slice || Blob.prototype.mozSlice || Blob.prototype.webkitSlice) );如果isSupportHtml5Upload为false就不初始化WebUploader直接显示兼容性提示。这个方案比UA判断可靠得多因为国产浏览器型号太多UA写什么的都有但能力是真实存在的。4.3 FileReader、Blob.slice与超大文件的兼容性处理跨浏览器改造中最坑的细节之一就是Blob.slice的来源兼容。早期WebKit内核里Blob.slice是带浏览器前缀的需要写成mozSlice或webkitSlice。我在MD5计算代码里专门写了这样一段兼容逻辑var blobSlice File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice;这样就覆盖了从老IE内核到新Chromium的大部分情况。超大文件还有一个隐形门槛老内核的浏览器对FormData上传的大小是有限制的有的版本在提交超过2GB的文件时会出现参数丢失甚至文件损坏。幸好我们的架构走了分片每次只提交5MB从根上绕开了这个限制。这也是分片方案除了断点续传之外带来的额外好处。我建议在项目里保留一个特殊能力检测判断当前浏览器是否支持2GB以上文件的大小读取和分片切片操作。如果浏览器对File对象的size属性都返回不了正确数值那说明这个终端确实太老了再怎么兼容也没有意义直接提示换终端。5. 常见问题与排查技巧实录5.1 高频问题速查表做这个插件前前后后也调了小半年各种奇怪的问题都遇到过。我把最高频的几个整理成了一张速查表方便大家直接对着排查。现象可能原因排查思路解决办法文件传完后无法打开或画面黑帧合并顺序错乱分片内容损坏检查服务端合并日志确认是否严格按chunk序号排序随机抽查分片MD5合并前校验每片MD5合并后比对全文件MD5断点续传时老是跳过不该跳过的分片状态查询接口返回了错误的分片列表抓前端请求查看before-send查询的分片参数和服务端返回状态接口按md5精确查询前端缓存后及时清理MD5计算时页面卡死一次性读取了大块文件内存被打满打开浏览器任务管理器观察内存占用改用增量式MD5计算每次只读5MB网络中断后重试又从零开始断点续传逻辑没生效抓包看重新上传的请求是否带chunk参数是否先调用了status接口检查before-send钩子是否正确地return了deferred.promise新版浏览器上报Flash相关错误Flash已停止支持但runtimeOrder仍优先加载flash查看运行时加载日志和console报错将runtimeOrder改为html5优先彻底屏蔽flash中文文件名传到服务端乱码前端与后端编码不一致抓包看Content-Disposition头里的文件名编码前后端统一UTF-8不要手动encodeURI后再解码并发上传时服务端文件顺序错乱并发数过高分片不是按序号顺序到达查看服务端接收日志确认接收顺序服务端落盘按chunk序号命名合并时统一排序不依赖到达顺序5.2 排查心得日志一定要按“分片级”打排查大文件上传问题最怕的就是日志太粗。只记录“某某文件上传失败”这种级别基本等于没记。我在改造过程中给前端和后端都加了分片级日志前端在before-send、uploadSuccess和uploadError事件里输出当前文件的md5、分片序号、总分片数、请求耗时后端在接收分片和合并时对称地记录同样的字段。一旦某次上传出了问题两边日志一比对几秒钟就能定位到是哪个分片出了问题是前端没发送还是服务端没接收还是合并时写错了位置。这个习惯帮我节省了大量时间。尤其是断点续传这类涉及状态同步的功能日志只要模糊一点排查成本立刻翻倍。5.3 上线前一定要做的几个实测插件开发完成后我强烈建议多做几种破坏性测试而不是只在好用的测试环境里传一个小文件就宣布完成。第一一定要用真实的几十GB大型文件做一次完整上传验证合并后的文件用视频播放器能否正常播放能不能正确地seek到中间某个时间点。文件的完整上传不代表内容一定能解码这两个标准之间还有一段距离。第二一定要模拟断网场景。打开浏览器开发者工具的Network面板把网络切到Offline等几秒再恢复触发一次断点续传反复做几十次确认每次都能只补传缺失的分片而不是从头开始。第三一定要测试并发上传时服务端的合并结果。把threads调成5甚至8多并发上传一个多个分片的文件再用全文件MD5去和服务端合并出的文件MD5做对比确保并发不影响最终结果的正确性。第四如果项目允许还可以做一次浏览器兼容性矩阵测试把内网里常见的浏览器型号、版本都过一遍至少确认每个型号都能走到正确的运行时分支。我在实际项目里最深刻的体会是大文件上传插件最重要的不是“功能多”而是“出错后还能不能兜得住”。WebUploader本身给你提供了一个很好的底座但真正的价值是通过before-send和before-send-file这些钩子把断点续传和完整性校验这些业务逻辑做扎实。卫星视频这类数据传得慢一点没关系传得稳、传完之后确定能用才是最核心的底线。希望我这次改造的经验能给你节省一些排查的时间。
返回列表