做过大文件上传的同学应该都有体会:视频素材、系统安装包、设计稿压缩包,动辄几个GB,直接用一个POST丢给服务器,大概率传输到一半连接就被掐断了。断掉之后要么重新传整个文件,要么看运气靠浏览器自动重试,体验非常不稳定。这个问题的标准解法就是分片上传加断点续传——把大文件切成多块,分别上传,服务器收齐后再合并,同时记录哪些分片已经传过了,下次自动跳过。这套方案并不复杂,核心就是HTML5的Blob.slice切片能力,加上一两个状态记录方案。
这篇文章我会把完整思路和可复现的代码都写清楚,包括前端怎么切片、怎么恢复进度、并发数怎么控制,以及后端接口怎么配合做合并和校验。适合正在做上传模块、被大文件稳定性折磨的前端同学,也适合需要和后端联调接口、想搞清楚整个链路的全栈开发者。你不需要有很深的底子,只要写过基本的AJAX请求,就能照着把整个流程跑通。
1. 分片上传的整体设计与方案选型
1.1 为什么大文件经不起一次直接上传
要理解分片上传存在的意义,得先弄清楚一次性上传为什么容易失败。HTTP连接在长时间传输过程中受网络波动、服务器网关超时、浏览器内存占用等因素影响,连接随时可能断开。比如Nginx默认的proxy_read_timeout通常只有60秒,5MB的宽带上传1GB文件需要超过半个小时,期间任何一个节点抖动,整个请求就废了。
就算连接没断,浏览器把一个大文件读进内存再构建FormData发送,内存占用也会非常夸张。File对象本身来自用户磁盘,浏览器不会直接把整个文件载入内存,但在提交multipart/form-data请求时,请求体会被完整缓冲,一个2GB文件的提交过程能把浏览器卡到几乎无法操作。分片之后,每次请求只携带几MB数据,这两类问题都能绕开。
另一层容易被忽视的收益是失败重传成本。全量上传失败要重来,分片上传失败只需要重传失败的那一小块数据。如果再做一层断点记录,刷新页面、断网甚至跨浏览器(配合服务端记录)都能接着传,不用从零开始。
1.2 分片上传的三个核心阶段
整体流程可以拆成三个阶段:切片、上传、合并。
切片阶段用Blob.slice()把File对象按指定字节数切成若干个独立的小Blob。上传阶段把每个分片作为独立请求发送到服务端,服务端按fileId和分片序号落盘保存。合并阶段等所有分片都传完后触发服务端拼接,生成完整文件。
这个流程中最关键的设计是fileId。所有分片必须归属于同一个文件ID,服务端才知道哪些分片该合并到一起。fileId的生成方式直接决定断点续传和秒传能不能做,后面我会专门讲。
1.3 断点续传的两种记录方案对比
断点续传的本质是"状态记录"。上传中断后,重新发起上传时先查一下哪些分片已经有了,跳过它们只传缺失部分。状态记录放在哪里,决定了断点恢复的边界。
第一种是纯前端方案,用localStorage记录已上传分片的索引列表。实现最简,不需要额外接口,但换浏览器、清缓存就失效。
第二种是服务端记录方案,上传接口之外再加一个检查接口,前端上传前先请求"这个fileId已经收到了哪些分片",服务端从临时目录里扫描返回。这种方式不依赖客户端环境,换设备都能续传,也更可靠。
实际项目里两种方案要结合用。localStorage做本地秒级恢复,服务端记录兜底。前端启动时优先读本地记录,同时异步请求服务端校验,以服务端为准,因为服务端记录才是权威的"已接收事实"。
2. 前端核心实现:切片、标识与进度记录
2.1 Blob.slice切片与分片大小怎么定
File对象继承自Blob,所以可以直接调用slice方法。这里注意老版本浏览器有blob.webkitSlice和blob.mozSlice的兼容写法,现代浏览器统一用slice即可。
function createChunks(file, chunkSize) { const chunks = []; let start = 0; let index = 0; while (start < file.size) { const end = Math.min(start + chunkSize, file.size); chunks.push({ chunk: file.slice(start, end), index: index++, start, end }); start = end; } return chunks; }分片大小是工程权衡的结果。分片太小会导致请求数量爆炸,一个2GB文件如果按256KB切就是8192个请求,光是HTTP握手开销就不可忽略。分片太大则单次请求耗时过长,失去了失败重试的粒度优势。我个人经验是1MB到10MB之间比较合理,推荐网络环境一般的时候用2MB,内网或带宽充足用5MB。
最后一个分片往往小于chunkSize,这是正常现象,切片循环用end = Math.min(start + chunkSize, file.size)就是处理这种情况。
2.2 文件唯一标识:hash计算与元数据方案对比
fileId的设计直接决定两个能力:断点续传的准确性,以及是否支持秒传。
最严谨的方案是给文件内容计算hash,比如MD5或SHA1。内容相同则fileId相同,同名同内容的新文件可以直接复用已上传结果,这就是秒传的实现基础。但大文件全量计算hash非常慢,一个1GB文件用纯JS算MD5可能要几十秒甚至更久,而且会卡住主线程。
工程上更常见的折中方案是用文件的name、size、lastModified三个字段拼接作为fileId。优点是毫秒级生成,缺点是文件改名后就识别为不同文件,不能做秒传。两个不同内容但恰好同大小同名字同修改时间的文件概率极低,可以忽略。
如果一定要做秒传,我建议用Web Worker跑hash计算,避免主线程卡死。分片hash和抽样hash也可以大幅度缩短计算时间,比如取文件头部1MB、中间1MB、尾部1MB拼接后算一个hash,精度略降但速度极快。这个方案对绝大多数业务场景足够用。
2.3 上传状态存储结构与断点恢复逻辑
本地断点记录我推荐按fileId维度存储,数据结构类似下面这样:
{ "upload_progress_xxx_fileId_xxx": { fileId: "xxx", fileName: "demo.zip", chunkSize: 2097152, totalChunks: 512, uploadedChunks: [0, 1, 2, 5, 6], updateTime: 1710000000000 } }uploadedChunks存已传成功的分片索引。恢复时从中过滤掉这些分片,剩下的继续传。
这里有个容易踩的坑:localStorage不能存undefined和超大对象。如果文件有上万分片,索引数组会很占空间。虽然localStorage通常有5MB配额,但存储大文件的分片索引可能碰到瓶颈。解决方法是只记录"连续已传分片的最大前缀"和"已传分片集合"两部分,或者干脆把数组压缩成范围区间,比如"0-100, 105, 108-120"这样。
更稳妥的方案是每次上传成功后,把进度同时写入localStorage并触发一次服务端check逻辑。刷新页面后直接从服务端拉已传列表,避免本地存储失效的问题。
2.4 并发上传控制:为什么要限流
分片都准备好了,接下来是上传调度。最省事的写法是for循环全量并发,把所有分片一次性全部发出。小文件没问题,大文件这样写必出问题。
两个原因:一是浏览器对同一域名的并发连接数有限制,HTTP/1.1下Chrome是6个,超额请求会排队,和并发没什么区别;二是全量并发会瞬间占满带宽,造成分片互相争抢网络资源,单个分片的传输速度反而变慢,而且如果服务器限制了连接数,超出的请求直接报错。
推荐的做法是维护一个3到5个并发的小型任务队列。每个worker异步地从待上传列表里取下一个分片,上传完成后再取下一个。这样网络资源利用均匀,也方便统一处理重试逻辑。
async function uploadWithConcurrency(chunks, limit = 3, uploadFn) { const results = new Array(chunks.length); let current = 0; async function worker() { while (current < chunks.length) { const index = current++; const chunkData = chunks[index]; let success = false; for (let retry = 0; retry < 3; retry++) { try { results[index] = await uploadFn(chunkData); success = true; break; } catch (e) { await new Promise(r => setTimeout(r, 1000 * (retry + 1))); } } if (!success) throw new Error(`分片${index}上传失败`); } } await Promise.all(Array.from({ length: limit }, worker)); return results; }这段代码顺带实现了失败重试。单个分片失败最多重试3次,间隔递增,避免服务端瞬时故障导致的全量失败。
3. 服务端配合:校验、落盘与合并方案
3.1 接口设计:检查接口、上传接口、合并接口
前端工作只完成一半,服务端的配合设计同样重要。完整的分片上传链路需要三个接口。
检查接口用于查询已上传分片列表,前端在启动上传时调用,拿到服务端已有的分片索引,跳过它们。上传接口负责接收单个分片文件,按fileId分目录落盘。合并接口在所有分片接收完成后触发,把临时目录里的分片按顺序拼成完整文件。
// 检查接口 app.post('/api/check', (req, res) => { const { fileId } = req.body; const chunkDir = path.join(tempDir, fileId); const uploadedChunks = []; if (fs.existsSync(chunkDir)) { const files = fs.readdirSync(chunkDir); for (const f of files) { const index = parseInt(f.split('_')[0], 10); if (!isNaN(index)) uploadedChunks.push(index); } } res.json({ uploadedChunks }); });上传接口用multer接收文件,文件名按"分片序号_原始文件名"存储在fileId对应的临时目录下。这里的关键是临时目录必须以fileId命名,不同文件的分片才能隔离。
3.2 Node.js后端接收分片与临时目录管理
我用Node.js加Express加multer来演示服务端。multer的diskStorage模式可以直接把分片落盘,不需要先读进内存再写文件,对大文件场景内存更友好。
const multer = require('multer'); const fs = require('fs-extra'); const path = require('path'); const tempDir = path.join(__dirname, 'temp'); fs.ensureDirSync(tempDir); const storage = multer.diskStorage({ destination: (req, file, cb) => { const fileId = req.body.fileId; const chunkDir = path.join(tempDir, fileId); fs.ensureDirSync(chunkDir); cb(null, chunkDir); }, filename: (req, file, cb) => { const chunkIndex = req.body.chunkIndex; cb(null, `${chunkIndex}_${file.originalname}`); } }); const upload = multer({ storage }); app.post('/api/upload', upload.single('file'), (req, res) => { const { fileId, chunkIndex, totalChunks } = req.body; const chunkDir = path.join(tempDir, fileId); const uploadedCount = fs.readdirSync(chunkDir).length; if (uploadedCount >= Number(totalChunks)) { res.json({ needMerge: true, uploadedCount }); return; } res.json({ needMerge: false, uploadedCount }); });每次分片上传成功后,服务端检查当前目录里分片数量是否已经等于totalChunks,如果是就告知前端可以触发合并。
临时目录的清理需要特别注意。合并完成后要把整个fileId目录删掉,否则几轮大文件传完后服务器磁盘会被临时分片占满。可以合并成功时同步删除,也可以定期任务扫描清理超过24小时未合并的目录。我在实际项目中两个都做了。
3.3 合并策略与文件完整性校验
合并的核心是按分片序号从小到大依次读取,写入目标文件。这里要注意不要用多个分片一次性读进内存再拼接,大文件扛不住。用流式处理或者按顺序追加写。
const { createWriteStream } = require('fs'); const { pipeline } = require('stream/promises'); const { createReadStream } = require('fs'); app.post('/api/merge', async (req, res) => { const { fileId, fileName, totalChunks } = req.body; const chunkDir = path.join(tempDir, fileId); const targetPath = path.join(uploadDir, `${Date.now()}_${fileName}`); const writeStream = createWriteStream(targetPath); try { for (let i = 0; i < Number(totalChunks); i++) { const chunkPath = path.join(chunkDir, `${i}_${fileName}`); if (!fs.existsSync(chunkPath)) { throw new Error(`分片${i}缺失`); } await pipeline(createReadStream(chunkPath), writeStream, { end: false }); } } catch (err) { writeStream.destroy(); fs.removeSync(targetPath); res.status(500).json({ message: err.message }); return; } writeStream.end(); fs.removeSync(chunkDir); res.json({ filePath: targetPath }); });这里的pipeline用end: false,意思是每个分片读完后不关闭写入流,继续写下一个分片,全部写完后手动writeStream.end()关闭。
完整性校验有两个层面。分片层面校验:合并时检查每个序号的分片文件是否存在,缺了直接报错,不生成残缺文件。整体层面校验:前端计算整个文件的hash(或大小),合并完成后拿目标文件的大小和hash做比对,不一致说明合并过程出现问题。
4. 工程化要点、进度算法与异常恢复
4.1 进度计算数值精确到小数的技巧
分片上传的进度计算比普通单文件上传复杂,因为同时有多个分片在跑。最直观的算法是:总进度等于已上传分片数除以总分片数,加上当前正在传输分片的权重。
每传输完一个分片就把已完成计数加一,当前正在传输的分片用它的in-flight进度乘以单个分片权重。这样进度条不会因为某个大分片中途卡顿而假死,也不会跳变。
权重按字节算更精确,因为最后一个分片比较小。总进度的公式:
const totalBytes = file.size; let uploadedBytes = 0; // 已完成的字节数 let activePercent = 0; // 当前正在传的分片的进度 // 某个分片完成时 uploadedBytes += chunk.end - chunk.start; // 某个分片进行中,回调里更新 activePercent = (e.loaded / e.total) * (当前分片大小 / totalBytes); const percent = (uploadedBytes / totalBytes) + activePercent;配合XMLHttpRequest的upload.onprogress事件可以拿到单分片的传输进度。fetch目前没有内置的上传进度事件,这也是我推荐用XHR而不是fetch做上传的原因。
4.2 上传中刷新页面:断点恢复的完整流程
用户传了一半刷新了页面,恢复流程是这样跑的:页面加载后读取localStorage记录,拿到上次上传的fileId;调用check接口把本地记录里的已传集合和服务端实际情况对齐;过滤出需要上传的分片列表;继续走并发上传逻辑。
删除已经上传过的临时文件,是另一个常见的边界情况。服务端在合并完成后清理了临时目录,而前端localStorage还留着旧记录。再次上传同一个文件时,前端以为分片都传完了,直接发起合并,服务端却找不到任何分片。所以绝对不能只信本地记录,必须以服务端check的返回为准。
4.3 典型故障排查实录与解决方案
记录几个实际项目中遇到过的高频问题。
分片乱序问题。严格来说HTTP请求发出顺序不代表服务端处理顺序,多个分片并发到达服务端,落盘时间有先后。但因为我们按分片序号命名文件,合并时按序号读取,天然不受乱序影响。只要每个分片文件完整独立落盘,顺序就不重要。
重复分片问题。前端重试机制可能导致同一个分片被上传两次。服务端文件名是分片序号加原始文件名,第二次上传会覆盖第一次的文件,不会产生脏数据。这也是用序号命名而不是用时间戳命名的原因。
服务器重启导致断点失效。临时目录在内存盘或者被清理后,服务端记录的已传分片就丢了。但前端本地记录还认为某些分片传过了。处理方案是check接口的返回作为唯一权威。本地记录和服务端不一致时,以服务端为准,重新上传缺失分片。
大文件hash计算卡死浏览器。前面提到过,全量hash用Web Worker和抽样hash解决。还有一个更轻量的方案:上传时不计算完整hash,用name加size加lastModified组合成fileId,秒传通过单独的上传完成验证逻辑处理。
5. 进阶扩展:从断点续传到秒传
断点续传解决了"传一半断了"的问题,秒传解决的是"同样的文件重复传"的问题。两个能力共用同一个fileId机制,只是判断时机不同。
秒传的流程是:前端拿到文件后先计算fileId(或直接用元数据组合),调用一个秒传检查接口,服务端查询这个fileId是否已经存在完整文件。存在就直接返回成功,前端假装上传完毕,实际一个字节也没传。这就是各大网盘秒传的原理。
服务端要支持秒传,需要一个文件映射表。简单做法是本地用一个哈希表记录fileId和存储路径的对应关系,复杂做法是接入对象存储服务,用fileId作为Object Key,天然去重。前端这边要注意:fileId如果是元数据拼接出来的,只对"同一个文件"有效,换个文件名就失效了。要真正做内容级秒传,还是得计算文件内容的hash,回到Web Worker那套方案上。
另一个值得做的优化是分片大小自适应。弱网环境下2MB的分片都可能频繁超时,而内网环境10MB分片也秒传。可以根据最近几次请求的平均速度动态调整chunkSize,比如上传速度低于500KB/s时改用1MB分片,高于5MB/s时用5MB分片。这个逻辑不复杂,但需要对上传队列做动态重组,工程上稍微有点绕。
当然后端也可以做分布式优化。分片不落本地磁盘,而是直接写入对象存储,合并操作变成在对象存储服务端发起multipart upload完成。这是云存储架构的标准能力,适合真正海量用户并发的场景。普通业务先把本地方案跑稳就足够。
分片上传这套方案,本质上是用空间换时间、用状态换容错。把大问题拆成小问题,把全量重试变成部分重试,把"什么都不知道"变成"每次都知道上次做到哪里"。我在实际项目里落过这套流程后,再也没被测试同事拿2GB视频文件卡上传的问题喊过。核心就记住三条:分片要敢切、记录要勤写、检查接口要做权威。把这三件事做扎实,断点续传不会出什么大问题。