ARTICLE DETAIL

资讯详情

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

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

基于WebUploader的卫星视频超大文件分片断点续传改造实践 先把话说在前头做超大文件上传这个需求我前后折腾过好几套方案真正让我觉得顺手且能落地到生产环境的还是百度开源的WebUploader。这玩意儿虽然官方文档停留在几年前但核心的HTML5分片机制非常稳定而且它内部那套任务队列、状态机设计拿到今天来看也不过时。我最近把它改造成了一套卫星视频大文件分片断点续传插件跑在统一浏览器环境里处理动辄几个GB的视频回传效果超出预期。这篇就完整复盘一下改造思路和具体实现给所有被大文件上传坑过的朋友们一个参考。先说结论如果你的业务场景也是“单文件几个GB起步、网络环境不稳定、用户浏览器五花八门、传输过程还要求可断可续”那直接用WebUploader做底座来二次开发比你自己从零撸一个上传组件要靠谱得多。1. 项目背后的问题超大文件为什么难传1.1 卫星视频大文件的技术特征做卫星视频回传和普通的图片上传完全是两个量级的事情。一张JPG撑死几MB常规表单POST直接搞定但卫星视频不一样动辄2GB、5GB甚至几十GB它的特征非常明显单个文件体积超大无法一次性读入内存更不能用传统multipart/form-data整体提交。视频文件头部包含关键元数据一旦传输中断整个文件废掉无法像普通文件那样“部分可用”。传输链路往往是卫星链路或者跨地域专线带宽波动大长时间占用连接容易导致超时、链路漂移。客户端环境复杂从老旧的Windows 7 IE11到新版Chrome、Edge、Firefox跨浏览器兼容是硬要求。这些特征决定了超大附件上传必须采用分片策略把大文件切成小块逐个上传并且要有断点续传能力这样任何一个分片传失败了不需要从头再来。1.2 WebUploader选型与改造决策既然要分片断点续传市面上能用的组件其实还有不少我为什么最终还是锁定了WebUploader有几点考量第一它原生内置了分片上传逻辑。chunked: true开启后WebUploader会自己把文件切成N个分片按顺序排队上传这对超大文件的并发控制非常重要。第二它的任务队列设计很成熟。WebUploader内部有Dispatcher事件机制每个文件从加入队列、分片上传、进度更新到上传完成全程都有对应的事件钩子。这些钩子恰恰是断点续传要利用的关键点位。第三它支持HTML5与Flash双轨降级。虽然Flash现在已退出历史舞台但WebUploader的HTML5部分足够稳定而且其架构中“运行时”的抽象让我可以很方便地在原有基础上做现代浏览器的能力增强。不过WebUploader官方版本停在1.x之后就基本不更新了直接拿原生版本跑生产环境是不行的必须做二次改造。我的改造目标也很明确在原有分片上传的基础上增加服务端分片状态查询能力实现断点续传同时解决跨浏览器兼容问题。2. 插件整体改造方案设计2.1 分片、并发与续传的基础模型先提一个生活化的类比。普通上传像是一个人抱着整头牛过独木桥桥窄了过不去走到一半掉下去就得从头再来而分片上传是先把牛切成几十块牛肉一块一块搬过去哪块掉了就补哪块。断点续传则是让对面的人告诉你“我这边已经有第1到第19块了你从第20块开始搬。”具体到技术实现核心模型就是三件事前端用File.slice()把大文件切成固定大小分片。每个分片以独立请求上传到服务端服务端落盘保存为临时分片文件。上传中断后前端通过文件唯一标识查询服务端已收到的分片序号从缺失分片继续上传。这套模型的优势非常明显网络抖动只影响当前分片重试成本极低多个分片可以并发上传充分利用带宽文件再大也只是分片数量变多不会让浏览器崩溃。2.2 文件指纹与分片索引的设计实现断点续传最关键的一点是如何判定“当前这个文件”和“上次传过的那个文件”是同一个文件很多初级实现直接用文件名做标识这完全是坑。同一台机器上两个不同文件可能同名同一个文件也可能被改名上传。文件名不可靠。我采用的是文件内容指纹 文件大小双重校验// 计算文件MD5作为文件唯一标识 function computeFileMD5(file) { return new Promise((resolve, reject) { const reader new FileReader(); const chunkSize 2 * 1024 * 1024; // 2MB一个读取块 const chunks Math.ceil(file.size / chunkSize); let currentChunk 0; const spark new SparkMD5.ArrayBuffer(); reader.onerror reject; reader.onload function(e) { spark.append(e.target.result); currentChunk; if (currentChunk chunks) { loadNext(); } else { resolve(spark.end()); } }; function loadNext() { const start currentChunk * chunkSize; const end Math.min(start chunkSize, file.size); reader.readAsArrayBuffer(file.slice(start, end)); } loadNext(); }); }MD5虽然理论上存在碰撞但在业务场景里作为文件内容校验已经足够。为了降低计算时间我配合了file.size两个文件必须内容MD5一致且大小一致才认定为同一个文件。分片索引则遵循业界通用的规范从0开始编号。比如一个5GB文件分片大小设为20MB那么分片序号就是0到255。服务端保存分片时用“文件标识_分片序号”作为临时文件名简单直观。2.3 服务端接口协议设计要支撑断点续传光靠前端是不够的服务端至少要提供三个接口。我在实际项目中是这样设计的接口功能核心参数返回内容POST /upload/precheck上传前预检fileId(文件MD5), fileName, fileSize已上传分片序号数组、是否秒传POST /upload/chunk上传单个分片fileId, chunkIndex, chunk, totalChunks分片是否保存成功POST /upload/merge通知服务端合并分片fileId, fileName, totalChunks合并是否成功、文件最终路径这里重点说一下预检接口的作用。它相当于服务器在告诉你“你上次传的文件我已经收到了哪些分片你不用重复传。”这直接决定了断点续传的实现层次。在实际开发中我还会在/upload/chunk返回分片ID同时服务端记录每个分片的接收时间便于后续清理过期分片。3. WebUploader二次开发的核心实现3.1 改造初始化配置WebUploader的初始化是整个改造的地基。我把核心配置拆成了三层基础配置、分片配置、续传钩子配置。const uploader WebUploader.create({ // swf 路径兼容老浏览器实际新版本已经不再依赖 swf: /static/Uploader.swf, // 上传服务端地址 server: /api/upload/chunk, // 文件选择按钮 pick: { id: #filePicker, multiple: true }, // 分片开启 chunked: true, // 分片大小卫星视频我推荐20MB兼顾网络吞吐和重试成本 chunkSize: 20 * 1024 * 1024, // 并发上传数这里设置3避免老浏览器连接数被打满 threads: 3, // 自动上传关闭先算MD5再上传 auto: false, // 文件在队列中不立即分片 prepareNextFile: false, // 服务端返回的数据类型 accept: { title: Video, extensions: mp4,mov,mxf,avi,ts, mimeTypes: video/* } });有朋友可能会问分片大小为什么定为20MB我从实际测试中得出的结论是分片太小了请求次数爆炸服务端IO压力大而且网络RTT开销会占主导分片太大了单个分片传输失败的重试代价变高断点续传的粒度就变粗了。20MB在卫星链路和普通专线环境下都是比较均衡的选择。另外threads: 3这个参数也是经验值。老版IE对同一域名的并发连接数限制是6如果并发开太大其他业务请求会被阻塞开3既能保证分片传输速度又不会把连接池占满。3.2 上传预检与断点续传钩子WebUploader提供了几个关键的钩子断点续传核心逻辑都挂在它们上面before-send-file整个文件上传前的钩子在此处做预检。before-send每个分片发送前的钩子在此处决定跳过还是发送。upload-success分片上传成功后的回调。upload-complete所有分片完毕后的回调。先看before-send-file的实现uploader.on(before-send-file, function(file) { // 第一步计算文件MD5 return computeFileMD5(file.source).then(function(md5) { file.fileId md5; file.uploading true; // 第二步调用预检接口查询服务端已有的分片 return $.ajax({ url: /api/upload/precheck, type: POST, data: { fileId: md5, fileName: file.name, fileSize: file.size } }).then(function(res) { if (res.data.skipUpload) { // 服务端已经存在完整文件直接秒传 file.skip true; return false; } // 记录服务端已有的分片序号 file.existedChunks res.data.existedChunks || []; return true; }); }); });这里有个关键设计返回值是false时WebUploader会认为这个文件不需要上传了。秒传场景正是利用了这个机制。当然只秒传是不够的真正的断点续传核心在before-send钩子里。uploader.on(before-send, function(file, chunk) { // 如果该分片服务端已有则跳过上传 if (file.existedChunks file.existedChunks.indexOf(chunk.index) 0) { return false; } // 动态添加分片索引等自定义参数 this.option(formData, { fileId: file.fileId, chunkIndex: chunk.index, totalChunks: Math.ceil(file.size / this.options.chunkSize), fileSize: file.size, fileName: file.name }); return true; });chunk.index是WebUploader内部给每个分片分配的编号从0开始。当before-send返回false时这个分片不会发起请求而是直接视为成功。这样断点续传的“跳过已有分片”机制就生效了。从交互上看上传中断后用户重新选择同一个文件系统自动只补传缺失分片不需要任何额外操作。3.3 分片请求的组装与并发控制分片请求本身用的是WebUploader内置的multipart上传它会自动把File.slice()出来的Blob对象包装成FormData提交。但要注意WebUploader原生分片请求里的文件字段名是固定的file我在改造中建议显式控制一下uploader.option(formData, function() { return {}; }); uploader.on(uploadBeforeSend, function(object, data, headers) { // 为每个分片补充服务端所需的业务参数 if (object.file object.file.fileId) { data.fileId object.file.fileId; data.chunkIndex object.chunk; data.totalChunks object.file.totalChunks; } // 跨浏览器场景经常需要关闭缓存 headers[Cache-Control] no-cache; headers[X-Requested-With] XMLHttpRequest; });并发控制这块除了WebUploader自己的threads参数我还加了一层业务层的控制。因为如果用户一次性丢进来20个文件每个文件3个并发分片那仍然可能瞬间产生60个请求服务端根本扛不住。我的做法是在上传器外层做一个信号量限制同时上传的文件数量。const maxConcurrentFiles 2; let activeFileCount 0; uploader.on(startUpload, function() { // 这里的目的是限制同时上传的文件数量 // 实际逻辑在 file.uploading 状态切换时处理 }); uploader.on(uploadStart, function(file) { activeFileCount; }); uploader.on(uploadComplete, function(file) { activeFileCount--; // 触发下一个文件的自动上传 scheduleNext(); }); function scheduleNext() { if (activeFileCount maxConcurrentFiles) { const pendingFiles uploader.getFiles(pending); if (pendingFiles.length 0) { uploader.upload(pendingFiles[0]); } } }在这个机制下实际在途请求数就是“文件并发数 × 每个文件的分片并发数”可控性大大增强。3.4 上传完成后合并通知与回调所有分片传完后WebUploader会触发upload-complete事件。这时候前端需要调用合并接口让服务端把散落的分片拼接成完整视频文件。这里有一个极为重要的细节必须等待全部文件分片完成且列表中不能有失败文件才允许触发合并。uploader.on(uploadComplete, function(file) { if (file.skip) { // 秒传文件不需要合并 showMessage(文件已存在秒传成功); return; } const totalChunks Math.ceil(file.size / chunkSize); const existedCount (file.existedChunks || []).length; // 已上传分片数 本次跳过的分片数 总分片数时才说明全部传完 const uploadedCount file.existedChunks.length (file.chunks file.chunks.length || 0); if (uploadedCount totalChunks) { // 还有分片没传完继续等 return; } // 调用合并接口 $.ajax({ url: /api/upload/merge, type: POST, data: { fileId: file.fileId, fileName: file.name, totalChunks: totalChunks, fileSize: file.size } }).then(function(res) { if (res.success) { showMessage(上传完成 res.data.filePath); uploader.removeFile(file); } else { showMessage(合并失败 res.message); } }); });注意一个容易被忽略的点合并操作是异步的服务端合并一个20GB的视频可能需要几秒钟甚至更久。所以合并接口的设计上最好返回一个任务ID前端通过轮询或者WebSocket查询合并状态避免前端一直挂起等待。我在项目中就是用一个mergeStatus轮询接口来实现的每2秒查询一次合并进度。4. 跨浏览器兼容性改造4.1 WebUploader的双轨模式与Flash退场最初的WebUploader设计是“HTML5优先、Flash降级”。在当年这是解决IE8/9等老浏览器无法使用File API和XMLHttpRequest Level 2的唯一方案。但到今天Flash已经被所有主流浏览器禁用继续依赖Flash降级只会带来安全问题和兼容问题。我在改造的时候做了一个重要决定彻底放弃Flash降级路径全面转向HTML5并对老旧浏览器提供明确的升级引导。现在的浏览器环境从Chrome 50、Firefox 60到Edge 80对File.slice()、FormData、XMLHttpRequest的支持都已经非常成熟。真正还需要考虑的是IE11和某些国产浏览器内核的兼容性。经过实际测试IE11的问题主要集中在两个方面一是FileReader.readAsArrayBuffer性能较差二是大文件切片时内存占用偏高。针对IE11我做了降级策略分片大小动态调整。function getAdaptiveChunkSize() { const ua navigator.userAgent; const isIE11 ua.indexOf(Trident/7.0) -1; const isOldChrome /Chrome\/(\d)/.test(ua) parseInt(RegExp.$1) 60; if (isIE11) { return 10 * 1024 * 1024; // 老IE用10MB } if (isOldChrome) { return 15 * 1024 * 1024; // 老Chrome用15MB } return 20 * 1024 * 1024; // 现代浏览器默认20MB }这个自适应逻辑看起来简单实际效果却非常明显。老浏览器一次性读取20MB数据到内存容易导致页面卡顿甚至崩溃降到10MB后GC压力小了很多上传过程明显更稳定。4.2 能力检测与降级提示策略跨浏览器兼容的核心原则是不要假设浏览器能力要在运行时检测并做出对应处理。我在插件启动时做了完整的能力检测const isSupportHTML5Upload (function() { const fileInput document.createElement(input); fileInput.type file; return files in fileInput slice in fileInput.files[0] ? true : false; })(); const isSupportFormData typeof FormData ! undefined; const isSupportXHR2 typeof XMLHttpRequest ! undefined upload in new XMLHttpRequest(); if (!isSupportHTML5Upload || !isSupportFormData || !isSupportXHR2) { // 低版本浏览器引导升级或给出明确提示 alert(当前浏览器版本过低无法支持大文件分片上传。请升级浏览器或切换到Chrome/Firefox/Edge。); return; }对于企业内外网环境特别是卫星视频回传这种场景浏览器版本往往受制于安全策略无法升级。针对这种情况我的插件还会给出一个“兼容模式”禁用多文件并发回退为单文件单分片串行上传。虽然速度慢不少但至少功能可用不会卡死。if (!isModernBrowser) { uploader.option(threads, 1); // 单并发 uploader.option(chunkSize, 5 * 1024 * 1024); // 更小的分片 }4.3 复杂的浏览器限制适配除了老旧的浏览器现代浏览器也有一些隐藏的限制需要在插件层面处理。我踩过的坑包括Chrome并发请求限制同一域名下HTTP/1.1最多6个并发请求这直接影响threads参数的上限设置。HTTPS 大文件上传的缓存问题某些代理服务器会把分片响应缓存下来导致分片上传后拿不到正确的响应状态。解决方法是给每个分片请求加随机query参数。浏览器的页面刷新恢复这是断点续传最常见的场景。用户传了2GB刷新了一下页面任务清单必须能恢复到上传前状态。我是用localStorage缓存了文件列表和MD5标识刷新后自动恢复任务队列。Safari的File对象兼容Safari对于File对象的name属性和size属性支持有瑕疵需要在初始化时做一次兜底取值。// 分片请求URL添加时间戳防止代理缓存 const rawServer uploader.options.server; uploader.option(server, rawServer ?_t Date.now());刷新恢复的实际逻辑我这里给出简版function saveUploadTask(file) { let tasks JSON.parse(localStorage.getItem(upload_tasks) || []); const idx tasks.findIndex(t t.fileId file.fileId); const task { fileId: file.fileId, fileName: file.name, fileSize: file.size, existedChunks: file.existedChunks || [], uploadTime: Date.now() }; if (idx 0) { tasks[idx] task; } else { tasks.push(task); } // 只保留最近20条任务记录防止localStorage爆掉 tasks tasks.slice(-20); localStorage.setItem(upload_tasks, JSON.stringify(tasks)); }用户刷新后从localStorage读取任务清单匹配到对应的文件重新走预检接口断点续传就自动接上了。5. 真正踩过的坑与排查记录5.1 “假续传”文件内容变更但文件名未变这是一个非常隐蔽的坑。用户第一次上传A文件传了一半然后他修改了这个视频比如剪辑了一下文件名没变。第二次上传时如果我只判断文件名相同就续传那么服务端继续拼接分片最后一定会得到一个损坏的视频文件。排查过程很痛苦最终定位到根因断点续传的标识不能只看文件名必须看文件内容的MD5。我在before-send-file钩子里强制计算MD5MD5不一致就视为“新文件”服务端用新的fileId存储分片。旧分片则通过定时任务清理。5.2 分片并发上限与服务端连接数冲突上线初期我把threads设置为5结果一到上传高峰期服务端频繁报“Too many open files”异常。排查发现某些客户环境的服务器配置了严格的连接数限制单个客户端连接数超过3就会被拒绝。最终方案是把并发数做成可配置参数// 初始化时从后端配置拉取推荐并发数 $.get(/api/config/upload, function(res) { uploader.option(threads, res.data.threads || 3); });同时在插件内部加了一个连接失败自动降级的逻辑如果连续3个分片请求因为连接数超限失败就把并发数降为1避免服务端雪崩。5.3 MD5校验耗时太长、卡死浏览器计算2GB文件的MD5在低配业务机上可能要20秒以上。如果主线程被卡住页面就会出现“无响应”的假死状态。用户体验极差。我的优化方案有两个第一个是分块读取 增量计算而不是一次读入内存。上面的computeFileMD5函数已经体现了这一点每2MB一个块边读边算避免内存暴涨。第二个是把MD5计算放到Web Worker中执行完全不影响UI线程const worker new Worker(/static/js/md5-worker.js); worker.onmessage function(e) { file.fileId e.data.md5; // 继续后续上传逻辑 }; worker.postMessage({ file: file.source });Web Worker方案实测下来效果极佳大文件MD5计算期间用户依然可以滚动页面、点击其他按钮、上传其他文件不再有卡顿。5.4 localStorage任务清单与真实文件状态不一致还有一个坑在断点续传的恢复环节。用户刷新后浏览器会尝试重新关联“本地文件”。但localStorage里保存的只是任务元数据保存在服务端的分片状态才是最终依据。如果服务端清理了过期分片而本地还残留着任务记录就会导致恢复后上传一直卡在0%。解决方案是恢复任务时必须先调用预检接口以服务端返回的existedChunks为准本地记录只做展示参考。同时增加一个“清空无效任务”按钮如果服务端确认文件ID不存在就移除本地任务记录。6. 写在最后的几点体会这一套改造完成之后我在实际使用中还发现一个小技巧对于超大文件的上传进度条展示逻辑要按分片维度做而不是按文件字节数做。WebUploader默认的进度是按分片序号/总分片数计算的如果直接用file.percent当用户断点续传时进度会出现“跳变”从40%直接跳到90%。我在改造中把进度条显示为“已上传有效分片数 / 总分片数”再展示一个“剩余时间估算”体验会顺滑很多。卫星视频这类超大文件的传输本质上是把不可靠的网络问题转化为可重试的分片问题。WebUploader给了我们一套成熟的分片框架但真正让它适应高可靠场景的是预检、续传、并发控制、跨浏览器适配这些细节的不断完善。希望这篇改造记录对你有实际帮助如果你也在做类似的大文件传输方案欢迎试试这套改造思路然后根据自己的业务场景继续打磨。
返回列表