简介:这份资源面向需要在 Spring Boot 项目中实现大文件上传的 Java 开发者,聚焦断点续传与分片上传两大核心场景,帮助解决网络中断重传、单次上传体积受限、失败后整包重传等实际痛点,适合具备一定 Spring 基础、正在做文件服务模块的中高级开发者参考。压缩包共 114 个文件,约 112KB,以 51 个 java 源码与 45 个 class 编译文件为主体,辅以 11 个 xml 配置、2 个 sql 脚本、2 个 yml 配置文件,以及 js、ftl 等前端与模板文件,覆盖上传接口、业务实现、实体与响应封装等层次。资源围绕 MultipartFile 接收、分片存储与合并、已上传字节数记录、上传状态管理与错误重试等环节展开,并涉及文件大小限制配置与安全性校验思路,可帮助读者快速搭建可续传、可分片的上传链路。目前已有 1717 人学习下载,适合作为文件上传模块的实践参考。
1. SpringBoot 断点续传与分片上传:从秒传失败到分片落盘的真实链路
线上传一个 2GB 的镜像包,前端进度条卡在 99% 然后报 502,用户刷新页面一切归零——这种事故在文件服务里几乎每周都能遇到。SpringBoot 断点续传或分片上传要解决的就是这件事:把一个大文件切成若干块分别传输,网络抖动只影响当前分片,已经传完的部分不重来;断点续传则是在此基础上记录进度,让用户关掉浏览器再回来还能接着传。它适合做网盘、视频平台、企业文档中心、CI 产物归档这类大文件场景,也适合任何被“上传超时”折磨过的后端同学。分片上传和断点续传经常被混着说,但两者关注点不同:分片解决单次请求体过大,断点解决跨会话的进度恢复,工程上通常一起实现。下面按“先跑通最小链路,再补校验和清理”的顺序拆开讲。
2. 分片上传的最小闭环:前端切片、后端落盘、合并校验
2.1 为什么不能直接传整个文件
浏览器FormData直接 POST 一个 2GB 文件,会同时踩三个坑。第一,Nginx 默认client_max_body_size是 1MB,网关层直接 413;第二,Tomcat 的maxSwallowSize和maxPostSize对超大请求体处理很别扭,连接容易被重置;第三,任何一次网络抖动都让整个请求重来,用户侧体验是“传了半小时白传”。分片上传把文件按固定大小切开,每个分片是一个独立 HTTP 请求,单请求体小、可重试、可并发,失败成本被限制在一个分片内。
分片大小不是随便定的。常见做法是 5MB 起步,因为主流对象存储(如 MinIO、OSS)的分片下限就是 5MB,低于这个值合并时会报EntityTooSmall。我一般按文件大小动态算:小于 100MB 用 5MB,100MB 到 1GB 用 10MB,超过 1GB 用 20MB。分片数控制在 1000 以内,避免合并时元数据过多。
2.2 前端切片与分片元数据
前端用File.slice切片,每片带上fileMd5、chunkIndex、chunkTotal、chunkSize四个字段。fileMd5是整文件指纹,用来做秒传和断点定位;chunkIndex从 0 开始,合并时按序拼接。
// 前端:计算文件指纹并切片上传 async function uploadFile(file) { const chunkSize = 5 * 1024 * 1024; // 5MB const chunkTotal = Math.ceil(file.size / chunkSize); // 整文件 MD5,用于秒传和断点续传定位 const fileMd5 = await computeFileMd5(file); // 先问后端:这个文件是否已存在(秒传) const check = await fetch(`/api/upload/check?md5=${fileMd5}`).then(r => r.json()); if (check.exists) return { url: check.url, skip: true }; // 再问后端:哪些分片已经传过(断点续传) const uploaded = await fetch(`/api/upload/chunks?md5=${fileMd5}`).then(r => r.json()); const uploadedSet = new Set(uploaded.indexes); for (let i = 0; i < chunkTotal; i++) { if (uploadedSet.has(i)) continue; // 跳过已传分片 const chunk = file.slice(i * chunkSize, (i + 1) * chunkSize); const form = new FormData(); form.append('file', chunk); form.append('md5', fileMd5); form.append('index', i); form.append('total', chunkTotal); await fetch('/api/upload/chunk', { method: 'POST', body: form }); } // 全部分片传完后触发合并 return fetch('/api/upload/merge', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ md5: fileMd5, total: chunkTotal, fileName: file.name }) }).then(r => r.json()); }computeFileMd5建议用spark-md5分块读取,避免一次性把 2GB 读进内存导致页面卡死。check接口返回exists时直接秒传,这是分片上传最省资源的路径。uploaded.indexes是后端扫描临时目录得到的已传分片下标,前端据此跳过,这就是断点续传的核心。
2.3 后端分片落盘与合并
后端接收分片时,按md5建目录,分片以index命名落盘。合并时按index顺序读取并写入最终文件,同时做一次 MD5 校验。
@RestController @RequestMapping("/api/upload") public class UploadController { private static final String BASE_DIR = "/data/upload"; // 分片落盘:/data/upload/{md5}/{index}.part @PostMapping("/chunk") public Result uploadChunk(@RequestParam("file") MultipartFile file, @RequestParam("md5") String md5, @RequestParam("index") int index) throws IOException { File dir = new File(BASE_DIR, md5); if (!dir.exists()) dir.mkdirs(); File part = new File(dir, index + ".part"); file.transferTo(part); // 直接落盘,不占 JVM 内存 return Result.ok(); } // 查询已传分片,供断点续传使用 @GetMapping("/chunks") public Map<String, Object> chunks(@RequestParam("md5") String md5) { File dir = new File(BASE_DIR, md5); List<Integer> indexes = new ArrayList<>(); if (dir.exists()) { for (File f : dir.listFiles()) { String name = f.getName(); if (name.endsWith(".part")) { indexes.add(Integer.parseInt(name.replace(".part", ""))); } } } return Map.of("indexes", indexes); } // 合并分片并校验 @PostMapping("/merge") public Result merge(@RequestBody MergeRequest req) throws IOException { File dir = new File(BASE_DIR, req.getMd5()); File target = new File(BASE_DIR, req.getFileName()); try (FileOutputStream out = new FileOutputStream(target, true)) { for (int i = 0; i < req.getTotal(); i++) { File part = new File(dir, i + ".part"); if (!part.exists()) throw new IllegalStateException("分片缺失: " + i); Files.copy(part.toPath(), out); } } // 合并后校验整文件 MD5,防止分片错序或损坏 String actual = DigestUtils.md5Hex(new FileInputStream(target)); if (!actual.equals(req.getMd5())) { target.delete(); throw new IllegalStateException("MD5 校验失败"); } // 清理临时分片目录 FileUtils.deleteDirectory(dir); return Result.ok(target.getAbsolutePath()); } }transferTo是 Spring 的MultipartFile方法,底层直接写磁盘,不会把分片读进堆内存,这是大文件场景必须用的写法。merge里用FileOutputStream(target, true)追加写入,循环按index顺序拼接。合并后必须做一次整文件 MD5 校验,因为分片可能因并发或重试导致错序,校验失败就删掉目标文件让前端重传,这是最后一道后悔药。
2.4 秒传与断点的判定逻辑
秒传的判定不能只看文件名,必须用文件内容 MD5。前端算 MD5 有成本,2GB 文件在普通笔记本上大约 3 到 5 秒,可以接受。后端收到check请求后,先查数据库里有没有相同md5且状态为“已完成”的记录,有就直接返回文件 URL。断点续传的判定则是查临时目录里已存在的.part文件,返回下标列表。两者结合,用户刷新页面后前端先check再chunks,已传分片全部跳过,只补缺失的,这就是“断点”两个字的落地。
提示:MD5 计算在前端做,后端不要重复算整文件 MD5,否则合并时又要读一遍 2GB,浪费 IO。后端只在合并后校验一次。
3. 分片上传的存储选型:本地磁盘、MinIO 还是对象存储
3.1 三种存储的适用边界
本地磁盘适合单机部署、文件量不大、没有多副本需求的场景,优点是零依赖、调试快,缺点是扩容麻烦、多实例部署时文件不共享。MinIO 适合私有化部署,兼容 S3 协议,分片上传有原生 API,minio-java客户端直接支持putObject分片和listParts断点查询。公有云对象存储适合已经上云的业务,分片上传由 SDK 封装,但要注意分片大小下限和合并超时。
| 存储方案 | 分片下限 | 断点查询 | 多实例共享 | 适用场景 |
|---|---|---|---|---|
| 本地磁盘 | 无 | 扫目录 | 否 | 单机、内网、小规模 |
| MinIO | 5MB | listParts | 是 | 私有化、中大规模 |
| 对象存储 | 5MB | listParts | 是 | 已上云、弹性扩容 |
3.2 MinIO 分片上传的接入方式
如果标题里的分片上传要落到 MinIO,常见做法是用MinioClient的putObject配合UploadObjectArgs,或者手动调createMultipartUpload、uploadPart、completeMultipartUpload三个 API。手动调的好处是能精确控制每个分片,断点续传时用listParts拿到已传分片号。
// MinIO 手动分片上传:适合需要断点续传控制的场景 MinioClient client = MinioClient.builder() .endpoint("http://127.0.0.1:9000") .credentials("minioadmin", "minioadmin") .build(); // 1. 初始化分片上传,拿到 uploadId String uploadId = client.createMultipartUpload( "my-bucket", "big-file.zip", null, null).result().uploadId(); // 2. 上传单个分片,partNumber 从 1 开始 client.uploadPart(UploadPartArgs.builder() .bucket("my-bucket") .object("big-file.zip") .uploadId(uploadId) .partNumber(partNumber) .stream(inputStream, chunkSize, -1) .build()); // 3. 查询已传分片,用于断点续传 ListPartsResponse parts = client.listParts( "my-bucket", "big-file.zip", uploadId); // 4. 合并所有分片 client.completeMultipartUpload(CompleteMultipartUploadArgs.builder() .bucket("my-bucket") .object("big-file.zip") .uploadId(uploadId) .parts(partList) // 每个 Part 带 partNumber 和 etag .build());createMultipartUpload返回的uploadId是整个分片会话的凭证,必须持久化到数据库,否则服务重启后无法恢复。uploadPart的partNumber从 1 开始,和本地磁盘方案的index从 0 开始不同,迁移时要注意这个偏移。listParts返回的Part列表里每个元素带partNumber和etag,合并时必须原样传回,etag 不匹配会报InvalidPart。
3.3 本地磁盘方案的目录结构设计
本地磁盘方案最容易翻车的地方是目录结构。我一般用/data/upload/{md5前两位}/{md5}/{index}.part两级散列,避免单个目录下文件过多导致listFiles变慢。合并后的文件放在/data/upload/final/{md5前两位}/{md5}.{ext},用 MD5 做文件名天然去重。临时分片目录在合并成功后立即删除,失败时保留供断点续传。清理策略上,超过 24 小时未合并的临时目录由定时任务删除,避免磁盘被垃圾分片占满。
注意:
listFiles在分片数超过 1000 时性能下降明显,两级散列能把单目录文件数控制在 256 以内,这是血泪经验。
4. 断点续传的进度持久化:数据库表设计与并发控制
4.1 上传任务表与分片记录表
断点续传要跨会话,进度必须落库。我一般建两张表:upload_task记录整文件任务,upload_chunk记录每个分片的状态。upload_task的md5加唯一索引,防止同一文件重复建任务;upload_chunk用(task_id, chunk_index)联合唯一索引,防止分片重复写入。
CREATE TABLE upload_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, file_md5 VARCHAR(64) NOT NULL, file_name VARCHAR(255) NOT NULL, file_size BIGINT NOT NULL, chunk_total INT NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0进行中 1已完成 2已失败 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_md5 (file_md5) ); CREATE TABLE upload_chunk ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id BIGINT NOT NULL, chunk_index INT NOT NULL, chunk_size INT NOT NULL, status TINYINT NOT NULL DEFAULT 0, -- 0未传 1已传 create_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_task_chunk (task_id, chunk_index) );upload_task的status字段是断点续传的入口:前端check时先查这张表,status=1直接秒传,status=0再查upload_chunk拿已传分片。upload_chunk的status在分片落盘成功后更新为 1,合并时只读status=1的记录。两张表的唯一索引是并发安全的底线,多个分片同时上报时靠数据库约束去重。
4.2 并发分片上传的幂等处理
前端可能并发上传多个分片,同一个分片也可能因重试被重复提交。后端处理分片时要做幂等:先insert ignore或on duplicate key update写upload_chunk,再落盘。如果分片已存在且status=1,直接返回成功,不重复写磁盘。
// 分片落盘前的幂等检查 @Transactional public void saveChunk(Long taskId, int index, MultipartFile file) throws IOException { // 先查分片记录,已传则直接返回 UploadChunk exist = chunkMapper.selectByTaskAndIndex(taskId, index); if (exist != null && exist.getStatus() == 1) { return; // 幂等:已传分片不重复处理 } // 落盘 File part = new File(getChunkDir(taskId), index + ".part"); file.transferTo(part); // 更新或插入分片记录 if (exist == null) { chunkMapper.insert(taskId, index, (int) file.getSize(), 1); } else { chunkMapper.updateStatus(exist.getId(), 1); } }@Transactional保证落盘和数据库更新在同一个事务里,落盘失败时记录不写入,避免“数据库说传了但磁盘没有”的不一致。selectByTaskAndIndex走联合唯一索引,查询是 O(1)。并发场景下两个请求同时查到exist == null,都执行insert,第二个会因唯一索引冲突抛异常,捕获后当作幂等成功处理即可。
4.3 合并时的分片完整性校验
合并前必须确认所有分片都已落盘且status=1,否则合并出来的文件是残缺的。我一般先count一下upload_chunk里status=1的记录数,和chunk_total比对,不相等直接拒绝合并。
// 合并前的完整性检查 int done = chunkMapper.countDone(taskId); if (done != task.getChunkTotal()) { throw new IllegalStateException("分片不完整: " + done + "/" + task.getChunkTotal()); }这个检查能拦住“前端以为传完了但后端少收一片”的情况。合并完成后把upload_task.status更新为 1,同时删除临时分片目录和upload_chunk记录,释放空间。如果合并失败,status保持 0,前端下次进来还能继续补分片,这就是断点续传的兜底。
5. 避坑与排查:分片上传最常见的 5 个翻车现场
5.1 分片合并后文件损坏,MD5 对不上
现象:合并成功但打开文件报错,MD5 校验失败。原因通常是分片错序或分片内容被截断。前端并发上传时,如果后端按到达顺序写文件名而不是按index命名,合并时顺序就乱了。解决:分片文件名必须用index,合并时严格按index升序读取,合并后做整文件 MD5 校验,失败就删掉重来。
5.2 断点续传查不到已传分片
现象:用户刷新页面后前端显示“已传 0 片”,实际磁盘上有分片。原因多半是chunks接口查的是数据库而分片落盘后数据库没更新,或者查的是临时目录但目录名用了taskId而前端传的是md5。解决:统一用md5作为目录名和查询键,落盘和数据库更新放在同一事务里,chunks接口优先查数据库,数据库没有时再扫目录兜底。
5.3 Nginx 413 或 Tomcat 连接重置
现象:分片上传到一半报 413 或连接被重置。原因是网关层client_max_body_size小于分片大小,或者 Tomcat 的maxSwallowSize太小。解决:Nginx 配client_max_body_size 50m;,SpringBoot 配spring.servlet.multipart.max-file-size=50MB和max-request-size=50MB,分片大小控制在配置值以内。
5.4 临时分片目录撑爆磁盘
现象:磁盘告警,/data/upload下大量.part文件。原因是用户传了一半就关页面,任务永远不合并,分片一直留着。解决:定时任务扫描upload_task里status=0且update_time超过 24 小时的任务,删除对应分片目录和记录。这个清理任务我一般用@Scheduled每小时跑一次。
5.5 秒传误判导致文件覆盖
现象:用户传了一个新文件,系统提示秒传成功,但下载下来是旧文件。原因是 MD5 碰撞或数据库里md5唯一索引没加,同一 MD5 对应多条记录。解决:upload_task的md5必须加唯一索引,秒传前校验file_size和file_name,两者都一致才返回秒传,否则走正常上传流程。
6. 进阶技巧:用 Redis 缓存分片进度和限流
分片进度查数据库在分片数多时会有压力,我一般用 Redis 做一层缓存。key 用upload:chunks:{md5},value 是已传分片下标的 Set,分片落盘成功后SADD,合并成功后DEL。前端chunks接口先查 Redis,miss 再查数据库并回填。这样高频的断点查询不会打到 MySQL。
// Redis 缓存分片进度 private static final String CHUNK_KEY = "upload:chunks:%s"; public void markChunkDone(String md5, int index) { redisTemplate.opsForSet().add(String.format(CHUNK_KEY, md5), String.valueOf(index)); redisTemplate.expire(String.format(CHUNK_KEY, md5), 24, TimeUnit.HOURS); } public Set<String> getDoneChunks(String md5) { Set<String> cached = redisTemplate.opsForSet().members(String.format(CHUNK_KEY, md5)); if (cached != null && !cached.isEmpty()) return cached; // 缓存 miss,查数据库回填 Set<String> fromDb = chunkMapper.selectDoneIndexes(md5); if (!fromDb.isEmpty()) { redisTemplate.opsForSet().add(String.format(CHUNK_KEY, md5), fromDb.toArray(new String[0])); } return fromDb; }markChunkDone在分片落盘后调用,expire设 24 小时和临时文件清理周期对齐。getDoneChunks的缓存 miss 回填逻辑保证 Redis 重启后进度不丢。限流方面,分片上传接口用 Redis 做令牌桶,单用户每秒最多 10 个分片请求,防止恶意并发打满磁盘 IO。
验证方法上,我习惯用curl模拟分片上传,先传 3 片,再查chunks接口确认返回[0,1,2],然后补传剩余分片并合并,最后比对 MD5。这套流程跑通,基本能覆盖 90% 的线上场景。踩过最深的坑是合并时没做 MD5 校验,用户下载后发现文件损坏,排查了两天才定位到是并发分片错序。从那以后,合并后校验成了我的肌肉记忆,宁可多读一遍文件,也不让坏文件流出去。希望帮到你。
本文还有配套的精品资源,点击获取