简介这是一份面向 Java 后端开发者的 MinIO 分片上传与断点续传实战示例针对大文件直传易超时、网络中断需重传等痛点给出可直接运行的完整方案。压缩包共 13 个文件约 19KB包含 7 个 Java 源码、2 个 JavaScript 脚本、1 个 HTML 页面、1 个 CSS 样式、1 个 Maven 的 pom.xml 及 1 个 properties 配置文件前后端结构清晰、依赖精简后端仅引入必要 jar 包。前端借助 spark-md5 计算文件指纹并切片配合 upload.js 完成分片调度与续传逻辑后端则负责分片合并与校验。启动时只需保证配置文件中的 MinIO 服务信息一致并留意 composeFile 函数注释即可直接上传文件验证效果。目前已有 20968 人学习下载适合希望掌握对象存储大文件上传、提升传输可靠性的开发者参考借鉴。1. MinIO 分片上传与断点续传为什么你的 Java 服务一传大文件就崩一个 4GB 的视频文件前端进度条卡在 37% 不动了用户刷新页面后一切归零后台日志里躺着一条java.lang.OutOfMemoryError: Java heap space。这不是段子是很多 Java 工程师第一次用 MinIO 做文件上传时的真实翻车现场。问题的根子在于把整个文件读进内存再往 MinIO 推文件一大堆内存直接爆掉网络一抖整个上传前功尽弃。MinIO 本身提供了完整的分片上传 API配合 Java SDK 可以做到分片并发、断点续传、秒传校验但前提是你得把分片大小、并发数、分片元数据管理这几件事想清楚。这篇笔记面向正在用或准备用 MinIO 做文件服务的 Java 工程师从分片上传的核心机制讲到断点续传的落地代码再到生产环境里那些让人后悔药都来不及吃的坑全部拆开讲透。读完你至少能拿到一套可以直接改改就上线的分片上传方案以及一套判断“这个方向值不值得投入”的评估依据。2. MinIO 分片上传的底层机制与 Java SDK 选型2.1 MinIO 分片上传到底在服务端做了什么MinIO 兼容 Amazon S3 的分片上传协议整个流程拆成三个动作初始化分片上传拿到一个全局唯一的uploadId然后按顺序或并发地把每个分片 PUT 上去并收集每个分片的ETag最后带着所有分片的编号和 ETag 列表调用完成接口MinIO 在服务端把这些分片按序拼成一个完整对象。这里有一个容易被忽略的细节分片上传过程中MinIO 并不会把分片暴露成可读对象只有调用了完成接口之后这个对象才真正可见。这意味着如果你传了一半用户取消或者进程挂了那些已经上传的分片会一直占着存储空间直到你显式调用中止接口或者配置了生命周期规则自动清理。分片本身有硬性约束每个分片最小 5MB最后一片可以小于 5MB最大 5GB最多 10000 片。这三个数字直接决定了你的分片策略。举个例子你要传一个 50GB 的文件如果按 5MB 分片需要 10000 片刚好卡在上限如果按 100MB 分片只需要 500 片但单片失败重传的成本就高了。所以分片大小不是拍脑袋定的得根据你的文件大小分布和网络状况来算。Java SDK 这边MinIO 官方提供了MinioClient底层封装了 S3 的UploadPartRequest和CompleteMultipartUploadRequest。但官方 SDK 并没有直接给你一个“传文件自动分片断点续传”的高层方法你需要自己管理分片状态。这就是为什么很多团队会选择在 MinIO SDK 之上再包一层或者干脆用 MinIO 的putObject配合PartSize参数让它自动分片——但自动分片不提供断点续传能力中断了就得从头来。2.2 Java 侧选型官方 SDK 裸调还是自建分片管理层我一般会推荐两条路线取决于你的业务复杂度。第一条路线是直接用MinioClient.putObject并指定PutObjectArgs的partSize让 SDK 内部自动做分片和并发。这条路线代码量最少适合文件大小相对可控比如单文件不超过 500MB、对断点续传要求不高的场景。代码大概长这样// 使用 MinIO Java SDK 自动分片上传 MinioClient client MinioClient.builder() .endpoint(http://127.0.0.1:9000) .credentials(minioadmin, minioadmin) .build(); // partSize 设为 10MBconcurrency 设为 4 client.putObject( PutObjectArgs.builder() .bucket(my-bucket) .object(big-file.zip) .stream(new FileInputStream(/data/big-file.zip), -1, 10 * 1024 * 1024) .build() );这段代码里stream方法的第二个参数传-1表示未知流长度第三个参数是分片大小。SDK 会自动按 10MB 切分并并发上传。逻辑说明putObject内部会判断流长度是否超过分片阈值超过就走分片上传流程。参数说明partSize最小 5MB低于这个值 SDK 会抛异常并发数由 SDK 内部线程池控制默认是 4可以通过MinioClient构建时的httpClient自定义。这条路线的问题是一旦上传中断没有uploadId的持久化下次只能重新传。第二条路线是手动管理分片自己调initiateMultipartUpload、uploadPart、completeMultipartUpload把uploadId和已上传分片的partNumber、etag存到数据库或 Redis。这条路线代码量大但断点续传、秒传、分片并发控制全部握在自己手里。对于做企业网盘、视频平台、备份系统的团队这条路线是绕不过去的。下面这张表对比一下两条路线的关键差异维度自动分片putObject手动分片自管 uploadId代码量少10 行以内多200 行起步断点续传不支持支持需持久化分片状态秒传不支持支持通过文件 MD5 查重并发控制SDK 内部固定可自定义线程池和分片顺序适用场景中小文件、内部工具大文件、对外服务、网盘选型建议很直接如果你的文件超过 1GB 或者用户网络环境不稳定直接上手手动分片别等到线上出事故再重构。手动分片的核心难点不在 MinIO API 调用而在分片状态的持久化和并发安全这部分在下一章展开。3. 手写断点续传从 uploadId 持久化到分片合并的完整代码3.1 分片上传的四个阶段与状态表设计手动分片上传拆成四个阶段初始化、上传分片、查询已传分片、完成合并。每个阶段都需要和数据库打交道。我一般会设计两张表一张upload_task记录上传任务一张upload_part记录每个分片的状态。upload_task的关键字段包括upload_idMinIO 返回的、object_name、file_md5、total_parts、status。upload_part的关键字段包括upload_id、part_number、etag、size、status。为什么要把etag存下来因为完成合并的时候MinIO 要求你提交一个PartETag列表这个列表必须和实际上传的分片一一对应。如果你不存完成的时候就得重新查一遍 MinIO多一次网络往返不说分片多了还容易超时。另一个关键字段是file_md5用来做秒传上传之前先算文件的 MD5拿这个 MD5 去upload_task表里查有没有已经传完的同 MD5 文件有的话直接返回对象的访问地址一个字节都不用传。状态表的设计要注意并发同一个upload_id下多个分片可能同时上传更新upload_part的时候要用part_number做唯一索引避免重复插入。我见过有团队用upload_id part_number做联合主键结果并发插入的时候死锁频发后来改成先INSERT IGNORE再UPDATE才稳住。3.2 初始化与分片上传的 Java 实现先看初始化分片上传的代码// 初始化分片上传返回 uploadId public String initMultipartUpload(String bucket, String objectName) throws Exception { // 创建分片上传请求 CreateMultipartUploadRequest request CreateMultipartUploadRequest.builder() .bucket(bucket) .key(objectName) .build(); // 调用 MinIO 初始化接口 CreateMultipartUploadResponse response minioClient.createMultipartUpload(request); String uploadId response.result().uploadId(); // 持久化 uploadId 到数据库 UploadTask task new UploadTask(); task.setUploadId(uploadId); task.setObjectName(objectName); task.setStatus(UPLOADING); uploadTaskMapper.insert(task); return uploadId; }逻辑说明createMultipartUpload是 MinIO Java SDK 提供的初始化方法返回的uploadId是整个分片上传会话的唯一标识。参数说明bucket是存储桶名称objectName是最终对象的完整路径比如video/2025/01/demo.mp4。注意objectName不要以/开头MinIO 会把它当成合法的 key 但后续拼接 URL 的时候容易出问题。接下来是上传单个分片// 上传单个分片 public PartETag uploadPart(String bucket, String objectName, String uploadId, int partNumber, InputStream stream, long size) throws Exception { UploadPartRequest request UploadPartRequest.builder() .bucket(bucket) .key(objectName) .uploadId(uploadId) .partNumber(partNumber) .build(); UploadPartResponse response minioClient.uploadPart(request, stream, size); String etag response.etag(); // 持久化分片状态 UploadPart part new UploadPart(); part.setUploadId(uploadId); part.setPartNumber(partNumber); part.setEtag(etag); part.setSize(size); part.setStatus(UPLOADED); uploadPartMapper.insertOrUpdate(part); return new PartETag(partNumber, etag); }逻辑说明uploadPart方法接收分片编号和输入流MinIO 返回该分片的etag。参数说明partNumber从 1 开始最大 10000size是当前分片的字节数必须和流实际长度一致否则 MinIO 会报IncompleteBody错误。这里有个血泪经验如果你用FileInputStream并且跳过了前面的字节来读分片一定要确保skip的字节数准确否则分片内容错位合并出来的文件就是坏的。3.3 断点续传的查询与合并逻辑断点续传的核心是上传之前先查一下这个uploadId下已经传了哪些分片前端只需要传缺失的分片。查询逻辑// 查询已上传的分片列表 public ListPartETag listUploadedParts(String bucket, String objectName, String uploadId) throws Exception { ListPartsRequest request ListPartsRequest.builder() .bucket(bucket) .key(objectName) .uploadId(uploadId) .build(); ListPartsResponse response minioClient.listParts(request); ListPartETag uploaded new ArrayList(); for (Part part : response.result().partList()) { uploaded.add(new PartETag(part.partNumber(), part.etag())); } return uploaded; }逻辑说明listParts直接向 MinIO 查询该uploadId下已经成功上传的分片返回的列表里包含分片编号和 ETag。参数说明这个方法适合在断点续传时调用但如果你已经在数据库里存了分片状态优先查数据库减少一次网络请求。数据库和 MinIO 状态不一致的时候以 MinIO 为准因为分片可能因为网络超时被 MinIO 标记为未完成。合并分片的代码// 完成分片上传合并所有分片 public void completeMultipartUpload(String bucket, String objectName, String uploadId, ListPartETag partETags) throws Exception { // 按分片编号排序MinIO 要求顺序提交 partETags.sort(Comparator.comparingInt(PartETag::partNumber)); CompleteMultipartUploadRequest request CompleteMultipartUploadRequest.builder() .bucket(bucket) .key(objectName) .uploadId(uploadId) .partETags(partETags) .build(); minioClient.completeMultipartUpload(request); // 更新任务状态为已完成 uploadTaskMapper.updateStatus(uploadId, COMPLETED); }逻辑说明completeMultipartUpload触发 MinIO 在服务端合并所有分片。参数说明partETags必须包含所有已上传分片的编号和 ETag缺一个都会导致合并失败并返回InvalidPart错误。排序不是必须的但建议排一下方便排查问题。合并完成后upload_task表的状态要更新同时可以异步清理upload_part表里的记录避免表无限膨胀。4. 分片上传避坑指南那些让你加班到凌晨的常见问题4.1 分片大小设错导致上传直接失败现象调用uploadPart的时候抛出EntityTooSmall异常提示分片小于最小允许大小。原因MinIO 要求除最后一个分片外每个分片必须大于等于 5MB。如果你按 1MB 分片前 N-1 个分片全部会被拒绝。解决分片大小至少设为 5MB推荐 10MB 到 50MB 之间。对于大文件可以用动态分片策略文件小于 100MB 用 10MB 分片100MB 到 1GB 用 20MB超过 1GB 用 50MB。这样既能控制分片数量又能减少单片失败的重传成本。4.2 uploadId 丢失导致断点续传变成重新上传现象用户刷新页面后前端拿不到之前的uploadId只能重新初始化之前传的分片全部作废。原因uploadId只存在前端内存里没有持久化到后端或者浏览器本地存储。解决初始化分片上传之后后端把uploadId返回给前端前端存到localStorage或者IndexedDB同时后端也存一份到数据库。下次上传同一个文件时前端先带着文件 MD5 问后端有没有未完成的任务有的话直接复用uploadId和已传分片列表。这里注意uploadId是有有效期的MinIO 默认 7 天超时后分片会被自动清理所以你的任务表也要有超时清理逻辑。4.3 并发上传分片导致数据库死锁现象多个分片同时上传更新upload_part表的时候频繁出现Deadlock found when trying to get lock。原因多个事务同时插入或更新同一upload_id下的不同分片如果索引设计不当间隙锁会互相等待。解决把upload_id part_number设为联合唯一索引插入的时候用INSERT IGNORE更新的时候用UPDATE ... WHERE upload_id ? AND part_number ?。另外把分片状态更新放在一个独立的事务里不要和文件流读取混在同一个长事务中。如果并发量特别大可以考虑用 Redis 暂存分片状态异步刷回数据库。4.4 合并时 ETag 列表不完整导致 InvalidPart现象调用completeMultipartUpload返回InvalidPart提示某个分片不存在或者 ETag 不匹配。原因数据库里记录的分片 ETag 和 MinIO 实际存储的不一致常见于分片上传成功但数据库更新失败的情况。解决合并之前先调listParts从 MinIO 拉取最新的分片列表用这个列表去合并而不是直接用数据库里的记录。如果发现数据库和 MinIO 不一致以 MinIO 为准同时修复数据库记录。另外ETag 字符串里可能包含引号存数据库的时候要去掉引号提交的时候再加回去这个细节很容易忽略。4.5 大文件上传超时导致连接被重置现象上传到 80% 的时候连接断开报Read timed out或者Connection reset。原因MinIO 客户端默认的 HTTP 超时时间太短大分片上传耗时超过了超时阈值。解决在构建MinioClient的时候自定义OkHttpClient把connectTimeout、writeTimeout、readTimeout都调大比如设成 5 分钟。同时分片大小不要设得太大50MB 以上的分片在弱网环境下很容易超时。如果还是超时考虑启用分片上传的重试机制SDK 本身支持RetryPolicy可以配置最大重试次数和退避策略。5. 进阶技巧用 Redis 加速分片状态查询与秒传校验5.1 Redis 缓存分片状态减少数据库压力当上传并发量上来之后每次查询已上传分片都走数据库upload_part表的读压力会很大。我一般会在 Redis 里用 Hash 结构缓存分片状态key 是upload:parts:{uploadId}field 是partNumbervalue 是etag。上传成功一个分片就HSET一次查询的时候直接HGETALL比查数据库快一个数量级。Redis 里的数据设置 7 天过期和 MinIO 的uploadId有效期对齐。数据库作为持久化兜底Redis 挂了再从数据库恢复。// Redis 缓存分片状态 public void cachePart(String uploadId, int partNumber, String etag) { String key upload:parts: uploadId; redisTemplate.opsForHash().put(key, String.valueOf(partNumber), etag); redisTemplate.expire(key, 7, TimeUnit.DAYS); } // 从 Redis 查询已上传分片 public MapObject, Object getCachedParts(String uploadId) { String key upload:parts: uploadId; return redisTemplate.opsForHash().entries(key); }逻辑说明用 Redis Hash 存储分片编号和 ETag 的映射查询和写入都是 O(1)。参数说明过期时间设为 7 天和 MinIO 的uploadId生命周期保持一致。注意 Redis 和数据库的双写一致性建议先写数据库再写 Redis或者用消息队列异步同步。5.2 秒传校验MD5 查重与分片复用秒传的逻辑是前端在上传之前先算文件的 MD5大文件可以用分片 MD5 合并或者抽样计算带着 MD5 请求后端。后端查upload_task表如果找到相同 MD5 且状态为COMPLETED的记录直接返回对象的访问 URL前端跳过上传。如果找到相同 MD5 但状态为UPLOADING的记录返回uploadId和已上传分片列表前端只传缺失的分片。这里有个坑MD5 计算本身很耗时对于 10GB 的文件前端算 MD5 可能要几十秒。常见的优化是抽样计算取文件头 1MB、中间 1MB、尾部 1MB 拼在一起算 MD5碰撞概率虽然不为零但对于秒传场景足够用。如果业务要求严格可以在后端合并完成后再算一次完整 MD5 做校验。5.3 分片上传的性能调优参数表下面这张表是我在生产环境调优时总结的关键参数可以直接参考参数推荐值说明分片大小10MB~50MB小于 5MB 会被 MinIO 拒绝并发分片数3~5太高会导致客户端带宽打满反而变慢连接超时30sOkHttpClient 的 connectTimeout写超时300s大分片上传需要更长的写超时读超时300s合并和查询操作可能耗时较长重试次数3SDK 的 RetryPolicy 最大重试次数Redis 过期7 天与 MinIO uploadId 有效期对齐调优的核心思路是分片大小和并发数要匹配你的网络带宽。如果客户端上行带宽是 100Mbps并发 5 个 10MB 分片理论上 4 秒传完一片但实际受限于服务端和网络抖动建议先用小并发跑观察监控指标再逐步调大。我自己的习惯是上线前用 1GB、5GB、20GB 三个档位的文件各跑一遍记录每个阶段耗时确认没有超时和内存溢出再放量。这套方案从分片初始化到断点续传再到秒传覆盖了 MinIO Java 分片上传的完整链路值不值得做取决于你的业务有没有大文件场景——如果有这就是绕不过去的基础设施。希望帮到你。本文还有配套的精品资源点击获取