☰
Java 对接 MinIO 大文件分片上传与断点续传:参数调优与避坑
2026/10/24 5:59:41 网站建设 项目流程

简介:面向 Java 后端开发者的 MinIO 大文件上传示例,聚焦分片上传与断点续传两个高频需求,适合已掌握 Spring Boot 基础、需要落地对象存储方案的初中级工程师参考。压缩包共 13 个文件、约 19KB,体量精简且不含无关依赖:7 个 java 文件承载服务端核心逻辑与测试用例,2 个 js 配合 html、css 构成前端交互页面,pom.xml 与 properties 负责依赖声明与服务连接参数,开箱即可对照阅读。前端用 spark-md5 计算文件指纹并切片,后端完成分片接收、合并与校验,断点续传的状态流转在代码中清晰可见;配置文件中的服务地址、密钥需与本地 MinIO 实例保持一致,composeFile 函数的注释也标注了可调参数,上传一个文件即可跑通全流程。已有 20970 人学习下载,可作为接入 MinIO 时的最小可用骨架与排错参照。

1. 大文件传到 90% 断网:分片上传与断点续传真正解决的三个问题

一个几 GB 的备份包传到九成断了,客户端重来一遍,前面几分钟白烧——这大概是每个用 java 对接对象存储的人都会碰到的一次。minio 的分片上传(multipart upload)加上断点续传,解决的正是这件事:把大文件切成固定大小的片、并发发出去、失败只补失败的那一片,进程重启后靠 uploadId 接着传。它适合后端要传日志包、数据库备份、音视频素材、离线安装包的同学,尤其是内网带宽一般、链路还经常抖的场景。但真落地会发现,SDK 给的是零件不是成品:分片多大、并发几路、断点信息存哪、失败怎么收尾,全得自己定。下面按我拆过的一版实现往下走。

2. 拆开 MinIO 的分片模型:五个必填参数先对齐

分片上传不是把文件切开发出去就完事,它是一套有状态的服务端流程。先把协议侧的状态机搞清楚,后面写代码才不会一边跑一边猜。

2.1 S3 兼容协议下的分片生命周期

一次完整的分片上传分四步:CreateMultipartUpload拿到一个 uploadId,之后所有分片都挂在这个 id 下;UploadPart逐片上传,每片带一个从 1 开始的 partNumber;CompleteMultipartUpload提交一个分片清单,服务端按清单顺序把片拼成最终对象;中途不要了就AbortMultipartUpload把服务端占用的分片空间释放掉。

这套流程有几条硬约束,绕不过去:分片号范围是 1 到 10000,也就是说单次分片上传最多一万片;除最后一片外,每片不能小于 5MiB;单片上限 5GiB;对象整体上限 5TiB。这些数字直接决定了 partSize 的下限和上限,第 3 章算 partSize 就是从这三个常量推出来的。

还有一个容易忽略的点:每个分片返回的 ETag 是该片内容的 MD5 十六进制串,而合并后对象的 ETag 变成「所有分片 MD5 拼起来的 MD5 + 连字符 + 分片数」。这意味着两件事:一是你没法用整体 MD5 去和对象 ETag 直接比对,校验得按片或者下载后重算;二是 complete 时提交的 ETag 必须和服务端记录的一致,任何一个字节对不上都会被打回。

最后是状态可见性。uploadId 创建之后,在 complete 或 abort 之前,服务端一直保留着已上传的分片并占用存储。客户端进程崩了、机器重启了,服务端并不知道你还要不要,这些分片会一直躺着。很多团队月底发现存储用量对不上账,就是这批孤儿分片没清。

2.2 客户端与连接池:先把地基打对

分片上传的吞吐,一半取决于 partSize 和并发度,另一半取决于 HTTP 连接能不能复用。SDK 默认的连接池参数是给「偶尔传个小文件」设计的,跑并发分片会明显不够用:连接频繁新建、握手开销堆上去、高并发下还可能因为连接回收出现偶发的写失败。

所以我的习惯是先自己构造一个 OkHttpClient 再塞给 MinioClient,把连接池、超时、重试三件事显式定下来:

