大文件分片上传实践

需求分析

  • 大文件切割分片上传
  • 断点续传
  • 文件上传进度展示
  • 暂停以及继续上传文件
  • 已上传文件秒传

技术选型

  • 前端:React.js
  • 服务端:Golang
  • 数据库:MySQL
  • oss:minio

涉及知识点

  • Blob 与 ArrayBuff
  • WebWorker
  • Wasm
  • MerkleTree
  • PromisePool

性能优化

  • 可控制 Promise 并发数量的 PromisePool
  • 基于 WebWorker 的 WorkerPool / ThreadPool
  • 解决了前端计算大文件 hash 速度过慢的痛点

整体流程

  1. 对文件进行分片处理
  2. 计算各分片 Hash 以及文件 Hash
  3. 文件上传状态检查
  • 新文件上传:
    1. 调用服务端API创建新的上传记录。
    2. 上传所有分片。
    3. 所有分片上传完成后,调用服务端完成接口。
  • 断点续传
    1. 只上传未完成的分片。
    2. 所有分片上传完成后,调用服务端完成接口。
  • 已上传文件
    1. 直接展示上传完成状态,无需重新上传。

前端Part

1. 文件分片

1.1 目标

将文件按指定的分片大小进行分片, 最终拿到文件的 ArrayBuffer 数组用于上传和分片 Hash 计算

1.2 实现

利用 File.API对文件进行切割

export function sliceFile(file: File, baseSize = 1): Blob[] {
  const chunkSize = baseSize * 1024 * 1024;
  const chunks: Blob[] = [];
  let startPos = 0;
  while (startPos < file.size) {
    chunks.push(file.slice(startPos, startPos + chunkSize));
    startPos += chunkSize;
  }
  return chunks;
}

获取到文件分片后的 Blob 数组后并不能直接用于计算分片 hash, 还需要将它们转成 ArrayBuffer 数组。

可以通过以下两种方式进行转换:

  1. FileReader
export async function getArrayBufFromBlobs(chunks: Blob[]): Promise<ArrayBuffer[]> {
  async function readAsArrayBuffer(file: Blob) {
    return new Promise<ArrayBuffer>((rs) => {
      const fileReader = new FileReader()
      fileReader.onload = (e) => rs(e.target!.result as ArrayBuffer)
      fileReader.readAsArrayBuffer(file)
    })
  }
  return await Promise.all(chunks.map((chunk: Blob) => readAsArrayBuffer(chunk)))
}
  1. Blob.ArrayBuff
export async function getArrayBufFromBlobs(chunks: Blob[]): Promise<ArrayBuffer[]> {
  return Promise.all(chunks.map(chunk => chunk.arrayBuffer()))
}

遗留:是否需要将分片过程放到 WebWorker 中, 以避免阻塞主线程

Tips: ArrayBuffer相关介绍

2. 计算分片 Hash

2.1 目标

使用文件分片的 Hash 来标识文件分片, 用来判断这个分片是否已经上传过了

2.2 使用 Promise.all 处理

使用 hash-wasm 对分片进行 Hash 计算

import { crc32, md5 } from 'hash-wasm';

export async function singleChunkProcessor(
  chunkBlob: Blob,
  strategy: Strategy
) {
  const arrayBuffer = await chunkBlob.arrayBuffer();
  const unit8Array = new Uint8Array(arrayBuffer);
  return strategy === Strategy.md5 || strategy === Strategy.mixed
    ? [await md5(unit8Array)]
    : [await crc32(unit8Array)];
}

export async function getChunksHashWithPromise(chunksBlob: Blob[]) {
  let chunksHash: string[] = [];

  await Promise.all(
    chunksBlob.map((v) => {
      return singleChunkProcessor(v, Strategy.crc32);
    })
  ).then((res) => {
    chunksHash = res.flat();
  });

  return chunksHash;
}

2.3 使用 Web Workers

由于计算文件分片 Hash 是一个 CPU 密集型任务, 直接在主线程中计算 hash 必定会导致 UI 卡死, 考虑做以下几点优化:

  1. 放到 WebWorker 中计算 Hash。并且,ArrayBuffer 是可 Transfer 的对象, 在主线程与 Worker 线程通信时, 可以通过移交控制权的方式通信, 避免线程通信引起的结构化克隆
  2. 分片之间的 Hash 计算没有关联, 而 WebWorker 可以用来开额外的计算线程, 考虑基于 WebWorker 实现线程池(WorkerPool)来加速计算分片 Hash
  3. 当文件较大时计算使用分片的 MD5值作为 Hash 计算速度仍然较慢, 但分片的 hash 其实只是为了标识分片, 对于唯一性要求并不高, 考虑在文件较大的场景下使用 CRC32 值作为分片的 Hash。CRC32的十六进制表示只有8位(MD5有32位), 且 CPU 对计算 CRC32 有硬件加速, 速度会比计算 MD5 快得多

相关参考:https://juejin.cn/post/7353106546827624463#heading-9

3. 计算文件 Hash

3.1 目标

计算文件的 Hash 用来标识这个文件是否已上传

3.2 存在的问题与解决思路

计算全部文件的 hash 效率过低,考虑使用以下两种方案之一

4.上传文件分片

4.1 目标

  • 实现文件分片的并发上传
  • 实现中止以及继续上传文件分片
  • 实时展示文件上传进度

4.2 实现文件分片的并发上传

实现 PromisePool 来控制请求的发送:https://juejin.cn/post/7353106546827624463#heading-23

展示上传进度

todo...

服务端 Part

GetSuccessChunks

通过文件 hash 判断当前文件是否存在上传记录

  • 未上传
  • 部分上传:通过 minio client 提供的接口判断当前文件上传过的 chunks
  • 上传完成

NewMultipart

文件未上传的情况下调用该请求,向数据库中插入一条记录

GetMultipartUploadUrl

通过 minio client 生成上传部分 chunks 的 url

UpdateFileChunk

更新 MySQL 记录中的 completed_parts 字段

CompleteMultipart

通过 minio client 提供的接口合并之前上传过的文件分片并标记数据库中记录为上传成功

Todo

  1. 暂停上传功能
  2. 关闭应用后重启保留上传进度
  3. 分布式服务
  4. 分片 Hash 判断文件分片是否上传
  5. 多文件同时上传

参考至:

  1. https://juejin.cn/post/7353106546827624463
  2. https://www.cnblogs.com/xiahj/p/vue-simple-uploader.html
  3. https://juejin.cn/post/6844904046436843527
  4. https://www.infoq.cn/article/lwlcldgjyc7lye95ewl8
  5. https://juejin.cn/post/7129446744080777224
  6. https://juejin.cn/post/7354362021428117519