前几天我想把一个 3GB 的压缩包从工作室传到家里,结果网络在 87% 的时候断掉了,再看进度条已经归零。那一刻我脑子里冒出来的第一个念头就是:任何一个做上传功能的人都该把 秒传、断点续传、分片上传 这三件事搞明白。这篇文章不打算做概念科普,而是直接把我做这一套上传方案时踩过的坑、设计过的接口、调过的参数都摊开讲。如果你正在写上传模块,或者想彻底弄懂大文件上传背后的策略,这篇内容应该能让你少走不少弯路。
1. 先说清楚:这三种能力到底解决什么问题
1.1 一个让用户崩溃的失败场景
大文件上传最伤人的不是慢,而是失败后一切推倒重来。用户花二十分钟等到 99%,一个闪断全部清零,这种体验几乎等同于事故。我最早做某个后台管理系统时就遇到这个投诉:运维同事上传打包好的日志,传到一半断了,然后又得重新选文件、重新点上传,重复操作直到怀疑人生。他们的原话是"你们这个功能是不是根本没测试过"。其实功能确实能传,只是单个请求太大,中间任何一点抖动都会把整个任务打回原形。
这个问题在我脑子里留了很久。后来接手的项目里要传的数据越变越大,动辄几个GB的视频素材、数据库备份、客户端安装包,我开始认真研究秒传、断点续传、分片上传的组合方案。市面上网盘类产品早就把这套能力做成标配,但真正落到自研系统时,细节远比想象中复杂。
1.2 三种策略各自的定位
先说结论:这三种能力解决的是三个不同层面的问题。
- 分片上传解决的是"单个请求扛不住大文件"的问题。一个 2GB 的文件塞进一次请求,超时、内存、网关限制都会冒出来。
- 秒传解决的是"文件实际上已经存在,不需要再传"的问题。同一份内容可能被不同用户重复上传,服务端如果已经存过,完全可以跳过上传过程。
- 断点续传解决的是"传了一半断了,尽量不用从头再来"的问题。它不是提速,而是让失败的成本不再与文件大小成正比。
这三者经常被放在一起提,因为大文件上传的痛点确实太密集:请求容易超时、服务端对请求体有大小限制、网络波动不可预测、用户还可能重复上传同一个文件。分片上传是承重墙,断点续传是恢复机制,秒传则是基于内容去重的特殊优化。
1.3 它们之间不是递进关系
很多文章把三者写成"分片→断点→秒传"的递进流程,其实不对。秒传和分片并不必要绑定:一个文件如果之前传过完整版本,以秒传方式可直接跳过,跟分不分享片没有任何关系。断点续传则必须建立在分片上传之上,没有分片就没有"已上传分片列表"这个认知维度。
所以更准确的理解是:三个机制是三个独立维度,只是在实际方案里经常协同工作。秒传可以独立部署,分片加上服务端状态记录就自然获得了断点续传能力。理解了这层定位,后面设计流程时才不会把接口搞乱。
2. 分片上传:一切的基础
2.1 为什么大文件必须分片
以浏览器上传为例,把整个文件塞进一次 HTTP 请求,至少有四个现实问题。
第一是内存。表单或二进制流会在前端、网关、后端处理过程中占用大量内存,几个 GB 的文件可能让服务端直接吃不消。第二是超时。一个传输需要几分钟,任何一层代理断开连接都会让请求失败,失败后的重试等于重新上传整个文件。第三是命中各种限制。网关、Web 服务器、后端框架默认对请求体大小有明确上限,超了就返回 413,用户看到的就是"上传失败"。第四是进度和重试粒度太大。只能显示"正在上传",失败后没有精确到块的重试手段,几乎只能从头再来。
分片的本质,就是把一个大任务拆成多个小任务,小到任意一个任务失败后可以被廉价地重试。生活里也更常见:搬家的时候用纸箱装箱,而不是直接扛衣柜。单个纸箱丢了、破了,重新补一个就完事,不需要把整个家再搬一遍。
2.2 分片大小怎么定
分片大小没有放之四海而皆准的唯一答案。我把常见选择整理成下表供参考:
| 分片大小 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| 1-2MB | 弱网环境、移动端 | 单片传输快,重试粒度小 | 分片数量多,HTTP 请求开销大,哈希计算次数多 |
| 5MB | 大多数 Web 上传场景 | 请求数量和网络开销均衡 | 需要根据带宽适当控制并发 |
| 10MB+ | 内网或高带宽场景 | 请求数少,合并效率高 | 弱网下单片耗时太长,失败重试代价大 |
我习惯先按目标带宽算一遍。假设用户上行带宽是 2MB/s,选 5MB 分片,单片理论耗时约 2.5 秒,加上网络往返和写盘,大约 3 秒左右,这个时长非常安全。如果上行带宽只有 200KB/s,5MB 分片可能就要 25 秒,明显太久,这时候把分片调到 1MB 更合适。
所以更合理的做法是前端先做一次测速,根据测速结果动态决定分片大小,而不是写死一个常量。不过这样做会引入额外的测速请求,项目早期可以先用一个固定值,等真正的弱网反馈出现后再做动态调整也不迟。
2.3 上传接口的最小闭环
分片上传的核心是三个操作:初始化上传、上传分片、合并分片。前端通过File.slice()拿到指定范围的二进制内容:
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB,可动态调整 const file = input.files[0]; const totalChunks = Math.ceil(file.size / CHUNK_SIZE); async function uploadChunk(uploadId, index, blob) { const formData = new FormData(); formData.append('uploadId', uploadId); formData.append('index', index); formData.append('blob', blob, `${file.name}.part${index}`); const resp = await fetch('/api/upload/chunk', { method: 'POST', body: formData }); if (!resp.ok) throw new Error(`chunk ${index} failed`); }初始化接口需要返回一个唯一的uploadId,服务端用它标识这次完整上传会话。合并接口则把所有分片按索引拼接成完整文件。合并前,前端最好是全部分片上传成功后再发起合并,避免服务端拿着残缺分片开始拼接。
提示:初始化时一定要把
totalChunks、hash、totalSize传给服务端,这些参数是后面秒传和断点续传判断的基础,会在后面的流程里反复用到。
3. 秒传的实现:不只是算个哈希
3.1 秒传是拿"内容指纹"去比对
秒传的核心思想是"这文件我见过"。判断两个文件是否相同,最可靠的维度是内容,不是文件名和路径。同一个文件换了名字从不同渠道传进来,业务上完全没必要重新上传。
技术实现通常是对文件内容计算一个哈希值,比如 MD5 或 SHA-1,再把hash + size作为内容指纹发给服务端,服务端在索引里查一下,如果已存在就返回"跳过上传,直接完成"。
这里必须强调:MD5 在这里是"一致性校验"用途,不是"安全加密"用途。它确实有碰撞风险,但大文件上传业务里,碰撞概率远低于宕机概率,真正要考虑的是有人恶意构造碰撞的情况。如果安全要求高,可以配合size做前置过滤,再加上对文件头尾若干字节的二次校验,成本可控,已经能堵住绝大多数碰撞。
3.2 大文件算哈希,最容易被忽略的耗时
秒传看起来简单,真正的坑在算哈希本身。一个 2GB 的文件,在前端主线程算 MD5,机器稍差就可能卡住页面,用户以为浏览器崩溃了。
我的处理方式是用 Web Worker 在后台线程算,避免阻塞 UI,同时给 UI 一个明显的"正在校验文件"状态。普通场景用现成的 MD5 实现就够了,追求性能再考虑 WASM 方案。
另一个务实的优化是"先快后慢"。文件选择完成后,先用size做一次粗筛:如果服务端连大小都对不上的文件,肯定不存在,根本不需要算全文哈希。再进一步,可以先抽文件的头部、尾部、中间若干 KB 算一个"快速指纹",如果快速指纹都撞不上直接放弃。但这只能作为前置加速手段,秒传的最终判定建议仍然以全量哈希为准。
3.3 秒传接口的完整交互
一次秒传检查至少需要两个接口:一个查重,一个根据uploadId完成上传。我习惯把查重融入初始化流程:
const resp = await fetch('/api/upload/init', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ fileName, fileSize, fileHash, totalChunks }) }); const data = await resp.json(); if (data.existed) { // 秒传命中,直接标记完成 showSuccess(); return; } // 未命中,继续走分片上传服务端拿到fileHash + fileSize后,先查文件内容表。如果命中,返回{ existed: true, uploadId: null };如果未命中,则创建一条上传记录,返回新的uploadId和已有的分片列表。这里有个容易被坑的细节:秒传命中时不返回uploadId,前端就别走后续分片逻辑了,否则容易白传一遍。
4. 断点续传:把分片清单变成一种"状态机"
4.1 断点续传的本质
断点续传要回答的问题只有一个:"我已经传了哪些,还差哪些?"
如果整个文件被切成 100 个分片,已经上传了 37 片,断线后再连上,只要继续传剩余的 63 片就好,不需要从第 0 片重新来。要实现这个能力,必须把"分片上传的进度"持久化下来,而且存储一定要放在服务端,不能只存在浏览器本地。
为什么服务端是必须的?因为用户可能清缓存、换设备、换浏览器。只靠 localStorage 或者 IndexedDB 记录,一旦清掉就完蛋。服务端只需为每个uploadId保存一份"已完成分片索引集合",恢复时让前端主动来查,就能准确接力。
4.2 服务端的分片状态设计
一个最小可用的状态结构可以这样设计:
uploadId:上传会话唯一标识fileHash、fileSize:文件内容指纹,用于秒传和防串数据chunkSize、totalChunks:分片规格uploadedChunks:已完成分片的索引集合,用哈希表存储status:uploading -> merging -> done
上传分片接口收到请求后,先判断该索引是否已在uploadedChunks里,如果已在就直接返回成功。这样做的目的是让上传接口变成幂等的:客户端重复发相同分片,服务端不会重复存储,也不会把状态搞乱。
前端断点恢复的核心逻辑其实很短:
const statusResp = await fetch(`/api/upload/${uploadId}/status`); const status = await statusResp.json(); const uploadedIndexes = new Set(status.uploadedChunks); for (let i = 0; i < totalChunks; i++) { if (uploadedIndexes.has(i)) continue; // 已传过,跳过 const start = i * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, file.size); await uploadChunk(uploadId, i, file.slice(start, end)); }这里的核心原则是:以服务端返回的已上传分片集合为准,而不是本地记录。因为本地记录可能过期,服务端记录才是会真正合并的凭据。
4.3 文件被改动,断点立即失效
断点续传有一个天然前提:文件内容没变。如果用户传到一半,原文件被重新替换,内容变了,此时继续上传旧的uploadId会造成合并出的文件前后不一致。
稳妥做法是:上传前重新计算文件哈希,与本地或者服务端保存的哈希比对。如果不一致,作废旧uploadId,重新初始化上传。这里的"重新初始化"成本远低于把错误分片硬传到底、最后合并校验失败再返工。
4.4 断点恢复的异常分支
还有个隐蔽问题:如果服务端因为磁盘清理把分片删了一部分,前端本地却还认为都传完了,就会跳过请求,导致合并时缺片。
所以合并前服务端必须做完整度校验,不能只信前端的"我传完了"。前端拿到 status 后,最好也以服务端返回的uploadedChunks为准。一个务实的设计是:前端每次进入页面时,先通过fileHash查一次"是否有没有完成的上传记录",如果有,直接把状态恢复出来,用户甚至不需要手动选择"继续上传"。
5. 把三套机制串成一条完整的上传链路
5.1 一次上传的完整状态流转
当三个机制放在同一套系统里,完整流程会变成这样的状态序列:
- 状态 A:用户选择文件,前端读取文件名、大小,文件哈希计算任务进入后台。
- 状态 B:哈希计算完成,携带
fileHash + fileSize请求初始化接口,服务端先判断秒传。秒传命中则直接完成。 - 状态 C:秒传未命中,服务端返回
uploadId和已上传分片集合,前端依据这个集合跳过已完成分片。 - 状态 D:对未上传分片发起并发上传,上传过程中断则暂停或报错。
- 状态 E:重新进入上传页面时,前端通过文件哈希找到历史
uploadId,继续上传未完成分片。 - 状态 F:全部分片上传成功,触发合并,并轮询合并结果。
这个流程看起来不复杂,但每一步都有需要前后端统一的判定规则。比如状态 B 的秒传命中,本质上等于"不需要分片、不需要合并的资源复用";状态 C 返回的已有分片,则必须优先于任何本地记录,避免缓存不一致。
5.2 接口一览表
我把自己用的最小接口集整理如下:
| 接口 | 作用 | 关键入参 | 返回 |
|---|---|---|---|
| POST /api/upload/init | 初始化、秒传判定 | filename, size, hash, totalChunks | existed, uploadId |
| POST /api/upload/chunk | 上传单个分片 | uploadId, index, blob, chunkHash | ok |
| GET /api/upload/{uploadId} | 查询上传状态 | uploadId | uploadedChunks, status |
| POST /api/upload/merge | 触发合并 | uploadId, totalChunks | mergeTaskId |
| GET /api/upload/merge/{taskId} | 查询合并结果 | mergeTaskId | status, finalPath |
这套接口设计足够支撑秒传、断点续传和分片上传的组合。注意在init阶段就把秒传和初始化合并了:先查内容指纹,不存在时直接创建uploadId,省得前端多调一次查重接口。
5.3 进度与 UI 计算
进度条其实有两种算法。最直观的是"已传字节数 / 总字节数",但它不好处理并发和秒传的分支。
更可靠的是"已成功分片数 / 总分片数"。前端维护一个uploadedIndexes集合,每成功一个就加进去,进度就是uploadedIndexes.size / totalChunks * 100。如果秒传命中,直接显示 100%;如果有断点恢复,初始进度就是服务端返回的uploadedChunks.length / totalChunks * 100。这样用户可以清晰看到进度跳变,也方便断点恢复时直接展示一个合理的初始值。
5.4 合并的超时处理
合并大文件在服务端也可能是个耗时操作,尤其分片很多时,边读边写的合并过程可能要几十秒。
所以合并接口不要同步等待到最后,而是启动一个后台任务,立即返回mergeTaskId,前端靠轮询获取结果。同步接口很容易触发网关超时,前端会收到一个失败响应,但服务端可能还在继续合并,这种"半成功半失败"的状态非常难处理。异步化是更成熟的做法。
6. 落地过程中的坑与实测建议
6.1 并发分片必须靠索引排序,不能靠到达顺序
开并发上传多个分片时,服务端如果随意把分片追加到文件末尾,最终文件必然错乱。合并时唯一可靠的方式是:每个分片携带index,服务端按index放入占位槽,全部就位后从 0 到 N 依次拼接。
这里需要注意内存占用。分片要写入临时文件,而不是缓存在内存里。一个分片按 5MB 算,并发 5 个也才 25MB 临时缓冲,通常可控。但如果你把 100 个分片全部读进内存再拼接,大文件场景内存直接就爆了。
6.2 所有上传分片都要做哈希校验
每个分片上传时,服务端可以比对chunkHash。如果某个分片在传输过程中损坏,服务端直接拒绝,前端捕获到错误后重传这个分片即可。
合并完成后,最好再对整个文件算一次哈希,与初始化时提交的fileHash比对。这个步骤做不做,直接影响最终文件的可靠性。我在生产环境里发生过一次分片损坏没被发现的情况,用户下载下来才发现压缩包打不开,排查成本极高。从那以后我强制要求分片级和文件级校验都做,宁可多花一点 IO,也不赌网络不会出错。
6.3 重试必须幂等:服务端别存重复分片
分片上传经常会出现"前端超时,但服务端其实已经成功写入"的情况。客户端一收到超时就去重发同一个分片,如果服务端不做幂等判断,可能会留下两份分片,导致占位槽被覆盖或者磁盘空间浪费。
所以uploadChunk接口开始前,先查uploadedChunks里是否已有该index,有就直接返回成功。这个操作代价极小,却能把重试从"危险操作"变成"安全操作"。类似的,合并接口也要处理重复调用的情况:如果合并任务已经存在且正在执行,直接返回mergeTaskId就好。
6.4 临时分片的过期清理
断点续传会带来一个后遗症:许多传了一半的上传会话永远没人继续。如果不清理,临时目录会被撑爆。
我的经验是给每个uploadId加一个lastModified时间,定期任务扫描超过 24 小时(或按业务自定义)未活跃的会话,删除对应临时分片,并在上传记录里标记为 expired。前端在恢复时如果发现状态已过期,自动重新初始化即可。这里有个细节:不要只按创建时间清理,因为一个量级大的文件可能传了很多天,最后活跃时间才是更准确的生命周期信号。
6.5 网关限制与弱网处理
大文件即使分片,单片也可能超过网关默认的请求体大小限制。这个限制通常指的不是浏览器,而是部署在前面的网关或反向代理。如果要支持 5MB 分片,这些中间层的请求体上限就得调到 5MB 以上,并给分片接口单独设置更长的超时时间。
弱网环境下,并发控制在 2-3 个比较好。如果并发太高,分片排队反而拖慢整体速度。我实测过一个上行带宽很不稳定的场景,开 5 个并发和开 3 个并发,总耗时几乎一样,但失败率明显更高。网络抖动时,低并发反而更稳。
6.6 一些容易被忽略的体验细节
哈希计算阶段虽然不需要传数据,但用户会盯着屏幕以为是卡了。此时要显示"正在校验"并且最好给出已校验的百分比。如果用 Web Worker,还要考虑计算期间用户又选了其他文件的情况,建议把计算任务和当前选中的文件标识绑定,结果返回时校验一次标识,避免显示错乱。
另外,文件上传完成后,前端不要立刻显示成功的绿色大勾,等合并状态轮询确认完成后再展示。很多用户会关掉页面认为已经传完,结果合并其实失败了,文件不完整,这比慢一点更让人恼火。
我自己在做过之后最大的体会是:上传方案的复杂度往往不是来自单点功能,而是来自三个机制叠加后的状态管理。只要能建立一个统一的上传会话概念,把hash、uploadId、uploadedChunks看成同一个状态机的多个字段,整个架构就不会乱。下次遇到类似需求,你不妨也试试先把状态表画清楚再动手写接口。