Spring Boot 分片上传与断点续传完整实现:从分片管理到秒传
简介面向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 用于分片级完整性校验。下表总结了分片上传中的通用参数参数名类型必要性identifierstring必传文件级唯一标识indexint必传从 0 开始totalChunksint必传合并和进度判断依赖chunkMultipartFile必传分片二进制内容fileNamestringmerge 阶段传入chunkMd5string建议传分片级校验状态记录推荐用一张简单表。字段不需要多但唯一索引必须加否则并发重复上传同一分片时会插入多条脏数据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 ResponseEntityString 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 ResponseEntityString 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-size10MB spring.servlet.multipart.max-request-size12MBmax-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 ResponseEntityMapString, Object handleMaxUpload(MaxUploadSizeExceededException e) { MapString, 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 ResponseEntityUploadStatus status( RequestParam String identifier, RequestParam Integer totalChunks) { ListInteger uploaded chunkMetaService.findUploadedIndexes(identifier); ListInteger 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 status1这样即使两个并发请求同时通过第一步校验也不会插入两条记录。如果用的是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 ResponseEntityBoolean 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。自研代码的价值在于把文件生命周期完全握在手里当这个前提不存在时不要在一套临时的文件调度逻辑上继续堆复杂度。本文还有配套的精品资源点击获取
上一篇/下一篇内容由系统自动关联
返回资讯列表 →