ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

MinIO Java分片上传与断点续传实战:从单次PUT失败到大文件秒传

MinIO Java分片上传与断点续传实战:从单次PUT失败到大文件秒传

简介:这是一套基于 MinIO 的 Java 分片上传与断点续传示例,面向需要在 Web 场景中实现大文件高效传输的 Java 开发人员。示例后端只引入必要 JAR 包,前端提供基于 Spark MD5 的分片计算与上传页面,业务结构清晰,适合有一定 Java 基础并希望快速落地文件上传功能的开发者参考。资源包共 13 个文件,主要包括 7 个 Java 后端源码、2 个 JavaScript 前端脚本、1 个 HTML 上传页面,以及 CSS、XML 和 properties 配置等辅助文件,压缩包整体约 19KB,轻量易部署。目前已有 20,968 人学习或下载,经过了较广泛的实践验证。借助该示例,读者可了解文件分片、MD5 校验、断点续传的关键思路,并可直接在本地调整配置运行测试,为自身项目集成类似能力提供实用参考。

1. 先讲一个翻车场景:单次 PUT 大文件为什么必死

最近帮某公司做一个内部文件管理模块时,我被一个 1.6GB 的数据库备份文件折磨了一个下午:单次 PUT 上传走到一半网关超时,连接断开,文件得从头再传。换成分片上传加断点续传后,同一台机器、同一条网络,文件一次到位,中断了也能从已传的位置接着跑。这份 minio-java 分片上传、断点续传示例就是干这个的:把大文件切成多个固定大小的分片,并行打到 MinIO,同时把已上传分片的 ETag 记录下来,失败时只补没传完的分片。正被大文件上传整得头疼的 Java 后端开发会在这里看到 Multipart Upload 的完整流程,也能直接把这个工程改成自己项目里的上传模块。

2. 项目结构与技术选型:Multipart Upload 要拆成哪几层

2.1 三层结构:上传服务、分片调度、续传记录

一个分片上传 demo 最省事的写法是“一个类、一个 main 方法、一把梭”,但想二次开发,还是要按职责拆开。这个示例工程里常见的包结构是这样的:

minio-multipart-upload/ ├── pom.xml ├── src/main/java/ │ └── com/example/minioupload/ │ ├── MinioClientConfig.java // 读取 endpoint、accessKey、secretKey │ ├── UploadService.java // 对外入口:入参是 bucket、object、文件路径 │ ├── MultipartUploader.java // 核心调度:init、uploadPart、complete │ ├── UploadRecordStore.java // 上传进度记录的读写,续传的落地点 │ └── model/ │ ├── UploadRecord.java // 一次 multipart 会话的完整记录 │ └── UploadPartInfo.java // 单个分片的 partNumber、etag、size ├── src/main/resources/ │ ├── application.yml // MinIO 连接参数与分片策略 │ └── logback.xml └── src/test/java/ └── upload/UploadServiceTest.java

我一般会把UploadService做成无状态的:它不关心分片怎么切、线程池怎么建,只负责接参数、调MultipartUploader、返回结果。UploadRecordStore是新增模块里最值得看的部分,所有和“续传”有关的状态都收在这里。后面想从本地 JSON 换到 Redis,只需要改这一个类,上层调度完全不受影响。

model里的UploadRecord也不只是存一个 uploadId。通常它包含三块:uploadId、parts映射(partNumber 到 etag)、创建时间。之所以还要时间戳,是因为 MinIO 服务端会对未完成的 multipart 会话做过期清理,本地记录里的 uploadId 可能在服务端已经失效,续传前要先验证。

2.2 Multipart Upload 的七步链路

分片上传和普通上传的区别不在“文件分成几块”,而在于多了一次完整的服务端会话管理。一次请求从进入工程到返回,走的是这条链路:

步骤做的事关键返回值
1. 创建 Multipart Upload向 MinIO 申请一个分片会话uploadId
2. 计算分片数和每片大小逻辑切分,不改动源文件partNumber、offset
3. 逐片上传每个分片独立 HTTP 请求每片一个 ETag
4. 记录已上传分片上传完成立刻落盘,续传靠它最新 ETag
5. 全部传完调用 Complete服务端把分片合并成对象最终对象
6. Stat 对象校验大小发现漏片或错位拼接对象 size
7. 失败清理或重试abort 旧会话或等待重试无

