大文件分片上传深度解析:切片、秒传与断点续传实战
分片上传把大文件切小块分别上传,配合文件指纹(MD5、SHA256)实现秒传与断点续传,配合并发池控制速度,让上传到 90% 断了重来成为历史。 面试常考切片算法、指纹计算时机、并发控制与断点续传的服务端分片合并逻辑。
一句话概括
上传 2GB 的文件,网络断了怎么办?——分片上传把大文件切成小块分别上传,配合文件指纹(MD5/SHA256)实现秒传和断点续传,配合并发池控制上传速度,让”上传到 90% 断了重来”成为历史。
核心知识点
1. 文件切片:File.slice() 是核心 API
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// 把文件切成 5MB 一片
const CHUNK_SIZE = 5 * 1024 * 1024; // 5MB
function createChunks(file) {
const chunks = [];
let start = 0;
while (start < file.size) {
const end = Math.min(start + CHUNK_SIZE, file.size);
chunks.push({
index: chunks.length, // 第几片
blob: file.slice(start, end), // Blob 切片(零拷贝,不占额外内存)
start, end
});
start = end;
}
return chunks;
}
// 上传单个分片
async function uploadChunk(chunk, fileHash) {
const formData = new FormData();
formData.append('chunk', chunk.blob);
formData.append('hash', fileHash);
formData.append('index', chunk.index);
formData.append('total', chunk.total);
return fetch('/api/upload/chunk', { method: 'POST', body: formData });
}
2. 文件指纹(秒传关键)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 计算文件 SHA256(Web Crypto API,不阻塞主线程)
async function computeHash(file) {
// 只采样计算大文件 hash(首尾 + 中间,大幅加速)
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB 采样块
const chunks = [];
if (file.size <= CHUNK_SIZE * 3) {
chunks.push(file); // 小文件全量
} else {
// 大文件:取首 2MB + 中 2MB + 尾 2MB
chunks.push(file.slice(0, CHUNK_SIZE));
chunks.push(file.slice(Math.floor(file.size / 2), Math.floor(file.size / 2) + CHUNK_SIZE));
chunks.push(file.slice(file.size - CHUNK_SIZE, file.size));
}
const buffers = await Promise.all(chunks.map(c => c.arrayBuffer()));
const combined = new Uint8Array(buffers.reduce((s, b) => s + b.byteLength, 0));
let offset = 0;
buffers.forEach(b => { combined.set(new Uint8Array(b), offset); offset += b.byteLength; });
const hashBuffer = await crypto.subtle.digest('SHA-256', combined);
return Array.from(new Uint8Array(hashBuffer)).map(b => b.toString(16).padStart(2, '0')).join('');
}
// 秒传:先问后端这个文件有没有传过
async function checkQuickUpload(hash) {
const res = await fetch(`/api/file/check?hash=${hash}`);
const { exists, url } = await res.json();
if (exists) {
console.log('秒传成功!文件已存在');
return url; // 直接返回已有文件的 URL
}
return null;
}
3. 并发池控制上传
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 并发上传分片(一次最多 3 个请求)
async function uploadWithConcurrency(chunks, fileHash, concurrency = 3) {
const results = new Array(chunks.length);
let currentIndex = 0;
let completed = 0;
async function worker() {
while (currentIndex < chunks.length) {
const index = currentIndex++;
try {
const res = await uploadChunk({ ...chunks[index], total: chunks.length }, fileHash);
results[index] = await res.json();
completed++;
// 进度回调
onProgress?.(Math.round(completed / chunks.length * 100));
} catch (err) {
// 失败重试(最多 3 次)
for (let retry = 0; retry < 3; retry++) {
try {
const res = await uploadChunk({ ...chunks[index], total: chunks.length }, fileHash);
results[index] = await res.json();
break;
} catch (e) {
if (retry === 2) throw e; // 3 次都失败 → 抛出让外层处理
await new Promise(r => setTimeout(r, 1000 * (retry + 1))); // 退避
}
}
}
}
}
// 启动 concurrency 个并行 worker
await Promise.all(Array.from({ length: concurrency }, () => worker()));
return results;
}
4. 断点续传:记住传到哪里了
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 上传前先问后端哪些分片已经上传
async function getUploadedChunks(fileHash) {
const res = await fetch(`/api/file/progress?hash=${fileHash}`);
const { uploaded } = await res.json();
return new Set(uploaded); // [0, 1, 3] → 第 0,1,3 片已上传,只需传第 2 片
}
// 完整上传流程
async function upload(file, onProgress) {
// 1. 计算 hash(可用 Web Worker 不阻塞 UI)
const hash = await computeHash(file);
// 2. 秒传检查
const quickUrl = await checkQuickUpload(hash);
if (quickUrl) return quickUrl;
// 3. 断点续传:跳过已上传分片
const uploadedSet = await getUploadedChunks(hash);
const chunks = createChunks(file).filter(c => !uploadedSet.has(c.index));
// 4. 并发上传剩余分片
await uploadWithConcurrency(chunks, hash, 3);
// 5. 通知后端合并分片
const res = await fetch('/api/file/merge', {
method: 'POST',
body: JSON.stringify({ hash, filename: file.name, totalChunks: Math.ceil(file.size / CHUNK_SIZE) }),
headers: { 'Content-Type': 'application/json' }
});
return (await res.json()).url;
}
5. 完整上传流程总结
1
2
3
4
5
6
7
8
9
选择文件 → 计算 hash(Web Worker)→ 秒传检查
↓
已存在?→ 是 → 秒传返回 URL,结束
↓ 否
查已上传分片 → 跳过已上传的
↓
并发上传剩余分片(3 并发 + 重试)
↓
通知后端合并 → 返回文件 URL
并发数不是越大越好:3-5 个并发是最优区间。太多并发会导致带宽争抢(每个分片都很慢)和浏览器连接数耗尽。
其实你每天都在用
- 百度网盘 / 阿里云盘上传大文件:一个 2GB 视频上传到一半关闭浏览器,下次打开继续传——断点续传
- 微信发文件:你发给朋友的文件,朋友秒收(因为文件指纹命中,不需要重新上传)——秒传
- 飞书文档上传:上传大文件时看到进度条逐步增长——分片并发上传 + 进度回调
- 视频网站投稿:B站/YouTube 上传视频时进度条卡了一下继续走而不是重来——分片重试机制
常见误解(FAQ)
❌ 误区:文件切片会占用额外内存
file.slice() 返回的是 Blob 对象,它只是对原始文件的引用(包含 start/end 偏移),不会复制数据。真正分配内存是在调用 .arrayBuffer() 或放入 FormData 发送时。
❌ 误区:分片越小上传越快
分片太小(如 100KB)会导致请求数暴增,每个 HTTP 请求有固定的头部开销(约 500 字节),1GB 文件切 100KB 就是 10000 个请求 → 5MB 头部开销 + 10000 次握手。最优分片大小是 2-10MB,平衡重试粒度和请求开销。
❌ 误区:并发数设得越高越快
浏览器同域名连接数有限(HTTP/1.1 约 6 个)。设 10 并发实际只有 6 个能同时传输。而且在弱网下,并发请求互相抢带宽,每个都很慢,不如 3 个并发每个稳定。实际上 3-5 并发吞吐量最高。
❌ 误区:hash 计算用 MD5 就够了
MD5 碰撞在实际工程中确实罕见,但出于安全考虑(防恶意构造碰撞文件),云服务商(阿里云 OSS、AWS S3)已全面迁移到 SHA256。Web Crypto API 原生支持 SHA256,计算速度也不慢。
一句话总结
分片上传的”不可能三角”是速度、可靠性和实现复杂度——3 并发 + 5MB 分片 + 采样 SHA256 是最均衡的工程选择,恰好能用 50 行核心代码实现。