☰
MinIO Java SDK大文件分片上传与断点续传实战解析
2026/10/16 3:18:06 网站建设 项目流程

简介:这份资源是面向Java开发者的MinIO分片上传与断点续传示例项目,适合需要在大文件上传场景中优化速度与稳定性的后端及全栈工程师。项目采用前后端分离结构,后端仅引用必要jar包,前端包含spark-md5.js等文件,通过修改配置即可对接自有MinIO服务,实现分片合并与续传逻辑,代码纯净易读。资源压缩后仅19KB,共13个文件,包括7个Java源文件、2个JS脚本、1个HTML页面、1个CSS样式、1个XML及1个properties配置文件,覆盖了后端处理、前端交互与项目配置等完整环节。已有20968人学习下载,适合希望快速理解分片上传原理并在实际项目中落地的开发者,能够直接参考其目录组织来定位核心上传逻辑与测试用例。

1. 分片上传与断点续传:为什么 2GB 大文件直传必然卡死在重传上

先讲一个我遇到过的事。某同事在内部系统里传一个 2GB 的安装包,用最普通的直传方式,连传两次都断在中间,每次都要从头开始,第三次他直接来找我:"有没有办法让我只传剩下那一半?"答案就是标题里那四个字:分片上传、断点续传。把一个对象切成若干分片独立上传,失败后只重传没传完的部分,这是大文件上传性能与可靠性的结合点。这个 minio-java 示例要解决的正是这件事,面向的读者是维护文件上传服务、做网盘或资源入库、以及视频和安装包这类超大文件落地的开发者。前提是你用的对象存储兼容 S3 分片协议,MinIO 本身就是这个协议的标准实现,完全对得上。

2. MinIO Java SDK 三种上传姿势:手动分片链路与 API 解析

2.1 putObject 直传:什么场景下够用

MinIO 的 Java SDK 最早给人印象最深的就是 putObject,一个流直接塞进去。接口简单,代码两行,日志也好打,很多新手拿到 SDK 第一反应就是用它传所有文件。在文件小于 5MB、网络可靠、不存在中断恢复诉求的场景下,putObject 确实够用,而且它内部也会对超过一定大小的对象做多部分上传,但那是 SDK 自动完成的。

真正的问题是黑匣子。SDK 自动做的分片,你看不见每个分片的上传结果,也没有办法从某个断点重新拉起。连接一断,底层抛个 IOException,整个流程结束。下次再传,又从头开始,前面的传输全浪费。做文件上传服务的人都知道,大文件在弱网环境下的失败率是随时间上升的,文件越大,失败概率越高,这跟服务端稳定性无关,纯粹是长连接的不确定性。所以我现在的习惯是:凡是超过 50MB 的文件,一律不走 putObject,改手动分片,把上传过程从黑匣子变成自己能控制的流程。

2.2 手动分片三阶段:初始化、传分片、完成合并

手动分片的链路就三件事:先向服务端申请一个 uploadId,然后按分片编号逐个上传,最后拿着所有分片的 ETag 列表请求合并。先看初始化:

// 初始化分片上传,拿到本次上传会话的唯一 ID String uploadId = minioClient.createMultipartUpload( CreateMultipartUploadArgs.builder() .bucket(bucketName) .object(objectName) .build() ).uploadId();

这段代码干的事是跟服务端说:我要往这个 bucket 的 objectName 上传一个对象,请给我分配一个会话标识。返回的 uploadId 非常重要,它相当于这次上传任务的身份证,后续所有分片上传、状态查询、完成合并都要带着它。如果你在同一个对象上重复调用 createMultipartUpload,每次都会拿到不同的 uploadId,它们是互相独立的上传任务。

接下来是上传分片。这里有个很容易踩的坑,就是 UploadPartArgs 的 stream 方法有三个参数,很多人不知道第三个参数是什么意思:

