资讯动态

MinIO Java SDK大文件分片上传与断点续传实战解析

发布时间:2026/10/16 3:18:07 来源:尧图企业网站定制
简介这份资源是面向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 查询服务端已上传的分片用于跳过已完成的分片 IterableResultPart partResults minioClient.listParts( ListPartsArgs.builder() .bucket(bucketName) .object(objectName) .uploadId(uploadId) .build() ); SetInteger uploadedPartNumbers new HashSet(); for (ResultPart 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 就复用// 列出对象下所有进行中的分片上传会话用于恢复断点 IterableResultUpload 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 状态恢复或分片参数调优时少走几条弯路。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