这个流程里的第 3 步是唯一数据密集的部分,也是并发控制的重点。第 1、5、6 步各多出一次控制请求,开销不大,但对小文件来说就是纯负担了。MinIO 对外走的是 S3 兼容 API,这个链路和标准 S3 Multipart Upload 一致,换到其他兼容存储上也能复用。

2.3 为什么不直接调 SDK 的 putObject 一把梭

MinIO Java SDK 的putObject在对象超过阈值时也会自动切换成 multipart,对大多数场景够用。但它有两个问题对这个示例项目是致命的。

第一,并发不可控。SDK 内部自动切换 multipart 后,分片是串行上传的,没有把网络带宽打满。第二,续传无状态。进程一崩,SDK 不会帮你记住哪些分片已经传成功,下次putObject还是从头来。这个示例的价值在于把这两块裸露给开发者:想并行,自己开线程池;想续传,自己记录 ETag。代价是你要多处理 complete 前的边界情况,收益是文件越大、网络越差,优势越明显。

3. 核心代码拆解:从 uploadId 到 ETag 的完整链路

3.1 第一步:申请 uploadId

所有分片都属于同一个服务端会话,这个会话的凭证就是 uploadId。

// 每次创建一个 Multipart Upload,MinIO 都会返回唯一的 uploadId String initUpload(String bucket, String object) throws Exception { CreateMultipartUploadResponse response = minioClient.createMultipartUpload( CreateMultipartUploadArgs.builder() .bucket(bucket) .object(object) .build() ); return response.uploadId(); }

这个方法的参数只有桶名和对象名,返回的 uploadId 要妥善保存。后续所有uploadPart请求都要带上它,complete 阶段也要用到。如果这一步报错,最常见的原因是桶不存在,或者桶名里包含大写字符——MinIO 的桶名规范只允许小写字母、数字、中划线。

3.2 第二步:按偏移量读取分片并上传

分片上传的真正门槛不在 HTTP 层,而是怎么从大文件里精确截取一个字节区间。最直接也最危险的做法是一次把整个文件读进内存,再切数组;这个示例里用的是RandomAccessFile按偏移量读取,只把当前要传的分片载入内存。

private String uploadPart( MinioClient client, String bucket, String object, String uploadId, int partNumber, Path filePath, long offset, int partSize) throws Exception { // 只读取当前分片对应的字节区间,最后一个分片可能不足 partSize byte[] content; try (RandomAccessFile raf = new RandomAccessFile(filePath.toFile(), "r")) { raf.seek(offset); content = new byte[partSize]; int len = raf.read(content); if (len < 0) len = 0; content = Arrays.copyOf(content, len); } // uploadPart 的 data 必须显式指定长度,避免 SDK 内部做未知长度缓冲 UploadPartResponse response = client.uploadPart( UploadPartArgs.builder() .bucket(bucket) .object(object) .uploadId(uploadId) .partNumber(partNumber) .data(new ByteArrayInputStream(content), content.length) .build()); // ETag 是后续 complete 阶段要做匹配的关键,必须返回给调用方记录 return response.etag(); }

三个参数要注意。第一,offset的计算公式是(partNumber - 1) * partSize,源文件不需要真正切开,只按位置读。第二,partNumber从 1 开始,且不能重复;重复上传同一个 partNumber 会覆盖服务端该分片的内容。第三,data必须传入内容长度,这是很多人忽略的细节:不告诉 SDK 长度,它会认为这是一个未知长度的流,内部可能需要先整体缓存才能计算分片边界,这会让内存控制失效。

3.3 第三步:线程池并发上传,每传完一片立刻记录

串行循环做分片上传虽然简单,但没有发挥分片的意义。示例的推荐做法是固定线程池,逐个提交每个 partNumber 的任务,任务内部把“读取分片、上传、记录 etag”打包成一个原子操作:

public String uploadFile(String bucket, String object, Path filePath) throws Exception { long fileSize = Files.size(filePath); int totalParts = (int) ((fileSize + DEFAULT_PART_SIZE - 1) / DEFAULT_PART_SIZE); String uploadId = recordStore.loadOrCreate(bucket, object); ExecutorService pool = Executors.newFixedThreadPool(UPLOAD_CONCURRENCY); List<Future<String>> futures = new ArrayList<>(); for (int partNumber = 1; partNumber <= totalParts; partNumber++) { // 续传的核心判断:已经成功过的分片直接跳过 if (recordStore.containsPart(uploadId, partNumber)) { continue; } long offset = (long) (partNumber - 1) * DEFAULT_PART_SIZE; int size = (int) Math.min(DEFAULT_PART_SIZE, fileSize - offset); final int partNo = partNumber; futures.add(pool.submit(() -> { String etag = uploadPart(client, bucket, object, uploadId, partNo, filePath, offset, size); recordStore.markPart(uploadId, partNo, etag); return etag; })); } // 等待所有分片任务结束,任何一个失败都会在这里抛出 for (Future<String> f : futures) { f.get(); } pool.shutdown(); return completeMultipart(bucket, object, uploadId); }

这段代码值得反复看的就一个点:recordStore.markPart必须放在上传成功之后立即执行,而不是攒到最后批量提交。原因很直接——只有落进持久化记录里的 ETag 才算数。进程在那批f.get()之前崩溃,内存里的记录全部丢失,续传就无从谈起。

这里也暴露了一个设计取舍:Future只是等待结果,重试逻辑并不在示例代码里。我会在完成任务后对失败的分片做有限次重试,重试时重新调用uploadPart并用新返回的 etag 覆盖旧记录,而不是直接让整个任务失败。

3.4 第四步:complete 合并与最终校验

所有分片都成功、记录也齐全之后,进入 complete 阶段:

public String completeMultipart(String bucket, String object, String uploadId, long expectedSize) throws Exception { // 从本地记录取出每一片的 etag,按 partNumber 排序后提交 List<Part> parts = recordStore.listParts(uploadId).stream() .sorted(Comparator.comparingInt(UploadPartInfo::partNumber)) .map(p -> new Part(p.partNumber(), p.etag())) .collect(Collectors.toList()); CompleteMultipartUploadResponse response = minioClient.completeMultipartUpload( CompleteMultipartUploadArgs.builder() .bucket(bucket) .object(object) .uploadId(uploadId) .parts(parts) .build()); // complete 成功后立即 statObject,校验远端对象大小和源文件是否一致 StatObjectResponse stat = minioClient.statObject( StatObjectArgs.builder().bucket(bucket).object(object).build()); if (stat.size() != expectedSize) { throw new IOException("complete 后对象大小与源文件不一致,分片列表不完整"); } return response.etag(); }

Part只有 partNumber 和 etag 两个字段,顺序必须和上传时一致。complete 阶段最常见的报错有两类:InvalidPart说明某个 etag 和本地记录对不上;NoSuchUpload说明 uploadId 对应的会话在服务端已经失效。前者查本地记录是否被覆盖错,后者只能 abort 旧会话、重新 init 一个全新的 uploadId,所以我在UploadRecord里建议带时间戳,超过 24 小时的分片记录直接作废重建。

4. 本地跑通与避坑记录:Docker 起 MinIO、压测、五个常见坑

4.1 把示例工程跑起来的完整步骤

先用 Docker 把 MinIO 服务端拉起来。分片上传和单 PUT 在行为上有明显差异,必须拿真实服务端验证,不能用本地 Mock:

# 启动 MinIO:9000 是 S3 API 端口,9001 是网页控制台 docker run -d -p 9000:9000 -p 9001:9001 \ -v /tmp/minio-data:/data \ -e MINIO_ROOT_USER=minioadmin \ -e MINIO_ROOT_PASSWORD=minioadmin \ minio/minio server /data --console-address ":9001"

接着把工程里的application.yml改成对应配置:

minio: endpoint: http://127.0.0.1:9000 access-key: minioadmin secret-key: minioadmin bucket: dev-bucket upload: part-size: 10485760 # 10MB,MinIO 除最后一片外不允许小于 5MB concurrency: 8

Maven 引 SDK 就按标准的io.minio:minio依赖来,版本号以你自己仓库能拉到的最新 8.x 为准。工程里带一个演示 main 类,运行参数依次是 bucket、object、文件路径:

mvn -q compile exec:java \ -Dexec.mainClass=com.example.upload.UploadDemo \ -Dexec.args="dev-bucket /tmp/big-file.dat"

如果是第一次跑,记得先建桶,否则调用createMultipartUpload会直接报桶不存在的错误。

4.2 本地压测能验证什么

压测前先准备一个足够大的测试文件,避免小文件的固定开销干扰结果:

# 生成 1GB 的测试文件,内容无所谓,只看传输 dd if=/dev/zero of=/tmp/big-file.dat bs=1M count=1024

我在同一台笔记本上对同一个 MinIO 容器做过对照,结果能清楚看到分片的收益:

上传方式100MB1GB
单线程 putObject约 2.4 秒约 30 秒
8 并发分片(10MB 每片)约 0.9 秒约 15 秒

这个对比不是硬件评测,意义只在于证明“并发分片确实能把本机带宽和磁盘空档利用起来”。如果你在自己机器上跑出来分片比直传还慢,不用慌,大概率是文件太小,或者跑在本地回环链路上,分片的收益场景是跨网络、弱网、大文件。

完整性的验证也不能只看“上传没报错”。我习惯的做法是 complete 之后立刻statObject比对 size,再把文件下载回来和源文件做一次 SHA-256 校验。多分片拼接的对象,最怕本地记录漏了一个 part 却 complete 成功,MinIO 有时会以“缺了尾片”的方式默默生成一个不完整对象,只看日志发现不了。

4.3 常见问题排查:现象、原因、解决方案

这里五个坑是我拆这个示例时实际踩过的,按“现象、原因、解决”记录下来。

坑 1:小文件走分片反而变慢

现象:几百 KB 的文件也走完整分片逻辑,总耗时比直接 PUT 多出一倍。

原因:multipart 固定多了 init 和 complete 两次控制请求,分片越多 HTTP 请求数越多,固定开销摊在小文件上特别不划算。

解决:在uploadFile入口做大小判断,小于 64MB 直接丢给putObject,超过再进分片流程。

坑 2:进程重启后断点续传失效

现象:上传到一半重启应用,再跑还是从头传起。

原因:uploadId 和 etag 只存在内存 Map 里,进程一结束记录就没了;服务端那次 multipart 会话还活着,但客户端不知道它的存在。

解决:UploadRecordStore的默认实现里用本地 JSON 文件落盘,每传完一个分片就写一次。下次启动先加载记录,有 uploadId 和完整 parts 就继续,没有才重新 init。

坑 3:并发调高后 OOM

现象:partSize 调到 256MB、并发开到 16,上传中途堆溢出。

原因:示例代码用的是new byte[partSize]一次载入分片,256MB 乘以 16 个并发线程,对普通 Java 应用的内存压力是灾难性的。

解决:单分片大小控制在 10 到 50MB,要提升吞吐优先加线程数,而不是加单片大小。真要用大分片,就得改成流式读取,不能全量ByteArrayInputStream。

坑 4:complete 报 InvalidPart

现象:日志里所有分片都显示上传成功,最后一步却抛InvalidPart。

原因:某个分片在上传过程中因为网络超时被重试,重试后的 etag 和本地记录的旧 etag 不一致,complete 时服务端校验失败。

解决:markPart必须始终使用最近一次uploadPart返回的 etag 覆盖旧值。complete 前如果还有怀疑,调listParts把服务端当前的分片列表拉回来和本地记录比对。

坑 5:会话过期后报 NoSuchUpload

现象:上传中断很久再续传,第一次发分片请求就抛NoSuchUpload。

原因:MinIO 服务端对未完成的 multipart 会话有默认过期时间,或者服务端数据重建后旧 uploadId 已经不存在。

解决:恢复续传前先验证 uploadId 有效性。失效就调用abortMultipartUpload清理服务端残留分片,再重新createMultipartUpload开启新会话。

提示:MinIO 对分片大小的硬限制是“除最后一片外,单片不得小于 5MB”。示例里默认 10MB 就是为了绕开这个边界,你要是改成 1MB 想省内存,上传时会直接收到 400。

4.4 上传结果的完整性验证

除了用statObject比对 size,更严的做法是把对象拉回来做二进制摘要对比。对于分片拼接过的对象,这是证明“对象没碎”最直接的方案:

try (InputStream in = client.getObject( GetObjectArgs.builder().bucket(bucket).object(object).build())) { byte[] bytes = in.readAllBytes(); System.out.println("remote sha256 = " + DigestUtils.sha256Hex(bytes)); }

大文件全量下载算哈希太耗带宽,我通常抽样验证:取源文件和远端对象的开头 4KB、中间 4KB、末尾 4KB 分别比对。multipart 上传最容易出错的场景是某个分片缺失或 etag 顺序颠倒,抽样能覆盖绝大多数错位情况,再配合 size 校验,基本可以放心。

5. 二次开发:把示例工程改成生产可用的上传模块

5.1 分片大小和并发线程数怎么调

这两个参数直接决定内存压力和网络利用率。经验公式是:partSize × 并发数 = 峰值内存占用。8 线程乘 10MB 是 80MB 堆外加少量堆内,绝大多数服务无感;想提速到 16 线程,内存占用翻倍,但不要超过 JVM 堆的四分之一。

典型场景分片大小并发数
局域网加 SSD10MB4 到 8
跨机房百兆链路20 到 50MB8 到 16
10GB 以上大文件50MB16,注意内存

另外要盯死分片总数不超过 10000这个上限。文件 50GB、分片 5MB,算出来正好 10001 片,会死在最后一刻。反推公式是:分片大小 × 10000 > 产品定义的最大文件大小,用这个不等式倒着定分片大小。

5.2 把本地 JSON 换成 Redis 或数据库存储

示例的续传记录默认写本地文件,单机 demo 没问题;多实例部署就得分片状态,否则两台机器各传各的,谁都没法续谁。我会把UploadRecordStore提成接口,只留load和save两个方法:

public interface UploadRecordStore { UploadRecord load(String bucket, String object); void save(String bucket, String object, UploadRecord record); } // Redis 实现:Hash 结构,key 是桶加对象,field 是 uploadId public class RedisUploadRecordStore implements UploadRecordStore { private final StringRedisTemplate redis; public void save(String bucket, String object, UploadRecord record) { String key = "minio:upload:" + bucket + ":" + object; redis.opsForHash().put(key, record.uploadId(), JSON.toJSONString(record.parts())); redis.expire(key, Duration.ofDays(7)); } }

改动只发生在实现类内部,MultipartUploader对UploadRecordStore的依赖是接口,完全不用动。这就是分层隔离的好处,后续想换数据库、换本地文件,都是换一个实现类的事。

5.3 加一个前端进度回调

分片上传最容易被产品经理盯上的点是“断点续传加实时进度条”。实现上可以在分片循环里维护一个AtomicLong uploadedBytes,每成功一个分片就累加它的实际字节数,再回调监听接口:

public interface UploadProgressListener { void onProgress(long uploaded, long total); } // 每传完一片就累加字节数,注意要加实际 size,而不是每个分片都加 partSize uploadedBytes.addAndGet(uploadedSize); listener.onProgress(uploadedBytes.get(), fileSize);

WebSocket 场景里监听器内部直接向前端推消息。注意进度不能按分片个数推,最后一个分片通常不满 10MB,按个数推进会导致进度条卡在 95% 好久,用户体验很差;按字节推进就自然平滑。

这是我拆这套示例最直观的收获。从那以后每次接大文件相关需求,我都会先跟业务确认三个数:最大文件多大、网络大概多少带宽、失败能不能接受重来。这三个数决定分片大小、并发数和要不要做续传,比先选框架再调参靠谱得多。希望这份拆解能帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进