大文件上传与断点续传:分片原理与Spring Boot实战

大文件上传与断点续传:分片原理与Spring Boot实战 最近在面试候选人的时候我经常问到一个问题“你们项目里实现过大文件上传吗断点续传是怎么做的”这个问题看起来基础但能把大文件上传从“能用”做到“好用”其实涉及分片、并发、合并、去重、异常恢复等一系列细节。很多候选人能说出“把文件切成几块传上去再合并”这个思路但一旦追问到分片大小怎么定、断点续传怎么判断、合并顺序怎么保证、秒传怎么实现就答不上来了。这篇文章就从面试视角出发把大文件上传与断点续传的完整知识体系和可落地代码整理出来。如果你是刚接触文件上传的开发者可以把它当成一份系统化的入门教程如果你已经做过普通文件上传但没深入过大文件场景本文也会帮你弥补方案设计和异常排查这两块短板。1. 背景与核心概念1.1 为什么大文件上传经常出问题普通文件上传很简单前端一个input typefile后端用MultipartFile接收几行代码就能跑通。但一旦文件体积上升到几百 MB、甚至几个 GB问题就会集中出现HTTP 连接超时一次请求传输时间过长中间网络抖动一下整个文件就要重新传。内存溢出后端通过byte[]一次性读取整个文件时很容易造成 JVM 堆内存溢出OOM。无法续传传输到 90% 失败没有续传机制只能从头再来。带宽浪费同一个文件在多个用户之间重复上传浪费存储空间和带宽。服务端压力大大量用户同时上传大文件服务端的线程、内存、磁盘 IO 都会被瞬间打满。大文件上传的核心思路就是“化整为零再聚零为整”前端把大文件切成多个小分片逐个上传到服务端最后服务端把所有分片按顺序合并成完整文件。这个过程中再配合断点续传、秒传等机制就能解决上面的大部分问题。1.2 分片上传、断点续传、秒传的区别这三个概念经常一起出现但含义不同。分片上传Multipart Upload是基础手段。前端把文件按照固定大小切开比如每个分片 5MB然后一个个传上去。它的价值在于单次请求的时间变短了失败后只需要重传失败的分片服务端也可以并发处理多个分片。断点续传是建立在分片上传之上的。它记录“哪些分片已经传成功”当网络中断、页面刷新、服务重启之后已经上传成功的分片不需要重传只需要从第一个失败的分片继续传。实现断点续传的关键在于服务端要能告诉前端“你已经有这些分片了跳过它们”。秒传则是更高一层的优化。它本质上做了“文件去重”在上传开始前先计算文件内容哈希服务端发现这个哈希对应的文件已经存在就直接返回已有文件地址前端不再真正上传数据。秒传面对的场景是“同一个文件被多人上传”而不是“文件真的能秒传”。用一个表格来总结概念解决什么问题核心依赖分片上传大文件传输超时、内存溢出前端切片、服务端合并断点续传传输失败后重新上传已上传分片记录秒传相同文件重复上传文件内容哈希去重1.3 常见应用场景大文件上传和断点续传在业务系统里非常常见视频类网站的视频上传、转码素材上传。网盘类的文档、压缩包、安装包上传。企业内部系统的大数据文件导入、日志上传。在线教育平台的课件、录播视频上传。对象存储服务的前端直传场景。这些场景往往对成功率、传输效率、用户体验都有较高要求所以需要一套比普通上传更完整的方案。2. 整体方案设计2.1 典型技术栈与流程一个可落地的大文件上传方案通常由三部分组成前端负责文件分片、并发控制、进度展示、失败重试。后端负责接收分片、记录上传状态、合并分片、校验文件。存储层保存分片临时文件、最终文件、上传元数据。本地磁盘、分布式文件系统、对象存储都可以。整体流程如下前端选择文件后计算文件 MD5/SHA 值并按照固定大小切成多个分片。前端调用后端初始化接口携带文件名、文件大小、总文件 MD5、总分片数。后端生成uploadId返回“该文件是否已经存在秒传判断结果”以及“已上传分片编号列表”。如果秒传命中前端直接结束。如果秒传未命中前端遍历分片跳过已经上传过的分片并发上传剩余分片。后端接收每个分片校验分片编号、分片大小、分片 MD5把分片落盘并记录“该分片已上传成功”。所有分片上传完成后前端调用合并接口。后端校验分片是否齐全按分片编号顺序合并生成最终文件。后端清理临时分片和上传元数据返回最终的文件访问地址。这里的关键设计是服务端必须能够回答“哪些分片已经传过了”这个问题这也是断点续传与普通分片上传的本质区别。2.2 需要维护哪些元数据要让整个流程可恢复、可校验服务端需要记录一份上传会话信息。通常包含以下字段字段含义uploadId上传会话唯一标识fileName原始文件名fileSize文件总大小fileMd5整个文件的 MD5用于秒传判断totalChunks总分片数chunkSize分片大小uploadedChunks已上传成功分片编号集合status上传状态例如 INIT / UPLOADING / MERGED / FAILEDcreateTime / updateTime创建时间、更新时间在中小项目或单机部署场景下这个信息可以放在内存Map里也可以存入数据库。在生产环境中更推荐存入 Redis因为 Redis 天然支持过期时间可以自动清理长时间未完成的上传会话而且多实例部署时状态是共享的。3. 核心原理拆解3.1 分片大小怎么定分片大小没有绝对标准需要结合网络环境、服务器配置和业务类型来选。如果分片太小比如 1MB分片数量会非常多HTTP 请求数过多反而增加网络开销和服务端压力。如果分片太大比如 100MB等于把大文件上传问题又绕回来了单次请求时间过长失败重试代价大。业界常用的范围是2MB ~ 20MB。局域网内部系统可以用 10MB 或 20MB公网环境下5MB是比较稳妥的选择。分片数量可以这样计算totalChunks Math.ceil(file.size / chunkSize)比如一个 1GB 的文件使用 5MB 分片1024MB / 5MB ≈ 205也就是约 205 个分片这个数量级对服务器来说是完全可以接受的。3.2 断点续传的实现方式断点续传有两种典型的实现思路方案一服务端记录已上传分片编号前端每次开始上传前先调用查询接口服务端返回已经上传成功的分片编号集合。前端筛选出未上传的分片只上传这些分片。这种方案的优点是逻辑清晰、实现简单适合前后端都是自己控制的场景。缺点是需要自己维护分片状态。方案二基于对象存储的 Multipart Upload如果使用 MinIO、阿里云 OSS、AWS S3 这类对象存储它们本身提供了 Multipart Upload 接口。服务端先调用 InitiateMultipartUpload 获取 uploadId然后逐片调用 UploadPart 上传最后调用 CompleteMultipartUpload 合并。如果中途失败可以调用 ListParts 查询已上传的分片。这种方案把分片存储、合并、容灾都下沉到对象存储适合云原生场景。但需要理解对象存储的 API并且上传凭证、签名逻辑要处理好。两种方案并不冲突。实际项目中也经常看到“前端分片 后端聚合 对象存储保存最终文件”的混合模式。3.3 秒传的哈希策略秒传的核心是内容去重最常用的手段是 MD5。但这里有一个坑如果文件非常大对完整文件计算 MD5 会非常耗时前端可能要卡顿几十秒甚至几分钟。所以生产环境常用折中方案先取文件大小、修改时间做一层快速筛选。再对文件的开头、中间、结尾各取一段字节合并计算 MD5。如果两个文件这些特征完全一致再决定是否执行全量 MD5。这种方案虽然存在理论上的碰撞概率但在绝大多数业务场景下已经够用。如果对准确性要求极高可以使用 SHA-256或同时结合文件大小做二次校验。我这里为了演示方便前端示例中还是使用 SparkMD5 对完整文件计算 MD5。实际项目如果担心性能可以参考上面的优化思路。3.4 服务端合并分片合并分片是大文件上传中最容易出现问题的环节核心是保证顺序和完整性。合并必须按照chunkIndex升序进行不能依赖文件名排序因为分片文件名可能是随机生成的。合并过程中要校验分片数量是否等于totalChunks每个分片大小是否符合预期。合并完成后要清理临时分片文件避免磁盘被占满。合并时建议使用流式读写不要把所有分片一次性读入内存。如果分片数量很多可以分批合并比如每次合并 100 个分片防止单次 IO 抖动。3.5 并发控制分片上传的一大好处是可以并发上传多个分片提升传输速度。但并发不能无上限否则服务端线程、带宽、磁盘 IO 会被直接打满。前端并发建议控制在3 ~ 6个后端在网关层或服务层也建议做总并发限制。分段并发上传时需要前端自己实现一个简单的“任务队列”而不是Promise.all一次性把所有分片请求发出去。4. 实战Spring Boot 实现大文件分片上传与断点续传4.1 环境准备本文的示例以后端 Spring Boot 前端原生 HTML/JavaScript 为主重点演示完整流程。需要准备的环境如下JDK 8 或以上版本。Maven 3.6 或以上版本。Spring Boot 2.x 或 3.x本示例以常见版本为基础实际请按项目情况调整。一个前端静态目录Spring Boot 直接放在src/main/resources/static下即可。由于分片上传的核心逻辑和 Spring Boot 版本关系不大核心代码在 2.x 和 3.x 上都可以运行。4.2 项目结构upload-demo ├── pom.xml └── src/main ├── java/com/example/upload │ ├── UploadDemoApplication.java │ ├── controller/UploadController.java │ ├── dto/UploadInitDTO.java │ ├── dto/UploadChunkDTO.java │ ├── dto/UploadMergeDTO.java │ ├── service/UploadService.java │ └── vo/UploadInitVO.java └── resources └── static ├── index.html └── index.js4.3 添加依赖pom.xml只需要 Spring Boot Web 依赖和文件上传相关依赖Spring Boot 默认已经引入了文件上传能力。parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency /dependencies重点说明示例中没有引入额外工具包合并分片全部使用 JDK 原生 IO 实现依赖少、容易理解。4.4 后端接口设计后端一共提供 4 个接口接口方法作用/upload/initPOST初始化上传会话返回 uploadId 和已上传分片列表/upload/chunkPOST上传单个分片/upload/check/{uploadId}GET查询已上传分片编号/upload/mergePOST合并分片我们先定义 DTO。// 文件路径src/main/java/com/example/upload/dto/UploadInitDTO.java public class UploadInitDTO { private String fileName; private Long fileSize; private String fileMd5; private Integer totalChunks; private Integer chunkSize; // 省略 getter/setter }// 文件路径src/main/java/com/example/upload/dto/UploadChunkDTO.java public class UploadChunkDTO { private String uploadId; private Integer chunkIndex; private Integer totalChunks; private String chunkMd5; // 省略 getter/setter }// 文件路径src/main/java/com/example/upload/dto/UploadMergeDTO.java public class UploadMergeDTO { private String uploadId; // 省略 getter/setter }然后定义上传会话实体。为了方便演示这里使用ConcurrentHashMap保存上传元数据。生产环境建议替换为 Redis。// 文件路径src/main/java/com/example/upload/service/UploadSession.java public class UploadSession { private String uploadId; private String fileName; private Long fileSize; private String fileMd5; private Integer totalChunks; private Integer chunkSize; private SetInteger uploadedChunks ConcurrentHashMap.newKeySet(); private String finalFilePath; }4.5 核心 Service 实现下面逐步实现UploadService。// 文件路径src/main/java/com/example/upload/service/UploadService.java Service public class UploadService { private static final String UPLOAD_DIR System.getProperty(java.io.tmpdir) /upload-demo/; private static final String MERGE_DIR System.getProperty(java.io.tmpdir) /upload-demo/merge/; private final MapString, UploadSession sessionMap new ConcurrentHashMap(); public UploadSession initUpload(UploadInitDTO dto) { // 先做秒传判断如果文件已存在直接返回完整的 session并标记为秒传命中 String existFile findExistByMd5(dto.getFileMd5()); UploadSession session new UploadSession(); session.setUploadId(UUID.randomUUID().toString().replace(-, )); session.setFileName(dto.getFileName()); session.setFileSize(dto.getFileSize()); session.setFileMd5(dto.getFileMd5()); session.setTotalChunks(dto.getTotalChunks()); session.setChunkSize(dto.getChunkSize()); if (existFile ! null) { session.setFinalFilePath(existFile); // 给一个特殊标记表示秒传命中 session.setSkipUpload(true); } sessionMap.put(session.getUploadId(), session); return session; } public void uploadChunk(UploadChunkDTO dto, MultipartFile file) throws IOException { UploadSession session sessionMap.get(dto.getUploadId()); if (session null) { throw new RuntimeException(uploadId 不存在请重新初始化上传); } // 分片编号不能超出总分片数 if (dto.getChunkIndex() 0 || dto.getChunkIndex() session.getTotalChunks()) { throw new RuntimeException(分片编号不合法); } // 如果该分片已经上传直接返回避免重复写盘 if (session.getUploadedChunks().contains(dto.getChunkIndex())) { return; } File chunkDir new File(UPLOAD_DIR dto.getUploadId()); if (!chunkDir.exists()) { chunkDir.mkdirs(); } // 分片文件名使用 uploadId chunkIndex便于合并时按顺序读取 File chunkFile new File(chunkDir, chunk_ dto.getChunkIndex()); try (InputStream in file.getInputStream(); FileOutputStream out new FileOutputStream(chunkFile)) { byte[] buffer new byte[8192]; int len; while ((len in.read(buffer)) ! -1) { out.write(buffer, 0, len); } } session.getUploadedChunks().add(dto.getChunkIndex()); sessionMap.put(dto.getUploadId(), session); } public SetInteger getUploadedChunks(String uploadId) { UploadSession session sessionMap.get(uploadId); if (session null) { throw new RuntimeException(uploadId 不存在); } return session.getUploadedChunks(); } public String mergeUpload(UploadMergeDTO dto) throws IOException { UploadSession session sessionMap.get(dto.getUploadId()); if (session null) { throw new RuntimeException(uploadId 不存在); } // 检查分片是否齐全 if (session.getUploadedChunks().size() ! session.getTotalChunks()) { throw new RuntimeException(分片不完整已上传 session.getUploadedChunks().size() / session.getTotalChunks()); } File mergeDir new File(MERGE_DIR); if (!mergeDir.exists()) { mergeDir.mkdirs(); } String targetFileName session.getFileMd5() _ session.getFileName(); File targetFile new File(mergeDir, targetFileName); // 使用 RandomAccessFile 按顺序写文件 try (RandomAccessFile raf new RandomAccessFile(targetFile, rw)) { for (int i 0; i session.getTotalChunks(); i) { File chunkFile new File(UPLOAD_DIR dto.getUploadId(), chunk_ i); if (!chunkFile.exists()) { throw new RuntimeException(第 i 个分片不存在); } byte[] bytes Files.readAllBytes(chunkFile.toPath()); raf.seek(raf.length()); raf.write(bytes); } } // 合并成功后清理临时分片 File chunkDir new File(UPLOAD_DIR dto.getUploadId()); if (chunkDir.exists()) { for (File f : chunkDir.listFiles()) { f.delete(); } chunkDir.delete(); } session.setFinalFilePath(targetFile.getAbsolutePath()); sessionMap.put(dto.getUploadId(), session); return targetFile.getAbsolutePath(); } private String findExistByMd5(String fileMd5) { // 简化逻辑遍历 sessionMap 中已经合并过的文件 // 生产环境改为查询数据库或对象存储 return null; } }这里需要解释几个关键点分片文件名使用的是chunk_0、chunk_1这种命名方式合并时直接按编号遍历即可不依赖文件修改时间或随机名。RandomAccessFile写入时先定位到文件末尾再写入保证分片按顺序追加。合并之后清理临时目录避免磁盘空间被不断占用。findExistByMd5在示例中返回null实际项目中可以查询数据库中的文件表判断是否存在同 MD5 的文件。4.6 Controller 实现// 文件路径src/main/java/com/example/upload/controller/UploadController.java RestController RequestMapping(/upload) public class UploadController { private final UploadService uploadService; public UploadController(UploadService uploadService) { this.uploadService uploadService; } PostMapping(/init) public UploadSession init(RequestBody UploadInitDTO dto) { return uploadService.initUpload(dto); } PostMapping(/chunk) public MapString, Object uploadChunk(UploadChunkDTO dto, RequestParam(file) MultipartFile file) throws IOException { uploadService.uploadChunk(dto, file); MapString, Object result new HashMap(); result.put(uploadId, dto.getUploadId()); result.put(chunkIndex, dto.getChunkIndex()); return result; } GetMapping(/check/{uploadId}) public SetInteger check(PathVariable String uploadId) { return uploadService.getUploadedChunks(uploadId); } PostMapping(/merge) public MapString, String merge(RequestBody UploadMergeDTO dto) throws IOException { String path uploadService.mergeUpload(dto); MapString, String result new HashMap(); result.put(filePath, path); return result; } }注意/upload/chunk接口接收的是MultipartFile所以前端上传分片时分片文件要放在表单的file字段中其他参数放在表单字段中。4.7 前端实现前端使用原生 HTML JavaScript Axios 实现。先写一个简单的页面。!-- 文件路径src/main/resources/static/index.html -- !DOCTYPE html html langzh-CN head meta charsetUTF-8 title大文件分片上传示例/title /head body input typefile idfileInput / button iduploadBtn开始上传/button div idprogress等待选择文件.../div script srchttps://cdn.jsdelivr.net/npm/axios/dist/axios.min.js/script script srchttps://cdn.jsdelivr.net/npm/spark-md53.0.2/spark-md5.min.js/script script srcindex.js/script /body /html然后写核心的 JavaScript 逻辑。// 文件路径src/main/resources/static/index.js const CHUNK_SIZE 5 * 1024 * 1024; // 5MB const MAX_CONCURRENT 3; // 同时上传 3 个分片 document.getElementById(uploadBtn).addEventListener(click, async () { const fileInput document.getElementById(fileInput); const file fileInput.files[0]; if (!file) { alert(请先选择文件); return; } // 1. 计算文件 MD5用于秒传判断 const fileMd5 await calcFileMD5(file); console.log(文件 MD5:, fileMd5); // 2. 计算总分片数 const totalChunks Math.ceil(file.size / CHUNK_SIZE); // 3. 初始化上传 const initRes await axios.post(/upload/init, { fileName: file.name, fileSize: file.size, fileMd5: fileMd5, totalChunks: totalChunks, chunkSize: CHUNK_SIZE }); const uploadId initRes.data.uploadId; // 如果服务端判断命中了秒传直接结束 if (initRes.data.skipUpload) { document.getElementById(progress).textContent 秒传成功文件已存在; return; } // 4. 查询已上传的分片编号这里用 set 提升查找效率 const checkRes await axios.get(/upload/check/ uploadId); const uploadedChunks new Set(checkRes.data); // 5. 构造待上传分片队列 const chunkTasks []; for (let i 0; i totalChunks; i) { if (uploadedChunks.has(i)) { continue; } const start i * CHUNK_SIZE; const end Math.min(start CHUNK_SIZE, file.size); const chunk file.slice(start, end); chunkTasks.push({ index: i, chunk: chunk }); } if (chunkTasks.length 0) { // 所有分片都已上传过只需要合并 await mergeFile(uploadId); return; } // 6. 并发控制上传 await uploadChunksInConcurrency(chunkTasks, uploadId, totalChunks); // 7. 合并分片 await mergeFile(uploadId); }); function uploadChunksInConcurrency(tasks, uploadId, totalChunks) { return new Promise((resolve, reject) { let index 0; let active 0; let completed 0; function next() { if (index tasks.length active 0) { resolve(); return; } while (active MAX_CONCURRENT index tasks.length) { const task tasks[index]; index; active; uploadOneChunk(task, uploadId) .then(() { active--; completed; const percent Math.floor(completed / totalChunks * 100); document.getElementById(progress).textContent 上传进度 percent %; next(); }) .catch((err) { active--; console.error(上传失败, err); reject(err); }); } } next(); }); } function uploadOneChunk(task, uploadId) { const formData new FormData(); formData.append(file, task.chunk); formData.append(uploadId, uploadId); formData.append(chunkIndex, task.index); formData.append(totalChunks, task.totalChunks); return axios.post(/upload/chunk, formData); } function mergeFile(uploadId) { return axios.post(/upload/merge, { uploadId }).then((res) { document.getElementById(progress).textContent 合并完成文件路径 res.data.filePath; }); } function calcFileMD5(file) { return new Promise((resolve, reject) { const reader new FileReader(); const spark new SparkMD5.ArrayBuffer(); let currentChunk 0; const chunkSize 2 * 1024 * 1024; // 读取 MD5 时也用分片避免大文件卡死浏览器 function readNext() { const start currentChunk * chunkSize; const end Math.min(start chunkSize, file.size); reader.readAsArrayBuffer(file.slice(start, end)); } reader.onload (e) { spark.append(e.target.result); currentChunk; if (currentChunk Math.ceil(file.size / chunkSize)) { readNext(); } else { resolve(spark.end()); } }; reader.onerror (err) { reject(err); }; readNext(); }); }前端代码中calcFileMD5使用分片读取的方式计算 MD5而不是一次性readAsArrayBuffer整个文件这样可以避免大文件导致浏览器内存暴涨。uploadChunksInConcurrency函数实现了一个简单的并发队列保证同一时间最多只有 3 个分片在传输。这种写法比Promise.all一次性发出所有请求更安全。4.8 运行与验证启动 Spring Boot 应用后浏览器访问http://localhost:8080/index.html选择一个比较大的文件比如几百 MB点击“开始上传”观察控制台输出。预期效果前端按 5MB 大小切分文件。后端临时目录下出现uploadId/chunk_0、chunk_1等分片文件。控制台显示上传进度。所有分片上传结束后后端合并文件并清理临时目录。如果想验证断点续传可以在上传过程中手动关闭后端服务然后重新启动再次选择同一个文件上传。前端会通过/upload/check接口拿到已上传分片跳过它们只补传失败的分片。5. 常见问题与排查思路5.1 上传大文件时 OOM问题现象上传过程中服务端抛出OutOfMemoryError: Java heap space。可能原因后端使用byte[]直接接收整个文件文件过大导致堆内存溢出。前端使用readAsArrayBuffer读取整个文件浏览器标签页内存飙升。并发分片太多每个请求都缓存了较大的分片数据。解决思路后端接收分片时用MultipartFile.getInputStream()流式读取不要转成byte[]。前端计算 MD5 时也要分片读取。控制并发数量避免同时多个大请求占用内存。5.2 合并后文件损坏问题现象分片上传全部成功但合并后的文件无法打开或文件大小不一致。可能原因合并时未按照chunkIndex排序分片顺序错乱。某个分片上传失败但被记录为成功。分片文件被多个请求同时写入。解决思路合并时严格按下标顺序遍历分片文件。服务端接收分片时校验分片大小如果分片大小与预期不符则不标记为成功。分片文件采用uploadId chunkIndex唯一命名防止并发覆盖。5.3 断点续传没生效问题现象页面刷新或网络断开后重新上传所有分片又重新传了一遍。可能原因上传元数据保存在服务端内存中服务重启后丢失。前端查询已上传分片接口的返回值没有正确使用。上传会话过期被清理。解决思路生产环境将上传元数据存入 Redis并设置合理的过期时间。前端在上传前调用/upload/check用返回的分片集合做跳过。设置合适的过期时间比如 2 小时保证大文件有足够时间完成。5.4 多实例部署时状态不同步问题现象应用部署了多个实例前端上传分片被负载均衡分发到不同实例导致合并时提示分片不完整。可能原因每个实例都有独立的本地内存和临时目录分片文件分散在不同节点上。上传元数据没有共享。解决思路使用 Redis 保存上传元数据保证多个实例读到的状态一致。分片临时文件放到共享存储NFS、MinIO、OSS。更推荐使用对象存储的 Multipart Upload 能力服务端只负责生成凭证和编排流程。5.5 MinIO 支持断点续传吗MinIO 本身就是对象存储它支持 S3 兼容的 Multipart Upload所以可以实现断点续传。核心流程和手写方案类似调用initiateMultipartUpload获取上传 ID。分片上传时调用uploadPartMinIO 会返回每个分片的 ETag。如果中断调用listParts查询已上传分片。最后调用completeMultipartUpload合并分片。但需要注意前端直接对接 MinIO 时不能暴露 AccessKey。更安全的做法是后端生成 STS 临时凭证或预签名 URL前端在凭证有效期内上传。5.6 分片上传会 OOM 吗分片上传本身不会导致 OOM但使用方式不对会。前端如果一次性把文件读入内存再分片会 OOM。后端如果每次把分片读入byte[]分片大小合理时通常没问题因为单个分片只有几 MB。后端如果合并时把所有分片一次性读入内存分片数量大时也会 OOM。正确做法是分片时用file.slice()读取服务端用流式写入合并时逐片读取并写入最终文件不要一次性加载所有分片。6. 最佳实践与工程建议6.1 分片大小与并发参数参数没有“银弹”要根据场景做实验。这里给一组常用参考值参数推荐值说明分片大小5MB ~ 10MB公网推荐 5MB内网可适当调大前端并发数3 ~ 6并发太高会增加服务器压力Redis 过期时间2 小时根据文件大小和网络速度调整分片 MD5 校验建议开启保证分片内容完整防止网络传输损坏6.2 安全校验大文件上传接口往往是攻击者关注的目标必须做好安全边界登录态校验接口必须校验用户登录状态不能匿名上传。文件类型校验通过文件扩展名和 MIME 类型双重校验并且不要信任前端传的唯一标识。文件大小限制对分片大小、总分片数和总文件大小做限制防止恶意上传超大文件拖垮磁盘。权限校验只有有权限的用户才能调用初始化、合并接口。上传目录不能放在可执行目录下防止上传木马后获得执行权限。6.3 存储选型单机和中小项目本地磁盘存储分片和最终文件代码简单部署方便。多实例项目需要共享存储或 Redis 保存状态否则断点续传会失效。云原生项目直接使用对象存储的 Multipart Upload可靠性和扩展性最好。如果使用 MinIO建议后端封装统一的文件服务接口而不是让业务代码直接操作桶。6.4 日志与监控文件上传是重 IO 操作必须记录足够的数据用于排查记录每次上传的 uploadId、文件名、文件大小、分片数。记录每个分片的上传耗时、重试次数。记录合并的起止时间、最终文件路径。监控临时分片目录的占用空间定时清理过期分片。6.5 前端体验优化大文件计算 MD5 会阻塞主线程建议使用 Web Worker 在后台线程计算。上传进度条要综合“当前已上传分片”和“总文件大小”来计算而不是简单按照最后一次请求的进度。失败时提供手动重试和自动重试按钮自动重试次数建议不超过 3 次。页面刷新后要能恢复上传进度这是断点续存在用户层的直观体现。6.6 面试官更看重什么面试中问到大文件上传面试官真正想考察的是是否理解分片上传的核心思路。是否考虑过失败恢复也就是断点续传。是否考虑过存储、内存、并发的边界情况。是否知道对象存储的 Multipart Upload 用法。是否能结合业务场景选择合适的方案。回答时可以先用一句话总结方案再展开讲分片逻辑、断点续传实现、秒传判断、合并流程最后补充 OOM、顺序错乱、多实例等问题。这样能体现出系统思维。7. 总结与学习路线这篇文章围绕“大文件上传与断点续传”展开完整介绍了分片上传、断点续传、秒传三者的关系拆解了分片大小、已上传分片记录、服务端合并、并发控制等核心原理并给出了一个基于 Spring Boot 和原生前端的完整实战示例。如果你准备面试建议按以下顺序建立知识体系先能用代码实现一个最简单的分片上传。再在此基础上加入已上传分片查询实现断点续传。接着加入 MD5 秒传判断。然后尝试把上传状态从内存迁移到 Redis。最后学习 MinIO 或 OSS 的 Multipart Upload理解生产级方案。学完这些之后你还可以继续探索分片上传的进度恢复、后端合并的并行优化、对象存储的预签名 URL、前端 Web Worker 计算大文件哈希等进阶话题。每一步都有很多细节值得深挖但掌握了基础链路之后再看这些高级方案会轻松很多。如果文章对你有帮助建议收藏备用动手敲一遍代码比只看不练效果好得多。