简介:这是一份面向 Java 后端开发者的 MinIO 分片上传与断点续传实战示例,针对大文件直传易超时、失败需重传等痛点,给出可直接运行的前后端完整方案。压缩包共 13 个文件,约 19KB,包含 7 个 Java 源码、2 个 JavaScript 脚本、1 个 HTML 页面、1 个 CSS 样式、1 个 Maven 的 pom.xml 及 1 个 properties 配置文件,分别承担后端接口与配置、前端分片计算与上传逻辑、页面展示等职责。后端仅引入必要依赖,启动前需核对配置中的 MinIO 服务信息;前端需留意 composeFile 函数注释,两端启动后上传文件即可验证。目前已有 20968 人学习下载,读者可从中掌握分片切分、MD5 校验、断点续传与秒传的实现思路,并借助清晰的目录结构快速定位后端服务、前端交互与配置模块,适合需要落地对象存储上传功能的中高级开发者参考。
1. 从一次 4GB 文件上传失败说起:这套 Java 分片方案到底解决什么
去年帮一个做在线教育的朋友排查问题,他们后台要传 4GB 的课程视频到 MinIO,前端用 vue-simple-uploader 做分片,后端 Java 直接putObject一把梭。结果只要网络抖一下,整个文件重传,用户骂声一片。后来换成 MinIO 官方推荐的uploadPart分片 + 断点续传方案,才算把这事按住。这套示例代码就是围绕这个场景写的:把大文件切成 5MB 以上的分片,逐片上传,中途断了能续,最后合并成完整对象。它适合两类人——一是正在用 MinIO 做文件存储、被大文件上传折磨的 Java 后端;二是想搞懂分片上传和断点续传底层逻辑、不想只调 SDK 黑匣子的工程师。下面我按「原理 → 代码 → 坑」的顺序拆一遍,代码可以直接抄。
2. MinIO 分片上传的底层机制:为什么不能直接 putObject
2.1 单次 putObject 的硬限制与分片阈值
MinIO 兼容 S3 协议,单次putObject最大只能传 5GB,而且一旦超过 100MB 左右,网络抖动导致失败的概率就直线上升。更关键的是,单次上传没有「续」的概念——失败了只能从头来。分片上传(Multipart Upload)把大文件拆成多个 Part,每个 Part 独立上传,失败只重传那一个 Part。MinIO 对分片有明确约束:单个 Part 最小 5MB(最后一片可以小于 5MB),最大 5GB,最多 10000 片。这意味着理论上你能传 5GB × 10000 = 50TB 的单个对象。实际选片大小要权衡:片太小,请求次数多,元数据开销大;片太大,单次失败重传成本高。我一般按文件大小动态算,下面代码里会体现。
2.2 断点续传的核心:uploadId 与 partNumber 的持久化
断点续传能成立,靠的是两个东西:uploadId和已上传的partNumber列表。当你调用initiateMultipartUpload时,MinIO 返回一个全局唯一的uploadId,后续所有分片都挂在这个 ID 下。每个分片上传成功后,MinIO 返回该分片的ETag。续传时,你先用listParts查出已经传了哪些片,跳过它们,只传剩下的。所以关键不是代码多复杂,而是你得把uploadId和「已传分片号 + ETag」存下来——存 Redis、存数据库、存本地文件都行,只要下次请求能拿到。很多人断点续传做不出来,不是 SDK 不会用,是忘了持久化这个状态。
2.3 初始化分片上传:initiateMultipartUpload 的参数细节
先看初始化这一步。下面代码用 MinIO Java SDK 8.5.x 的写法,创建客户端和初始化分片:
// 创建 MinIO 客户端,endpoint 换成你自己的地址 MinioClient client = MinioClient.builder() .endpoint("http://127.0.0.1:9000") .credentials("minioadmin", "minioadmin") .build(); // 初始化分片上传,返回 uploadId InitiateMultipartUploadArgs args = InitiateMultipartUploadArgs.builder() .bucket("course-video") // 存储桶名 .object("2024/lesson-01.mp4") // 对象路径,建议按日期分目录 .contentType("video/mp4") // 正确设置 MIME,否则浏览器播放可能异常 .build(); InitiateMultipartUploadResponse resp = client.initiateMultipartUpload(args); String uploadId = resp.result().uploadId(); // 这个 ID 必须持久化逻辑说明:initiateMultipartUpload不会真正传数据,只是在 MinIO 服务端登记一个「待完成」的上传任务。uploadId是后续所有操作的钥匙,丢了就只能重新初始化。参数上,contentType建议显式指定,不设的话 MinIO 默认application/octet-stream,前端播放视频时可能被当成下载而不是流式播放。object路径用日期分层,是为了避免单目录下对象过多导致 list 操作变慢——这是运维层面的经验,不是 SDK 要求。
2.4 分片上传与 ETag 收集:uploadPart 的正确调用姿势
拿到uploadId后,就可以逐片上传了。核心是uploadPart,每片要指定partNumber(从 1 开始)和uploadId:
// 假设文件已按 10MB 切片,partNumber 从 1 开始 int partNumber = 1; String etag = client.uploadPart(UploadPartArgs.builder() .bucket("course-video") .object("2024/lesson-01.mp4") .uploadId(uploadId) .partNumber(partNumber) .stream(inputStream, partSize, -1) // 流、片大小、长度-1表示读到流结束 .build()).result().etag(); // 把 partNumber 和 etag 存起来,续传时要用 partEtags.put(partNumber, etag);逻辑说明:uploadPart的stream方法第三个参数传-1表示由 SDK 自己读到流末尾,但前提是你这个流就是当前分片的流,不能是整个文件的流。常见翻车点是把整个文件流传进去,结果每片都传了整个文件。正确做法是前端或后端先按固定大小切片,每片单独构造流。etag是 MinIO 对该片内容的校验值,合并时必须按partNumber顺序提供,顺序错了合并出来的文件就是坏的。
2.5 合并分片:completeMultipartUpload 的顺序陷阱
所有片传完后,调用completeMultipartUpload合并。这里最大的坑是:Part列表必须按partNumber升序排列,否则 MinIO 报InvalidPartOrder:
// partEtags 是 TreeMap,天然按 partNumber 排序 List<Part> parts = partEtags.entrySet().stream() .map(e -> new Part(e.getKey(), e.getValue())) .collect(Collectors.toList()); client.completeMultipartUpload(CompleteMultipartUploadArgs.builder() .bucket("course-video") .object("2024/lesson-01.mp4") .uploadId(uploadId) .parts(parts) .build());逻辑说明:Part对象只包含partNumber和etag,不包含数据本身——数据早在uploadPart时就传完了,这一步只是告诉 MinIO「按这个顺序拼」。如果你用HashMap存partEtags,遍历顺序不确定,合并大概率失败。我一般直接用TreeMap<Integer, String>,省心。合并成功后,MinIO 才会真正生成完整对象,之前的分片数据会被清理。
3. 断点续传的 Java 实现:状态查询与续传逻辑
3.1 listParts 查询已上传分片:续传的起点
续传的第一步是查「已经传了哪些片」。MinIO 提供listParts,返回指定uploadId下所有已成功上传的分片:
// 查询已上传分片,用于断点续传 ListPartsResponse listResp = client.listParts(ListPartsArgs.builder() .bucket("course-video") .object("2024/lesson-01.mp4") .uploadId(uploadId) .maxParts(10000) // 最多返回 10000 片,够用 .build()); // 已传分片号集合,续传时跳过 Set<Integer> uploadedParts = listResp.result().partList().stream() .map(Part::partNumber) .collect(Collectors.toSet());逻辑说明:listParts返回的Part列表里包含partNumber、etag、size等信息。你只需要partNumber来判断哪些片已经传过。注意maxParts默认是 1000,如果你分片数超过 1000,必须显式调大,否则查不全,续传时会重复传已传的片——虽然不会导致数据错误,但浪费带宽。这个参数很多人不知道,踩过坑。
3.2 前端分片与后端续传的配合:vue-simple-uploader 的校验逻辑
前端用 vue-simple-uploader 时,它默认会先发一个GET请求问后端「这个文件传过没」。后端要返回已传分片列表,前端据此跳过。常见做法是后端暴露一个/check接口:
// 前端校验接口:返回已传分片号 @GetMapping("/upload/check") public Map<String, Object> check(@RequestParam String fileMd5, @RequestParam String objectName) { // 从 Redis 或数据库查该文件对应的 uploadId String uploadId = redis.get("upload:" + fileMd5); if (uploadId == null) { return Map.of("skipUpload", false); // 没传过,从头开始 } // 查已传分片 ListPartsResponse resp = client.listParts(...); List<Integer> uploaded = resp.result().partList().stream() .map(Part::partNumber).collect(Collectors.toList()); return Map.of("skipUpload", false, "uploadedParts", uploaded); }逻辑说明:fileMd5是前端算的文件指纹,用来唯一标识一个文件。后端用fileMd5做 key 存uploadId,这样同一个文件无论什么时候续传,都能找到之前的uploadId。注意fileMd5计算大文件时前端可能卡顿,常见做法是抽样计算(比如取文件头、中、尾各 1MB 算 MD5),牺牲一点碰撞概率换性能。这是工程折中,不是标准做法。
3.3 uploadId 的持久化方案:Redis 与数据库的取舍
uploadId存哪里,直接影响续传的可靠性。我一般用 Redis,key 设 7 天过期:
// 存 uploadId,7 天过期 redis.setex("upload:" + fileMd5, 7 * 24 * 3600, uploadId); // 同时存已传分片,避免每次都 listParts redis.hset("upload:parts:" + uploadId, String.valueOf(partNumber), etag);逻辑说明:Redis 的好处是快,适合高频查询。但 Redis 可能丢数据(比如没开持久化),所以关键业务建议同时落库。数据库方案更稳,但每次查已传分片都要走 SQL,QPS 高时压力大。折中方案是 Redis 做缓存、MySQL 做兜底:续传时先查 Redis,没有再查 MySQL 并回填 Redis。这套组合我在生产环境用过,没出过问题。
3.4 分片大小动态计算:5MB 到 100MB 的取舍
分片大小不是固定的。我一般按文件总大小动态算:
// 动态计算分片大小:目标 1000 片左右 long fileSize = file.length(); long partSize = 10 * 1024 * 1024; // 默认 10MB if (fileSize > 1024L * 1024 * 1024) { // 大于 1GB partSize = 50 * 1024 * 1024; // 50MB } if (fileSize > 10L * 1024 * 1024 * 1024) { // 大于 10GB partSize = 100 * 1024 * 1024; // 100MB } // 确保不超过 10000 片 if (fileSize / partSize > 10000) { partSize = fileSize / 10000 + 1; }逻辑说明:分片太小(比如 5MB)传 10GB 文件要 2000 次请求,元数据开销大;分片太大(比如 500MB)单次失败重传成本高。10MB 到 100MB 是经验区间。另外注意,MinIO 对小于 5MB 的非最后一片会直接拒绝,所以partSize不能低于 5MB。上面代码里fileSize / partSize > 10000的判断是为了兜底,防止极端大文件导致分片数超限。
4. 避坑与排查:分片上传最容易翻车的五个点
4.1 现象:合并时报 InvalidPartOrder
原因:completeMultipartUpload传入的Part列表没有按partNumber升序排列。很多人用HashMap存partEtags,遍历顺序不确定。解决:改用TreeMap,或者在合并前手动sort一次。这个错误 MinIO 返回的报错信息很明确,看到InvalidPartOrder直接查排序。
4.2 现象:续传时重复上传已传分片
原因:listParts的maxParts默认 1000,分片数超过 1000 时查不全,导致已传分片被当成未传。解决:显式设置maxParts(10000)。另外注意listParts返回的是分页结果,如果isTruncated为 true,需要继续用partNumberMarker翻页。这个坑在传超大文件时才会遇到,小文件测试发现不了。
4.3 现象:上传成功但文件损坏
原因:分片内容错位。常见于用同一个InputStream读整个文件,然后每片都从流头开始读。正确做法是每片单独构造InputStream,比如用RandomAccessFile按偏移量读。或者前端切片时就把每片存成独立文件,后端直接读文件。这个坑最隐蔽,因为上传过程不报错,只有播放或校验时才发现。
4.4 现象:uploadId 过期导致续传失败
原因:MinIO 对未完成的分片上传有生命周期规则,默认可能几天后自动清理。如果用户隔了一周才续传,uploadId已失效。解决:在initiateMultipartUpload后记录创建时间,续传前先listParts试探,如果报NoSuchUpload,就重新初始化。另外可以在 MinIO 桶上配置生命周期规则,延长未完成上传的保留时间。
4.5 现象:分片上传速度慢
原因:串行上传。很多人用for循环一片一片传,带宽利用率低。解决:用线程池并发上传,但注意partNumber和etag的收集要线程安全。我一般用ExecutorService固定 4 到 8 个线程,配合ConcurrentHashMap收集结果。并发数不是越大越好,太大反而因为网络拥塞变慢,8 个线程在千兆网下基本能跑满。
5. 进阶技巧:用 CompletableFuture 并发上传并校验完整性
5.1 并发上传的线程池配置与结果收集
串行上传 10GB 文件,按 10MB 一片要传 1000 次,每次 200ms 就是 200 秒。并发 8 线程能压到 30 秒左右。下面是用CompletableFuture的写法:
// 并发上传分片,线程池固定 8 个线程 ExecutorService pool = Executors.newFixedThreadPool(8); Map<Integer, String> etagMap = new ConcurrentHashMap<>(); List<CompletableFuture<Void>> futures = new ArrayList<>(); for (int i = 1; i <= totalParts; i++) { final int partNumber = i; // 跳过已传分片 if (uploadedParts.contains(partNumber)) continue; futures.add(CompletableFuture.runAsync(() -> { try (InputStream is = readPart(file, partNumber, partSize)) { String etag = client.uploadPart(UploadPartArgs.builder() .bucket("course-video") .object(objectName) .uploadId(uploadId) .partNumber(partNumber) .stream(is, partSize, -1) .build()).result().etag(); etagMap.put(partNumber, etag); // ConcurrentHashMap 线程安全 } catch (Exception e) { throw new RuntimeException("分片 " + partNumber + " 上传失败", e); } }, pool)); } // 等待所有分片完成 CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])).join(); pool.shutdown();逻辑说明:ConcurrentHashMap保证多线程写etagMap安全。readPart是自定义方法,按partNumber和partSize从文件里读对应区间,返回独立流。allOf().join()会等所有分片完成,任何一个抛异常都会让join抛出,方便统一处理。注意线程池要shutdown,否则 JVM 不退出。
5.2 合并前用 ETag 校验分片完整性
MinIO 的etag对分片来说是内容的 MD5(单分片上传时)。合并前可以本地算一遍分片的 MD5,和上传返回的etag对比,确保传输没损坏:
// 校验分片 MD5 与 etag 是否一致 String localMd5 = DigestUtils.md5Hex(readPartBytes(file, partNumber, partSize)); if (!localMd5.equals(etagMap.get(partNumber))) { throw new RuntimeException("分片 " + partNumber + " 校验失败,需重传"); }逻辑说明:DigestUtils来自 Apache Commons Codec。这一步会增加 CPU 开销,但对视频、备份这类不能损坏的文件值得做。注意 MinIO 的etag对分片来说就是 MD5,但对合并后的完整对象,etag不是简单 MD5,而是带-分片数后缀的特殊值,别搞混。
5.3 完整流程的验证方法:用 mc 命令行核对对象
上传完成后,怎么确认文件真的完整?我习惯用 MinIO 自带的mc命令行工具核对:
# 配置 mc 别名 mc alias set myminio http://127.0.0.1:9000 minioadmin minioadmin # 查看对象元信息,确认大小和 etag mc stat myminio/course-video/2024/lesson-01.mp4 # 下载回来对比 MD5 mc cp myminio/course-video/2024/lesson-01.mp4 /tmp/check.mp4 md5sum /tmp/check.mp4逻辑说明:mc stat会显示对象大小、etag、分片数等信息。如果大小和原文件一致,基本没问题。更严格的做法是下载回来算 MD5 对比。这个步骤在自动化测试里可以写成脚本,每次上传后跑一遍。
5.4 一个习惯:上传前先算文件指纹
从那以后我每次做分片上传,都强制走一遍「先算文件指纹 → 查 uploadId → 续传或新建」的流程。指纹用抽样 MD5,兼顾性能和唯一性。这个习惯帮我省了无数次「用户传了一半关浏览器,第二天重传」的投诉。希望帮到你。
本文还有配套的精品资源,点击获取