大文件断点续传实战:基于WebUploader的卫星视频上传改造
2026/9/15 3:02:21 网站建设 项目流程

去年年中我接过一个挺硬的需求:军工行业的内网平台里,要做一套浏览器端的卫星视频上传功能。需求方的限制条件很短,但每一条都压手——文件动辄几十GB,必须支持断点续传,IE、老版Chrome、国产双核浏览器都要能跑,整套前端资源还不能引用公网。当时第一反应就是WebUploader,毕竟它自带分片、并发和MD5秒传,网上聊这个的教程也不少。可真等我把“单文件100GB卫星视频”和“浏览器从IE11跨度到Chrome 120”摆在一起时,才发现原生WebUploader只能算半成品,从分片策略到MD5计算方式,再到失败重试和服务端台账,全得按场景重新捋一遍。

这篇文章就把整个改造过程摊开讲,包括断点续传的底层逻辑、跨浏览器兼容的处理方式、内网部署的工程化约束,以及我实际踩过的几个大坑。内容不绕弯子,能给正在做类似大文件上传方案的人一个可参考的路线图。

1. 卫星视频上传场景里,原生WebUploader为什么顶不住

1.1 需求拆解:这个上传任务到底特殊在哪

这个需求真正难的,不是“上传”本身,而是几个约束条件叠在一起之后,把普通上传组件的短板全暴露出来了。我习惯开工前先把约束条件列成表格,看得清楚,后面设计才不会跑偏。

约束条件表象本质问题
文件超大卫星视频单文件数十GB不能整文件读内存,必须流式处理
断点续传上传中断后不能从头再来需要可靠的文件标识和分片台账
跨浏览器IE11、老Chrome、国产双核混用不能依赖单一HTML5能力,得有降级策略
内网隔离无法访问公网CDN和在线依赖所有资源本地化,离线可运行
数据重要视频不可丢失、需要可追溯服务端要能校验分片完整性,留足日志

这中间最容易被低估的就是“文件超大”和“跨浏览器”的组合。单说大文件,现代浏览器File API都能处理;单说老浏览器,很多上传组件也兼容。但老浏览器碰上几十GB的文件,很多常规做法就直接失效了,比如一次性把文件读进内存算MD5,在IE11和低版Chrome上几乎是必崩。

1.2 原生组件的三个软肋

WebUploader本身的定位是一个“基础上传框架”,它把文件选择、分片、队列、进度做了封装,方向是对的。但拿到这个场景里,有三个软肋绕不过去。

软肋一是MD5计算。WebUploader默认的文件唯一标识逻辑,对于超大文件非常不友好。它会把文件读进内存做计算,视频文件稍微大点,浏览器内存直接飙升,页面卡死属于常态。这块必须自己改成增量计算。

软肋二是断点续传只做了“前端一半”。组件负责切分片、传分片,但服务端支不支持分片存储、支不支持查询已上传分片、合并时怎么校验,官方示例里基本没给成熟方案。也就是说,续传能力需要你自己把服务端接口和数据模型设计出来。

软肋三是Flash降级路径。WebUploader老版本依赖Flash作为低版本浏览器的渲染内核,可现在的内网终端对Flash控件限制很严格,运维侧倾向直接封禁。所以跨浏览器兼容不能靠Flash,得靠能力检测,然后有明确的降级或不支持提示。

2. 断点续传的底层逻辑:文件指纹与分片台账

2.1 文件唯一标识:为什么必须用增量MD5

断点续传的本质,是让服务端能够识别“你传的这份文件,之前是不是已经传过一部分”。识别文件靠的是文件指纹,实际项目里最稳妥的就是MD5。

但问题在于MD5怎么算。很多刚接触的人会写:

const buffer = await file.arrayBuffer(); const md5 = SparkMD5.ArrayBuffer.hash(buffer);

这段代码在10MB的小文件上没毛病,放到卫星视频上就是灾难。file.arrayBuffer()会把整个文件读进内存,一个50GB的视频,内存先吃掉50GB,再加上计算过程中的临时对象,浏览器不崩才怪。

