简介:这套 Java 示例基于 MinIO 对象存储,完整演示分片上传与断点续传的实现思路,面向需要处理大文件稳定传输的 Java 开发者,可有效解决普通上传超时、失败后全量重传等问题。压缩包共 13 个文件,体积仅 19KB,以 Java 后端源码为核心,另含前端页面、样式脚本以及 Maven 配置与属性文件,目录结构清晰,源码与测试分离,便于查看分片、合并及断点续传的关键逻辑。后端仅引用必要 jar 包,启动前对齐自己的 MinIO 服务参数即可;前端通过 spark-md5 计算文件唯一指纹,配合上传分片的拆分发送与合成机制,实现断点续传与秒传能力,同时 composeFile 函数注释对请求组装做了细致说明,可直接在浏览器中上传文件进行验证。该示例当前已有 20968 人学习/下载,是经过大量实践验证的高人气参考项目,适合具备一定 Java 基础、希望快速搭建生产级上传模块的开发者。
1. MinIO 分片上传,不只是把文件拆开那么简单
搞 Java 后端的人迟早会遇到一个大文件上传的坑:文件稍微大一点,HTTP 请求超时、内存撑爆、传一半断网又得从头再来。我拆过不少对象存储的上传模块,最后发现 MinIO 的 Java SDK 自带一整套分片上传(multipart upload)和断点续传机制,但官方示例很简略,真正要跑到生产环境还得自己补很多细节。这篇笔记把完整的实现链路拆开讲清楚——从 init 一个 uploadId 到逐片上传、最后合并完成,每一个环节的代码、参数选择和容易出现的问题都过一遍。适合两类人:一是项目里要接对象存储但还没选型的人,二是已经接了 MinIO 但上传大文件时性能不理想、想改成真分片上传的开发者。
2. 为什么是 MinIO:从本地磁盘到 S3 兼容协议的选型逻辑
2.1 本地磁盘存储到大文件上传的瓶颈在哪
小项目阶段,文件存本地磁盘是最省事的,一个目录加一个 URL 映射就能用。但文件量上来之后问题就多了:磁盘空间要手动扩、多台机器之间文件不共享、备份要自己写脚本。我见过一个项目把所有上传文件直接丢在应用服务器上,后来磁盘满了,排查半天才发现是日志和上传文件共用了同一块分区。换对象存储几乎是必然的选择,关键是怎么选。
MinIO 在这类场景里的定位很明确:它自己就是对象存储服务端,支持 S3 兼容协议,Java 端用它的 SDK 做上传,本质上是在和 S3 协议打交道。这意味着你写的上传代码不只适用于 MinIO,换到任何兼容 S3 的对象存储服务,代码基本不用动。这是它最值钱的地方——协议层统一,业务层不绑定某个具体厂商。从本地磁盘迁移到 MinIO 之后,文件访问、权限控制、生命周期管理这些能力都是现成的,重点是上传这个大文件的问题还远没解决。
2.2 分片上传的协议层原理:uploadId 贯穿整个生命周期
分片上传和普通 PUT 上传最大的区别在于:普通上传是客户端发一个完整的请求体,服务端一次性接收;分片上传则是把一个文件切成 N 片,一片一片往上传,最后再发一个完成指令让服务端合并。S3 协议里,这个过程分为三步:
- 第一步,客户端发起 InitiateMultipartUpload,服务端生成一个全局唯一的 uploadId 并返回。
- 第二步,客户端携带这个 uploadId,按 partNumber 逐片调用 UploadPart 上传分片。
- 第三步,所有分片传完后,客户端携带 uploadId 和全部 Part 的 ETag 列表调用 CompleteMultipartUpload,服务端将所有分片合并成完整对象。
uploadId 是整个操作的状态凭证,分片没传完之前,服务端会在内存和元数据里保留这些分片的记录。MinIO 服务端默认对未完成的 multipart 上传有清理策略——超过 7 天没 complete 的 uploadId 会被回收,分片数据也会被清理。这个参数在服务端启动时可以通过环境变量调整,但默认值就是 7 天。
2.3 工程目录结构:一个最小可跑的 Maven 项目长什么样
我拆的这份资源是一个标准的 Spring Boot 工程,包结构很清晰,分片上传的核心逻辑全部收敛在 service 层,Controller 只做参数透传和结果封装。整个项目就三个关键源码文件:
minio-multipart-demo/ ├── pom.xml ├── src/main/java/ │ └── com/example/miniodemo/ │ ├── MinioConfig.java # 客户端连接配置 │ ├── MultipartUploadService.java # 分片上传核心逻辑 │ └── UploadController.java # HTTP 接口入口 └── src/main/resources/ └── application.yml # 连接参数与自定义配置项pom.xml 里需要引入两个依赖:一个是 MinIO Java SDK,另一个是 spring-boot-starter-web。SDK 版本建议直接用 Maven 中央仓库里最新的稳定版,不要用老版本,因为旧版 SDK 里 uploadPart 的流式参数签名和现在有些出入,照着老教程写经常编译不过。MinioConfig 里做的事情很简单——读 application.yml 里的 endpoint、accessKey、secretKey,构建一个 MinioClient 单例注入 Spring 容器。这里有个细节:endpoint 不要带路径,SDK 会自己拼 bucket 路径。
3. 核心实现拆解:分片上传三步走与参数选择的完整链路
3.1 初始化上传:桶检查、分片大小计算与 uploadId 获取
上传的第一步不是直接切文件,而是先确认桶存在。桶都不存在你传什么传?SDK 里判断桶是否存在需要一个请求,如果不存在就创建一个。创建桶的时候有个坑:MinIO 默认创建的桶是私有读写权限,如果你希望上传成功后文件可以公开访问,得单独设置桶策略。这点我放在避坑章节再细说,这里先只保证上传链路通畅。
接下来是计算分片大小,这个参数直接决定上传性能和失败重试的粒度。S3 协议规定:除最后一片外,每个分片最小 5MB、最大 5GB。实际生产中不能直接用最大或最小,而是要按文件的预估大小来反推。常见做法是:给一个目标分片数(比如 1000 片),用文件大小除以目标分片数得到基础分片大小,再向上取整到 5MB 的倍数。代码实现如下:
private long calculatePartSize(long fileSize) { // S3 协议规定分片最小 5MB,最大 5GB long minPartSize = 5 * 1024 * 1024L; long maxPartSize = 5 * 1024 * 1024 * 1024L; long targetPartCount = 1000L; // 按文件大小和期望分片数计算基础分片大小 long partSize = fileSize / targetPartCount; // 对齐到 5MB 的整数倍,避免出现不合法分片大小 partSize = (partSize / minPartSize + 1) * minPartSize; // 边界约束:最小不能低于 5MB,最大不能超过 5GB return Math.max(minPartSize, Math.min(partSize, maxPartSize)); }这段代码的逻辑很直白:先按 1000 片作为期望值算出每片大约多大,然后向上对齐到 5MB 倍数。这里向上取整是为了确保除了最后一片之外的每个分片都严格大于等于 5MB。有些新手会直接拿文件大小除以 1000 就当作分片大小,算出个 4.7MB 之类的数值,传前几片就开始报错。注意Math.min(partSize, maxPartSize)的边界检查,不要传 5GB 以上的文件时算出的分片大小超过协议上限。
计算好分片大小之后,就可以发起初始化请求:
CreateMultipartUploadResponse response = minioClient.createMultipartUpload( CreateMultipartUploadArgs.builder() .bucket(bucketName) .object(objectName) .contentType("application/octet-stream") .build() ); String uploadId = response.uploadId();createMultipartUpload对应协议里的 InitiateMultipartUpload,SDK 底层发出的就是一个 POST 请求,服务端返回的 uploadId 是后续所有操作的必要参数。这里有个容易忽略的点:contentType最好在初始化时指定,如果不在这一层指定,合并完成后对象的 Content-Type 可能变成默认的application/octet-stream,前端下载文件时就会触发浏览器下载而不是预览。别笑,这问题我在生产环境见过不止一次。
3.2 并发上传分片:线程池、缓冲区与速率匹配
初始化拿到 uploadId 之后,就到了整个上传链路里最能拉开性能差距的环节——分片上传。最简单的实现是同步逐个上传,每个分片传完再传下一个,但这在网络延迟较高的场景下非常慢。正确做法是用线程池并发上传多个分片,让服务端的带宽和客户端的带宽都被充分撑满。
我一般用ExecutorService配合CountDownLatch或者CompletableFuture来做并发控制。注意,并发数不能盲目调大——每片数据都要先读进内存,如果并发数开到 50 而每片又是 20MB,一瞬间就能吃掉 1GB 堆内存,线上直接 OOM。建议并发数控制在 5~10 之间,这是成本和吞吐量的平衡点。
ExecutorService executor = Executors.newFixedThreadPool(concurrentUploadCount); CountDownLatch latch = new CountDownLatch(totalPartCount); for (int partNumber = 1; partNumber <= totalPartCount; partNumber++) { final int partNum = partNumber; executor.submit(() -> { try (RandomAccessFile raf = new RandomAccessFile(localFile, "r")) { // 每个分片独立定位文件指针,互不干扰 raf.seek((long) (partNum - 1) * partSize); byte[] buffer = new byte[partSize]; int readLen = raf.read(buffer); // 注意:最后一片的实际长度可能小于 partSize ByteArrayInputStream bais = new ByteArrayInputStream(buffer, 0, readLen); UploadPartResponse uploadPartResponse = minioClient.uploadPart( UploadPartArgs.builder() .bucket(bucketName) .object(objectName) .uploadId(uploadId) .partNumber(partNum) .stream(bais, readLen, partSize) // 流、长度、分片大小 .build() ); // ETag 必须保存,complete 时需要用到 parts.add(new Part(partNum, uploadPartResponse.etag())); } catch (Exception e) { // 记录失败的分片号,便于后续重试 failedParts.add(partNum); } finally { latch.countDown(); } }); } latch.await();这段代码里每个任务都独立打开文件并 seek 到自己的偏移量,是空间换时间的做法。虽然每个线程都打开了一次文件句柄,但避免了多个线程共享一个InputStream导致的指针错乱问题。readLen做了一层保护——最后一片文件读不满partSize时,实际传入uploadPart的字节数是readLen而不是partSize,这个细节不处理,最后一片的校验和一定会失败。
UploadPartResponse回调里有几个关键参数:stream是分片数据的输入流,readLen是当前分片的实际长度,partSize是分片上限。三个参数的含义分别是“数据从哪读”“这一片实际多大”“这一片最多允许多大”。服务端会用partSize做边界校验,用readLen决定实际接收长度。这里如果不小心把readLen传成了partSize,最后一片会因为超长而抛异常。
3.3 合并分片:ETag 列表的顺序与完整性
所有分片上传完成后,最后一步是发送completeMultipartUpload请求。这一步看似简单,但因为分片上传是并发执行的,parts列表里的顺序可能不是按 partNumber 排好的,而协议要求 ETag 列表必须按分片号升序排列。
// 按分片号排序,协议明确规定必须升序排列 parts.sort(Comparator.comparingInt(Part::partNumber)); CompleteMultipartUploadResponse completeResponse = minioClient.completeMultipartUpload( CompleteMultipartUploadArgs.builder() .bucket(bucketName) .object(objectName) .uploadId(uploadId) .parts(parts) .build() );排序这行代码看似多余,实则救了很多人的命。我在自己项目里第一次写完就漏了排序,结果传完 20 个分片后 complete 一直报InvalidPart错误,排查半天才发现部分分片乱序返回。CompleteMultipartUploadResponse返回后可以从返回对象里拿到最终对象的 ETag,这个 ETag 和直接一次性 PUT 上传得到的 ETag 规则不一样——分片合并后的 ETag 通常带一个-N后缀表示分片数量,这是区分“直传”和“分片传”的标记。
合并完成后还有一步收尾操作:检查合并结果。S3 协议里 complete 返回时对象已经可见,但 MinIO 在极少数情况下会有延迟,所以做完 complete 后建议立刻发起一个statObject请求确认对象存在、大小与本地文件一致。这一步不是必须的,但我每次都会加,尤其在网络不稳定的环境里,它能帮你快速发现“客户端以为传完了但服务端实际没合上”的幻象。
4. 断点续传实战:用本地索引表找回之前的上传进度
4.1 断点续传的本质:uploadId 是唯一的状态恢复凭证
断点续传这个词听起来很玄,实际上就是回答一个问题:上传传了一半程序崩了,怎么从传输中断的位置继续而不是从头开始?答案很朴素——把分片上传的中间状态记下来,下次启动时读取中间状态,跳过已经传完的分片,只补传剩下的。
S3 协议里断点续传的凭证有两个:一个是 uploadId,另一个是本地已经传完的分片号集合。uploadId 代表服务端还记得这次上传操作,分片号集合代表客户端知道哪些数据已经安全落地。两者缺一不可——只有 uploadId 没有分片号,你只能重新查询已经传了哪些分片,多一次网络往返;只有分片号没有 uploadId,服务端不认识你,所有分片都要重传。
所以工程上最稳妥的做法是:在本地持久化一份上传元数据文件。我一般用一个简单的 JSON 文件记录 bucket、objectName、uploadId、partSize 和已完成的分片号列表。上传中断后重新启动,程序读这个文件就能精确知道从哪里继续。
4.2 查询已上传分片:listParts 与本地记录的双保险
支持断点续传的程序,启动时通常面对两种状态:本地有元数据文件和本地没有元数据文件。没有元数据文件就是全新上传,走 3.2 节的正常流程;有元数据文件就要先做一次对账,确认服务端的分片状态和本地记录是否一致。
对账的方式是调用 SDK 的listParts方法,传入 bucket、objectName 和 uploadId,服务端会返回这个 multipart 上传当前已有的全部分片列表:
ListPartsResponse listPartsResponse = minioClient.listParts( ListPartsArgs.builder() .bucket(bucketName) .object(objectName) .uploadId(uploadId) .build() ); // 将服务端已有的分片号收集成一个集合,用于恢复时跳过 Set<Integer> uploadedPartNumbers = listPartsResponse.parts().stream() .map(Part::partNumber) .collect(Collectors.toSet());这里有三种情况值得说明。第一种,本地记录的服务端也都有,继续补传缺失的分片即可。第二种,本地记录有某分片但服务端没有,说明上传完成后服务端清理了这个不完整的 multipart 记录,此时本地元数据就是脏数据,需要丢弃并重新初始化。第三种,本地没有记录但服务端有,说明上次中断后元数据文件丢失了,这时要么放弃之前的进度重新上传,要么用 listParts 拉取服务端状态重建本地索引——我一般直接放弃,因为重建索引意味着要重新拉取整份分片列表,成本不低。
4.3 完整恢复流程编排:从扫描文件到补偿上传
把上面的内容串起来,一个可运行的断点续传服务的主流程大概长这样:
public String uploadWithResume(String bucketName, String objectName, String localFilePath) { // 1. 读取本地元数据,判断是否存在未完成的上传任务 UploadMeta meta = readLocalMeta(objectName); String uploadId; Set<Integer> uploadedParts = new HashSet<>(); if (meta != null) { // 2. 已有未完成任务,用本地记录的 uploadId 查询服务端状态 uploadId = meta.getUploadId(); uploadedParts = fetchServerParts(bucketName, objectName, uploadId); } else { // 3. 全新上传,创建新的 multipart 上传任务 uploadId = initMultipartUpload(bucketName, objectName); } // 4. 跳过已上传分片,只补传尚未成功的分片 List<Part> parts = uploadMissingParts( bucketName, objectName, uploadId, localFilePath, partSize, uploadedParts ); // 5. 全部补齐后合并对象 completeMultipartUpload(bucketName, objectName, uploadId, parts); // 6. 成功后删除本地元数据文件 deleteLocalMeta(objectName); return objectName; }这一段流程的关键点在第 4 步——并发上传时给每个分片加一层“是否已存在”的判断,存在就直接跳过,不存在才真正发起上传请求。这样断点续传的恢复成本只和缺失分片数成正比,而不是和文件大小成正比。一个 2GB 的文件如果只差最后一片,恢复时只传 5MB 的数据,速度几乎是无感的。
有一个容易被忽略的边界:断点续传和并发上传在同一个任务里共存时,parts列表的构建必须考虑已存在的分片和本次新上传的分片如何合并。顺序无所谓,反正最后统一排序;关键是不要重复——同一分片号既出现在服务端已有集合里边、又出现在本地新上传集合里,complete 时就会报InvalidPart。解决的办法是维护一个线程安全的ConcurrentHashMap<Integer, Part>,写入时按分片号为 key 覆盖写入,最后取出做一次排序即可。
5. 性能调优与避坑指南:参数配置、异常现象与处理记录
5.1 分片大小与并发数的最优配比
分片大小和并发数是影响上传吞吐最重要的两个自由参数。分片太大,单次请求持续时间长,失败重试的代价高;分片太小,请求数量多,服务端处理请求的固定开销占比高,吞吐上不去。我踩过一轮之后总结出一个经验区间:单分片 8MB~32MB、并发 5~10 时,大多数内网场景都能跑满带宽;外网场景建议分片 8MB、并发 5,能兼顾稳定性和速度。
分片并发和线程池大小是两个概念,分片并发是指同一时间有多少分片在上传,线程池大小决定系统能同时支撑多少分片上传操作。下面的配置参数是我在一个模拟生产环境里验证过的:
| 参数 | 推荐值 | 说明 |
|---|---|---|
| partSize | 8MB | 基本盘,平衡请求数量与单请求耗时 |
| maxConcurrentUploads | 5 | 默认并发,避免大文件突发 OOM |
| 线程池队列 | 有界队列 | 无界队列在极端情况下会内存打爆 |
| socketTimeout | 300s | 网络抖动时给足够的重试时间 |
| retryCount | 3 | SDK 默认有重试,但次数不够 |
5.2 五个高频踩坑记录与解决办法
这里整理一下我实际运行中遇到过的坑,按“现象 → 原因 → 解决”的模板来写,每一条都是血泪经验换来的。
坑一:小分片报 EntityTooSmall 错误。
- 现象:上传了十几片,到第 5 片左右开始抛
EntityTooSmall,提示单个分片必须大于等于 5MB。 - 原因:计算分片大小时直接拿文件大小除以目标分片数,没有向上对齐到 5MB 的整数倍。比如 100MB 文件除以 30 片约等于 3.3MB,远低于协议下限。
- 解决:分片大小统一经过
calculatePartSize方法处理,严格保证除最后一片外所有分片都在 5MB 以上,最好对齐到 5MB 的整数倍。对齐到整数倍不只是为了满足协议,还能让服务端合并时减少内存碎片的产生。
坑二:并发上传导致 JVM 堆内存飙升。
- 现象:文件不大但并发开到了 20 以上,上传跑到一半 GC 频繁,甚至直接 OOM。
- 原因:每个分片上传前都把全部分片数据读进内存,并发数乘以单分片大小就是瞬间的内存增量。20 并发乘以 10MB 分片就等于 200MB 额外内存,堆不大的 JVM 直接扛不住。
- 解决:把分片并发限制在 5~10 之间。如果确实希望跑更高并发,就改用
RandomAccessFile按需读入,不要一次性把整片数据 load 到内存。
坑三:complete 时报 InvalidPart 错误。
- 现象:所有分片上传都显示成功,但 complete 时服务端抛
InvalidPart,提示某个分片号不存在或 ETag 不匹配。 - 原因:最常见的就是 parts 列表没按分片号排序,其次是个别分片因为网络抖动被服务端判定为失败,但客户端代码把异常吃掉没有上报。
- 解决:complete 前强制按
partNumber排序;分片上传的异常不要只打日志,要抛到上层走重试逻辑。我习惯在并发上传结束后检查failedParts集合,非空就触发重传而不是直接 complete。
坑四:上传成功后对象 Content-Type 不对。
- 现象:上传一个 PDF 文件,下载后浏览器直接当二进制文件处理,无法在线阅读。
- 原因:初始化 multipart 上传时没有指定
contentType,服务端默认将对象标记为application/octet-stream,后续 merge 完成也不会自动修正。 - 解决:在
createMultipartUpload时根据文件扩展名显式设置contentType。注意这个参数是属于 object 的,不是属于某个分片的,所以只需要初始化时设置一次。
坑五:网络闪断后重新续传居然从头开始了。
- 现象:上传中断后重启程序,发现并没有从上次断点继续,所有分片重新传了一遍。
- 原因:本地元数据文件没有落盘。程序退出或者 JVM 崩溃时,内存里的元数据全部丢失,重启后没有 uploadId 可查,只能当作全新上传。
- 解决:每上传完一个分片就立即更新本地元数据文件的已上传分片记录,用临时文件加 rename 的方式写入避免元数据文件损坏。不要等全部传完再写文件,那是拿内存赌稳定性。
6. 从示例到生产:二次开发中我建议优先改的三个地方
拿到这份资源先跑通 Demo 是第一步,但真正落到生产环境,我个人会优先做三处改造,每一处都能避免一个潜在线上事故。
第一处是线程池全局化。示例代码里直接newFixedThreadPool在每次上传时创建线程池,用完就丢,高并发场景下频繁创建线程的成本不可忽视。改造思路是在 Spring 容器里维护一个全局的线程池实例,接口只需要关心并发数参数,线程池本身复用。线程池的队列改用有界队列并设置一个合理的拒绝策略,防止瞬时并发把所有请求都堆积在内存里。
第二处是元数据落盘方案升级。本地 JSON 文件方案适合单机场景,如果应用是多实例部署,必须把元数据放到缓存中间件里,以 objectName 加 uploadId 作为复合键保存分片上传进度。实现上只需定义一个接口抽象出来,本地文件实现和 Redis 实现各写一套,业务层不关心存取细节。这样即使某个实例挂了,另一个实例拿到同一个 objectName 的 uploadId 也能继续补传,真正的无损故障转移。
第三处是验证与回归手段补全。我会在改造完成后写一个冒烟脚本,测试用例覆盖三段场景:分片大小为边界值的合法验证、并发数为 1 和 10 的上传耗时对比、随机中断进程后的断点恢复。每次改完代码都跑一遍这三个用例,能保证你对上传逻辑的任何改动都不会破坏掉之前的稳定性。改代码不可怕,可怕的是改完不知道出了什么问题。
这份资源的价值不在那几行示例代码本身,而在于把分片上传的骨架搭好了,让你能在此基础上按自己的场景做扩展。从那以后我每接一个要传大文件的项目,都强制先走一遍这个流程——分片大小算一遍、并发参数调一遍、断点恢复测一遍,确认无误再放给业务方用。折腾一次,后面省心很久。希望这篇拆解对你也有同样的帮助。
本文还有配套的精品资源,点击获取