WebUploader深度改造:卫星链路超大视频分片断点续传实践

发布时间:2026/9/16 6:34:50
WebUploader深度改造:卫星链路超大视频分片断点续传实践 做军工行业的技术交付很多东西不能拿到台面上细说但“卫星视频上传”这个场景本身很典型动辄几十个GB的mp4、ts原始文件终端在边远现场网络是卫星链路浏览器环境五花八门。这种前提下前端上传组件选型空间非常小云厂家的上传SDK不能用私有化部署又要求代码可控最后绕了一圈还是回到百度开源的WebUploader上。这个老组件确实有些年头了但它的分片上传、断点续传、多实例并发这套骨架设计得很扎实。我基于它做了一层深度改造把它从“普通Web上传工具”变成了面向军工卫星视频场景的“跨浏览器超大附件分片断点续传插件”。这篇文章把整个改造过程拆给你看包括为什么这么设计、代码怎么落、卫星链路上最容易踩的坑。内容偏实战适合正在搞大文件上传、尤其是网络环境不稳定的私有化项目的朋友参考。1. 为什么一定要基于WebUploader改造而不是新写一套先说结论不是WebUploader有多完美而是它的核心机制加上开源可控的属性最适合军工这类高度封闭、不能依赖外部服务的场景。1.1 WebUploader的生命力与短板WebUploader是百度在前端上传领域留下的一个经典作品2016年前后逐渐停止高频维护。它的核心设计放到今天依然不过时把文件切成固定大小的分片每个分片独立形成HTTP请求上传前用MD5做完整性校验并通过“服务端记录已上传分片编号”的方式实现断点续传。这套机制对应到超大文件场景优势非常明显。普通一把梭提交要么受限于后端接收体量要么在弱网下随便一断就前功尽弃。而WebUploader天然把大文件拆成了一个个小任务哪个分片失败就重传哪个分片用户体验和心理预期都完全不一样。短板也客观存在。官方版本默认针对的是普通互联网环境放到卫星链路上会出现几个致命问题第一默认并发数偏激进卫星链路带宽窄、延迟大并发一上去直接把链路打满甚至触发拥塞第二失败重试策略是粗略的没有指数退避弱网下会陷入“无限快速重试”的循环第三没有针对超大文件的MD5计算做优化几十GB的文件在前端一次性算MD5轻则卡死页面重则浏览器直接崩掉。所以与其说我在“用”WebUploader不如说是在“拆”它保留它的分片队列与状态管理骨架把传输策略、断点续传协议、安全性这几层全部换掉。1.2 军工卫星视频场景的特殊约束军工行业做卫星视频上传和互联网公司做视频点播完全不是一码事。这个场景有四个特殊约束直接决定了技术改造方向。第一是文件体量极端。卫星拍摄的原始视频单文件从几个GB到几十个GB都很正常有的甚至超过100GB。这么大的文件首先要考虑的不是“能不能传”而是“怎么让上传过程可观测、可恢复”。第二是链路质量不稳定。卫星通信的典型特征是带宽有限、RTT高、抖动大。光纤链路上几百毫秒的超时在卫星链路上可能只是正常波动。再加上现场可能有雨衰、遮挡链路中断是常态而不是异常。第三是浏览器环境不可控。现场终端有Windows 7、Windows 10也有国产化操作系统浏览器可能是Chrome、Edge、360、奇安信、红莲花等各类内核。插件方案必须做到“一套代码兼容多个浏览器”。第四是安全要求极高。视频数据本身敏感上传过程不能直接明文裸传要用国密算法做完整性校验和加密所有操作需要留痕审计整个系统必须私有化部署不能调用任何外部云服务。这四个约束叠加在一起基本排除了所有商业上传组件和云上传SDK。剩下的可选项里WebUploader这种开源的、底子干净的方案反而是最优解。改造它不涉及版权纠纷不做黑盒依赖一切逻辑可审计。2. 整体架构设计与思路拆解改造的第一步不是写代码而是把整体架构理清楚。这个插件不是孤立的“前端控件”它是上传系统的一个组成部分。2.1 上传链路的整体拓扑整个上传系统分为三层浏览器终端层、接入服务层、存储层。浏览器终端层就是我们要改造的插件。它负责文件选择、分片切片、MD5计算、并发控制、断点续传状态管理、UI交互。终端层不直接写存储所有分片通过HTTP POST上传到接入服务层。接入服务层是一组对外的Web服务接口通常部署在内网或专网环境中部署形态可以是单机也可以是集群。它主要负责接收分片数据、落盘到临时目录、记录分片元数据信息文件名、分片索引、分片大小、MD5值、上传会话ID等、查询已上传分片状态、合并分片生成最终文件。存储层是最终的文件落地位置有可能是分布式存储也有可能是传统NAS/磁盘阵列。军工环境下比较常见的是那种“集中存储备份”的结构这里不做过多讨论重点在接口层。这个拓扑里最核心的一条原则是前端不直接接触存储路径所有路径映射都在接入服务层完成。原因很简单安全隔离和权限控制必须集中在服务端前端只传数据不过问业务存储细节。2.2 断点续传的核心协议设计断点续传要回答三个问题这次上传是谁已经传了哪些分片接下来从哪开始对应到协议设计上就是“会话ID 分片状态查询 跳过已传分片”的闭环。第一步选择文件后前端根据文件名、文件大小、最后修改时间、分片大小这几个维度生成一个唯一的指纹字符串再基于这个指纹生成会话ID。会话ID的意义在于同一文件在不同的时间、不同的会话中如果指纹一致就认为它们是同一个上传任务。第二步前端拿着会话ID去服务端查“分片状态”。服务端返回一个JSON数组或者位图结构标记哪些分片已经持久化成功。这里有一个性能细节如果文件非常大几百上千个分片用数组一个个查效率很低。我实际改造时用的是“String数组Set索引”的方式服务端把已上传分片索引放进一个Set查询时直接Hash判断。第三步前端根据服务端返回的状态把未上传的分片加入上传队列已经上传过的直接跳过。这是断点续传的关键所在——不是断点“继续传”而是断点“跳过已传的”。2.3 前后端交互流程整个交互流程可以归纳成下面这条链路选择文件 → 计算指纹 → 请求创建会话/校验历史 → 获取已上传分片列表 → 分片切片 → 循环上传未传分片 → 所有分片完成后通知服务端合并 → 服务端校验完整性 → 返回最终结果。这里面每一个节点都有对应的回调接口。改造的核心工作之一就是把WebUploader默认的“闷头传”模式改造成“先探路再走”的模式。所谓“探路”就是在上传之前先发起一次会话预检请求。这个请求在实现上是一个简单的HTTP GET接口例如 /upload/status参数带上会话ID服务端返回{ code: 0, data: { sessionId: aaa-bbb-ccc, uploadedChunks: [0, 1, 2, 5, 6, 9], chunkSize: 1048576, totalChunks: 128, filePath: /data/video/xxx } }前端拿到 uploadedChunks 之后直接把它转成一个Set在WebUploader的分片入队逻辑里过滤掉这些分片索引。这一步做扎实了断点续传就完成了一大半。3. 跨浏览器兼容与内核适配跨浏览器是这个项目里最容易出问题的环节不是功能实现难度大而是“你不知道用户现场到底用的什么浏览器”。3.1 能力检测与功能降级传统的前端兼容思路是看浏览器的userAgent字符串但这种做法在军工现场经常失灵。国产化浏览器很喜欢伪装内核有的把Chrome的UA改得面目全非有的干脆UA字符串都能乱写。所以我的做法是能力检测优先UA只做辅助。能力检测的核心是三个点是否支持File API能拿到File对象。是否支持Blob.slice或File.prototype.slice方法这决定了能不能把大文件切成多个分片。是否支持XMLHttpRequest的send方法直接传Blob数据。这三个能力同时具备就走HTML5现代上传通道。如果不支持就考虑降级方案。早期的WebUploader会切到Flash上传但Flash这个技术本身已经被浏览器厂商放弃了军工内网还有很多终端是老系统可这类环境也不该再依赖Flash。我改造时直接把Flash逻辑完全去掉遇到实在没法支持HTML5的浏览器就明确提示用户“当前浏览器不支持大文件分片上传请使用Chrome/Edge/奇安信/红莲花等现代内核浏览器”。这种“明确拒绝”比“强行兼容”更稳妥。原因很简单在军工项目里系统稳定性是第一位的与其用一套妥协的技术方案带来后续无穷无尽的问题不如给出准入门槛。3.2 国产化浏览器环境下的一些细节在真实项目中国产化终端比例不低常见组合是“统信UOS 奇安信浏览器”或者“麒麟OS 红莲花浏览器”。这些浏览器底层都是Chromium内核整体能力与Chrome一致所以HTML5分片上传本身没问题。真正的坑出现在一些细节上。第一个坑是macOS或者Linux环境下的“文件路径处理”。军工终端大量使用Linux系国产系统前端不能依赖取文件完整路径因为浏览器出于安全策略返回的都是C:\fakepath或者统一的文件对象。正确做法是只使用File对象的name、size、lastModified这些属性绝不依赖真实路径。第二个坑是证书问题。内网环境经常是自签名HTTPS证书浏览器默认阻止。WebUploader请求全部走XHR一旦TLS证书不受信任所有请求都会失败。这个问题的处理方式通常是在现场终端上预装根证书或者在部署文档里明确要求使用受信任的证书链。这个工作应该在交付前完成不能等用户反馈。第三个坑是大并发文件描述符限制。国产浏览器在某些终端配置下默认的文件描述符或者并发连接数限制比较严格。如果WebUploader默认的并发数设置过高会出现“前几个分片成功、后面的全部排队失败”的情况。解决办法是把默认并发调到合理值一般卫星链路建议并发数控制在3以内后续我会细说。4. 超大视频分片上传的核心改造核心改造部分我按“参数、策略、性能”三个维度展开这一块最能看到改造前后的差距。4.1 分片大小与并发数的选择分片大小直接决定两个东西分片数量和失败重传的粒度。分片越小失败重传的代价越低但分片数量越多服务端元数据压力越大总请求次数也越多。反过来说分片越大请求次数越少但一旦失败重传的代价就高而且在卫星链路上单个大分片传输时间长、中途被中断的概率也更大。我在实际项目中测过一个经验区间普通内网环境下分片大小设在2MB到8MB之间体验比较好。但在卫星链路上我倾向于设成1MB到2MB。原因很简单卫星链路带宽一般只有几兆甚至几百Kbps一个4MB的分片可能要传几十秒这期间链路抖动导致分片传输中断的概率会显著增加。如果切成1MB单分片传输时间控制在三四秒左右即使重传代价也可控。并发数的选择更讲究。WebUploader默认的并发数是3这个值在办公室宽带下没任何问题。但在卫星链路上如果把并发开到3每个请求都在争抢本就有限的带宽结果就是所有分片都变慢整体吞吐反而下降。我在实际项目中把并发数设为1到2更极端的情况下直接串行传输。最重要的一点是上传过程中要动态观察分片平均耗时。如果发现单个分片耗时异常增加说明带宽已经被占满这时候程序会自动暂停一个并发槽位。我用一个简单的“滑动窗口自愈”策略来实现最近20个分片的平均耗时超过预设阈值就把并发数降1连续10个分片都很快就把并发数升回原来的值。// 并发自适应核心逻辑简化版 let currentThreads 2; let recentChunkCosts []; function pushCost(costMs) { recentChunkCosts.push(costMs); if (recentChunkCosts.length 20) recentChunkCosts.shift(); if (recentChunkCosts.length 20) { const avg recentChunkCosts.reduce((a, b) a b) / 20; if (avg 15000 currentThreads 1) { currentThreads--; uploader.options.threads currentThreads; } else if (avg 8000 currentThreads 3) { currentThreads; uploader.options.threads currentThreads; } } }这个策略的思路其实很朴素就是让插件自己去适应链路状态而不是让用户手动调参数。4.2 卫星链路的可靠传输策略卫星链路最大的敌人是“波动”。今天带宽可能有几十Mbps明天可能只有几百Kbps这一分钟延迟300毫秒下一分钟延迟800毫秒都是正常的。要在这条链路上做超大文件上传不能只用一套固定的重试策略。我改造后的重试机制包含三层。第一层是分片级超时控制。正常互联网上传单分片30秒超时可以接受卫星链路下我把超时时间放宽到60到120秒。这样做的原因是卫星链路的RTT本来就高TCP的拥塞窗口建立需要时间分片传输过程中还要算上服务端写盘的时间如果超时设得太短大量请求会被误判为失败。第二层是失败重试与指数退避。单个分片失败后第一次隔2秒重试第二次隔4秒第三次隔8秒最多重试5次。如果第5次仍然失败就把整个上传任务暂停等待用户手动点击恢复或者等待程序检测到链路恢复后自动拉起。这里的封顶次数很重要不然弱网环境下插件会一直重试下去既消耗带宽又消耗电量和日志空间。第三层是链路保障辅助。我还在服务端加了一个“续传窗口”的机制分片上传接口在同一会话ID下3分钟内没有新的分片上传请求服务端会自动锁定这个会话防止后续分片写入。前端每次上传分片时带上会话ID如果服务端返回“会话已锁定”的状态前端就会自动触发一次“会话激活”请求重新解锁。4.3 MD5计算优化大文件上传必须要做完整性校验但几十个GB的文件用普通方式在前端算MD5真能把浏览器卡死。我改造时做了两个层面的优化。第一个优化是“分片增量MD5”说白了就是不对整个文件一次性计算哈希而是每个分片计算自己的MD5服务端合并之后再对所有分片MD5做一个聚合校验。这样做的好处是计算和上传能流水线化边上传边计算避免了上传前的长时间等待。第二个优化是引入Web Worker来做MD5计算。Web Worker是浏览器里独立于主线程的子线程把MD5计算丢给Worker后主线程的UI再也不会卡死。WebUploader默认的MD5处理是直接在主线程里干的我改造时把spark-md5这类算法库封装进Worker里跑效果立竿见影。// 在Web Worker中计算分片MD5的核心逻辑简化示例 self.onmessage function (e) { const { file, chunkSize, startByte, endByte } e.data; const blob file.slice(startByte, endByte); const reader new FileReader(); reader.onload function (event) { const arrayBuffer event.target.result; const spark new SparkMD5.ArrayBuffer(); spark.append(arrayBuffer); const md5 spark.end(); self.postMessage({ startByte, endByte, md5 }); }; reader.readAsArrayBuffer(blob); };改造后的启动流程变成了先显示“文件解析中”的提示同时后台Worker逐个分片计算MD5缓存等用户点击开始上传时MD5已经算完一大半直接复用缓存结果不重复计算。这个改动对体验提升特别明显。5. 军工场景的安全与合规改造军工场景下做开发功能只是底线安全合规才是真正的硬指标。5.1 摘要算法与加密WebUploader默认用MD5做文件校验这在军工场景里不够看。虽然MD5作为完整性校验算法本身问题不大但在合规审计的角度国密算法是更稳妥的选择。我在改造时把分片校验从MD5切到了SM3。SM3是国密哈希算法摘要长度256位安全性上比MD5要高一档。完整链路是这样的前端用SM3计算每个分片的摘要上传时把摘要放在HTTP请求头里服务端收到分片后同样计算SM3两个摘要一致说明分片传输完整才会落盘并记录元数据。合并后的全文件校验也优先使用SM3而不是MD5。在加密方面分片内容本身不要求一定在前端加密——因为军工内网环境下有链路加密设备而且在浏览器里做全量加密会拖慢上传速度。但是如果文件涉密等级较高、链路加密能力不足就必须在分片写入前做国密SM4加密。这个改造不复杂但在服务端落盘和解密时需要配合密钥管理系统实现复杂度会明显上升。我这里给出的建议是先确认链路上是否有独立的加密设备如果有前端不做内容加密如果没有再启用SM4加密方案。5.2 审计留痕军工项目里的“审计”不是一个附加功能而是硬性要求。每一次上传操作都要能回答这几个问题谁上传的从哪个IP上传的上传了什么文件文件哈希是多少分片到达时间线是什么样的我做的审计方案分成两步。第一步前端插件在每次上传分片时都会在请求头里带上用户身份标识从单点登录系统或者统一身份认证中获取服务端拒绝没有合法身份标识的请求。第二步服务端记录一张完整的分片上传日志表包含会话ID、文件名、分片索引、分片大小、SM3摘要、上传时间、用户标识、客户端IP、浏览器UA。所有日志只允许追加写入不允许删除和篡改。这一步看起来不起眼但真到了项目验收或者问题追溯阶段这些日志就是唯一的凭据。我有一次排查“上传成功但文件打不开”的问题全靠分片日志定位到是某个分片在写入临时目录时发生部分损坏最终靠日志里的SM3摘要锁定了具体分片快速解决了问题。6. 完整实现代码示例前面讲的都是设计思路下面落到代码。我只把核心链路代码列出来完整工程涉及很多业务封装没法全部展开。6.1 前端插件封装基于WebUploader做一层插件封装对外暴露一个简单的调用接口class SatelliteUploader { constructor(options) { this.options Object.assign({ chunkSize: 1 * 1024 * 1024, // 1MB分片 threads: 2, // 卫星链路默认并发数 serverBase: /api, sessionId: , fileFingerprint: , retryTimes: 5, }, options); this.init(); } init() { const me this; me.uploader WebUploader.create({ auto: false, // 不自动上传手动控制 chunked: true, chunkSize: me.options.chunkSize, threads: me.options.threads, server: me.options.serverBase /upload/chunk, formData: { sessionId: me.options.sessionId, }, accept: { title: Video, extensions: mp4,mov,avi,ts,mxf, }, }); // 最关键的一步在每次发送分片前先查询服务端已接收分片状态 me.uploader.on(before-send, function (block) { return me.checkRemoteChunkStatus(block).then(function (isUploaded) { if (isUploaded) { return false; // 返回false跳过当前分片 } return true; }); }); // 分片发送完成后请求头加上当前分片摘要 me.uploader.on(uploadBeforeSend, function (object, data, headers) { headers[X-Chunk-MD5] object.chunkMd5 || ; headers[X-Chunk-Index] object.chunkIndex || ; headers[X-Session-Id] me.options.sessionId; }); } // 查询服务端已接收的分片状态 checkRemoteChunkStatus(block) { const me this; return new Promise(function (resolve, reject) { $.ajax({ url: me.options.serverBase /upload/status, type: GET, data: { sessionId: me.options.sessionId, fileName: me.options.fileFingerprint, }, success: function (resp) { const uploadedSet new Set(resp.data.uploadedChunks || []); resolve(uploadedSet.has(block.chunk.index)); }, error: function () { reject(new Error(查询分片状态失败)); }, }); }); } start() { this.uploader.upload(); } }这段代码的骨架不复杂核心就在before-send事件里做了分片状态过滤。WebUploader对每个分片在发送前都会触发这个事件我们通过异步查询服务端状态如果当前分片已经传过就跳过这比“服务端拒绝上传并返回状态、前端再处理”的方式更节省链路开销。同样的上传前要对会话进行初始化async function initUploadSession(file) { const fingerprint md5(file.name file.size file.lastModified); const resp await fetch(/api/upload/init, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileName: file.name, fileSize: file.size, fingerprint: fingerprint, chunkSize: 1 * 1024 * 1024, }), }); const data await resp.json(); return data.sessionId; }指纹用MD5就够了因为它的目的不是防篡改而是判断文件是否为同一个。真正常见的做法是“文件名 大小 最后修改时间”三段拼接后计算摘要这样一个文件换台终端再传指纹也不会变。6.2 服务端配合实现服务端我是基于Node.jsKoa写的用两个接口就能讲清楚逻辑。第一个是初始化会话接口// POST /api/upload/init async function initSession(ctx) { const { fileName, fileSize, fingerprint, chunkSize } ctx.request.body; const sessionId crypto.createHash(md5).update(fingerprint Date.now()).digest(hex); const session { sessionId, fileName, fileSize, chunkSize, uploadedChunks: new Set(), // 已上传分片索引集合 totalChunks: Math.ceil(fileSize / chunkSize), createTime: Date.now(), }; // 如果根据指纹找到历史会话直接复用历史状态 const exists await db.findByFingerprint(fingerprint); if (exists exists.fileSize fileSize) { return ctx.body { code: 0, data: { sessionId: exists.sessionId, uploadedChunks: Array.from(exists.uploadedChunks) } }; } await db.saveSession(session); ctx.body { code: 0, data: { sessionId, uploadedChunks: [] } }; }第二个是分片上传接口。每个分片到达后服务端先校验会话是否存在、分片索引是否合法、分片是否已存在再落盘// POST /api/upload/chunkContent-Type为multipart/form-data async function uploadChunk(ctx) { const { sessionId, index } ctx.request.body; const chunk ctx.request.files.chunk; const session await db.getSession(sessionId); if (!session) { ctx.status 400; ctx.body { code: 40001, msg: 会话不存在或已过期 }; return; } if (session.uploadedChunks.has(Number(index))) { ctx.body { code: 0, msg: 分片已存在无需重复上传 }; return; } // 落盘临时目录 const tmpDir path.join(config.tempDir, sessionId); await mkdir(tmpDir); const chunkPath path.join(tmpDir, chunk_${index}); await fs.rename(chunk.path, chunkPath); // 记录分片元数据 const chunkMd5 await calcSm3(chunkPath); // 使用国密SM3 session.uploadedChunks.add(Number(index)); session.chunkMd5Map.set(Number(index), chunkMd5); await db.updateSession(session); ctx.body { code: 0, data: { index: Number(index), md5: chunkMd5 } }; }最后一个接口是合并分片// POST /api/upload/complete async function completeUpload(ctx) { const { sessionId } ctx.request.body; const session await db.getSession(sessionId); // 校验所有分片是否齐全 const expected session.totalChunks; if (session.uploadedChunks.size ! expected) { ctx.status 400; ctx.body { code: 40002, msg: 分片不完整已上传 ${session.uploadedChunks.size}/${expected} }; return; } // 按索引顺序合并分片 const finalPath path.join(config.finalDir, session.fileName); const ws fs.createWriteStream(finalPath); for (let i 0; i expected; i) { const chunkPath path.join(config.tempDir, sessionId, chunk_${i}); ws.write(fs.readFileSync(chunkPath)); } ws.end(() { // 计算完整文件SM3并清理临时目录 const finalSm3 calcSm3(finalPath); db.markUploaded(session.id, finalSm3); ctx.body { code: 0, data: { filePath: finalPath, sm3: finalSm3 } }; }); }这几个接口组合起来就是断点续传的服务端闭环。实际项目中还应该在合并后校验一下文件大小防止某一分片在传输过程被篡改或损坏。有一个细节要特别注意合并分片时一定要按索引顺序“追加”式写入不能乱序否则某些视频格式会出现花屏或无法解码。7. 常见问题与排查技巧实录这个项目里我在现场遇过很多问题挑几个最有代表性的写出来希望能帮你少走弯路。7.1 现象速查表现象可能原因处理要点上传永远停在0%分片状态查询接口跨域失败检查所有上传相关接口是否配置了CORS或同域代理上传到一半全部失败服务端临时目录磁盘满了检查tempDir所在分区的磁盘空间临时目录和最终目录不要放同一块盘刷新页面后断点续传失效前端会话ID没持久化用localStorage或IndexedDB保存会话ID而不是存在JS变量里单个分片反复失败卫星链路丢包严重超时时间过短把分片超时时间调到120秒以上启用指数退避合并后的视频无法播放分片合并顺序混乱或部分分片损坏检查合并逻辑是否按索引顺序写入检查每个分片的SM3是否与服务端记录一致国产浏览器UA奇奇怪怪浏览器伪装内核字符串不要依赖UA判断只做能力检测7.2 卫星网络下的高频问题卫星网络下最让人头疼的其实不是失败而是“假失败”。分片明明已经传到服务端了但客户端因为长时间没有收到响应超时判定了失败。这种时候如果客户端直接重传服务端就需要做去重处理。我处理这个问题的办法是在服务端做分片幂等。同一会话ID下、同一分片索引第二次上传时服务端直接返回“分片已存在”的响应不重复写盘。这就保证了即使客户端因为超时误判发起重传服务端也不会产生脏数据。另一个问题是“断网重连后上传队列卡住”。卫星链路恢复后TCP连接会自动重建但如果WebUploader当时正卡在一个分片的pending状态上传队列可能不会自动恢复。我改造时给上传队列加了一个守护逻辑每10秒检查一次当前是否有“卡住超过120秒”的分片请求如果有主动终止该请求并触发重试。setInterval(() { // 遍历WebUploader内部请求队列 uploader.getStats().pending.forEach((block) { const elapsed Date.now() - block.startTime; if (elapsed 120 * 1000) { // 主动把该分片踢回队列尾部重新上传 uploader.retry(block); } }); }, 10000);7.3 MD5和SM3使用过程中踩过的坑最终做完整性校验时我一开始用spark-md5在主线程算文件的完整MD520GB文件算了将近十分钟浏览器标签页直接无响应最后用户强制关闭了整个浏览器上传任务彻底丢失。后来改用Worker分片流式计算才算把“算MD5阶段页面假死”这个问题压下来。另一个容易被忽视的坑是文件对象兼容性。在部分老版本浏览器中File对象没有lastModified属性取到的值是undefined。我用file.lastModified || file.lastModifiedDate?.getTime?.() || 0兼容处理不然指纹会不一致刷新页面后断点续传就会失效。8. 一些更深层的思考这个项目做下来我对“技术选型”这件事有了完全不同的理解。很多人觉得WebUploader已经过时了但真正到极端环境下反而是这种底子干净、代码开源、没有外部依赖的老框架更能扛事。军工行业的视频上传场景本质上是在跟“不稳定”作斗争。文件太大了链路太差了环境太受限了。这时候考验的不是某个框架新不新而是你对文件上传的核心机制理解得深不深。分片的思想不变断点续传的思想不变变的只是实现策略和防护等级。我个人在实际交付中还有一个体会越是这种看似“不潮”的技术改造越能拉开有经验和没经验的人之间的距离。懂得把并发数从3调到1懂得给超时时间加上退避懂得在研发阶段就考虑SM3和审计日志这些细节单独拿出来都不起眼但整条链路串起来就是一套能抗住恶劣现场环境的生产系统。最后再分享一个小技巧WebUploader改造完成后别急着上线。找一台性能最差的终端、网络最差的链路先挂一个10GB的文件连续传几天观察中间各种异常场景的恢复情况。模拟“断网重连”“服务端重启”“磁盘写满”三种故障一套一套跑。这种极限测试跑完现场问题就会少很多。