跳到主要内容

大文件分片上传、断点续传、秒传与流式进度

目录

背景与目标

浏览器通过 HTTP 上传大文件时,容易遇到 超时、弱网中断、内存暴涨、无法重试 等问题。工程上通常组合使用:

  • 分片(Chunk):把文件切成多段,逐段上传并在服务端合并。
  • 断点续传:记录已成功上传的分片,失败后只补传缺失分片。
  • 秒传:上传前用文件指纹在服务端命中已有对象,跳过数据拷贝(本质是 去重 / 引用已有文件)。
  • 进度条:对「已读字节」「已确认分片」或「流式已发送字节」做可观测聚合。
  • 流式上传:在单请求里用 ReadableStream 边读边发,适合中等文件或网关对「单连接流」更友好的场景;与分片是不同维度的策略,可并存。

本文给出 原理、对比表、流程图、接口约定、前后端参考实现与排错清单,便于落地一套可维护的上传方案。

概念总览

术语含义典型实现要点
分片File/Blob 按固定大小切片Blob.slice(start, end);每片带 index/offset
会话一次上传任务的逻辑容器uploadId / sessionId,服务端保存元数据与分片状态
合并所有分片到齐后拼成最终对象服务端按序写入同一对象存储键;校验 ETag/哈希
断点续传中断后从上次进度继续客户端持久化进度;服务端返回「已存在分片」列表
秒传无需再传文件体文件级哈希命中对象存储或数据库映射
流式上传单请求边读边发fetch(body: ReadableStream);进度需自行按已读量估算

传输形态对比

方案优点缺点适用场景
单次 multipart/form-data实现简单大文件易超时;重试成本高小文件、内网
分片 + 合并可并发、可重试、易做进度协议与存储实现复杂网盘、素材、日志包
分片 + 断点续传弱网体验好需会话状态与一致性设计移动端、跨境链路
秒传省带宽与时间需可信指纹与隐私权衡重复文件多的业务
流式 ReadableStream内存占用低、语义「一条流」进度与中间代理行为要验证中等文件、直连对象存储

分片上传原理

  1. 切片:按 chunkSize(如 2~8MB)对文件 slice,得到 (index, blob, start, end)
  2. 并发控制:用 滑动窗口 限制同时进行的请求数(如 3~6),避免占满带宽与文件描述符。
  3. 每片校验:可对每片计算 MD5/SHA-256(或用对象存储的分片 MD5 规则),合并前做完整性校验。
  4. 合并:服务端(或对象存储 API)按 index 顺序写入;若存储支持「分片上传 API」(如 S3 Multipart),可直接映射为同一模型。

断点续传原理

核心思想:把上传变成 可幂等的分片写入 + 可查询的会话状态

  1. 初始化会话POST /upload/init 返回 uploadId,并记录 fileSizechunkSize、可选 fileHash
  2. 查询进度GET /upload/{uploadId}/status 返回已成功分片索引集合 uploadedParts
  3. 客户端持久化:将 { uploadId, fileName, fileSize, chunkSize, fileHash, uploadedIndexes }IndexedDB/localStorage(注意配额与隐私)。
  4. 补传:只对 uploadedParts 中不存在的 index 发上传请求;全部完成后调用 merge/complete

一致性注意:若同一 uploadId 下允许「覆盖重传某分片」,需定义 分片版本或 ETag;否则应采用 不可变分片内容(同一 index 只允许成功一次)。

秒传与去重原理

秒传不是魔法,而是 用文件指纹在服务端找到已存在对象,本次上传只建立 用户空间到该对象的引用

常见指纹:

指纹类型计算成本碰撞风险说明
全文件 SHA-256高(与大文件体积线性相关)极低强一致,适合正式秒传判断
MD5已知的碰撞攻击场景需注意许多旧系统仍用
抽样哈希(头/尾/中间块)仅适合「预筛选」,不能单独作为秒传依据

