大文件分片上传:原理、源码与断点续传实战指南

发布时间:2026/10/18 5:20:33
大文件分片上传:原理、源码与断点续传实战指南
一个2GB的视频文件上传进度走到87%手机切了个Wi-Fi上传失败用户回来发现进度条归零气得直接在工单里开骂。这个场景我接手的内容管理后台里几乎每周都要上演一次。后来我把组件改成了大文件分片上传配合视频上传的前置处理类似的投诉基本绝迹了。这篇文章把我在这类网页组件里集成分片上传的完整路径写出来大文件分片上传的核心原理、可直接复制的前端源码、断点续传与重试机制的工程细节、视频上传特有的预览与封面处理以及后端合并校验要关心的几个点。无论你是正在做内容后台、视频托管页面还是想给自己的表单加一个能扛住大文件的上传组件这篇都能当参考。代码尽量不绑框架浏览器原生能力加常规后端逻辑拿走就能改造。1. 普通上传在大文件面前必然翻车问题到底出在哪1.1 三道最常见的死结先说第一道服务端的请求体限制。大多数Web服务器和中间层都会对单次请求体大小做限制默认值往往只有几MB到几十MB。你传一个500MB的文件请求还没到业务代码就被拦住了。就算你改大了限制网关和反向代理往往还有超时时间动辄几十秒没传完就会断开连接前端拿到的是一个莫名其妙的网络错误。第二道是服务端内存。很多后端框架在处理请求时会把整个body读进内存一个并发上传2GB文件服务进程的内存瞬间就上去了。稍微多个几个人同时传机器直接打满连带着其他正常接口都变慢。这种情况在视频场景特别明显因为视频文件永远是大头。第三道是用户体验。整文件上传是一个不可分割的请求失败就是彻底失败没有接着传这回事。用户在地铁里、电梯里、跨楼层切换网络TCP长连接早就断没了。前端最多给个进度条但那个进度条一旦清零用户对产品的信任也就归零了。1.2 分片是怎么把问题拆掉的分片上传的思路很简单把一个2GB的文件按固定大小切成几百个小块每个小块单独发起一个上传请求全部传完后由服务端按顺序拼回原文件。这么做有三个直接收益。第一单个请求的体积变小了2MB一个分片就算网络差一个分片也能在几秒内传完服务器限制和网关超时基本碰不到。第二失败成本被摊薄某个分片传失败了只重传那一个分片不需要整个文件重来。第三可以并发多个分片同时往上传整体速度反而比单请求快。再加上文件哈希做秒传、已传分片列表做续传整个上传体验就完全不一样了。对比项整文件上传分片上传请求体大小几百MB~几GB2MB~10MB中途失败全部重来只重传失败分片并发能力无可多分片并行服务器内存压力高低是否支持续传不支持支持2. 分片上传的骨架一条大文件从本地到服务器的完整路径2.1 三个关键标识uploadId、fileHash、chunkIndex分片上传这套流程里有三个标识贯穿始终理解它们后面的代码就顺了。uploadId是一次上传任务的唯一编号。前端在发起上传前先请求一个初始化接口后端为这次上传建一个任务记录返回一个uploadId。之后所有分片都挂在它下面最后合并也靠它把碎片归拢到一起。可以把它理解成快递单号一票货从揽收到签收都跟着这个号走。fileHash是整个文件的签名。前端在切片之前会先对文件内容算出一个哈希值传给后端。后端拿着这个哈希去存储里查如果发现一模一样的文件已经存在就不用传了直接返回秒传成功。这就是所谓秒传的底层逻辑。chunkIndex是分片序号从0开始递增。服务端靠它判断碎片是否齐全、合并时按什么顺序拼接。缺了哪个分片一查就知道。这几个字段加上分片总数量、每个分片的大小、原始文件名就组成了分片上传协议的核心数据。2.2 服务端怎么知道碎片齐了临时目录加顺序合并后端在收到初始化请求后通常会为这个uploadId建一个临时目录例如tmp/uploadId/每个分片到达后就直接落盘文件名里带上分片序号比如fileHash_0.part、fileHash_1.part。等前端把全部分片传完再调用一次合并接口。后端检查临时目录里的分片数量是否等于声明总数按序号顺序读出来追加写入最终文件。这个过程必须用流式写入边读边写不能把分片都加载到内存里再合并否则2GB的视频文件又会把内存打爆。合并完成之后临时目录清掉最终文件进入正式存储区可以被访问、被转码、被播放了。2.3 秒传与断点续传其实是同一份清单很多人觉得秒传和断点续传是两个高级功能其实它们背后都依赖同一个东西已存在内容的清单。秒传查的是最终存储区哈希命中了就直接返回续传查的是临时目录看看当前uploadId下哪些分片已经传过了把已传的分片从任务列表里剔除。前端拿到这个清单构建任务的时候过滤一遍代码量和复杂度都不高但这两个功能对上线的观感提升极大。3. 前端上传组件源码从选择文件到并发调度3.1 文件选择与基础校验入口是一个普通的文件选择框但要注意两个细节。一是accept属性只能起到提示作用不能当安全边界用户照样能选择其他类型的文件所以读取后还要在代码里做二次校验。二是视频文件建议用video/*加常见扩展名的组合否则某些移动端浏览器可能过滤掉部分合法格式。input typefile idfileInput accept.mp4,.mov,.mkv,.avi,video/* /const input document.querySelector(#fileInput); input.addEventListener(change, async (e) { const file e.target.files[0]; if (!file) return; const allowedExtensions [mp4, mov, mkv, avi, webm]; const ext file.name.split(.).pop().toLowerCase(); if (!allowedExtensions.includes(ext)) { alert(不支持该文件格式请选择视频文件); return; } // 还可以加 file.size 上限判断比如单文件最大 5GB startUpload(file); });这里多说一句格式校验最好放在选择时而不是等几秒后上传到一半再报错用户体验完全两个级别。3.2 不读爆内存的哈希计算计算整个文件的哈希最直接的做法是file.arrayBuffer()一次性把整个文件读进内存再算。但一个2GB的视频这么干内存直接爆炸手机浏览器当场白屏。更合理的做法是逐分片计算哈希再把分片哈希串聚合算出最终值。这样每次只读一个分片进内存2MB一个循环既拿得到文件整体指纹又不会卡死页面。代价是它不等于对整个文件做一次完整哈希但对于秒传识别和去重来说完全够用没有人会拿一个上传组件去较真密码学级别的完整性。async function calcFileHash(file, chunkSize) { const chunkHashes []; let offset 0; while (offset file.size) { const blob file.slice(offset, offset chunkSize); const buffer await blob.arrayBuffer(); const digest await crypto.subtle.digest(SHA-256, buffer); const hex Array.from(new Uint8Array(digest)) .map((b) b.toString(16).padStart(2, 0)) .join(); chunkHashes.push(hex); offset chunkSize; // 让出主线程避免大文件循环时界面卡顿 await new Promise((resolve) setTimeout(resolve, 0)); } const joined chunkHashes.join(,); const finalDigest await crypto.subtle.digest( SHA-256, new TextEncoder().encode(joined) ); return Array.from(new Uint8Array(finalDigest)) .map((b) b.toString(16).padStart(2, 0)) .join(); }3.3 分片任务构建与真正的切片切片用的就是File.prototype.slice方法这是浏览器原生API不需要任何第三方库。关键点是浅切片它返回的是一个引用原文件某段范围的Blob对象并不会真正拷贝数据所以构建上千个分片任务也不会产生内存拷贝开销。function buildChunkTasks(file, chunkSize, fileHash, uploadId) { const totalChunks Math.ceil(file.size / chunkSize); const tasks []; for (let i 0; i totalChunks; i) { const start i * chunkSize; const end Math.min(start chunkSize, file.size); tasks.push({ uploadId, fileHash, index: i, totalChunks, blob: file.slice(start, end), size: end - start, }); } return tasks; }分片大小我建议取2MB到5MB之间。太小了一个1GB文件会产生几百个请求服务端压力大请求开销占比也高太大了又退回到一个请求传很久的老路。高情商的说法是根据业务调优低情商的说法是2MB在大多数弱网环境都好使5MB在宽带场景更省请求数自己压测一下选一个。3.4 带重试的并发调度与进度汇总并发控制我习惯用一个简单的固定工人数模型把分片任务放进队列开N个worker同时取任务执行谁先完成谁继续取下一个。这种写法的好处是代码量小、行为直观而且天然不会让某个空闲worker闲着。async function uploadInChunks(file, { chunkSize 2 * 1024 * 1024, concurrency 3 } {}) { const fileHash await calcFileHash(file, chunkSize); // 1. 初始化上传任务拿到 uploadId若 hash 已存在则直接秒传 const initResp await fetch(/api/upload/init, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ fileHash, fileName: file.name, fileSize: file.size }), }).then((r) r.json()); if (initResp.exists) { return { status: instant, url: initResp.url }; } const uploadId initResp.uploadId; // 2. 查询已传分片做断点续传 const statusResp await fetch(/api/upload/status?uploadId${uploadId}).then((r) r.json()); const uploadedSet new Set(statusResp.uploadedChunkIndexes || []); const allTasks buildChunkTasks(file, chunkSize, fileHash, uploadId); const pendingTasks allTasks.filter((task) !uploadedSet.has(task.index)); const totalChunks allTasks.length; let doneCount uploadedSet.size; // 3. 单分片上传函数内部自带重试 async function uploadOne(task) { const form new FormData(); form.append(uploadId, task.uploadId); form.append(fileHash, task.fileHash); form.append(chunkIndex, task.index); form.append(totalChunks, task.totalChunks); form.append(size, task.size); form.append(chunk, task.blob, ${task.fileHash}_${task.index}.part); let lastError; for (let retry 0; retry 3; retry) { try { const resp await fetch(/api/upload/chunk, { method: POST, body: form }); if (!resp.ok) throw new Error(chunk ${task.index} upload failed: ${resp.status}); return; } catch (err) { lastError err; // 指数退避1s、2s、4s await new Promise((resolve) setTimeout(resolve, 1000 * Math.pow(2, retry))); } } throw lastError; } // 4. 固定并发数的队列调度 const queue [...pendingTasks]; async function worker() { while (queue.length) { const task queue.shift(); await uploadOne(task); doneCount 1; onProgress onProgress(doneCount / totalChunks); } } await Promise.all(Array.from({ length: concurrency }, worker)); // 5. 请求合并接口 const mergeResp await fetch(/api/upload/merge, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ uploadId, fileHash, totalChunks }), }).then((r) r.json()); return { status: done, url: mergeResp.url }; }注意uploadOne里的重试循环每次重试都新建FormData而不是复用同一个。因为Fetch请求体一旦发送FormData对象内部的状态可能被消费重试时重新构造才能保证二进制数据完整。3.5 进度条与状态切换进度计算很简单已完成分片数 / 总分片数但要做对两件事初始进度要包含续传的分片数否则用户上次传到80%这次重新进页面又从0%开始跳会产生怎么白传了的错觉合并阶段单独显示状态分片传完后进度会停在100%附近但此时后端还在合并这时候要显示合并中不然用户以为卡死了。let lastProgress 0; function onProgress(ratio) { if (ratio lastProgress) { lastProgress ratio; // 更新进度条 width Math.round(ratio * 100) % } } function setState(state, extra) { if (state merging) { // 显示合并且按钮禁用 } else if (state done) { // 显示完成、播放地址 } else if (state error) { // 显示错误原因与重试入口 } }提示视频上传完成后的合并阶段往往比最后一个分片耗时更长尤其是大文件。前端一定要给用户一个明确的合并中等候信号而不是让进度条孤零零停在99%不动。4. 视频上传要单独考虑预览、元信息、封面与转码4.1 objectURL让用户边传边看视频上传和普通文件上传最大的差别在于用户对我传的这个东西对不对有强烈的确认需求。几十秒的预览可以大幅降低传错的风险。浏览器有个能力叫Object URL用URL.createObjectURL(file)给本地文件生成一个临时地址可以直接塞给video标签播放完全不需要等上传完成。用户选完文件就能看到视频内容和时长体验接近本地播放器。function previewVideo(file) { const url URL.createObjectURL(file); const videoElement document.querySelector(#previewVideo); videoElement.src url; videoElement.style.display block; // 注意切换或完成上传后记得 URL.revokeObjectURL(url) 释放内存 }提示revokeObjectURL要在视频不再使用之后调用。我见过有人选完文件立刻revoke结果预览黑屏。正确时机是组件销毁、用户切到下一个文件或上传成功不再需要预览时。4.2 读取时长和分辨率把不兼容的格式尽早挡掉视频元信息同样可以在本地读取。核心做法是用一个隐藏的video元素设置preloadmetadata加载完成后就能拿到duration、videoWidth、videoHeight。这套逻辑可以同时用于两件事一是展示给用户二是做上传前的格式和规格判断。function loadVideoMeta(file) { return new Promise((resolve, reject) { const video document.createElement(video); video.preload metadata; video.onloadedmetadata () { const meta { duration: video.duration, width: video.videoWidth, height: video.videoHeight, size: file.size, name: file.name, }; URL.revokeObjectURL(video.src); resolve(meta); }; video.onerror () { URL.revokeObjectURL(video.src); reject(new Error(无法读取视频信息)); }; video.src URL.createObjectURL(file); }); }拿到这些信息后可以在上传前做几道判断时长超过限制的直接拒绝分辨率异常的给提示某些浏览器能播但服务端转码困难的封装格式提前告知用户可能无法播放预览。有一点要认清浏览器能不能放和服务端转码能不能转是两码事。比如部分移动设备拍出来的视频是HEVC编码浏览器可能能放但服务端的老转码链路不支持出来的视频就黑了。这种坑最好在上传前通过格式名加编码探测识别出来。4.3 封面截取加上传完成后的转码联动视频封面的常见做法是把video元素跳到某个时间点通常第1秒等画面出来后用canvas把当前帧画出来再导出成图片。async function captureCover(file, timeSec 1) { const video document.createElement(video); video.muted true; // 必须静音否则部分浏览器会因自动播放策略拦截 video.src URL.createObjectURL(file); await new Promise((resolve, reject) { video.onloadeddata resolve; video.onerror reject; video.currentTime Math.min(timeSec, video.duration || 1); }); const canvas document.createElement(canvas); canvas.width video.videoWidth; canvas.height video.videoHeight; canvas.getContext(2d).drawImage(video, 0, 0); URL.revokeObjectURL(video.src); return canvas.toDataURL(image/jpeg, 0.7); }这里有个容易踩的点捕获封面必须在用户交互的上下文里进行。如果页面初始化时就尝试加载视频并截帧就会被自动播放策略拦下来。我通常是在用户点选文件之后调用这时浏览器认为有交互手势能顺利出帧。封面截取只是前端辅助。上传完成的后端联动才是视频业务的重头戏合并完成后服务端应该把文件地址丢进转码队列转出多码率的播放版本和缩略图再把状态回传。前端在合并成功后轮询或通过通知拿到转码状态才真正给用户展示可播放。5. 弱网下的生存法则重试、续传与并发控制5.1 分片级重试与指数退避上面源码里的重试循环已经体现了核心思路失败只重传当前分片并且重试间隔逐步拉长。第一次失败等1秒第二次等2秒第三次等4秒这叫指数退避。它的意义在于如果网络短暂抖动快速重试能兜住如果网络持续恶化不给服务器造成连环重试的压力。我见过的反面教材是一失败就立刻重试且无限重试结果在网络恢复前客户端已经和服务端来回拉扯了十几个回合日志里全是超时记录。加个最大重试次数上限超过就终止整个上传明确告诉用户网络实在不行请稍后再试比傻等更可靠。5.2 断点续传的两个信息来源续传信息有两个来源一个是前端本地存储一个是服务端查询。本地存储的做法是把uploadId、文件哈希、已传分片列表记在浏览器的本地存储里页面刷新后还能恢复。它的问题是不可控用户换浏览器、清缓存、换设备就丢了。而且不同浏览器对本地存储容量有上限几MB的分片索引还好如果分片数过千序列化后的字符串会变得很大。我更推荐以服务端查询为准文件重新选择后先算哈希再调初始化接口拿到uploadId然后请求已传分片列表把已传的跳过。这样不依赖任何本地状态只要临时目录里的分片还在换台电脑登录同一账号都能续传。前端本地存储只能作为弱网时的加速手段不是主方案。5.3 并发数、切片大小到底怎么调这是每次分享都会被问到的问题直接给一组我实测下来的参考区间场景切片大小并发数理由手机4G/弱网2MB2~3请求体小单分片易成功并发太高会加剧弱网丢包普通宽带5MB3~5平衡请求数量与总耗时内网/高速链路10MB5~8传输快减少请求数降低服务端压力并发不是越大越好。设置太高网络环境差时大量请求同时失败重试风暴会让服务端雪上加霜。另外要注意浏览器的同域连接数限制常规浏览器对一个域名同时建立的连接数在6个左右并发开10个也没意义还会挤占页面其他请求的资源。提示分片大小、并发数这类参数一定不要在代码里写死后就再也不动。给它们留出配置项线上出问题时能通过后端开关临时调整比改代码发版本快得多。6. 后端接口设计合并、校验与清理6.1 四个接口的约定整个分片上传流程后端只需要四个接口设计得足够清晰接口作用关键入参关键返回POST /api/upload/init初始化上传fileHash、fileName、fileSizeuploadId、exists、urlPOST /api/upload/chunk接收单个分片uploadId、chunkIndex、totalChunks、chunk文件okGET /api/upload/status查询已传分片uploadIduploadedChunkIndexesPOST /api/upload/merge合并分片uploadId、fileHash、totalChunksurl、size初始化接口里有一个关键逻辑如果fileHash在最终存储区已存在直接返回exists: true和现有的访问地址前端就不会再传任何一个字节了。这个判断要放在创建临时目录之前省得产生垃圾目录。对于已存在的任务还要加过期策略。用户可能初始化了没传或者传了一半离开页面这些临时目录如果永远留着磁盘迟早被垃圾分片占满。我习惯在合并接口和定时清理任务里双重把关超过24小时没有新分片写入的临时目录直接删除。6.2 合并时校验什么合并接口是碎片变成文件的关键一步至少要做三项校验第一分片数量与序号完整性。合并前遍历0到totalChunks-1逐个检查临时分片文件是否存在缺任何一个都拒绝合并返回缺失分片序号让前端能精准补传。第二文件字节数一致性。前端在初始化时声明了原始文件大小合并时把所有分片大小累加起来如果和声明不一致说明有分片内容损坏或伪造直接报错。第三合并方式要用流式。大文件合并绝对不能把所有分片读进内存再concat。按顺序打开每个分片流式写入最终文件内存占用恒定在单分片大小附近。// 合并逻辑示意以JavaScript服务端环境为例路由注册按框架习惯来 async function handleMerge({ uploadId, fileHash, totalChunks, expectedSize, ext }) { const tmpDir path.join(TMP_ROOT, uploadId); const targetPath path.join(STORAGE_ROOT, ${fileHash}.${ext}); // 校验1分片齐全性 let sizeSum 0; for (let i 0; i totalChunks; i) { const chunkPath path.join(tmpDir, ${fileHash}_${i}.part); if (!await exists(chunkPath)) { throw new Error(missing chunk: ${i}); } sizeSum await getFileSize(chunkPath); } // 校验2总字节数一致性 if (sizeSum ! expectedSize) { throw new Error(size mismatch: expected ${expectedSize}, got ${sizeSum}); } // 流式顺序写入避免大文件合并吃内存 for (let i 0; i totalChunks; i) { const chunkPath path.join(tmpDir, ${fileHash}_${i}.part); await appendFileTo(targetPath, chunkPath); } // 合并完成清理临时目录 await removeDir(tmpDir); return { url: /file/${fileHash}.${ext} }; }6.3 临时分片清理与安全细节最后聊几个容易忽略但必须做的细节。安全扩展名校验。合并时最终文件的扩展名不能直接信任前端传的原始文件名要拉白名单比如只允许mp4、mov、webm等。否则攻击者可以传一个伪装成视频的脚本文件合并后放在可执行目录里就是严重的安全事故。路径穿越防护。文件名、uploadId如果直接拼到文件路径里存在被注入的风险。规范做法是用服务端生成的随机标识做目录名原始文件名只保留展示用途不在路径中使用。临时分片清理的兜底。合并成功后清空临时目录只是常规操作还要定期清理那些初始化后就没有后续动作的孤儿任务。我的经验是每次合并成功或者初始化新任务时顺手扫一次过期目录不让清理逻辑依赖单独的定时任务双保险。最后分享一个调试习惯我把分片大小、并发数、重试次数全部做成可配置项每次现场碰到上传问题先看是哪个环节失败的是初始化、分片传输还是合并。大部分问题其实不在分片逻辑本身而在网络代理层的超时设置和服务端的并发写入能力。把这些排查一遍上传组件基本就能安稳跑很久。