【场景题】如何解决大文件上传问题

如何解决大文件上传问题

  • [1. 概述](#1. 概述)
  • [2. 技术方案](#2. 技术方案)
    • [2.1 好处](#2.1 好处)
    • [2.2 前端怎么生成文件分片呢?后端如何合并文件分片呢?](#2.2 前端怎么生成文件分片呢?后端如何合并文件分片呢?)
  • [3. 代码](#3. 代码)
    • [3.1 整体思路与流程](#3.1 整体思路与流程)
    • [3.2 流程图](#3.2 流程图)
    • [3.3 前端代码](#3.3 前端代码)
    • [3.4 后端代码](#3.4 后端代码)
  • 附录

1. 概述

场景一:上传一个5G大小的视频,如果上传进度到达99%,然后突然网络断了,这个时候你发现需要重新上传,很抓狂,那么如何解决这个问题呢?

答案:分片上传!

什么是分片上传呢? 简单来说就是先将文件切分成多个文件分片(如下图),然后再上传这些小的文件分片。

前端发送所有文件分片之后,服务端将这些文件分片进行合并即可,这样就得到一个完整的文件。

2. 技术方案

大致的流程如下:

  1. 生成要上传文件的唯一标识(如SHA-256);
  2. 将需要上传的文件按照一定的分割规则,分割成相同大小的分片;
  3. 初始化一个分片上传任务,返回本次分片上传的唯一标识;
  4. 每个分片在发送前,客户端会计算其哈希值(如SHA-256),并将这个哈希值与分片一起发送给服务端;
  5. 按照一定的策略(串行或并行)发送各个分片;
  6. 服务器接收到分片后,会重新计算分片的哈希值,并与客户端发送的哈希值进行比对;
  7. 如果哈希值匹配,则认为该分片有效,服务器会存储该分片并等待其他分片的到来;如果哈希值不匹配,服务器会通知客户端重新发送该分片;
  8. 所有分片发送完成后,服务端会根据判断数据上传是否完整。如果数据完整,服务端则进行分片的合成,以得到原始文件。
  9. 再计算合并后的文件的唯一标识,两者进行对比,一致则说明没问题。

2.1 好处

使用分片上传主要有下面2点好处:

  1. 断点续传:上传文件中途暂停或失败(比如遇到网络问题)之后,不需要重新上传,只需要上传那些未成功上传的文件分片即可。所以,分片上传是断点续传的基础。
  2. 多线程上传:我们可以通过多线程同时对一个文件的多个文件分片进行上传,这样的话就大大加快的文件上传的速度。

2.2 前端怎么生成文件分片呢?后端如何合并文件分片呢?

前端可以通过 Blob.slice()方法来对文件进行切割(File 对象是继承 Blob 对象的,因此 File对象也有 slice()方法)。

生成文件切片的示例代码如下:


RandomAccessFile 类可以帮助我们合并文件分片,示例代码如下:

3. 代码

3.1 整体思路与流程

核心目标

  1. 支持大文件上传(数百 MB / GB 级别)。
  2. 支持断点续传,避免重复上传已完成的分片。
  3. 保证文件完整性(分片与文件级哈希校验)。
  4. 并行上传以提高上传速度。

整体流程

  1. 文件唯一标识

    • 客户端计算文件 SHA-256(可用于断点续传和完整性校验)。
  2. 初始化上传任务

    • 客户端请求服务器创建 uploadId。
    • 服务端在 Redis 或数据库记录任务信息(文件名、文件大小、已上传分片)。
  3. 分片上传

    • 客户端将文件按固定大小切片(4MB ~ 8MB)。
    • 计算每个分片 SHA-256。
    • 客户端请求服务端缺失分片列表,只上传未完成分片。
    • 上传分片时,服务端校验分片哈希并保存到临时目录。
    • 成功上传后,更新 Redis 中已上传分片状态。
  4. 分片合并

    • 所有分片上传完成后,客户端请求服务端合并。
    • 服务端按序号合并分片,并计算完整文件哈希与客户端 hash 比对。
    • 合并成功后,删除临时分片和 Redis 记录。

3.2 流程图

bash 复制代码
+---------------------+       +--------------------+
|      客户端         |       |      服务端        |
+---------------------+       +--------------------+
          |                             |
          |---> 计算文件 SHA-256 ------>|
          |                             |
          |---> 初始化上传任务 -------->|
          |       返回 uploadId        |
          |                             |
          |<-- 请求缺失分片列表 -------|
          |                             |
          |---> 上传分片(chunk+hash)->|
          |                             |
          |<-- 分片上传结果 success ----|
          |                             |
         ...(循环上传缺失分片)...
          |                             |
          |---> 请求合并分片 ---------->|
          |                             |
          |<-- 合并完成,返回文件路径 --|
          |                             |

3.3 前端代码

javascript 复制代码
<template>
  <div>
    <input type="file" @change="handleFileChange" />
    <button @click="uploadFile">上传文件</button>
    <div v-if="progress >= 0">上传进度:{{ progress }}%</div>
  </div>
</template>

<script>
import SparkMD5 from 'spark-md5'; // 用于快速计算文件或分片 hash

export default {
  data() {
    return {
      file: null,
      progress: -1,
      chunkSize: 4 * 1024 * 1024, // 4MB分片
      uploadId: null,
    };
  },
  methods: {
    handleFileChange(e) {
      this.file = e.target.files[0];
    },

    // 计算文件或分片 SHA-256
    async calculateFileHash(file) {
      return new Promise((resolve, reject) => {
        const chunkSize = 4 * 1024 * 1024;
        const chunks = Math.ceil(file.size / chunkSize);
        let currentChunk = 0;
        const spark = new SparkMD5.ArrayBuffer();

        const fileReader = new FileReader();
        fileReader.onload = e => {
          spark.append(e.target.result);
          currentChunk++;
          if (currentChunk < chunks) {
            loadNext();
          } else {
            resolve(spark.end()); // 返回 hash
          }
        };

        fileReader.onerror = () => reject('文件读取错误');

        function loadNext() {
          const start = currentChunk * chunkSize;
          const end = Math.min(start + chunkSize, file.size);
          fileReader.readAsArrayBuffer(file.slice(start, end));
        }

        loadNext();
      });
    },

    async uploadFile() {
      if (!this.file) return alert('请选择文件');

      // 1️⃣ 计算文件 hash
      const fileHash = await this.calculateFileHash(this.file);

      // 2️⃣ 初始化上传任务
      const initResp = await fetch('/upload/init', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ fileName: this.file.name, fileSize: this.file.size, fileHash }),
      });
      const initData = await initResp.json();
      this.uploadId = initData.uploadId;

      const totalChunks = Math.ceil(this.file.size / this.chunkSize);

      // 3️⃣ 获取未上传分片列表(断点续传)
      const missingResp = await fetch(`/upload/missing?uploadId=${this.uploadId}&totalChunks=${totalChunks}`);
      const missingChunks = (await missingResp.json()).missingChunks;

      // 4️⃣ 上传分片
      let uploaded = totalChunks - missingChunks.length;

      const uploadChunk = async (index) => {
        const start = index * this.chunkSize;
        const end = Math.min(start + this.chunkSize, this.file.size);
        const chunk = this.file.slice(start, end);
        const chunkHash = await this.calculateFileHash(chunk);

        const formData = new FormData();
        formData.append('uploadId', this.uploadId);
        formData.append('chunkIndex', index);
        formData.append('chunkHash', chunkHash);
        formData.append('chunk', chunk);

        const resp = await fetch('/upload/chunk', { method: 'POST', body: formData });
        const data = await resp.json();

        if (!data.success) {
          // 重试逻辑
          await uploadChunk(index);
        } else {
          uploaded++;
          this.progress = Math.floor((uploaded / totalChunks) * 100);
        }
      };

      // 并行上传
      const concurrency = 3;
      const queue = missingChunks.map(i => async () => await uploadChunk(i));

      const parallel = async (tasks, limit = concurrency) => {
        const results = [];
        const executing = [];
        for (const task of tasks) {
          const p = task();
          results.push(p);
          executing.push(p);
          if (executing.length >= limit) {
            await Promise.race(executing);
            executing.splice(executing.findIndex(e => e === p), 1);
          }
        }
        return Promise.all(results);
      };

      await parallel(queue);

      // 5️⃣ 合并分片
      await fetch('/upload/merge', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ uploadId: this.uploadId, fileHash }),
      });

      alert('上传完成!');
    },
  },
};
</script>

这个前端代码实现了一个 分片上传 + 断点续传 + 并行上传 的文件上传器,主要功能:

  1. 用户选择文件
  2. 计算文件或分片的 hash
  3. 初始化上传任务(生成 uploadId,存储任务信息)
  4. 获取未上传的分片列表(支持断点续传)
  5. 并行上传缺失分片,每个分片上传前校验 hash
  6. 上传完成后调用合并接口,将分片合并成完整文件
  7. 显示上传进度
swift 复制代码
文件选择
    │
    ▼
计算文件 Hash
    │
    ▼
初始化上传任务 (/upload/init)
    │
    ▼
获取缺失分片 (/upload/missing)
    │
    ▼
┌───────────────────────────────┐
│   并行上传缺失分片 (/upload/chunk) │
│   - 分片 Hash 校验             │
│   - 上传成功更新进度           │
└───────────────────────────────┘
    │
    ▼
合并分片 (/upload/merge)
    │
    ▼
上传完成提示

1️⃣ 初始化上传任务 (/upload/init)

前端第一次调用 /upload/init,服务端会做三件事:

  1. 生成唯一 uploadId(标识本次上传任务)
  2. 创建临时目录存储分片
  3. 在 Redis 里初始化上传状态
bash 复制代码
Key: uploadId
Fields:
- fileName: 文件名
- fileHash: 文件 hash
- fileSize: 文件大小
- uploadedChunks: ""  ← 空字符串,表示还没有上传任何分片
  • 这个接口不会返回哪些分片已上传,因为刚开始上传还没有分片成功。

2️⃣ 获取未上传分片 (/upload/missing)

这个接口的作用是 断点续传。

  • 前端传 uploadId 和 totalChunks(文件总分片数)
  • 服务端从 Redis 中读取 uploadedChunks 字段(已上传分片编号,逗号分隔)
  • 然后计算哪些分片 还没有上传:
bash 复制代码
missingChunks = 0..totalChunks-1 - uploadedChunks

第一次上传:

  • Redis uploadedChunks 为空

  • 所以 missingChunks 就是 [0, 1, 2, 3, ... totalChunks-1],即全量上传

断点续传场景:

  • 比如客户端上传了一部分分片,断网后重新调用 /upload/missing

  • Redis 里 uploadedChunks 记录了已上传成功的分片编号

  • /missing 接口就只返回 缺失分片编号,前端只上传缺失分片


3️⃣ 上传分片 (/upload/chunk)

每上传一个分片,前端会:

  1. 计算分片 hash
  2. POST 分片到服务端 /upload/chunk

服务端会:

  1. 保存分片到临时目录
  2. 校验分片 hash
  3. 如果成功,将分片编号添加到 Redis uploadedChunks 中
java 复制代码
上传 chunkIndex=0 成功
Redis: uploadedChunks = "0"

上传 chunkIndex=1 成功
Redis: uploadedChunks = "0,1"

4️⃣ 前端如何知道分片上传成功?

  • /upload/chunk 接口返回 JSON:
bash 复制代码
{ "success": true }
  • success = true → 分片上传成功
  • success = false → 分片上传失败,需要重传
  • 前端同时更新本地上传进度:
bash 复制代码
uploaded++; 
this.progress = Math.floor((uploaded / totalChunks) * 100);
  • 所以 上传成功的分片信息 主要由两部分保证:
    1. 服务端 Redis 记录 uploadedChunks
    2. 每个 /chunk 接口返回的 success

3.4 后端代码

java 复制代码
@RestController
@RequestMapping("/upload")
public class UploadController {

    private final String TEMP_DIR = "upload_tmp/";
    private final String FINAL_DIR = "upload_final/";

    @Autowired
    private StringRedisTemplate redisTemplate;

    /** 1.初始化上传任务 */
    @PostMapping("/init")
    public Map<String, Object> initUpload(@RequestBody Map<String, Object> request) {
        // 1️⃣ 从客户端请求中获取文件信息
        // 文件名
        String fileName = (String) request.get("fileName");
        // 文件整体 SHA-256
        String fileHash = (String) request.get("fileHash");
        // 文件大小(字节)
        long fileSize = ((Number) request.get("fileSize")).longValue();
				
		// 2️⃣ 生成本次上传任务的唯一 ID
        String uploadId = UUID.randomUUID().toString();
		// 3️⃣ 创建临时存放分片的目录
        File dir = new File(TEMP_DIR + uploadId);
        if (!dir.exists()) dir.mkdirs();

        // Redis记录任务信息
        redisTemplate.opsForHash().put(uploadId, "fileName", fileName);
        redisTemplate.opsForHash().put(uploadId, "fileHash", fileHash);
        redisTemplate.opsForHash().put(uploadId, "fileSize", String.valueOf(fileSize));
        redisTemplate.opsForHash().put(uploadId, "uploadedChunks", "");
				
		// 返回 uploadId 给客户端,后续上传分片使用
        Map<String, Object> resp = new HashMap<>();
        resp.put("uploadId", uploadId);
        return resp;
    }

    /** 2.获取未上传分片 */
    @GetMapping("/missing")
    public Map<String, Object> getMissingChunks(@RequestParam String uploadId,
                                                @RequestParam int totalChunks) {
        // 从 Redis Hash 中获取字段 uploadedChunks,表示已成功上传的分片编号(逗号分隔)。
	    // 示例值可能是 "0,1,2,5",表示 0、1、2、5 分片已经上传。
        String uploadedChunksStr = (String) redisTemplate.opsForHash().get(uploadId, "uploadedChunks");

        Set<Integer> uploadedChunks = new HashSet<>();
        if (uploadedChunksStr != null && !uploadedChunksStr.isEmpty()) {
            for (String s : uploadedChunksStr.split(",")) uploadedChunks.add(Integer.parseInt(s));
        }

        List<Integer> missingChunks = new ArrayList<>();
        for (int i = 0; i < totalChunks; i++) {
            if (!uploadedChunks.contains(i)) missingChunks.add(i);
        }
				
		// 缺失分片列表
        Map<String, Object> resp = new HashMap<>();
        resp.put("missingChunks", missingChunks);
        return resp;
    }

    /** 3.上传分片 */
    @PostMapping("/chunk")
    public Map<String, Object> uploadChunk(@RequestParam String uploadId,
                                           @RequestParam int chunkIndex,
                                           @RequestParam String chunkHash,
                                           @RequestParam MultipartFile chunk) throws Exception {
        File dir = new File(TEMP_DIR + uploadId);
        if (!dir.exists()) dir.mkdirs();

        File file = new File(dir, chunkIndex + ".part");
        chunk.transferTo(file);

        // 校验分片hash
        String localHash = DigestUtils.sha256Hex(new FileInputStream(file));
        boolean success = localHash.equalsIgnoreCase(chunkHash);

        if (success) {
            // 更新 Redis 已上传分片记录
            String uploadedChunksStr = (String) redisTemplate.opsForHash().get(uploadId, "uploadedChunks");
            Set<String> uploadedSet = new HashSet<>();
            if (uploadedChunksStr != null && !uploadedChunksStr.isEmpty())
                uploadedSet.addAll(Arrays.asList(uploadedChunksStr.split(",")));
            uploadedSet.add(String.valueOf(chunkIndex));
            redisTemplate.opsForHash().put(uploadId, "uploadedChunks", String.join(",", uploadedSet));
        }

        Map<String, Object> resp = new HashMap<>();
        resp.put("success", success);
        return resp;
    }

    /** 4.合并分片 */
    @PostMapping("/merge")
    public Map<String, Object> mergeChunks(@RequestBody Map<String, Object> request) throws Exception {
        String uploadId = (String) request.get("uploadId");
        String fileHash = (String) request.get("fileHash");

        File dir = new File(TEMP_DIR + uploadId);
        File[] chunks = dir.listFiles((d, name) -> name.endsWith(".part"));
        if (chunks == null || chunks.length == 0) throw new RuntimeException("没有分片");

        Arrays.sort(chunks, Comparator.comparingInt(f -> Integer.parseInt(f.getName().replace(".part", ""))));

        File finalFile = new File(FINAL_DIR + uploadId + ".dat");
        if (!finalFile.getParentFile().exists()) finalFile.getParentFile().mkdirs();

        try (FileOutputStream fos = new FileOutputStream(finalFile)) {
            for (File chunk : chunks) {
                Files.copy(chunk.toPath(), fos);
            }
        }

        // 校验完整文件hash
        String mergedHash = DigestUtils.sha256Hex(new FileInputStream(finalFile));
        if (!mergedHash.equalsIgnoreCase(fileHash))
            throw new RuntimeException("文件合并后hash校验失败");

        // 清理临时分片和Redis
        for (File chunk : chunks) chunk.delete();
        dir.delete();
        redisTemplate.delete(uploadId);

        Map<String, Object> resp = new HashMap<>();
        resp.put("success", true);
        resp.put("filePath", finalFile.getAbsolutePath());
        return resp;
    }
}

Redis 存储结构,初始化上传任务

Key (uploadId) Type Field Value
550e8400-e29b-41d4-a716-446655440000 Hash fileName "myfile.zip"
fileHash "abc123..."
fileSize "104857600"
uploadedChunks "" (空,表示还没有分片上传)
  • Key:uploadId(唯一标识一次上传任务)
  • Hash Fields:
    • fileName:文件名
    • fileHash:整个文件的 SHA-256
    • fileSize:文件大小
    • uploadedChunks:已上传的分片编号,用逗号分隔,初始化为空

3.上传分片代码介绍

  • 参数:
    • uploadId:上传任务唯一标识,用于找到对应的临时目录和 Redis 信息。
    • chunkIndex:分片序号(0、1、2...),用于存储和合并时排序。
    • chunkHash:客户端计算的分片 SHA-256,用于校验分片完整性。
    • chunk:MultipartFile 分片内容。
      步骤解释:
  1. 从 Redis 获取已上传分片列表 uploadedChunks。
  2. 转成 Set 存储,方便添加新分片并去重。
  3. 将当前分片 chunkIndex 添加到 Set。
  4. 将 Set 再拼成逗号分隔字符串写回 Redis,更新上传状态。
  5. 这样客户端下次请求 /missing 接口就知道哪些分片已经上传,支持断点续传。

附录

  1. 撸了个多线程断点续传下载器,我从中学习到了这些知识 https://mp.weixin.qq.com/s/bI5xYq3jUtp-sviKlzHtNg
  2. 大规格文件的上传优化 https://juejin.cn/post/6844904155086061576
相关推荐
小信丶13 分钟前
Spring Cloud Stream EnableBinding注解详解:定义、应用场景与示例代码
java·spring boot·后端·spring
无限进步_17 分钟前
【C++】验证回文字符串:高效算法详解与优化
java·开发语言·c++·git·算法·github·visual studio
亚历克斯神18 分钟前
Spring Cloud 2026 架构演进
java·spring·微服务
七夜zippoe21 分钟前
Spring Cloud与Dubbo架构哲学对决
java·spring cloud·架构·dubbo·配置中心
海派程序猿21 分钟前
Spring Cloud Config拉取配置过慢导致服务启动延迟的优化技巧
java
阿维的博客日记32 分钟前
为什么不逃逸代表不需要锁,JIT会直接删掉锁
java
William Dawson33 分钟前
CAS的底层实现
java
九英里路1 小时前
cpp容器——string模拟实现
java·前端·数据结构·c++·算法·容器·字符串
YDS8291 小时前
大营销平台 —— 抽奖前置规则过滤
java·spring boot·ddd
仍然.1 小时前
多线程---CAS,JUC组件和线程安全的集合类
java·开发语言