
上周在维护一个保险业务平台的附件上传模块时收到一条报障客户的核保材料视频传了一半就失败重试后又从头开始来回折腾将近一个小时。这种体验放在谁身上都受不了。问题不在网络而在于这个平台的痛点很典型——保单扫描件、理赔录音、核保体检视频单文件动辄几百MB系统又跑在私有化部署的内网环境里不能依赖公网的对象存储服务只能自己把上传链路打通。我整套方案重构下来就是题目里说的大文件分片上传 断点续传。这篇文章直接把完整实现思路和可改的源码写出来前半部分讲清楚分片参数和接口怎么设计中间是前端JavaScript核心代码后半部分补上后端配合逻辑和几个我实际踩过的坑。只要你做的是保险、金融、政务这类管理系统遇到“网页上传大文件”的场景这篇可以帮你省掉一大轮试错。1. 保险系统里的大文件上传业务场景与旧方案之痛1.1 哪些业务环节会接触大文件保险业务系统中的文件上传远比普通后台管理系统复杂。我做过的几个项目里大文件主要集中在四类场景电子保单批量导入线下渠道批量生成的保单PDF、Excel清单经常几十个文件打包成几百MB的压缩包由内勤批量导入核心系统。理赔材料扫描件一宗赔案涉及发票、病历、身份证明、事故证明等扫描成高清PDF后整卷材料动辄50MB到300MB。核保体检视频大额保单核保时客户需要到指定机构体检部分健康异常件会录制体检视频或动态影像这类文件常常超过1GB。销售双录录音/录像监管要求销售过程可回溯理财险、分红险的销售录音录像文件一段完整双录视频就可能达到300MB到800MB。这些文件有一个共同特征体积大、数量集中、上传人员多半不是技术人员。理赔员、内勤、业务员在弱网环境下传一半失败根本不知道怎么处理。如果系统没有断点续传他们只能重新选择文件从0%重新开始。一次两次还能忍反复失败就直接打电话投诉了。1.2 传统整文件上传为什么扛不住很多人第一反应是“增加服务器超时时间”“调大上传限制”就行了。真正在保险项目里踩过一轮就会发现整文件上传的失败不是单一原因造成的而是多层限制叠加的结果网关和Web服务器限制业务流程中普遍存在Nginx、负载均衡器、API网关。默认的请求体大小上限往往只有几MB到几十MB一个200MB的FormData直接返回413。请求超时即使是内网大文件传输耗时也可能超过网关默认的60秒超时。连接断开后浏览器端拿到的往往是一个莫名其妙的504。失败重传成本极高整文件上传没有“进度检查点”。网络抖动、浏览器切到后台、用户手滑关闭标签页所有已传的字节全部作废。前端内存风险某些实现用FileReader把整个文件读成ArrayBuffer再构造请求几百MB或GB级文件会直接让浏览器崩溃或标签页卡死。换句话说把大文件整个交给HTTP协议去扛等于让一辆卡车走一条限高2米的小路。HTTP本身不是设计用来传巨型文件的正确的思路是把大文件拆成无数个小包裹分别运输到了目的地再按顺序拼回去。1.3 分片上传 断点续传的整体思路这套方案的核心逻辑并不复杂前端把文件按固定大小切成多个分片chunk逐个上传。上传前先计算文件的哈希值比如MD5作为这个文件的唯一标识。每次开始上传前向后端查询“这个文件已经传了哪些分片”只传缺失的部分。所有分片上传完成后前端通知后端合并分片生成完整文件。如果后端已经存在相同哈希的完整文件直接跳过上传实现“秒传”。断点续传的本质就是把“整个文件成功”这个大目标拆解成“若干分片成功”的小目标。每个分片独立请求、独立确认失败时只重传失败的那几片而不是从头再来。2. 关键参数怎么定切片大小、并发数与前后端接口2.1 切片大小和并发数分片方案里最常见的两个参数就是切片大小chunkSize和并发数concurrency。这两个值不是拍脑袋定的需要结合网络条件、服务器吞吐和用户体验一起考虑。参数推荐值说明chunkSize5MB - 10MB内网或带宽充足用10MB外网弱网环境用5MBconcurrency3 - 5内网可调到5传统小型服务器建议3为什么不是越大越好分片太大一旦某片失败重传成本很高而且服务器接收单个大分片时会把分片先读进内存过大的分片容易撑满内存。为什么不是越小越好分片太小会导致请求数量爆炸一个1GB文件切1MB就有1024个请求网关和接口的压力都很大且每个请求都有网络往返开销。我实践下来10MB是私网部署环境下的甜点值既能控制分片数量又不会让单片失败代价太高。并发数方面以前在保险项目里图快把并发调到8结果后端服务器CPU、磁盘I/O直接被干满出现大面积超时。后来收敛到3到5整体上传速度反而更稳定。原理很简单并发上传会同时占用多条TCP连接按传统服务器单核处理能力计算每个请求的FormData解析、磁盘写入都是成本并发过高只会让系统忙于切换上下文。2.2 文件指纹用哈希解决“一个文件只传一次”断点续传必须有一个“身份证”来标识文件否则后端无法判断哪些分片属于哪个文件。常用做法是用SparkMD5在浏览器端计算整个文件的MD5值。文件hash至少有四个用途秒传判断后端查询该hash是否已经有完整文件有就直接合并完成。分片归属上传分片时携带hash服务端按hash建目录存储分片。断点续传查询上传前通过hash查看哪些分片已存在。合并校验合并后重新计算文件MD5与前端传入的hash对比确保数据完整。有一点需要提前说明计算大文件完整MD5是有开销的。一个1GB文件在普通PC上计算完整MD5大约需要2到5秒。如果直接在主线程做页面会“卡住”用户以为浏览器崩了。正确的做法是放到Web Worker里算或者采用“抽样hash 后台完整校验”的折中方案。这一点我在第5章会详细展开。2.3 接口约定check/chunk/merge 三个端点分片上传要求前端和后端严格约定接口重点不是技术多高深而是字段名和流程统一。我一般会在项目里约定三个接口前端只需要对着这三个接口写逻辑即可。接口作用请求参数返回结果POST /api/upload/check查询文件上传状态hash,fileName,fileSizeuploadedChunks: 已上传分片序号数组,exist: 服务器是否已存在完整文件POST /api/upload/chunk上传单个分片FormData字段file(分片),hash,index,totalChunkssuccess: true/falsePOST /api/upload/merge通知后端合并分片hash,fileName,totalChunksurl: 文件访问地址这里有个容易忽略的点check接口的返回必须包含“已上传分片序号数组”。如果后端返回的是“已上传数量”前端只能知道有3片传了但不知道到底是第0、1、2片还是第2、3、4片。只有拿到完整下标列表前端才能准确过滤掉已传分片。3. 核心源码前端分片与并发上传实现3.1 创建分片对象与文件哈希先用一个Uploader类来组织整个上传流程。第一步是创建分片和计算文件hash。切片用File.slice()它不会把文件一次性载入内存而是按需读取这也是前端大文件方案的基础能力之一。class Uploader { constructor(file, options {}) { this.file file; this.chunkSize options.chunkSize || 10 * 1024 * 1024; // 默认10MB this.concurrency options.concurrency || 3; // 默认并发3 this.fileHash ; this.chunks []; this.uploadedChunks new Set(); // 保存已上传分片索引 this.paused false; this.onProgress options.onProgress || (() {}); } // 切片按固定大小切分文件 createChunks() { const count Math.ceil(this.file.size / this.chunkSize); const chunks []; for (let i 0; i count; i) { const start i * this.chunkSize; const end Math.min(this.file.size, (i 1) * this.chunkSize); chunks.push({ index: i, start: start, end: end, blob: this.file.slice(start, end) }); } return chunks; } }紧接着计算hash。我会通过FileReader以固定大小的块读入文件再增量喂给SparkMD5避免一次读取整个文件导致内存暴涨// 完整计算文件MD5建议放到Web Worker执行 async calculateFileHash() { const CHUNK_SIZE 2 * 1024 * 1024; // hash读取块大小2MB const spark new SparkMD5.ArrayBuffer(); const reader new FileReader(); let offset 0; return new Promise((resolve, reject) { const readNext () { const slice this.file.slice(offset, offset CHUNK_SIZE); reader.readAsArrayBuffer(slice); }; reader.onload (e) { spark.append(e.target.result); offset CHUNK_SIZE; if (offset this.file.size) { readNext(); } else { resolve(spark.end()); } }; reader.onerror (err) reject(err); readNext(); }); }注意FileReader在读取时分片大小和上传分片大小没有关系。这里用2MB的读取块是为了让hash计算过程内存平稳每读一块就append一次不积压数据。3.2 查询已上传分片hash算完后调用check接口查询上传状态。这一步是断点续传的关键async checkUploadStatus() { const params new URLSearchParams({ hash: this.fileHash, fileName: this.file.name, fileSize: this.file.size }); const res await fetch(/api/upload/check?${params.toString()}); const data await res.json(); if (data.exist) { // 文件已完整存在于服务器直接秒传 return { exist: true, needUpload: false }; } (data.uploadedChunks || []).forEach(index this.uploadedChunks.add(index)); return { exist: false, needUpload: true }; }这里要强调一下查询接口返回的已上传分片是上一次未完成上传留下的。比如上一次传了前30片这次从第31片开始传就行。如果文件在服务器已经合并过exist为 true前端可以直接标记上传完成用户完全感觉不到“传文件”的过程。3.3 并发控制的上传器分片上传的核心是并发控制。我采用的是一种简单的“任务池”模式把所有需要上传的分片当作任务维护一个当前游标启动N个worker并发消费任务。这种模式比Promise.all一次性发所有请求要健康得多能精确控制同时飞在网络的请求数量async upload() { // 过滤掉已上传的分片 const pending this.chunks.filter(chunk !this.uploadedChunks.has(chunk.index)); let cursor 0; const worker async () { while (cursor pending.length) { if (this.paused) { // 暂停语义不继续领取新任务已领取的任务会跑完 return; } const chunk pending[cursor]; cursor; try { await this.uploadChunk(chunk); this.uploadedChunks.add(chunk.index); } catch (e) { // 单分片连续重试多次仍失败直接抛出中断整体上传 throw e; } const percent Math.ceil((this.uploadedChunks.size / this.chunks.length) * 100); this.onProgress(percent, this.uploadedChunks.size, this.chunks.length); } }; await Promise.all( Array.from({ length: this.concurrency }, () worker()) ); }然后看单分片上传方法。这里我加入了失败重试逻辑并且用了一个小技巧每个分片最多重试3次重试间隔递增500ms、1s、2s避免失败时疯狂请求后端async uploadChunk(chunk) { const maxRetry 3; for (let attempt 0; attempt maxRetry; attempt) { try { const formData new FormData(); formData.append(file, chunk.blob, ${this.fileHash}_${chunk.index}); formData.append(hash, this.fileHash); formData.append(index, chunk.index); formData.append(totalChunks, this.chunks.length); const res await fetch(/api/upload/chunk, { method: POST, body: formData }); if (!res.ok) { throw new Error(分片${chunk.index}上传失败HTTP ${res.status}); } return; } catch (err) { if (attempt maxRetry - 1) { throw new Error(分片${chunk.index}重试${maxRetry}次仍失败: ${err.message}); } await new Promise(resolve setTimeout(resolve, (attempt 1) * 500)); } } }整个过程就是先过滤已传分片再并发上传未传分片每片完成后立刻记录uploadedChunks这个集合就是断点续传的“记忆”。即使浏览器刷新只要后端还保留着已经传好的分片下次检查状态时就能全部找回来。3.4 页面集成与暂停恢复在实际页面上我们还需要一个启动入口和一个暂停按钮。我建议把上述逻辑封装成下面这样的使用方式const uploader new Uploader(file, { chunkSize: 10 * 1024 * 1024, concurrency: 3, onProgress: (percent, uploaded, total) { // 更新页面进度条 progressBar.style.width percent %; progressText.textContent ${percent}% (${uploaded}/${total}分片); } }); // 启动上传 async function startUpload() { uploader.paused false; await uploader.calculateFileHash(); // 大文件建议放Web Worker uploader.chunks uploader.createChunks(); await uploader.checkUploadStatus(); if (uploader.uploadedChunks.size uploader.chunks.length) { await uploader.merge(); return; } await uploader.upload(); await uploader.merge(); } // 暂停上传 function pauseUpload() { uploader.paused true; }暂停按钮的语义是不再领取新的上传任务当前正在进行的那几个分片请求会自然结束。重新点击“继续上传”时再执行一次startUpload()即可因为checkUploadStatus()会把未完成的分片全部捞回来。这种方式实现简单、符合直觉也足够健壮。如果你希望暂停时立刻中断在途请求可以引入AbortController去取消fetch但需要注意取消后后端可能已经写入了部分分片数据反而要处理半截状态成本更高。实测下来上面的“软暂停”在大多数场景下已经够用。4. 断点续传的完整落地查询、重试与合并校验4.1 续传的判定逻辑断点续传并非只有“断网后恢复”这一种状态。我把整个判定逻辑整理成几个分支文件不存在分片未传check返回空数组前端从第0片开始传。文件不存在分片部分已传check返回部分下标数组前端跳过这些分片从缺失位置继续传。文件已存在且完整check返回exist: true前端不再需要上传任何分片直接提示“上传成功”也就是秒传。文件已存在但状态是“合并失败”这种情况我会在后端增加一个status字段区分status: 0表示上传中status: 1表示完整可用status: 2表示合并失败。合并失败的文件需要后端先清理临时分片再让前端重新传。这里最容易踩的坑是把“文件存在”和“分片已传完”混为一谈。一个文件hash对应的临时分片目录可能已经存在但之前合并时就中断了文件并没有真正可用。所以在check接口里后端必须返回完整状态而不是简单返回存在/不存在。4.2 失败重试策略单分片上传失败有很多原因最常见的是网络抖动、网关超时和服务器临时错误。我的重试策略很简单但也够用单个分片最多重试3次。重试间隔按500ms - 1000ms - 2000ms递增避免快速失败时把服务器打爆。3次都失败后整体终止并在页面提示“上传中断在第X分片”。用户点击“续传”时程序不会从头开始而是从失败分片继续。为什么不无限重试因为如果连续3次都失败大概率是网络断掉了或者服务器已经把该分片标记为损坏。此时让用户检查网络、反馈错误信息比盲目重试更有价值。4.3 后端分片存储与合并实现没有后端配合断点续传就无从谈起。这里给出一个简化的Node.js实现思路生产环境按项目语言改写即可。后端需要做的三件事接收分片、存储分片、合并分片。const fs require(fs); const path require(path); const multiparty require(multiparty); // 上传分片 app.post(/api/upload/chunk, (req, res) { const form new multiparty.Form(); form.parse(req, (err, fields, files) { if (err) { return res.status(500).json({ success: false }); } const hash fields.hash[0]; const index fields.index[0]; const chunkFile files.file[0]; const tmpDir path.join(__dirname, tmp, hash); fs.mkdirSync(tmpDir, { recursive: true }); const chunkPath path.join(tmpDir, index); // 小文件用rename大分片建议用流式写入 fs.renameSync(chunkFile.path, chunkPath); res.json({ success: true }); }); });合并逻辑要注意两个关键点必须按index升序合并必须做完整性校验。顺序错了文件损坏不做校验损坏了还不知道。// 合并分片 app.post(/api/upload/merge, (req, res) { const { hash, fileName, totalChunks } req.body; const tmpDir path.join(__dirname, tmp, hash); const outPath path.join(__dirname, upload, ${hash}_${fileName}); if (!fs.existsSync(tmpDir)) { return res.status(400).json({ success: false, message: 分片目录不存在 }); } // 按整数索引排序防止字符串排序导致 10 2 const chunkIndexes fs.readdirSync(tmpDir).map(Number).sort((a, b) a - b); if (chunkIndexes.length ! Number(totalChunks)) { return res.status(400).json({ success: false, message: 分片数量不完整 }); } // 流式合并避免一次性把整个文件读进内存 const writeStream fs.createWriteStream(outPath); const appendChunk (cur) { if (cur chunkIndexes.length) { writeStream.end(); handleAfterMerge(hash, tmpDir); return; } const data fs.readFileSync(path.join(tmpDir, String(chunkIndexes[cur]))); writeStream.write(data); appendChunk(cur 1); }; appendChunk(0); res.json({ success: true, url: /upload/${hash}_${fileName} }); });真实生产环境里我还会加一个“合并状态字段”和“临时分片定期清理任务”。因为用户可能传了一半就放弃临时目录里的分片会长期占用磁盘。每24小时清理一次超过48小时未完整的临时目录是必要的维护手段。4.4 合并后的校验与清理合并完成后最严谨的姿势是计算合并后文件的MD5与前端传来的hash对比。一致才认为文件真正完整再把状态置为“可用”。这一步能挽回的悲剧很多比如弱网环境下某个分片虽然收到了但字节数不对或者并发上传时两个相同文件的hash冲突导致合并时混入了另一个文件的分片。我通常会在后端建立一张file_upload_record表核心字段如下字段说明id主键file_hash文件唯一哈希file_name原始文件名file_size文件字节数chunk_count总分片数status0上传中 / 1完成 / 2合并失败create_time创建时间finish_time完成时间这张表的存在让“查询是否秒传”变得非常简单直接按file_hash查status 1就返回exist: true。同时合并失败时可以快速定位问题并触发前端重传。5. 实测踩坑与工程化收尾建议5.1 五个必须提前知道的问题这套方案我前后落地过不止一次真正调试中才会暴露的问题远不止“代码写对就能跑”这么简单。第一个坑计算MD5时页面卡死。第一次实现时我在主线程直接算一个1.2GB视频文件的MD5页面卡了差不多10秒用户以为浏览器挂了。后来把hash计算放到Web Worker页面才保持可交互。如果你的文件普遍在500MB以上强烈建议直接把计算逻辑丢进Worker。第二个坑并发设置太高服务器内存被打满。我遇到过并发8、每个分片10MB的场景后端同时接收8个10MB的请求再加上Multipart解析时的缓冲直接导致服务器可用内存告急。现在的原则是先确认服务器并发处理能力再决定前端并发数宁可慢一点也不要频繁崩溃。第三个坑网关的body size限制。很多项目前端调试没问题一上生产环境就413。原因在于前面还有一层网关默认限制请求体大小。如果网关限制是50MB分片大小就不能超过50MB但我一般不依赖调大网关而是把分片控制在10MB从源头上避开限制。第四个坑合并时文件损坏。合并顺序必须按数字大小排序。字符串排序时第10片会排在第2片前面结果合并出的文件可能看起来能打开但内容错乱、无法校验。我在后端统一用Number()转换再排序并用MD5校验兜底。第五个坑“秒传”判断标准不一致。如果只判断hash存在就秒传而实际上文件是上次合并失败的半成品用户就会看到一个“上传成功”但下载的文件打不开。后来我在记录表里加了status字段只有完整可用状态才允许秒传。5.2 私有化部署环境下的注意事项保险项目大多跑在客户内网或私有云环境和公有云SaaS场景有个明显区别内网带宽通常稳定但服务器硬件配置不一定高。我遇到过客户用一台普通的双核4G服务器跑整套系统这种情况下并发3、分片5MB反而比并发5、分片10MB更稳。另外两个容易被忽略的维护点临时分片目录清理用户反复取消上传临时目录会积累大量残留分片。我见过磁盘100%占用导致整库瘫痪的事故教训就是必须设置定时清理。日志记录上传接口的日志一定要记清楚hash、分片index、文件大小、耗时。大文件上传问题往往是随机发生的没有日志几乎无法复现。5.3 后续优化方向这套方案稳定运行之后还可以从几个方向继续增强Web Worker计算hash避免主线程阻塞尤其是GB级文件。本地状态持久化把“已上传分片”写入localStorage或IndexedDB即使刷新页面也能在check结果返回前先过滤一部分分片。分片自适应根据当前网速动态调整下一批分片大小网络好时用大分片网络差时用小分片。接入对象存储如果客户允许使用公网对象存储可以把“接收分片”这一步改为后端生成预签名URL前端直接把分片传到对象存储后端不再占用服务器带宽。我在实际项目中最后还加了一个小功能上传完成后给用户弹一个“本次上传用时、平均速度”的统计。这个信息对产品运营和客户支持都很有用因为很多用户觉得“传得慢”是系统bug有了统计就能判断到底是网络问题还是服务器问题。这套大文件分片上传与断点续传方案从改造上线到现在最直观的感受就是用户不再关心“上传会不会失败”而是开始关心“这次传了多快”。如果你正在重构类似系统先把接口约定和后端状态字段设计好前端代码反而是这些环节里最容易替换的部分。文件上传这件事方案不一定要最复杂但一定要让用户“失败的代价足够小”这正是分片和续传带来的最大价值。