浏览器分片上传如何实现可靠的暂停与续传?
从大文件分片、上传会话、幂等重试和完整性校验出发,设计一个能暂停恢复的浏览器上传流程,并说明并发、安全、兼容性和服务端边界。
简答
- 先设计初始化、上传分片、完成三阶段协议,再写并发循环。
- File.slice() 只创建视图,不复制整个文件。
- 续传依赖查询服务端已保存的分片,uploadId 等信息持久化到 IndexedDB。
- 分片重试必须幂等,超时不等于失败;并发要有上限,完成时由服务端最终校验。
上传一个几个 GB 的视频时,用户切换网络或关闭页面,重新从零开始会浪费时间和流量。把文件切成多个小片并逐片发送,可以让客户端只重试失败的片段;但“切片”本身还不等于可靠续传。客户端和服务器还必须约定上传会话、片段编号、重复请求的结果以及最终合并规则。
先确定协议,而不是先写并发循环
一个可维护的上传协议通常分为三个阶段:
- 创建会话:客户端提交文件大小、文件名、片段大小和可选的文件指纹,服务器返回不可猜测的
uploadId。 - 上传片段:每个请求带上
uploadId、片段编号、总片段数和片段内容。服务器校验编号与长度后保存片段,并以幂等方式返回结果。 - 完成合并:客户端通知服务器所有片段都已上传。服务器按编号读取并合并,校验完整文件后再把会话标记为完成。
片段编号应从 0 开始且连续。最后一个片段可以小于固定大小,但中间片段不能因为客户端计算错误而出现空洞。服务器不能只相信客户端提供的总大小,必须根据实际收到的字节数再次检查。
下面是一种示意请求:
PUT /api/uploads/9b2.../parts/12 HTTP/1.1
Authorization: Bearer <短期令牌>
Content-Type: application/octet-stream
X-Part-Number: 12
X-Part-Count: 80
X-Part-SHA-256: <该片段的摘要>
<第 12 个片段的二进制内容>
实际项目也可以把元数据放在查询参数或 JSON 中,但二进制正文和元数据的含义必须固定。不要把整块文件转换成 Base64 放进 JSON:Base64 会增加传输体积,并让服务端和浏览器多做一次编码、解码和内存复制。
File.slice() 只创建视图,不会复制整个文件
浏览器的 File 继承自 Blob,slice(start, end) 返回指定字节区间的 Blob。结束位置不包含在区间内,因此每片的起点和终点可以这样计算:
function getPart(file, partNumber, partSize) {
if (!(file instanceof File)) throw new TypeError('必须传入 File');
if (!Number.isSafeInteger(partNumber) || partNumber < 0) {
throw new RangeError('片段编号必须是非负整数');
}
if (!Number.isSafeInteger(partSize) || partSize <= 0) {
throw new RangeError('片段大小必须是正整数');
}
const start = partNumber * partSize;
if (start >= file.size) return null;
const end = Math.min(start + partSize, file.size);
return { blob: file.slice(start, end), start, end, size: end - start };
}
它不会把整个文件一次性读进 JavaScript 堆,因此适合大文件。不过上传请求真正发送时仍会占用网络缓冲,摘要计算也可能读取片段内容。不要在主线程同时计算几十个片段的摘要,否则会造成输入卡顿;可以限制并发,或把计算放进 Web Worker。
续传依赖“查询服务器状态”,不能只依赖浏览器内存
刷新页面后,内存中的 uploadedParts 会消失。可靠做法是把 uploadId、文件大小、文件指纹和片段大小保存到 IndexedDB,并在恢复时向服务器查询已经持久化的片段:
async function getUploadedParts(uploadId, signal) {
const response = await fetch(`/api/uploads/${encodeURIComponent(uploadId)}`, {
headers: { Accept: 'application/json' },
signal,
});
if (!response.ok) throw new Error(`查询上传状态失败:HTTP ${response.status}`);
const data = await response.json();
if (!Array.isArray(data.parts)) throw new Error('服务器返回的片段列表无效');
return new Set(
data.parts.filter((number) => Number.isSafeInteger(number) && number >= 0),
);
}
恢复时必须重新确认文件仍然是同一个文件。文件名相同不够,用户可能替换了内容。小文件可以对完整文件计算摘要;大文件可以先用大小、lastModified 和若干固定位置的片段做快速指纹,再由服务器在合并阶段计算完整摘要。快速指纹只是减少误匹配的手段,不能代替最终完整性校验。
重试必须满足幂等,超时不能直接判定失败
网络超时后,服务器可能已经保存了片段,而客户端还没有收到响应。若再次发送同一编号,服务端应该根据 uploadId + partNumber 幂等处理:内容摘要相同就返回已存在,摘要不同则拒绝并要求客户端重新创建会话。不能让后一次请求无条件覆盖前一次内容,否则重试可能悄悄替换文件的一部分。
客户端可以采用有限次数的指数退避,并对 AbortSignal 保持可取消:
async function uploadPart({ uploadId, partNumber, blob, digest, signal }) {
for (let attempt = 0; attempt < 4; attempt += 1) {
signal?.throwIfAborted();
let response;
try {
response = await fetch(
`/api/uploads/${encodeURIComponent(uploadId)}/parts/${partNumber}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream',
'X-Part-SHA-256': digest,
},
body: blob,
signal,
},
);
} catch (error) {
if (signal?.aborted || attempt === 3) throw error;
}
if (response) {
// 本协议仅以 204 表示已经持久化;相同摘要的重复请求也返回 204。
await response.body?.cancel();
if (response.status === 204) return;
if (![502, 503, 504].includes(response.status) || attempt === 3) {
throw new Error(`片段上传未确认:HTTP ${response.status}`);
}
}
await wait(500 * 2 ** attempt + Math.random() * 250, signal);
}
}
function wait(ms, signal) {
signal?.throwIfAborted();
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort);
resolve();
}, ms);
function onAbort() {
clearTimeout(timer);
reject(signal.reason);
}
signal?.addEventListener('abort', onAbort, { once: true });
});
}
这里约定服务端持久化成功或确认相同摘要的重复上传时返回 204,409 表示内容冲突并立即停止。401、403、413 等响应也不应靠重试解决,示例在网络异常的 catch 之外直接按状态码抛出它们,只有网络失败和可重试的服务端错误才进入重试。202 只表示接受处理,不作为保存成功的证明。
示例最多请求四次,仅对网络异常和 502、503、504 做有限重试。网络异常也可能来自永久性 CORS 配置问题,并不保证下一次成功。生产实现还应解析 Retry-After、设置总截止时间,并按接口契约处理 429;本例不自动重试 429。退避等待同样响应取消,暂停时不必等待定时器到期。服务端应设置会话过期时间和遗留片段清理任务。
并发不是越高越快
可以维护一个有限大小的任务池,例如同时上传 3 到 6 个片段。并发数太高会争抢上行带宽、增加移动设备耗电,也可能让服务端连接数和临时磁盘压力上升。并发数应根据网络类型、片段大小和失败率动态调整,至少要支持暂停:暂停时停止派发新任务,并通过 AbortController 取消正在进行的请求;已成功保存的片段不要删除,恢复时重新查询即可。
不要用 Promise.all 一次启动所有片段。它会同时创建大量请求,并且一个请求拒绝后不能自动取消其余请求。更稳妥的任务池需要记录每个片段的状态(等待、上传中、成功、失败),只从等待队列取任务,并在取消时统一终止控制器。
完成阶段仍要由服务器做最终校验
客户端发送“完成”请求时,可以带上片段摘要列表和完整文件摘要,但服务器必须重新检查:
- 会话属于当前用户或当前授权范围,且没有过期;
- 片段编号完整、没有重复冲突,实际字节数符合预期;
- 每个片段的摘要与保存内容一致;
- 合并后的文件摘要、媒体类型和大小符合业务限制。
合并应写入临时文件,校验通过后再以原子方式改名为最终文件。不要在用户能访问的公开目录中直接使用用户提供的文件名或路径;文件名应经过规范化,或直接生成服务端文件名,并根据实际内容重新判断 Content-Type。上传文件如果会被浏览器直接打开,还要考虑 HTML、SVG、脚本和恶意压缩包造成的存储型 XSS 或资源消耗攻击。
跨域、鉴权和兼容性
跨源上传需要服务端正确配置 CORS,并允许实际使用的请求方法和请求头。带 Authorization、自定义摘要头或预检条件的请求通常会触发 OPTIONS,服务端必须正确响应 Access-Control-Allow-Origin,且不能在允许凭据时使用通配来源。上传令牌应短期、绑定用户和文件会话,并避免把长期凭据写入 URL。
File、Blob、fetch 和 AbortController 在现代浏览器中普遍可用;目录选择、后台持续上传、文件系统写入和流式摘要等能力的兼容性则更复杂。不能把浏览器关闭后的持续上传当作普通网页能力保证。需要后台上传时,应单独评估 Service Worker、操作系统分享目标或原生应用方案,并为不支持的浏览器提供重新选择文件后继续的提示。
分片上传适合大文件、网络不稳定或需要显示精确进度的场景。小文件直接一次上传更简单,只有在确实需要暂停恢复、并发控制或大文件容错时才引入这套协议;否则额外的会话、临时存储和清理逻辑会增加系统复杂度。