首页 / 资讯中心 / 文章详情

Spring Boot + Vue 分片上传与断点续传实战:大文件上传不再重来

Spring Boot + Vue 分片上传与断点续传实战:大文件上传不再重来 ★ FEATURED ARTICLE
简介面向需要处理大文件上传的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/chunkidentifier, chunkIndex200 已存在 / 404 不存在上传检查POST/api/upload/checkfileMd5, fileName, totalSizeuploadedChunks 列表 complete分片上传POST/api/upload/chunkfile, fileMd5, chunkIndexuploaded 状态 已存大小合并完成POST/api/upload/mergefileMd5, 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 ResponseEntityCheckResult 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); ListInteger 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 ResponseEntityUploadResult 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 ResponseEntityMergeResult 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 ResponseEntityVoid 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 放宽到 50MBNginx 在 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-DispositionRFC 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 bs1M count5 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 fileMd5testmd5 -F chunkIndex0 curl -s -X POST http://localhost:8080/api/upload/chunk \ -F file/tmp/chunk_ab -F fileMd5testmd5 -F chunkIndex1 # 模拟断线后续传先 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 fileMd5testmd5 -F chunkIndex2 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 比对不一致就直接报“前后端分片大小不一致”这个字段能在联调早期拦截掉大部分“合并后文件损坏”的问题。分片上传这东西看着简单翻车点多集中在不同组件的隐式约定上协议里多一个显式字段后面少一次血泪排查。希望这篇能帮你把这条链路一次搭稳。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站