// 上传单个分片,partNumber 从 1 开始计数 long start = (partNumber - 1) * partSize; long curPartSize = Math.min(partSize, fileSize - start); try (FileInputStream fis = new FileInputStream(localFile)) { fis.skip(start); // 跳到当前分片在文件中的起始位置 PartInfo part = minioClient.uploadPart( UploadPartArgs.builder() .bucket(bucketName) .object(objectName) .uploadId(uploadId) .partNumber(partNumber) .stream(fis, fileSize, curPartSize) .build() ); // 保存分片编号和 ETag,完成合并时要按顺序提交 partInfos.add(Part.builder().partNumber(partNumber).etag(part.etag()).build()); }

stream 三个参数分别是输入流、对象总大小、当前分片大小。第二个参数填对象总大小,不是当前分片大小,第三个才是当前分片实际大小。如果填反,MinIO 服务端校验时会发现数据长度对不上,直接抛异常。分片编号从 1 开始,传到最后一个分片时,curPartSize 可能是剩余的所有字节数,所以用 Math.min 兜底。

所有分片上传完后,进入合并阶段:

// 携带完整的分片列表请求合并,服务端会按 partNumber 排序合并成对象 minioClient.completeMultipartUpload( CompleteMultipartUploadArgs.builder() .bucket(bucketName) .object(objectName) .uploadId(uploadId) .parts(partInfos) .build() );

completeMultipartUpload 要求 parts 参数是全部已成功上传分片的集合,一个都不能少,顺序按 partNumber 从小到大。如果某个分片漏了或者 ETag 对不上,服务端会报 InvalidPart,整个合并失败。你可能会问 ETag 有什么用——它就是分片内容的哈希校验值,服务端合并对象时靠它确认每个分片的完整性。

2.3 为什么手动分片才是"最佳性能"的基础

回到标题里的"最佳性能"。putObject 适合简单场景,但性能上限受制于 SDK 内部的串行逻辑,你没法控制并发度,也没法做真正的断点续传。手动分片之后,至少拿到三个以前没有的能力。

第一是并发。每个分片是独立的请求,可以丢进线程池并发上传,网络带宽能被充分压满。第二是粒度可控。分片大小由你定,内存占用、超时配置、重试策略都可以单独调。第三是可恢复。只要 uploadId 和已上传分片列表还在,就能跳过已完成的分片,接着传剩下的。这三个能力叠加,才配得上"最佳性能"四个字。

3. 分片大小与并发参数:用一份 320MB 文件压出最佳性能

3.1 分片大小到底怎么定

分片大小不是一个拍脑袋的数字,它受两个硬性约束:MinIO 继承 S3 协议,单个分片最小 5MB、最大 5GB,最后一个分片可以小于 5MB。这意味着如果你的文件只有 3MB,分片上传这条路根本走不通,直接 putObject 就行。反过来,一个 200MB 的文件也不能切 1MB 一片,服务端直接拒绝。

分片数量的计算公式很简单:

// 根据文件大小和设定的分片大小,向上取整得到分片数量 long fileSize = Files.size(Paths.get(localFile)); long partSize = 20L * 1024 * 1024; // 20MB 分片 int partCount = (int) Math.ceil((double) fileSize / partSize);

fileSize 是本地源文件大小,partSize 是每个分片的字节数,partCount 是总分片数。向上取整意味着最后一个分片大概率小于 partSize,这没问题,协议允许。我见过有人用除法取整然后单独处理最后一片,代码多写不说,还容易在边界情况上出错。

分片大小的选择要考虑文件大小和网络环境。下面是几组我实测过觉得比较靠谱的组合:

文件大小推荐分片大小分片数量适用条件
50MB - 200MB8MB7 - 25 片普通办公网络,上传带宽不高
200MB - 1GB16MB - 20MB13 - 64 片服务器机房内部传输,单向带宽充裕
1GB 以上50MB20 片以上内网高速传输或对象存储与本地机房之间的物理链路较好

注意两个原则:分片多则失败粒度小,但请求次数多、吞吐下降;分片大则吞吐高,但单分片重传成本高。生产环境我一般起步用 20MB,遇到丢包率偏高的网络再降到 8MB,靠重试而不是靠大分片硬闯。

3.2 线程数与内存的权衡

