大文件上传不再难:分片上传+断点续传完整实现指南

发布时间:2026/10/18 12:06:45
大文件上传不再难:分片上传+断点续传完整实现指南 做过大文件上传的同学应该都有体会视频素材、系统安装包、设计稿压缩包动辄几个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视频文件卡上传的问题喊过。核心就记住三条分片要敢切、记录要勤写、检查接口要做权威。把这三件事做扎实断点续传不会出什么大问题。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询