Java实现MinIO分片上传:从原理到工具类实战
2026/9/9 2:56:40 网站建设 项目流程

写这篇的起因,是上周帮朋友调一个内网文件上传接口。他们上传一个1.2GB的文件,客户端用的是最朴素的MultipartFileInputStream之后一把put。在办公室网络里测试一切正常,结果到了真机环境传了十几分钟,中间断了一次,整个文件直接上传失败,日志里只剩一行timeout。后来我把这部分改成分片上传,同样的网络环境,从“一半概率失败”变成了“基本都能传上去”。这篇文章就把这套基于 Java 的 MinIO 分片上传工具类和测试 demo 的完整思路写下来,包括为什么非分片不可、SDK 底层机制、工具类怎么设计、用什么方式验证,以及我实际调试中踩过的几个坑。

1. 大文件直接PUT上传,为什么迟早会出事

先说结论:大文件不是不能直接传,而是不可靠。这里说的“不可靠”不是 MinIO 本身的问题,而是网络、内存、超时这些现实因素叠加在一起,导致简单的单请求上传在文件体积变大之后会越来越难用。

1.1 整个文件读进内存是不可控的

很多第一次接触对象存储的人,写上传代码的时候很自然就想到“把文件转成 byte[],再交给 SDK”。文件小的时候没问题,文件一旦到了 500MB、1GB,问题立刻浮现。一个byte[]会占用连续内存,JVM 堆稍微小一点就直接OutOfMemoryError,堆调得大一点又会影响整台机器的 GC 表现。就算你用的是InputStream,只要 SDK 底层为了计算 MD5 或者做重试把流缓存了,大对象照样会把内存吃穿。

你可能说“我用FileInputStream传就行了”,确实可以,但这只是解决了内存路径,没有解决网络路径的问题。

1.2 网络抖动一次,整批重来

单请求上传大文件,意味着这个 HTTP 连接要保持很久。内网环境可能还好,一旦经过公网、跨机房、或者中间有防火墙和负载均衡,长连接很容易被某些网络设备掐掉。TCP 层断了,客户端可能根本感知不到,等服务端迟迟没有响应、触发超时的时候,整个上传已经失败了。

更难受的是,这种失败几乎是不可恢复的——你得重新发起一次完整上传,前面传出去的全部白费。对 GB 级别的文件来说,重试成本太高了,尤其是移动网络或者弱网环境,进行到 90% 再断一次,心态直接爆炸。

1.3 串行传输浪费了本可以并行的带宽

单个连接传输时,TCP 的拥塞控制会限制吞吐量。哪怕服务器带宽足够,单连接也很难跑满。而分片上传可以做到多分片并行,把整体传输时间从“文件大小 / 单连接速度”变成“文件大小 / 多连接聚合速度”。这在文件越大、网络质量越好的场景下差异越明显。

所以分片上传解决的其实是一组问题:内存可控、失败可重试、传输可并发。这也是为什么 S3 协议和 MinIO 都会把 Multipart Upload 作为标准能力。我们自己写工具类,本质就是把这种能力从“SDK 内部行为”变成“自己掌控的流程”。

2. MinIO的Multipart Upload机制,还有Java SDK里能用的方法

如果你用过 MinIO Java SDK,会发现它的高层putObject方法也支持大文件,而且流式上传超过某个阈值会自动走分片。那自己写工具类还有没有意义?我的判断是:如果只是“能传上去”,高层 API 就够了;如果需要“看得见进度、能恢复、能控制并发”,就必须亲自操作 Multipart Upload 的完整生命周期。

2.1 一次分片上传的完整生命周期

分片上传在 S3 兼容对象存储里的流程是固定的,分为四步:

  1. 初始化上传:调用createMultipartUpload,服务端返回一个uploadId,这个 ID 标识了这次未完成的上传任务。
  2. 上传分片:把文件切割成多段,逐段调用uploadPart,每一段都带上同一个uploadId和分片序号,服务端返回每个分片的 ETag。
  3. 完成上传:把所有分片的 ETag 按序号整理成列表,调用completeMultipartUpload,服务端会把这些分片组合成最终对象。
  4. 中止上传:如果中途失败且不打算继续了,调用abortMultipartUpload,服务端会清理残留分片。

