Skip to content

大文件分片上传、断点续传 &I

实现思路

整体流程

  • 获取文件元数据
  • 文件分片
  • 计算分片 Hash 与 文件 Hash
  • 检查文件是否已经上传过
  • 查询需要上传的文件分片
  • 构建上传参数
  • 上传实际需要上传的分片
  • 待全部分片上传完成后校验分片
  • 合并分片

局部思路

  • 秒传: 文件已存在, 直接给前端返回文件 url
    • 记录文件的 hash 与元数据到数据库中
    • 上传文件前先计算 hash 和获取文件元数据请求接口进行比对
    • 若比对成功则说明文件已存在, 直接返回前端文件 url
  • 断点续传: 上传过程意外中断, 下次上传时不需要从头上传整个文件
    • 前端将文件分片上传, 后端接收分片然后进行合并
    • 上传分片前先请求接口查询需要上传的分片即实现断点续传
  • 上传
    • 实现了基于文件真实上传进度的进度条
    • 实现了可控制 Promise 并发数量的 PromisePool
    • 实现了基于 WebWorker 的 WorkerPool / ThreadPool

大文件分片上传

基本原理

  • 1、将上传的file 先slice分片;
  • 2、对每个分片进行MD5 hash;
    • 为什么要对分片进行MD5 hash:数据完整性
    • 因为文件在传输写入过程中可能会出现错误,导致最终合成的文件可能和原文件不一样,所以要对比一下前端计算的MD5和后端计算的MD5是不是一样,保证上传数据的一致性。
  • 3、异步promise 分片 和 计算Hash;
  • 4、hash计算:CRC32的十六进制表示只有8位(MD5有32位), 且 CPU 对计算 CRC32 有硬件加速, 速度会比计算 MD5 快得多;

进一步优化点

  • 1、改变 hash空间:web worker中计算;
  • 2、改进 hash计算方法:spark-md5 => CRC32 => hash-wasm、MerkleTree 默克尔树:树根 hash 作为 文件的 hash
  • 3、减少hash内容:采样分片内容进行hash可以作为折中方案:将file分片10份 -> 每份采样1kb -> 合并采样数据 -> 直接浏览器hash

代码测试

js-spark-md5 示例代码

js
document.getElementById('file').addEventListener('change', function () {
    var blobSlice = File.prototype.slice || File.prototype.mozSlice || File.prototype.webkitSlice,
        file = this.files[0],
        chunkSize = 2097152,                             // Read in chunks of 2MB
        chunks = Math.ceil(file.size / chunkSize),
        currentChunk = 0,
        spark = new SparkMD5.ArrayBuffer(),
        fileReader = new FileReader();
    fileReader.onload = function (e) {
        console.log('read chunk nr', currentChunk + 1, 'of', chunks);
        spark.append(e.target.result);                   // Append array buffer
        currentChunk++;
        if (currentChunk < chunks) {
            loadNext();
        } else {
            console.log('finished loading');
            // 得到 hash
            console.info('computed hash', spark.end());  // Compute hash
        }
    };
    fileReader.onerror = function () {
        console.warn('oops, something went wrong.');
    };
    function loadNext() {
        var start = currentChunk * chunkSize,
            end = ((start + chunkSize) >= file.size) ? file.size : start + chunkSize;
        // 分片 然后读取
        fileReader.readAsArrayBuffer(blobSlice.call(file, start, end));
    }
    loadNext();
});
js
const ipt = document.getElementById("asyncInput")
ipt.onchange = async (e) => {
    const file = e.target.files[0];
    if (!file) {
        return
    }
    console.time()
    cutFile(file).then(res => {
      console.log("res", res)
      console.timeEnd()

    })
}
const CHUNK_SIZE = 10 * 1024 * 1024; // 每一片文件的大小 这里暂定 每片 10MB
async function cutFile(file) {
    let res = [];
    let chunkCount = Math.ceil(file.size / CHUNK_SIZE); 
    const chunks = new Array(chunkCount).fill("").map((item,index) => createChunks(file,index,CHUNK_SIZE))
    return Promise.allSettled(chunks)
}
function createChunks (file, index, chunkSize) {
    return new Promise((resolve) => {
        const start = index * chunkSize;
        const end = start + chunkSize;
        // const spark = new SparkMD5()
        const fileReader = new FileReader();
        const blob = file.slice(start, end);
        fileReader.onload = e => {
            console.log("onload",index)
            // spark.append(e.target.result)
            console.log("spark.append",index)
            // const hash = spark.end()
            console.log(e.target.result)
            const hash = SparkMD5.ArrayBuffer.hash(e.target.result)
            console.log("spark.end",index)
            resolve({
                start,
                end,
                blob,
                hash,
                index
            });
        }
        fileReader.readAsArrayBuffer(blob)
    });
}

测试结果

  • SparkMD5:
    • 2GB,每片1MB,分2048片,default: 6264.6611328125 ms;
    • 2GB,每片10MB,分205片,default: 1776.876953125 ms;

参考: