☰
MinIO分片上传与断点续传:Java大文件上传实战避坑指南
2026/10/8 2:24:12 网站建设 项目流程

简介:这是一份面向 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 设为 10MB,concurrency 设为 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_id(MinIO 返回的)、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 开始,最大 10000;size是当前分片的字节数,必须和流实际长度一致,否则 MinIO 会报IncompleteBody错误。这里有个血泪经验:如果你用FileInputStream并且跳过了前面的字节来读分片,一定要确保skip的字节数准确,否则分片内容错位,合并出来的文件就是坏的。

3.3 断点续传的查询与合并逻辑

断点续传的核心是:上传之前先查一下这个uploadId下已经传了哪些分片,前端只需要传缺失的分片。查询逻辑:

// 查询已上传的分片列表 public List<PartETag> 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); List<PartETag> 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, List<PartETag> 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 是partNumber,value 是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 Map<Object, 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 分片上传的完整链路,值不值得做取决于你的业务有没有大文件场景——如果有,这就是绕不过去的基础设施。希望帮到你。

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

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

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

立即咨询