大文件分片上传、断点续传、秒传与流式进度
目录
- 背景与目标
- 概念总览
- 传输形态对比
- 分片上传原理
- 断点续传原理
- 秒传与去重原理
- 进度计量:整包与分片聚合
- 流式上传与分片的边界
- 端到端协议与接口约定
- 前端参考实现
- 服务端要点
- 安全、一致性与降级
- 排错清单
- 小结
背景与目标
浏览器通过 HTTP 上传大文件时,容易遇到 超时、弱网中断、内存暴涨、无法重试 等问题。工程上通常组合使用:
- 分片(Chunk):把文件切成多段,逐段上传并在服务端合并。
- 断点续传:记录已成功上传的分片,失败后只补传缺失分片。
- 秒传:上传前用文件指纹在服务端命中已有对象,跳过数据拷贝(本质是 去重 / 引用已有文件)。
- 进度条:对「已读字节」「已确认分片」或「流式已发送字节」做可观测聚合。
- 流式上传:在单请求里用
ReadableStream边读边发,适合中等文件或网关对「单连接流」更友好的场景;与分片是不同维度的策略,可并存。
本文给出 原理、对比表、流程图、接口约定、前后端参考实现与排错清单,便于落地一套可维护的上传方案。
概念总览
| 术语 | 含义 | 典型实现要点 |
|---|---|---|
| 分片 | 将 File/Blob 按固定大小切片 | Blob.slice(start, end);每片带 index/offset |
| 会话 | 一次上传任务的逻辑容器 | uploadId / sessionId,服务端保存元数据与分片状态 |
| 合并 | 所有分片到齐后拼成最终对象 | 服务端按序写入同一对象存储键;校验 ETag/哈希 |
| 断点续传 | 中断后从上次进度继续 | 客户端持久化进度;服务端返回「已存在分片」列表 |
| 秒传 | 无需再传文件体 | 文件级哈希命中对象存储或数据库映射 |
| 流式上传 | 单请求边读边发 | fetch(body: ReadableStream);进度需自行按已读量估算 |
传输形态对比
| 方案 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
单次 multipart/form-data | 实现简单 | 大文件易超时;重试成本高 | 小文件、内网 |
| 分片 + 合并 | 可并发、可重试、易做进度 | 协议与存储实现复杂 | 网盘、素材、日志包 |
| 分片 + 断点续传 | 弱网体验好 | 需会话状态与一致性设计 | 移动端、跨境链路 |
| 秒传 | 省带宽与时间 | 需可信指纹与隐私权衡 | 重复文件多的业务 |
流式 ReadableStream | 内存占用低、语义「一条流」 | 进度与中间代理行为要验证 | 中等文件、直连对象存储 |
分片上传原理
- 切片:按
chunkSize(如 2~8MB)对文件slice,得到(index, blob, start, end)。 - 并发控制:用 滑动窗口 限制同时进行的请求数(如 3~6),避免占满带宽与文件描述符。
- 每片校验:可对每片计算 MD5/SHA-256(或用对象存储的分片 MD5 规则),合并前做完整性校验。
- 合并:服务端(或对象存储 API)按
index顺序写入;若存储支持「分片上传 API」(如 S3 Multipart),可直接映射为同一模型。
断点续传原理
核心思想:把上传变成 可幂等的分片写入 + 可查询的会话状态。
- 初始化会话:
POST /upload/init返回uploadId,并记录fileSize、chunkSize、可选fileHash。 - 查询进度:
GET /upload/{uploadId}/status返回已成功分片索引集合uploadedParts。 - 客户端持久化:将
{ uploadId, fileName, fileSize, chunkSize, fileHash, uploadedIndexes }存IndexedDB/localStorage(注意配额与隐私)。 - 补传:只对
uploadedParts中不存在的index发上传请求;全部完成后调用 merge/complete。
一致性注意:若同一 uploadId 下允许「覆盖重传某分片」,需定义 分片版本或 ETag;否则应采用 不可变分片内容(同一 index 只允许成功一次)。
秒传与去重原理
秒传不是魔法,而是 用文件指纹在服务端找到已存在对象,本次上传只建立 用户空间到该对象的引用。
常见指纹:
| 指纹类型 | 计算成本 | 碰撞风险 | 说明 |
|---|---|---|---|
| 全文件 SHA-256 | 高(与大文件体积线性相关) | 极低 | 强一致,适合正式秒传判断 |
| MD5 | 中 | 已知的碰撞攻击场景需注意 | 许多旧系统仍用 |
| 抽样哈希(头/尾/中间块) | 低 | 高 | 仅适合「预筛选」,不能单独作为秒传依据 |
推荐流程:
- 客户端计算 全文件哈希(大文件用 Web Worker + 分块 digest 避免阻塞主线程)。
POST /upload/preflight提交{ fileHash, fileSize, mime }。- 若命中:直接返回已有
fileId/url,前端进度条可走满并结束。 - 若未命中:走正常分片上传;合并后服务端保存
fileHash -> objectKey映射供下次秒传。
进度计量:整包与分片聚合
| 数据源 | 公式(示例) | 说明 |
|---|---|---|
| 单请求 XHR | loaded / total | XMLHttpRequest.upload.onprogress 原生支持 |
单请求 fetch + ReadableStream | 已 enqueue 字节 / file.size | fetch 对上传进度无统一事件,需在 stream 中累加 |
| 分片上传 | sum(已完成分片字节) / file.size | 某分片失败时,该分片进度可细化为「片内 loaded」 |
注意:XMLHttpRequest 的 onprogress 表示 已发送到网络栈的字节,在慢服务端或缓冲较大时,与用户感知的「服务端已落盘」仍有差距;可在 分片完成响应 200 时将该片视为 100% 已确认,体验更稳。
流式上传与分片的边界
- 流式:一个请求体就是整个文件的字节流,服务端按流写入;中断后通常 从头重传(除非配合 HTTP Range 或自定义协议,成本高)。
- 分片:每个分片是小请求,失败重试粒度细,更适合 断点续传。
- 组合:可对「每个分片内部」再用流式读取(
blob.stream()),避免一次性把分片读进内存。
端到端协议与接口约定
以下为 最小完备 REST 形状,可按对象存储(S3/OSS/COS)能力改写为直传预签名 URL。
| 接口 | 方法 | 作用 | 关键字段 |
|---|---|---|---|
/upload/preflight | POST | 秒传判断 | fileHash, fileSize, fileName |
/upload/init | POST | 创建会话 | fileHash, fileSize, chunkSize, totalChunks |
/upload/{uploadId}/part/{index} | PUT | 上传分片 | Body: 二进制;Header: Content-Type, 可选 X-Chunk-Hash |
/upload/{uploadId}/status | GET | 续传查询 | 返回 uploadedIndexes 或 parts[] |
/upload/{uploadId}/merge | POST | 合并完成 | 返回 url, fileId |
/upload/stream | POST | 流式整文件 | Body: ReadableStream;可选 X-File-Hash 结尾校验 |
分片请求幂等建议:使用 Idempotency-Key: {uploadId}:{index} 或 URL 中含版本,避免重试导致重复写。
前端参考实现
1. 切片与并发池
export type ChunkTask = {
index: number;
start: number;
end: number;
blob: Blob;
};
export function createChunks(file: File, chunkSize: number): ChunkTask[] {
const chunks: ChunkTask[] = [];
let start = 0;
let index = 0;
while (start < file.size) {
const end = Math.min(start + chunkSize, file.size);
chunks.push({
index,
start,
end,
blob: file.slice(start, end),
});
start = end;
index += 1;
}
return chunks;
}
/** 限制并发:同时最多 limit 个 Promise 在执行 */
export async function runPool<T>(
tasks: (() => Promise<T>)[],
limit: number
): Promise<T[]> {
const results: T[] = [];
let i = 0;
const workers = new Array(Math.min(limit, tasks.length))
.fill(0)
.map(async () => {
while (i < tasks.length) {
const cur = i++;
results[cur] = await tasks[cur]();
}
});
await Promise.all(workers);
return results;
}
2. 全文件 SHA-256(可分块喂给 crypto)
export async function sha256HexOfFile(file: File): Promise<string> {
const buf = await file.arrayBuffer();
const hash = await crypto.subtle.digest("SHA-256", buf);
return [...new Uint8Array(hash)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
大文件应避免一次性 arrayBuffer();生产环境可用 Worker 分块 digest 或 增量哈希库(如按块更新 SHA-256 状态),此处从略,保留接口边界即可。
3. 分片 + 聚合进度 + XHR 片内进度
export type Progress = {
loaded: number;
total: number;
percent: number;
};
function xhrPutChunk(
url: string,
blob: Blob,
onChunkProgress?: (loaded: number) => void
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open("PUT", url);
xhr.upload.onprogress = (e) => {
if (e.lengthComputable && onChunkProgress) {
onChunkProgress(e.loaded);
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) resolve();
else reject(new Error(`HTTP ${xhr.status}`));
};
xhr.onerror = () => reject(new Error("network error"));
xhr.send(blob);
});
}
export async function uploadFileInChunks(options: {
file: File;
chunkSize: number;
concurrency: number;
preflight: (hash: string) => Promise<{ skip: boolean; url?: string }>;
initUpload: (hash: string) => Promise<{ uploadId: string; partBaseUrl: string }>;
getUploaded: (uploadId: string) => Promise<Set<number>>;
merge: (uploadId: string) => Promise<{ url: string }>;
onProgress?: (p: Progress) => void;
}) {
const { file, chunkSize, concurrency, onProgress } = options;
const hash = await sha256HexOfFile(file);
const hit = await options.preflight(hash);
if (hit.skip && hit.url) {
onProgress?.({ loaded: file.size, total: file.size, percent: 100 });
return { url: hit.url, mode: "instant" as const };
}
const { uploadId, partBaseUrl } = await options.initUpload(hash);
const chunks = createChunks(file, chunkSize);
const uploaded = await options.getUploaded(uploadId);
const total = file.size;
let aggregatedLoaded = 0;
const partLoaded = new Map<number, number>();
const report = () => {
let sum = 0;
for (const c of chunks) {
if (uploaded.has(c.index)) sum += c.blob.size;
else sum += partLoaded.get(c.index) ?? 0;
}
aggregatedLoaded = sum;
onProgress?.({
loaded: aggregatedLoaded,
total,
percent: Math.round((aggregatedLoaded / total) * 100),
});
};
const tasks = chunks
.filter((c) => !uploaded.has(c.index))
.map(
(c) => () =>
xhrPutChunk(
`${partBaseUrl}/${uploadId}/part/${c.index}`,
c.blob,
(loaded) => {
partLoaded.set(c.index, loaded);
report();
}
).then(() => {
uploaded.add(c.index);
partLoaded.set(c.index, c.blob.size);
report();
})
);
await runPool(tasks, concurrency);
const merged = await options.merge(uploadId);
onProgress?.({ loaded: total, total, percent: 100 });
return { url: merged.url, mode: "chunked" as const };
}
4. 流式上传 + 手动进度(fetch + ReadableStream)
export async function uploadFileAsStream(options: {
file: File;
url: string;
onProgress?: (p: Progress) => void;
}) {
const { file, url, onProgress } = options;
const total = file.size;
let sent = 0;
const stream = new ReadableStream({
start(controller) => {
const reader = file.stream().getReader();
const pump = (): void => {
reader.read().then(({ done, value }) => {
if (done) {
controller.close();
return;
}
sent += value.byteLength;
onProgress?.({
loaded: sent,
total,
percent: Math.round((sent / total) * 100),
});
controller.enqueue(value);
pump();
});
};
pump();
},
});
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": file.type || "application/octet-stream" },
body: stream,
duplex: "half",
} as RequestInit);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json() as Promise<{ url: string }>;
}
duplex: "half" 为 Chromium/Fetch 对流式请求体的要求;Safari/Firefox 支持度需以目标浏览器为准,上线前务必做兼容性矩阵验证。
服务端要点
- 对象存储:优先使用云厂商 分片上传 API(multipart upload),减少自建合并逻辑与磁盘临时文件风险。
- 会话过期:
uploadId设置 TTL;过期后清理孤儿分片。 - 合并校验:比较 合并后对象哈希 与客户端
fileHash;不一致则删除并重传。 - 列表接口:
status返回的分片集合应来自 数据库或存储列举,避免仅信任客户端。 - 秒传表:
UNIQUE(file_hash, file_size)索引;注意 同哈希不同内容 的极端风险(SHA-256 可认为业务上可忽略)。
Node(Express)伪代码:接收分片
// 伪代码:将分片落到临时路径,合并时按序拼接
app.put("/upload/:uploadId/part/:index", express.raw({ type: "*/*", limit: "20mb" }), (req, res) => {
const { uploadId, index } = req.params;
const buf = req.body;
// saveTo(`${uploadId}/${index}`, buf)
// markPartDone(uploadId, Number(index))
res.sendStatus(200);
});
安全、一致性与降级
| 风险 | 缓解 |
|---|---|
| 伪造分片 | 每片 HMAC 签名或短期 JWT;URL 使用预签名且限时 |
| 盗链秒传 | preflight 与 merge 必须带登录态;秒传只返回你有权限引用的对象 |
| 内容类型欺骗 | 服务端魔数检测;不信任扩展名 |
| 无限并发压垮网关 | Nginx limit_req;服务端每用户并发上限 |
| 浏览器不支持流式 fetch | 降级为 分片 + XHR |
排错清单
| 现象 | 可能原因 | 处理 |
|---|---|---|
| 进度条卡住不动 | 代理缓冲了整个 body | 换分片;或调整代理/网关缓冲 |
| 合并后文件损坏 | 分片顺序错误或未校验 | 合并强制按 index;加每片与整文件哈希 |
| 续传重复传 | status 未持久化或服务端丢状态 | 存储层与 DB 双写;合并前对账 |
| 秒传误命中 | 仅用弱指纹或 size | 改为全文件 SHA-256;或合并后强校验 |
| CORS 失败 | PUT/自定义头未放行 | 放行 PUT、暴露 ETag 等必要头 |
小结
- 分片解决大文件与弱网下的 可重试粒度;断点续传依赖 可查询的会话状态 与 幂等分片写入。
- 秒传本质是 文件级去重 + 引用已有对象,必须用 强哈希 并在合并后做 一致性校验 才稳妥。
- 进度条:分片场景用 已确认分片字节 + 当前分片片内进度 聚合;流式上传用
ReadableStream已读字节 驱动。 - 生产落地优先对齐 对象存储 Multipart 模型,减少自建合并链路的维护成本。
更多通用上传交互(拖拽、校验、相册)可与 文件上传处理 搭配阅读。