正确做法是按片读取、增量追加摘要:

function calcFileMd5(file, chunkSize, onProgress) { const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReader(); const size = file.size; let current = 0; return new Promise(function (resolve, reject) { function next() { const slice = file.slice(current, Math.min(current + chunkSize, size)); reader.onload = function (e) { spark.append(e.target.result); current += chunkSize; onProgress && onProgress(Math.min(1, current / size)); if (current < size) { next(); } else { resolve(spark.end()); } }; reader.onerror = reject; reader.readAsArrayBuffer(slice); } next(); }); }

这里每次只读一个分片(比如2MB~5MB)进内存,计算完摘要之后,上一步的ArrayBuffer就能被垃圾回收,内存占用非常平稳。实测下来,计算一个60GB文件的MD5,浏览器内存峰值始终控制在200MB以内,耗时也确实不短,但至少是可接受的,而且能给出进度条。用户看到“正在校验文件”的进度,比干等强太多。

2.2 服务端分片台账接口怎么设计

文件指纹确定了,接下来就是服务端怎么记录“传到第几个分片了”。我的做法是设计三个接口,形成一个闭环。

接口入参出参说明
文件注册/查询fileMd5, fileName, size已上传分片序号数组判定是否已存在、哪些分片可跳过
分片上传fileMd5, chunkIndex, chunks, file当前分片序号服务端幂等处理,重复传同一分片直接成功
分片合并fileMd5, chunks, fileName文件访问路径按序号合并,缺失分片则返回失败

以Node.js为例,分片上传接口的核心逻辑大致是这样:

