☰
Spring Boot 大文件断点续传与分片上传实战方案
2026/10/1 17:26:29 网站建设 项目流程

简介:这份资源面向需要在 Spring Boot 项目中实现大文件上传的 Java 开发者,聚焦断点续传与分片上传两大核心场景,帮助解决网络中断重传、单次上传体积受限、上传效率低下等实际问题,适合具备一定 Spring 基础、正在做文件服务模块的中级开发者参考。压缩包共 114 个文件,约 112KB,以 51 个 java 源码和 45 个 class 编译文件为主体,另含 11 个 xml 配置、2 个 sql 脚本、2 个 yml 配置及 js、ftl 等前端与模板文件,覆盖实体类、服务实现、控制器与持久层等完整分层结构。目前已有 1717 人学习下载。资源围绕 MultipartFile 接收、分片存储与合并、已上传字节数记录、上传状态管理与错误重试等环节展开,并涉及文件大小限制配置与安全性校验思路,读者可据此梳理断点续传与分片上传的落地流程,快速搭建可运行的上传模块并排查常见问题。

1. 大文件上传总翻车,这套 Spring Boot 断点续传方案到底能不能救场

做过文件上传的兄弟大概率都遇到过这种场景:用户传一个 2GB 的视频,进度条走到 87% 网络抖了一下,页面刷新,一切归零,用户骂骂咧咧地重新点上传,你盯着日志里那个MultipartFile一脸无奈。这不是玄学,是默认的MultipartFile上传机制压根没考虑过"中断恢复"这件事——它要么全成功,要么全失败,中间状态全丢。

这套 Spring Boot 断点续传 / 分片上传资源,核心就是解决这个"归零"问题。它把大文件切成固定大小的分片,每片独立上传、独立校验、独立记录状态,最后在服务端合并成完整文件。断点续传则是在此基础上,客户端上传前先问服务端"我已经传到哪了",跳过已成功的分片,只补缺失的部分。适合谁?正在做后台管理系统、在线教育、医疗影像、视频平台这类有大文件上传诉求的 Java 后端,尤其是用 Spring Boot 做技术栈、又不想引入 MinIO 或 OSS 这类外部组件的团队。资源里包含UploadFileServiceImpl、UploadFile、FileForm、FileDto、ResponseResult等核心类,是一套可以直接嵌进现有项目的实现骨架。

2. 分片上传的底层逻辑:从MultipartFile到合并落盘

2.1 为什么不能直接调transferTo

很多人第一反应是file.transferTo(new File(...))一行搞定,但这条路在大文件场景下有三个硬伤。第一,MultipartFile默认会把整个请求体缓存在内存或临时目录,Spring Boot 里spring.servlet.multipart.max-file-size一旦设成 2GB,Tomcat 的临时目录和堆内存都会遭殃。第二,transferTo是原子操作,中途断了就是断了,没有中间状态可查。第三,它没法并行——一个 5GB 文件只能单线程慢慢写。

分片上传的思路是把"一次大请求"拆成"N 次小请求"。前端用File.slice()把文件切成比如 5MB 一片,每片带三个关键参数:fileMd5(整个文件的唯一标识)、chunkIndex(当前第几片)、totalChunks(总片数)。服务端收到后不急着合并,先按fileMd5/chunkIndex的路径把分片存到临时目录,等所有分片到齐再触发合并。

这里有个选型细节:fileMd5怎么算?常见做法是前端用spark-md5对文件做增量哈希,大文件算一次大概几秒到十几秒,但换来的是秒传和精确断点定位。如果嫌慢,可以退而求其次用"文件名 + 文件大小 + 最后修改时间"拼一个伪 MD5,代价是同一文件改名后无法秒传。

2.2 分片存储目录与状态记录

服务端的分片落盘结构我一般这么设计:

upload/ └── {fileMd5}/ ├── chunk_0 ├── chunk_1 ├── ... └── chunk_99