这四步的对应关系如下。

生命周期阶段核心操作关键返回值
初始化createMultipartUploaduploadId
上传片段uploadPart每个分片的 ETag
合并分片completeMultipartUpload最终对象元数据
清理残留abortMultipartUpload

2.2 Java SDK 中直接操作分片的方法

以 minio-java 8.5.x 为例,MinioClient直接暴露了这些方法:

// 初始化,返回 uploadId CreateMultipartUploadResponse initResp = client.createMultipartUpload(bucket, null, object, Map.of(), Map.of()); String uploadId = initResp.result().uploadId(); // 上传单个分片,返回该分片 ETag UploadPartResponse partResp = client.uploadPart(bucket, null, object, partStream, partLength, partNumber, uploadId, Map.of(), Map.of()); // 完成所有分片 client.completeMultipartUpload(bucket, null, object, uploadId, partList, Map.of(), Map.of()); // 中止上传 client.abortMultipartUpload(bucket, null, object, uploadId, Map.of(), Map.of());

使用这些方法的时候有一个前提,就是分片本身要被包装成能准确报告sizeInputStream,而且这个流必须能完整读出对应长度的数据。SDK 内部在做签名和网络请求时会依赖这个长度信息,长度对不上,服务端要么报错,要么挂起等待更多数据。

我自己封装工具类的时候,还额外用了RandomAccessFile来定位文件偏移量。这样就能做到“只读某一段分片”,而不是把整个文件都读到内存里。分片大小控制在 5MiB 到 50MiB 之间,每次读入内存的只有当前分片的大小,对 JVM 非常友好。

3. 工具类落地:接口设计、分片计算和核心实现

3.1 对外接口应该长什么样

工具类不能只为了自己爽,还要考虑同事接手的成本。我倾向于对外只暴露一个方法:传入桶名、对象名、文件对象和希望的分片大小,让内部去处理分片、合并和异常清理。调用方不需要知道uploadId是什么,也不需要关心 ETag 怎么排序,这些都是工具类内部的事。

