简介:面向Spring Boot开发者的大文件上传方案资源,聚焦断点续传与分片上传两种核心场景,解决网络中断重传、大文件传输效率低等实际问题。资源包内含114个文件,以Java源码及对应class文件为主,辅以XML配置、YAML环境配置、SQL脚本以及前端JS与模板文件,可完整展示从上传接口、分片接收、状态管理到文件合并的实现链路;其中SQL和YAML可用于初始化项目基础环境,前端文件便于本地联调验证。压缩包仅112KB,轻量但关键代码逻辑齐全,适合有一定Java基础、希望快速落地文件上传功能的开发者参考。目前已有1717人学习下载,内容对MultipartFile处理、上传参数限制、错误恢复与安全校验均有涉及,能帮助理解服务端如何设计分片上传与断点续传的完整方案。
1. 断点续传或分片上传在 Spring Boot 里到底解决什么问题
“上传一个大文件,网络一断又得从头再来”,这是后台管理、文档中心、课程系统里最容易被吐槽的交互。分片上传把大文件切成若干小块,分多次提交到服务端;断点续传则记录哪些分片已经成功接收,下次只传缺口部分。两者在 Spring Boot 里可以完全组合成一套方案。本文给出的不是引入分布式文件系统的重量级架构,而是用原生接口就能跑通的路径:一个接收分片的接口、一个合并接口、一张记录分片状态的表,再加前端切片逻辑,就能支撑起 2GB 甚至更大的文件上传。适合需要自己维护文件上传服务的后端开发,也适合准备面试时把“断点续传和分片上传区别”讲清楚的候选人。
2. 分片上传的前置设计:切多大、怎么切、传什么参数
2.1 分片大小的选择与网络抖动的代价权衡
分片不是切得越小越好。分片过小,比如 512KB,确实能把断点重传的代价压得很低,但一个 1GB 文件会切出 2048 个请求,每个请求都要经过一次 multipart 解析、一次磁盘写入和一次状态更新,请求量的放大效应会直接把服务端压垮。分片过大,比如 50MB,一次网络抖动就可能要重传 50MB,断点粒度也就失去了意义。结合多数生产环境的情况,2MB 到 8MB 是常见区间,5MB 是一个改动最少的起点。
既然标题落在 Spring Boot,就必须提一个默认限制:spring.servlet.multipart.max-file-size的默认值是 1MB。不调整配置时,超过 1MB 的分片请求根本到不了 Controller,会直接在 multipart 解析层抛异常。另外要注意浏览器的同域并发连接数限制,Chrome 大约只有 6 个连接。把所有分片同时发出去并不会更快,反而会在网络层排队。常见做法是限制 3 到 5 个并发槽,依序拿任务并上传。
2.2 前端用 file.slice 切片,identifier 决定文件身份
前端切分在浏览器能力范围内已经是固定写法,File.slice不会把整个文件加载进内存,而是创建 Blob 引用。下面是一个最小切片与顺序上传示例:
const CHUNK_SIZE = 5 * 1024 * 1024; const file = document.getElementById('fileInput').files[0]; const totalChunks = Math.ceil(file.size / CHUNK_SIZE); const identifier = `${file.name}_${file.size}_${file.lastModified}`; async function uploadChunk(index, blob) { const form = new FormData(); form.append('chunk', blob); form.append('index', index); form.append('identifier', identifier); form.append('totalChunks', totalChunks); await fetch('/upload/chunk', { method: 'POST', body: form }); } for (let i = 0; i < totalChunks; i++) { const start = i * CHUNK_SIZE; const end = Math.min(start + CHUNK_SIZE, file.size); await uploadChunk(i, file.slice(start, end)); }这段代码是顺序上传版本,真实项目中会在循环外套一个并发控制器,比如用 p-limit 把并发限制在 4。identifier的生成没有采用整个文件的 md5,因为大文件计算完整 md5 特别耗时,前端会卡在“计算中”状态很久。用name + size + lastModified已经能区分绝大多数重复上传,必要的时候再拼接用户 id 做前缀,区分不同账号之间的同名文件。
2.3 服务端需要哪些分片参数,用什么结构记录状态
服务端接收分片时真正关心的参数不多:identifier 用来识别同一个文件,index 用来定位第几个分片,totalChunks 用来判断何时凑齐,chunk 是二进制内容。fileName 留到 merge 阶段再传,chunkMd5 用于分片级完整性校验。下表总结了分片上传中的通用参数:
| 参数名 | 类型 | 必要性 |
|---|---|---|
| identifier | string | 必传,文件级唯一标识 |
| index | int | 必传,从 0 开始 |
| totalChunks | int | 必传,合并和进度判断依赖 |
| chunk | MultipartFile | 必传,分片二进制内容 |
| fileName | string | merge 阶段传入 |
| chunkMd5 | string | 建议传,分片级校验 |
状态记录推荐用一张简单表。字段不需要多,但唯一索引必须加,否则并发重复上传同一分片时会插入多条脏数据:
CREATE TABLE upload_chunk ( id BIGINT PRIMARY KEY AUTO_INCREMENT, identifier VARCHAR(128) NOT NULL, chunk_index INT NOT NULL, total_chunks INT NOT NULL, status TINYINT DEFAULT 1, created_at DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_identifier_chunk (identifier, chunk_index) );唯一键(identifier, chunk_index)是最重要的一层防重设计。前端因超时重试而重复发送分片时,数据库层能把第二次插入直接挡掉,服务端再配合“存在即返回成功”的接口逻辑,就能让整个上传过程保持幂等。如果不用数据库,只靠扫描磁盘分片目录也能知道已上传哪些片,但目录命名和遍历顺序不如数据库直观,断点查询的速度也会差不少。
3. Spring Boot 服务端实现:分片落盘与合并
3.1 接收分片接口:落盘路径与文件名设计
Controller 层只需接收 multipart 请求,把分片写入固定目录,然后登记状态。先定义两个根目录常量:UPLOAD_ROOT存放分片文件,MERGE_ROOT存放合并结果。分片目录名用 identifier,分片文件名用补零的 index,这样在合并阶段可以直接按文件名排序,不需要额外解析数值。
目录名和文件名必须处理路径穿越问题。identifier 和 fileName 都来自前端,直接拼接路径可能把文件写到任意目录,所以先写一个safeName方法,把非白名单字符统一替换成下划线:
private static final String UPLOAD_ROOT = "/data/upload/chunks"; private static final String MERGE_ROOT = "/data/upload/merged"; private String safeName(String name) { return name.replaceAll("[^a-zA-Z0-9._-]", "_"); } @PostMapping("/upload/chunk") public ResponseEntity<String> uploadChunk( @RequestParam("chunk") MultipartFile chunk, @RequestParam("index") Integer index, @RequestParam("identifier") String identifier, @RequestParam("totalChunks") Integer totalChunks) throws IOException { Path chunkDir = Paths.get(UPLOAD_ROOT, safeName(identifier)); Files.createDirectories(chunkDir); Path dest = chunkDir.resolve(String.format("%06d", index)); chunk.transferTo(dest); chunkMetaService.markUploaded(identifier, index, totalChunks); return ResponseEntity.ok("ok"); }String.format("%06d", index)生成 000000、000001 这类文件名,合并阶段按字符串排序就是正确分片顺序,不会出现第 2 片排到第 10 片后面的情况。Files.createDirectories是幂等的,多个分片并发到达时不需要加if (!exists)判断。chunk.transferTo直接把 Spring MVC 解析出的临时文件移动到目标位置,比另开输出流再写一遍更高效。
3.2 合并分片:流式拷贝比一次性读进内存安全
合并接口是上传流程的最后一环,前端在确认所有分片完成后调用。合并的核心动作是:列出分片目录、排序、依次写入目标文件。
@PostMapping("/upload/merge") public ResponseEntity<String> merge(@RequestParam String identifier, @RequestParam String fileName) throws IOException { Path chunkDir = Paths.get(UPLOAD_ROOT, safeName(identifier)); File[] chunks = chunkDir.toFile().listFiles(); if (chunks == null || chunks.length == 0) { return ResponseEntity.badRequest().body("没有可合并的分片"); } Arrays.sort(chunks, Comparator.comparing(File::getName)); Files.createDirectories(Paths.get(MERGE_ROOT)); Path target = Paths.get(MERGE_ROOT, safeName(fileName)); try (BufferedOutputStream out = new BufferedOutputStream(Files.newOutputStream(target))) { for (File part : chunks) { byte[] buf = new byte[8192]; try (BufferedInputStream in = new BufferedInputStream(Files.newInputStream(part.toPath()))) { int len; while ((len = in.read(buf)) != -1) { out.write(buf, 0, len); } } } } deleteQuietly(chunkDir); return ResponseEntity.ok("合并完成: " + target); }合并时最忌讳的做法是把所有分片一次性读进内存,比如先拼一个大的 byte[] 再整体写盘。分片数量多时会产生很高的内存压力,直接用 8KB 缓冲区配合流式读写是比较稳妥的路径。deleteQuietly在合并成功后删除分片目录,这一点很关键。相反,如果合并抛异常,要保留分片目录,前端再次查询状态时仍能返回缺口信息,用户只要重新触发 merge 就行,不需要重传大文件。
3.3 上传参数配置与 Spring Boot 版本差异
分片大小定为 5MB 后,Spring Boot 配置里要同步调大上传限制。2.x 和 3.x 的配置路径保持一致,直接改这两个属性即可:
spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=12MBmax-file-size 控制单个文件大小,max-request-size 控制单个请求总体大小。分片请求里除了 chunk 还有 index 和 identifier 等参数,请求体实际大小会略大于 5MB,给到 12MB 是合理余量。不要把这俩参数调得过大,比如 100MB,因为并发请求时会放大服务端内存占用。
如果项目从 Spring Boot 2.x 升到 3.x,遇到版本差异带来的编译问题,通常集中在 servlet 包名从 javax 换成 jakarta;使用 MultipartFile 的 Controller 代码基本不需要改,Spring 已经把底层实现封装好了。只要spring-boot-starter-web在依赖里,分片上传相关的配置注入逻辑没有出现迁移变更,这套代码可以直接搬过去。
分片大小超过限制时,Spring 会抛MaxUploadSizeExceededException,建议用@RestControllerAdvice统一处理:
@ExceptionHandler(MaxUploadSizeExceededException.class) public ResponseEntity<Map<String, Object>> handleMaxUpload(MaxUploadSizeExceededException e) { Map<String, Object> body = new HashMap<>(); body.put("code", 413); body.put("message", "分片大小超过服务端限制"); return ResponseEntity.status(413).body(body); }前端拿到 413 后要明确提示用户调整分片大小或配置文件限制,而不是把 413 当成网络错误去整体重试。默认的异常响应既不好解析,又会把服务端堆栈暴露到调用方,统一 JSON 结构对移动端和 JS 端都更友好。
3.4 磁盘占用:分片目录和合并文件要预留双倍空间
分片上传对磁盘空间不是一次到位,而是先占一份分片空间,再占一份合并空间。一个 2GB 文件切完后,/data/upload/chunks下会先堆积约 2GB 分片,合并出的完整文件又占 2GB,磁盘至少要有 4GB 可用才能安全完成整个流程。如果上传后不清理分片目录,多个人同时传大文件会把磁盘直接塞满,所以 merge 成功后的清理动作和定时补扫任务必须存在,这一点在后面第 5 章展开。
4. 断点续传的状态查询与分片校验
4.1 查询已上传分片接口:返回缺口列表而不是 nextIndex
断点续传的服务端核心是一个状态查询接口,前端在上传开始前调用它。返回数据建议组合两种形式:已上传分片列表和仍需上传分片列表。不建议只返回一个 nextIndex,因为并发上传常见乱序完成,index 3 和 5 传完了,index 4 还没有,只给一个 nextIndex 表达不了这种缺口。
@GetMapping("/upload/status") public ResponseEntity<UploadStatus> status( @RequestParam String identifier, @RequestParam Integer totalChunks) { List<Integer> uploaded = chunkMetaService.findUploadedIndexes(identifier); List<Integer> needUpload = IntStream.range(0, totalChunks) .filter(i -> !uploaded.contains(i)) .boxed() .collect(Collectors.toList()); UploadStatus status = new UploadStatus(); status.setUploaded(uploaded); status.setNeedUpload(needUpload); status.setFinished(needUpload.isEmpty()); return ResponseEntity.ok(status); }前端拿到needUpload后,只对列表中的 index 发起上传;如果finished为 true,前端可以直接跳过所有分片,调用/upload/merge。这样即使上次中断发生在第 200 片,再次打开页面也只需补传未完成的部分,断点续传的完整路径就落地了。
4.2 重复上传同一分片时的幂等处理
断点续传机制很容易触发重复上传:网络超时后前端重试、用户刷新页面后再次点击上传、并发控制下的重复调度。如果服务端收到相同(identifier, index)就再写一次文件,可能覆盖一个正在被读取的分片,造成数据错乱。处理分两步:先在接收接口里查一次状态,再依赖数据库唯一索引做兜底。
if (chunkMetaService.exists(identifier, index)) { return ResponseEntity.ok("duplicate"); }服务端对前端返回的成功响应,既可能是本次写盘完成,也可能是“这个分片之前就传过了”。这两种情况对调用方没有区别,都视为成功。数据库写入建议用INSERT INTO upload_chunk ... ON DUPLICATE KEY UPDATE status=1,这样即使两个并发请求同时通过第一步校验,也不会插入两条记录。如果用的是INSERT IGNORE,重复时状态不会更新,遇到更复杂的业务状态流转容易漏掉标记。
4.3 用 MD5 校验分片,排查静默截断问题
传输过程中最隐蔽的问题是分片内容被截断:请求在网络层超时但部分字节已经到达,服务端拿到一个不完整文件却不报错。合并出来的结果文件就是坏的,视频花屏、压缩包解压失败,这类现象往往在几十分钟后才被发现。
可靠的做法是在服务端落盘后对分片文件再算一次 md5,和前端传入的 chunkMd5 对比:
private String md5Of(Path path) throws Exception { MessageDigest digest = MessageDigest.getInstance("MD5"); try (InputStream in = Files.newInputStream(path)) { byte[] buf = new byte[8192]; int len; while ((len = in.read(buf)) != -1) { digest.update(buf, 0, len); } } return HexFormat.of().formatHex(digest.digest()); }如果服务端算出的 md5 与请求中的 chunkMd5 不一致,可以直接返回错误并删除该分片文件,让前端重传。5MB 分片计算 md5 的耗时在毫秒级,不会成为瓶颈。注意上面用了 Java 17 的HexFormat,如果项目还在 Java 8,要换成DatatypeConverter.printHexBinary或自己写字节转十六进制。前端计算分片 md5 是一个相对轻量的操作,跟计算整文件 md5 是两条线,不要混为一谈。
4.4 文件损坏时按三个方向排查
合并结果损坏的排查顺序通常是:
- 确认分片目录下文件数量等于 totalChunks,不相等说明有分片没传完。
- 确认分片文件名集合没有缺号,000000、000001、000002 应当连续。
- 确认分片文件大小不为 0,若有空文件且状态表已写入,优先检查 Nginx 的
client_max_body_size配置。
注意:如果服务端入口加了 Nginx,默认
client_max_body_size是 1MB。分片大于 1MB 时,请求在网关层直接被拦截,服务端日志里看不到任何异常,但分片目录下会留下 0 字节文件。先改网关限制,再排查 Spring Boot 代码,顺序不能反。
5. 进阶:秒传、合并并发保护与自研边界
5.1 秒传:在分片上传开始前就返回完成
秒传不是断点续传的替代品,而是前置拦截。文件在服务端已经存在时,没有必要再去分片。做法是前端在 Web Worker 里计算整个文件的 md5,先调用一个检查接口:
@GetMapping("/upload/check") public ResponseEntity<Boolean> check(@RequestParam String fileMd5) { return ResponseEntity.ok(fileService.existsByMd5(fileMd5)); }第一次上传完成并且 merge 成功后,服务端计算完整文件 md5 并存入文件表。后续相同文件再次上传,check 返回 true,前端可以把进度条直接置为已完成。这个文件级 md5 和 4.3 的分片级 md5 不是一回事,前者用于去重,后者用于单分片落盘校验,两者在同一流程中各自发挥作用。
5.2 合并阶段的并发保护与失败保留
当多个请求同时对一个 identifier 发起 merge 时,最坏情况是两个线程同时读同一个分片目录,合并出残缺文件。单机部署下可以用 identifier 作为锁粒度:
synchronized (identifier.intern()) { return doMerge(identifier, fileName); }synchronized配合intern()适合单实例;多实例部署时,这个锁就失效了,需要换成数据库乐观锁或 Redis 锁。另外 merge 失败后先不要清理分片目录,保留现场,让前端重新调用 status 和 merge;分片目录清扫交给定时任务去做,比如每 24 小时扫一遍已超过 24 小时未合并的 identifier,整体删除,这样才能避免中断一半的上传任务把磁盘彻底占满。
5.3 自研方案与对象存储 SDK 的取舍
这套基于本地磁盘的实现,在单机部署、中等规模业务下完全可用。真正需要判断的前提是:分片文件和最终合并文件能否接受放在同一台机器的本地磁盘上。如果可以,自研方案成本低、流程透明、不依赖外部服务;如果业务已经使用对象存储,官方 SDK 基本都提供分片上传、断点续传、合并以及 MD5 校验能力,那就优先考虑直接调用 SDK。自研代码的价值在于把文件生命周期完全握在手里,当这个前提不存在时,不要在一套临时的文件调度逻辑上继续堆复杂度。
本文还有配套的精品资源,点击获取