每个分片就是一个普通文件,命名用chunk_{index},合并时按 index 排序顺序读取写入即可。状态记录有两种做法:轻量级用 Redis 存一个 Set,key 是upload:{fileMd5},value 是已完成的分片 index 集合;重量级用数据库表,字段包括file_md5、chunk_index、chunk_size、status、create_time。资源里的UploadFile和UploadFileServiceImpl走的是后者,好处是重启不丢状态,坏处是每次上传多一次 DB 写入,分片数多的时候要注意批量插入。

// 分片上传核心逻辑(简化版) @PostMapping("/chunk") public ResponseResult uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("fileMd5") String fileMd5, @RequestParam("chunkIndex") Integer chunkIndex) { // 1. 校验分片合法性 if (file.isEmpty() || chunkIndex == null) { return ResponseResult.error("分片参数缺失"); } // 2. 构建分片存储路径 String chunkDir = basePath + File.separator + fileMd5; File dir = new File(chunkDir); if (!dir.exists()) { dir.mkdirs(); } // 3. 写入分片文件 File chunkFile = new File(chunkDir + File.separator + "chunk_" + chunkIndex); try { file.transferTo(chunkFile); } catch (IOException e) { return ResponseResult.error("分片写入失败: " + e.getMessage()); } // 4. 记录分片状态到数据库 uploadFileService.saveChunkStatus(fileMd5, chunkIndex, file.getSize()); return ResponseResult.success("分片上传成功"); }

这段代码里basePath建议配在application.yml里而不是硬编码,file.transferTo的目标路径必须是绝对路径,相对路径在不同容器下行为不一致,这是血泪经验。saveChunkStatus里要做幂等——同一个fileMd5 + chunkIndex重复上传时用INSERT ... ON DUPLICATE KEY UPDATE或者先查后写,否则并发重传会插出重复记录。

2.3 合并分片的触发时机与校验

合并不能靠"最后一片到了就合",因为分片到达顺序不保证。正确做法是每次分片上传成功后查一次已完成数量,等于totalChunks时才触发合并。合并逻辑本身不复杂,但有两个坑:一是合并时要用RandomAccessFile或FileChannel按 index 顺序写,别用FileOutputStream追加,因为分片可能乱序到达;二是合并完成后要校验最终文件的 MD5 或大小,防止某个分片写坏导致整个文件损坏。

