文章

大文件分片上传深度解析:切片、秒传与断点续传实战

分片上传把大文件切小块分别上传,配合文件指纹(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 行核心代码实现。

本文由作者按照 CC BY 4.0 进行授权