做军工行业地面站卫星视频归档的时候,最头疼的就是上传那一环。视频文件动不动就几个GB到几十个GB,内网带宽还忽高忽低,传到一半断了就得从头再来,领导和甲方盯着进度,烦得很。我当时的做法是基于WebUploader做了一次深度改造,把它封装成一个真正可用的大文件分片断点续传插件。这个插件从设计到落地,踩了不少坑,也沉淀了一套可以复用到其他场景的方案。这篇就完整拆解一下整个改造思路和关键实现,包含跨浏览器兼容处理、分片策略、断点续传机制和插件架构设计,希望能给同样被大文件上传折磨的人一点参考。
1. 需求拆解与插件总体思路
1.1 卫星视频上传的真实场景
先说说实际业务长什么样。地面站接收卫星下传的视频数据后,需要把原始视频文件从站端设备上传到数据中心做后续的人工智能识别、编目和存储归档。这个数据链路非常特殊:第一,文件极大,单段任务原始视频通常在2GB到50GB之间,某些连续观测任务甚至能到上百GB;第二,网络环境差,现场往往是专线或微波链路,虽然内网隔离但延时高、丢包率不稳定,断传是常态;第三,基本都是国产化软硬件环境,浏览器可能是老版本的Chromium内核、IE11、甚至政务内网定制的国产浏览器,HTML5新特性支持不完整,你不能只赌一个完美环境。
我最初试着直接用WebUploader默认配置传一个6GB文件,结果传了三个小时,中途一个网络抖动直接回到起点,非常痛。由此确定需求:文件分片上传、任意分片失败可重传、断网或刷新页面后可恢复进度、不同浏览器行为一致、最好能秒传重复文件。
1.2 为什么选WebUploader作为底座
市面上有大文件上传方案很多,比如plupload、resumable.js、vue-simple-uploader,还有后端的MinIO分片接口。但WebUploader有一个别家不太比得上的优势:它的分片和file切片逻辑非常成熟,自带多线程(多实例并发)控制、文件MD5计算、队列管理、事件机制,而且对旧浏览器的兼容策略很早就有一套(历史上用Flash降级,虽然现在Flash已死,但它的HTML5降级架构仍然可以借鉴)。在军工内网这种“不能用最新酷炫特性”的环境里,WebUploader的老成持重反而成了优点。
不过WebUploader本身也不是为“超大文件断点续传”设计的。它默认的分片是一次性传完,服务端按分片合并即可,但没有记录“哪些分片已经传过了”的机制。所以我的思路是保留它已有的文件解析、分片、并发上传、事件回调这套核心,在它的外层封装一个带元数据登记、断点状态恢复、服务端校验能力的插件层。这样既不用推翻重写,又能满足军工行业的特殊要求。
1.3 插件整体架构设计
整个插件我命名为UploaderPro,逻辑上分成四层:
- 核心层:基于WebUploader的初始化、文件队列、分片上传执行,这层完全复用WebUploader的能力。
- 扩展层:自定义插件模块,负责监听WebUploader的事件,拦截分片上传的before-send流程,注入断点校验、自定义请求头、文件指纹等。
- 存储层:把已上传分片信息、文件MD5、上传进度快照存入
localStorage或IndexedDB,按文件ID维度做映射,方便恢复。 - 服务端对接层:统一封装后端接口协议,包含“文件登记—分片校验—分片上传—分片合并—完成确认”五个动作。
这样分完之后,每一层职责清晰,测试也好写,后续如果要迁移到其他上传组件或者换成原生XMLHttpRequest,只需要替换调用层,扩展层和存储层可以继续复用。
2. 分片、秒传与断点续传核心机制
2.1 分片大小到底怎么定
分片大小是第一个要拍板的参数,直接影响上传成功率和并发效率。我在实际测试中发现,分片太小会导致请求数量爆炸:一个50GB文件如果按1MB分片,会产生51200个HTTP请求,光是请求头消耗都受不了;分片太大会失去断点续传的意义,一个分片传一半失败还是要整片重传。
军工内网链路的特点是带宽不低但延迟高、抖动明显,我最终把默认分片设为8MB,同时提供一个chunkSize配置项给不同站点调优。8MB这个值是有考量的:在带宽100Mbps、RTT 50ms的内网环境,8MB单分片传输时间约0.65秒,HTTP请求头和响应头的开销占比很小;如果RTT高到150ms,分片可以适当放大到16MB,减少来回次数。
同时我建议限制并发数,不要默认开5个以上。因为分片大了以后,并发太多会打爆链路,丢包重传反而拖慢整体速度。我实测在弱网下并发数设为3时吞吐量最高,超过5后丢包率显著上升。
chunkSize: 8 * 1024 * 1024, threads: 3,2.2 文件指纹与秒传实现
断点续传的前提是要能稳定标识一个文件。我的方案是分两级指纹:文件整体MD5和分片MD5。不要只算文件整体MD5,因为一个上百GB的文件算整体MD5可能要几分钟,等不起。更聪明的做法是“抽点计算 + 分片MD5确认”。
具体逻辑是这样:文件加入队列后,先算每个分片的MD5,同时每隔N个分片抽取一个作二次校验。上传前把该文件的全部分片MD5列表一次性提交给后端登记接口,后端返回哪些分片已经存在(可能来自其他用户或历史上传),哪些需要重新上传。这样一来:
- 新文件第一次上传:没有历史分片,后端返回全部需要上传。
- 上次传了一半的文件:后端比对MD5列表,返回缺失的分片编号,前端只传缺失部分。
- 完全相同的文件再次上传:后端发现所有分片都已经存在,直接标记合并,实现秒传。
这个方案比单纯靠文件名+大小判断可靠得多。我遇到过两个不同任务生成了同样大小的文件,但内容完全不同,如果只靠名字和大小会误判成同一个文件导致数据损坏。MD5列表校验虽然多传了一些数据,但在军工数据归档场景里,正确性优先于效率。
2.3 断点续传的上传记录与恢复
断点续传最大的难点不是“能不能停下来”,而是“停下来之后怎么知道传了哪些”。我设计了一个UploadRecordStore,上传过程中的关键节点都会向它写快照:
{ "fileId": "8a3f9c2e-1b5d-4f7a-9c1e-6b2d8a4f0e71", "fileName": "2024-06-18_120000_sat_video.mkv", "fileSize": 23622320128, "overallMd5": "9e107d9d372bb6826bd81d3542a419d6", "chunkSize": 8388608, "totalChunks": 2816, "uploadedChunks": [0, 1, 2, 5, 6], "serverPath": "/data/upload/archive/20240618/", "timestamp": 1718700000000 }上传状态变更时,uploadedChunks数组会更新。这里有个性能注意点:当分片数量到了几千个时,每次更新都写整个数组会卡顿,我最后采用“批量累积更新 + 节流落盘”的策略:内存里维护一个已上传分片的Set,每成功一个就往Set里加,每成功10个或者间隔5秒才把Set序列化写入localStorage。如果文件特别大、分片数上万,建议改用IndexedDB,因为localStorage有5MB上限,几十GB文件的记录可能超过。
恢复的时候,页面加载完插件后,会读取localStorage里的记录,筛选出未完成的,在文件列表中显示“继续上传”按钮。点击后不需要重新选择文件,直接调用WebUploader的addFiles接口把记录关联到实际文件对象上,然后进入断点校验逻辑,只传缺失分片。
2.4 失败分片的重试与网络恢复
WebUploader原始的重试机制比较粗暴,retry会重新加入队列,但它对“哪个分片失败”没什么记忆。我要做的改造是:监听uploadChunkFailed事件,把失败的分片自动加入重试队列,而不是让整个文件上传中断。
我设置了一套重试策略:
- 每个分片失败后,指数退避重试,间隔从1秒开始,最大延迟30秒。
- 同一个分片连续失败超过3次,暂停该分片所在的任务,弹出网络异常提示,但不清理已上传进度。
- 监听
online事件,网络恢复后自动继续暂停的任务。 - 服务端返回的响应如果包含
chunkIndex,前端会精确跳过错传失败的分片。
这部分的细节直接决定了用户会不会骂人。我用一次真实场景验证过:一个18GB文件在传了60%时,现场交换机重启了一下,所有网络连接被切断。WebUploader原始版本直接所有任务失败,重启后从0开始;改造后的插件在网络恢复后,只补传了网络断开瞬间没传完的那几个分片,然后继续从60%往下传,最后总共只多花了三分钟就完成了剩余40%的任务。
3. 插件化设计与跨浏览器兼容
3.1 事件总线与扩展点设计
要让这个插件在军工不同的站点之间复用,不能把具体业务逻辑写死在代码里。我借鉴了轻量级插件系统的事件总线思想,把整个上传生命周期拆成一系列钩子,每个站点可以按需注入自己的逻辑。
核心扩展点我用一张表来托管:
| 扩展点 | 触发时机 | 典型用途 |
|---|---|---|
onFileAdded | 文件加入队列 | 自定义文件名规则、加密标记 |
beforeChunkUpload | 每个分片上传前 | 注入Token、加密参数、校验服务端状态 |
afterChunkUpload | 每个分片成功 | 更新进度、上报日志 |
onChunkFailed | 分片失败 | 重试策略、告警通知 |
beforeFileComplete | 全部传完准备合并 | 引导服务端合并、验证文件长度 |
onFileComplete | 合并完成 | 更新业务状态、通知下游系统 |
事件总线的实现其实很轻量,用一个Map存储事件名到回调数组的映射,配合一个简单的中介者模式就可以了。通过这个机制,不同站点可以只通过配置JSON来启用或禁用某些扩展模块,而不需要改插件源码。
3.2 现代浏览器的原生分片方案
在Chromium 80+的现代内核上,WebUploader底层使用的是Blob.slice()方法,这个兼容性没有问题。我的插件在现代浏览器上可以直接使用完整的文件流处理,包括FileReader读取二进制切片做MD5、XMLHttpRequest发送二进制数据、Blob对象直接上传。
这里有一个特别要注意的点:WebUploader在默认情况下,分片数据是用File.slice(start, end)拿到的Blob,然后调用formData.append('file', blob)上传。这种方式的缺点是MD5计算和上传各读取了一次文件,大文件会占用大量内存和IO。我做了一层优化:MD5计算时使用FileReader分片异步读取,读完一片立即释放;上传时再用slice直接取原始Blob。这样在两个阶段不会同时加载同一份数据,减少了内存峰值。
在我的实测中,一个4GB文件在普通桌面电脑上,MD5计算加上传的总内存占用控制在800MB以内,相比一次加载整个文件的方案下降了约60%,在工控机级别的国产硬件上也跑得动。
3.3 老旧浏览器与国产浏览器的降级处理
军工内网的老浏览器是个现实存在,尤其是一些定制的安全浏览器和老的360企业版,对Blob.slice的支持不完整,甚至部分浏览器对FormData的二进制支持都有兼容问题。我针对这种情况做了三层降级:
- 第一层:检测浏览器支持
Blob.prototype.slice和FileReader,如果支持,走正常分片上传流程。 - 第二层:如果
Blob.slice不可用,但FileReader可用,就用FileReader.readAsArrayBuffer手动切割ArrayBuffer,然后用new Blob([buffer])包装成Blob再上传。这种方法兼容IE10+,性能略差,但不影响功能。 - 第三层:如果浏览器连
FormData上传二进制都支持不好,那就退回模拟表单上传,用隐藏iframe提交整个文件(不进行分片)。这是最后保底方案,只在小文件场景可用,一旦检测到这种情况我会在界面上提示“当前上传限制2GB以内”。
此外,服务端接口在设计时就要兼容分片上传和非分片上传两种模式。非分片模式上传时,chunk字段固定为0,服务端直接落盘即可。这样老旧浏览器用户虽然无法享受断点续传,但至少能完成小文件传输。
3.4 统一配置中心与按站点定制
军工项目中,不同站点的网络带宽、存储路径、认证方式、数据加密要求都不一样。为了不让插件变成一座孤岛,我把所有可调参数收口到一个配置对象,并在插件初始化时加载:
const uploader = new UploaderPro({ container: '#uploadPanel', chunkSize: 8 * 1024 * 1024, threads: 3, retryTimes: 3, maxFileSize: 100 * 1024 * 1024 * 1024, // 100GB serverUrl: { register: '/api/upload/register', chunkCheck: '/api/upload/check', chunkUpload: '/api/upload/chunk', merge: '/api/upload/merge' }, authToken: () => getCurrentUserToken(), encryptChunk: true, encryptType: 'sm4', // 国密SM4示例 storage: 'localStorage', // 或 'indexedDB' hooks: { onFileAdded: customRenameHook } });按站点定制的逻辑写在启动脚本里,把具体配置传给构造函数。插件核心代码不感知站点差异,只认配置和事件。这样在日后维护时,一个插件包可以适配所有现场,出问题也能快速定位是配置问题还是核心代码问题。
4. 关键代码实现与落地过程
4.1 WebUploader初始化与插件挂载
改造的第一步是初始化原生的WebUploader,并让它具备对外暴露核心能力的能力。我用了一个包装类,把WebUploader实例藏起来,只暴露自己封装的公共方法:
class UploaderPro { constructor(config) { this.config = this._normalizeConfig(config); this._chunkStatusMap = new Map(); this._initCore(); this._initStorage(); this._bindEvents(); } _initCore() { this.uploader = WebUploader.create({ swf: this.config.swfUrl, server: this.config.serverUrl.chunkUpload, pick: this.config.pickElement, accept: this.config.accept || {}, auto: false, chunked: true, chunkSize: this.config.chunkSize, threads: this.config.threads, duplicate: true, formData: { token: this.config.authToken() } }); } }这里的核心是chunked: true。开启之后WebUploader会自动把文件切成chunkSize大小的分片并逐个上传,它会自动在请求中附带chunk(当前分片索引)和chunks(总分片数)这样的参数,就不用我们手动管理分片坐标了。
4.2 分片MD5计算与文件登记
分片MD5是整个断点续传的数据基础,做不好后面全乱。我使用spark-md5这个库,把文件切片后按顺序计算:
_uploader.on('beforeFileQueued', (file) => { const fileId = this._generateFileId(file); this._chunkStatusMap.set(file.id, { fileId, uploadedChunks: [], totalChunks: Math.ceil(file.size / this.config.chunkSize) }); // 如果本地已有上传记录,说明是续传 const record = this._storage.getRecord(fileId); if (record) { this._chunkStatusMap.get(file.id).uploadedChunks = record.uploadedChunks; this._setRestoreMode(true); } return file; });在正式上传第一个分片之前,必须先调用服务端登记接口,把分片MD5列表发过去,再根据返回结果决定哪些分片要传。服务端返回的是“待上传分片索引列表”,前端根据这个列表去重排队:
async _registerFile(file, chunkMd5List) { const response = await fetch(this.config.serverUrl.register, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileId: file.fileId, fileName: file.name, fileSize: file.size, chunkMd5List }) }); const result = await response.json(); // result.uploadedChunks: 服务端已有的分片索引 // result.missingChunks: 需要上传的分片索引 return result; }MD5计算对超大文件来说是一个耗时操作,尤其是纯JS在低配工控机上跑。我建议界面显示“正在计算文件指纹,预计耗时1分钟”的进度条,而不是让用户以为卡死了。计算过程用requestAnimationFrame分批推进,保证UI不冻结。
4.3 服务端分片校验与合并
服务端接口设计直接决定断点续传能不能成功。因为分片上传本质上是一个“无状态快递服务”,每个分片都是独立的HTTP请求,服务端必须能自主判断“这个分片要不要收”。我的后端接口设计如下:
- 登记接口
POST /api/upload/register:接收文件元数据与全部分片MD5,在数据库创建上传任务记录,返回已存在分片列表和缺失分片列表。这里的“已存在”包含两种情况:历史未完成的任务中已上传的分片,或者其他任务里MD5一致的分片。 - 校验接口
POST /api/upload/check:接收文件ID和分片索引,返回该分片是否已存在。前端在每次续传前会先校验剩余分片的状态,避免盲目重传。 - 上传接口
POST /api/upload/chunk:接收分片二进制数据和分片索引,落盘到临时目录,记录分片MD5到数据库。 - 合并接口
POST /api/upload/merge:接收文件ID,服务端检查所有分片是否齐全,按索引顺序合并成完整文件,并计算合并后文件的整体MD5,与登记时的整体MD5比对。比对通过才标记完成,不通过则返回缺失分片列表让前端补传。
合并操作我建议放在服务端做,而不是让前端把所有分片下载下来再合并。原因很简单:前端合并虽然省了服务端的IO,但超大文件会在浏览器里产生巨大的内存压力,而且如果合并到一半浏览器崩溃,状态会变得无法追踪。服务端合并可以保证原子操作和数据一致性。
服务端合并的时候还有一个容易被忽略的细节:分片大小不一致。最后一个分片往往比设定的chunkSize小,合并时不能假设所有分片大小相同,必须按“文件名_分片索引”的规则遍历,用文件流按字节追加,而不是用固定长度截取。
4.4 进度上报与UI状态同步
大文件上传时,用户最关心的就是“传了多少、还剩多少、速度多少”。WebUploader默认的progress事件只报告当前文件的整体进度,由已上传分片数量除以总分片数量计算得出,这只是“已传输数据量”,不是“文件可用进度”。
我的改造方案是在上传界面展示三个维度:总进度(已传分片占比)、当前速度(最近10秒平均速度)、剩余时间(按当前速度预估)。速度计算不能直接用瞬时值,瞬时值波动太大,我用一个环形缓冲保存最近5个采样点做滑动平均,视觉上会稳很多。
此外还有进度恢复的问题:续传开始时,总进度并不是从0开始,而是从记录中的百分比恢复。这里我会弹一个提示“检测到未完成的上传任务,正在恢复进度……”,恢复完成后直接显示真实百分比,不会让用户以为进度搞错了。
4.5 加密分片与安全传输
军工场景的数据安全等级比较高,链路虽然内网隔离,但为了保险起见,我加入了分片级别的加密支持。前端在上传前对分片数据做SM4加密(国密对称加密),服务端收到后先解密再落盘。
加密的字节处理有一个大坑:加密之后的分片大小会变,导致合并时无法直接按顺序拼接。我的做法是每一片都使用固定长度的加密块格式:[16字节IV][加密数据],加密数据是原始分片的密文,长度是16的倍数(PKCS7填充)。合并时服务端按顺序读取,每一片单独解密后再写入目标文件。这样虽然多了些计算开销,但保证了数据在传输过程中不会被旁路嗅探。
另外,加密操作尽量用Web Worker来做,避免阻塞主线程。我实测一个64MB分片在普通PC上SM4加密大约需要800毫秒,放到Worker里之后页面完全无感知。
5. 军工内网场景的常见问题与性能优化
5.1 常见问题排查速查表
改造过程中积累了很多排查经验,整理成表格供参考:
| 现象 | 可能原因 | 排查思路 |
|---|---|---|
| 所有分片上传都失败 | Token过期或认证方式不兼容 | 检查请求头中的Authorization字段,看服务端日志是否有权限拒绝 |
| 上传成功后合并超时 | 分片过多或并发数量过大导致临时文件堆积 | 增加服务端合并线程池大小,或将每批合并分片数限制在500个以内 |
| 断网恢复后进度回退 | localStorage记录没有及时落盘 | 检查是否启用了节流落盘机制,确认上传成功回调里有写入快照 |
| MD5计算阶段内存飙升 | 一次性读取所有分片进行计算 | 改为逐片读取并调用spark-md5的append,计算完毕立即置空引用 |
| 老浏览器点击上传按钮无响应 | 文件选择控件被安全策略阻止或Flash组件缺失 | 查看浏览器控制台报错,必要时为老浏览器单独适配隐藏input方案 |
| 分片上传后服务端只有一个文件 | 服务端合并逻辑按固定长度截取而非按索引拼接 | 检查合并代码,确认按“文件索引+分片大小”动态拼接 |
| 相同文件第二次上传仍然慢 | 秒传校验逻辑没有生效 | 确认登记接口是否返回了缺失分片列表,以及前端是否跳过已存在分片 |
5.2 弱网和断网情况下的细节优化
弱网断点续传里,最怕的不是“断”而是“假死”。有一种情况特别坑:链路质量差时,HTTP请求会一直处于pending状态,既没有成功也没有失败,前端等不到回调就会一直挂着,用户看到的就是“卡住了”。我针对这个问题加了超时控制:
$.ajax({ url: this.config.serverUrl.chunkUpload, timeout: 120000, // 单个分片最大等待时间 error: function(xhr, status) { if (status === 'timeout') { // 手动把该分片置为失败,触发重试机制 } } });超时时间设置需要考虑分片大小和链路带宽的匹配。比如8MB分片在10Mbps的链路上理论传输需要约6.5秒,在100Mbps链路上不到1秒,但加上排队和丢包重传,2分钟的等待上限是合理的。如果分片调到16MB,超时上限应同步提高到3分钟。
另一个优化点是“并行分片和MD5预计算”:当一个分片正在上传时,后台线程预计算下一个分片的MD5。这样流水线作业能显著减少整体耗时。实测下来,预计算可以让总耗时减少15%到20%,因为在等待网络响应的空档里,CPU是不闲着的。
5.3 性能评估:这套改造的收益
用真实的18GB卫星视频文件做了一次完整测试,测试环境:内网千兆局域网、RTT 20ms、丢包率0.1%的工业交换网络,客户端是国产台式机(四核CPU、8GB内存)。对比原生WebUploader和改造后的UploaderPro:
| 指标 | 原生WebUploader | UploaderPro |
|---|---|---|
| 首次上传成功耗时 | 失败(中途断连) | 7分12秒 |
| 断网恢复后续传耗时 | 需从头再传 | 1分48秒 |
| 同一文件重复上传 | 无法识别,完整传一遍 | 秒传(2秒内完成) |
| 内存峰值 | 1.6GB | 780MB |
| 分片请求数量(单次完整上传) | 约2304个 | 2304个(但失败重传仅2个) |
最大的收益就是:把一个“用户手动盯着传、断了一次就绝望”的场景,变成了“扔在那里不用管、断网自动恢复继续”的场景。对现场运维人员来说,这种体验提升是革命性的。
5.4 国产化环境适配经验
最后单独说国产化环境。军工现场很多用的是麒麟操作系统和国产浏览器,这些浏览器内核大多是Chromium的特定版本,有的比Chrome 69还老。我在适配中发现三个高频问题:
一是字体渲染和UI组件兼容性,WebUploader自带的UI在国产浏览器上会有样式错乱,我干脆弃用了它的自带面板,只用它的js API,界面完全用自己的React组件渲染,彻底绕开样式兼容问题。
二是文件路径问题,在Linux桌面环境下,File.name可能包含奇怪的编码,服务端落盘时要统一做字符集转换,否则合并文件名会乱码。
三是软链接和挂载目录问题,上传临时目录和最终归档目录可能在不同磁盘分区,合并时如果跨文件系统移动大文件会非常慢。我建议服务端把临时目录和归档目录规划在同一文件系统内,合并时用rename立即完成,而不是复制。
6. 从插件到平台:一些后续可以继续做的事
项目上线之后,我在复盘时发现几个可以继续扩展的方向。第一个是把上传插件的状态数据接到运维监控平台,通过心跳上报每个站点的上传成功率、平均速度、失败分片分布之类的指标。这样总部能提前预判哪些站点网络链路有问题,而不是等现场人员打电话才排查。
第二个方向是做一个上传策略的智能调度。比如在带宽紧张的时候,自动降低并发数和分片大小,把资源让给更高优先级的任务;在夜间带宽空闲时,自动提高并发数加速大批量历史数据回传。
第三个方向是结合轻量级任务编排,上传完成后自动触发后续的格式转换、视频抽帧、元数据提取步骤。这样“上传”就不只是文件传输动作,而是整个数据入湖流程的起点。
这些扩展方向我不打算一股脑写进插件里,而是保持插件核心稳定,把新能力做成独立的外围模块,通过配置热插拔。毕竟军工系统的上线节奏不像互联网产品那么快,每一步改动都要对稳定性和安全性负责。插件稳定、易排查、可演进,比功能堆砌重要得多。
从我个人经验看,WebUploader虽然已经不是一个“新”项目了,但它的架构设计底子非常好,只要理解清楚它的分片事件机制和队列模型,完全可以在它之上长出一个满足军工行业严苛要求的上传系统。这个改造项目最有价值的地方不是那几行代码,而是整套“可断、可续、可校验、可恢复、可适配”的工程思路。希望这篇拆解能帮你少走一些弯路,尤其是那些网络环境差、浏览器版本旧、文件动辄几十GB的场景,值得把一个上传体验认认真真做到位。