做农田监控项目时,最折磨人的不是摄像头布点,不是算法识别,而是每天定时定点抓拍的那几百张高清图片怎么稳定传回云端。这些图片单张动辄三五兆字节,一个点位一天就是好几个G,而农田现场的4G信号经常只有两格,风速大了天线都会晃,上传到一半断掉再正常不过。后来我在Vue2前端里集成WebUploader,用分片上传加断点续传这套组合拳,总算把上传成功率从五成左右拉到九成以上,断网重连后还能自动接着传。这篇就从选型、原理、实操和踩坑四个方面完整梳理一遍,给同样在做农田监控或者弱网图片上传的同学一个可复用的参考。
1. 农田监控场景下的上传困境与技术选型
1.1 农田现场到底难在哪
先说说我这边实际遇到的约束。项目里农田监控设备通常部署在城市边缘甚至完全没有城市基础设施的偏远区域,最近的基站可能在一两公里外,信号穿过树林和地形遮挡后已经衰减得厉害。摄像头定时抓拍高清原图,单张2到10兆字节不等,一个点位一天上百张,如果赶上早晨和傍晚光线变化频繁,抓拍密度还会更高。边缘盒子性能又有限,不可能像机房服务器那样做大量本地缓存和压缩处理。
更头疼的是传输不稳定。农田环境里4G信号受天气影响很大,下雨、起雾、农忙时大型机械遮挡,都可能导致连接断开;偶尔还会因为供电不稳出现边缘节点重启。传统做法是前端用FormData一把梭,把整张图片POST到服务器,网络一抖就要从头传,大图重传几次就把带宽耗光了。所以这个场景必须做分片上传,并且要支持断点续传。
1.2 为什么最终选WebUploader
选WebUploader不是因为它最新最潮,恰恰是因为它稳定、老练、该有的都有。它把分片上传、并发控制、队列管理这些都封装好了,我只需要关心业务参数和事件回调。原生XMLHttpRequest加FormData虽然也能实现,但是要自己处理分片切割、并发调度、失败重试、进度合并,开发周期至少翻一倍,还容易在边缘场景里漏掉边界情况。
另一个考量是兼容性。农田现场还有相当一部分老旧的安卓设备和管理端浏览器,WebUploader对低版本浏览器的支持比一堆新出的现代组件友好得多。它不依赖Vue,可以很自然地嵌入Vue2生命周期里,用起来没什么框架摩擦。而且它的事件体系非常完整,fileQueued、uploadBeforeSend、uploadProgress、uploadSuccess、uploadError都有,后面做自定义续传逻辑时非常顺手。
1.3 最终确定的整体链路
整个方案链路其实并不复杂,可以分成几段:
- 摄像头定时抓拍,图片先落到边缘节点的本地目录;
- 管理端(Vue2页面)通过WebUploader选择或自动扫描这些图片,进入上传队列;
- 每张图先计算MD5,向服务端查询这个文件是否传过一部分;
- 未传完的,按2兆字节一个分片并发上传,服务端按分片落盘;
- 所有分片到齐后服务端自动合并,并把合并后的完整文件转存到对象存储;
- 转存完成后回写业务数据库,告知图片地址、大小、拍摄时间等元数据。
这套链路最核心的就是中间那段“查状态—跳分片—续传—合并”的逻辑,下面我把原理拆开讲。
2. 分片与断点续传的核心原理拆解
2.1 分片上传的本质
分片上传的本质很简单:把一个大文件按固定大小切成若干块,每一块当作独立的请求发到服务端。WebUploader里只需要开两个配置:chunked: true和chunkSize: 2 * 1024 * 1024,它就会自动把文件切片,并在每个分片请求里带上chunk(当前是第几片)和chunks(总片数)等参数。
我为什么把分片大小定成2兆字节,而不是1兆或者5兆?综合考虑了三点:第一是重试成本,弱网环境下分片越小,单次传输失败重传的代价越低;第二是请求开销,如果分片太小,一个10兆的图片要切10片以上,HTTP握手和排队带来的延迟会抵消掉重试优势;第三是服务端合并压力,分片太多合并时频繁做磁盘读写。实测下来,2兆到4兆这个区间在农田4G网络下表现比较均衡,我用的是2兆,信号特别差的时候可以临时调到1兆。
2.2 断点续传的“身份证”机制
断点续传要回答一个核心问题:服务端怎么知道这个文件之前传过哪些分片?答案就是用文件内容算出一个稳定的MD5,把它当作整张图片的唯一身份证。
流程是这样:文件进入队列后,前端先读取文件内容计算MD5;然后调用一个查询接口,把这个MD5发给服务端。服务端查分片记录表,返回该文件已上传的分片序号列表。如果是全新文件,返回空列表;如果之前传过,就拿到类似[0, 1, 2, 5, 6]这样的数组。接下来前端处理分片时,如果某个分片序号已经在已传列表里,就直接跳过,不需要再传。
这里需要说明一下实现方式的选择。让前端真正“跳过分片”在逻辑上最省流量,但也会引入一个麻烦:如果一个文件所有分片都传完了,前端不知道何时触发合并。我们的做法是双保险:前端通过uploadBeforeSend事件拦截掉已存在的分片,同时服务端保留幂等判断——即使某个分片被重复上传,服务端也直接返回成功并丢弃重复数据。这样既减少了无效流量,又不会因为前端状态判断失误导致上传永远卡住。
2.3 “云端同步”在这里到底指什么
很多同学一听到云端同步就以为只是把文件丢到对象存储上,其实农田监控场景里的“云端同步”有三层含义。
第一层是文件本身的同步:分片合并完成后,把完整图片转存到对象存储,相当于从边缘节点搬到云端。第二层是元数据同步:图片转存成功后,要往业务库里写一条记录,字段至少包括设备ID、拍摄时间、文件MD5、文件大小、云端URL。第三层是业务联动:监控分析系统订阅这些记录,对图片做长势识别、病虫害分析等。也就是说,上传成功只是起点,云端同步打通的是“边缘产生数据—云端沉淀数据—算法消费数据”的整条链路。
在断网期间,边缘节点会先把图片暂存在本地队列里,网络恢复后由控制端重新发起上传。这时候断点续传的价值就完全体现出来了:网络反复断,文件反复传了一部分,但只要MD5不变、分片记录在,每次恢复都能接着上次的位置继续,不用整张图重来。
3. Vue2集成WebUploader完整实操
3.1 引入依赖和初始化Uploader实例
由于WebUploader没有非常规范的npm发行版本,很多工程实际是把它放在static目录或公共资源目录里静态引入,我也沿用了这个方式。在index.html里直接引入脚本和样式:
<script src="/static/third-party/webuploader/webuploader.min.js"></script> <link rel="stylesheet" href="/static/third-party/webuploader/webuploader.css" />然后在Vue组件里初始化。要注意Uploader实例的创建必须要在DOM挂载完成之后,因为pick配置需要绑定一个已经存在的按钮元素。我一般放在mounted钩子里。
// 引入spark-md5用于计算文件MD5 import SparkMD5 from 'spark-md5'; export default { data() { return { uploader: null, uploadPercent: 0, currentTaskName: '' }; }, mounted() { this.initUploader(); }, beforeDestroy() { // 页面离开时必须释放资源,否则组件切换后上传状态会乱 if (this.uploader) { this.uploader.destroy(); } }, methods: { initUploader() { this.uploader = WebUploader.create({ auto: false, // 不自动上传,由业务控制 chunked: true, chunkSize: 2 * 1024 * 1024, threads: 3, // 并发上传分片数 server: '/api/upload/chunk', method: 'POST', fileVal: 'file', accept: { title: '农田监控图片', extensions: 'jpg,jpeg,png,tif' }, formData: { bizType: 'farm-monitor', deviceId: this.deviceId }, fileNumLimit: 300, fileSizeLimit: 50 * 1024 * 1024, duplicate: true, // 允许选择重复文件 pick: { id: '#uploadPicker', multiple: true } }); this.bindEvents(); } } }这里面有几个参数值得单独说一下。threads: 3是我在弱网环境下反复试出来的并发数,太低浪费带宽,太高容易把本来就弱的链路打满导致大面积超时。auto: false配合业务手动触发,是因为我们要等MD5算完才能上传,如果设成auto,文件一旦选中就开始传,MD5还没算完就会导致服务端无法判断续传状态。duplicate: true在监控场景里很实用,摄像头可能重复抓拍同一时间点的图片,重复文件也需要重新上传做覆盖,不能因为名字重复就被过滤掉。
3.2 文件进入队列后的MD5计算与断点查询
文件被用户选中或由业务自动加入队列后,会触发fileQueued事件。我在这个钩子里做两件事:计算整个文件的MD5,然后调用服务端check接口查已上传分片列表。
MD5计算必须用增量方式,否则几十兆的图片一次性读进内存,手机浏览器和低配边缘盒子直接卡死。下面这个函数就是用2兆的切片不断追加到SparkMD5的ArrayBuffer实例里,算完再resolve。
function calcFileMd5(file) { const blob = file.source.getSource(); // 拿到原始File对象 const size = blob.size; const chunkSize = 2 * 1024 * 1024; const chunks = Math.ceil(size / chunkSize); const spark = new SparkMD5.ArrayBuffer(); const reader = new FileReader(); let currentChunk = 0; return new Promise((resolve, reject) => { reader.onerror = () => reject(new Error('文件读取失败')); reader.onload = (e) => { spark.append(e.target.result); currentChunk += 1; if (currentChunk < chunks) { loadNext(); } else { resolve(spark.end()); } }; function loadNext() { const start = currentChunk * chunkSize; const end = Math.min(start + chunkSize, size); reader.readAsArrayBuffer(blob.slice(start, end)); } loadNext(); }); }MD5算完之后,调用查询接口:
uploader.on('fileQueued', async (file) => { try { const md5 = await calcFileMd5(file); file.md5 = md5; file.totalChunks = Math.ceil(file.size / (2 * 1024 * 1024)); file.doneChunkSet = new Set(); // 向服务端查询该文件的历史上传进度 const res = await checkUploadStatus({ md5, bizType: 'farm-monitor', deviceId: this.deviceId }); // uploadedChunks: 例如 [0, 1, 2, 5] file.uploadedChunks = new Set(res.data.uploadedChunks || []); // 如果所有分片都已经传过,需要直接走“续传完成”逻辑。 // 如果后端采用自动合并,这一步可以省略;如果采用显式合并接口, // 就要在这里主动通知服务端合并,否则文件会卡在“传完未合并”的状态。 if (file.uploadedChunks.size === file.totalChunks) { await notifyMerge(file); } } catch (e) { this.$message.error('文件校验失败:' + e.message); } });这里有个容易被忽略的坑:如果check接口返回的是“所有分片都传完了”,说明上次合并流程可能没走完,前端必须主动再调一次合并通知,否则这张图就永远卡在“已传完但没合并”的中间状态。
3.3 分片请求发送前的拦截与参数注入
每个分片在真正发出请求之前,都会触发uploadBeforeSend事件。这个钩子有两个用途:把MD5和业务参数塞进分片请求里,以及判断该分片是否已经传过,传过就阻止请求发出。
uploader.on('uploadBeforeSend', (file, data) => { // data就是要POST给后端的参数集合 data.md5 = file.md5; data.deviceId = this.deviceId; // 如果这个分片已经存在于服务端,直接跳过 if (file.uploadedChunks && file.uploadedChunks.has(data.chunk)) { // 返回false表示不发这个分片请求 return false; } return true; });但这里有个细节必须注意:WebUploader对返回false的处理并不是把该分片记为成功,而是放弃发送。如果某个文件大部分分片都被跳过,最后统计完成度时会比较麻烦。所以我通常不会完全依赖前端的跳过逻辑,服务端在收到分片时仍然会做一次“这个分片是否已存在”的判断,已存在就直接返回成功。也就是说,前端跳是“省流量”,后端跳是“兜底”,两边不冲突。
3.4 进度计算、完成回调与错误处理
分片上传的进度统计跟普通上传不太一样。uploadProgress事件回调里的percentage表示当前那个分片内的传输进度,不是整个文件的上传进度。想要一个可信的整体进度,我建议用成功分片数除以总分片数来算。
uploader.on('uploadSuccess', (file, response) => { if (response && response.chunkIndex !== undefined) { // 用Set做去重,防止重试导致重复计数 file.doneChunkSet.add(response.chunkIndex); const percent = Math.round(file.doneChunkSet.size / file.totalChunks * 100); this.currentTaskName = file.name; this.uploadPercent = percent; } }); uploader.on('uploadError', (file, reason) => { console.error(`分片上传失败: ${file.name}`, reason); this.$message.warning(`${file.name} 部分分片失败,等待重试`); });上传完成之后,需要区分两个层级:单个文件的最后一个分片完成,和整个队列的所有任务完成。前者触发的是文件级合并逻辑,后者是uploadComplete事件。我们的后端选择“最后一个分片到达后自动触发合并”,所以前端在uploadComplete里主要做界面收尾:
uploader.on('uploadComplete', () => { this.uploading = false; this.$message.success('本轮农田监控图片已全部同步完成'); });如果后端没有自动合并,那么前端就要在最后一个分片成功的回调里主动调一次/api/upload/merge,这个按实际后端设计来。我是推荐后端自动合并的,这样就算前端崩溃,服务端只要所有分片都在,仍然可以自己完成合并,不依赖前端状态。
3.5 后端接口与云端转存的设计速览
既然标题里提到了云端同步,后端这部分就绕不开。我简单整理一下核心接口和数据结构,前后端联调时可以直接照这个框架对。
| 接口 | 作用 | 关键参数 | 返回要点 |
|---|---|---|---|
| POST /api/upload/check | 查询文件历史分片 | md5, bizType, deviceId | uploadedChunks 数组、是否已完成 |
| POST /api/upload/chunk | 上传单个分片 | file, md5, chunk, chunks | chunkIndex、是否需要合并 |
| POST /api/upload/merge | 合并所有分片 | md5, fileName, totalChunks | 文件URL、云端Key |
后端处理分片时,我推荐按文件MD5建一个临时目录,分片文件名就是序号,比如/data/tmp/{md5}/0、/data/tmp/{md5}/1。每收到一个分片先校验该分片大小是否超过预估值,避免异常请求把磁盘写满;然后写临时文件,再把一条记录写入分片表。分片表的唯一键可以用(md5, chunk_index),防止同一个分片被并发写两遍。
当服务端检测到“已到齐的分片数等于总片数”,就可以做两件事:把分片按顺序合并成一个完整文件,再从临时目录转存到对象存储。转存成功后,往业务库里写图片记录,同时把临时分片目录删掉。这里建议加一个状态字段,比如pending、syncing、synced、failed,这样就算转存失败,也能靠后台重试任务把漏掉的图片补上去。
4. 实战踩坑与问题排查实录
4.1 断点续传莫名失效,先查分片大小是否变化
我在项目里遇到过一次很诡异的问题:同一张图第一次传到一半断开,过两天重传,前端明明从check接口拿到了已上传分片,却仍然把所有分片重新传了一遍。排查发现是后端同事在两次上传之间调整了分片临时目录规则,导致前端拿到的“已上传分片列表”对应的是旧的分片存储位置,统计表里的记录倒是都在,文件却找不到。后来我们在check接口的返回里增加了一个chunkSizeVersion字段,前端处理续传前先校验当前的chunkSize和记录里的版本一致,不一致就直接重置状态重新传。这个坑提醒我:断点续传的记录不是永久有效的,分片策略一变,历史记录就要作废。
4.2 MD5计算慢导致用户狂点上传
第一次上线时,MD5计算是在fileQueued里做的,文件还没算完用户就点了“开始上传”,结果上传请求里没有带MD5,服务端只能当全新文件处理,断点续传名存实亡。后来我在界面上做了状态区分:文件进入队列后先显示“校验中”,此时上传按钮不可点;等到MD5算完并拿到续传状态,再切换成“可上传”。同时在上传事件里加了硬校验:if (!file.md5) return false。这样就算有人绕过界面操作,请求也发不出去。
4.3 弱网下分片并发数到底调多少
这个参数不能拍脑袋定死。信号好的时候并发开到5,总吞吐反而上来了;一到信号差的地方,并发5会导致每个分片都在排队等待,超时率飙升。我在项目里做的是动态调整:页面每隔30秒调一次网络质量接口,根据当前延时和丢包率,在2到4之间动态改uploader.options.threads。如果拿不到网络质量数据,用固定3就行,这是一个相对安全的值。
4.4 网络恢复后如何自动续传
真正在农田里,用户不可能一直盯着页面。我的做法是监听浏览器的online事件和visibilitychange事件,当检测到网络状态从离线变为在线,或者用户重新回到页面时,先刷新断点状态,再调用uploader.upload()把排队中的任务重新拉起来。如果用的是自动扫描上传模式,还要加上一个“待传列表”的本地持久化,把还没传完的文件信息写进IndexedDB,这样边缘节点重启后也能恢复任务。
4.5 云端转存失败的重试与临时文件清理
合并完成的文件转到对象存储这一步看起来简单,实际上也会因为凭证过期、网络波动而失败。我给转存任务加了一个消费队列,转存失败的任务会在五分钟后退避重试,最多重试五次。另外,临时目录里的分片文件如果一直没有合并成功,会越积越多,需要安排一个定时任务,清理超过24小时还没有合并的目录。
5. 农田弱网场景下的几个实战优化建议
最后再聊几个我用下来效果很明显的优化点,虽然不是核心代码,但影响很大。
第一,合理利用凌晨低峰期。农田里白天农网和车辆作业会导致信号波动大,凌晨反而是网络最稳定的时段。我的方案里加了一个“定时采集上传”模式,摄像头白天抓拍的图片先不传,到了凌晨两点统一上传。配合断点续传,整个晚上慢慢传,第二天早上基本都能同步完。
第二,弱网时可以降级传缩略图。在信号极差的时候,先传一张几百KB的缩略图,保证看板端能及时看到监控画面;原图继续走分片续传,什么时候传完什么时候补上。这个策略极大改善了用户体验。
第三,多通道网络切换。部分边缘节点同时配置了4G和有线网络,我在控制端加了个探活逻辑,监测到当前通道长时间丢包就切到备用通道。断点续传在这种情况下帮了大忙,因为分片已经传了一部分,切换通道后可以直接接着传,不需要从头开始。
整套方案跟了我快两年,最大的感触是:在农田里做数据上传,不能把“一次传完”当目标,而要把“随时断开、接着再传”当成默认假设。分片和断点续传不是锦上添花,而是这种场景下的基础设施。如果你也在类似方向上折腾,希望上面的经验和踩坑记录能帮你少走几段弯路。