ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Spring Boot 大文件断点续传与分片上传实战方案

Spring Boot 大文件断点续传与分片上传实战方案 简介这份资源面向需要在 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一旦设成 2GBTomcat 的临时目录和堆内存都会遭殃。第二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 存一个 Setkey 是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. 查已完成分片列表 ListInteger uploadedChunks uploadFileService.getUploadedChunks(fileMd5); MapString, 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 计算、合并校验、清理任务这四件事在纸上画一遍再动手省下的返工时间远比画图那十分钟多。希望帮到你。本文还有配套的精品资源点击获取
返回列表