为什么需要分片上传 在做文件上传功能时,最初我采用的是最简单的方案:前端通过一个 HTTP 请求,将完整文件直接上传到服务端。
测试时,我在上传一个3MB的照片时就报错了。按理说,这个才3MB,已经很小了,还是上传失败了。
后来我找到了错误原因,竟是multipart 解析器限制了上传文件的大小。
具体配置在application.yaml中的:
1 2 3 4 5 spring: servlet: multipart: max-file-size: 1MB max-request-size: 10MB
我以为只是配置问题,但很快意识到: 即使放开限制,这种“整文件上传”的方案本身就是不可靠的
问题在于:
极高的失败率
在上传较大文件(如视频、安装包)时,经常出现上传中断 的情况,例如网络波动或请求超时。一旦失败,用户只能重新上传整个文件。
接口不稳定
大文件上传请求持续时间长,占用服务器连接资源,一旦并发上来,容易导致接口响应变慢甚至超时。
用户体验很差
当文件上传到80%或90%时失败,用户需要从头开始上传,体验非常糟糕。
基于这些问题,我开始思考:
是否可以将一个大文件拆分为多个小块分别上传,从而降低单次请求的风险?
这就是分片上传(Chunk Upload)的核心思想:“分而治之” 。在这个基础上,我们还可以实现断点续传、并发上传等功能。
流程 既然是“分而治之”,那么实现这个功能就需要我们的前后端配合工作。
客户端流程: 文件切片 计算每片文件的hash(用于秒传/去重) 查询服务器已上传分片 并发上传缺失分片 通知服务器合并 服务端流程: 接收分片并保存 记录分片上传状态(可选Redis / DB) 校验分片完整性 合并分片成完整文件 标记上传完成,供客户端确认 流程图 graph TD
A[开始上传文件] --> B[前端计算文件唯一标识 MD5]
B --> C{请求后端: 秒传检查}
C -- 文件已存在 --> D[显示: 秒传成功]
C -- 存在部分分片 --> E[获取已上传分片列表]
C -- 完全未上传 --> F[分片列表为空]
E --> G[文件分片 Chunking]
F --> G
G --> H[循环上传未完成的分片]
H --> I{所有分片上传完成?}
I -- 否: 发生断网/中断 --> J[进度保存在后端/数据库]
J --> K[用户下次点击上传]
K --> B
I -- 是 --> L[前端发起合并请求]
L --> M[后端校验分片完整性]
M --> N[调用存储接口合并文件]
N --> O[清理分片临时记录]
O --> P[上传结束: 成功] 前端分片 客户端计算整个文件的MD5:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 const calculateFileMD5 = (file ) => { return new Promise ((resolve ) => { const chunkSize = 2 * 1024 * 1024 ; const chunks = Math .ceil (file.size / chunkSize); const spark = new SparkMD5 .ArrayBuffer (); let currentChunk = 0 ; const reader = new FileReader (); reader.onload = (e ) => { spark.append (e.target .result ); currentChunk++; if (currentChunk < chunks) { loadNext (); } else { resolve (spark.end ()); } }; const loadNext = ( ) => { const start = currentChunk * chunkSize; const end = Math .min (file.size , start + chunkSize); reader.readAsArrayBuffer (file.slice (start, end)); }; loadNext (); }); };
客户端计算每个分片的MD5:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 const calculateChunkMD5 = async (chunk ) => { return new Promise ((resolve ) => { const reader = new FileReader (); const spark = new SparkMD5 .ArrayBuffer (); reader.onload = (e ) => { spark.append (e.target .result ); resolve (spark.end ()); }; reader.readAsArrayBuffer (chunk); }); };
客户端分片并上传:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 const submitVideo = async ( ) => { const file = selectedVideoFile.value ; if (!file) return ; const CHUNK_SIZE = 2 * 1024 * 1024 ; const totalChunks = Math .ceil (file.size / CHUNK_SIZE ); const fileMD5 = await calculateFileMD5 (file); const uploadId = fileMD5; const checkRes = await request.get ('/file/check' , { params : {fileMD5} }); if (checkRes.data .status === 'complete' ) { alert ('秒传成功' ); return ; } const uploadedSet = new Set (checkRes.data .uploadedChunks || []); for (let i = 0 ; i < totalChunks; i++) { if (uploadedSet.has (i)) continue ; const start = i * CHUNK_SIZE ; const end = Math .min (file.size , start + CHUNK_SIZE ); const chunkFile = file.slice (start, end); const chunkMD5 = await calculateChunkMD5 (chunkFile); await uploadVideoChunk ({ uploadId, chunkIndex : i, totalChunks, fileName : file.name , chunkFile, chunkMD5, fileMD5 }); } const videoUrl = await completeVideoUpload ({ uploadId, totalChunks, fileName : file.name , contentType : file.type , fileMD5 }); console .log ('上传成功:' , videoUrl); };
客户端上传分片函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 export const uploadVideoChunk = async ({ uploadId, chunkIndex, totalChunks, fileName, chunkFile, chunkMD5, fileMD5 } ) => { const formData = new FormData (); formData.append ('uploadId' , uploadId); formData.append ('chunkIndex' , chunkIndex); formData.append ('totalChunks' , totalChunks); formData.append ('fileName' , fileName); formData.append ('file' , chunkFile); formData.append ('chunkMD5' , chunkMD5); formData.append ('fileMD5' , fileMD5); return request.post ('/file/video/chunk' , formData); };
客户端通知合并分片:
1 2 3 4 5 6 7 8 9 10 11 export const completeVideoUpload = async ({ uploadId, totalChunks, fileName, contentType, fileMD5 } ) => { return request.post ('/file/video/complete' , null , { params : {uploadId, totalChunks, fileName, contentType, fileMD5} }); };
讲完了前端的分片,我们接下来讲后端是如何处理分片的。
后端处理 我们先来看看后端是如何上传分片至MinIO的:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 private static final String CHUNK_KEY = "upload:chunks:" ;private static final String META_KEY = "upload:meta:" ;@Override public void uploadVideoChunk (String uploadId, Integer chunkIndex, Integer totalChunks, String fileName, String chunkMD5, String fileMD5, MultipartFile file) { try { String serverMD5 = DigestUtils.md5DigestAsHex(file.getInputStream()); if (!serverMD5.equals(chunkMD5)) { throw new RuntimeException ("分片校验失败" ); } Path uploadDir = VIDEO_TEMP_DIR.resolve(uploadId); Files.createDirectories(uploadDir); Path chunkPath = uploadDir.resolve(chunkIndex + ".part" ); if (!Files.exists(chunkPath)) { Files.copy(file.getInputStream(), chunkPath); } String chunkKey = CHUNK_KEY + fileMD5; redisTemplate.opsForValue().setBit(chunkKey, chunkIndex, true ); String metaKey = META_KEY + fileMD5; if (!Boolean.TRUE.equals(redisTemplate.hasKey(metaKey))) { Map<String, String> meta = new HashMap <>(); meta.put("fileName" , fileName); meta.put("totalChunks" , totalChunks.toString()); meta.put("uploadId" , uploadId); redisTemplate.opsForHash().putAll(metaKey, meta); redisTemplate.expire(metaKey, Duration.ofHours(24 )); redisTemplate.expire(chunkKey, Duration.ofHours(24 )); } } catch (Exception e) { throw new RuntimeException ("上传失败" , e); } }
后端秒传检查接口:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 @Override public UploadCheckResult checkFile (String fileMD5, Integer totalChunks) { String completedKey = "upload:completed:" + fileMD5; String url = redisTemplate.opsForValue().get(completedKey); if (url != null ) { return new UploadCheckResult ("complete" , url, null ); } String chunkKey = "upload:chunks:" + fileMD5; if (Boolean.TRUE.equals(redisTemplate.hasKey(chunkKey))) { List<Integer> uploaded = getUploadedChunks(fileMD5, totalChunks); return new UploadCheckResult ("partial" , null , uploaded); } return new UploadCheckResult ("none" , null , null ); }
后端返回已上传的分片:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 @Override public List<Integer> getUploadedChunks (String fileMD5, Integer totalChunks) { String chunkKey = "upload:chunks:" + fileMD5; List<Integer> uploaded = new ArrayList <>(); for (int i = 0 ; i < totalChunks; i++) { Boolean exists = redisTemplate.opsForValue().getBit(chunkKey, i); if (Boolean.TRUE.equals(exists)) { uploaded.add(i); } } return uploaded; }
后端检查是否全部上传完成:
1 2 3 4 5 6 7 8 9 10 11 private boolean isAllUploaded (String fileMD5, int totalChunks) { String chunkKey = "upload:chunks:" + fileMD5; for (int i = 0 ; i < totalChunks; i++) { if (!Boolean.TRUE.equals(redisTemplate.opsForValue().getBit(chunkKey, i))) { return false ; } } return true ; }
后端合并分片:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 @Override public String completeVideoUpload ( String uploadId, Integer totalChunks, String fileName, String contentType, String fileMD5) { Path uploadDir = VIDEO_TEMP_DIR.resolve(uploadId); if (!Files.exists(uploadDir)) { throw new RuntimeException ("未找到上传任务" ); } if (totalChunks == null || totalChunks <= 0 ) { throw new RuntimeException ("分片总数非法" ); } String safeName = fileName == null ? "" : fileName; String suffix = safeName.contains("." ) ? safeName.substring(safeName.lastIndexOf("." )) : ".mp4" ; if (!Arrays.asList(".mp4" , ".mov" , ".webm" , ".mkv" ).contains(suffix.toLowerCase())) { throw new RuntimeException ("不支持的视频格式" ); } if (!isAllUploaded(fileMD5, totalChunks)) { throw new RuntimeException ("分片未全部上传完成" ); } Path mergedFile = uploadDir.resolve("merged" + suffix); try (RandomAccessFile raf = new RandomAccessFile (mergedFile.toFile(), "rw" )) { for (int i = 0 ; i < totalChunks; i++) { Path chunkPath = uploadDir.resolve(i + ".part" ); try (InputStream in = Files.newInputStream(chunkPath)) { byte [] buffer = new byte [8192 ]; int len; while ((len = in.read(buffer)) != -1 ) { raf.write(buffer, 0 , len); } } } } catch (IOException e) { throw new RuntimeException ("分片合并失败" , e); } String mergedMD5; try (InputStream in = new FileInputStream (mergedFile.toFile())) { mergedMD5 = DigestUtils.md5DigestAsHex(in); } if (!mergedMD5.equals(fileMD5)) { throw new RuntimeException ("文件损坏" ); } String objectName = UUID.randomUUID() + suffix; String videoType = (contentType == null || contentType.isBlank()) ? "video/mp4" : contentType; try (InputStream in = new FileInputStream (mergedFile.toFile())) { minioClient.putObject( PutObjectArgs.builder() .bucket("video" ) .object(objectName) .stream(in, Files.size(mergedFile), -1 ) .contentType(videoType) .build() ); String videoUrl = endpoint + "/video/" + objectName; redisTemplate.opsForValue().set("upload:completed:" + fileMD5, videoUrl); redisTemplate.delete("upload:chunks:" + fileMD5); redisTemplate.delete("upload:meta:" + fileMD5); log.info("video路径:{}" , videoUrl); return videoUrl; } catch (Exception e) { throw new RuntimeException ("视频上传到对象存储失败" , e); } finally { try (Stream<Path> walk = Files.walk(uploadDir)) { walk.sorted(Comparator.reverseOrder()).forEach(path -> { try { Files.deleteIfExists(path); } catch (IOException ignored) { } }); } catch (IOException ignored) { } } }
通过前后端的共同努力,我们成功实现了分片上传和断点续传。
总结 实现分片上传的过程,其实就是**“分而治之”**思想在工程实践中的完美体现。面对单次请求无法承载的巨量数据,我们通过拆解任务、状态追踪和最终合并,化整为零地解决了难题。
从最初简单的 multipart 解析器报错,到最后手写出一套支持秒传与断点续传的系统,这种解决问题的路径也是每一个后端开发者的成长必经之路。代码虽多,但只要掌握了核心流程图,剩下的不过是根据业务需求进行填补。