做军工信息化相关的系统,最头疼的功能清单里,“大文件上传”绝对排前三。用户给你一个几个GB的卫星视频,要求在内网环境传到服务器,浏览器还是五花八门的老版本,网络偶尔还给你来个中断。常规的上传组件根本扛不住,Flash方案已经彻底废了,纯自研又费时费力。最后我选择基于WebUploader做了一次深入改造,封装成一个跨浏览器的超大附件分片断点续传插件。这篇就把整个改造思路和落地过程完整复盘一遍,包括分片策略、断点续传机制、前后端接口约定,以及军工内网环境下必须处理的浏览器兼容和国密算法问题,给准备搞大文件上传又不想从零造轮子的同学一份可直接参考的方案。
1. 项目背景:为什么WebUploader还需要二次改造
1.1 卫星视频根本不是“普通大文件”
先明确一下业务场景。我们在做的是一套面向军工行业的视频资料管理系统,用户上传的素材主要是卫星视频数据,单文件动辄几个GB到几十个GB,格式包括MP4、TS、MXF这类专业封装。之前用的方案是JSP页面里嵌入ActiveX控件上传,但ActiveX只能在IE下运行,浏览器一升级整个功能就瘫痪,运维压力非常大。后来考虑用原生HTML5实现,但时间紧、兼容性要求又高,最终决定在WebUploader基础上做改造。
WebUploader是百度开源的老牌文件上传组件,优点很明显:支持分片上传、并发控制、拖拽上传、进度回调,API设计成熟,而且底层自动封装了HTML5和Flash两套Runtime。不过这里有个必须说清楚的前提:Flash Runtime现在已经不能用了,Adobe在2020年底就停止了维护,军工内网更不可能允许Flash这种有严重安全漏洞的组件存活。所以我们的改造目标非常明确:只用HTML5 Runtime,不支持老掉牙的IE6/IE7/IE8,但必须兼容IE11、Chrome 45以上、以及国产化浏览器(比如基于Chromium内核的奇安信、360企业版等)。
1.2 超大附件上传的四个核心痛点
积累了这几年做大文件上传的经验,我认为所谓的“超大附件”难点其实就四个:文件切分、断点续传、并发控制、数据完整性。
第一是文件切分。浏览器虽然可以通过Blob.slice把文件切成小块,但不能无脑切,切大了单请求容易超时、切小了会产生成千上万个小文件,服务端存起来都费劲。第二是断点续传。几GB的视频传了一半网络断了,如果让用户重新传,这种体验在军工行业是绝对不行的,用户不会骂产品经理,会直接打电话骂开发。第三是并发控制。一次性把所有分片都发出去,浏览器崩溃不说,服务端也扛不住,必须限制并发线程数。第四是数据完整性。军工行业对数据出错的容忍度极低,视频文件必须保证分片上传完成后合并出来的文件和源文件完全一致,哈希校验、国密算法这些都不能少。
1.3 跨浏览器的真实含义
标题里说的“跨浏览器”在军工内网场景下,跟互联网公司理解的完全不是一回事。互联网公司只要兼容Chrome、Firefox、Safari的最新版就行,而军工内网由于安全策略和采购渠道的限制,浏览器版本往往滞后很多年。有的办公电脑还在用IE11,有的单位强制使用国产化终端和国产浏览器,还有的单位用的定制版Chrome内核浏览器屏蔽了CDN域名导致外部JS加载不出来。
所以这次改造要解决的跨浏览器兼容,具体来说就是:支持IE11(放弃IE10以下)、支持基于Chromium 45+内核的各类国产浏览器、支持标准的Chrome和Firefox,同时所有静态资源必须本地化部署,不能依赖任何公网CDN。在保证这些兼容性的前提下,再把分片上传和断点续传做到插件里。
2. 总体设计:分片与断点续传的核心机制
2.1 分片策略:大小怎么定、并发开多少
分片是整个方案的地基。我的经验是分片大小要根据实际网络情况来定,不能拍脑袋。一般有两种典型场景:一种是军工内网专线,带宽高、延迟低,分片可以适当大一些,50MB甚至100MB都能接受;另一种是远程站点通过加密链路传数据,带宽小还容易抖动,这时候分片必须小,5MB左右比较稳。
具体计算可以参考这个思路:假设峰值带宽是10MB/s,一个分片在网上的传输时间控制在1到3秒之间比较理想,那分片大小就在10MB到30MB之间。如果带宽只有2MB/s,分片还设成50MB,传一个分片要25秒,中间任何一次网络抖动都可能导致分片失败,失败重传的成本非常高。如果带宽很大但分片设成1MB,几GB文件会产生几千个分片,服务端文件句柄和Redis记录都会爆炸。
我在这套插件里的默认配置是单分片10MB、并发线程3。为什么并发开3而不是10?因为卫星视频文件通常不止一个用户同时传,如果每个用户开10个并发,服务端同时要处理几十个分片写入,磁盘I/O很容易成为瓶颈。而且WebUploader的并发线程数其实是指同一文件的分片并发数,3个线程配合自动重试,实测在10MB分片下能把内网带宽跑满,同时服务端压力也在可控范围。
2.2 断点续传的三要素
断点续传说起来很简单,就是“已经传过的分片不再传第二次”,但真正实现要解决三个问题:怎么识别同一个文件、怎么知道哪些分片传过了、怎么在服务端把分片拼回去。
文件识别我采用“文件指纹”方案。对大文件做全量MD5耗时太长,几GB文件算一次MD5可能要几十秒,用户体验很差。实际项目里我采用“抽样指纹”:取文件开头1MB、中间1MB、结尾1MB,加上文件大小,拼成字符串后用MD5或国密SM3计算出一个指纹。这种方案的碰撞概率在超大文件场景下可以接受,而且计算速度非常快,基本秒出。
已传分片的记录我做了“双端存储”。前端通过localStorage存储每个文件指纹对应的已上传分片索引数组,用户刷新页面后还能读取到;服务端通过Redis存储同样的分片索引集合。每次上传分片前,前端先向服务端查询该文件指纹下哪些分片已经存在,返回后直接跳过这些分片。
最后是服务端合并。全部片传完后,前端调用合并接口,服务端按照分片索引顺序拼接文件。拼接的时候要注意并发写入问题,我采用的是所有分片先落盘到临时目录,合并时按索引顺序读取并写入最终文件,合并完成后计算整个文件的SM3哈希,和前端提交的哈希进行比对,一致才认为上传成功。
2.3 为什么选用WebUploader而非完全自研
有一部分人说可以完全自研,我承认纯封装XMLHttpRequest加Blob切片并不难,但工程落地时你会发现需要处理大量边缘场景:文件重复选择、上传队列管理、拖拽上传的样式处理、进度条动画、文件类型过滤、多文件并行队列等等。WebUploader把这些都做好了,我们只需要在它的事件机制上做二次开发,把分片、断点、合并这些业务逻辑挂进去就行。
还有一点是WebUploader的插件机制。它本身支持通过WebUploader.Uploader.register注册插件方法,扩展起来很舒服。我最终交付的不是一个改得面目全非的Demo页面,而是一个独立的JS文件,其他系统只要引入WebUploader核心库加这个插件文件,再写几行初始化代码,就能获得完整的分片断点续传能力。这就是标题里说的“插件”的含义。
3. 插件化改造实操:WebUploader JS改造全过程
3.1 初始化参数与关键配置
我把整个改造封装成一个名为SatVideoUploader的插件,外部调用方式很简单:
var uploader = new SatVideoUploader({ pick: '#pickBtn', dnd: '#dndArea', accept: { title: '视频文件', extensions: 'mp4,ts,mxf,avi,mov' }, server: '/api/upload/chunk', chunkSize: 10 * 1024 * 1024, threads: 3, auto: false, formData: function() { return { token: getToken() }; } }); uploader.on('uploadComplete', function(result) { // 上传完成回调 console.log(result.fileKey, result.fileName); });内部初始化WebUploader时,有几个参数必须特别注意。第一个是chunked: true,开启分片模式。第二个是chunkSize,我们设为10MB。第三个是threads: 3,并发线程数,不要调太高。第四个是fileVal,设置分片上传时的字段名,这套方案里我用的是chunk。还要加一个duplicate: true,允许重复选择同一个文件,因为断点续传场景下用户可能重复选择同一个文件来继续上次的上传进度。
有一个参数容易被忽略:prepareNextFile。平台默认情况下,WebUploader会在一个文件正在上传时自动准备下一个文件,但分片断点续传场景下,这会导致内存占用过高。我建议显式设置为false,让用户手动触发上传,避免多个大文件同时加载到内存。
3.2 核心代码:断点续传状态的读写
断点续传的完整实现贯穿WebUploader的几个关键事件。核心逻辑在uploadBeforeSend事件中:每次发送分片之前,先判断该分片是否已经上传过,如果是,直接标记跳过。
uploader.on('uploadBeforeSend', function(block, data) { var file = block.file; var fileKey = getFileKey(file); var uploadedChunks = getUploadedChunks(fileKey); data.fileKey = fileKey; data.chunkIndex = block.chunk; data.chunkTotal = block.chunks; data.fileName = file.name; data.fileSize = file.size; data.fileHash = fileHashCache[fileKey]; if (uploadedChunks.indexOf(block.chunk) >= 0) { block.skip = true; skipChunkCount++; } });这里的getFileKey就是我前面说的抽样指纹,getUploadedChunks从localStorage读取已上传分片数组。WebUploader会在发送前检测到block.skip属性,如果为true就不再发送该分片对应的HTTP请求。这一步是整个断点续传能成立的基石,没有它,前面所有的本地记录都是摆设。
分片上传成功后,需要把当前分片索引写入本地记录,同时准备触发合并操作:
uploader.on('uploadSuccess', function(file, response) { var fileKey = response.fileKey; var chunkIndex = response.chunkIndex; if (fileKey) { markChunkUploaded(fileKey, chunkIndex); } if (response.complete) { requestMerge(response.fileKey, response.fileName); } });服务端在每次分片上传时都会返回当前文件的已上传分片总数和维护合并状态。当服务端检测到所有分片都已上传时,会在响应里标记complete: true,前端拿到这个标记后直接触发合并请求,不需要自己数分片数量,这样能避免“前端以为传完了、服务端其实还缺分片”的时序坑。
3.3 暂停、恢复与取消的实现
WebUploader原生并没有提供完整的暂停/恢复API,只有stop()和upload()这两个方法。实际项目中,我通过组合这两个方法实现了控制能力。
暂停时调用uploader.stop(true),这个true表示停止后清空任务队列,避免恢复时重复入队。恢复时重新调用uploader.upload(),这时每个分片在发送前都会执行uploadBeforeSend判断,已经传过的分片会被自动跳过,只继续传未完成的分片。整个过程不需要额外维护复杂的队列状态。
取消上传就更直接了,调用uploader.removeFile(file),同时把本地存储中的该文件指纹记录清掉。这里容易踩一个坑:如果不清除记录,用户取消后重新选择同一个文件,插件会误判为“断点续传”而跳过所有已传分片,但此时服务端分片可能早已被清理规则删掉,最后会陷入死循环。所以取消时一定要同步清除服务端的临时分片记录,我一般是先调用服务端的abort接口,再执行前端清理。
3.4 大文件指纹与抽样哈希实现
文件指纹的代码需要结合抽样策略来写。由于浏览器没有直接读取任意偏移量的同步API,我使用FileReader配合Blob.slice来读取指定片段:
function getFileKey(file, callback) { var CHUNK_SIZE = 1024 * 1024; // 抽样片段大小 1MB var fileSize = file.size; if (fileSize <= 3 * CHUNK_SIZE) { readSlice(file, 0, fileSize, function(buffer) { callback(calcHash(buffer)); }); return; } var promises = []; // 头部 promises.push(readSlicePromise(file, 0, CHUNK_SIZE)); // 中间 var midStart = Math.floor(fileSize / 2) - Math.floor(CHUNK_SIZE / 2); promises.push(readSlicePromise(file, midStart, CHUNK_SIZE)); // 尾部 promises.push(readSlicePromise(file, fileSize - CHUNK_SIZE, CHUNK_SIZE)); Promise.all(promises).then(function(results) { var combined = new Blob(results); readSlice(combined, 0, combined.size, function(buffer) { callback(calcHash(buffer)); }); }); }这里要注意,promises.push的顺序就是读取顺序,头部、中间、尾部各1MB,组合成一个3MB的Blob后再计算哈希。用Promise.all而不是逐个串行读,是为了减少整体耗时。实测下来一个10GB的文件生成指纹只需要1到2秒,用户基本无感知。
还有一个细节:在军工内网环境下,如果对哈希算法有合规要求,可以将calcHash函数替换为国密SM3实现。Web Crypto API原生不支持SM3,但可以使用asm.js或WebAssembly版本的国密库。在我的插件里我把哈希算法做成了可配置项,默认使用MD5或SHA-256,配置为sm3时自动加载国密模块。
4. 前后端接口约定与合并策略
4.1 分片上传接口设计
前端改造只是整个链路的一半,服务端接口必须和前端完全对齐。我这边配套使用Java开发的服务端,接口约定如下:
| 参数 | 类型 | 说明 |
|---|---|---|
| chunk | MultipartFile | 分片文件二进制内容 |
| fileKey | String | 文件指纹,由前端抽样哈希生成 |
| chunkIndex | Integer | 当前分片索引,从0开始 |
| chunkTotal | Integer | 总分片数量 |
| fileName | String | 原始文件名 |
| fileSize | Long | 文件总大小 |
| fileHash | String | 合并后完整文件的哈希值 |
分片保存接口的处理逻辑主要有三步:校验参数合法性、将分片保存到以fileKey命名的临时目录、更新Redis中该文件的分片索引集合。
@PostMapping("/api/upload/chunk") public Result uploadChunk(@RequestParam("chunk") MultipartFile chunk, @RequestParam("fileKey") String fileKey, @RequestParam("chunkIndex") Integer chunkIndex, @RequestParam("chunkTotal") Integer chunkTotal, @RequestParam("fileName") String fileName, @RequestParam("fileSize") Long fileSize, @RequestParam("fileHash") String fileHash) throws IOException { // 1. 参数校验:fileKey是否存在、chunkIndex是否在合法范围 if (chunkIndex < 0 || chunkIndex >= chunkTotal) { return Result.error("分片索引非法"); } // 2. 保存分片到临时目录 String shardPath = shardRootDir + File.separator + fileKey; File shardDir = new File(shardPath); if (!shardDir.exists()) { shardDir.mkdirs(); } chunk.transferTo(new File(shardDir, chunkIndex + ".part")); // 3. 更新Redis已上传分片集合 String redisKey = "upload:chunks:" + fileKey; SetOperations<String, Integer> ops = redisTemplate.opsForSet(); ops.add(redisKey, chunkIndex); int uploadedCount = ops.size(redisKey).intValue(); // 4. 返回合并状态 Map<String, Object> data = new HashMap<>(); data.put("fileKey", fileKey); data.put("chunkIndex", chunkIndex); data.put("complete", uploadedCount == chunkTotal); return Result.ok(data); }这里有一个容易踩的坑:MultipartFile.transferTo()在部分国产中间件上会抛出跨目录限制异常。我最初在WebLogic上部署测试时,这个方法一直报FileNotFoundException,排查了半天发现是WebLogic对临时目录的访问权限做了限制。后来改成先getInputStream()再自己写文件,采用Files.copy()的方式,这个问题才彻底解决。在军工内网常用的一些老旧应用服务器上,这类兼容问题极其常见。
4.2 合并接口的并发安全
合并接口做两件事:按索引顺序拼接文件、计算合并后文件哈希并与前端提交的fileHash比对。
@PostMapping("/api/upload/merge") public Result merge(@RequestParam("fileKey") String fileKey, @RequestParam("fileName") String fileName, @RequestParam("fileHash") String fileHash) throws IOException { String shardDir = shardRootDir + File.separator + fileKey; File target = new File(finalDir + File.separator + generateStoredFileName(fileName)); // 按索引顺序读取所有分片并写入目标文件 Set<Integer> uploaded = redisTemplate.opsForSet().members("upload:chunks:" + fileKey); List<Integer> sortedChunks = new ArrayList<>(uploaded); Collections.sort(sortedChunks); // 排序是关键,顺序错乱会导致视频文件损坏 try (OutputStream out = new BufferedOutputStream(new FileOutputStream(target))) { for (Integer index : sortedChunks) { File part = new File(shardDir, index + ".part"); Files.copy(part.toPath(), out); } } // 计算合并后的SM3哈希并与前端提交的fileHash比对 String actualHash = sm3Digest(target); if (!actualHash.equals(fileHash)) { target.delete(); return Result.error("文件完整性校验失败,请重新上传"); } // 清理临时目录和Redis记录 FileUtils.deleteDirectory(new File(shardDir)); redisTemplate.delete("upload:chunks:" + fileKey); return Result.ok("上传成功"); }合并过程最核心的就是分片排序。前端虽然是按索引顺序发送的,但并发线程3的情况下,很多分片几乎是同时到达服务端的,如果服务端为了省事直接把分片追加写入目标文件,那顺序就会错乱,最终合并出的视频文件会出现花屏、卡帧甚至完全无法播放。所以我选择先落盘再合并的方式,宁可多占一些磁盘空间,也要保证顺序完全正确。
合并接口还必须考虑幂等性。如果前端因为网络原因调了两次合并接口,第二次调用时临时目录可能已经不存在了,这时应该返回已上传成功的结果,而不是报错。我在代码里加了一层判断:如果目标文件已经存在且哈希一致,直接返回成功。
4.3 超大文件的上传记录清理策略
军工内网的服务器磁盘往往有限,大量的几GB分片临时文件如果不及时清理,很快就会把磁盘塞满。我定了一套清理策略:分片临时目录保留24小时,超过时间且未进入合并阶段的自动清理;Redis记录过期时间设为48小时;每天凌晨通过定时任务扫描临时目录删除过期文件。
需要注意一个细节:清理策略不能太激进。用户可能在第一天上传了一半,第二天继续传。如果不到24小时就清理掉分片文件,断点续传能力就直接作废了。我的经验是至少在业务要求的“最大暂停时长”上加一倍作为临时文件的保留期限。
5. 军工场景下的浏览器兼容与安全处理
5.1 兼容IE11与国产浏览器的技巧
插件在标准Chrome上跑通只是第一步,真正的考验在IE11和国产浏览器上。我总结几个必须处理的点。
第一是File.slice方法的兼容。IE10以上的File对象支持slice,但部分IE11版本对File.prototype.slice的稳定性和大数据量切片的性能并不理想。WebUploader内部做了一个兼容封装,它优先使用File.prototype.slice,如果检测到File.prototype.mozSlice或File.prototype.webkitSlice也会使用。对于IE11,它走的是原生slice。我建议不要直接修改WebUploader底层,而是在选择文件后先做一次能力检测,如果不支持分片,直接提示用户更换浏览器,而不是让用户看到一个永远卡在0%的进度条。
第二是localStorage的可用性。IE11还有一个残酷的Bug:如果用户关闭了浏览器隐私模式,localStorage会变成只读状态,setItem会抛异常。所以封装插件时必须对localStorage的读写做try-catch,异常情况下降级为内存记录——虽然刷新后断点续传能力会丢失,但至少不会让整个上传功能崩溃。
第三是网络请求的问题。很多国产浏览器的内核安全策略比较独特,会默认阻止携带Content-Type: multipart/form-data的POST请求出现跨域情况。在军工内网里,前端页面和应用后端通常部署在同一个域名下,这个问题不明显。但如果是分域名部署,一定要在服务端正确配置跨域响应头Access-Control-Allow-Origin,同时前端要设置withCredentials属性,否则上传请求会在浏览器里被静默拦截,表现为分片“永远传不上去”。
5.2 静态资源本地化与离线部署
军工内网通常是完全离线的,WebUploader自带的CSS和JS资源不能通过公网CDN加载。我专门建了一个静态资源目录,把WebUploader的核心JS、中文语言包、CSS样式、以及图片素材全部打进安装包里。插件对外只暴露一个dist目录,集成方把整个dist拷贝到自己的新项目里就行。
这里要说一个典型坑:WebUploader的源码注释里包含了许多外部链接,个别安全审查工具会检测出这些外链并报“数据外传风险”。这些其实只是一段注释,并不会真正发请求,但为了过安全评审,我直接用发布版压缩文件替换了源码版,压缩后的JS中注释已经被webpack抹掉,风险提示自然消失。
5.3 国密算法与数据完整性校验
军工行业的数据安全要求远高于普通企业,传输链路加密和数据完整性校验是标配。我在插件里做了两层防护。
第一层是分片内容完整性。前端在计算文件抽样指纹后,还会为每个分片计算独立的SM3哈希值,并在发送分片时把当前分片的哈希传给服务端。服务端在保存分片之前,会重新计算接收到的分片内容的SM3哈希并与前端提交的值比对,不一致直接拒绝。这样能保证每个分片在传输过程中没有被篡改或损坏。
第二层是合并后的文件级完整性。合并完成后服务端计算整个文件的SM3哈希,与前端在fileHash参数中提交的哈希进行比对。由于前端提交的fileHash是在选择文件后就固定下来的全程哈希,所以这个比对能确认合并后的文件与源文件完全一致。前端计算全程哈希是通过抽样方式,但合并后校验如果要求更严格,也可以让服务端在合并完成后,把完整的SM3哈希单独传给前端约定好的回调,由前端决定是否更新业务系统里的档案记录。
需要强调的是,这里用的是“哈希校验”来防传输错误,并不是防恶意篡改的加解密。如果业务确实需要对分片内容做加密传输,可以在分片发送前使用国密SM4算法对分片字节进行加密,服务端解密后再保存。我这套方案考虑到性能优先,默认不启用力,但在插件里预留了encrypt: true的配置项,需要时接入即可。
6. 常见问题与排查实录
6.1 断点续传失效排查
在实际运行中,断点续传失效是最常见的工单问题。现象是:用户上传到一半,刷新页面或关闭浏览器后重新打开,点击同一个文件,插件没有跳过已传分片,而是从头开始传。
排查思路先看localStorage。打开浏览器开发者工具,查看当前域名下的Local Storage,确认是否存在以upload:progress:开头的记录。如果没有,说明前端没有成功写入记录,这时要检查是不是浏览器隐私模式导致的localStorage只读,或者存储写入时抛了异常被catch吞掉了。
如果前端记录正常,再看Redis。用Redis客户端查看upload:chunks:{fileKey}键是否存在,以及它的成员数量。如果Redis中对应的分片集合已经不存在或为空,那很可能是定时清理策略把记录删了。我推测这种情况多发生在“用户当天传了一半,第二天才续传”的场景,48小时的TTL恰好到期。
最后还要检查一个容易忽略的点:文件指纹的一致性。如果用户选择了两个不同路径但同名同大小的文件,或者系统在指纹计算时用了包含绝对路径的元数据导致key变化,断点续传也会失效。我建议指纹计算时只用文件名、大小、抽样片段内容这些内容属性,不要掺入路径信息。
6.2 浏览器崩溃与内存溢出
上传几个GB的文件时,浏览器内存占用会持续攀升,严重的直接崩溃。这个问题要从两个维度解决。
一是控制分片并发数。threads不要盲目调大,4个并发已经很多了,实际测试中3个并发最稳妥,既能利用带宽又不会把浏览器内存吃满。二是及时释放File对象的引用。WebUploader在文件上传完成后会把文件对象保留在队列中,这对超大文件来说就是几个GB的内存泄漏。我改造时在uploadComplete事件里手动调用uploader.removeFile(file),让垃圾回收尽早回收大文件的内存占用。
chunkSize也会影响内存。分片发送时浏览器需要把分片读入内存,10MB的分片意味着每个并发线程最多占10MB左右的临时内存,3个线程就是30MB,还算合理。但如果把chunkSize调到100MB,三个并发同时加载就可能有300MB内存占用,在低配办公电脑上很容易触发崩溃。
还有一个细节:拖拽上传时,浏览器会先把整个文件读入内存。对于几GB的视频,拖拽上传可能会直接导致页面无响应。我改造后的插件在检测到文件大于2GB时,会提示用户使用文件选择框而不是拖拽上传,这是一个非常实用的避坑经验。
6.3 分片上传卡住不动
用户反馈“进度条卡在某个百分比不动”,这种情况最先怀疑网络问题,但军工内网的网络往往很稳定,更多是因为服务端并发处理不过来。分片上传请求是短连接,服务端如果不小心在接口里加了全局锁,并发3个线程会变成串行处理,进度看起来就像卡住了一样。
我用arthas诊断过一次类似问题,发现服务端接口里有一个全局级synchronized代码块,把分片写入Redis的集合操作锁住了,导致并发请求排队。解决办法是把锁的粒度缩小,只对同一个fileKey的分片写入加锁,或者直接使用Redis的原子集合操作,不再加应用层锁。改完后上传进度立即恢复流畅。
还有一个前端层面的原因:WebUploader默认的失败重试次数是0次,分片请求一旦失败就直接标记为失败,但用户感知是“卡住不动”。我建议在插件初始化时配置retry: 3,并监听uploadError事件,遇到临时网络错误做自动重试。注意重试不能无限次,不然服务器持续故障时前端会疯狂发送请求,把服务端彻底打挂。
6.4 上传速度优化心得
最后聊几个实测有效的提速技巧。
第一,用uploadProgress事件里返回的speed字段做实时测速,如果发现连续几秒速度低于某个阈值,自动把并发线程数降为1,减少无效请求。第二,把分片大小和系统MTU对齐:内网传输环境MTU一般是1500字节,分片大小是10MB(约7000个包),实际传下来效率最高。第三,服务端保存分片时用SSD临时目录而不是机械硬盘,大文件合并的性能瓶颈几乎都在磁盘I/O。我们最初把分片临时目录放在机械硬盘上,3个并发时服务端CPU占用不高,但合并且等待时间翻了三倍,后来换成SSD后整个流程快得让人怀疑之前是不是代码写错了。
还有一个容易被忽略的点:浏览器本身对同一域名下的并发连接数有限制。HTTP/1.1下,Chrome对同一个域名的最大并发连接数是6,我们插件用3个线程传分片,页面还有资源请求和其他AJAX请求,加起来很容易触发浏览器连接限制。如果上传速度一直提不上去,可以让运维在后端加一层Nginx,开启HTTP/2支持,或者把上传接口单独部署一个域名,都能明显改善并发性能。我在实际部署中就采用了上传接口独立端口的方式,成功的把上传速度从20MB/s提高到了50MB/s以上。
7. 最后的补充与个人体会
整个改造过程持续了大概三周,最深的体会有两点。
一是不要迷信“从头造轮子”。WebUploader虽然看起来老,但它的分片队列模型和事件机制写得很扎实,比很多年轻人从零写的上传组件成熟得多。在WebUploader上做扩展,远比在原生XMLHttpRequest基础上重新应对各种浏览器细节更靠谱。
二是“断点续传”四个字里,“断点”是前端问题,“续传”是前后端协同问题。只在前端做localStorage记录,服务端不配合保留分片,续传就是空谈;反过来,服务端做得再完善,前端没有可靠的本地状态记录,也不知道从哪里继续传。所以做这种功能一定要想着前后端一体设计,接口约定写清楚,分片排序、哈希校验、临时文件清理这些细节都得两边同时对齐。
如果后续有继续扩展的机会,我会在插件里加入多文件级别的任务调度,比如多个大文件同时上传时的资源分配策略,以及上传完成后的秒传能力——服务端发现fileKey对应的完整文件已经存在时,直接跳过所有分片,返回秒传成功。这些能力在目前的版本里已经有雏形,只是没有完全打磨成熟。
这套方案上线后,最让我欣慰的是用户真的不再抱怨上传问题了。一个十几GB的卫星视频,内网环境稳定时大约十分钟传完,中途断网、刷新、换电脑都能接着原来的进度传。希望这份实操记录对正在处理类似大文件上传需求的人有用。