简介:面向需要处理大文件上传的Spring Boot开发者,资源包系统讲解断点续传与分片上传两大关键技术,内容覆盖上传配置限制、Controller接口设计、分片接收与合并、断点位置续传、状态管理与错误处理等完整链路。包内共114个文件,以51个Java源码、45个class编译文件为主,另有11个XML配置、2个SQL脚本、2个YAML配置等辅助内容,整体压缩后仅112KB,小巧且便于快速查看完整实现。目前已有1717人学习下载。资料在MultipartFile接口使用、分片存储合并、已上传进度定位等方面提供了可直接参考的代码实现,同时兼顾异常恢复、文件类型校验等安全与可靠性设计,适合直接对照改造或集成到现有Spring Boot项目中,无论是断点续传的位置计算还是分片上传的合并策略,都能找到清晰示例,帮助开发者快速落地大文件上传功能。
1. 断点续传不是重传一遍,而是让服务器记住你传到了哪
用户上传 1.2GB 视频,进度跑到 70%,路由器闪断一下,重新上传只能从头再来。大文件上传失败率高到离谱,根因就是普通 HTTP 请求没有“断点”概念,连接断开后服务端拿不到完整文件,前端也无法确定哪些数据已经到了。分片上传要解决的就是这件事:把文件切成固定大小的块,逐块上传,服务端按块记录状态,失败后只补传没传过的块,全部到位后再合并。这个方案适合网盘、在线教育视频上传、设计稿交付系统等场景,只要你被“上传超时、断线重来、进度条白跑”折磨过,这套 Spring Boot 接口就值得花半天搭出来。搭配 vue-simple-uploader 这类前端组件,是从业者验证最多的一条路,本文按这个组合把协议、代码和坑一次讲清。
2. 拆解分片上传模型:三个接口、两段存储和一个校验规则
2.1 先分片才能续传:固定大小切片为什么能精确定位缺失部分
断点续传的本质很简单:把一个总大小为 N 的文件切成 M 个固定大小的块,第 i 块的起止位置完全由 i 和 chunkSize 决定,和网络状态无关。因此服务端只要知道“哪些块的字节已经落到磁盘”,就能在下次上传时精确跳过已完成的块,而不是记录一个模糊的文件偏移量。这个“已传块列表”是断点续传的核心状态,前端组件和后端都要维护,只是前端维护的是 UI 进度,后端维护的是落盘事实,最终以后端为准。
很多人有个误区,以为断点续传是前端把已上传百分比存到 localStorage,下次上传时跳过前 N 个字节。这个思路对单请求大文件并不成立:连接一旦断开,服务端可能只收到了不完整的数据,你记录的进度是假的。分片方案的可靠性来自一个简单事实——每个分片都是一个独立完整的 HTTP 请求,成功即成功、失败即失败,状态可校验、可恢复。这就把“一个容易失败的大请求”拆成了“多个容易重试的小请求”,网络波动的影响面被控制在单个分片内。
2.2 三个核心接口的协议设计:check、upload 与 merge 各管一段
常见做法是设计三个后端接口加一个探测接口,职责拆开,避免一个大接口既判断又写盘又合并,单次请求耗时过长导致超时。表格里是这套协议的标准形态,前后端按这个约定联调,基本不会出现语义分歧。
| 接口 | 方法 | 路径 | 核心参数 | 返回 |
|---|---|---|---|---|
| 分片探测 | GET | /api/upload/chunk | identifier, chunkIndex | 200 已存在 / 404 不存在 |
| 上传检查 | POST | /api/upload/check | fileMd5, fileName, totalSize | uploadedChunks 列表 + complete |
| 分片上传 | POST | /api/upload/chunk | file, fileMd5, chunkIndex | uploaded 状态 + 已存大小 |
| 合并完成 | POST | /api/upload/merge | fileMd5, fileName, totalSize, totalChunks | 最终文件路径 + 大小 |
check 接口要做两件事:如果整文件已经合并过,直接返回 complete 标记,前端弹个“秒传”提示;如果没有,就扫描临时目录,把已存在分片的索引号列出来返回。upload 接口接收单个分片并写入临时目录,写入前先做幂等判断——分片已存在就直接返回,不重复写。merge 接口把所有分片按索引顺序拼成最终文件,拼完立刻用 totalSize 校验长度,不一致就报错并清理半成品。这四件事分开做,每一步失败都能单独重试,不会牵连其他步骤。
2.3 临时存储与最终存储分层:.part 文件为什么不能直接放目标目录
实现时要规划两个目录:临时目录存分片,文件名用 fileMd5 加下划线加 chunkIndex 加 .part 后缀;最终目录存合并后的完整文件,文件名同样以 fileMd5 开头。分片是中间产物,可能残留、可能被重复上传、可能永远等不来最后的 merge,所以要跟最终文件隔离,否则一旦合并逻辑有 bug,会把半成品当正式文件发给用户。目录结构大约是这样:/data/upload/tmp/ 下是 {fileMd5}_0.part、{fileMd5}_1.part,/data/upload/files/ 下是 {fileMd5}_report.zip。两个目录建议配置化,别写死在代码里。
合并成功后,临时分片应该删除,但这个删除动作不建议放在 merge 接口里同步做。大文件的合并本来就要写几秒,再逐个删分片会拉长请求时间。常见做法是 merge 接口只保证最终文件生成并返回 success,清理交给一个定时任务,扫描超过 24 小时没动的 .part 文件删掉。我一般会顺便把“已完成文件”的元数据写进一张表或一个 JSON,方便后续做秒传判断,而不是每次去扫整个 files 目录,文件一多,目录扫描本身就成瓶颈。
2.4 秒传、续传与重传的边界:什么情况算已上传
状态判断其实只有三种:全部结束、部分存在、完全没传。全部结束时走秒传分支,check 接口发现目标文件已存在且大小一致,直接把 URL 返回;部分存在时走续传分支,前端拿到 uploadedChunks 列表把已传分片跳过;完全没传就是正常全量上传。这里的关键是“大小一致”只能解决 90% 的情况,严谨的做法是后端把文件长度做成索引,合并时对得上,如果前后端都算 md5,还可以把整个文件的 md5 也存下来做二次校验。
需要特别提醒的是,秒传不是靠运气:前端算出的 fileMd5 要可靠,大文件建议用 spark-md5 做分块累加,而不是整个文件读内存。后端判断“目标已存在”时也别只看文件名,文件同名是常事,必须用 fileMd5 作为唯一键,出现同名冲突时可以对目标文件重命名而不是覆盖,否则用户传了两份同名文件,第二份会把第一份覆盖掉,这在网盘场景里是事故级别的 bug。
3. Spring Boot 实现分片上传:从配置到三个接口的完整代码
3.1 先把配置写死:multipart 大小和存储路径是第一个坑
先看 application.yml。Spring Boot 的 multipart 默认限制是 max-file-size 1MB、max-request-size 10MB,不调它,前端分片 2MB 直接会被拒之门外。
spring: servlet: multipart: max-file-size: 5MB max-request-size: 50MB upload: chunk-size: 2097152 temp-dir: /data/upload/tmp target-dir: /data/upload/filesmax-file-size 设 5MB 是因为前端分片按 2MB 切,但 multipart 表单还会带 fileMd5、chunkIndex 这些字段,留一倍冗余避免误伤。max-request-size 对单分片请求来说 5MB 其实已够,设 50MB 是为了防止有人绕过前端直接把整个文件传上来。chunk-size 是后端校验用的标准块大小,如果前端传上来的分片实际大小超过这个数,可以在接口里直接拒绝,防止恶意请求。路径按服务器实际情况改,生产环境建议放到独立数据盘,别跟系统盘放一起,日志占满磁盘时上传接口会先挂。
3.2 check 接口:扫描临时目录,返回已传分片清单
这段代码要做两件事:先判断最终文件是否已存在实现秒传,再遍历临时目录得出 uploadedChunks。
@PostMapping("/check") public ResponseEntity<CheckResult> check(@RequestBody ChunkMeta meta) { String fileMd5 = meta.getFileMd5(); String fileName = meta.getFileName(); long totalSize = meta.getTotalSize(); File finalFile = new File(targetDir, fileMd5 + "_" + fileName); if (finalFile.exists() && finalFile.length() == totalSize) { return ResponseEntity.ok(CheckResult.complete(finalFile.getName())); } int totalChunks = (int) Math.ceil((double) totalSize / chunkSize); List<Integer> uploadedChunks = new ArrayList<>(); for (int i = 0; i < totalChunks; i++) { File part = new File(tempDir, chunkFileName(fileMd5, i)); if (part.exists() && part.length() > 0) { uploadedChunks.add(i); } } return ResponseEntity.ok(new CheckResult(false, uploadedChunks)); }这段代码的逻辑是:先用 fileMd5 和文件名拼出最终文件路径,存在且长度相等就直接返回 complete;否则按 totalSize 除以 chunkSize 向上取整得出总块数,遍历每一块,临时目录里能找到且大于 0 字节就加入已传列表。这里用 part.length() > 0 过滤掉那些只有文件名没有内容的空壳文件,是实战中常见的容错。需要说明的是,check 接口返回的分片列表只能说明这些分片曾经完整落盘,不能说明最终文件一定可用,所以合并后还要有总长度校验兜底。还有一点:check 里已经在用 totalSize 推导 totalChunks,前端没必要再把 totalChunks 作为必传参数,少一个参数少一份对不齐的风险。
3.3 upload 接口:分片写入临时目录,幂等跳过已存在分片
@PostMapping("/chunk") public ResponseEntity<UploadResult> upload(@RequestParam MultipartFile file, @RequestParam String fileMd5, @RequestParam Integer chunkIndex) throws IOException { File tmpDir = new File(tempDir); if (!tmpDir.exists()) tmpDir.mkdirs(); File partFile = new File(tmpDir, chunkFileName(fileMd5, chunkIndex)); if (partFile.exists() && partFile.length() > 0) { return ResponseEntity.ok(UploadResult.skipped(chunkIndex, partFile.length())); } try (InputStream in = file.getInputStream(); FileOutputStream out = new FileOutputStream(partFile)) { byte[] buffer = new byte[8192]; int len; while ((len = in.read(buffer)) != -1) { out.write(buffer, 0, len); } } return ResponseEntity.ok(UploadResult.success(chunkIndex, partFile.length())); }这段代码在写入前先做 exists 判断,是断点续传的幂等关键:前端并发传 3 个分片,如果用户刷新页面后又重试同一批分片,已存在的分片在这里直接短路返回,不会造成同一个 .part 被两个线程同时写。写入用 8192 字节的缓冲区循环拷贝,不用 Files.readAllBytes,避免大分片把内存撑爆。这里有个细节要注意:分片上传接口不需要校验 totalSize,因为单分片请求的 totalSize 是文件总大小,与这个分片本身无关;真正需要严格校验的是合并接口。如果要在 upload 里校验,应该校验分片实际大小不超过 chunkSize 加允许的偏差,防止有人传一个 200MB 的大块进来。
3.4 merge 接口:顺序透传合并,长度不符就回滚
@PostMapping("/merge") public ResponseEntity<MergeResult> merge(@RequestBody MergeRequest req) throws IOException { File finalFile = new File(targetDir, req.getFileMd5() + "_" + req.getFileName()); synchronized (finalFile.getAbsolutePath().intern()) { if (finalFile.exists()) { if (finalFile.length() == req.getTotalSize()) { return ResponseEntity.ok(MergeResult.done(finalFile.getName(), finalFile.length())); } finalFile.delete(); } try (FileOutputStream fos = new FileOutputStream(finalFile)) { byte[] buffer = new byte[8192]; for (int i = 0; i < req.getTotalChunks(); i++) { File part = new File(tempDir, chunkFileName(req.getFileMd5(), i)); if (!part.exists() || part.length() == 0) { throw new IllegalStateException("chunk missing: " + i); } try (FileInputStream fis = new FileInputStream(part)) { int len; while ((len = fis.read(buffer)) != -1) { fos.write(buffer, 0, len); } } } } if (finalFile.length() != req.getTotalSize()) { finalFile.delete(); throw new IllegalStateException("merged file size mismatch"); } deleteChunks(req.getFileMd5(), req.getTotalChunks()); return ResponseEntity.ok(MergeResult.done(finalFile.getName(), finalFile.length())); } }合并的逻辑是从第 0 块开始顺序追加,8192 字节缓冲区流式写,任何一块缺失就抛异常并删除半成品,不留下一个“上传成功但打开就损坏”的文件。synchronized 加在最终文件的绝对路径字符串上,防止同一个文件的两次合并请求并发执行,这在单实例部署下够用;如果以后拆了多实例,要换成 Redis 分布式锁,否则两个节点同时合并同一批分片会互相删文件。合并成功后的 deleteChunks 是顺手清掉临时分片,这个动作放在 merge 里而不是另一个定时任务,是因为它很快;但如果文件特别多、分片数量大,可以改成异步删除,具体取舍看机器压力。注意 finalFile.exists() 分支里的“长度一致直接返回”逻辑,是为了让前端在 merge 超时后重试时不会重复合并,这是断点续传的最后一层保障。
4. 前端用 vue-simple-uploader 对接:参数怎么设才能续传
4.1 vue-simple-uploader 如何校验断点续传:默认的 GET 探测是核心
vue-simple-uploader 在上传每个分片之前,会默认向 target 地址发一个 GET 请求,带上 identifier、chunkIndex 这类参数;后端对这个 GET 返回 200 表示该分片已存在、跳过不再传,返回 404 表示不存在、继续上传。这就是它校验断点续传的默认机制。所以后端代码里 /api/upload/chunk 这个路径要同时支持 GET 和 POST 两个方法,GET 是探测,POST 才是上传,很多第一次接的人只写了 POST,结果前端进度永远从 0 开始。
后端探测接口的实现很简单,和 check 接口的判断逻辑一样,只是粒度从文件级变成分片级。
@GetMapping("/chunk") public ResponseEntity<Void> probeChunk(@RequestParam String identifier, @RequestParam Integer chunkIndex) { File part = new File(tempDir, chunkFileName(identifier, chunkIndex)); if (part.exists() && part.length() > 0) { return ResponseEntity.ok().build(); } return ResponseEntity.notFound().build(); }这个方法只做一件事:临时目录里对应分片存在且非空,返回 200,否则返回 404。前端组件拿到 200 后会把该分片标记为完成,直接跳到下一个;拿到 404 才真正发起二进制上传。参数名 identifier 对应前端组件生成的文件唯一标识,默认是文件内容的 md5 十六进制串;后端用这个值作为分片文件名的前缀,就能保证同一个文件续传时命中的是同一批 .part 文件。注意前后端的分片文件名规则必须一致,格式统一写成 {identifier}_{chunkIndex}.part,不要透传任何由前端拼接好的私有路径,避免目录穿越风险。
4.2 关键参数设置:chunkSize、simultaneousUploads 与 testChunks
前端组件里的 options 大致是这样,每个参数的取值后面会逐一解释。
options: { target: '/api/upload/chunk', chunkSize: 2 * 1024 * 1024, simultaneousUploads: 3, testChunks: true, allowDuplicateUploads: false, fileParameterName: 'file', query: { token: getToken() }, headers: { 'X-Auth-Token': getToken() } }chunkSize 设 2MB 是公网场景的常见值。分片太小,请求数量多,每片握手开销占比大;分片太大,单请求失败后重传的成本高。5MB 也行,但 2MB 在弱网下体验更稳。simultaneousUploads 是并发上传的分片数,3 到 5 比较合理,超过带宽承载能力时并发 10 反而更慢,还会把后端磁盘 IO 打满。testChunks 必须为 true,它是断点续传校验的总开关,关掉后前端不探测、不留任何断点记录。allowDuplicateUploads 设 false 是防止用户重复选择同一文件后前端不识别、强行走一遍上传流程,影响秒传判断。query 和 headers 用来带登录信息,按你们项目实际方式传,注意 query 里的 token 会出现在 URL 上,如果走 GET 探测,会被 Nginx access log 记录下来,敏感环境建议放 header。
4.3 秒传分支与 merge 触发:最后一步交给后端
前端在所有分片都传完后,需要调用后端的 merge 接口把分片合成完整文件。这里要处理一个细节:即使所有分片都返回了 200,也不代表后端已经生成了目标文件,因为 upload 接口只是写临时分片,merge 才是幂等合成的入口。所以前端在文件完成事件里,对每个成功上传的文件都要主动 POST 一次 /api/upload/merge,传 fileMd5、fileName、totalSize、totalChunks 过去。
秒传分支挂在 check 接口之上:用户把文件拖进上传区后,先调一次 POST /api/upload/check,如果返回 complete 为 true,就不进上传队列,直接把后端返回的文件地址展示给用户。这样用户第二次传同一个文件时几乎是瞬间完成的。很多团队把这个逻辑做在 fileAdded 钩子里,但 fileAdded 里发同步请求会阻塞 UI,稳妥的做法是让上传器先进入队列,在首块上传前用 check 的结果决定是续传还是秒传,代码里给一个标记位就行,不必强求同步。
5. 避坑:分片上传的 5 个翻车现场与排查顺序
5.1 分片请求被拒:multipart 和 Nginx 的双层大小限制
现象:前端明明只传 2MB 的分片,后端却报 400,日志里有 “the request was rejected because its size exceeds the configured maximum”;或者请求根本没到后端,浏览器直接收到 413 Request Entity Too Large。
原因:两层限制叠加。Spring Boot 默认 max-file-size 是 1MB,前端分片 2MB 必然超;Nginx 默认 client_max_body_size 是 1m,同样会拦。很多人只改了 application.yml,没改 Nginx,或者反过来,排查时少看了一层。
解决:application.yml 里把 max-file-size 调到分片大小的两倍左右,max-request-size 放宽到 50MB;Nginx 在 server 或 location 块里加 client_max_body_size 20m,按分片大小乘以并发数再留余量,比如 2MB 乘 3 并发就设 10 到 20MB。改完两步都要重启验证,我见过有人改完 Nginx 忘 reload,浏览器端排查了半小时。
5.2 合并时 OOM:一把梭把整个文件读进内存
现象:2GB 文件合并到一半接口超时,后端老年代增长异常,GC 日志频繁出现 GC overhead limit exceeded,最终 OOM。
原因:合并代码图省事用了 Files.readAllBytes(part) 或把 FileUtils.readFileToByteArray 结果往 List 里塞,分片列表一多,内存直接被多个 GB 级的 byte[] 吃掉。分片本身占内存,合并缓冲再占一份,几十个并发一上来就顶不住。
解决:合并代码用固定 8192 字节的 byte[] 循环读写,见 3.4 的写法。再给 merge 接口加一个并发信号量,比如 Semaphore(2),同一时间最多两个合并任务在跑,其他请求排队等待。这样即使机器内存只有 4GB,也不会被几个大文件的合并打爆。信号量的具体值根据单次合并峰值内存估算,一个任务按 128MB 算、JVM 堆开 1GB,并发 2 就比较保守。
5.3 合并后 md5 对不上:分片索引从 0 还是 1 开始必须锁死
现象:合并成功、文件大小和源文件一模一样,但 md5 校验不过,文件打开时中间一段错位,图片中间有块花屏,压缩包提示损坏。
原因:这是顺序性 bug。前端组件如果从 1 开始编号,后端从 0 开始落盘,合并时按 0 到 n-1 遍历,实际上是把第二个分片拼到了第一个位置。字节数没错,总大小没错,就是顺序错了,大小校验完全发现不了。
解决:在接口协议里硬性约定分片索引从 0 开始,后端 upload 里直接使用前端传来的 chunkIndex,不做加一或减一的偏移;merge 遍历前先按索引做一轮存在性检查,任意一块缺失或为空直接报错。有精力的话,把前端算好的整个文件 md5 也作为 merge 参数传到后端,合并完成后比对,这是最硬的兜底。比对不上时不要返回成功,宁可让前端重传也不要给用户一个坏文件。
5.4 断点后续传不生效:已传分片被重复上传甚至写坏
现象:用户刷新页面或者换个浏览器,进度又从 0 开始;严重时某个 .part 文件被两个请求同时写,内容错乱,合并后文件损坏。
原因:前端只依赖 localStorage 存上传进度,清缓存、换浏览器就没了;后端 upload 接口又没做幂等判断,重复上传的同一分片被两个线程同时打开同一个文件写入,字节互相覆盖。localStorage 是前端的一层记忆,但真正的状态必须以后端磁盘上的 .part 文件为准。
解决:前端保持 testChunks: true,让每个分片发送前先探测;后端 upload 接口开头加“已存在且非空直接返回”的短路逻辑,就是 3.3 代码里那两行。若并发高,再用 ConcurrentHashMap 以 fileMd5 加 chunkIndex 为 key 做分片锁,确保同一个分片同时只被一个线程写。这样即使前端丢了本地记录,后端也能通过探测把已传部分识别出来。
5.5 中文文件名在存储和下载时双双乱码
现象:上传“述职报告-最终版.pptx”,后端落盘文件名变成一段乱码,下载时浏览器另存的名字也乱码。
原因:multipart 规范里文件名字段默认按 ISO-8859-1 传输,中文字节被错误解码;下载时如果直接用文件名拼 Content-Disposition,RFC 6266 又要求特殊编码,浏览器解析不出来就成了乱码。
解决:上传时后端拿到 OriginalFilename 后按 ISO-8859-1 还原 UTF-8,用 new String(original.getBytes(StandardCharsets.ISO_8859_1), StandardCharsets.UTF_8);或者前端上传前用 encodeURIComponent 把文件名编码,后端再解码。下载时用 filename*=UTF-8'' 的形式,URL 编码后再塞进 Content-Disposition。另外建议后端落盘文件名默认用 fileMd5 作主名,把原始文件名单独存字段,展示时再映射,这样文件名再乱也不会影响磁盘上的文件管理。
6. 进阶:用 curl 模拟一次断点续传,验证整条链路是否可靠
最后一个技巧是写一个 shell 脚本,模拟“上传两个分片、中断、重传第三个分片”的完整过程。它能帮你验证三件事:check 能不能返回已传列表、upload 是不是幂等、merge 对缺失分片会不会报错。这三关过了,整条链路基本就稳了。
# 生成测试文件,按 2MB 分片 dd if=/dev/urandom of=/tmp/test.bin bs=1M count=5 split -b 2m /tmp/test.bin /tmp/chunk_ # 上传第 1、2 个分片,模拟传输中断 curl -s -X POST http://localhost:8080/api/upload/chunk \ -F "file=@/tmp/chunk_aa" -F "fileMd5=testmd5" -F "chunkIndex=0" curl -s -X POST http://localhost:8080/api/upload/chunk \ -F "file=@/tmp/chunk_ab" -F "fileMd5=testmd5" -F "chunkIndex=1" # 模拟断线后续传:先 check,应返回 uploadedChunks=[0,1] curl -s -X POST http://localhost:8080/api/upload/check \ -H "Content-Type: application/json" \ -d '{"fileMd5":"testmd5","fileName":"test.bin","totalSize":5242880}' # 补传第 3 个分片,再 merge curl -s -X POST http://localhost:8080/api/upload/chunk \ -F "file=@/tmp/chunk_ac" -F "fileMd5=testmd5" -F "chunkIndex=2" curl -s -X POST http://localhost:8080/api/upload/merge \ -H "Content-Type: application/json" \ -d '{"fileMd5":"testmd5","fileName":"test.bin","totalSize":5242880,"totalChunks":3}' # 用 md5 对比源文件与合并结果 md5sum /tmp/test.bin /data/upload/files/testmd5_test.bin这个脚本的作用是让接口脱离前端组件单独可测。前端那些复杂的 probe、续传、上传队列,本质都是在模拟这几个 curl 请求的组合;脚本能跑通,说明后端协议对、状态记录对、合并对。我自己的习惯是把 check 和 merge 的响应体里加上 serverChunkSize 字段,前端拿到后和自己的 chunkSize 比对,不一致就直接报“前后端分片大小不一致”,这个字段能在联调早期拦截掉大部分“合并后文件损坏”的问题。分片上传这东西看着简单,翻车点多集中在不同组件的隐式约定上,协议里多一个显式字段,后面少一次血泪排查。希望这篇能帮你把这条链路一次搭稳。
本文还有配套的精品资源,点击获取