大文件上传与断点续传
大文件上传的核心不是「切片」这个动作,而是切片之后的去重、并发控制、失败重试与进度一致性。
一、四个接口串起来
一套完整的大文件上传由五步组成:
- 选文件并切片 —— 按固定大小切成多个 Blob;
- 算指纹 —— 对文件内容算一个哈希,作为这个文件的唯一标识;
- 查询已传 —— 问服务端这个文件/这些分片是否已存在(秒传与断点续传的基础);
- 并发上传未完成的分片 —— 控制并发数,失败要能重试;
- 请求合并 —— 服务端按序号拼成完整文件并校验。
对应的接口通常有四个:
POST /upload/check 秒传检测:同 hash 的文件是否已存在
GET /upload/chunks 查询已上传分片:断点续传的基础
POST /upload/chunk 上传单个分片
POST /upload/merge 合并分片并返回结果
切片本身很简单:
const CHUNK_SIZE = 5 * 1024 * 1024 // 5MB,可按文件大小调整
function sliceFile(file, size = CHUNK_SIZE) {
const chunks = []
for (let start = 0; start < file.size; start += size) {
chunks.push({ index: chunks.length, blob: file.slice(start, start + size) })
}
return chunks
}
注意每个分片都要带序号,服务端合并时按序号排序——网络到达顺序不保证有序,靠序号才是可靠的。
二、分片大小与并发数
两个极端各有代价:
- 太小 —— 请求数暴涨,每个请求都有握手与头部开销,服务端也要维护更多状态;
- 太大 —— 单次失败的重试成本高,进度条粒度粗,弱网下一次失败就是几兆白传。
常见取值在 1MB 到 5MB 之间;更大的文件可以按比例放大(上 GB 的文件用十几到二十 MB 一片,能显著减少请求数)。真正的决定因素有三个:
- 网络环境 —— 弱网下宜小,重试粒度更细;
- 服务端限制 —— 有些网关对单次请求体有上限;
- 并发数与浏览器限制 —— 同源并发请求通常只有 6 个左右,片太小会让并发度成为瓶颈。
结论:别把这个数字当固定结论,按实际网络与服务端的上限调。
三、算 hash 的代价
指纹用于两件事:秒传(服务端已有同文件)和分片校验。最直接的做法是对整个文件算 MD5,但这会带来两个问题:
- 主线程被卡死 —— 读一遍几百 MB 再算哈希,界面完全无响应;
- 内存峰值高 —— 一次性把文件读进内存会让低端设备直接崩。
三个层次的优化:
① 放进 Web Worker。 计算与渲染分离,界面不卡。这是必做项。
② 增量计算。 用 FileReader 按片读取、逐片 append 到哈希实例,而不是一次载入整个文件:
// worker 内:增量计算,内存占用可控
import SparkMD5 from 'spark-md5'
self.onmessage = ({ data: { file, chunkSize } }) => {
const spark = new SparkMD5.ArrayBuffer()
const reader = new FileReader()
let offset = 0
reader.onload = ({ target }) => {
spark.append(target.result)
offset += chunkSize
if (offset < file.size) readNext()
else self.postMessage({ hash: spark.end() })
}
function readNext() {
reader.readAsArrayBuffer(file.slice(offset, offset + chunkSize))
}
readNext()
}
③ 抽样哈希。 只取文件头、尾与中间若干片段参与计算,用极小的碰撞概率换数量级的速度。适合超大文件,但要注意:抽样哈希只能用于秒传的初步判断,服务端在合并后仍应做一次完整校验,否则可能把不同文件误判为同一个。
四、并发控制与退避重试
并发要限制。 一次性发几百个请求,浏览器和服务端都扛不住;同源并发上限通常在 6 个左右,实践中 3~5 个是比较稳的选择。
async function uploadWithLimit(chunks, limit, uploadOne) {
const executing = new Set()
for (const chunk of chunks) {
const p = uploadOne(chunk).finally(() => executing.delete(p))
executing.add(p)
if (executing.size >= limit) await Promise.race(executing)
}
await Promise.all(executing)
}
重试的粒度要落在分片上,而不是整个文件——一个分片失败只需要重传那一片。策略用指数退避(失败后等待时间递增),并限制重试次数,避免无限重试把服务端压垮。
进度要节流。 每个分片完成都更新一次 DOM 会造成频繁重排,按 100ms 左右节流一次即可。
五、断点信息存哪
断点续传要回答两个问题:服务端已经有哪些分片,以及刷新之后怎么继续。
服务端查询是权威来源。 上传前先问一次,拿到已完成的分片列表,跳过它们:
const uploaded = await fetchUploadedChunks(hash) // 服务端返回已完成序号
const pending = chunks.filter((c) => !uploaded.includes(c.index))
await uploadWithLimit(pending, 3, uploadOne)
await merge(hash)
本地记录是体验优化。 把已传记录存进 IndexedDB,刷新后能立刻恢复进度,省掉一次查询往返。但不能只依赖本地——换设备、清缓存就失效了。正确顺序是:本地先恢复、服务端再校准。
暂停与取消用 AbortController。 中断正在进行的请求,同时把已完成的分片状态保存下来,恢复时接着传。
六、弱网与大文件
这些才是上线后真正会遇到的:
- 文件被替换 —— 上传过程中用户改了文件,hash 与内容不匹配。要在上传开始时锁定 File 引用,并在合并时校验;
- 合并失败 —— 服务端合并出错要能返回明确错误,前端给出重试入口,不要让用户从头再来;
- 页面关闭 —— 依赖 IndexedDB 记录 + 服务端查询双保险;
- 存储配额 —— 服务端临时分片要有清理策略,否则会堆积大量半成品;
- 同名不同内容 —— 用 hash 而不是文件名做标识,否则同名文件会互相覆盖。
七、切片大小怎么定
切片不是越小越好。请求数与服务端的状态成本会随之上升,要按带宽、并发上限与超时一起平衡。
秒传也不是「不用传」——它是服务端已有同指纹文件时做的直接关联,前提是 hash 算得对;抽样哈希只能用于初判,合并后仍要完整校验。
断点续传不能只靠前端记录,换设备、清缓存就没了,必须以服务端查询为准。
失败重试更不能整个文件重来,重试粒度要落在分片上,否则一处失败就前功尽弃。
断点续传的权威在服务端、体验在本地——只依赖一边,要么换设备就废,要么弱网下白白多一次往返。
分片的底层能力是 Blob.slice,它不读内存,大文件才能这么切。