前端大文件上传全方案:切片、秒传、断点续传与 Worker 并行 Hash 计算实践

4 阅读8分钟

前端大文件上传全方案:切片、秒传、断点续传与 Worker 并行 Hash 计算实践

上传一个 10MB 的图片,没人会多想。但当产品经理说"我们要支持上传 2GB 的视频"时,你会发现:一个普通的 <input type="file">FormData,能把浏览器干崩、把用户等到关掉页面、把后端网关超时搞到怀疑人生。

这不是一个"调 API"的问题,这是一个系统设计问题。


大文件上传到底难在哪?

先把问题摊开:

痛点根因
上传超时单次请求体积太大,网关/Nginx 有 body size 和超时限制
失败要重来没有断点机制,传了 90% 断网 = 白传
重复上传浪费带宽同一个文件换个名字又传一遍
上传期间页面卡死主线程做大文件 Hash 计算,UI 直接冻住
内存爆炸一次性读取整个大文件到内存

一句话总结:大文件上传的本质问题是——如何把一个"大而脆弱"的操作,拆成"小而可靠"的操作。

这跟分布式系统的思路一模一样:拆分、校验、重试、幂等。


整体架构一览

先看全局,再看细节:

用户选择文件
    
    
Worker 并行计算文件 Hash(不阻塞 UI)
    
    Hash 问服务端:"这文件传过没?"
    
    ├── 已存在  秒传完成 
    
    ├── 部分存在  返回已有分片列表  断点续传(只传缺失的)
    
    └── 不存在  全量分片上传
                    
                    
              并发上传分片(控制并发数)
                    
                    
              全部完成  通知服务端合并

下面逐个拆解。


第一步:文件切片

切片的原理没什么魔法,File 对象继承自 Blob,而 Blob 天生支持 slice

function createChunks(file: File, chunkSize = 5 * 1024 * 1024) {
  const chunks: Blob[] = []
  let cur = 0

  while (cur < file.size) {
    // Blob.slice 不会把数据读进内存,只是创建一个"引用切片"
    chunks.push(file.slice(cur, cur + chunkSize))
    cur += chunkSize
  }

  return chunks // 一个 2GB 文件 → 400 个 5MB 的 Blob 引用
}

关键点:slice 是零拷贝的。它不会真的把文件内容读到内存里,只是标记了起止偏移量。所以哪怕切 1000 片,内存开销也几乎为零。

切片大小怎么定?

这是个工程权衡题:

切片大小优点缺点
1MB失败重传成本低请求数太多,HTTP 开销大
5MB均衡选择大多数场景够用
10MB+请求数少弱网下单片失败概率高

实际项目中,5MB 是最常见的默认值。如果你的用户群体网络质量差(比如东南亚市场),可以降到 2MB。


第二步:文件 Hash 计算——把主线程解放出来

为什么需要 Hash?两个目的:

  1. 秒传判断:同一个文件无论改什么文件名,Hash 都一样
  2. 分片校验:确保传上去的每一片内容没有损坏

但问题来了:对一个 2GB 文件做 Hash 计算,主线程直接卡死,用户以为页面崩了。

方案:Web Worker + 分片增量计算

思路类似流式处理——不是一口气读完整个文件,而是一片一片喂给 Hash 算法。

// hash-worker.ts —— 运行在 Worker 线程
import SparkMD5 from 'spark-md5'

self.onmessage = async (e: MessageEvent) => {
  const { chunks } = e.data
  const spark = new SparkMD5.ArrayBuffer()
  let completed = 0

  for (const chunk of chunks) {
    // FileReader 读取每一片的 ArrayBuffer
    const buffer = await readAsArrayBuffer(chunk)
    spark.append(buffer) // 增量喂给 MD5 算法

    completed++
    // 向主线程报告进度(用户能看到 Hash 计算的百分比)
    self.postMessage({ type: 'progress', percent: (completed / chunks.length) * 100 })
  }

  // 所有分片都喂完了,输出最终 Hash
  self.postMessage({ type: 'done', hash: spark.end() })
}

function readAsArrayBuffer(blob: Blob): Promise<ArrayBuffer> {
  return new Promise((resolve) => {
    const reader = new FileReader()
    reader.onload = () => resolve(reader.result as ArrayBuffer)
    reader.readAsArrayBuffer(blob)
  })
}

主线程这边:

