拓冰建站拓冰建站
首页 / 资讯中心 / 正文

WebUploader二次开发:超大文件分片上传与断点续传实践

前一阵处理一个安全保密要求极高的内部系统需求网页端要上传几个GB甚至十几GB的卫星视频文件。第一版直接用了原生 WebUploader上线后很快被用户投诉——4.7GB的视频传到83%网络一抖刷新页面从头再来。那次之后我用 JS 对 WebUploader 做了整轮改造把它重写成一个支持跨浏览器、超大附件、分片断点续传的上传插件。这篇就整理整个改造过程的选型理由、参数计算、协议设计和踩坑记录给同样要在大文件上传场景里做定制开发的同学一个参考。这不是一篇WebUploader官方文档的翻译。我默认你已经知道WebUploader是什么、能做什么。真正有价值的是后面这些原生组件到底在哪些地方扛不住、我把分片和续传改成什么样、跨浏览器和涉密内网环境里有什么隐藏限制以及最后那几个折腾到半夜的坑。1. 卫星视频这类超大文件为什么原生WebUploader扛不住1.1 需求场景文件大、链路差、要求高卫星视频单条文件通常在2GB以上我实际项目里见过最大的到16GB。这类文件放到公网场景绕过大网站长限制通常靠分块上传。但在单位内部的专网/内网环境下问题恰好反过来——带宽虽然不小链路质量却非常随机网络抖动、代理拦截、中间设备的连接超时都很常见。业务方对上传还有一个硬性要求过程可追溯文件不丢传完能直接用。这几个条件叠在一起普通的“选文件-点击上传-等成功”模式根本走不通。第一版用WebUploader其实是合理的选型因为它基础能力齐全。但只用原生配置而不做二次开发在超大文件场景下就是给自己埋雷。后续我们实际跑起来才发现它所谓的“分片上传”能解决问题的一半另一半要靠外部补。1.2 WebUploader的底子为什么还要选它当底座当时也纠结过其他方案比如Resumable.js、Plupload或者完全自己写XHR上传。最后仍然以WebUploader为底座有几个现实原因自带 HTML5 Flash 双通道降级虽然Flash已经淘汰但它的架构里对“不同浏览器环境”是有预案的分片、并发、队列、进度、MD5计算这些基础能力都内置不用从零开始Widget机制和事件机制设计得清晰fileQueued、uploadProgress、uploadAccept这些钩子足够多做二次开发空间很大。我最后是在WebUploader的HTML5 Runtime基础上改造而不是换框架就是因为它的基建还在改造成本比重写一个上传组件低得多。1.3 原生组件在超大文件面前的四个死穴这些死穴是用了几天之后才暴露出来的文档里基本不会写分片状态只存在内存里。WebUploader的分片队列寄生在Uploader实例中页面一刷新不管传到百分之多少全部丢光。它所谓的“续传”是同一个页面会话内失败后的重试不是跨会话断点续传。chunkSize全局写死。一个全局值不随文件大小变化。有人传2GB文件用1MB分片切出2000多个分片服务端合并时分片过多性能很差。MD5计算是全量文件计算。官方MD5插件用FileReader把整个文件读进内存再算哈希。几十MB文件没问题几个GB文件轻则卡几十秒重则内存爆掉。没有服务端校验闭环。前端认为“上传成功”但它无法保证服务端把分片全部收到并且完整合并。内网环境一旦某个分片在传输中被中间设备截断最终合并出来就是一个损坏文件。1.4 一个让我下决心动手的现场事故印象很深有个用户反馈4.7GB的卫星影像传到83%时内网某个防火墙策略调整导致连接被重置。用户刷新页面重新选文件一切从头再来。三个小时后他打电话说“再也不传了”。那次之后我给的方案就是组件必须改否则这功能没法交差。2. 分片机制的二次设计切片大小、并发数与任务队列的取舍2.1 切片大小不能一把尺子量到底原生WebUploader默认的chunkSize是2MB这个值在早期网络环境下问题不大。但在内网千兆甚至万兆环境下2MB分片太多每个分片一次HTTP往返的协议开销和磁盘IO都会被放大。我改造时用了一个按文件大小动态计算切片大小的函数function getChunkSize(fileSize) { const MB 1024 * 1024; if (fileSize 1 * 1024 * MB) return 4 * MB; if (fileSize 5 * 1024 * MB) return 8 * MB; if (fileSize 10 * 1024 * MB) return 16 * MB; return 32 * MB; }这个参数传给WebUploader的chunkSize。设计原则是文件越大单分片越大分片总数控制在1000个以内。服务端合并时按chunkIndex顺序读取写入分片太碎会造成严重的随机IO。文件大小分片大小分片数估算1GB4MB≤2561GB-5GB8MB128~6405GB-10GB16MB320~64010GB32MB≤512还有一个隐藏限制很多nginx、网关设备对单次POST请求体大小有默认限制常见值10MB或20MB。分片设为32MB但链路里最小限制是10MB时请求会直接失败。所以上线前我特意检查了整条链路上所有代理的client_max_body_size把分片大小取到小于等于链路最小值。2.2 并发数不是越大越快也不是越小越稳WebUploader的threads参数控制同时上传的分片并发数默认是3。我一开始调到10结果反而变慢。原因并不复杂浏览器对同一域名的并发连接数有上限HTTP/1.1规范建议2Chrome实际放宽到6超出的连接在浏览器层排队并没有真正并行。后面我给前端加了能力检测根据浏览器能力动态计算并发数。支持HTTP/2的浏览器可以开到6老浏览器就保持3。如果服务端磁盘是机械盘并发也建议不要超过3因为多个分片同时写入会加剧随机写性能会明显下降。2.3 把“上传一个文件”拆成“上传一组分片任务”改造的核心思路把文件级队列转化为分片级独立任务。原来WebUploader队列的最小单元是file一个文件失败重试会把整个文件重新来一遍。我改造后每个分片都有自己的状态失败后只重试该分片最多3次。代码上用Map存分片状态const chunkStatus new Map(); // key: chunkIndex, value: pending | uploading | done | failed本质上就是把每个分片当成一个小文件来管理。文件上传总进度按字节加权计算已成功分片的总字节数 / 文件总字节数。不使用原生组件返回的文件级进度否则断点续传后进度会失真。2.4 切片的正确姿势利用Blob.slice不做全量内存读取这是最容易踩的地方。很多人第一反应是FileReader读整个文件生成ArrayBuffer再按字节切分。几个GB的文件这么干浏览器必崩。正确做法是用Blob的slice方法让浏览器底层去实现零拷贝切片const blob file.slice(start, end);改造时还要注意一个坑不要把所有分片Blob装进数组长期持有。引用不释放浏览器就不会回收内存。需要哪个分片就从原始File对象上临时slice一个出来上传结束后引用随请求结束自动释放。3. 断点续传的地基文件指纹、分片状态表与服务端校验闭环3.1 文件指纹全量MD5慢但不能完全不用先说结论超大文件不建议做全量MD5。我第一次用官方MD5插件算5GB文件肉眼可见卡了二三十秒期间页面无法操作。但完全不做指纹也不行续传和秒传都要靠文件唯一标识去匹配服务端记录。我的做法是分片级MD5加文件元数据的组合先按分片大小切片分别计算每个分片的MD5算完立刻释放内存然后把分片数、每个分片的MD5摘要、文件大小、文件名拼成一个fp字段作为整个文件的标识。这样设计的好处不管断点续传还是秒传服务端都能精确定位到“哪些分片已存在”而不是笼统知道“这个文件传过”。缺点是分片级计算总耗时依然不短所以我把计算过程做成异步任务队列配合进度提示体验上可以接受。如果只想做轻量版本还有一个替代方案取文件头部、中部、尾部各1MB做抽样MD5加上文件大小和最后修改时间。这个方案速度快很多但一致性保证弱一些只能用于快速判断文件是否大体相同不能作为分片级别的校验依据。3.2 续传协议三个接口一把状态表断点续传要真正落地光前端处理不够。我设计的上传协议比较简洁三个接口POST /api/upload/create前端提交fp、文件名、总大小、分片大小服务端创建上传记录返回uploadId和已存在的分片列表POST /api/upload/chunk前端上传单个分片参数带uploadId、chunkIndex、md5服务端按uploadId分目录保存校验md5后返回成功POST /api/upload/merge所有分片传完后前端触发合并。这个协议里最重要的设计是接口幂等。同一个chunkIndex可以重复上传服务端每次都校验md5并返回最新状态。这样网络超时后重试同一个分片不会出错重试是安全的。3.3 续传上下文不能只放内存也不能只放前端原生WebUploader刷新丢进度就是因为上下文在实例内存里。改造时我把上下文分成两份前端只保存uploadId和文件fp存localStorage或IndexedDB服务端保存所有分片的上传状态。前端刷新后重新调create接口服务端返回已存在分片列表前端过滤后只传缺失的。这里有个决策点已上传分片列表到底放前端还是放服务端。我最终选择放服务端原因有两个一是分片数量可能上万localStorage有5MB上限二是多个浏览器标签页甚至多台终端可能同时操作同一个文件只有服务端的状态是全局一致的。3.4 合并校验闭环成功不是前端说了算所有分片传完后前端调merge接口。服务端合并时按chunkIndex从小到大读取分片顺序写入目标文件然后对整个合并文件做一次MD5与前端上传的抽样指纹比对。比对不通过merge接口返回400同时返回缺失或损坏的分片索引前端自动发起定向重传。我实际遇到过一次前端显示100%但合并后文件校验失败。原因是某个分片在交换机上被截断长度不对前端看不出来只有服务端校验md5才暴露问题。这个闭环补上之后用户那边再没出现“传完但不能用”的投诉。4. 跨浏览器兼容的实战边界从File API差异到国产浏览器适配4.1 先明确要兼容的浏览器范围而不是“所有浏览器”有次开会用户拿出的兼容清单从IE8到最新Chrome都有。我的建议是不要试图支持所有浏览器要支持“实际业务环境中会出现且能力可达”的浏览器。我做了核心能力清单window.File、Blob.prototype.slice、FormData、XMLHttpRequest。凡是不支持这些能力的浏览器直接引导切换内核或升级不强行兼容。像“跨浏览器控件SDK”那种老思路在今天浏览器生态下很难走通。与其给每个浏览器写一套控件不如用JS特性检测做统一方案再对老浏览器给明确提示。4.2 能力检测优先于UA判断UA可以伪装很多国产浏览器会同时上报两个内核的UA靠UA判断不可靠。我写了一个能力检测函数function detectUploadSupport() { return !!(window.File window.Blob (Blob.prototype.slice || Blob.prototype.mozSlice || Blob.prototype.webkitSlice) window.FormData); }WebUploader支持指定runtimeType检测不通过就不实例化uploader实例直接显示提示页。这样做比自动降级到Flash可靠得多。Flash已经停止维护在安全内网环境属于高风险组件宁可让用户换浏览器也不要引入Flash。4.3 国产浏览器双核切换提示比黑科技可靠在军工行业用户环境里360安全浏览器、360极速浏览器、QQ浏览器这类双核浏览器很常见。默认模式往往是兼容模式对应IE内核。这时即便Chrome支持的能力都支持因为内核不同上传组件表现可能完全不同。我踩过的一个坑是360浏览器兼容模式下FormData里带Blob请求发出去后服务端收到的文件名为空。后面我在页面里加了一段检测逻辑识别到Trident内核运行时顶部弹一个黄条提示用户手动切换到“极速模式”。不自动切也不碰隐藏API因为自动切换在安全策略严格的系统里可能被拦住用户手动点一下最稳妥。4.4 HTTP/1.1并发限制与跨域配置跨浏览器还隐藏了一个性能差异HTTP/1.1下Chrome对同一域名的并发连接上限是6个老Edge和IE只有2个。同样threads6在老环境下真实并发只有2上传速度差不少。我根据检测到的连接数动态调整请求并发避免用户在部分浏览器上觉得“变慢了”。浏览器环境HTTP/1.1单域名并发上限实际建议并发Chrome较新版本64~6老Edge / IE1122国产浏览器极速模式64国产浏览器兼容模式21~2“跨域访问被拒绝请检查浏览器配置”这类提示在安全内网里出现频率很高。最省事的解法是用Nginx把外部系统的/upload路径反向代理到上传服务让页面和接口同域从根源上规避CORS。如果必须跨域再在服务端配置CORS头Access-Control-Allow-Origin按内网域名单写死不用通配符方便审计。4.5 一个典型的兼容性翻车复盘有次上线后同事反馈某台Windows 7加某个国产浏览器“上传卡在0%”但网络面板里分片请求一直在发。查了一圈发现是XMLHttpRequest的progress事件在那个旧内核版本下不触发前端收不到进度事件进度条自然不动。业务表象是“没速度”实际是“没反馈”。最后处理方式在没有progress事件的环境下降级为轮询查询服务端分片状态来推进度并且用节流控制轮询频率。这正好也回应了长期开setInterval不清理会导致页面卡顿的问题——轮询必须节流组件销毁时也必须清理定时器。5. 安全内网环境的专属适配离线部署、传输校验与国产化5.1 所有资源必须离线化、本地化安全内网和外网通常物理隔离或逻辑隔离页面绝对不能引用公网CDN。WebUploader的JS、CSS包括用到的SparkMD5、本地封装库全部下载后放进工程里构建时跟随前端站点发布到同一域名下路径用相对路径避免写成//cdn.xxx这种协议相对地址导致加载失败。这一步看着简单实际翻过车。早期有同事在模板里直接粘贴官方文档CDN地址结果用户打开页面后上传组件静默不出现查了很久才发现是外部资源被网络环境拦截。5.2 传输加密、分片签名与文件头白名单内网不等于绝对安全传输加密是标配。无论HTTP还是HTTPS建议统一走HTTPS。自签名证书要提前在客户端安装受信避免浏览器直接拦截导致上传请求失败。我在每个分片请求里加了签名参数token HMAC-SHA256(uploadId chunkIndex md5 timestamp)。服务端对签名做校验防止分片被篡改或重放。这个签名对卫星视频文件尤其重要因为这类数据的完整性要求本来就高。文件类型白名单也要做不能只查扩展名。卫星视频常见封装格式MP4、MXF、TS各有固定文件头。我在前端和后端都做了魔数校验前端提前拦截能节省服务端流量后端校验兜底。5.3 国产化环境放弃控件思维拥抱JS方案军工行业终端环境国产化比例越来越高统信UOS、银河麒麟这些系统上自带的浏览器大多是Firefox或Chromium内核。传统的ActiveX控件、Flash插件在这些环境里基本没有安装条件。JS改造这个方向的价值就在这里它不依赖任何私有插件只要内核支持标准Web API就能跑。适配过程中我发现国产化系统上的浏览器对Web标准支持通常不错但性能可能比主流浏览器低一些。所以JS侧做了降级大分片、低并发、减少DOM操作尽量把计算压力分担给服务端。5.4 监控上报与审计留痕超大文件上传耗时长、失败频率高没有监控很难定位问题。我在改造同时给上传插件做了简单埋点文件大小、分片数、每个分片耗时、重试次数、最终结果这些元数据上报给内部日志系统。上报时只记元数据不记文件内容避免额外泄露数据。这里顺带提醒一句涉密环境日志和审计要求很严日志里不要出现文件内容相关字段只保留业务标识和状态即可否则反而制造新的安全风险。6. 实测踩坑记录从内存崩溃到续传失灵的完整排查6.1 分片Blob数组把浏览器内存吃光现象上传5GB以上文件内存占用持续上升几分钟后标签页崩溃。排查链路先用DevTools的Memory面板抓堆快照发现大量Blob对象驻留。点开引用关系定位到代码里把切片后的Blob全部push进了一个数组目的是“方便取分片”。这个数组把所有分片的引用都持有住了JavaScript引擎认为对象还在使用不会回收于是内存越涨越高。修复去掉数组改为按需切片。上传某个分片时临时执行file.slice(start, end)传完即释放引用。后来的版本里我还组件销毁时彻底清空Uploader实例防止事件处理器残留导致的内存泄漏。6.2 刷新后“续传”命中了不存在的会话现象用户上传中断后刷新页面点击继续上传服务端直接返回“上传记录不存在”。排查链路第一步查服务端日志发现uploadId对应记录确实不存在。原因是服务端有临时目录清理策略24小时没有更新的临时分片会被回收。第二步看前端代码发现续传逻辑调的是create接口它返回的missing列表为空前端就认为不需要传了实际上服务端记录早就没了。修复前端续传时如果接口返回“记录不存在”自动重新走创建流程生成新uploadId并提示用户“原上传会话已过期将从头开始”。同时服务端把临时文件保留时间从24小时延长到7天减少这种误判。这个坑让我形成了一条设计原则断点续传协议里必须把“服务端状态丢失”当正常情况处理而不是当异常。否则一个清理策略就能把用户心态搞崩。6.3 并发拉满反而更慢瓶颈在服务端磁盘现象内网千兆带宽线程数从3调到6总上传速度反而从50MB/s掉到30MB/s。排查链路先看浏览器Network面板单个分片请求平均耗时从800ms涨到2.5s再看服务端监控磁盘IO队列长度很高出现大量随机写。原因是一个任务的所有分片都写在同一个临时目录并发高时多个流同时写磁头来回寻道机械盘直接崩。修复服务端按uploadId建子目录让同一个文件的分片集中在同一目录系统盘和临时上传盘分开避免日志和分片互相抢IO。前端侧适当调低并发并主动提高单分片大小更贴合顺序写的偏好。6.4 自签名证书环境下的“部分机器全部请求失败”现象同一套系统部分终端上传正常另一部分终端的每次上传请求都失败控制台报证书不受信任。排查链路第一批排查直接查到证书。能正常上传的机器都提前装了内网根证书失败的都是没装的。走HTTPS时浏览器TLS校验失败发生在业务层之外前端代码根本拿不到请求结果。修复协调管理员把内网根证书做成策略统一分发而不是让用户自己去点“继续访问”。同时前端加了一个证书自检工具进入页面时用一个轻量请求探测TLS是否正常失败时直接提示“请先安装内网根证书”并附安装指引。这个问题靠业务代码解决不了必须从终端管理层面解决。6.5 轮询进度导致页面卡顿setInterval滥用后的修复现象某个版本里为了兼容不支持progress事件的浏览器我用setInterval每200ms查一次服务端分片状态来更新进度。结果上传时页面越来越卡切换标签页后回来更明显。排查链路查看Performance面板定时器回调持续触发回调里还做了DOM更新时间线上出现大量强制重排。另外组件销毁时忘了clearInterval多次进入页面后堆了一堆定时器全部在向服务端发请求。修复轮询改成setTimeout递归加动态间隔。第一次查询间隔大越接近完成越频繁每次请求结束再设定下一次定时器。组件销毁时在destroy里清理所有定时器。这个改动直接让上传过程中页面保持流畅。如果让我重新做一遍我会在一开始就把服务端那三个接口的协议定死而不是先在前端改分片。很多问题最后都出在前后端状态不一致上——前端以为传完了服务端没有服务端有分片前端不知道。一个清晰、幂等、带状态查询的协议能省掉后续大量联调时间。断点续传这个东西真正让人头大的不是技术本身有多难而是边界情况太多网络闪断、服务端清理、浏览器关闭、分片损坏、证书不可信每一环都可能断掉链条。把这些场景都当成正常流程去设计插件才能真正在恶劣环境里站住脚。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门