简介:面向 Java 后端开发者的一份 MinIO 分片上传与断点续传实战示例,聚焦对象存储大文件上传场景下的性能优化问题。代码保持纯净,后端仅引入必要依赖,前端配合实现分片计算与上传调度,适合已掌握 Java 基础、希望深入理解分片上传机制与断点续传实现思路的中级开发者参考。压缩包共 13 个文件,约 19KB,以 7 个 Java 源文件为核心,辅以 2 个 JavaScript 脚本、1 个 HTML 页面、1 个 CSS 样式表及 pom.xml、properties 配置文件,分别承担后端上传逻辑、前端分片与 MD5 校验、页面展示和依赖配置等职责。目前已有 20971 人学习下载,具备较高的参考热度。资源完整呈现了前后端协作的分片上传链路,读者可据此理解分片切分、合并、断点续传的实现要点,并结合配置文件对接自有 MinIO 服务进行调试与二次开发。
1. 分片上传这件事,MinIO Java SDK 的示例比文档更值得先跑一遍
去年接手一个内部资料库的迁移,几百个 G 的视频和压缩包要从旧存储搬到自建 MinIO 集群。第一版代码图省事,直接putObject一把梭,结果 2G 以上的文件十次有三次超时,断网重来还得从头传,测试同事在群里刷了一屏问号。后来翻 MinIO Java SDK 的示例,才把分片上传和断点续传这套东西补齐:大文件切成固定大小的 part 并发上传,每个 part 的 ETag 落库,失败只补传缺的那几片,最后completeMultipartUpload合并。这份示例就是围绕这条链路写的,适合正在用 Java 对接对象存储、被大文件上传折磨过的后端开发,也适合想搞清楚 S3 分片协议到底怎么落地的人。下面按「能干什么 → 怎么跑 → 核心代码 → 怎么改」拆开讲。
2. 项目定位与技术选型:为什么是 MinIO + Java SDK 这套组合
2.1 这个示例解决的真实问题
对象存储上传大文件,绕不开三个硬约束:单次请求体大小有限、网络会抖、进程会挂。MinIO 兼容 S3 协议,分片上传(Multipart Upload)是协议层就规定好的能力,Java SDK 把它封装成createMultipartUpload、uploadPart、completeMultipartUpload、abortMultipartUpload四个动作。这份示例的价值不在于教你调 API,而在于把「分片大小怎么定、并发怎么控、断点信息存哪、失败怎么续」这几个工程问题用可运行的代码串起来。它面向的是需要把文件可靠送进 MinIO 的业务系统,比如文档中心、媒资库、备份工具,而不是简单的 demo 玩具。
技术栈上,核心是 Java 8 及以上 + MinIO Java SDK(io.minio:minio),构建用 Maven,HTTP 客户端底层走 OkHttp,日志用 SLF4J 门面。示例里通常还会带一个轻量的配置读取(properties 或环境变量)和一个本地状态存储(文件或数据库)来记录分片进度。这套组合的好处是依赖少、可嵌入,扔进 Spring Boot 项目或者纯命令行工具都能跑。
2.2 目录结构长什么样
一个能跑的分片上传示例,目录一般是这样组织的,读源码前先有个地图:
minio-multipart-demo/ ├── pom.xml # 依赖:minio、slf4j、okhttp ├── src/main/java/com/example/minio/ │ ├── MinioClientFactory.java # 构建 MinioClient,读 endpoint/ak/sk │ ├── MultipartUploader.java # 分片上传主流程 │ ├── ResumeManager.java # 断点信息读写(ETag、partNumber) │ ├── UploadState.java # 上传会话状态模型 │ └── DemoApplication.java # main 入口,演示调用 └── src/main/resources/ └── application.properties # endpoint、bucket、分片大小、并发数MultipartUploader是主链路,ResumeManager是断点续传的关键,UploadState承载 uploadId 和已完成分片列表。实际项目里ResumeManager的实现会换成 Redis 或 MySQL,示例用本地文件是为了让你先跑通再替换。
2.3 依赖与配置项
pom.xml里最关键的三个依赖,版本按你项目实际锁定的来,示例不写死具体小版本号:
<dependencies> <!-- MinIO Java SDK,分片上传 API 都在这里 --> <dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>${minio.version}</version> </dependency> <!-- SDK 底层 HTTP 客户端,显式引入便于调超时 --> <dependency> <groupId>com.squareup.okhttp3</groupId> <artifactId>okhttp</artifactId> <version>${okhttp.version}</version> </dependency> <!-- 日志门面,SDK 内部会打请求日志 --> <dependency> <groupId>org.slf4j</groupId> <artifactId>slf4j-api</artifactId> <version>${slf4j.version}</version> </dependency> </dependencies>配置项集中在application.properties,这几个参数直接决定上传行为,改之前先理解含义:
| 配置项 | 含义 | 常见取值 |
|---|---|---|
minio.endpoint | MinIO 服务地址,带协议和端口 | http://127.0.0.1:9000 |
minio.accessKey/secretKey | 访问凭证 | 由部署方分配 |
minio.bucket | 目标桶名,需提前创建 | 业务自定义 |
upload.partSize | 单个分片字节数,S3 协议要求最小 5MB(最后一片除外) | 10 * 1024 * 1024 |
upload.concurrency | 并发上传分片数 | 4~8,视带宽和客户端资源 |
提示:
partSize不是越小越好。分片太小请求数暴涨,元数据开销和失败重试成本都上去了;太大则单请求超时风险高、断点粒度粗。10MB 到 50MB 是多数场景的舒适区。
3. 跑起来:从零到一次完整分片上传
3.1 准备 MinIO 与桶
本地验证最省事的方式是用容器起一个单机 MinIO,映射 API 端口和控制台端口:
# 启动单机 MinIO,数据挂到本地目录 docker run -d --name minio-demo \ -p 9000:9000 -p 9001:9001 \ -e "MINIO_ROOT_USER=minioadmin" \ -e "MINIO_ROOT_PASSWORD=minioadmin" \ -v /data/minio:/data \ minio/minio server /data --console-address ":9001"启动后访问http://127.0.0.1:9001用上面的账号登录,手动建一个桶,比如demo-bucket。桶必须提前存在,SDK 的uploadPart不会帮你建桶,这是新手第一个容易卡住的地方。凭证用默认的minioadmin/minioadmin只适合本地,任何共享环境都要换掉。
3.2 构建客户端
MinioClientFactory负责把配置变成可复用的客户端实例。客户端本身是线程安全的,全应用一个实例就够,不要每次上传都 new 一个:
public final class MinioClientFactory { // 客户端内部持有连接池,全局单例复用,避免频繁建连 public static MinioClient create(Properties props) { return MinioClient.builder() .endpoint(props.getProperty("minio.endpoint")) .credentials( props.getProperty("minio.accessKey"), props.getProperty("minio.secretKey")) // 自定义 OkHttp,调大连接池和超时,适配大分片 .httpClient(OkHttpClientBuilder.build()) .build(); } }endpoint必须带http://或https://,只写 IP 端口会抛参数异常。credentials两个参数顺序是 accessKey 在前。自定义httpClient那一步是为了把读超时从默认值调大,分片上传单请求可能持续几十秒,默认超时容易误杀。
3.3 一次完整上传的调用链
DemoApplication的 main 方法把流程串起来:读配置、建客户端、调MultipartUploader.upload。上传器内部的动作顺序是固定的,理解这个顺序比记 API 名重要:
public String upload(File file, String objectName) throws Exception { long fileSize = file.length(); long partSize = Long.parseLong(props.getProperty("upload.partSize")); // 1. 计算分片数,向上取整 int partCount = (int) ((fileSize + partSize - 1) / partSize); // 2. 尝试恢复:有历史 uploadId 就续传,没有就新建会话 UploadState state = resumeManager.load(objectName); String uploadId = (state != null) ? state.getUploadId() : client.createMultipartUpload(bucket, null, objectName, null, null).uploadId(); // 3. 并发上传未完成的分片 uploadPartsConcurrently(file, objectName, uploadId, partSize, partCount, state); // 4. 合并分片,得到最终对象 String etag = complete(objectName, uploadId, partCount); resumeManager.clear(objectName); // 成功后清理断点记录 return etag; }这段是主骨架,几个点值得说清:partCount用(fileSize + partSize - 1) / partSize算,避免整除丢尾片;恢复逻辑先查断点再决定新建还是续传,这是断点续传的入口;合并前必须确保所有分片都成功,缺一片complete就会报InvalidPart。createMultipartUpload的参数依次是桶、区域(可空)、对象名、请求头、额外参数,示例传 null 即可。
3.4 分片并发上传的实现
并发上传是性能关键,用固定大小线程池控制并发度,每个任务负责一片:
private void uploadPartsConcurrently(File file, String objectName, String uploadId, long partSize, int partCount, UploadState state) throws Exception { ExecutorService pool = Executors.newFixedThreadPool( Integer.parseInt(props.getProperty("upload.concurrency"))); List<Future<?>> futures = new ArrayList<>(); for (int partNumber = 1; partNumber <= partCount; partNumber++) { // 已完成的分片直接跳过,这就是断点续传省下的流量 if (state != null && state.isCompleted(partNumber)) { continue; } final int pn = partNumber; futures.add(pool.submit(() -> { long offset = (long) (pn - 1) * partSize; long length = Math.min(partSize, file.length() - offset); try (InputStream in = new FileInputStream(file)) { in.skip(offset); // 定位到本片起始位置 UploadPartArgs args = UploadPartArgs.builder() .bucket(bucket).object(objectName) .uploadId(uploadId).partNumber(pn) .stream(in, length).build(); Part part = client.uploadPart(args); resumeManager.markCompleted(objectName, pn, part.etag()); } catch (Exception e) { throw new RuntimeException("part " + pn + " failed", e); } })); } // 等待全部完成,任一失败则整体失败,交由上层决定重试 for (Future<?> f : futures) { f.get(); } pool.shutdown(); }partNumber从 1 开始,这是 S3 协议规定,从 0 开始会直接报错。stream(in, length)的 length 必须是本片实际字节数,最后一片通常小于partSize,写死partSize会导致读取越界或数据错位。每片成功后立刻markCompleted落 ETag,进程崩了下次能跳过。线程池大小别超过 8,客户端带宽和 MinIO 服务端连接数都是瓶颈,开太大反而互相拖慢。
4. 核心模块拆解:断点续传的状态管理与合并
4.1 UploadState 与 ResumeManager 的设计
断点续传的本质是「把上传会话的中间状态持久化」。UploadState至少要有三个字段:uploadId、objectName、已完成分片的partNumber → etag映射。ResumeManager提供load、markCompleted、clear三个动作,示例用本地 JSON 文件实现,生产环境换成 Redis 更合适:
public class UploadState { private String uploadId; private String objectName; // key 是 partNumber,value 是 ETag,合并时要用 private Map<Integer, String> completedParts = new ConcurrentHashMap<>(); public boolean isCompleted(int partNumber) { return completedParts.containsKey(partNumber); } }用ConcurrentHashMap是因为多个上传线程会并发写同一个 state,普通 HashMap 在高并发下会丢数据甚至死循环。ETag 必须存,completeMultipartUpload时要按 partNumber 顺序把每个 Part 的 etag 传回去,缺一个都合并不了。
4.2 合并分片与清理
合并是最后一步,把所有 Part 按编号排序后提交:
private String complete(String objectName, String uploadId, int partCount) throws Exception { List<Part> parts = new ArrayList<>(); UploadState state = resumeManager.load(objectName); for (int i = 1; i <= partCount; i++) { String etag = state.getCompletedParts().get(i); if (etag == null) { throw new IllegalStateException("part " + i + " missing, abort"); } parts.add(new Part(i, etag)); } // 按 partNumber 升序,顺序错了合并结果会错乱 parts.sort(Comparator.comparingInt(Part::partNumber)); return client.completeMultipartUpload( CompleteMultipartUploadArgs.builder() .bucket(bucket).object(objectName) .uploadId(uploadId).parts(parts).build()) .etag(); }Part构造参数是编号和 ETag,顺序必须和上传时一致。合并成功后立刻resumeManager.clear,否则下次同名上传会误用旧 uploadId。如果合并前发现缺片,正确做法是abortMultipartUpload清掉服务端残留,而不是干等——未完成的分片会一直占存储,时间久了桶里全是看不见的垃圾。
4.3 失败重试与超时控制
网络抖动是常态,重试要落在分片粒度而不是整个文件。常见做法是给每个分片任务包一层有限次重试,指数退避:
private Part uploadWithRetry(UploadPartArgs args, int maxRetry) throws Exception { int attempt = 0; while (true) { try { return client.uploadPart(args); } catch (Exception e) { if (++attempt >= maxRetry) throw e; // 退避 1s、2s、4s,避免瞬时打爆服务端 Thread.sleep(1000L << (attempt - 1)); } } }重试次数 3 次左右够用,再多说明网络或服务端有系统性问题,该报警而不是硬扛。注意UploadPartArgs里的 stream 在重试时可能已被消费,稳妥做法是每次重试重新打开文件流定位 offset,示例里把流创建放在任务内部就是为了这个。
5. 避坑与排查:分片上传最容易翻车的几个点
5.1 分片小于 5MB 报 EntityTooSmall
现象:上传小文件正常,稍大一点合并时报EntityTooSmall。原因:S3 协议规定除最后一片外,每个分片不得小于 5MB,示例里partSize配成了 1MB。解决:把upload.partSize调到 5MB 以上,推荐 10MB 起步;如果文件本身小于分片大小,直接走单次putObject更划算。
5.2 断点续传后合并报 InvalidPart
现象:中断后重启续传,所有分片都显示成功,合并却失败。原因:续传时用了新的 uploadId,但旧分片的 ETag 属于旧会话,两者对不上。解决:ResumeManager.load必须把 uploadId 和分片记录一起读出来,续传全程复用同一个 uploadId;uploadId 失效(比如服务端已 abort)时清空记录重新开始。
5.3 并发过高导致连接被拒
现象:并发调到 16 后大量Connection reset或SocketTimeoutException。原因:客户端连接池和服务端连接数都被打满,OkHttp 默认连接数有限。解决:并发控制在 4~8,同时调大 OkHttp 的maxRequestsPerHost和连接池大小,并确认 MinIO 服务端没有限流。
5.4 上传成功但对象大小不对
现象:合并成功,下载下来文件比原文件小或尾部损坏。原因:最后一片的stream(in, length)里 length 写成了固定partSize,多读了不存在的字节。解决:length 一律用Math.min(partSize, fileSize - offset)计算,最后一片按实际剩余字节传。
5.5 残留分片占满存储
现象:桶的用量持续上涨,但对象列表里看不到对应文件。原因:失败的上传没有 abort,未完成的分片在服务端是隐藏状态但占空间。解决:捕获异常时调abortMultipartUpload,并定期用生命周期规则清理超过 N 天未完成的分片上传。
6. 二次开发:把它改成能进生产的断点续传组件
示例跑通只是起点,真正落地要解决三件事:状态存储换掉、上传入口做成可复用组件、验证手段补齐。我一般先把ResumeManager抽成接口,本地文件实现只留作测试,生产用 Redis:
public interface ResumeManager { UploadState load(String objectName); void markCompleted(String objectName, int partNumber, String etag); void clear(String objectName); }Redis 实现里 key 用upload:{bucket}:{objectName},value 存 uploadId,分片 ETag 用 Hash 结构,HSET天然支持并发写单字段,比整份 JSON 覆盖写安全得多。过期时间设 7 天,配合服务端生命周期规则兜底。
第二个改造点是把上传器做成 Spring Bean,暴露一个upload(InputStream, objectName, size)方法,内部根据 size 自动选单次上传还是分片,调用方不用关心阈值。分片大小也别写死,按文件大小动态算:小于 100MB 用 10MB 分片,更大的用 50MB,控制总片数在 1000 以内,避免complete请求体过大。
验证方法上,别只测成功路径。我习惯做三个用例:上传中途 kill 进程再重启,确认能从断点继续且最终文件 MD5 一致;把并发调到 1 和 8 各跑一遍,对比耗时确认并发确实生效;人为把某片 ETag 改错,确认合并会失败并触发 abort。这三个用例覆盖了断点、性能、一致性三条线。
血泪经验是:断点续传的坑几乎都出在「状态和实际不一致」上——记录说传完了但服务端没有,或者服务端有但记录丢了。所以每次续传开始前,先调listParts跟本地记录对一遍账,比盲目相信本地状态靠谱得多。从那以后我每次接对象存储上传,都强制先跑一遍「杀进程续传 + MD5 校验」这条链路,再谈别的优化。希望帮到你。
本文还有配套的精品资源,点击获取