public class MinioPartUploadUtils { public void uploadWithParts(String bucket, String object, File file, long customPartSize) throws Exception { // 内部处理全部流程 } }

如果后续需要进度回调,再额外增加一个BiConsumer<Long, Long>之类的参数,把“已上传字节数”和“总字节数”暴露给调用方。初期版本先把主流程跑通最重要。

3.2 分片大小怎么算才合理

S3 协议对分片有两个硬约束:除了最后一个分片,其余分片大小不能小于 5MiB;整个对象的分片数量最多 10000 个。MinIO 继承了这个约束。

所以工具类里必须做一次自适应计算:

long partSize = Math.max(customPartSize, MIN_PART_SIZE); // 5MiB int partCount = (int) ((fileSize + partSize - 1) / partSize); if (partCount > MAX_PART_COUNT) { // 10000 partSize = (fileSize + MAX_PART_COUNT - 1) / MAX_PART_COUNT; partSize = ((partSize + MIN_PART_SIZE - 1) / MIN_PART_SIZE) * MIN_PART_SIZE; partCount = (int) ((fileSize + partSize - 1) / partSize); }

如果调用方传的customPartSize太小,直接拉到 5MiB。如果文件太大导致分片数量超过 10000,就自动把分片大小往上调。这里把partSize对齐到 5MiB 的整数倍,是为了避免出现非最后分片不足 5MiB 的边界问题。实际项目里我一般默认 10MiB 或 20MiB,太大反而会增加单分片重试的失败代价。

3.3 核心代码实现

下面是一个简化但完整可用的工具类。为控制篇幅,我去掉了多余的校验和日志,重点保留流程。

import io.minio.*; import io.minio.messages.Part; import java.io.*; import java.util.*; public class MinioPartUploadUtils { public static final long MIN_PART_SIZE = 5L * 1024 * 1024; public static final int MAX_PART_COUNT = 10000; private final MinioClient client; public MinioPartUploadUtils(MinioClient client) { this.client = client; } public void uploadWithParts(String bucket, String object, File file, long customPartSize) throws Exception { long fileSize = file.length(); long partSize = Math.max(customPartSize, MIN_PART_SIZE); int partCount = (int) ((fileSize + partSize - 1) / partSize); if (partCount > MAX_PART_COUNT) { partSize = (fileSize + MAX_PART_COUNT - 1) / MAX_PART_COUNT; partSize = ((partSize + MIN_PART_SIZE - 1) / MIN_PART_SIZE) * MIN_PART_SIZE; partCount = (int) ((fileSize + partSize - 1) / partSize); } System.out.printf("文件大小=%d, 分片大小=%d, 分片数量=%d%n", fileSize, partSize, partCount); // 1. 初始化上传 CreateMultipartUploadResponse initResp = client.createMultipartUpload( bucket, null, object, Map.of(), Map.of()); String uploadId = initResp.result().uploadId(); List<Part> parts = new ArrayList<>(); try { // 2. 逐分片上传 for (int partNumber = 1; partNumber <= partCount; partNumber++) { long offset = (long) (partNumber - 1) * partSize; long length = Math.min(partSize, fileSize - offset); try (RandomAccessFile raf = new RandomAccessFile(file, "r")) { raf.seek(offset); byte[] buffer = new byte[(int) length]; raf.readFully(buffer); try (InputStream partStream = new ByteArrayInputStream(buffer)) { UploadPartResponse partResp = client.uploadPart( bucket, null, object, partStream, length, partNumber, uploadId, Map.of(), Map.of()); parts.add(new Part(partNumber, partResp.etag())); System.out.println("分片 " + partNumber + "/" + partCount + " 完成"); } } } // 3. 完成上传 client.completeMultipartUpload( bucket, null, object, uploadId, parts, Map.of(), Map.of()); System.out.println("上传完毕"); } catch (Exception e) { // 4. 失败时清理服务端残留 client.abortMultipartUpload(bucket, null, object, uploadId, Map.of(), Map.of()); throw e; } } }

这段代码有几个地方值得解释一下。为什么用RandomAccessFile?因为它支持seek,可以精确跳到文件的指定偏移量去读分片数据,比每次打开一个全新的FileInputStream再 skip n 个字节要高效可靠。为什么每轮都重新打开RandomAccessFile?这个其实不是必须的,我在多线程版本里会让每个线程各持有一个独立的RandomAccessFile,避免共享指针互相干扰;单线程下只需要在循环外打开一次即可。

为什么分片读出来要包一层ByteArrayInputStream?因为uploadPart需要的是一个能返回精确sizeInputStream,而ByteArrayInputStream是最简单可靠的选择。这里有个隐含代价:每片数据都会在内存里留一份副本。如果分片大小控制在 10MiB 到 20MiB,完全没问题;但如果有人为了减少请求次数把分片调到 500MiB,这里就会成为新的内存瓶颈。所以我的建议是:分片大小别贪大,保持“可重试粒度”比“最少请求次数”更重要。

4. 测试demo:用Docker跑起MinIO,把流程完整验证一遍

工具类写完之后,不能只在单元测试里 mock,最好还是连一个真实服务跑一遍。我平时最常用的方式是本地 Docker 起一个 MinIO,然后写一个简单的main方法去执行分片上传。

4.1 一条命令把MinIO跑起来

MinIO 官方镜像支持两条端口:9000是 API 端口,9001是控制台端口。

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就能看到控制台,用户名和密码都是minioadmin。如果我们只是为了验证上传流程,控制台可不看,API 端口能用就行。

4.2 准备一个几GB的测试文件

测试文件不需要真的有含义,只要体积够大、内容随机,就能模拟真实场景。Linux 或 macOS 下用dd生成一个 300MB 的随机文件:

mkdir -p /tmp/upload-test dd if=/dev/urandom of=/tmp/upload-test/bigfile.bin bs=1M count=300

这个文件生成后,后续每次上传的 MD5 都是随机的,我们不需要校验内容,只要确认对象在 MinIO 里存在且大小正确即可。

4.3 编写并执行测试入口

接下来在项目里写一个简单的入口类。为了让代码能直接跑,我把它做成main方法而不是 JUnit 测试,因为很多人对@Test方法怎么获取MinioClient还存在一点理解成本,直接main最直白。

public class MinioPartUploadDemo { public static void main(String[] args) throws Exception { MinioClient client = MinioClient.builder() .endpoint("http://127.0.0.1:9000") .credentials("minioadmin", "minioadmin") .build(); String bucket = "demo-bucket"; // 桶不存在就创建 boolean exists = client.bucketExists( BucketExistsArgs.builder().bucket(bucket).build()); if (!exists) { client.makeBucket( MakeBucketArgs.builder().bucket(bucket).build()); } File file = new File("/tmp/upload-test/bigfile.bin"); MinioPartUploadUtils utils = new MinioPartUploadUtils(client); long start = System.currentTimeMillis(); utils.uploadWithParts(bucket, "dir/bigfile.bin", file, 10L * 1024 * 1024); long cost = System.currentTimeMillis() - start; System.out.println("耗时: " + cost + " ms"); } }

执行时注意两点:一是 pom 或 gradle 要引入 minio SDK 依赖;二是 Java 版本要在 8 以上。如果项目里已经有 Spring Boot,直接注入MinioClient也行,但测试 demo 里用main方法独立跑,隔离性更好。

跑完控制台会输出每一片的上传结果,最后打印“上传完毕”。如果看到这个输出,说明工具类的完整流程已经通了。然后我们可以顺手验证一下 MinIO 服务端的对象情况。

4.4 用控制台和命令行交叉验证

最直接的验证方式是打开控制台,进入demo-bucket,看到dir/bigfile.bin这个对象,右侧会显示大小 300MB。这里要注意一个细节:分片上传完成后生成的 ETag 和普通单请求上传不一样,它往往带一个-N后缀,比如f2f1...-24,后面的24表示 24 个分片。这个现象是正常的分片上传标记,别当成 bug。

如果用 MinIO 客户端命令行工具mc,可以这样验证:

mc alias set local http://127.0.0.1:9000 minioadmin minioadmin mc ls local/demo-bucket/dir/ mc stat local/demo-bucket/dir/bigfile.bin

mc stat会显示对象大小、最后修改时间、ETag。只要大小正确、对象存在,就说明工具类的完成流程没有遗漏。

5. 踩坑实录:调试分片上传时遇到的五种意外状况

这部分我觉得价值不低于工具类本身。下面五个问题都是我在实际调试中真实遇到过、并且花了不少时间才定位到根因的。

5.1 非最后一个分片小于5MiB,上传直接报错

第一次写分片逻辑时,我没做最小大小限制,用户传 2MiB 就按 2MiB 切。结果上传大概 30 个分片之后,某个分片突然报InvalidPart,错误码一看是EntityTooSmall。我当时还奇怪:最后一片可以小于 5MiB,为什么中间某一片报这个?

后来查了文档才意识到,服务端对除最后一个分片之外的所有分片都要求不小于 5MiB。我的计算方式里,如果partSize本身小于 5MiB,那中间大片全是非法分片。解决方式就是工具类里那句Math.max(customPartSize, MIN_PART_SIZE),先把入口卡死,不给非法值流入后续逻辑。

5.2 completeMultipartUpload时ETag顺序反了

第二次踩坑更隐蔽。我当时图省事,用一个Map<Integer, String>存分片结果,然后转成List<Part>时直接遍历了 Map。结果因为 HashMap 的迭代顺序不可控,传到completeMultipartUpload里面的分片列表顺序是乱的。服务端没有报错,最终对象也创建了,但下载下来发现文件 CRC 校验不过、内容根本没法用。

这类问题最可怕的地方在于“半成功”——对象存在,数据却是坏的。排查方式是用mc stat看 ETag 的后缀分片数,再去控制台下载对比。从此之后我统一用一个ArrayList,严格按照partNumber顺序添加,绝不依赖 Map 迭代顺序。

5.3 上传中途失败后,服务端会残留“孤儿分片”

如果你按照我上面的工具类写法,catch 块里会调用abortMultipartUpload,这个流程会把服务端残留分片清理掉。但真实项目里,客户端可能直接崩了、断电了,根本没有机会执行 abort。这种情况下,MinIO 服务端会一直保留未完成的分片数据,日积月累下来,会白白占用大量存储空间。

所以工具类能做的只是“尽力而为”,更稳妥的方案是服务端定期执行一次生命周期清理规则。MinIO 支持给桶配置生命周期规则,把带有incomplete-upload标签的对象在指定天数后删除。这个配置可以在控制台的 Bucket Lifecycle 里加,如果你们公司用的是自己搭的 MinIO,这块必须提前考虑。

5.4 并发上传分片时,RandomAccessFile指针互相干扰

为了让上传再快一点,我把单线程循环改成了线程池并发上传 5 个分片。结果一开始就出问题,因为多个线程共用同一个RandomAccessFile,线程 A 调seek之后,线程 B 也调seek,两个线程的读取位置全部混乱,上传出去的分片数据张冠李戴。

解决方式有两个。一是每个线程独立打开RandomAccessFile,只在自己的偏移区间内读取;二是不用RandomAccessFile,改用FileChannelposition参数,每次读取时显式指定位置。无论哪种方式,核心原则是一样的:文件指针不能被多个线程共享。工具类里写成每次循环都打开一次RandomAccessFile的原因就在这里——虽然性能上会有一点损耗,但换来了绝对的并发安全。

5.5 临时凭证过期导致分片上传时间不够

如果项目里使用 STS 临时凭证访问 MinIO,这里还有一个隐蔽问题:临时凭证默认有效期可能只有几十分钟。正常对象上传几秒钟就结束了,无所谓;但一个几 GB 的文件,串行分片上传可能跑一两个小时。追加上传只能追溯到凭证签发时间,凭证过期之后,再调uploadPart就会返回AccessDenied

这种场景下,要么调大临时凭证过期时间,要么把上传逻辑拆成“预签名 URL + 前端直传”,要么采用更细粒度的“定期刷新凭证”机制。后者实现复杂,一般团队用不上,所以我平时在项目里会先确认临时凭证的过期时间是否覆盖整个上传周期。如果覆盖不了,直接改用长期 AccessKey 做服务端上传反而更省事。

6. 生产环境真正上线前,值得再补充的几个能力

工具类跑通 demo 只是第一步。真实生产环境里,有几个点是我建议在上线前就补上的,否则后续大概率要返工。

6.1 进度回调

用户上传大文件时,最关心的是“现在传到哪里了”。工具类里可以在每个分片上传完后累计已上传字节数,然后回调一个进度接口。比如用BiConsumer<Long, Long>,第一个参数是已上传字节数,第二个是文件总字节数,调用方可以拿去刷新进度条或者写日志。

6.2 断点续传

分片上传天然支持断点续传:每次初始化时拿到uploadId,信息持久化到数据库或 Redis。下次再传同一个文件时,先调用listParts查询服务端已有的分片,跳过已经完成的分片,只续传缺失部分。这个功能听起来很香,但实现时要注意清理过期uploadId,否则数据库里会积攒大量无效记录。

6.3 预签名URL直传

如果上传是发生在浏览器或手机端,让文件先经过后端再转存到 MinIO 会浪费很多带宽。更合理的方案是后端生成预签名 URL,客户端直传 MinIO。针对分片上传,MinIO 同样支持生成分片维度的预签名 URL,前端可以并行把分片直传到对象存储,最后由后端回调完成合并。这套方案比服务端中转复杂不少,但带宽成本和响应速度都有明显改善。

6.4 上传元数据的扩展

分片上传完成后,我们通常还想记录文件原始文件名、上传用户、业务 ID 等信息。MinIO 的对象元数据(自定义 Header)可以承载这些信息。在createMultipartUpload阶段就设置好元数据,最终合并出来的对象就会带着这些信息。后续做对象检索、事件通知时都会方便很多。

我在实际项目里的习惯是:先把工具类做成内部公共组件,进度回调、断点续传、预签名 URL 这些都作为独立接口扩展,而不是一开始就塞进一个巨型类里。这样既保证 demo 能快速跑通,又不会牺牲后续的可维护性。如果你正打算接 MinIO 分片上传,建议先复制这套流程完整跑一遍,再按自己的业务场景做裁剪。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询