router.post('/api/video/chunk', upload.single('file'), async (req, res) => { const { fileMd5, chunkIndex } = req.body; const chunkDir = path.join(uploadRoot, fileMd5); await fs.mkdir(chunkDir, { recursive: true }); // 关键点:每个分片写独立临时文件,写完再rename,避免并发写同一文件 const tmpPath = path.join(chunkDir, chunkIndex + '.tmp'); const finalPath = path.join(chunkDir, chunkIndex); await fs.writeFile(tmpPath, req.file.buffer); await fs.rename(tmpPath, finalPath); res.json({ ok: true, chunkIndex: Number(chunkIndex) }); });

合并接口要做一个非常重要的检查——分片完整性校验:

router.post('/api/video/merge', async (req, res) => { const { fileMd5, chunks, fileName } = req.body; const chunkDir = path.join(uploadRoot, fileMd5); const outputPath = path.join(uploadRoot, fileMd5 + '_' + fileName); const partPaths = []; for (let i = 0; i < Number(chunks); i++) { const p = path.join(chunkDir, String(i)); if (!fs.existsSync(p)) { return res.status(400).json({ ok: false, msg: `缺失分片 ${i}` }); } partPaths.push(p); } // 按顺序流式合并,避免一次性读入内存 await mergeFiles(partPaths, outputPath); res.json({ ok: true, url: '/download/' + fileMd5 }); });

合并前逐分片检查,缺失任何一个都拒绝合并。这样“看似传完、实际漏片”的情况能被及时挡住,而不是等到用户打开视频发现花屏了才回来找。

2.3 分片参数怎么定才合理

分片大小和并发数是这套方案里最容易“拍脑袋”的参数。我给不同网络环境整理过一组参考值:

网络情况推荐分片大小并发数单分片重试次数
千兆内网8-10MB33
百兆内网/跨网段2-5MB2-35
链路抖动明显1-2MB25-8

分片不是越大越好。分片过大,一旦中途某个分片失败,重试成本就高;分片过小,请求数量爆炸,服务端合并时文件句柄开太多,磁盘压力也大。内网环境下我最终选了5MB分片、并发3路,这个组合在稳定性和速度上最均衡。

并发数也不要贪多。WebUploader的threads参数控制并发分片数量,设太大会导致服务端同时收到大量写请求,普通机械盘反而会因寻道开销导致整体吞吐下降。换SSD之后可以适当提到5,但3到4路通常已经能把千兆内网带宽跑满。

3. 核心改造流程:从MD5增量计算到跳过已传分片

3.1 初始化配置:一套能支撑百GB文件的options

WebUploader的初始化参数看起来文档都有,但真正应对超大文件时,有几个细节必须显式设置,否则会踩默认值的坑。我项目里的配置是这样的:

const uploader = new WebUploader.Uploader({ swf: './resources/Uploader.swf', server: '/api/video/chunk', pick: '#picker', accept: { title: 'Video', extensions: 'mp4,ts,mkv,avi', mimeTypes: 'video/*' }, chunked: true, chunkSize: 5 * 1024 * 1024, threads: 3, fileNumLimit: 1, fileSingleSizeLimit: 100 * 1024 * 1024 * 1024, duplicate: true, formData: { token: getToken() } });

这里说几个容易被忽略的点。duplicate: true必须显式打开,否则同名同大小的文件会被WebUploader默认去重拦截,而卫星视频经常出现一系列命名极其相似的文件。fileSingleSizeLimit要按实际需求设大,默认值只适合普通办公文件。accept.mimeTypes不建议用video/*之外过于精确的MIME,因为不同编码的视频文件在浏览器里的MIME识别结果不完全一致。

3.2 增量MD5与文件注册

初始化之后,流程需要拆成两步走:先算MD5,再启动上传。不要一选完文件就调uploader.upload(),否则分片已经发出去了,MD5还没算出来,后续的断点续传就没有标识可用。

uploader.on('fileQueued', async function (file) { const md5 = await calcFileMd5(file.source, 5 * 1024 * 1024, function (ratio) { // 更新界面上的“校验进度” }); // 查询服务端已上传分片,作为断点续传依据 const res = await fetch(`/api/video/exist?md5=${md5}`, { method: 'GET' }); const data = await res.json(); uploadedChunks = data.chunks || []; uploader.option('formData.fileMd5', md5); // 重要:此时再真正触发上传 uploader.upload(); });

这里有一个容易被忽略的体验问题:计算MD5耗时可能很长,中间用户如果把页面关了,MD5结果就丢了,下次还是得重算。后续优化时我把MD5结果缓存到了localStorage,用文件大小 + 文件修改时间做索引,但注意localStorage在超大文件条目上可能有配额限制,需要做一下容错。

3.3 分片请求的参数注入与跳过逻辑

WebUploader在uploadBeforeSend事件里会暴露当前分片的信息,这是注入自定义参数和实现跳过的关键点:

uploader.on('uploadBeforeSend', function (block, data) { data.fileMd5 = uploader.options.formData.fileMd5; data.chunkIndex = block.chunk; data.chunks = block.chunks; data.fileName = encodeURIComponent(block.file.name); // 如果服务端台账里已经有这个分片,直接标记完成 if (uploadedChunks.indexOf(Number(block.chunk)) > -1) { block.complete(); } });

注意一个坑:block.chunk在不同场景下可能是字符串也可能是数字,必须用Number()统一类型再比较,否则indexOf永远返回-1,断点续传就会失效。这个坑我后面实测章节还会展开讲。

4. 跨浏览器兼容方案:能力检测与老内核适配

4.1 不迷信UA判断,用能力检测决定降级策略

军工内网的浏览器环境比公网复杂得多,Windows 7配老Chrome常见的很,Windows 10装国产双核浏览器也不稀奇。我一开始想用UA字符串区分浏览器,后来发现不靠谱——国产双核浏览器往往会把UA伪装成Chrome,但真实内核却可能是IE。正确思路是能力检测,直接探测浏览器支不支持关键的API:

const supportHtml5 = Boolean( window.File && window.Blob && window.FileReader && window.FormData && window.XMLHttpRequest ); // 分片能力需要File.slice,老浏览器可能带浏览器前缀 const supportSlice = Boolean(File.prototype.slice) || Boolean(File.prototype.webkitSlice) || Boolean(File.prototype.mozSlice);

能力检测通过,就走HTML5分片通道;不通过,就明确降级。我给项目定的兼容矩阵如下:

浏览器类型处理方式
Chrome 60+ / Edge / FirefoxHTML5分片上传,完整功能
IE11 / 老Chrome / 国产双核兼容模式HTML5可用,但需转译代码,部分安全提示需处理
IE10及以下不建议支持,直接提示升级浏览器

说句实在话,军工内网的老旧终端确实还存在,但基本都是IE11级别,IE8/9已经非常罕见了。与其花大精力维护Flash降级链路,不如把资源花在IE11适配和提示引导上。

4.2 老内核浏览器下最常见的三个拦路虎

第一,ES6语法。WebUploader本身是ES5写的,问题往往出在我们自己写的增补代码上。如果用了箭头函数、Promiseconst/let,在IE11上连解析都会报错。解决方案是引入Babel把代码转译成ES5,并且把Promise等polyfill文件本地化打包。内网环境没法在线拉polyfill,这一步必须提前做。

第二,File.prototype.slice的兼容性。老Chrome和IE11支持的不是标准slice,而是webkitSlicemozSlice。这个问题在文件小于2GB时不容易暴露,一旦处理超大文件,切片方法不对直接抛异常。WebUploader内部其实有兼容处理,但我们自己写增量MD5时也要用同样的兼容写法。

第三,超大文件在32位浏览器上的处理。如果终端是32位浏览器,File.size在超过2GB时可能溢出,slice处理也会有各种诡异问题。这个问题没有代码层面的完美解法,只能够在能力检测结果里把浏览器位数纳入判断,提示用户使用64位浏览器。

5. 内网部署的工程化约束与安全加固

5.1 离线部署:所有依赖本地化

内网隔离环境下,第一件事就是把所有依赖从公网CDN搬到本地。这个项目用到的前端依赖必须全部打进发布包,一个都不能漏:

依赖用途说明
jqueryWebUploader依赖用1.12.x版本,兼容老浏览器
WebUploader js/css组件本体版本锁定,不随意升级
Uploader.swfFlash降级保留但实际很少触发
SparkMD5增量MD5计算用压缩版即可
promise polyfill老浏览器兼容本地打包,不引用外部CDN

发布包部署完成后,要专门做一个页面做自检,逐项探测关键文件是否可访问。否则终端用户打开页面上传时才发现某个静态资源404,排查起来非常被动。

5.2 服务器与网关配置的几个关键项

分片上传模式单个请求体只有几MB,nginx的client_max_body_size理论上不用设太大。但合并接口的请求体带文件名等信息,而且上传目录的磁盘格式直接影响超大文件合并结果。建议关注以下几个点:

client_max_body_size 50m; proxy_read_timeout 600s; proxy_send_timeout 600s;

proxy_read_timeout一定要调大。内网环境虽然带宽稳定,但分片上传时偶尔会有慢速分片,默认60秒超时会让个别分片误报失败。另外上传临时目录要放在空间充足的独立分区,合并过程中会产生和最终文件同等大小的临时数据,磁盘不够会在最后一步失败,这个比网络问题更难排查。

5.3 上传插件的审计与安全

军工行业项目对数据追溯要求高,上传插件的日志必须做到分片级别。我把日志结构简化成了这样:

[2026-05-01 14:23:10] fileMd5=xxx chunkIndex=12 chunks=120 name=xxx.mp4 size=60GB ip=xxx result=ok

每次分片上传都记录一行,合并时再记录一条合并结果。后续如果出现文件损坏,可以按fileMd5倒查所有分片状态。

安全方面要特别注意路径穿越。服务端接收分片时,fileMd5fileName都不能直接拼接进文件路径,要做字符白名单过滤:

function safeFileName(name) { return String(name).replace(/[^\w.\-\u4e00-\u9fa5]/g, ''); }

6. 实测踩坑实录:三类典型故障的完整排查链路

6.1 页面卡死:MD5计算导致内存溢出

现象很直接:在Chrome里上传一个30GB视频,文件刚选完,页面逐渐失去响应,任务管理器里浏览器内存飙升到2GB以上,最后直接崩溃。

一开始我怀疑是WebUploader分片太多的问题,但排查时发现文件选完到崩溃之间,网络请求根本没发出去。打日志定位后发现,卡死在MD5计算阶段。原生SparkMD5.ArrayBuffer.hash(buffer)把整个文件一次性读进内存,然后还要生成一份完整的ArrayBuffer副本,内存瞬间翻倍。

修复方案就是前面写的增量计算方式。修复后我专门拿一个50GB的文件测试,内存稳定在180MB左右,计算耗时约70秒,但页面始终流畅。这个问题属于“看起来是浏览器问题,其实是实现方式问题”的典型。

6.2 合并后视频花屏:分片并发乱序与服务端写入竞争

现象是上传结束后视频文件大小也对,但播放到某个位置花屏或卡死。第一次遇到时我以为是视频源文件本身的问题,后来让需求方发了原始文件对比,才发现合并后的文件和源文件校验不一致。

排查过程分三步:先用MD5对比原始文件和合并文件,发现不一致;然后抽查分片内容,发现某个分片文件的内容被覆盖成了另一个分片的数据;最后定位到服务端写入逻辑——并发分片请求到达时,用了同一个目标文件句柄写入,导致数据交叉污染。

修复方案是每个分片先写独立临时文件,写完后通过rename原子操作改名落地。合并时逐分片检查存在性,再按顺序流式合并。经过这三层防护,再也没出现过花屏问题。我这里得到的教训是:前端分片并发做得再好,服务端写入不严谨,一切都白搭。

6.3 断点续传不生效:序号类型不一致与服务端幂等缺失

现象是用户上传到80%,刷新页面后重新上传,又从头开始传。排查时先确认MD5计算每次一致,再确认服务端查询接口确实返回了已上传分片数组,最后发现是前端比较逻辑的问题——block.chunk在WebUploader内部是字符串类型,而服务端返回的数组元素是数字类型,indexOf永远匹配不上。

修复很简单,统一用Number()转换。但这个坑很有代表性,前后端联调时如果字段类型约定不明确,很容易出现“接口返回了数据,但前端逻辑没生效”的诡异情况。同时服务端分片接口也做了幂等处理:同一个chunkIndex重复提交,直接返回成功。有了这层兜底,即使前端跳过逻辑出错,服务端也不会产生重复数据。

7. 插件API设计与运行成效

7.1 对外暴露的接口

改造完成后,我把整套逻辑封装成了一个独立的插件对象,内部依赖WebUploader,但外部调用方完全不感知:

const uploader = new SatelliteUploader({ pick: '#picker', server: '/api/video/chunk', mergeUrl: '/api/video/merge', accept: { title: 'Video', extensions: 'mp4,ts,mkv,avi' }, chunkSize: 5 * 1024 * 1024, threads: 3, onProgress: function (percent) { /* 更新进度条 */ }, onStatus: function (status) { /* 状态:校验/上传/合并/成功/失败 */ } }); uploader.start();

内部细节对外完全屏蔽,调用方不需要理解MD5、分片、断点续传这些概念,拿到插件直接初始化就行。这也方便其他业务线复用,后来平台里其他大文件上传需求都直接套了这个插件。

7.2 实际运行效果与后续扩展

这套插件上线到现在,单文件最大跑过100GB的卫星视频,千兆内网环境下稳定传输速度能达到80MB/s以上,一个多小时传完。出现过两三次传输中断的情况,重新打开页面后都能从断点续传恢复,没有再从头传过。

如果后续你的场景比这个更重,可以考虑两个扩展方向:一是给超大文件增加“分片级校验”,合并后不仅校验大小,还按分片做整体MD5校验,代价是合并耗时增加一点;二是把MD5缓存迁移到IndexedDB,解决localStorage配额问题,对数百GB级别的文件更友好。

最后分享一个个人体会:做这种偏底层的上传组件,千万别把它当黑盒用。WebUploader这类框架给了你一个起点,但真正决定方案能不能撑住场景的,是分片策略怎么设计、服务端台账怎么配合、失败恢复怎么做。把这几个点想清楚,哪怕不用WebUploader,换个组件或者自己写一套,思路也完全一样。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询