public void mergeChunks(String fileMd5, int totalChunks, String targetPath) throws IOException { File targetFile = new File(targetPath); try (FileChannel outChannel = new FileOutputStream(targetFile).getChannel()) { for (int i = 0; i < totalChunks; i++) { File chunk = new File(basePath + File.separator + fileMd5 + File.separator + "chunk_" + i); try (FileChannel inChannel = new FileInputStream(chunk).getChannel()) { inChannel.transferTo(0, inChannel.size(), outChannel); } // 合并后删除分片,释放磁盘 chunk.delete(); } } // 校验合并后文件大小是否与预期一致 long expectedSize = uploadFileService.getTotalSize(fileMd5); if (targetFile.length() != expectedSize) { throw new IOException("合并后文件大小不一致,可能分片损坏"); } }

transferTo是零拷贝,比传统的byte[]缓冲区循环快不少,但注意它单次传输有 2GB 上限,超大分片要分段调用。合并完成后删分片这一步别省,否则磁盘会被临时文件吃满,尤其是测试环境反复上传同一个文件的时候。

3. 断点续传的落地:状态查询接口与前端配合

3.1 秒传与断点查询接口设计

断点续传的前置动作是"问服务端我传到哪了"。这个接口通常叫/check或/status,入参是fileMd5和fileName,返回三种状态:文件已存在(秒传)、部分分片已上传(返回已完成 index 列表)、全新文件(返回空列表)。

@GetMapping("/check") public ResponseResult checkFile(@RequestParam("fileMd5") String fileMd5, @RequestParam("fileName") String fileName) { // 1. 先查最终文件是否已存在(秒传) UploadFile existFile = uploadFileService.getByMd5(fileMd5); if (existFile != null && new File(existFile.getFilePath()).exists()) { return ResponseResult.success("秒传成功", Collections.singletonMap("skip", true)); } // 2. 查已完成分片列表 List<Integer> uploadedChunks = uploadFileService.getUploadedChunks(fileMd5); Map<String, Object> data = new HashMap<>(); data.put("skip", false); data.put("uploaded", uploadedChunks); return ResponseResult.success("查询成功", data); }

前端拿到uploaded列表后,在切片循环里if (uploaded.includes(index)) continue;跳过已传分片。这里有个容易翻车的点:fileMd5的计算必须和上传时完全一致,如果前端用了spark-md5分块计算,注意最后一块的FileReader要等onload完成再拼结果,否则算出来的哈希和实际文件对不上,断点永远查不到。

3.2 分片大小怎么定:5MB 不是万能答案

分片大小直接影响上传效率和失败重传成本。太小,请求数暴增,HTTP 握手开销占比高;太大,单片失败重传代价大,而且可能撞上 Nginx 的client_max_body_size限制。我一般按这个经验值走:

文件大小范围建议分片大小理由
< 100MB2MB请求数可控,重传成本低
100MB ~ 1GB5MB平衡请求数和重传成本
1GB ~ 5GB10MB减少请求数,避免超时
> 5GB20MB配合分片并发上传

注意spring.servlet.multipart.max-file-size要设成比分片大小略大,比如分片 5MB 就设 10MB,留出表单其他字段的空间。Nginx 那边client_max_body_size也要同步放开,否则请求根本到不了 Spring Boot 就被拦了,日志里连个异常都看不到,这是最隐蔽的坑之一。

3.3 并发上传与顺序控制

分片上传天然适合并发——前端开 3 到 5 个并发通道同时传不同分片,整体速度能提升 2 到 3 倍。但并发带来两个问题:一是服务端saveChunkStatus的并发写入,二是合并触发时机的竞态。前者用数据库唯一索引兜底,后者用一个基于fileMd5的分布式锁或者 Redis 的SETNX控制,保证只有一个线程能触发合并。

// 用 Redis 锁控制合并触发,防止并发重复合并 public void tryMerge(String fileMd5, int totalChunks) { String lockKey = "upload:merge:" + fileMd5; Boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 30, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { int uploaded = uploadFileService.countUploadedChunks(fileMd5); if (uploaded == totalChunks) { mergeChunks(fileMd5, totalChunks, buildTargetPath(fileMd5)); uploadFileService.markComplete(fileMd5); } } finally { redisTemplate.delete(lockKey); } } }

锁的过期时间给 30 秒是拍脑袋值,实际要按合并耗时调整,合并 5GB 文件可能要一两分钟,锁提前过期会导致重复合并。稳妥做法是锁续期,或者干脆用数据库行锁SELECT ... FOR UPDATE把状态行锁住。

4. 避坑排查:那些让你加班到凌晨的上传问题

4.1 分片上传成功但合并后文件损坏

现象:所有分片返回成功,合并接口也返回成功,但下载下来的文件打不开,视频花屏、压缩包解压报错。

原因:分片写入时用了file.transferTo,但目标文件已存在时部分容器会追加而不是覆盖,导致分片内容错位。或者合并时按文件名排序而不是按数字 index 排序,chunk_10排在了chunk_2前面。

解决:写入分片前先chunkFile.delete()确保干净;合并时用Integer.parseInt(name.split("_")[1])转数字排序,别用字符串排序。合并后强制校验文件 MD5 或大小,不一致就抛异常并清理。

4.2 断点查询返回空但分片明明传过

现象:前端刷新后调/check,返回的uploaded列表是空的,但临时目录里分片文件都在。

原因:fileMd5前后不一致。常见于前端第一次上传时用了文件名 + 大小拼的伪 MD5,刷新后文件名被浏览器改了(比如加了(1)),或者spark-md5计算时最后一块没读完就返回了。

解决:统一 MD5 计算逻辑,前端封装一个calcMd5(file)函数,确保每次调用结果一致;服务端在日志里打印收到的fileMd5,和临时目录名对比,一眼就能看出对不对。

4.3 大文件上传到 99% 报SizeLimitExceededException

现象:分片都传完了,合并请求或者最后一片上传时报请求体超限。

原因:spring.servlet.multipart.max-request-size设的是整个请求的上限,如果合并接口也走MultipartFile接收,或者最后一片带了额外的大字段,就会撞限制。

解决:合并接口不要用MultipartFile,用普通@RequestParam接收fileMd5和totalChunks即可;max-request-size设成max-file-size的 1.5 倍留余量。

4.4 并发上传时数据库出现重复分片记录

现象:同一个fileMd5 + chunkIndex在表里出现多条记录,合并时数量对不上。

原因:前端重试机制导致同一分片并发提交,saveChunkStatus先查后写不是原子操作。

解决:给file_md5 + chunk_index建唯一索引,插入用INSERT IGNORE或ON DUPLICATE KEY UPDATE;或者用 Redis 的SADD做去重,DB 只做持久化。

4.5 合并后临时目录没清理导致磁盘爆满

现象:跑了一段时间后服务器磁盘 100%,排查发现upload/下堆了几百 GB 的分片。

原因:合并成功后只删了分片文件,没删fileMd5目录;或者合并失败后没有清理机制,失败的分片一直留着。

解决:合并成功后FileUtils.deleteDirectory(new File(chunkDir));加一个定时任务,每天凌晨清理超过 24 小时未完成的fileMd5目录,用lastModified判断。

5. 进阶技巧:把上传成功率从 90% 拉到 99%

5.1 分片重试与指数退避

前端上传分片时别用Promise.all一把梭,任何一个失败整个批次就挂了。我一般封装一个uploadWithRetry(chunk, retries = 3),失败后等2^retry * 500ms再重试,三次都失败才标记该分片为失败,最后统一补传失败分片。这样网络抖动导致单分片失败时,用户几乎无感知。

async function uploadWithRetry(formData, retries = 3) { for (let i = 0; i < retries; i++) { try { const res = await axios.post('/upload/chunk', formData); if (res.data.code === 200) return res.data; } catch (e) { if (i === retries - 1) throw e; await new Promise(r => setTimeout(r, Math.pow(2, i) * 500)); } } }

5.2 上传进度与速度计算

进度不能简单用"已完成分片数 / 总分片数",因为分片大小可能不一致(最后一片通常小)。准确做法是累加已完成分片的loaded字节数除以文件总大小。速度计算用滑动窗口,取最近 5 秒的loaded增量除以时间,比瞬时速度稳定得多,不会因为一个分片传完就跳一下。

5.3 服务端限流与磁盘保护

上传接口是最容易被刷的,加一个基于fileMd5的限流:同一fileMd5每秒最多接受 20 个分片请求,超过就返回 429。磁盘保护方面,上传前先查File.getUsableSpace(),剩余空间小于文件大小的 2 倍时直接拒绝,别等写满了才发现。

5.4 验证清单

上线前我会强制走一遍这个清单:分片大小和 Nginx、Spring Boot 配置是否对齐;fileMd5计算前后是否一致;合并后文件 MD5 是否校验;临时目录清理任务是否生效;并发上传 100 个分片是否出现重复记录;断点查询在刷新、换浏览器、断网重连三种场景下是否都能正确返回。这套流程跑通,基本能覆盖 95% 的线上问题。

从那以后我每次接文件上传需求,都先把分片大小、MD5 计算、合并校验、清理任务这四件事在纸上画一遍再动手,省下的返工时间远比画图那十分钟多。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询