简介这是一个面向Java开发者的MinIO分片上传与断点续传完整示例聚焦大文件分段上传、断点恢复与前后端交互场景适合需要在业务系统中集成MinIO可靠上传能力的工程师。资源包仅引用必要jar包后端启动时核对配置文件与MinIO服务信息一致即可前端启动前阅读composeFile注释前后端运行后直接上传文件即可验证效果。包内共13个文件以7个Java源文件为主覆盖后端上传逻辑与测试代码另有2个JavaScript文件实现前端分片与Spark-md5指纹计算配套HTML/CSS页面和pom.xml、properties等配置整体约19KB结构紧凑便于快速迁移。通过前端切片与后端临时分片持久化可应对网络中断后的续传需求适合文件较大、网络不稳定的业务环境。目前已有20968人学习或下载示例经过验证的调用路径与精简实现能显著降低分片上传开发门槛并提供断点续传的关键思路供直接参考。1. Java 上传大文件到 MinIO分片、并发与断点续传差在哪一个 2GB 的文件传到一半断网进度条清零这种经历做后端的人多少都遇过。Java 往 MinIO 上传大文件如果只是 new 一个MinioClient然后putObjectSDK 确实会自动分片但自动分片的并发度、失败重试和进程重启后的恢复能力不一定适合你的场景。这份 MinIO Java 分片上传示例核心解决三件事分片参数怎么定、断点之后怎么续、合并之前怎么验。适合带宽不稳定、文件超过 1GB、或者需要自己掌握上传进度的 Java 后端开发者。下文代码基于 MinIO 的 Java SDK 8.x 写法。2. 搭环境与初始化依赖选型、客户端参数和 Bucket 准备2.1 Maven 依赖怎么选才不会踩到 okhttp 冲突MinIO 官方 Java SDK 的坐标是io.minio:minio它内部依赖 okhttp。很多工程的 Spring Boot 版本已经自带 okhttp 3.x而 MinIO 8.x 默认用 okhttp 4.x两者包名相同但 API 有差异初始化时容易报NoSuchMethodError或类加载冲突。我一般会显式统一 okhttp 版本让 MinIO 的调用链保持一致。dependency groupIdio.minio/groupId artifactIdminio/artifactId version8.5.7/version /dependency dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependency如果只是跑独立示例工程第二段 okhttp 可以不写SDK 传递依赖会带上来。一旦工程里同时存在 Web 框架和 MinIO SDK建议把 okhttp 显式写在dependencyManagement里避免两个版本的类互相覆盖。还有一个容易被忽略的点MinIO SDK 8.x 要求 Java 8 及以上但分片上传示例里我用了var、Path.of这类 API所以本地 JDK 至少是 11推荐直接用 17。这里不建议选太新的 SDK 版本去看花哨功能。分片上传依赖的createMultipartUpload、uploadPart、completeMultipartUpload这几个底层方法在 8.x 里已经很稳定。版本号以你本地mvn dependency:tree最终解析到的为准不是越高越好。2.2 初始化 MinioClientendpoint、凭证与超时边界MinioClient 初始化代码很短短到容易被人忽略。endpoint 要写协议加地址加端口不要漏http://也不要画蛇添足把对象路径拼进去。凭证在本地开发可以用默认的minioadmin生产环境建议单独建 AccessKey并只授权给目标 Bucket。import io.minio.MinioClient; import okhttp3.OkHttpClient; import java.util.concurrent.TimeUnit; OkHttpClient okHttpClient new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) .writeTimeout(5, TimeUnit.MINUTES) .readTimeout(5, TimeUnit.MINUTES) .build(); MinioClient minioClient MinioClient.builder() .endpoint(http://127.0.0.1:9000) .credentials(minioadmin, minioadmin) .httpClient(okHttpClient) .build();这里httpClient参数很容易被跳过但分片上传场景下我建议显式设置。MinIO 默认的 okhttp 超时对一次完整的小文件上传够用分片上传时每个分片可能因为网络抖动拖到几十秒读写超时太短会频繁触发重试超时太长又会让连接一直挂着。写超时和读超时给 5 分钟是我常用的起始值分片大小 64MB 时基本够。关于 region 有个容易误会的点。MinIO 本地部署通常不校验 region但不代表可以不设置。如果服务端配置了MINIO_REGION而客户端没设置部分 Bucket 操作会返回AuthorizationHeaderMalformed。示例代码里不传 region是让服务端决定生产环境建议在MinioClient.builder()里把 region 写死避免机房迁移后行为漂移。初始化参数作用建议值endpoint协议 地址 端口不带 Bucket 路径credentialsAccessKey / SecretKey单独的最小权限账号httpClient连接与读写超时写/读超时 5 分钟region区域标识服务端强相关时必填2.3 创建 Bucket 与对象名前缀约定上传前先确认 Bucket 存在成本低收益高。不存在时创建已存在时跳过这段逻辑放在应用启动时执行一次即可不需要每次上传都做。import io.minio.BucketExistsArgs; import io.minio.MakeBucketArgs; boolean exists minioClient.bucketExists( BucketExistsArgs.builder().bucket(uploads).build()); if (!exists) { minioClient.makeBucket( MakeBucketArgs.builder().bucket(uploads).build()); }对象名不建议只放文件名。分片上传完成后同一个 Bucket 里会出现最终对象和临时分片对象命名上要能一眼区分。我习惯用2024/demo/large-file.zip作为最终对象名临时分片对象用2024/demo/large-file.zip.part前缀这样清理和续传都方便。bucketExists和makeBucket返回的是布尔值或空结果不需要额外 catch 业务异常连接失败时底层会抛ErrorResponseException在调用方统一处理即可。把MinioClient当作单例复用不要每次上传都 newSDK 内部维护连接池复用同一个实例能显著减少握手开销。这个点对“最佳性能”的贡献比调大线程池更直接。3. 分片上传核心流程从文件计算分片到并发提交3.1 分片大小与分片数量公式不能想当然大文件上传的第一步不是读文件而是算分片。拿到文件总大小确定partSize再算出partCount。分片大小不能随便拍也不能无限调大。import java.nio.file.Files; import java.nio.file.Path; Path filePath Path.of(/data/large-file.zip); long fileSize Files.size(filePath); long partSize 64 * 1024 * 1024; // 64MB int partCount (int) ((fileSize partSize - 1) / partSize);这个公式是向上取整不是简单整除。文件刚好 64MB 的整数倍时除法结果就是准确分片数文件是 100MB 时直接除会得到 1还要再补一个分片所以(fileSize partSize - 1) / partSize是标准写法。分片大小怎么选取决于文件大小和带宽。MinIO 的 S3 兼容接口要求除最后一个分片外每个分片不能小于 5MB分片数量上限是 10000。对 1GB 文件64MB 分片只有 16 片对 10GB 文件64MB 分片是 160 片都在限制内。我通常在 16MB 到 64MB 之间选带宽高用 64MB带宽差用 16MB。文件大小推荐分片大小分片数1GB16MB642GB32MB645GB64MB8010GB64MB160分片太大单片失败重传成本高分片太小请求数量多吞吐被网络往返拖慢。一个反直觉现象在对等网络里64MB 分片不一定比 16MB 分片慢因为瓶颈经常在服务端合并和本地磁盘读写而不是单次请求体量。3.2 初始化分片上传uploadId 是整个续传的根分片上传和普通上传的区别是先向服务端申请一个uploadId。后续每个分片都挂在同一个uploadId下合并时也靠它把散片拼成完整对象。uploadId丢了前面的分片全变孤儿数据。CreateMultipartUploadResponse initResponse minioClient.createMultipartUpload( bucketName, null, objectName, null, null); String uploadId initResponse.result().uploadId();四个参数依次是 bucket、region、objectName、额外 header、额外 query。region 传null表示交给服务端分片场景用不到额外 header 和 query所以留空。不同 SDK 版本对createMultipartUpload的返回类型可能不同有的直接返回String有的返回响应对象认准响应里的uploadId()即可。uploadId要立刻持久化不能只放在内存变量里。后面断点续传时如果进程重启这个uploadId是唯一能定位旧分片的凭据。这个初始化请求本身也要放在重试逻辑里因为服务端暂时不可用时MinioClient会抛出连接异常重试间隔建议指数退避不要每秒打一次。3.3 并发上传线程池怎么配、Part 信息怎么收拿到uploadId之后可以按分片号并发上传。每个分片是一次独立的 HTTP 请求线程池大小必须可控。见过有人一次性丢 200 个任务进newFixedThreadPool(200)结果本地 socket 和内存先撑不住。import java.io.ByteArrayInputStream; import java.io.RandomAccessFile; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.concurrent.*; ExecutorService pool Executors.newFixedThreadPool(8); ListFuturePart futures new ArrayList(); for (int partNumber 1; partNumber partCount; partNumber) { final int pn partNumber; futures.add(pool.submit(() - { long offset (long) (pn - 1) * partSize; int length (int) Math.min(partSize, fileSize - offset); byte[] data readRange(filePath, offset, length); ObjectWriteResponse resp minioClient.uploadPart( bucketName, null, objectName, uploadId, pn, new ByteArrayInputStream(data), length, null, null); return new Part(pn, resp.etag()); })); } ListPart parts new ArrayList(); for (FuturePart future : futures) { parts.add(future.get()); } pool.shutdown();private static byte[] readRange(Path path, long offset, int length) throws IOException { try (RandomAccessFile raf new RandomAccessFile(path.toFile(), r)) { raf.seek(offset); byte[] buf new byte[length]; raf.readFully(buf); return buf; } }uploadPart参数顺序是 bucket、region、objectName、uploadId、partNumber、输入流、长度、header、query。partNumber 从 1 开始不能从 0 开始这是 S3 协议的习惯也是合并排序的依据。resp.etag()返回每个分片的 ETag合并时必须原样带回不能自己改格式。代码为了清晰把每个分片读成byte[]适合 1GB 左右文件。超过 2GB 时64 个 64MB 分片同时留在堆里就是 4GB内存大概率扛不住工程上建议改成自己实现的InputStream只读取当前分片的字节区间或者用两条线程做双缓冲一个线程预读下一个分片另一个线程上传当前分片。线程池大小建议从 CPU 核数的 2 倍开始试看吞吐再调整。公网带宽不对等时线程池开再大也没用反而超时重试增多。3.4 上传后的 ETag合并前先看清楚每个分片上传完成后MinIO 会返回一个 ETag。它不只是个字符串合并时会用它校验分片是否完整、是否被篡改。for (Part p : parts) { System.out.println(part p.partNumber() etag p.etag()); } if (parts.size() ! partCount) { throw new IllegalStateException(分片数量不一致不能进入合并); }ETag 是排查InvalidPart的关键线索。合并报错时先把这批 ETag 打出来和 MinIO 服务端返回的 ETag 对比。常见情况是 ETag 带了空格、引号或者某个分片重传后 ETag 没更新。传输层没有校验问题基本都出在这一层。4. 断点续传与合并从清单恢复到 completeMultipartUpload4.1 进度持久化用清单文件代替内存状态断点续传最忌讳把进度放在内存里。进程一重启进度归零分片上传就名存实亡。示例项目的做法是每个分片上传成功后就往本地清单文件里追加一条记录记录包含uploadId、分片号、ETag、分片大小。{ bucket: uploads, object: 2024/demo/large-file.zip, uploadId: Mjk2MzQ3, partSize: 67108864, fileSize: 2147483648, parts: [ { partNumber: 1, etag: etag-01 }, { partNumber: 2, etag: etag-02 }, { partNumber: 3, etag: etag-03 } ] }清单文件写到哪里有讲究。要写到应用有写权限的本地磁盘不要写到 MinIO 的同一个 Bucket。本地进程崩了磁盘还在清单还在写到远端可能连清单都拿不回来。清单的作用不只是记录成功分片还记录uploadId。恢复时先读清单拿着uploadId去确认服务端任务是否还活着能省一次重新初始化的开销。更新清单时不要等到所有分片完成才整体覆盖。我习惯每成功一个分片就追加一条并在写入后用系统调用刷盘。这样做即便中途再次崩溃最多丢最后一个分片的记录重传一个分片的成本远低于重新扫一遍服务端。4.2 续传判断哪些分片要重传续传逻辑不是把所有分片重新跑一遍而是先读清单跳过已成功的分片。第一步是加载清单构建“已完成分片号”集合。SetInteger doneNumbers loadDonePartNumbers(); ListPart parts new ArrayList(loadDoneParts()); for (int partNumber 1; partNumber partCount; partNumber) { if (doneNumbers.contains(partNumber)) { continue; } long offset (long) (partNumber - 1) * partSize; int length (int) Math.min(partSize, fileSize - offset); ObjectWriteResponse resp uploadOnePart(uploadId, partNumber, offset, length); Part part new Part(partNumber, resp.etag()); parts.add(part); appendProgressRecord(part); }这里有三个边界情况要处理。第一续传前确认uploadId是否有效。MinIO 对未完成的 multipart 任务有生命周期管理服务端重启、Bucket 策略变更或超过清理时间后uploadId会失效。判断失效的方法是用 SDK 提供的 multipart 查询接口或者直接尝试上传一个最小分片看是否报NoSuchUpload。失效就重新createMultipartUpload清空旧清单。第二清单里的分片号和 ETag 只能说明上次上传成功不能说明分片内容没变化。如果本地源文件在上传过程中被替换续传前要把旧清单作废否则新旧内容混在一个对象里。第三重复上传同一个分片号服务端会覆盖旧分片但旧 ETag 还留在清单里。如果不更新合并时会出现 ETag 不一致。所以每次重传后必须同步更新清单不能只写新分片。4.3 合并分片completeMultipartUpload 的入参顺序所有分片上传完成后发起合并请求。这一步在服务端进行把所有分片按序号拼成完整对象。合并时传的Part数组必须和上传返回的 ETag 一一对应。Part[] partArray parts.stream() .sorted(Comparator.comparingInt(Part::partNumber)) .toArray(Part[]::new); ObjectWriteResponse response minioClient.completeMultipartUpload( bucketName, null, objectName, uploadId, partArray, null, null);这里显式sorted是故意的。分片是并发上传的parts集合的顺序取决于 Future 完成的先后不排序直接合并等于把正确性交给运气。把排序放在显眼位置少一个翻车点。合并成功后返回的 ETag 对 multipart 对象会带-和分片数后缀比如abc123-16。看到这个后缀不要慌说明合并且分片成功。还有一个细节如果某个分片小于 5MB 且不是最后一个服务端会在合并时报错所以分片参数在第一步就要算对合并阶段没有后悔药。4.4 合并后校验文件大小不等于没缺块合并完成后第一步看对象大小第二步看对象能不能正常读取。StatObjectResponse stat minioClient.statObject( StatObjectArgs.builder() .bucket(bucketName) .object(objectName) .build()); System.out.println(remote size stat.size()); System.out.println(expect size fileSize);如果stat.size()不等于fileSize说明合并对象是残的。先检查 partCount 是否算错再检查是否有分片被跳过。很多completeMultipartUpload报错并不是网络问题而是Part[]里少了最后一片服务端合并出一个短一截的对象而且这种错不一定在合并时暴露。合并成功后还要顺手清理临时分片对象和本地清单文件。我是用一个带.part前缀的临时目录来放分片合并成功后用removeObject批量删除避免垃圾对象占存储。清理要放在stat校验通过之后校验没过就保留现场方便排查。5. 分片上传避坑并发、超时与失效 uploadId 的排查记录以下几条都是实际项目中踩过的按“现象→原因→解决”写出问题时可以直接对照。5.1 并发太高反而慢还内存溢出现象线程池从 16 调到 64上传速度没提升GC 频繁最终 OOM。原因每个分片上传时代码先把分片读成byte[]再包成ByteArrayInputStream。64 个 64MB 分片同时留在堆里就是 4GB还没算 SDK 内部缓冲和 okhttp 的响应缓冲。客户端和服务端在同一个机器时瓶颈常常是内存和磁盘不是并发数。解决线程池不要无限调大。分片读取改成流式一次只保留少量分片缓冲。实测 8 线程对 1GB 文件足够10GB 文件用 16 线程也算到顶。想继续压榨性能把分片预读和上传拆成两步形成双缓冲。5.2 分片小于 5MB 导致 EntityTooSmall现象上传时报EntityTooSmall提示每个分片必须至少 5MB但日志里的文件明明是 100MB。原因S3 兼容协议要求除最后一个分片外每个分片不能小于 5MB。partSize设成 4MB按公式算出来 25 个分片前 24 个都小于 5MB自然报错。解决partSize至少 5MB。为避免边界问题统一用 8MB、16MB、64MB 这类 2 的幂不用 7MB 或 10MB 这种不整齐的数字。最后一个分片可以小于 5MB这是协议允许的例外。5.3 重启后恢复失败uploadId 已被服务端清理现象进程崩溃重启读取本地清单里的uploadId去续传结果报NoSuchUpload。原因MinIO 对未完成的 multipart 任务有清理机制服务端重启、配置变更或空闲时间过长uploadId都会失效孤儿分片被定期清理。解决续传前先确认uploadId是否有效无效就重新初始化并重置清单。另一个习惯是清单里记录任务开始时间超过 6 小时的任务直接放弃重来。在大文件场景下分片任务拖着比重传更消耗资源。5.4 合并时报 InvalidPartETag 与 Part 顺序对不上现象所有分片都上传成功清单也完整completeMultipartUpload却抛InvalidPart。原因Part[]里某个分片的 ETag 不对。常见来源有两种一是分片上传失败后重试旧 ETag 被写进清单但没更新二是把分片号当参数传入时写反了。解决合并前把Part[]按分片号排序再和本地清单逐个比对。调试时把清单里的 ETag 全量打印出错时能快速定位到第几个分片。服务端报错信息会带期望的 ETag拿那个值和打印值对比差异一眼可见。5.5 重复上传同名对象最终对象是旧的现象多次上传同一个对象名合并完成后stat看到的是旧大小或者时间戳是旧时间。原因completeMultipartUpload会把分片合并成完整对象但它不关心对象名前一次是谁在写。如果续传逻辑重新初始化了uploadId而对象名一致旧 multipart 的合并结果可能被覆盖也可能反过来覆盖新结果。解决对象名里带任务标识比如2024/demo/large-file-uploadId/file.zip。合并成功后先用stat校验再清理临时分片。这样即使两次上传冲突也不会把生产对象搞坏。6. 提升上传可靠性的进阶技巧合并后的完整性验证与状态感知这里的完整性验证不是简单比对 size。上传场景下用户真正关心的是文件能不能打开、内容是否完整。我在示例项目里加了第二层验证合并后statObject确认大小再从本地源文件随机抽样几个区间用getObject下载远端对应区间逐字节比对。import io.minio.GetObjectArgs; StatObjectResponse stat minioClient.statObject( StatObjectArgs.builder() .bucket(bucketName) .object(objectName) .build()); if (stat.size() ! fileSize) { throw new IllegalStateException(merge size mismatch); } try (InputStream in minioClient.getObject( GetObjectArgs.builder() .bucket(bucketName) .object(objectName) .offset(0) .length(1024 * 1024) .build())) { byte[] remote in.readNBytes(1024 * 1024); byte[] local readRange(filePath, 0, remote.length); if (!Arrays.equals(remote, local)) { throw new IllegalStateException(head range mismatch); } }抽样比对不用全量下载取文件开头、中间、结尾各 1MB 就足够。公网低带宽场景下这层验证能拦住“文件大小对但内容错位”的诡异问题。第二个建议是给 MinIO 开启事件通知。上传完成后服务端向配置的 Webhook 或消息队列投递事件Java 后端监听后做转码、解析、入库等后续处理不用客户端傻等结果。completeMultipartUpload成功的事件意味着对象已经落盘这比轮询statObject更高效也把上传和处理解耦。我固定下来的检查习惯是初始化前确认 bucket 存在分片前把partSize和partCount写进日志每个分片成功后立即更新本地清单合并前强制排序合并后先stat再抽样getObject。从那以后每次跑分片上传我都会强制走一遍这五步宁可多花几秒校验也不让坏文件悄悄混上线。希望这次拆解能帮到你。本文还有配套的精品资源点击获取