function calculateHash(file: File): Promise<string> {
  return new Promise((resolve) => {
    const chunks = createChunks(file)
    const worker = new Worker(new URL('./hash-worker.ts', import.meta.url))

    worker.postMessage({ chunks })

    worker.onmessage = (e) => {
      if (e.data.type === 'progress') {
        // 更新进度条,用户知道"正在计算中"而不是"页面卡了"
        updateProgress(e.data.percent)
      }
      if (e.data.type === 'done') {
        resolve(e.data.hash)
        worker.terminate() // 用完就关,别占资源
      }
    }
  })
}

进阶:多 Worker 并行计算

单个 Worker 是串行读片的。如果机器是 8 核 CPU,只用 1 个 Worker 是浪费。

async function parallelHash(file: File, workerCount = 4): Promise<string> {
  const chunks = createChunks(file)

  // 把分片均匀分配给多个 Worker,类似 Map-Reduce 的 Map 阶段
  const groups = Array.from({ length: workerCount }, () => [] as Blob[])
  chunks.forEach((chunk, i) => groups[i % workerCount].push(chunk))

  // 每个 Worker 独立计算自己那一组的 Hash
  const partialHashes = await Promise.all(
    groups.map(group => runWorker(group))
  )

  // 最后把所有子 Hash 合并成最终 Hash(类似 Reduce 阶段)
  const spark = new SparkMD5()
  partialHashes.forEach(h => spark.append(h))
  return spark.end()
}

实测下来,4 个 Worker 并行计算 2GB 文件,速度比单 Worker 快 2~3 倍。但别开太多——Worker 线程也要占内存,开 8 个以上收益递减,还可能让低端设备更卡。


第三步:秒传——最快的上传就是不上传

原理极其简单:

async function tryInstantUpload(hash: string, fileName: string) {
  const res = await fetch('/api/upload/check', {
    method: 'POST',
    body: JSON.stringify({ hash, fileName }),
  })

  const data = await res.json()

  if (data.exists) {
    // 服务端已经有这个文件了,直接返回文件地址
    // 用户:哇,2GB 文件 0.3 秒传完了???
    return { success: true, url: data.url }
  }

  // 返回已上传的分片索引列表,为断点续传做准备
  return { success: false, uploadedChunks: data.uploadedChunks || [] }
}

服务端用文件 Hash 作为唯一标识。张三传过一次,李四再传同一个文件,直接复用——本质上是内容寻址,和 Git 的对象存储思路完全一样。

⚠️ 注意:秒传的前提是你用的 Hash 算法碰撞率足够低。MD5 在安全领域已经不推荐了,但在文件去重场景下够用。如果你实在不放心,可以用 SHA-256,就是计算慢一些。


第四步:断点续传

用户传到 80% 断网了,重新打开页面,难道要从头再来?

不需要。服务端已经存了哪些分片成功落盘了,客户端只需要补传缺失的部分。

async function uploadWithResume(file: File) {
  const chunks = createChunks(file)
  const hash = await calculateHash(file)

  // 问服务端:这个文件的哪些分片已经传过了?
  const { success, url, uploadedChunks } = await tryInstantUpload(hash, file.name)

  if (success) return url // 秒传命中,收工

  // 过滤掉已上传的分片,只传剩下的
  const pendingChunks = chunks
    .map((chunk, index) => ({ chunk, index }))
    .filter(({ index }) => !uploadedChunks.includes(index))

  // 并发上传(控制并发数,别把浏览器和服务端打满)
  await concurrentUpload(pendingChunks, hash)

  // 所有分片传完,通知服务端合并
  await fetch('/api/upload/merge', {
    method: 'POST',
    body: JSON.stringify({ hash, fileName: file.name, totalChunks: chunks.length }),
  })
}

断点续传能工作的前提是:同一个文件每次计算出的 Hash 一致,分片方式一致。所以切片大小要固定(或者存储在某处),否则恢复时分片对不上,写到这里我开始怀疑人生。


第五步:并发控制

一口气把 400 个分片同时发出去?浏览器同域名最多 6 个并发连接,剩下 394 个在排队。而且并发太高,服务端也扛不住。