推荐流程

  1. 客户端计算 全文件哈希(大文件用 Web Worker + 分块 digest 避免阻塞主线程)。
  2. POST /upload/preflight 提交 { fileHash, fileSize, mime }
  3. 若命中:直接返回已有 fileId/url,前端进度条可走满并结束。
  4. 若未命中:走正常分片上传;合并后服务端保存 fileHash -> objectKey 映射供下次秒传。

进度计量:整包与分片聚合

数据源公式(示例)说明
单请求 XHRloaded / totalXMLHttpRequest.upload.onprogress 原生支持
单请求 fetch + ReadableStreamenqueue 字节 / file.sizefetch 对上传进度无统一事件,需在 stream 中累加
分片上传sum(已完成分片字节) / file.size某分片失败时,该分片进度可细化为「片内 loaded」

注意XMLHttpRequestonprogress 表示 已发送到网络栈的字节,在慢服务端或缓冲较大时,与用户感知的「服务端已落盘」仍有差距;可在 分片完成响应 200 时将该片视为 100% 已确认,体验更稳。

流式上传与分片的边界

  • 流式:一个请求体就是整个文件的字节流,服务端按流写入;中断后通常 从头重传(除非配合 HTTP Range 或自定义协议,成本高)。
  • 分片:每个分片是小请求,失败重试粒度细,更适合 断点续传
  • 组合:可对「每个分片内部」再用流式读取(blob.stream()),避免一次性把分片读进内存。

端到端协议与接口约定

以下为 最小完备 REST 形状,可按对象存储(S3/OSS/COS)能力改写为直传预签名 URL。

接口方法作用关键字段
/upload/preflightPOST秒传判断fileHash, fileSize, fileName
/upload/initPOST创建会话fileHash, fileSize, chunkSize, totalChunks
/upload/{uploadId}/part/{index}PUT上传分片Body: 二进制;Header: Content-Type, 可选 X-Chunk-Hash
/upload/{uploadId}/statusGET续传查询返回 uploadedIndexesparts[]
/upload/{uploadId}/mergePOST合并完成返回 url, fileId
/upload/streamPOST流式整文件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 支持度需以目标浏览器为准,上线前务必做兼容性矩阵验证。

服务端要点

  1. 对象存储:优先使用云厂商 分片上传 API(multipart upload),减少自建合并逻辑与磁盘临时文件风险。
  2. 会话过期uploadId 设置 TTL;过期后清理孤儿分片。
  3. 合并校验:比较 合并后对象哈希 与客户端 fileHash;不一致则删除并重传。
  4. 列表接口status 返回的分片集合应来自 数据库或存储列举,避免仅信任客户端。
  5. 秒传表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 使用预签名且限时
盗链秒传preflightmerge 必须带登录态;秒传只返回你有权限引用的对象
内容类型欺骗服务端魔数检测;不信任扩展名
无限并发压垮网关Nginx limit_req;服务端每用户并发上限
浏览器不支持流式 fetch降级为 分片 + XHR

排错清单

现象可能原因处理
进度条卡住不动代理缓冲了整个 body换分片;或调整代理/网关缓冲
合并后文件损坏分片顺序错误或未校验合并强制按 index;加每片与整文件哈希
续传重复传status 未持久化或服务端丢状态存储层与 DB 双写;合并前对账
秒传误命中仅用弱指纹或 size改为全文件 SHA-256;或合并后强校验
CORS 失败PUT/自定义头未放行放行 PUT、暴露 ETag 等必要头

小结

  • 分片解决大文件与弱网下的 可重试粒度断点续传依赖 可查询的会话状态幂等分片写入
  • 秒传本质是 文件级去重 + 引用已有对象,必须用 强哈希 并在合并后做 一致性校验 才稳妥。
  • 进度条:分片场景用 已确认分片字节 + 当前分片片内进度 聚合;流式上传用 ReadableStream 已读字节 驱动。
  • 生产落地优先对齐 对象存储 Multipart 模型,减少自建合并链路的维护成本。

更多通用上传交互(拖拽、校验、相册)可与 文件上传处理 搭配阅读。