ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

分片上传实战指南:解决大文件传输难题,实现断点续传与秒传

分片上传实战指南:解决大文件传输难题,实现断点续传与秒传 1. 整车厂大附件到底有多大被附件拖垮的供应商门户去年我给一家汽车制造企业做供应商门户的附件模块改造真正触发这次改造的是一通来自工艺部门的电话。对方语气很急一个3.5GB的焊装线改造仿真视频传到73%断了重传一次到50%又崩了浏览器直接白屏再点上传按钮毫无反应。这不是个例。整车厂和供应商之间每天流转的文件体积早就超出普通网页上传能承受的范畴。我翻了后台日志把过去一个月上传失败的文件做了个统计发现超过1GB的文件失败率高达四成失败原因几乎都是网络中断、请求超时、浏览器内存溢出这老三样。最夸张的一条记录里同一个2.8GB的检测报告被供应商传了11次才成功。1.1 一组真实的文件体量数据汽车制造场景里的大附件不是我们平时理解的几百MB而是以GB为单位的庞然大物。我整理了一下那家企业在供应商门户、研发协同平台、质量追溯系统里经常出现的附件类型大概是这么个量级文件类型典型体量产生环节上传方向CAD数模Catia/UG500MB - 3GB产品设计供应商 → 主机厂焊装线仿真视频1GB - 6GB工艺验证供应商 → 主机厂路试车载视频500MB - 8GB质量测试测试部门内部PPAP整套提交包200MB - 1.5GB生产件批准供应商 → 主机厂BOM清单与检测报告50MB - 800MB采购与质检供应商 → 主机厂这些文件有很多共同特征周期紧当天必须交付网络环境不可控供应商那边可能是跨国专线也可能是普通宽带文件本身是企业核心数据不容丢失。1.2 为什么普通上传方案在制造场景必然翻车很多人觉得大附件传不上去是后端服务器或者带宽的问题其实第一道坎在浏览器。传统的input typefile配合表单提交浏览器会先把整个文件读进内存再发请求。一个3GB的文件先不说用户要等多久Chrome在32位系统下单进程内存上限都不够用直接白屏太正常了。就算文件勉强读进去了一个POST请求把几GB数据一次推给后端中间经过Nginx、网关、应用服务器任何一个环节的超时时间设置都会成为断点。请求断了就得从头再来没有任何挽回余地。汽车行业的供应商网络质量参差不齐跨境传输时丢包率稍高大请求基本必断。这就引出分片上传的核心思路不跟大文件正面硬刚把它切成几十上百个小块每个小块独立上传。小块传输天然抗网络抖动失败只需要重传那一个小块而不是整个文件。加上并发控制、断点续传、秒传校验才能保证几个GB的文件在弱网环境下也能完整落到服务器。2. 分片上传的核心逻辑把3.5GB拆成700份再逐个击破分片上传不是什么新概念早年网盘就是这么干的。但制造业门户跟消费级网盘不一样它要考虑文件权限、审计、与工单系统的关联以及供应商和主机厂之间的网络现实。下面我把这套逻辑拆开讲清楚理解了再去看源码自己改起来才不慌。2.1 分片上传的整体流程一次完整的分片上传包含七个阶段前端读取文件计算MD5指纹调用初始化接口把文件名、大小、总分片数、MD5交给后端拿到文件唯一ID后端拿到MD5后先查重如果库里已有相同指纹的文件直接返回秒传完成不走后续流程前端按固定大小切分文件生成分片序号前端把分片并发上传每传完一片后端落盘并记录所有分片传完后前端调用合并接口后端把临时分片按顺序拼成完整文件校验合并后的文件MD5核对大小标记上传完成。在这个流程里分片只是手段不是目的。真正要解决的是两个问题单次请求体量可控以及失败后最小成本重试。2.2 秒传与断点续传MD5在这里的真正作用很多人以为MD5是用来校验文件完整性的在分片上传里它的首要作用是去重。供应商第二次提交同一个检测报告前端算出MD5以后后端一查指纹一样直接告诉前端不用传了文件已经有了上传进度条一秒拉满。汽车行业里同一份PPAP包改来改去、反复提交的情况太多了这一招能省掉大量无效传输。断点续传的逻辑更直接。前端每次开始上传前调用一次查询已上传分片接口后端返回已落盘的分片序号列表前端只把缺失的分片补传。这样哪怕传了一半电脑断电、浏览器崩溃重新打开页面选同一个文件秒进续传状态而不是从头再来。2.3 并发数和分片大小的选择逻辑分片大小和并发数是整个方案里最需要权衡的两个参数。切得太小分片数量暴增请求次数多了后端压力大而且每个请求都有TCP握手和HTTP头开销总耗时反而变长。切得太大单个请求又回到了大请求容易断的老问题。我在这套系统里用的是5MB一片并发3个请求。算一下3.5GB 3584MB正好切714片默认带宽下并发3路每片独立确认整体进度只增不减。并发数为什么是3而不是10因为浏览器对同一域名的TCP连接默认限制是6个如果前端还有其他接口要调用占满连接池会导致页面其他请求排队等待。留3个连接给普通接口调用上传占3个是综合体验最稳的配比。3. 前端源码实现切片、并发池、进度与重试前端这部分我直接贴核心代码基于原生JavaScript实现不依赖框架你把它接进Vue、React或者单纯的jQuery页面都能用。先说明一下整体结构File切片用File.slice()MD5计算用SparkedMD5库并发控制自己写一个简单的请求池不用第三方库方便理解也方便改。3.1 文件的读取与分片切片本身特别简单但要注意分片序号必须补零。后面合并时要按字典序排序如果不补零10会排在2前面文件顺序就乱了。const CHUNK_SIZE 5 * 1024 * 1024; // 5MB function createChunks(file) { const count Math.ceil(file.size / CHUNK_SIZE); const chunks []; for (let i 0; i count; i) { const start i * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); chunks.push({ index: i, indexStr: String(i).padStart(6, 0), // 补零保证排序正确 blob: file.slice(start, end) }); } return chunks; }padStart(6, 0)这行是关键别省。700多片最多三位数补6位是为了以后超大文件扩展时不用改代码。切完以后每个分片就是一个独立的小Blob对象5MB的小块发给后端资源占用完全可控。3.2 MD5指纹计算用Web Worker避免卡死页面计算3.5GB文件的MD5不是瞬间的事在纯前端主线程里算会导致页面卡顿用户以为浏览器死了。解决方案是把计算丢到Web Worker里。我用的是SparkMD5的增量计算模式逐片读取文件边读边算不把整个文件加载进内存。// worker.js importScripts(spark-md5.min.js); self.onmessage function(e) { const file e.data.file; const chunkSize e.data.chunkSize; const spark new SparkMD5.ArrayBuffer(); let offset 0; const total file.size; function readNext() { if (offset total) { const hex spark.end(); self.postMessage({ type: done, hex }); return; } const blob file.slice(offset, Math.min(offset chunkSize, total)); const reader new FileReader(); reader.onload function(event) { spark.append(event.target.result); offset event.target.result.byteLength; // 每算完一块汇报一次进度 self.postMessage({ type: progress, percent: Math.round(offset / total * 100) }); readNext(); }; reader.readAsArrayBuffer(blob); } readNext(); };主线程那边只需要function calcFileMD5(file) { return new Promise((resolve, reject) { const worker new Worker(worker.js); worker.postMessage({ file, chunkSize: CHUNK_SIZE }); worker.onmessage (e) { if (e.data.type progress) { updateHashProgress(e.data.percent); // 展示正在校验文件提示条 } else if (e.data.type done) { worker.terminate(); resolve(e.data.hex); } }; worker.onerror reject; }); }3GB的文件用这种方式算MD5大概需要20到40秒取决于CPU性能。体验上要给用户明确反馈不然用户以为卡死了直接把页面关了。3.3 请求并发池的实现并发池是前端控制上传节奏的核心。它的作用是同时最多跑3个上传请求一个完成了马上从待上传队列里拿下一个补上。这样不会把浏览器连接池占满也能保证进度条平滑前移。async function uploadChunks(chunks, fileId, onProgress) { const concurrency 3; const queue [...chunks]; const results new Array(chunks.length); let completed 0; async function worker() { while (queue.length 0) { const chunk queue.shift(); const formData new FormData(); formData.append(fileId, fileId); formData.append(chunkIndex, chunk.indexStr); formData.append(chunk, chunk.blob); try { await axios.post(/api/upload/chunk, formData, { timeout: 120000, // 单分片超时2分钟 onUploadProgress: () {} }); results[chunk.index] true; completed; onProgress(Math.round(completed / chunks.length * 100)); } catch (err) { // 单片失败重试3次仍然失败就中断并抛出 await retryUpload(chunk, fileId, 3); completed; onProgress(Math.round(completed / chunks.length * 100)); } } } const workers []; for (let i 0; i concurrency; i) { workers.push(worker()); } await Promise.all(workers); return results; }这里注意一个细节单分片超时设置成了2分钟。5MB的分片哪怕是拨号网络也不该传2分钟设置这个值的意义是防止某个请求卡死导致并发池里的worker永远不释放拖慢整个队列。超时后主动失败触发重试逻辑新的请求会重新加入排队。3.4 进度的精确计算进度条不应该是某一片正在传就显示那一小段的百分比而是以已成功落盘的分片数量为准。每成功一片进度 成功片数 / 总分片数。这种计算方式不受单片传输速度波动影响用户看到的进度条是稳定向前的。有两点要特别提示前端开发注意一是进度到达100%不代表上传完成后端还要合并需要把状态切成服务器合并中二是进度条卡在99%一定不要慌张那是因为合并阶段没有独立的进度反馈等后端合并完成就好了。如果你想让体验更好可以在合并接口被调用后轮询一个合并状态接口展示真实的合并进度后面我会讲到后端如何配合。4. 后端源码实现分片接收、索引校验与安全合并后端我用Node.js Express来写因为这在一线环境里最容易快速部署和二次开发。如果你所在的企业后端是Java或.NET逻辑完全一致只需要把文件操作、接口返回换成对应的方言即可。目录结构我先给出来upload-service/ ├── app.js ├── config.js ├── uploads/ │ ├── tmp/ # 分片临时目录 │ └── files/ # 合并后的正式文件 └── routes/ ├── init.js ├── chunk.js ├── query.js └── merge.js4.1 初始化接口拿到文件唯一凭证前端算完MD5后调用这个接口后端返回fileId这个ID贯穿整个上传生命周期。// routes/init.js const crypto require(crypto); const fs require(fs); const path require(path); const { tmpDir } require(../config); router.post(/init, async (req, res) { const { fileName, fileSize, chunkCount, md5 } req.body; // 1. 秒传检查MD5已存在则直接返回已完成 const exists await findFileByMd5(md5); if (exists) { return res.json({ code: 0, data: { fileId: exists.fileId, uploaded: true, message: 文件已存在秒传成功 } }); } // 2. 断点续传检查如果有未完成的上传记录则复用 const existing await findUploadRecord(md5); if (existing) { return res.json({ code: 0, data: { fileId: existing.fileId, uploaded: false } }); } // 3. 新建上传记录 const fileId crypto.randomUUID().replace(/-/g, ); const task { fileId, fileName, fileSize, chunkCount, md5, status: uploading, uploadedChunks: [], createTime: Date.now() }; // 落库同时创建临时目录 await saveUploadRecord(task); fs.mkdirSync(path.join(tmpDir, fileId), { recursive: true }); res.json({ code: 0, data: { fileId, uploaded: false } }); });秒传这个分支一定要做在初始化阶段而不是合并阶段这样能省掉整个上传过程的带宽和计算资源。4.2 分片写入接口与完整性校验分片接口接收的是multipart/form-data格式前端每次提交一片后端把这片追加写入临时目录。这里我做两个校验分片序号必须落在合法范围内分片大小不能超过约定上限防止恶意请求写坏磁盘。// routes/chunk.js const multer require(multer); const fs require(fs); const path require(path); router.post(/chunk, multer().single(chunk), async (req, res) { const { fileId, chunkIndex } req.body; const chunkIndexNum parseInt(chunkIndex, 10); const record await findUploadRecord(fileId); if (!record) { return res.status(404).json({ code: 404, message: 上传记录不存在 }); } if (chunkIndexNum 0 || chunkIndexNum record.chunkCount) { return res.status(400).json({ code: 400, message: 非法分片序号 }); } if (req.file.size 5 * 1024 * 1024 1024) { return res.status(400).json({ code: 400, message: 分片大小超限 }); } const chunkPath path.join(tmpDir, fileId, ${chunkIndex}.part); fs.writeFileSync(chunkPath, req.file.buffer); await markChunkUploaded(fileId, chunkIndexNum); res.json({ code: 0, message: 分片写入成功 }); });分片文件名用纯数字加.part后缀方便合并时排序。这里为了方便直接用了writeFileSync因为单个分片只有5MB内存压力不大。如果你把分片调到20MB以上建议改成流式写入避免多并发时内存暴涨。这里还涉及一个容易被忽略的点服务端必须记录已上传分片否则断点续传无从谈起。我在markChunkUploaded里做的是更新数据库记录把分片序号追加到一个数组字段里。更稳妥的方案是一个分片一条记录用独立的表存储这样查询已上传分片时可以直接用索引。4.3 合并接口流式合并而不是内存拼接合并是整个后端最容易翻车的地方。新手最容易犯的错误是用Buffer.concat()把所有分片拼起来再写文件几个GB的文件直接内存溢出。正确做法是用Node.js的createReadStream和createWriteStream流水线式合并每次只读写一小块。// routes/merge.js const fs require(fs); const path require(path); const { Transform } require(stream); router.post(/merge, async (req, res) { const { fileId } req.body; const record await findUploadRecord(fileId); const uploadedCount record.uploadedChunks.length; if (uploadedCount ! record.chunkCount) { return res.status(400).json({ code: 400, message: 尚有分片未上传还需${record.chunkCount - uploadedCount}片 }); } const destPath path.join(filesDir, record.fileName); const writeStream fs.createWriteStream(destPath); // 按分片序号排序 const sortedChunks Array.from({ length: record.chunkCount }, (_, i) i); async function appendChunk(index) { if (index sortedChunks.length) { writeStream.end(); return; } const chunkPath path.join(tmpDir, fileId, ${sortedChunks[index]}.part); const readStream fs.createReadStream(chunkPath); readStream.on(end, () appendChunk(index 1)); readStream.on(error, (err) { writeStream.destroy(); res.status(500).json({ code: 500, message: 合并失败分片${index}读取出错 }); }); readStream.pipe(writeStream, { end: false }); } appendChunk(0); });合并这种写法本质上就是挨个把分片文件接起来像拼水管一样。这里每个分片的读取是流式的内存占用始终只有几百KB不会因为文件大而增长。合并完成之后还要做一次完整的校验计算合并后文件的MD5与初始化时的MD5比对。一致才更新上传状态为完成。这一步不能省网络传输环节虽然每个分片的TCP校验都过了一遍但拼接顺序、分片内容丢失这类逻辑错误只有整体MD5才能抓出来。4.4 临时分片的生命周期管理合并完成后临时分片目录要及时清理。我是用一个定时任务每小时扫描一次临时目录清理两类文件合并完成后遗留的分片以及超过24小时没有新分片的半截上传记录。这里有个业务考量保留24小时是为了给断点续传留余地用户今天传了一半下班了明天还能续传。超过24小时基本可以判定是放弃上传了僵尸分片会占据硬盘空间必须清掉。清理时还要同步清理数据库里的上传记录避免记录越积越多拖慢查询。5. 让3.5GB真正传完的可靠性设计代码能跑通是一回事真正让几个GB的文件在各种恶劣网络环境下都能传完是另一回事。我在这里把上线过程中验证过的可靠性机制完整列出来。这些机制单独看都是小改动合在一起才是3.5GB一次传完的底气。5.1 网络层超时、重试与并发降级分片上传最大的好处是单次请求变小了但小请求同样会遇到超时和断连。我的处理策略是三层兜底单分片请求超时120秒超时就中断中断后的分片自动重试最多3次每次间隔指数退避2秒、4秒、8秒连续3次失败后并发数从3降为1用单线程继续传这样能避免在网络质量极差时多个并发请求互相争抢带宽反而一个都传不完。这层逻辑写在前端并发池的worker里后端不需要感知。后端要做的反而是把超时时间设得尽量宽松一些避免因为网关代理的默认超时把慢速请求提前掐断。如果你们Nginx配了proxy_read_timeout建议调到180秒以上。5.2 服务端崩溃后如何续传服务端重启、磁盘满、进程被杀这些故障都会中断上传过程。但只要分片已经落盘、上传记录写进了数据库前端感知到连接断开后重新走一遍初始化流程后端发现同名MD5的上传记录还在返回同一个fileId前端再查询已上传分片就能精准定位到中断位置。这套机制的关键在于每个分片写入必须是原子操作。也就是要么完全写成功要么完全不写。writeFileSync天然具备这个特性只要不出现写一半进程被杀的情况。为了更稳我建议分片文件名里包含分片序号写入时先写临时名再rename成正式名rename是原子操作更安全。5.3 页面层关闭标签页后的恢复方案用户传了一半关了标签页再回来发现进度条归零会非常崩溃。前端要做的是把上传状态持久化到localStorage包含fileId、文件名、文件大小、MD5、总分片数。用户重新选择同一个文件时先查localStorage发现相同大小的文件且MD5一致直接调用后端查询接口恢复进度条。function saveUploadTask(file, fileId, md5) { localStorage.setItem(uploadTask, JSON.stringify({ fileName: file.name, fileSize: file.size, fileId, md5, uploadTime: Date.now() })); } async function resumeUploadIfNeeded(file) { const saved localStorage.getItem(uploadTask); if (!saved) return null; const task JSON.parse(saved); if (task.fileName ! file.name || task.fileSize ! file.size) return null; const { data } await axios.post(/api/upload/query, { fileId: task.fileId }); // data.uploadedChunks: 已上传分片序号数组 return { task, uploadedChunks: data.uploadedChunks }; }注意localStorage有5MB大小限制这里只保存描述信息不保存文件本身不会越界。5.4 磁盘与存储合并前的最后一公里后端存储是整个链路里最容易静默崩溃的一环。分片写入阶段临时目录可能迅速膨胀几个大文件同时在传时磁盘占用是几倍于最终文件大小的。我遇到过生产环境磁盘满导致分片写不进去前端一直报网络错误排查半天才发现是磁盘问题。建议从第一天起就加三样东西磁盘剩余空间监控低于10%告警、临时目录的独立挂载盘与系统盘分离避免写满系统盘、以及合并完成后的文件归档策略正式文件立即转存到独立存储或对象存储应用服务器只保留最近几天的文件。6. 上线后踩过的坑从413到文件损坏的完整排查链路方案上线两周问题一个接一个冒出来。很多坑在开发环境根本测不出来只有真正跑到供应商的真实网络和生产环境里才暴露。我把这几个坑按排查链路写出来你照着这个顺序排查能省大量时间。6.1 第一个坑Nginx默认限制导致的413上线当天就有供应商反馈小文件能传大文件一点上传就报413 Request Entity Too Large。我第一时间想到的不是应用代码而是Nginx的client_max_body_size默认值是1MB。分片虽然是5MB但这个限制作用于整个请求体大小所以5MB的分片请求也会被Nginx挡下来。排查链路很简单先看浏览器开发者工具里的网络请求发现POST /api/upload/chunk返回413再确认请求体确实约5MB最后登录服务器检查Nginx配置。修复也直接server { client_max_body_size 10m; client_body_buffer_size 128k; proxy_request_buffering off; }为什么加proxy_request_buffering off因为默认开启时Nginx会把完整请求体缓冲到磁盘再转发给后端分片这种流式提交场景等缓冲完再转发会带来无谓的延迟和磁盘IO。关闭后直接流式转发给Node进程。注意这里10m上限要大于分片大小的实际值留出multipart格式的额外开销余量。6.2 第二个坑负载均衡把分片拆到了两台机器这个是排查时间最长的坑。供应商反馈文件传到一半报错查看后端日志发现有的分片写入成功有的直接404。仔细检查后发现问题出在部署架构上服务跑在负载均衡后面的两台机器上同一个上传任务的分片A请求被负载均衡转发到节点1分片B请求被转发到节点2两台机器各自的磁盘上只有一半分片合并接口在所有节点上都找不到完整数据。解决方案有两种我选了比较简单的那种做应用层会话保持。在负载均衡配置里按fileId做哈希路由保证同一个fileId的所有分片请求都落到同一个节点。更彻底的方案是把临时目录挂到共享存储上但那个改动太大不是紧急情况不值得动。如果你用的是阿里云SLB或Nginx upstream有个更稳妥的处理方式分片接口不依赖会话保持而是把分片数据直接写到共享的存储层比如NFS或对象存储各节点读取同一份数据。这属于一次性投入以后扩容也方便。6.3 第三个坑分片排序的字典序陷阱这个坑特别隐蔽而且只在文件超过100片时出现。某供应商传一个600MB的压缩包合并后文件损坏解压报错。我一开始以为是分片丢失数了一遍数量是齐的又怀疑是某个分片内容损坏但MD5对不上。最后把合并后的文件按顺序对比原始文件发现有些数据块顺序反了。原因就是我在前端只用了String(i)做序号没补零后端合并时用字符串排序10排到了2前面。虽然前端createChunks里已经修正了这个问题但当时后端合并逻辑里自己也重新组装了一次分片列表用的还是普通数组顺序没按补零后的字符串排。教训有两条一是前端序号的padStart(6,0)必须做二是后端在合并前要把分片序号转成数字再排序不要用字符串。两边都按数字排序这个坑就彻底解决了。6.4 第四个坑合并进度为零的假卡死进度条走到100%接口迟迟不返回用户端看到的是上传完成但处理中一直转圈。排查后发现是合并时没有给前端进度反馈大文件合并要几十秒用户没有任何提示体验上就是卡死了。我的解决办法是把合并改成长轮询后端合并时把进度写进Redis前端启动合并后每2秒拉一次合并状态接口展示单独的合并进度条。合并完成后前端弹出结果提示同时把localStorage里的上传任务清掉。顺带还发现一个细节合并状态接口要设置合理的缓存头否则前端每次拉取都会走完整HTTP链路浪费资源。我用的是Cache-Control: no-store因为状态是实时变化的绝不能缓存。最后一点个人体会整套方案从设计到上线用了一个多月跑通核心流程只用了一周剩余时间几乎都花在踩坑和加固上。如果让我给正准备做大附件上传的人一个建议那就是不要沉迷于一次性把代码写完美先把主流程跑通然后把网络故障当成必然事件来设计。大附件上传的本质不是上传而是对抗不确定性——网络会断、服务器会重启、用户会中途关浏览器、磁盘会满、代理会超时。把这些场景先想清楚代码怎么写都对。另外有一件小事值得提汽车行业对文件的合规要求比一般行业严涉及供应商提交的文件我们后来给每个分片加了上传人的身份信息记录合并完成后生成完整的审计日志。虽然这跟分片上传本身关系不大但如果你做的系统要过体系审核最好提前把这条链路设计进去别等审核组来查的时候再补那时候数据早就不全了。这套方案的源码我手上还留着完整版包括前端组件化封装、后端分片清理脚本的定时任务实现以及合并进度的轮询逻辑。篇幅原因没法全部贴出来如果你在实际落地时遇到具体问题欢迎在评论区带上你的报错信息和技术栈我可以针对性地给出解释。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表