【学习】大文件分片上传技术详解

前言

在日常的Web应用中,文件上传是一个非常常见的功能。对于小文件,传统的表单提交或Ajax异步上传方式尚能应付。但当涉及到G级别甚至更大的文件时,一次性将整个文件内容发送到服务器会面临诸多挑战,例如:上传时间过长导致HTTP请求超时、网络波动导致上传失败需要从头开始、服务器内存和带宽压力过大等。为了解决这些问题,大文件分片上传技术应运而生。本文将详细介绍分片上传的原理、实现方案以及相关的优化策略。

一、什么是分片上传?

分片上传,顾名思义,就是将一个大文件分割成若干个较小的数据块(称为”分片”或”chunk”),然后将这些分片独立地、逐个或并发地上传到服务器。当所有分片都成功上传后,服务器再将这些分片按照原始顺序合并成完整的文件。

(一)分片上传的优势

采用分片上传技术可以带来诸多好处:

  1. 提高上传成功率:网络环境复杂多变,大文件一次性上传很容易因网络抖动、超时等原因失败。分片上传将大任务分解为小任务,单个分片上传失败后,只需重新上传该分片,而无需重传整个文件,大大提高了成功率。
  2. 支持断点续传:可以记录已成功上传的分片信息。即使用户关闭浏览器或网络中断,下次上传时可以从上一次中断的地方继续上传,提升了用户体验。
  3. 支持秒传:通过计算文件的唯一标识(如MD5或SHA值),可以在上传前检查服务器是否已存在相同文件。如果存在,则无需实际上传文件数据,实现”秒传”效果。
  4. 并发上传提高效率:可以同时上传多个分片,充分利用客户端和服务器的带宽资源,缩短整体上传时间。
  5. 减轻服务器压力:分片上传避免了服务器一次性处理巨大请求的压力,可以更平稳地处理流入数据。
  6. 更友好的进度反馈:可以精确地计算并展示每个分片乃至整个文件的上传进度。

二、分片上传的核心流程

分片上传的实现涉及前端和后端的紧密配合。

(一)前端核心逻辑

  1. 选择文件:用户通过文件选择框 <input type="file"> 选择需要上传的文件。
  2. 文件信息获取:获取文件的基本信息,如文件名 (name)、大小 (size)、类型 (type)。
  3. 计算文件Hash(用于秒传和唯一标识)
    • 对于大文件,直接读取整个文件内容计算Hash会导致浏览器卡顿或崩溃。因此,通常采用增量计算的方式,例如使用 spark-md5 库。
    • 可以读取文件的一小部分(如头部、尾部和中间部分)进行抽样Hash,或者使用Web Workers在后台线程计算完整文件Hash。
      // 示例:使用spark-md5计算文件hash
      // import SparkMD5 from 'spark-md5'; // 需要引入库
      
      function calculateFileHash(file) {
        return new Promise((resolve, reject) => {
          const spark = new SparkMD5.ArrayBuffer();
          const fileReader = new FileReader();
          const chunkSize = 2 * 1024 * 1024; // 2MB per chunk for hashing
          let currentChunk = 0;
          const chunks = Math.ceil(file.size / chunkSize);
      
          fileReader.onload = (e) => {
            spark.append(e.target.result);
            currentChunk++;
            if (currentChunk < chunks) {
              loadNext();
            } else {
              resolve(spark.end());
            }
          };
      
          fileReader.onerror = (e) => {
            reject(e);
          };
      
          function loadNext() {
            const start = currentChunk * chunkSize;
            const end = Math.min(start + chunkSize, file.size);
            fileReader.readAsArrayBuffer(file.slice(start, end));
          }
      
          loadNext();
        });
      }
  4. 文件分片
    • 根据预设的分片大小(如1MB、5MB)将文件切割成多个Blob对象。JavaScript的 File.prototype.slice() 方法可以轻松实现文件切割。
      function createFileChunks(file, chunkSize = 5 * 1024 * 1024) { // 默认5MB分片
        const chunks = [];
        let offset = 0;
        while (offset < file.size) {
          chunks.push(file.slice(offset, offset + chunkSize));
          offset += chunkSize;
        }
        return chunks;
      }
  5. 上传分片
    • 查询已上传分片(断点续传):在开始上传前,前端可以携带文件Hash向后端查询该文件的上传状态,获取已成功上传的分片列表。
    • 构造表单数据:每个分片上传时,通常需要携带以下信息:
      • 当前分片数据 (chunk)
      • 分片序号 (chunkIndexchunkNumber)
      • 总分片数量 (totalChunks)
      • 文件唯一标识 (fileHash)
      • 文件名 (filename)
      • (可选)分片大小 (chunkSize)、文件总大小 (totalSize)
    • 发送请求:使用 FormData 对象包装分片数据,并通过 XMLHttpRequestfetch API异步上传到后端指定接口。
    • 并发控制:为了避免一次性发送过多请求导致浏览器或服务器卡顿,需要控制并发上传的分片数量。可以使用 Promise.all 配合一个”请求池”或限制并发的异步队列来实现。
      async function uploadChunk(chunk, index, fileHash, totalChunks, filename) {
        const formData = new FormData();
        formData.append('chunk', chunk);
        formData.append('chunkIndex', index);
        formData.append('totalChunks', totalChunks);
        formData.append('fileHash', fileHash);
        formData.append('filename', filename);
        // 假设后端接口为 /upload-chunk
        try {
          const response = await fetch('/upload-chunk', {
            method: 'POST',
            body: formData,
          });
          if (!response.ok) {
            throw new Error(`Chunk ${index} upload failed: ${response.statusText}`);
          }
          return await response.json(); // 后端可能返回一些信息
        } catch (error) {
          console.error(`Error uploading chunk ${index}:`, error);
          // 此处可以加入重试逻辑
          throw error;
        }
      }
      
      // 简易并发控制示例
      async function concurrentUpload(chunks, fileHash, filename, concurrency = 3) {
        const totalChunks = chunks.length;
        const uploadPromises = [];
        let currentIndex = 0;
      
        const worker = async () => {
          while(currentIndex < totalChunks) {
            const chunkIndexToUpload = currentIndex;
            currentIndex++; // 提前递增,防止多个worker拿到相同index
            if (chunkIndexToUpload < totalChunks) {
              // 实际项目中,这里应检查此分片是否已上传
              console.log(`Uploading chunk ${chunkIndexToUpload + 1}/${totalChunks}`);
              try {
                await uploadChunk(chunks[chunkIndexToUpload], chunkIndexToUpload, fileHash, totalChunks, filename);
                console.log(`Chunk ${chunkIndexToUpload + 1} uploaded successfully.`);
                // 更新进度条
              } catch (error) {
                console.error(`Failed to upload chunk ${chunkIndexToUpload + 1}.`);
                // 可以将失败的块索引记录下来,用于后续重试
              }
            }
          }
        };
      
        for (let i = 0; i < concurrency; i++) {
          uploadPromises.push(worker());
        }
      
        await Promise.all(uploadPromises);
        console.log('All chunks attempted to upload.');
        // 所有分片上传完毕(或尝试完毕后),通知后端合并
        await notifyServerToMerge(fileHash, filename, totalChunks);
      }
  6. 进度显示:监听每个分片上传的 progress 事件(XMLHttpRequest.upload.onprogress)或通过已上传分片数量计算整体进度。
  7. 通知合并:所有分片上传成功后,前端发送一个请求通知后端合并这些分片。该请求通常需要携带文件Hash、文件名、总分片数等信息。