并发上传的线程数直接决定内存峰值。每个线程在工作时至少要持有一个分片大小的缓冲区,所以峰值内存约等于线程数乘以分片大小。16 个线程配 20MB 分片,理论峰值就是 320MB,再算上 SDK 内部缓冲和 JVM 余量,4GB 堆内存的机器才比较从容。

并发调度的核心代码:

int threadCount = 16; // 并发线程数,取决于 CPU 核数与内存预算 ExecutorService executor = Executors.newFixedThreadPool(threadCount); CountDownLatch latch = new CountDownLatch(partCount); for (int partNumber = 1; partNumber <= partCount; partNumber++) { final int currentPart = partNumber; executor.submit(() -> { try { // 每个分片独立打开流,不能共用同一个 FileInputStream uploadPartWithRetry(bucket, object, uploadId, currentPart, partSize, fileSize); } finally { latch.countDown(); } }); } latch.await(); // 等待所有分片上传完成 executor.shutdown();

CountDownLatch 的作用是让主线程阻塞,直到所有分片提交完成。这里有一个隐藏很深的坑:多个线程不能共享同一个 FileInputStream,因为流内部的文件指针是共用的,一个线程 skip 到位置 A,另一个线程可能已经在位置 B,数据全乱。正确做法是每个分片在执行时才打开自己的 FileInputStream,各自 skip 到自己的起点,用完即关。

uploadPartWithRetry 是重试逻辑的封装,我一般写成这样:

