【场景题】如何解决大文件上传问题
如何解决大文件上传问题
- 1. 概述
- 2. 技术方案
- 2.1 好处
- 2.2 前端怎么生成文件分片呢?后端如何合并文件分片呢?
- 3. 代码
- 3.1 整体思路与流程
- 3.2 流程图
- 3.3 前端代码
- 3.4 后端代码
- 附录
1. 概述
场景一:上传一个5G大小的视频,如果上传进度到达99%,然后突然网络断了,这个时候你发现需要重新上传,很抓狂,那么如何解决这个问题呢?
答案:分片上传!
什么是分片上传呢? 简单来说就是先将文件切分成多个文件分片(如下图),然后再上传这些小的文件分片。
前端发送所有文件分片之后,服务端将这些文件分片进行合并即可,这样就得到一个完整的文件。
2. 技术方案
大致的流程如下:
- 生成要上传文件的唯一标识(如SHA-256);
- 将需要上传的文件按照一定的分割规则,分割成相同大小的分片;
- 初始化一个分片上传任务,返回本次分片上传的唯一标识;
- 每个分片在发送前,客户端会计算其哈希值(如SHA-256),并将这个哈希值与分片一起发送给服务端;
- 按照一定的策略(串行或并行)发送各个分片;
- 服务器接收到分片后,会重新计算分片的哈希值,并与客户端发送的哈希值进行比对;
- 如果哈希值匹配,则认为该分片有效,服务器会存储该分片并等待其他分片的到来;如果哈希值不匹配,服务器会通知客户端重新发送该分片;
- 所有分片发送完成后,服务端会根据判断数据上传是否完整。如果数据完整,服务端则进行分片的合成,以得到原始文件。
- 再计算合并后的文件的唯一标识,两者进行对比,一致则说明没问题。
2.1 好处
使用分片上传主要有下面2点好处:
- 断点续传:上传文件中途暂停或失败(比如遇到网络问题)之后,不需要重新上传,只需要上传那些未成功上传的文件分片即可。所以,分片上传是断点续传的基础。
- 多线程上传:我们可以通过多线程同时对一个文件的多个文件分片进行上传,这样的话就大大加快的文件上传的速度。
2.2 前端怎么生成文件分片呢?后端如何合并文件分片呢?
前端可以通过 Blob.slice()
方法来对文件进行切割(File 对象是继承 Blob 对象的,因此 File对象也有 slice()
方法)。
生成文件切片的示例代码如下:
RandomAccessFile 类可以帮助我们合并文件分片,示例代码如下:
3. 代码
3.1 整体思路与流程
核心目标
- 支持大文件上传(数百 MB / GB 级别)。
- 支持断点续传,避免重复上传已完成的分片。
- 保证文件完整性(分片与文件级哈希校验)。
- 并行上传以提高上传速度。
整体流程
-
文件唯一标识
- 客户端计算文件 SHA-256(可用于断点续传和完整性校验)。
-
初始化上传任务
- 客户端请求服务器创建 uploadId。
- 服务端在 Redis 或数据库记录任务信息(文件名、文件大小、已上传分片)。
-
分片上传
- 客户端将文件按固定大小切片(4MB ~ 8MB)。
- 计算每个分片 SHA-256。
- 客户端请求服务端缺失分片列表,只上传未完成分片。
- 上传分片时,服务端校验分片哈希并保存到临时目录。
- 成功上传后,更新 Redis 中已上传分片状态。
-
分片合并
- 所有分片上传完成后,客户端请求服务端合并。
- 服务端按序号合并分片,并计算完整文件哈希与客户端 hash 比对。
- 合并成功后,删除临时分片和 Redis 记录。
3.2 流程图
+---------------------+ +--------------------+
| 客户端 | | 服务端 |
+---------------------+ +--------------------+| ||---> 计算文件 SHA-256 ------>|| ||---> 初始化上传任务 -------->|| 返回 uploadId || ||<-- 请求缺失分片列表 -------|| ||---> 上传分片(chunk+hash)->|| ||<-- 分片上传结果 success ----|| |...(循环上传缺失分片)...| ||---> 请求合并分片 ---------->|| ||<-- 合并完成,返回文件路径 --|| |
3.3 前端代码
<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'; // 用于快速计算文件或分片 hashexport default {data() {return {file: null,progress: -1,chunkSize: 4 * 1024 * 1024, // 4MB分片uploadId: null,};},methods: {handleFileChange(e) {this.file = e.target.files[0];},// 计算文件或分片 SHA-256async 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️⃣ 计算文件 hashconst 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>
这个前端代码实现了一个 分片上传 + 断点续传 + 并行上传 的文件上传器,主要功能:
- 用户选择文件
- 计算文件或分片的 hash
- 初始化上传任务(生成 uploadId,存储任务信息)
- 获取未上传的分片列表(支持断点续传)
- 并行上传缺失分片,每个分片上传前校验 hash
- 上传完成后调用合并接口,将分片合并成完整文件
- 显示上传进度
文件选择│▼
计算文件 Hash│▼
初始化上传任务 (/upload/init)│▼
获取缺失分片 (/upload/missing)│▼
┌───────────────────────────────┐
│ 并行上传缺失分片 (/upload/chunk) │
│ - 分片 Hash 校验 │
│ - 上传成功更新进度 │
└───────────────────────────────┘│▼
合并分片 (/upload/merge)│▼
上传完成提示
1️⃣ 初始化上传任务 (/upload/init)
前端第一次调用 /upload/init,服务端会做三件事:
- 生成唯一 uploadId(标识本次上传任务)
- 创建临时目录存储分片
- 在 Redis 里初始化上传状态
Key: uploadId
Fields:
- fileName: 文件名
- fileHash: 文件 hash
- fileSize: 文件大小
- uploadedChunks: "" ← 空字符串,表示还没有上传任何分片
- 这个接口不会返回哪些分片已上传,因为刚开始上传还没有分片成功。
2️⃣ 获取未上传分片 (/upload/missing)
这个接口的作用是 断点续传。
- 前端传 uploadId 和 totalChunks(文件总分片数)
- 服务端从 Redis 中读取 uploadedChunks 字段(已上传分片编号,逗号分隔)
- 然后计算哪些分片 还没有上传:
missingChunks = 0..totalChunks-1 - uploadedChunks
第一次上传:
- Redis uploadedChunks 为空
- 所以 missingChunks 就是 [0, 1, 2, 3, … totalChunks-1],即全量上传
断点续传场景:
- 比如客户端上传了一部分分片,断网后重新调用 /upload/missing
- Redis 里 uploadedChunks 记录了已上传成功的分片编号
- /missing 接口就只返回 缺失分片编号,前端只上传缺失分片
3️⃣ 上传分片 (/upload/chunk)
每上传一个分片,前端会:
- 计算分片 hash
- POST 分片到服务端 /upload/chunk
服务端会:
- 保存分片到临时目录
- 校验分片 hash
- 如果成功,将分片编号添加到 Redis uploadedChunks 中
上传 chunkIndex=0 成功
Redis: uploadedChunks = "0"上传 chunkIndex=1 成功
Redis: uploadedChunks = "0,1"
4️⃣ 前端如何知道分片上传成功?
- /upload/chunk 接口返回 JSON:
{ "success": true }
- success = true → 分片上传成功
- success = false → 分片上传失败,需要重传
- 前端同时更新本地上传进度:
uploaded++;
this.progress = Math.floor((uploaded / totalChunks) * 100);
- 所以 上传成功的分片信息主要由两部分保证:
- 服务端 Redis 记录 uploadedChunks
- 每个 /chunk 接口返回的 success
3.4 后端代码
@RestController
@RequestMapping("/upload")
public class UploadController {private final String TEMP_DIR = "upload_tmp/";private final String FINAL_DIR = "upload_final/";@Autowiredprivate StringRedisTemplate redisTemplate;/** 1.初始化上传任务 */@PostMapping("/init")public Map<String, Object> initUpload(@RequestBody Map<String, Object> request) {// 1️⃣ 从客户端请求中获取文件信息// 文件名String fileName = (String) request.get("fileName");// 文件整体 SHA-256String fileHash = (String) request.get("fileHash");// 文件大小(字节)long fileSize = ((Number) request.get("fileSize")).longValue();// 2️⃣ 生成本次上传任务的唯一 IDString 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);// 校验分片hashString 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);}}// 校验完整文件hashString mergedHash = DigestUtils.sha256Hex(new FileInputStream(finalFile));if (!mergedHash.equalsIgnoreCase(fileHash))throw new RuntimeException("文件合并后hash校验失败");// 清理临时分片和Redisfor (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 分片内容。
步骤解释:
- 从 Redis 获取已上传分片列表 uploadedChunks。
- 转成 Set 存储,方便添加新分片并去重。
- 将当前分片 chunkIndex 添加到 Set。
- 将 Set 再拼成逗号分隔字符串写回 Redis,更新上传状态。
- 这样客户端下次请求 /missing 接口就知道哪些分片已经上传,支持断点续传。
附录
- 撸了个多线程断点续传下载器,我从中学习到了这些知识 https://mp.weixin.qq.com/s/bI5xYq3jUtp-sviKlzHtNg
- 大规格文件的上传优化 https://juejin.cn/post/6844904155086061576