尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

WebUploader大文件上传改造:动态分片、断点续传与国产浏览器兼容实践

WebUploader大文件上传改造:动态分片、断点续传与国产浏览器兼容实践 1. 军工内网上传场景的特殊性为什么一套通用上传组件会被打穿接手这个需求的时候我的第一反应不是“怎么实现”而是“为什么不能用现成的东西”。客户的场景很明确航天系统内部卫星下行链路采集到的视频文件需要从各分中心的内网终端上传到中心机房的视频管理系统。单文件动辄 2GB 到 15GB文件数量不多但每个都大得离谱而且断一次就得从头再来。这里要说清楚一个事实看了热搜词里那一堆云存储直传、S3 multipart upload、MinIO 断点续传这些方案在互联网业务里确实香但在军工内网这套环境里基本都跑不起来。原因很直接数据不能出内网服务必须私有化部署传输链路要可控终端环境又是各种国产化浏览器的混搭。你不可能让用户开着浏览器去访问一个公网的 OSS 域名也不大可能在内网服务端随便部署一套高度依赖云厂商 API 的对象存储中转层。WebUploader 是老东西百度开源2021 年之后基本没有实质性更新。但它有一个在军工内网语境下非常值钱的特性它自带了完整的 Runtime 抽象层能在 HTML5 不可用的时候自动降级到 Flash而且整个上传队列、chunked uploads、并发控制机制都是现成的。你要做的不是重写一个上传器而是把它的分片粒度、续传逻辑、兼容策略全部换掉让它适配“超大文件 跨浏览器 断点续传”这三个硬需求。这个改造的核心价值不是让你重新发明轮子而是把一个已经证明可用的轮子凿出适合军工资质环境的胎纹。下面我按实际改造的路径拆开讲每一步都带真实的代码、参数和踩坑记录方便你们在自己项目里直接对照着改。2. 先吃透 WebUploader 的运行机制再决定改哪里2.1 WebUploader 如何实现“队列 分片 并发上传”WebUploader 的本质是一个基于任务队列的文件上传框架。你调用 uploader.addFile(file) 之后文件不会立刻进入传输状态而是先被包装成一个内部的文件对象进入等待队列。然后 WebUploader 会根据你配置的 chunkSize 来决定是否对文件进行切片。分片的核心逻辑在 Runtime 层。HTML5 Runtime 模式下它用的是 Blob.prototype.slice 方法把 File 对象按字节偏移量切成若干个 Blob 片段Flash Runtime 模式下则是通过 Flash 的 FileReference 按同样的偏移读取片段。这两条路径的最终目标是一致的把一个大文件拆成多个独立的 HTTP POST 请求体。每个分片上传时请求头里会带上标准的 HTTP 参数Content-Disposition 里的 filename、分片索引chunk以及分片总数chunks。服务端拿到这些参数后就可以按索引把分片依次落盘等所有分片都上传完成后再执行合并。它的并发控制也很值得说。WebUploader 默认的 fileNumLimit 控制队列中的总文件数fileSizeLimit 控制单文件大小上限而真正影响传输速率的是线程池的概念——它内部维护了一个请求并发数默认值由 uploader.option(threads) 决定常规默认是 3。也就是说同一时刻最多只会有 3 个分片请求在网络上并行传输其余分片都在队列里排队等待。var uploader WebUploader.create({ swf: /static/Uploader.swf, server: /upload/chunk, pick: #picker, chunked: true, chunkSize: 5 * 1024 * 1024, threads: 3, fileNumLimit: 10, fileSizeLimit: 20 * 1024 * 1024 * 1024 });看到 fileSizeLimit 用 20GB 的时候很多人会担心浏览器内存。实际上 WebUploader 在 HTML5 模式下并没有把整个文件读入内存它只持有文件引用按需 slice所以内存占用是可控的。这个问题后面讲分片策略时会细说。2.2 现有组件在超大附件场景下的四个致命短板对我来说直接拿原生 WebUploader 上生产环境有四个问题无论如何都绕不过去。第一没有真正的断点续传。WebUploader 的分片机制保证了“单个分片可以重试”但如果你刷新页面或者关闭浏览器它不会记住哪些分片已经上传成功了。下次打开页面一切归零整个文件从头开始传。这个问题在长达几十分钟的大文件上传中几乎是致命的。第二分片大小不可动态调整。原生配置只支持固定 chunkSize比如你设 5MB那对所有文件都是 5MB。但 500MB 的文件用 5MB 分片没问题15GB 的文件用 5MB 分片意味着要切 3000 多个分片服务端合并压力和网络请求开销都会变得很大。第三对国产浏览器的兼容策略不够细。WebUploader 的降级顺序是“HTML5 优先Flash 兜底”但军工内网环境里常见的奇安信、红莲花、360 企业版这类双核浏览器它的 UA 识别逻辑往往会出错。有的浏览器明明支持 HTML5却被判断成 IE8 进而加载 Flash有的则反过来IE 内核下强行用 HTML5 API导致 slice 方法不可用。第四没有分片校验机制。默认情况下WebUploader 上传分片之后服务端返回的 response 里只包含成功或失败的状态没有每个分片的 MD5 校验。如果传输过程中数据损坏客户端根本不知道最终合并出来的文件大概率是坏的。这四个问题就是整个改造工程的主线。2.3 改造前的评估为什么替换成 vue-simple-uploader 并不划算做技术选型的时候我很认真地对比过 vue-simple-uploader 和 tus-js-client 这类现代方案。vue-simple-uploader 的 UI 组件化程度高支持秒传、断点续传tus 协议的话走的是标准 Resumable Upload 规范服务端实现也有现成库。看起来都比改造 WebUploader 省事。但我最后没有选它们核心原因有三个。一是服务端兼容性。军工项目的后端往往是 JavaSpring Boot或者国产化框架而 WebUploader 的服务端接口格式非常简单粗暴——就是普通的 multipart POST服务端按 chunk 参数落盘即可。tus 协议需要服务端实现 PATCH 方法、Upload-Offset 头、版本协商这些在老旧网管系统里很难推动。二是浏览器降级链路的完整度。vue-simple-uploader 的 HTML5 方案很成熟但它的降级方案依赖类似 vue-simple-uploader 自带的 Flash 支持实际上已经很少有人维护。军工内网里有一定比例的机器还是 Windows 7 IE11 内核的国产浏览器外壳这种情况下 Flash 反而是最可靠的上传通道。三是改造成本的可控性。WebUploader 的 API 设计虽然老但结构简单清晰对它的分片和续传逻辑做手术风险和范围都很好控制。与其重新接入一套全新框架再踩一遍适配的坑不如在现有能力上进行扩展。当然这句话有个前提——你的服务端接口是自己人维护的。如果服务端是完全不可变的老系统那改造 WebUploader 的成本确实不低。后文所有改动我都是基于“客户端重构 服务端配合小改”这个假设来讲的。3. 核心改造一动态分片策略与 MD5 预计算3.1 分片大小的边界计算从带宽、内存、并发数反推分片大小的选择不是拍脑袋定的。军工内网有个特点带宽通常不大很多分中心到机房的专线也就 10Mbps 到 100Mbps而且链路质量未必稳定偶尔会出现几分钟的抖动。如果分片设太小比如 1MB大量的小请求会打满 HTTP 连接且每个请求都有握手和响应开销吞吐率反而下降。如果分片设太大比如 100MB一旦某个分片传输超时重传的代价就会非常大。我的经验公式是单个分片的上传时间控制在 5~15 秒之间并发数乘以分片大小不超过可用带宽的 70%单个文件的分片总数尽量控制在 2000 片以内方便服务端管理举个例子假设内网带宽是 20Mbps也就是约 2.5MB/s 的实际吞吐。并发数设为 3 时如果单分片 5MB3 个并发同时上传理论瞬时吞吐 15MB/s远超带宽上限必然导致排队和超时。所以这时应该把分片调小比如 2MB3 并发约 6MB/s再加上 TCP 重传损耗正好能跑满带宽。我用一个动态计算的函数来处理function calcChunkSize(fileSize, bandwidthMbps) { // bandwidthMbps 从服务端配置接口获取默认取 20 var safeBytesPerSecond bandwidthMbps * 1024 * 1024 * 0.7 / 8; var targetTime 10; // 目标每个分片10秒内传完 var chunkSize Math.floor(safeBytesPerSecond * targetTime); // 边界约束 var minChunk 2 * 1024 * 1024; // 2MB var maxChunk 50 * 1024 * 1024; // 50MB chunkSize Math.max(minChunk, Math.min(chunkSize, maxChunk)); // 超大文件分片总数控制 var maxChunks 2000; if (fileSize / chunkSize maxChunks) { chunkSize Math.ceil(fileSize / maxChunks); } return chunkSize; }文件大小对分片的影响也要考虑。卫星视频文件动辄几个 GB用 2MB 分片会上万分片服务端创建文件句柄和记录分片索引的开销非常大。所以我的策略是文件越大分片越大。2GB 以下用 5MB2GB~10GB 用 10MB10GB 以上用 20MB带宽实在不够的再降到 5MB。3.2 基于文件大小的动态分片算法动态分片的核心是不能在 WebUploader 初始化时一次性定死 chunkSize而要在 addFile 的时候根据文件大小重新计算再动态设置。WebUploader 的坑在于它的 chunkSize 是在 create 时传入并缓存的通过 uploader.option(chunkSize, xxx) 可以在运行时修改但如果你是在一个文件入队之后才修改的并不会影响这个文件的切片逻辑。我的做法是覆写 addFile 的包装流程uploader.on(beforeFileQueued, function(file) { var size file.size; var bw getBandwidthFromServer(); // 同步缓存服务端下发 var dynamicChunk calcChunkSize(size, bw); uploader.option(chunkSize, dynamicChunk); });beforeFileQueued是在文件进入队列之前触发的事件在这里面修改 chunkSize 可以保证本次文件的切片是按照新值来执行的。这种方式实测有效但要注意一点如果你的页面允许用户同时添加多个文件而且这些文件大小差异很大那么队列中前面的文件切片已经被旧值锁定了后面的文件才用新值。要避免这个问题最简单的方式是限制一次只添加一个文件或者干脆在添加前强制清空队列。3.3 大文件 MD5 计算的性能优化分片抽样 增量计算断点续传和秒传都依赖文件指纹。我的方案里用文件的 MD5 作为唯一标识。但对于 10GB 的文件直接在整个文件上算 MD5 是一个非常可怕的操作纯 JS 环境下可能要跑几十秒甚至几分钟用户会以为页面卡死了。我之前见过不少团队在这里栽过跟头——他们把整个文件丢给 SparkMD5 去算页面直接冻住。实际上 WebUploader 社区里老早就有人提出了解决方案分片抽样 增量计算 MD5。具体思路是不扫描全部字节而是按一定间隔取若干片段的字节参与哈希计算。比如一个 10GB 的文件我取文件头 2MB、文件尾 2MB、以及按文件大小均匀分布的 8 个 1MB 片段总共约 12MB 的数据算出一个“抽样指纹”。对断点续传来说这个指纹的碰撞概率极低完全可以作为分片归属的标识。但这里有个细节必须强调如果服务端要做完整文件校验抽样指纹是不够的服务端最终需要在合并完成后对全文件做一次 MD5。抽样指纹只用于“识别这是哪个文件、哪些分片已上传”不做最终完整性判断。增量计算方面WebUploader 队列里每添加一个文件我就先读取抽样片段用 SparkMD5 的 incremental 模式增量更新function calcSampledMD5(file, callback) { var blobSlice File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice; var chunkSize 1024 * 1024; var md5 new SparkMD5.ArrayBuffer(); // 定义抽样点 var samplePoints [0]; var totalChunks Math.ceil(file.size / chunkSize); if (totalChunks 10) { for (var i 1; i 8; i) { samplePoints.push(Math.floor(totalChunks * i / 8) * chunkSize); } // 文件末尾偏移注意要至少留1MB samplePoints.push(Math.max(0, file.size - 2 * 1024 * 1024)); } var current 0; function readNext() { if (current samplePoints.length) { callback(md5.end()); return; } var start samplePoints[current]; var end Math.min(start 2 * 1024 * 1024, file.size); var reader new FileReader(); reader.onload function(e) { md5.append(e.target.result); current; readNext(); }; reader.readAsArrayBuffer(blobSlice.call(file, start, end)); } readNext(); }这段代码在 10GB 文件上采样计算的耗时大概在 1~2 秒用户基本无感知。算完之后文件标识符就是s_ 抽样MD5。服务端拿到标识符去查分片记录能匹配上就返回已接收的分片索引客户端跳过这些分片只传缺失的部分。4. 核心改造二断点续传的本地索引与恢复机制4.1 断点续传逻辑的基本链路断点续传听起来很玄实际拆开就是三条数据链路的配合文件标识抽样 MD5 值用来告诉服务端“我是哪个文件”分片索引每个分片的编号从 0 到 chunks-1已上传状态哪些分片已经完整到达服务端哪些还需要传客户端改造后的续传逻辑是这样的uploader.on(uploadStart, function(file) { var fileId file.sampledMD5; // 1. 查询服务端该文件已接收的分片 $.ajax({ url: /upload/status, method: GET, data: { fileId: fileId }, success: function(res) { var doneChunks res.doneChunks || []; file.doneChunks doneChunks; // 2. 跳过已完成分片 uploader.options.formData { fileId: fileId }; uploader.upload(); } }); });这里的核心技巧是WebUploader 的每一个分片请求都会携带 formData而这些 formData 里如果有chunk和chunks参数服务端会基于这个来识别分片索引。那么怎么让已上传的分片被跳过呢官方并没有提供“跳过分片”的 API但你可以在分片请求发送之前做一个拦截。我用的方法是覆写 WebUploader 内部的 sendRequest 逻辑或者更优雅一点在 uploadProgress 阶段检查当前分片是否在 doneChunks 中如果是则直接调用一个“伪成功”回调。这个方法可以用一个 hack 实现——把该分片的上传文件 Blob 替换为空 Blob然后服务端识别到空内容就立即返回成功客户端于是认为该分片已上传完成。不过这个方法在部分版本里不稳定我的建议是直接在服务端做兜底客户端还是老老实实上传每一个分片但服务端收到 chunk 索引后发现这个分片已经存在并且校验 MD5 一致直接返回成功不重复写盘。这样客户端逻辑简单服务端压力也不大毕竟已经传过的分片不会真的再传一遍网络流量。其实这个方案比我前面那个 hack 更可靠推荐你们直接走服务端去重。4.2 本地记录放哪里localStorage、IndexedDB 还是两者并用断点续传不仅依赖服务端记录本地也要存一份“正在上传的文件列表”用来在页面重新打开时恢复上传任务。WebUploader 的队列生命周期只在当前页面内存里刷新即失所以必须持久化到本地。localStorage 虽然简单但有 5MB 左右的容量限制而且只能存字符串。如果只存文件 ID、文件名、总大小、分片大小、时间戳这些元数据几个文件是没问题的。但问题是一旦你有多个 GB 级文件同时在传localStorage 的同步读写也可能造成主线程卡顿。我最后采用的是localStorage 存索引 约定式键名的方式每个上传任务对应一个 localStorage key命名为wu_task_{fileId}value 是 JSON 字符串内容就是文件的元信息和分片进度。分片进度不需要精确到每个分片因为服务端有准确记录只需要记录“这个文件曾经传过、传到哪了、用的多大分片”这样恢复的时候知道该请求哪个状态接口。function saveTask(file, fileId) { var task { name: file.name, size: file.size, chunkSize: file.currentChunkSize, fileId: fileId, timestamp: Date.now() }; localStorage.setItem(wu_task_ fileId, JSON.stringify(task)); }IndexedDB 更适合记录每个分片的详细状态但它的 API 是异步的而且在小文件场景下读写开销反而不如 localStorage 来得轻。所以我只在需要精细记录每个分片上传结果比如要支持“已在本地确认但服务端未记录”的补交机制时才把分片明细写进 IndexedDB。实测下来对 99% 的超大附件续传需求localStorage 索引 服务端分片状态查询已经足够。4.3 刷新、断电、崩溃后的恢复流程页面刷新后的恢复流程我在代码里是这样组织的function restoreTasks() { var keys []; for (var i 0; i localStorage.length; i) { var key localStorage.key(i); if (key.indexOf(wu_task_) 0) { keys.push(key); } } keys.forEach(function(key) { var task JSON.parse(localStorage.getItem(key)); // 根据fileId回查服务端拿到已完成分片列表 checkAndResume(task); }); } function checkAndResume(task) { var fileId task.fileId; $.ajax({ url: /upload/status, data: { fileId: fileId }, success: function(res) { if (res.complete) { localStorage.removeItem(wu_task_ fileId); return; } // 重建File对象用于续传 var file new File([/*空占位*/], task.name, { type: video/mp4 }); file.size task.size; file.sampledMD5 fileId; // 重新加入队列分片大小沿用原值 uploader.option(chunkSize, task.chunkSize); uploader.addFile(file); } }); }这里有一个 json 必须注意浏览器安全策略下浏览器刷新后无法直接从本地磁盘恢复 File 对象的真实内容引用。也就是说你不能在页面刷新后凭空变出一个用户之前选择的文件。所以在刷新后我的做法是弹窗让用户“重新选择文件”通常用同一个文件名选择之后用新的 File 对象替换掉旧占位对象并且沿用之前记录的分片大小匹配文件标识抽样 MD5 算出来一致从而无缝衔接续传。如果用户重新选择的文件和之前不是同一个MD5 对不上就当作新文件处理避免数据错乱。4.4 秒传与命中逻辑避免重复上传秒传其实是断点续传的一个自然延伸。当服务端查到该 fileId 的任务已经处于 complete 状态客户端就可以直接显示“上传成功”而不再发起任何分片请求。实现上我在uploadStart之前加入一个 judge 校验uploader.on(beforeFileQueued, function(file) { // 先算抽样MD5 calcSampledMD5(file, function(md5) { file.sampledMD5 md5; $.ajax({ url: /upload/exists, data: { fileId: md5 }, success: function(res) { if (res.exists) { // 秒传直接标记为已完成 uploader.skipFile(file); } } }); }); });实际部署中发现秒传的命中率很高——因为卫星视频文件一般是按照固定的采集周期、命名规范生成的很多内容其实高度相似比如同一地区不同时间的遥感视频编码参数一致文件内容完全相同。这在后续运维中省了很多带宽。5. 核心改造三跨浏览器兼容与国产化浏览器适配5.1 军工内网常见的浏览器矩阵军工内网工作终端的浏览器环境比互联网业务复杂得多。我实际调研过的终端浏览器主要有这么几类浏览器内核对HTML5 File API支持对Flash支持备注奇安信浏览器Chromium IE双核极速模式支持完整内置PPAPI Flash默认可能跑在兼容模式红莲花安全浏览器Chromium Trident极速模式支持完整支持安全策略严格会拦截本地文件读写360企业版Chromium Trident极速模式支持完整支持双核切换不稳定原生ChromeBlink完整不支持已禁用互联网环境IE11Trident支持Blob.slice但分片文件大小限制4GB支持大文件有4GB限制必须走Flash国产化Linux浏览器Chromium系完整不支持Flash需走纯HTML5从这张表能看出来真正的兼容难点集中在两块一是双核浏览器不知道什么时候会用 IE 内核来渲染页面这种情况下你必须能探测到并切换二是纯国产化 Linux 终端上彻底没有 Flash你只能依赖 HTML5但还要处理一些非标准 File API 的实现差异。5.2 从 HTML5 切换到 Flash 降级WebUploader 的 Runtime 切换WebUploader 的 Runtime 选择逻辑是优先使用 HTML5如果检测不到 HTML5 支持的 API比如旧版 IE 下没有 FileReader 或 Blob.slice就自动降级到 Flash。但军工内网的特殊之处在于很多双核浏览器在兼容模式下虽然不支持 HTML5但 UA 却伪装得像一个现代浏览器。结果就是 WebUploader 误判为 HTML5 Runtime然后在真正调用 FileReader 时抛出异常上传功能直接挂掉。我的处理方式是自定义 Runtime 探测逻辑不再依赖 WebUploader 的默认判断function detectUploadRuntime() { // 1. 判断当前浏览器是否有完整的HTML5能力 var html5Ready window.File window.FileReader window.Blob (window.Blob.prototype.slice || window.Blob.prototype.mozSlice || window.Blob.prototype.webkitSlice); // 2. 双核浏览器兼容模式下即使UA是Chrome也不一定支持这些API所以直接实测 var testBlob new Blob([test]); var sliceSupport testBlob.slice ? true : false; // 3. 大文件支持检查IE11下File对象有限制超过4GB会截断 var ieLimit /Trident/.test(navigator.userAgent) fileSize 4 * 1024 * 1024 * 1024; if (html5Ready sliceSupport !ieLimit) { return html5; } if (window.Flash document.getElementById(flashContainer)) { return flash; } return null; // 完全没有可用Runtime提示用户更换终端 }这个探测函数我要在页面渲染完成、WebUploader 创建之前执行拿到结果后再配置对应的 Runtimevar runtime detectUploadRuntime(); var opts { swf: /static/Uploader.swf, server: /upload/chunk, pick: #picker, chunked: true, chunkSize: currentChunkSize, threads: 3, runtimeOrder: runtime html5 ? html5 : flash }; var uploader WebUploader.create(opts);这里有个容易被忽略的细节Flash Runtime 模式下分片大小不能设得太小否则 Flash Player 频繁创建二进制流会导致 CPU 占用飙升。我一般会让 Flash 模式下的 chunkSize 至少是 HTML5 模式下的 2 倍比如 HTML5 用 5MBFlash 用 10MB 到 20MB。原因在于 Flash 的 ByteArray 读取是整体拷贝到内存的分片太多、太碎会导致频繁 GC。5.3 国产浏览器兼容的典型坑位坑一奇安信浏览器兼容模式下的 FormData 问题。该浏览器在兼容模式下会把页面当成旧版 IE 渲染FormData 对象不可用。WebUploader 在 HTML5 Runtime 下依赖 FormData 来组装 multipart 请求一旦 FormData 不存在整个请求就发不出去。我的做法是在 detectUploadRuntime 里再加上 FormData 检测if (typeof FormData undefined) { html5Ready false; }坑二红莲花浏览器对本地文件读取的权限限制。红莲花有比较严格的安全策略在默认配置下页面获取 File 对象后FileReader.readAsArrayBuffer 可能被拦截控制台报一个类似“NotAllowedError”的错误。这个问题的解法是引导用户把该站点加入浏览器的可信站点列表或者在页面加载时主动请求“文件访问权限”。实测中让用户切换浏览器到极速模式问题大多能消失。坑三国产 Linux 浏览器缺少 Flash但 WebUploader 的 swf 文件无法加载。在纯国产化终端比如麒麟、统信 UOS上部署时没有 Flash 插件WebUploader 的 Flash Runtime 就完全不可用所以必须保证 HTML5 路径在这种环境下能走通。我遇到过一次某国产 Linux 浏览器的 FileReader 读取 ArrayBuffer 正常但 FormData 构造 multipart 时 Content-Type 设置异常请求发出去服务端收不到文件体。后来排查发现是它的 XHR2 send 方法实现有 bug需要把请求降级为 iframe 提交。这个属于极少见的兼容问题但一旦遇到就是阻塞性的建议在项目启动时就在这些浏览器上跑一个“上传自检页面”。6. 服务端分片接收、校验与合并接口设计6.1 接口清单与数据格式客户端改造完成后服务端需要配合提供这样几个接口接口方法参数返回用途/upload/chunkPOSTfileId, chunk, chunks, file(分片文件), fileName, fileSize, chunkSizeJSON接收单个分片/upload/statusGETfileIdJSON查询某文件已上传分片索引列表/upload/existsGETfileIdJSON秒传判断文件是否已存在且完整/upload/mergePOSTfileId, fileNameJSON通知服务端合并所有分片/upload/cancelPOSTfileIdJSON取消上传清理分片临时目录分片请求的 POST body 使用标准的 multipart/form-data字段名要与客户端 formData 保持一致。fileId 建议用“采样MD5 文件大小”拼接生成例如s_8f14e45fceea167a5a36dedd4bea2543_10737418240这样服务端可以根据 fileId 直接解析出文件大小提前分配空间。6.2 分片校验策略MD5、文件大小、偏移量每个分片到达服务端后只做“完整性校验”不做“全文件校验”。完整性校验包含三个层面一是分片数据大小要与预期一致。客户端计算文件总大小时可能对最后一个分片做特殊处理服务端判断第 chunk 个分片的字节数是否等于 chunkSize最后一个分片除外。如果分片大小不一致直接返回错误码客户端会重新上传。二是分片索引的偏移量要正确。WebUploader 的分片偏移公式是start chunk * chunkSize服务端在落盘时可以检查分片文件的大小是否符合偏移量公式不符合则拒绝写入。三是可选的分片 MD5。这个看性能需求如果服务端资源充足可以让客户端在每个分片上传前计算一个分片 MD5服务端比对后再落盘。我实际跑下来分片 MD5 的额外开销主要是服务端 CPU但对公网弱网环境很值得。军工内网链路相对稳定我做的是“抽样 MD5 服务端比对”一旦发现网络传输导致的文件损坏就重传该分片。服务端落盘时要注意保存两个目录一个临时目录每个分片用{fileId}_{chunkIndex}.part命名一个索引文件或数据库表记录每个 fileId 已收到的分片索引、分片大小、总大小。不要递归扫描临时目录来拼装完成列表那样在几千个分片时效率会非常低。6.3 分片合并策略顺序合并与并发合并的取舍当最后一个分片到达客户端调用 /upload/merge服务端开始合并。顺序合并是最简单的方式从索引 0 开始按顺序把每个分片的字节流追加到最终文件中。如果分片数在一两千顺序合并的耗时大约也就几十秒到几分钟完全可以接受。但顺序合并的问题是如果中途某一两个分片损坏或缺失比如客户端漏传合并过程就会中断需要返回错误码并告诉客户端缺失哪些分片。并发合并更快但合并时需要对最终文件做随机写入必须保存每个分片在最终文件中的偏移量。如果某个分片缺失随机写入的模式可以跳过缺失部分先写后面的最后再补传缺失分片但这会让合并逻辑复杂很多。我的建议偏务实军工内网带宽可控、链路相对稳定分片丢失的概率不高优先用顺序合并但合并过程中一旦发现分片不存在不要中断整个合并流程而是记录缺失索引等所有现有分片合并完后返回缺失列表让客户端补传缺失分片后再次触发 merge。// 伪代码表达合并逻辑 for (int i 0; i totalChunks; i) { File part new File(tmpDir, fileId _ i .part); if (!part.exists()) { missingList.add(i); continue; } FileOutputStream fos new FileOutputStream(finalFile, true); Files.copy(part.toPath(), fos); fos.close(); part.delete(); } if (!missingList.isEmpty()) { return error(MISSING_CHUNKS, missingList); } return success(fileId);合并完成后服务端再对最终文件做一次全量 MD5 校验。前面说过客户端算的是抽样 MD5不能用于最终完整性确认所以这一步必须做。校验通过后文件就算真正上传完成服务端在状态表中把任务标记为 complete。7. 实测结果、参数调优与踩坑记录7.1 真实环境下的实测数据我在一个模拟内网环境里做了压测网络拓扑是千兆局域网但限速到 20Mbps模拟专线服务端是 4 核 8G 的国产化应用服务器客户端分别是 Chrome 104、奇安信浏览器极速模式、IE11Flash 通道。文件大小分片策略并发数耗时是否断点续传说明1.2GB5MB3约16分钟模拟刷新一次刷新后恢复补传约8个分片续传成功6.8GB10MB3约1小时10分模拟断电恢复后补传12个分片续传成功12.5GB20MB2约2小时20分否完整一次上传无中断续传恢复的补传量非常少因为断点续传只重新上传“未确认”的分片。这也验证了抽样 MD5 做文件标识是可靠的——刷新后重新选择的文件能和原先文件匹配上。7.2 调参经验并发数、分片大小、超时时间这些参数在实际环境里需要反复调我给出一个比较稳妥的起点组合线程数默认 3带宽低于 10Mbps 时改成 2高于 50Mbps 时改成 4~5分片大小动态策略2GB 以下 5MB2~10GB 用 10MB10GB 以上 20MBFlash 模式自动翻倍单分片超时我设的是 120 秒。在弱网下 5MB 分片传 2 分钟还没完成基本就是链路有问题继续等没有意义直接重试更高效失败重试次数每个分片重试 3 次超过 3 次标记该分片失败并暂停整个任务等待用户手动恢复。这里不要自动无限重试否则在网络故障时会产生大量无意义的请求另外有一个和 WebUploader 本身相关的调优点文件队列上限。原版 WebUploader 的 fileNumLimit 默认值是 100但军工内网场景下同一时间可能只会有几个超大文件在传不建议用户排一大堆文件进队列因为队列里所有文件的分片都会占用浏览器内存中的索引记录。我把它调成 5并且每个文件上传完成后立即从队列中移除。7.3 印象最深的三个坑第一个坑是Flash Runtime 下分片大小不能太小。我第一次调试时Flash 模式用了和 HTML5 差不多的 5MB 分片结果在 IE11 内核的终端上传了几个分片后浏览器内存暴涨最终崩溃。后来用 10MB 分片并在每次分片上传后主动释放 ByteArray问题才解决。第二个坑是双核浏览器的缓存策略会缓存分片响应。某些国产浏览器在兼容模式下会执行严格缓存导致分片上传请求的服务端响应被浏览器缓存客户端读取响应时永远拿到的是第一次的 success从而误判分片状态。解决方案是在服务端分片响应头中显式加上Cache-Control: no-store并且在请求 URL 上追加一个随机参数。第三个坑最有意思某个国产 Linux 浏览器不支持 XHR2 的 upload.onprogress 事件。WebUploader 在上传超大型文件时依赖 progress 事件来更新进度条如果该事件不触发界面就永远显示 0%。排查到最后发现是这个浏览器在 send() 方法调用后没有正确触发 XMLHttpRequestUpload 的 progress 事件。最后的绕行办法是放弃进度条改用“已上传分片数 / 总分片数”作为进度依据——反正分片数在超大文件场景下足够多显示出来完全能接受。最后说一个我自己的处理习惯所有涉及大文件上传的改动我都会维护一个本地的“分片模拟器”。它可以在不依赖真实服务端的情况下模拟分片丢失、超时、响应乱序、重复请求等场景跑一遍改造后的上传逻辑。很多断点续传的 bug 不是出现在正常路径上而是出现在异常恢复路径上。没有这个模拟器你很难在真实环境里反复复现那些半小时才出现一次的偶发问题。我对这个改造项目的整体判断是WebUploader 确实老了但它作为一套基础框架的价值还在。用动态分片、服务端去重续传、多 Runtime 探测这几层手术去补全它的短板比从零搭建一套上传系统要稳妥得多。军工内网的场景决定了稳定性和可控性优先于技术的新旧而这两点恰恰是改造后的 WebUploader 能提供的。
返回列表