(二)后端核心逻辑

  1. 接收分片
    • 提供一个接口用于接收前端上传的分片数据。
    • 对接收到的参数进行校验(如 chunkIndex 是否在范围内,fileHash 是否有效等)。
    • 后端需要临时存储这些分片。通常的做法是为每个文件创建一个以其Hash命名的临时文件夹,文件夹内存储各个分片文件,分片文件名可以使用其序号(如 0, 1, 2, …)。
      # 示例:服务器端存储结构
      /tmp/upload_temp/
          ├── <fileHash1>/
          │   ├── 0
          │   ├── 1
          │   ├── ...
          ├── <fileHash2>/
          │   ├── 0
          │   ├── 1
          │   ├── ...
  2. 记录分片状态(用于断点续传)
    • 在接收到分片后,后端需要记录哪些分片已经成功上传。可以使用Redis、数据库或文件系统自身来记录。
    • 提供一个接口供前端查询已上传的分片列表。
  3. 合并分片
    • 提供一个接口,接收前端的合并请求。
    • 校验该文件的所有分片是否都已上传完毕。
    • 如果所有分片都存在,则按照分片序号顺序读取每个分片的内容,并将它们写入到最终的目标文件中。
    • 合并完成后,可以删除临时存储的分片文件和文件夹。
    • 注意:文件合并可能是一个耗时操作,对于非常大的文件,可以考虑异步处理合并请求。
      // 示例:Java后端合并分片逻辑 (伪代码)
      // public void mergeChunks(String fileHash, String finalFileName, int totalChunks) {
      //   File tempDir = new File("/tmp/upload_temp/" + fileHash);
      //   if (!tempDir.exists() || !tempDir.isDirectory()) {
      //     throw new RuntimeException("Temporary chunks directory not found.");
      //   }
      //   File finalFile = new File("/path/to/final/storage/" + finalFileName);
      //
      //   try (FileOutputStream fos = new FileOutputStream(finalFile);
      //        FileChannel outChannel = fos.getChannel()) {
      //     for (int i = 0; i < totalChunks; i++) {
      //       File chunkFile = new File(tempDir, String.valueOf(i));
      //       if (!chunkFile.exists()) {
      //         throw new RuntimeException("Chunk " + i + " not found for file " + fileHash);
      //       }
      //       try (FileInputStream fis = new FileInputStream(chunkFile);
      //            FileChannel inChannel = fis.getChannel()) {
      //         inChannel.transferTo(0, inChannel.size(), outChannel);
      //       }
      //       // chunkFile.delete(); // 可以在合并成功后统一删除
      //     }
      //   } catch (IOException e) {
      //     // 处理IO异常,可能需要删除已创建的部分合并文件
      //     throw new RuntimeException("Failed to merge chunks.", e);
      //   }
      //   // tempDir.delete(); // 删除临时文件夹
      //   System.out.println("File merged successfully: " + finalFileName);
      // }
  4. 秒传逻辑
    • 在文件上传前(例如,前端计算完Hash后),前端可以向后端发送一个”检查文件”的请求,携带文件Hash。
    • 后端根据Hash查找是否已存在该文件(通常在文件信息表中记录Hash与文件路径的映射)。
    • 如果文件已存在且完整,则直接返回成功信息和文件URL,前端无需再进行上传操作。