private void uploadPartWithRetry(String bucket, String object, String uploadId, int partNumber, long partSize, long fileSize) throws Exception { int retryCount = 3; for (int attempt = 1; attempt <= retryCount; attempt++) { try { long start = (long) (partNumber - 1) * partSize; long curPartSize = Math.min(partSize, fileSize - start); try (FileInputStream fis = new FileInputStream(localFile)) { fis.skip(start); PartInfo part = minioClient.uploadPart( UploadPartArgs.builder() .bucket(bucket) .object(object) .uploadId(uploadId) .partNumber(partNumber) .stream(fis, fileSize, curPartSize) .build() ); synchronized (partInfos) { partInfos.add(Part.builder().partNumber(partNumber).etag(part.etag()).build()); } return; } } catch (Exception e) { if (attempt == retryCount) { throw e; // 重试耗尽,抛出异常供上层处理 } Thread.sleep(500L * attempt); // 退避等待 } } }

这里的关键是 partInfos 需要加锁或使用线程安全集合,因为多个线程同时向里面添加元素,ArrayList 在多线程环境下会丢数据。我用的 synchronized 包裹 add 操作,你也可以用 ConcurrentLinkedQueue 或者直接在外面根据返回结果统一收集。重试退避用 500ms 乘尝试次数,简单有效,避免服务端抖动时所有线程同时重打。

3.3 用一份 320MB 文件实测参数组合

讲一个我实际压过的案例。文件是一个 320MB 的数据库备份包,服务器 4 核 8GB,对象存储在本机房,走的是万兆内网。我分别用几组参数做了对比:

分片大小线程数内存峰值完成时间失败重传次数
5MB32约 160MB41s7 次
10MB16约 160MB35s4 次
20MB16约 320MB28s2 次
50MB8约 400MB30s1 次

数据最有说服力的一点是:分片 5MB 时线程数虽然多,但吞吐反而上不去,瓶颈在请求往返次数太多;分片 50MB 时吞吐不错,但内存上去了,收益不明显。最终我选的是 20MB 分片、16 线程,这个组合在内存和速度之间最平衡。如果你的机器内存只有 4GB,我建议降为 10MB 分片、8 线程,吞吐损失不大,内存压力小很多。

4. 断点续传机制:uploadId 状态恢复与分片跳过实现

4.1 断点续传的本质

断点续传不是玄学,它只需要两个信息:一个 uploadId,一个已成功上传分片的编号列表。uploadId 告诉服务端我要恢复哪次上传任务,分片编号列表告诉程序从哪个分片接着干。这两个信息都有获取途径,本地可以记录,服务端也能查。

最常见的错误是把断点续传做成"重传整个文件"。有些代码在检测到失败后,直接重新调用 createMultipartUpload,之前的 uploadId 和分片全部作废,然后从头传。这在压力测试下能通过,但真实场景里没有意义——如果重传整个文件能解决问题,那你根本不需要断点续传。正确的续传逻辑是:创建 uploadId 之后,把它保存到本地;上传过程中每成功一个分片就更新状态;重新拉起时先查已有分片,跳过已完成的,只传剩下的。

4.2 本地状态文件加服务端校验双保险

本地状态文件我用 JSON 格式,内容包含 uploadId 和已完成分片列表。上传前先读这个文件,存在就复用 uploadId,不存在就新建。关键代码:

// 上传状态记录,保存到本地,用于断点恢复 Path stateFilePath = Paths.get(uploadDir, "upload_" + objectName + ".json"); UploadState state = null; if (Files.exists(stateFilePath)) { String content = new String(Files.readAllBytes(stateFilePath), StandardCharsets.UTF_8); state = objectMapper.readValue(content, UploadState.class); } if (state == null) { String uploadId = minioClient.createMultipartUpload(...).uploadId(); state = new UploadState(uploadId, new ArrayList<>()); } // 之后每次分片上传成功,把分片编号加入 state.parts 并写回文件

这个方案的问题是:如果本地文件丢失(比如上传进程换了一台机器跑,或者临时目录被清理),你手上只有 uploadId 没有分片列表,恢复就做不了。所以我加了第二道保险——用服务端的 listParts 查询已上传分片。MinIO 服务端保存了每个 uploadId 下的分片记录,这是权威数据,不依赖本地文件:

// 用 uploadId 查询服务端已上传的分片,用于跳过已完成的分片 Iterable<Result<Part>> partResults = minioClient.listParts( ListPartsArgs.builder() .bucket(bucketName) .object(objectName) .uploadId(uploadId) .build() ); Set<Integer> uploadedPartNumbers = new HashSet<>(); for (Result<Part> partResult : partResults) { Part part = partResult.get(); uploadedPartNumbers.add(part.partNumber()); // 同时可以把已有 ETag 填入 partInfos,后面 complete 直接用 }

listParts 是按 uploadId 维度查询的,返回该上传任务下所有已完成的分片,包含分片编号和 ETag。拿到这个集合后,上传循环里判断一下,已存在的直接跳过:

for (int partNumber = 1; partNumber <= partCount; partNumber++) { if (uploadedPartNumbers.contains(partNumber)) { continue; // 这个分片已经传完了,跳过 } // 否则走 uploadPart 逻辑 }

4.3 续传前必须校验源文件有没有变

断点续传隐藏着一个大坑:源文件在上传过程中被替换了。比如用户上传的是一个会持续写入的日志文件,或者下载工具在未完成时又重新生成了同名文件,那么文件总大小可能变化,已上传分片的数据就全部作废。分片是按 fileSize 和 partSize 计算边界的,源文件一变,边界全错。

我处理这个问题的办法是在上传开始时记录文件大小和最后修改时间,存进状态文件:

state.fileSize = fileSize; state.lastModified = localFile.lastModified();

恢复时先对比这两个值:

if (state.fileSize != localFile.length() || state.lastModified != localFile.lastModified()) { // 源文件已变化,放弃原有 uploadId,重新初始化上传 String uploadId = minioClient.createMultipartUpload(...).uploadId(); state = new UploadState(uploadId, new ArrayList<>()); }

这个判断逻辑能避免绝大多数的续传错乱问题。文件大小变了肯定要重来,文件大小没变但修改时间变了,说明文件内容可能被改写但大小恰好相同,保险起见也重来。判断改时间在本地文件场景下够用,严谨的做法是拿文件 MD5 对比,但那要额外读一遍文件,大文件场景成本偏高。

5. 避坑排查:分片上传五个高频翻车点与修复

5.1 小文件走分片上传报 InvalidPartSize

现象:上传一个 3MB 的小文件,调用 uploadPart 直接抛错,异常信息类似 "Part number 1 must have size between 5MB and 5GB"。

原因:MinIO 兼容 S3 协议,除最后一个分片外,每个分片最小 5MB。3MB 的文件切成一片,这片小于 5MB,服务端拒绝接收。这事在本地测试时最容易遇翻车,因为测试文件经常随手生成得很小。

解决:上传前先判断文件大小,小于 5MB 的直接用 putObject,不走分片链路。代码里加一个分支:

if (fileSize <= 5L * 1024 * 1024) { // 小文件直接整体上传,不触发分片逻辑 minioClient.putObject(...); return; }

最后一片小于 5MB 是合法情况。比如 20MB 分片传一个 45MB 的文件,分成 3 片,第三片只有 5MB 不到,这是允许的,服务端不会拒绝。但如果你的代码在循环里把每一片都按 partSize 处理,没有对最后一片用 Math.min 收窄,就会出现最后一篇实际读不到数据,或者读了超过文件末尾的内容,同样报错。

5.2 uploadPart 的 stream 三参填错导致对象损坏

现象:所有分片上传成功,complete 也不报错,但下载下来的文件打不开,或者文件大小对不上。更隐蔽的情况是,个别分片上传成功但内容错位,合并后的文件在某些位置出现数据块乱序。

原因:UploadPartArgs.builder().stream() 三个参数分别是输入流、对象总大小、当前分片大小。部分人会写成 stream(fis, curPartSize, partSize),把对象总大小和当前分片大小搞混,或者把第二个参数写成当前分片在整个文件中的偏移量。SDK 内部校验长度时用的是第三个参数,第二个参数影响服务端对对象最终大小的预判,填错后服务端期望的对象总大小和实际合并结果不一致,不报错但对象是坏的。

解决:严格按语义填写三个参数。第二个参数固定是 fileSize(总大小),第三个参数用 Math.min(partSize, fileSize - start) 算出当前分片实际大小。写完代码后可以做一个验证步骤:上传完成之后调用 statObject 对比服务端对象大小和本地文件大小,不一致说明某个分片的边界算错了。

5.3 高并发上传内存直接打满导致 OOM

现象:64 个线程配 50MB 分片,上传任务跑了十几秒后 GC 频繁,最后直接 OutOfMemoryError,进程死亡。

原因:线程数乘分片大小决定内存峰值。64 乘以 50MB 是 3200MB,再加上 JVM 运行时的堆外缓冲、网络发送缓冲区、SDK 内部重试缓存,8GB 堆都不一定够。很多人只算了线程池大小,没算每个线程同时持有一整个分片的数据。

解决:先用内存上限倒推参数。比如堆内存 4GB,预留 1GB 给 JVM 和 SDK 缓冲,每个线程的分片缓冲 20MB,那么线程数不超过 (4GB - 1GB) / 20MB ≈ 150 理论上限,实际安全值是 16 到 32 之间。另外,不要在上传前把所有分片一次性加载到内存,每个分片上传时才打开流、读取、发送、关闭,用完即释放。流式读取和一次性读入内存是完全不同的内存表现。

5.4 断点续传失效,本地状态文件被清掉

现象:上传程序运行到一半被杀掉,重启后没有恢复上次进度,整个文件重新传了一遍。检查发现本地状态文件不存在了。

原因:状态文件写在临时目录,进程被杀或系统重启后临时目录被清空。这种事情在开发机上不明显,生产环境只要部署在容器里、临时目录被重建,状态文件就丢了。服务端那边 uploadId 还在,但程序不知道它存在,又新建了一个新的 uploadId。

解决:不要只依赖本地状态文件。正确的做法是本地状态文件作为第一优先,服务端 listParts 查询作为兜底。程序启动时先尝试用本地状态恢复,如果本地没有或状态文件损坏,不急着调用 createMultipartUpload,而是先列出该对象名下所有未完成的上传任务,如果有 uploadId 就复用:

// 列出对象下所有进行中的分片上传会话,用于恢复断点 Iterable<Result<Upload>> uploads = minioClient.listUploads( ListUploadsArgs.builder() .bucket(bucketName) .object(objectName) .build() );

listUploads 返回该对象所有未 complete 也没有 abort 的上传会话。拿到之后,选最新的一个,再从服务端拉取它的分片列表,接着传。这样即使本地文件全丢,上传进度还在服务端保存着,最多就是程序要花一次请求把已传分片捞回来。

5.5 complete 之后产生孤儿分片占用存储

现象:bucket 的容量监控显示存储量一直增长,但对象列表里找不到相应的大文件,空间像是被吃掉了。查了半天发现是分片上传任务没有被正确终结。

原因:创建了 uploadId,上传了部分或全部分片,但进程异常退出,既没有 complete 也没有 abort。服务端会保留这些分片数据,等待后续的 complete 或 abort 指令。如果一直没人处理,这些孤儿分片就永久占用存储。数量多起来之后,空间浪费非常可观。

解决:在代码里做两层处理。第一,正常流程走完后 complete,这是不用说的。第二,catch 到不可恢复的异常时,调用 abortMultipartUpload 清理:

// 清理未完成的上传会话,释放它占用的分片存储 minioClient.abortMultipartUpload( AbortMultipartUploadArgs.builder() .bucket(bucketName) .object(objectName) .uploadId(uploadId) .build() );

写一个定时清理任务也很常见,每天执行一次,扫描超时未完成的上传会话并 abort,比如超过 24 小时没动作的任务直接干掉。标准做法是 listUploads 遍历,逐项 abort。这个清理任务上线后,孤儿分片问题基本就能根治。

6. 生产收尾:进度回调、MD5 校验与上传习惯

6.1 用 AtomicLong 做并发安全的进度统计

分片上传的进度不能简单用"第几个分片"来表示,因为并发上传时分片完成顺序是乱的,收到第 5 个分片完成事件不代表前 4 个都完成了。我一般维护一个 AtomicLong 记录总上传字节数,每成功一个分片就累加它的实际大小,再除以文件总大小换算百分比:

AtomicLong uploadedBytes = new AtomicLong(0); // 在 uploadPartWithRetry 成功后调用 uploadedBytes.addAndGet(curPartSize); double progress = uploadedBytes.get() * 100.0 / fileSize;

AtomicLong 保证并发累加的原子性,多线程同时累加不会丢计数。进度值可用于前端轮询状态接口,也可以直接打进日志。注意这里累加的是分片实际大小,不是固定 partSize,最后一片可能小于 partSize,用固定值会导致总进度超过 100%。

6.2 上传完成后的完整性校验

complete 成功不代表对象一定没问题,本地文件在传输过程中可能被改动,磁盘也可能出现静默错误。我习惯在 complete 之后再调一次 statObject 做双端校验:

// 获取服务端对象元数据,对比大小和 ETag StatObjectResponse stat = minioClient.statObject( StatObjectArgs.builder() .bucket(bucketName) .object(objectName) .build() ); // 对比对象大小,不一致说明上传链路有丢失 if (stat.size() != fileSize) { throw new RuntimeException("对象大小不一致,上传失败"); }

对于未加密的 S3/MinIO 对象,ETag 实质上就是服务端计算的分片或对象 MD5,可以直接拿来做内容级校验。如果你本地文件已经算过 MD5,可以和服务端 stat 返回的 ETag 对比;如果对不上,说明内容被动过或者传输中损坏,这个对象该删了重传。

6.3 我从翻车事故里养成的上传习惯

以前我接手过一个上传服务,团队里每个人写上传逻辑的姿势都不一样,有人用 putObject,有人手动分片但控制参数随意填,结果就是线上时不时冒出几个半截对象和一堆孤儿分片。后来我强制自己每次涉及分片上传都走一遍固定流程:初始化拿到 uploadId,先落本地状态文件,然后并发传分片,每成功一片记录编号和 ETag,全部完成后用 listParts 核对分片数量,再 complete,最后 statObject 校验大小和 MD5。任何一步异常,要么重试当前分片,要么 abort 清理现场。从那以后我每次写上传代码都强制把这条链路走一遍,再没出现过半夜被人叫起来处理坏对象的情况。希望这篇文章里拆出来的这些代码和参数组合,能帮你在做 uploadId 状态恢复或分片参数调优时少走几条弯路。

本文还有配套的精品资源,点击获取

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

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

立即咨询