async function concurrentUpload(
  tasks: { chunk: Blob; index: number }[],
  hash: string,
  maxConcurrency = 4
) {
  const pool: Promise<void>[] = []
  const errors: number[] = []

  for (const task of tasks) {
    const p = uploadChunk(task, hash)
      .catch(() => {
        errors.push(task.index) // 失败的记下来,后面重试
      })
      .finally(() => {
        pool.splice(pool.indexOf(p), 1) // 完成一个,让出一个坑位
      })

    pool.push(p)

    // 池子满了就等,等一个完成再放下一个进去
    if (pool.length >= maxConcurrency) {
      await Promise.race(pool)
    }
  }

  await Promise.all(pool) // 等最后一批跑完

  // 失败的重试一轮(生产环境建议加指数退避)
  if (errors.length) {
    const retryTasks = tasks.filter(t => errors.includes(t.index))
    await concurrentUpload(retryTasks, hash, 2) // 重试时降低并发
  }
}

function uploadChunk(task: { chunk: Blob; index: number }, hash: string) {
  const form = new FormData()
  form.append('chunk', task.chunk)
  form.append('hash', hash)
  form.append('index', String(task.index))

  return fetch('/api/upload/chunk', { method: 'POST', body: form })
    .then(res => { if (!res.ok) throw new Error(`Chunk ${task.index} failed`) })
}

并发数设多少合适?经验值:

  • 强网环境(内网/Wi-Fi):4~6 并发
  • 弱网/移动端:2~3 并发
  • 可以根据上传速度动态调整(测几个分片的平均耗时,快就加,慢就减)

设计权衡:为什么不用其他方案?

为什么不用 tus 协议?

tus 是一个开源的断点续传协议,有现成的客户端和服务端库。如果你不需要秒传和自定义分片策略,tus 是非常好的选择。但它的 Hash 策略和分片逻辑不够灵活,大厂通常会自建。

为什么不用 WebSocket 传?

WebSocket 适合双向通信,但不适合大数据量传输。HTTP 有天然的分段、重试、CDN 缓存能力,WS 没有。用 WS 传大文件是用螺丝刀锤钉子。

为什么不在服务端算 Hash?

可以,但有两个问题:

  1. 文件要先完整传到服务端才能算——秒传就没意义了
  2. 服务端 CPU 资源更贵,客户端有大量闲置算力(Worker)

边界与踩坑

1. Safari 的 Worker 限制

Safari 对 Worker 中使用 import 的支持一直磕磕绊绊。如果用 Vite 打包,Worker 中的第三方库导入可能报错。解决方案:把 spark-md5 的代码内联到 Worker 文件中,或者用 ?worker&inline 模式。

2. 分片顺序不等于合并顺序

并发上传意味着分片到达服务端的顺序是乱的。服务端合并时必须按 index 排序,否则文件就废了。这不是 bug,这是特性——并发的代价。

3. Hash 碰撞的理论风险

MD5 的碰撞概率约为 2^(-128),对于文件去重来说足够安全。但如果你在做金融、医疗等对数据完整性要求极高的系统,建议用 SHA-256,或者 Hash + 文件大小双重校验。

4. 内存泄漏

Worker 用完一定要 terminate()。曾经遇到一个线上问题:用户连续上传 10 个大文件,10 个 Worker 全开着没关,Chrome 占了 4GB 内存。用户的风扇开始起飞。

5. 移动端的坑

移动浏览器对 Worker 数量有更严格的限制,有些低端安卓机开 2 个 Worker 就开始吃力。建议做能力检测:

// navigator.hardwareConcurrency 返回 CPU 逻辑核心数
const workerCount = Math.min(navigator.hardwareConcurrency || 2, 4)

总结:大文件上传的通用模型

退一步看,大文件上传的核心策略可以抽象成一个通用模型:

  1. 分治:大任务拆成小任务(切片)
  2. 幂等:每个小任务可以安全重试(分片 + Hash 索引)
  3. 去重:基于内容寻址跳过重复工作(秒传)
  4. 并行:利用多核/多连接提高吞吐(Worker + 并发池)
  5. 渐进:保存中间状态,失败后从断点恢复(断点续传)

这五个原则不止适用于文件上传。消息队列、分布式计算、大数据 ETL,甚至你在做任何"大规模、不可靠环境下的数据传输"时,都是这套思路。

下次再遇到类似的问题,别急着写代码。先问自己:能不能拆?能不能跳过?能不能断点恢复?能不能并行?

把这四个问题回答清楚,方案就出来了。