三、关键技术点与优化

(一)断点续传

断点续传的核心在于前端能够知晓哪些分片已经上传成功,并在下次上传时跳过这些分片。

  1. 前端
    • 在上传开始前,向后端发送请求,携带文件Hash,查询已上传的分片序号列表。
    • 在上传分片时,跳过已存在于列表中的分片。
  2. 后端
    • 每成功接收一个分片,就记录下该分片的信息(如 fileHashchunkIndex)。可以使用Redis的Set结构存储,或者在数据库中记录。
    • 提供查询接口,根据 fileHash 返回已上传的分片序号列表。

(二)秒传实现

秒传依赖于文件内容的唯一性校验。

  1. 前端:计算文件Hash。
  2. 后端
    • 维护一个文件Hash与实际存储路径的映射表。
    • 接收到前端的秒传检查请求(携带Hash)后,查询该Hash是否存在。
    • 若存在,则说明文件已上传,直接返回成功和文件信息。
    • 注意:需要确保Hash对应的文件是完整且可用的。

(三)并发控制与请求队列

同时发送大量HTTP请求会耗尽浏览器资源并可能导致服务器过载。

  • 请求池:维护一个固定大小的”正在进行的请求”池。当池未满时,可以发送新的分片上传请求;当池满时,新的请求需要等待池中有请求完成后才能发出。
  • 异步队列:将所有待上传的分片任务放入一个队列,然后启动固定数量的worker从队列中取出任务并执行上传。

(四)错误处理与重试机制

网络不稳定或服务器临时故障可能导致分片上传失败。

  • 前端:为每个分片上传请求设置超时时间,并捕获错误。对于可恢复的错误(如网络超时、服务器50x错误),可以进行自动重试,例如重试2-3次,每次重试间隔可以适当增加。
  • 后端:确保分片上传接口的幂等性,即同一分片多次上传(例如因前端重试)不会产生副作用。

(五)分片大小的选择

分片大小是一个需要权衡的参数:

  • 太小:会导致分片数量过多,HTTP请求次数增加,网络开销和服务器处理开销增大。
  • 太大:单个分片上传时间变长,发生错误时重传成本高,断点续传的粒度变粗,进度反馈不及时。
  • 通常建议将分片大小设置在1MB到10MB之间,具体可以根据应用场景和网络状况进行调整测试。

(六)安全性考虑

  • 权限校验:所有上传相关接口都需要进行用户身份验证和权限检查。
  • 文件类型与大小限制:后端需要对上传的文件类型、总大小以及分片大小进行校验和限制,防止恶意上传。
  • 临时文件清理:确保上传失败或长时间未完成合并的临时分片文件能够被定期清理,防止磁盘空间被占满。

四、常用库与工具

  • 前端
    • spark-md5.js: 用于快速计算文件MD5值。
    • axios / fetch API: 用于发送HTTP请求。
    • Web Workers: 可用于在后台线程计算文件Hash或处理其他耗时任务,避免阻塞主线程。
    • vue-simple-uploader / WebUploader / plupload: 成熟的前端上传组件库,内置了分片、断点续传、并发控制等功能。
  • 后端
    • 具体语言和框架的文件操作API (如Node.js的 fs模块, Java的 java.io / java.nio)。
    • 缓存服务如Redis,用于存储分片上传状态。
    • 对象存储服务(如AWS S3, Aliyun OSS, MinIO),它们通常原生支持分片上传协议。

五、总结

大文件分片上传通过将文件分解、逐片处理、最终合并的方式,有效地解决了传统大文件上传面临的各种难题。它不仅提高了上传的可靠性和用户体验,也优化了服务器资源的使用。实现一套完善的分片上传机制需要仔细设计前后端的交互逻辑,并考虑并发控制、断点续传、秒传、错误处理等关键环节。借助现有的成熟库和云服务,可以大大简化开发工作量。

六、参考资料