import io.minio.MinioClient; import okhttp3.ConnectionPool; import okhttp3.OkHttpClient; import java.util.concurrent.TimeUnit; public class MinioClientFactory { public static MinioClient build(String endpoint, String ak, String sk) { // 连接池大小要 >= 分片并发数,否则任务会在取连接上排队 ConnectionPool pool = new ConnectionPool(64, 5, TimeUnit.MINUTES); OkHttpClient http = new OkHttpClient.Builder() .connectionPool(pool) .connectTimeout(10, TimeUnit.SECONDS) // 建连阶段,短一点便于快速失败 .writeTimeout(10, TimeUnit.MINUTES) // 单片写超时,按 partSize/带宽 估 .readTimeout(10, TimeUnit.MINUTES) .retryOnConnectionFailure(true) // 只覆盖连接级失败,不覆盖业务重试 .build(); return MinioClient.builder() .endpoint(endpoint) .credentials(ak, sk) .httpClient(http) .build(); } }

连接池的maxIdleConnections建议设成并发数的 1.2 到 2 倍,多出来的部分用来兜住突发。keepAliveDuration给 5 分钟,比大多数中间设备的空闲断连阈值短,能少踩一类「连接看着还在、写出去直接断」的问题。

writeTimeout是最容易拍脑袋填的一个。它应该按「单片大小 ÷ 单连接最低保证带宽 × 安全系数」来估:8MiB 的片、按 2MB/s 估,就是 4 秒,乘 10 倍安全系数给 40 秒;如果链路抖动大,给到几分钟也合理。填太小,链路一慢就整片重传,反而更慢;填太大,真断了要等很久才感知。

retryOnConnectionFailure只处理连接层失败,对 5xx、超时这类业务失败不生效,所以不要指望它兜底,业务侧的重试逻辑得自己写,第 3 章会讲。

2.3 最小可跑的一次分片上传

先把最短路径跑通,再谈优化。下面这段能完整传一个文件,没有任何断点续传能力,但它把四个 API 串起来了:

import io.minio.*; import io.minio.messages.Part; import java.io.File; import java.io.FileInputStream; import java.io.InputStream; import java.util.ArrayList; import java.util.List; public class SimpleMultipart { // 单片 8MiB,满足 >=5MiB 的下限,也留了足够余量 private static final long PART_SIZE = 8L * 1024 * 1024; public static void upload(MinioClient client, String bucket, String object, File file) throws Exception { long fileSize = file.length(); // 第一步:拿 uploadId,顺带把 contentType 定下来 String uploadId = client.createMultipartUpload( CreateMultipartUploadArgs.builder() .bucket(bucket) .object(object) .contentType("application/octet-stream") .build() ).result().uploadId(); List<Part> parts = new ArrayList<>(); try { long offset = 0; int partNumber = 1; while (offset < fileSize) { // 最后一片可能不足 PART_SIZE,这里必须按实际剩余长度算 long len = Math.min(PART_SIZE, fileSize - offset); // 每次重新打开流,避免 InputStream 复用的坑 try (InputStream in = new FileInputStream(file)) { skipFully(in, offset); UploadPartArgs args = UploadPartArgs.builder() .bucket(bucket) .object(object) .uploadId(uploadId) .partNumber(partNumber) .stream(in, len, -1) // 关键:长度为 len,分片参数 -1 表示不再二次切分 .build(); Part part = client.uploadPart(args).result().part(); parts.add(part); } offset += len; partNumber++; } // 第二步:提交清单,顺序必须是 partNumber 升序 client.completeMultipartUpload( CompleteMultipartUploadArgs.builder() .bucket(bucket) .object(object) .uploadId(uploadId) .parts(parts) .build() ); } catch (Exception e) { // 第三步:失败一定要 abort,否则分片一直占着存储 client.abortMultipartUpload( AbortMultipartUploadArgs.builder() .bucket(bucket) .object(object) .uploadId(uploadId) .build() ); throw e; } } private static void skipFully(InputStream in, long n) throws Exception { long remain = n; while (remain > 0) { long skipped = in.skip(remain); if (skipped <= 0) { if (in.read() == -1) throw new IllegalStateException("文件长度不符"); skipped = 1; } remain -= skipped; } } }

几个参数值得单独说。stream(in, len, -1)的三个参数依次是流、本片长度、分片大小。第三个填 -1 表示「这一段整体作为一片」,SDK 不会在内部再切。很多人这里填错成PART_SIZE或者fileSize,结果要么 SDK 内部又切了一层,要么读越界,最终表现是 complete 之后对象大小对不上。

partNumber必须从 1 开始、连续递增,中间不能跳号。跳号不会在上传时报错,只会在 complete 时返回InvalidPart。

skipFully这段看着啰嗦但省不掉。InputStream.skip的返回值是「实际跳过的字节数」,可能小于请求值,直接用一次 skip 会导致读到错误位置,传上去的数据错位——这种错误不会报异常,只会静默地传出一个坏文件。

abortMultipartUpload放在 catch 里,是这段代码里最容易被删掉的一行。它不影响功能,只影响你的存储账单。

最后提醒一句:SDK 的方法签名在不同大版本之间有过调整,stream的入参形式、uploadPart的返回封装都可能略有差异,以你实际依赖的那个版本的 SDK 源码为准,别照抄网上的旧例子。

3. 把吞吐拉起来:partSize、并发度与内存的三方平衡

跑通之后就到了真正花时间的部分。很多人以为并发度越高越快,实测下来往往是先撞内存、再撞连接数、最后发现自己其实卡在磁盘或者带宽上。这一章把这三个变量拆开算。

3.1 partSize 怎么算:从 5MiB 下限到 10000 片上限

partSize 的取值区间由协议常量卡死,但在这个区间里怎么选,取决于文件大小和你的失败重传成本。

先看下限。5MiB 是硬性规定,低于它 complete 必定失败,这是第 5 章第一条坑的根源。再往上看,partSize 越小,单片传得越快、失败重传的代价越小,但分片总数变多,createMultipartUpload 之后的元数据、连接复用、complete 时的清单体积都会涨;partSize 越大,总片数少、调度开销小,但单片的失败重传成本直线上升,一个 256MiB 的片传了 90% 断了,前面那几十秒全废。

我一般用这个公式起步:

public class PartSizeCalculator { private static final long MiB = 1024L * 1024L; private static final long MIN_PART_SIZE = 5L * MiB; // 协议下限 private static final long MAX_PARTS = 10_000L; // 协议上限 private static final long DEFAULT_PART_SIZE = 8L * MiB; // 经验默认值 public static long calc(long fileSize) { // 小文件直接走 putObject,不走分片 if (fileSize <= MIN_PART_SIZE) { return fileSize; } // 保证片数不超过 10000 long byCount = (fileSize + MAX_PARTS - 1) / MAX_PARTS; long raw = Math.max(DEFAULT_PART_SIZE, byCount); // 向上对齐到 1MiB,避免出现非整数倍的零碎片 return ((raw + MiB - 1) / MiB) * MiB; } }

这个公式的逻辑是:默认给 8MiB,只有当文件大到「按 8MiB 切会超过一万片」时才往上抬,抬的时候对齐到 1MiB。按这个算法,文件在 80GB 以内都是 8MiB 一片;超过之后按需增长,5TiB 的文件大约会落在 512MiB 一片。

8MiB 这个默认值不是拍的吗?也不完全是。它对应的重传成本大约是「几十秒」,在大多数链路上属于能忍的范围;同时一万片的上限意味着这个值能撑到 80GB,覆盖了绝大多数业务文件。真遇到几十 GB 的备份包,我更倾向于调大并发而不是调大 partSize,因为调大 partSize 会同步吃掉内存。

还有一个容易被忽略的边界:文件小于 5MiB 时不要走分片,直接putObject一次发完,多一次 create 和 complete 的往返纯属浪费。上面代码里第一行判断就是拦这个的。

3.2 线程池与背压:并发不等于线程数

并发度这个参数,最直接的约束是内存。每个分片任务在内存里持有一份数据缓冲,占用大致是并发数 × partSize。8MiB × 8 路 = 64MiB,还算温和;但如果有人把 partSize 调到 64MiB 又开 16 路,就是 1GB 堆内存,GC 一上来整个服务跟着抖。

有两种做法。一种是「读进 byte[] 再发」,实现简单,内存占用就是上面那个乘积,适合 partSize 小、并发可控的场景。另一种是「每次从文件按偏移打开流直接发」,内存只占 SDK 内部的小缓冲,代价是每片多一次文件打开,文件句柄数会涨。我一般选后者,配合前面那个skipFully,把并发度开到 8 到 16 都不会有内存压力,只需要盯着ulimit -n。

线程池的选型也有讲究。分片上传是典型的 IO 密集任务,线程大部分时间在等网络,所以线程数可以明显大于 CPU 核数。但不要用无界队列——无界队列会让任务堆积,内存压力从「并发 × partSize」悄悄变成「任务总数 × partSize」,最后 OOM 的时候你会发现堆里全是等着上传的分片。

import java.util.concurrent.*; public class UploadExecutor { public static ThreadPoolExecutor create(int concurrency) { return new ThreadPoolExecutor( concurrency, concurrency, 60L, TimeUnit.SECONDS, // 有界队列 + CallerRunsPolicy:队列满了让提交线程自己传,天然形成背压 new ArrayBlockingQueue<>(concurrency * 2), new ThreadFactory() { private int idx = 0; @Override public Thread newThread(Runnable r) { Thread t = new Thread(r, "minio-part-" + (idx++)); t.setDaemon(true); return t; } }, new ThreadPoolExecutor.CallerRunsPolicy() ); } }

CallerRunsPolicy是这里的关键。队列满了之后,提交任务的线程自己去执行,上传速度自然降下来,不会把内存撑爆。这比设一个巨大的队列然后祈祷它不满要靠谱得多。

并发度本身怎么定?我的经验是先按 4 跑一遍,看服务端和客户端的 CPU、网络是不是都闲着;如果都闲着就翻倍,直到出现下面任意一个信号为止:网络出口打满、客户端 GC 时间明显上升、服务端开始返回 5xx 或SlowDown。到了这个点再往上加就是负收益,血泪经验是「并发调到 32 之后总耗时反而变长」,因为重传率上去了。

3.3 可复用的分片上传器:代码与参数说明

把前面的东西拼起来,下面这版是我实际会用的结构。它把一个文件切成任务丢进线程池,收集每片的结果,最后统一 complete:

import io.minio.*; import io.minio.messages.Part; import java.io.File; import java.io.FileInputStream; import java.io.InputStream; import java.util.*; import java.util.concurrent.*; public class ParallelMultipartUploader { private final MinioClient client; private final ThreadPoolExecutor executor; public ParallelMultipartUploader(MinioClient client, int concurrency) { this.client = client; this.executor = UploadExecutor.create(concurrency); } public void upload(String bucket, String object, File file, long partSize) throws Exception { long fileSize = file.length(); String uploadId = client.createMultipartUpload( CreateMultipartUploadArgs.builder() .bucket(bucket).object(object) .contentType("application/octet-stream") .build() ).result().uploadId(); // 用并发安全的容器收集结果,key 是 partNumber ConcurrentHashMap<Integer, Part> done = new ConcurrentHashMap<>(); List<Future<?>> futures = new ArrayList<>(); try { long offset = 0; int partNumber = 1; while (offset < fileSize) { final long off = offset; final int pn = partNumber; final long len = Math.min(partSize, fileSize - off); futures.add(executor.submit(() -> { Part part = uploadOnePart(bucket, object, uploadId, file, off, len, pn); done.put(pn, part); return null; })); offset += len; partNumber++; } // 先把所有异常收上来,一片失败就整体失败 for (Future<?> f : futures) { f.get(); } // complete 前必须按 partNumber 升序排序 List<Part> parts = new ArrayList<>(done.values()); parts.sort(Comparator.comparingInt(Part::partNumber)); client.completeMultipartUpload( CompleteMultipartUploadArgs.builder() .bucket(bucket).object(object) .uploadId(uploadId).parts(parts) .build() ); } catch (Exception e) { client.abortMultipartUpload( AbortMultipartUploadArgs.builder() .bucket(bucket).object(object).uploadId(uploadId).build() ); throw e; } } private Part uploadOnePart(String bucket, String object, String uploadId, File file, long offset, long len, int partNumber) throws Exception { int retry = 0; while (true) { try (InputStream in = new FileInputStream(file)) { skipFully(in, offset); return client.uploadPart(UploadPartArgs.builder() .bucket(bucket).object(object) .uploadId(uploadId).partNumber(partNumber) .stream(in, len, -1) .build() ).result().part(); } catch (Exception e) { // 单片最多重试 3 次,间隔指数退避 if (++retry > 3) throw e; Thread.sleep(500L * retry); } } } private static void skipFully(InputStream in, long n) throws Exception { long remain = n; while (remain > 0) { long skipped = in.skip(remain); if (skipped <= 0) { if (in.read() == -1) throw new IllegalStateException("文件长度不符"); skipped = 1; } remain -= skipped; } } }

三个细节值得单独拎出来。

第一,重试逻辑写在uploadOnePart内部,而不是丢给外层。原因是每次重试都要重新打开流并重新 skip 到偏移位置,如果复用一个 InputStream,第二次重试读到的就是上一次剩下的数据,传上去的是坏片——这个坑我在第 5 章会展开讲。

第二,done用ConcurrentHashMap而不是ArrayList。多线程往 ArrayList 里 add 会出现覆盖和 size 不对,表现出来是 complete 时报InvalidPart,但你怎么看代码都觉得逻辑没错。

第三,complete 之前一定要排序。ConcurrentHashMap.values()的顺序是按 hash 来的,跟 partNumber 毫无关系,直接提交大概率报InvalidPartOrder。这个排序只要一行,但少写的代价是排查半天。

参数上再补一句:partSize作为入参传进来而不是写死,是为了让上层能按文件大小动态算——小文件走 8MiB,超大文件按第 3.1 节的公式抬上去。写死一个常量,早晚会在某个几十 GB 的文件上撞到一万片的上限。

4. 断点续传的持久化设计:uploadId 存哪、续传听谁的

分片上传跑通之后,断点续传才是真正区分「能传」和「能上线」的地方。核心问题只有一个:进程重启之后,你怎么知道上一次传到哪了,以及服务端那边的状态和你本地记的是不是一回事。

4.1 断点记录该存哪些字段

先明确一件事:断点记录不能只存一个 uploadId。uploadId 只解决「续到哪个会话」,解决不了「这个 uploadId 对应的是哪个文件的哪一份内容」。同一个文件名被改过内容再传一次,如果只按名字匹配,就会把新文件的片续到旧会话上,最后合并出一个四不像。

我一般按下面这张表落库,字段不多但每个都有存在的理由:

字段类型作用踩坑点
file_keyvarchar文件指纹,通常是 大小 + 首尾若干 KB 的哈希只算文件名必翻车
object_namevarchar目标对象路径含中文要统一编码
upload_idvarchar服务端会话 id会失效,不能当永久凭据
part_sizebigint创建会话时用的分片大小续传时绝不能改
file_sizebigint文件总长度用来校验指纹和算片数
statustinyint上传中/已完成/已作废不加状态没法清理
updated_atdatetime最后活跃时间过期清理的依据

part_size这一栏特别关键。它在 create 的时候定下来,后续所有分片都必须按这个大小切。如果续传时用了不同的 partSize,已传的分片和新增的分片边界对不上,complete 出来的文件就是错乱的。所以它必须持久化,不能每次现算。

file_key的生成方式,常见的做法是把文件大小和首尾各 16KB 的内容一起做哈希。全文件哈希对几十 GB 的包太慢,只算文件大小又会把「同名同大小但内容不同」的情况漏掉,首尾取值是个折中。

CREATE TABLE t_multipart_task ( id BIGINT AUTO_INCREMENT PRIMARY KEY, file_key VARCHAR(64) NOT NULL COMMENT '文件指纹', object_name VARCHAR(512) NOT NULL COMMENT '目标对象', bucket VARCHAR(128) NOT NULL, upload_id VARCHAR(256) NOT NULL COMMENT '分片会话id', part_size BIGINT NOT NULL COMMENT '分片大小,续传不可变', file_size BIGINT NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT '0上传中 1已完成 2已作废', updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_file (bucket, object_name, file_key), KEY idx_updated (updated_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

唯一索引建在(bucket, object_name, file_key)上,保证同一个文件的同一份内容只有一条有效记录。idx_updated是给清理任务扫过期记录用的,没有它,表一大清理就会全表扫。

4.2 续传判定:listParts 与本地记录对冲

有了记录,续传的判断逻辑就清晰了,但这里有个原则必须守住:以服务端 listParts 的结果为准,本地记录只负责告诉你去问哪个 uploadId。

原因是本地记录一定会过时。进程被 kill 掉的那一瞬间,可能有几片已经发出去并且成功了,但本地还没来得及写库;反过来也可能本地记了成功、实际服务端因为超时没落盘。所以每次续传都要重新问一次服务端:

public class ResumePlanner { /** 返回还需要上传的分片号列表 */ public List<Integer> plan(MinioClient client, MultipartTask task, long fileSize) throws Exception { Set<Integer> uploaded = new HashSet<>(); try { ListPartsResponse resp = client.listParts( ListPartsArgs.builder() .bucket(task.getBucket()) .object(task.getObjectName()) .uploadId(task.getUploadId()) .maxParts(1000) .partNumberMarker(0) .build() ); for (Part p : resp.result().partList()) { uploaded.add(p.partNumber()); } } catch (Exception e) { // 会话已被服务端清理,整个任务作废,重新开始 if (isNoSuchUpload(e)) { return Collections.emptyList(); } throw e; } int totalParts = (int) ((fileSize + task.getPartSize() - 1) / task.getPartSize()); List<Integer> todo = new ArrayList<>(); for (int i = 1; i <= totalParts; i++) { if (!uploaded.contains(i)) { todo.add(i); } } return todo; } private boolean isNoSuchUpload(Exception e) { return e.getMessage() != null && e.getMessage().contains("NoSuchUpload"); } }

listParts单次最多返回 1000 片,超了要翻页。一万片的上限意味着最多翻 10 次,实现的时候要用partNumberMarker循环拉全,别只读第一页就开始算——只读第一页的后果是「明明传过的片被当成没传」,重复上传,速度慢一倍还不报错。

maxParts这个参数填 1000 是协议允许的上限,填大了不会更快,填小了翻页次数多。翻页逻辑记得把partNumberMarker设成上一页最后一片的 partNumber,而不是页码。

还有一种情况要单独处理:会话还在,但本地记录里的part_size和本次计算出来的不一致。这时候不能续传,必须 abort 掉重来。代码上就是拿task.getPartSize()和当前算出来的值比一下,不等就走 abort。

4.3 秒传与收尾:complete、abort、生命周期清理

进到 4.3 之前,先补一个「续传之前」的检查:文件可能已经传完了。用户重试的时候,上一次其实已经 complete 成功,只是响应超时客户端没收到。这时候再走一遍分片上传纯属浪费。

所以续传流程的第一步应该是statObject,比对已存在对象的大小:

public boolean alreadyUploaded(MinioClient client, String bucket, String object, long fileSize) { try { StatObjectResponse stat = client.statObject( StatObjectArgs.builder().bucket(bucket).object(object).build() ); return stat.size() == fileSize; } catch (Exception e) { return false; // 对象不存在或无权访问,都当没传过 } }

只比大小是有意的取舍。比 ETag 比不了(分片上传的 ETag 是拼出来的),比修改时间没意义,比大小在绝大多数场景下够用,真要求严格就再加一个业务侧的内容哈希放在对象元数据里。

收尾有三条路,分别对应三种状态:

第一条是正常完成。complete 提交的分片清单必须来自listParts(或者你自己维护的、与服务端一致的集合),且按 partNumber 升序。complete 成功之后把t_multipart_task的状态改成已完成,别急着物理删除,留几天便于对账。

第二条是主动放弃。用户取消、文件被替换、指纹变了,都走abortMultipartUpload并把状态置为已作废。abort 是幂等的,重复调用不报错,所以放心重试。

第三条是被动清理。进程崩了、机器下线了,没人调 abort,分片就一直躺在服务端。这类孤儿只能靠定时任务扫updated_at超过阈值(比如 24 小时)且状态是「上传中」的记录,逐条 abort。扫描的时候注意加个批量上限,一次捞几百条处理完再捞,别一次把全表拉进内存。

// 定时任务片段:清理超过 24 小时未活跃的会话 List<MultipartTask> stale = taskMapper.selectStale(24); for (MultipartTask t : stale) { try { client.abortMultipartUpload(AbortMultipartUploadArgs.builder() .bucket(t.getBucket()).object(t.getObjectName()) .uploadId(t.getUploadId()).build()); taskMapper.markAborted(t.getId()); } catch (Exception e) { // 会话可能已被服务端回收,忽略继续 taskMapper.markAborted(t.getId()); } }

另外,服务端侧一般也支持配置生命周期规则来清理未完成的分片上传,具体入口和字段名随部署版本不同,以你手头那套的实际配置项为准。客户端定时清理 + 服务端生命周期兜底,两层都配上,孤儿分片才不会积累成账单上的一笔糊涂账。

5. 避坑与排查:五个凌晨三点还在查的现场问题

下面这五条,基本覆盖了分片上传上线后能遇到的大部分疑难。共同点是:报错信息都不够具体,光看异常根本猜不到原因。

5.1 EntityTooSmall:每一片都成功,合并却 400

现象:日志里所有uploadPart都是 200,最后 complete 返回 400,错误码EntityTooSmall。

原因:除最后一片外,有分片的实际长度小于 5MiB。最常见的触发方式是「按文件大小动态算 partSize,但算出来的值没做下限保护」——比如文件 12MiB、并发 4 路、有人用fileSize / concurrency算片大小,得到 3MiB,前几片全部违规。还有一种更隐蔽:最后一片本来可以小于 5MiB,但代码里把「最后一片」的判断写成了offset + partSize > fileSize,边界差一,把倒数第二片也切小了。

解决:partSize 的计算入口统一走一个工具方法,进去第一件事就是Math.max(5MiB, ...),把这个下限焊死在一个地方,别散落在各处的业务代码里。切片的循环条件用offset < fileSize,片长用Math.min(partSize, fileSize - offset),这两个写法配在一起,边界就不会错。

5.2 InvalidPartOrder:清单顺序错了,或者混进了脏数据

现象:complete 返回InvalidPartOrder,或者InvalidPart。

原因有三类。一是清单没按 partNumber 升序排,ConcurrentHashMap.values()直接拿来用必挂。二是清单里混进了服务端不存在的片,常见于「本地记了成功、实际没落盘」,然后你拿本地记录去提交。三是同一个 partNumber 提交了两次,ETag 还不一样,多见于重试逻辑写错、并发写了同一个分片号。

解决:提交前一定用listParts拉一次服务端真实状态,拿它当唯一数据源,本地记录只用来算「哪些需要补传」。提交前做一次显式排序parts.sort(Comparator.comparingInt(Part::partNumber)),再做一次按 partNumber 去重。这两行代码是后悔药,加上的成本几乎为零。

5.3 重试把流吃掉了:传上去的文件是坏的,但不报错

现象:上传成功、complete 成功、对象大小也对,但下载下来校验不过,中间某一段内容是错的。

原因:重试时复用了同一个 InputStream。第一次上传失败时,流已经读了一部分,第二次直接用这个流,SDK 从当前位置开始读,读到的数据整体错位。更麻烦的是长度对得上,所以大小校验发现不了。

解决:每次上传(包括重试)都重新new FileInputStream(file)并 skip 到偏移位置。这也是我在第 3 章把重试写在uploadOnePart内部的原因——只有在那里才知道该重新打开流。顺带说一句,skip必须循环调用直到跳够字节数,单次skip返回值可能不足。

5.4 存储用量莫名涨了几百 GB:孤儿分片没人收

现象:业务侧统计的文件总量和对象存储的实际用量对不上,差得还不少,而且随着时间推移差距越来越大。

原因:createMultipartUpload 之后没有 complete 也没有 abort 的会话,服务端一直保留着已上传的分片。每次进程崩溃、每次用户取消、每次部署重启,都可能留下几个。单个看不出来,攒上几个月就是几百 GB。

解决:三件事一起做。客户端代码里,任何失败路径都要走到 abort,用 try-catch-finally 或者 try-with-resources 的思路保证不漏;服务端加定时任务扫过期会话;再配一层服务端的生命周期规则兜底。排查阶段可以先listMultipartUploads把当前所有未完成会话拉出来,按创建时间排一下,看看积压了多少、都是哪些前缀,通常一眼就能定位到是哪个业务漏了 abort。

5.5 并发一上去就 OOM 或者 Too many open files

现象:并发调到 16 之后,服务开始报 OOM,或者日志里出现Too many open files。

原因有两种。如果是 OOM,多半是每个分片把数据读进了 byte[],内存占用等于「并发数 × partSize」,partSize 一调大就直接爆。如果是打开文件过多,则是每片都新开 FileInputStream 但没关,或者连接池里的连接没释放,句柄数随着并发和时长线性上涨。

解决:OOM 就走流式读取,不要整片驻留内存,让内存占用与 partSize 解耦。句柄问题先查 try-with-resources 有没有漏,再查 OkHttp 连接池的maxIdleConnections是不是设得过大、keepAliveDuration是不是设得过长。另外线程池一定要用有界队列配CallerRunsPolicy,无界队列会把「并发压力」悄悄转换成「内存压力」,等你发现的时候堆里已经全是排队的分片了。

6. 压测与验证:用三组对照把「最佳性能」量出来

「最佳性能」这四个字不能靠感觉。我的做法是固定一台客户端、固定一个测试文件,只改一个变量跑对照,把吞吐和失败率都记下来。

对照组partSize并发观察点典型结论
A8MiB4基线吞吐、GC 次数大多数内网环境的稳妥起点
B8MiB12吞吐增幅、5xx 比例增幅趋缓就该停手
C32MiB12内存峰值、单片刻重传耗时partSize 抬上去收益通常不如加并发

跑的时候有三个数必须一起看,只看吞吐会被骗。第一个是有效吞吐,也就是「文件大小 ÷ 实际耗时」,别用 SDK 打的速度日志,那个不含重试和等待。第二个是重传率,用「实际上传字节数 ÷ 文件大小」算,正常应该在 1.05 以内,超过 1.2 说明链路或超时配置有问题,这时候吞吐再高也是假的。第三个是内存峰值,用jstat -gcutil盯老年代增长,如果每次上传都能把老年代推高一截,说明有对象没释放,跑久了必然 Full GC。

验证一致性这一步别省。分片上传最怕的不是失败,是「成功但内容错了」。我会在传完之后把对象下载回来,按 8MiB 分段和服务端 ETag 对一遍,或者干脆在对象元数据里塞一个上传前算好的整体哈希,下载后重算比对。这一步在压测阶段跑个几十次,比上线后被业务方追着问「文件怎么打不开」要省事得多。

# 单次上传的耗时与内存粗测,配合业务日志里的重传率一起看 /usr/bin/time -v java -jar upload-bench.jar \ --file /data/test-20g.bin \ --part-size 8388608 \ --concurrency 8

最后说个习惯。从那以后我每次改动上传相关代码,上线前都强制跑一遍「1GB 正常网络 + 20GB 限速网络 + 中途 kill 进程再续传」这三组,一组不过就不发。因为分片上传的坑几乎全在异常路径上,正常路径跑一百次都是绿的,真出问题的那一次往往就在你没测过的那条分支里。希望帮到你。

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

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

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

立即咨询