简介:面向 Java 后端开发者的 MinIO 分片上传与断点续传实战示例,聚焦大文件上传场景下的性能与稳定性问题,适合已具备 Spring Boot 基础、需要在项目中实现对象存储上传能力的中初级开发者参考。压缩包共 13 个文件、约 19KB,以 7 个 java 源文件承载分片切分、合并与续传的核心逻辑;js、html、css 组成简洁的前端测试页面;pom.xml 与 properties 分别提供依赖清单和 MinIO 服务连接配置,整体保持纯净,只引入必要的 jar 包。示例采用前后端分离的最小结构,便于对照调试完整上传链路与分片合并细节。目前已有 20970 人学习下载。读者可从中获得分片大小设定、并发上传控制、断点校验与秒传判断的实现思路,以及一套可直接复用的前后端上传代码与接口结构,用于改造自有项目的文件上传模块,减少重复调试与踩坑成本。
1. 为什么一个 2GB 视频会卡在 99%:MinIO 分片上传要解决的真问题
一个 2.3GB 的培训录像传到 99% 时标签页被误关,进度条归零重来——我在做 Java 后台文件服务时见过不止一次。用 MinIO 的 Java SDK 做分片上传和断点续传,本质是把「一口气传完」拆成「按块记账、断了接上」,但真正决定它能不能在生产环境稳定运行的,不是调通 createMultipartUpload,而是分片怎么切、uploadId 怎么存、失败后拿什么对账。这套示例适合正在用 Java 接对象存储、被大文件重试和内存打爆折腾过的后端同学,也适合做视频、备份包、日志归档这类中大型文件链路的场景。它不解决「怎么把文件变小」,只解决「大文件怎么稳定、可恢复、不把服务端拖垮地落进桶里」。
2. 把分片上传拆开看:MinIO 的 multipart 接口与分片规划
MinIO 兼容 S3 的 multipart upload 协议,一次完整的分片上传由三拍组成:createMultipartUpload 拿 uploadId、若干次 uploadPart 各传一片、completeMultipartUpload 按顺序合并。理解这三拍的边界,比背 API 名字重要得多——因为断点续传的全部信息都藏在中间那一拍里,而性能的全部空间都压在分片大小和并发度这两个参数上。这一章先把协议面和参数面立住,再落一个能跑的最小闭环。
2.1 putObject 与 multipart 的边界在哪
很多人第一次接 MinIO 用的是 putObject,一行代码把整个文件流塞进去,小文件没问题,一旦超过几百 MB 就开始出状况:要么客户端要一次性持有完整输入流,要么网络抖一下整条连接重来。putObject 在 SDK 内部其实也会走分片(当你传了 partSize 参数时),但它不暴露 uploadId,也就意味着你拿不到「上次传到哪」这个状态,断点续传无从谈起。
multipart 的显式接口把这层控制权交回来:uploadId 是服务端发的一张「上传会话凭证」,只要这个会话没被 abort、没被生命周期规则回收,你随时可以带着它继续传。所以选型判断很直接——文件可能超过 100MB、网络不是内网专线、业务要求失败可恢复,就走显式 multipart;纯内网、文件小、一次成功率高,putObject 足够。
几个硬边界必须记住,它们是后面所有参数计算的地基:
| 约束项 | 取值 | 影响 |
|---|---|---|
| 单分片最小 | 5MiB(最后一片除外) | 分片切太小会被服务端直接拒绝 |
| 单分片最大 | 5GiB | 超过要重新规划 |
| 分片数量上限 | 10000 | 决定分片大小的下限 |
| 单对象最大 | 5TiB | 超大文件要配合分片上限反推 |
| partNumber 起始 | 1,连续递增 | 合并时必须升序,缺号会失败 |
这张表里最容易被忽略的是「10000 片上限」。假设你固定用 8MiB 分片,理论上限就是 80GB 左右,再大就必须调大分片。我一般会写一个根据文件大小动态选分片的函数,而不是拍一个固定值。
2.2 分片大小、并发度与内存的换算
分片大小不是一个孤立的数字,它同时被三件事约束:文件大小决定了最小可用分片(文件大小 / 10000),网络带宽决定了单片传输耗时,JVM 堆决定了你能同时开几片。
先看下限。文件越大,单片必须越大,否则片数超限。计算方式是把「文件大小除以 10000」向上取整,再对齐到 2 的幂,同时不低于 5MiB:
public static long choosePartSize(long fileSize) { final long MIN_PART = 5L * 1024 * 1024; // 协议下限 5MiB final long MAX_PART = 5L * 1024 * 1024 * 1024; // 协议上限 5GiB final int MAX_PARTS = 10000; // 分片数硬上限 long need = (fileSize + MAX_PARTS - 1) / MAX_PARTS; // 至少这么大才不超片数 long size = MIN_PART; while (size < need) { size <<= 1; // 对齐到 2 的幂,便于估算缓冲 } return Math.min(size, MAX_PART); }这段逻辑的关键在循环:从 5MiB 开始翻倍,直到能覆盖 need。翻倍而不是线性增长,是为了让分片大小落在 8/16/32/64MiB 这种好估算内存的档位上。参数说明:fileSize 用字节;返回值直接喂给上传循环。需要注意,如果返回值撞到 MAX_PART 仍然不够(文件超过 5TiB 级别),那就不是分片能解决的了,得走对象拼接。
再看并发。并发度不是越高越好,它受两个上限夹击:客户端出口带宽,以及每个分片占用的缓冲区。经验值是「并发数 × 分片大小 ≈ 20~50MB 的在途数据」比较稳,比如 8MiB 分片配 4~8 并发,16MiB 分片配 4 并发。再往上堆,收益会被 TCP 拥塞和磁盘读抢占吃掉,反而让 P99 变差。
内存这块要说清楚:SDK 在处理每个分片时会持有与分片等长的缓冲,所以峰值堆占用大致是「并发数 × 分片大小 × 2」。16 并发 × 16MiB × 2 就是 512MB,这个量级在容器里很容易触发 OOM kill,血泪经验是先在压测环境把这个数打出来,再决定并发。
2.3 Java SDK 最小分片上传闭环
下面这段是能直接跑通的最小实现,串行版本,先把协议面走通,并发放到第 4 章再叠。代码里刻意保留了完整的异常回滚路径,因为「失败后不 abort」是后面资源泄漏坑的源头。
import io.minio.*; import io.minio.messages.Part; import java.io.*; import java.nio.channels.Channels; import java.nio.channels.FileChannel; import java.nio.file.*; import java.util.*; public class SimpleMultipartUpload { public static void upload(MinioClient client, String bucket, String object, Path file, long partSize) throws Exception { long fileSize = Files.size(file); // 1) 开启上传会话,拿到 uploadId String uploadId = client.createMultipartUpload( CreateMultipartUploadArgs.builder() .bucket(bucket) .object(object) .contentType("application/octet-stream") .build() ).result().uploadId(); List<Part> parts = new ArrayList<>(); try { int partNumber = 1; long offset = 0; while (offset < fileSize) { long len = Math.min(partSize, fileSize - offset); // 最后一片允许小于 5MiB try (InputStream in = slice(file, offset, len)) { UploadPartResponse resp = client.uploadPart( UploadPartArgs.builder() .bucket(bucket) .object(object) .uploadId(uploadId) .partNumber(partNumber) // 从 1 开始 .stream(in, len, -1) // 本次只读 len 字节 .build() ); parts.add(new Part(partNumber, resp.etag())); } offset += len; partNumber++; } // 2) 按 partNumber 升序合并 parts.sort(Comparator.comparingInt(Part::partNumber)); client.completeMultipartUpload( CompleteMultipartUploadArgs.builder() .bucket(bucket).object(object).uploadId(uploadId) .parts(parts) .build() ); } catch (Exception e) { // 3) 任何一步失败都要显式回滚,否则分片残留在服务端 try { client.abortMultipartUpload(AbortMultipartUploadArgs.builder() .bucket(bucket).object(object).uploadId(uploadId).build()); } catch (Exception ignore) { // abort 自身失败不覆盖原始异常 } throw e; } } // 从 offset 开始切出 length 字节的只读流,避免整文件读入内存 static InputStream slice(Path file, long offset, long length) throws IOException { FileChannel ch = FileChannel.open(file, StandardOpenOption.READ); ch.position(offset); return new LimitedInputStream(Channels.newInputStream(ch), length); } static final class LimitedInputStream extends FilterInputStream { private long remaining; LimitedInputStream(InputStream in, long limit) { super(in); this.remaining = limit; } @Override public int read() throws IOException { if (remaining <= 0) return -1; int b = super.read(); if (b >= 0) remaining--; return b; } @Override public int read(byte[] b, int off, int len) throws IOException { if (remaining <= 0) return -1; int n = super.read(b, off, (int) Math.min(len, remaining)); if (n > 0) remaining -= n; return n; } @Override public long skip(long n) throws IOException { long s = super.skip(Math.min(n, remaining)); remaining -= s; return s; } } }逻辑说明:先创建会话拿 uploadId,然后按 offset 循环切片,每片单独开一个 FileChannel 定位读取,读完即关,内存里始终只有一个分片的量。uploadPart 的返回值里带 etag,它是服务端对该片内容的校验码,必须原样收集起来,合并时逐片提交。异常路径里调用 abort,是因为未完成的分片会以隐藏形式占着服务端存储,时间一长就是一笔看不见的开销。
参数说明:stream(in, len, -1)的第一个参数是流,第二个是本片字节数,第三个传 -1 表示「这次上传只有一片」,这是 uploadPart 的用法,不要和 putObject 的自动分片参数搞混。partSize来自 2.2 的选片函数,contentType按实际文件类型填,不填也能传,但下载时浏览器行为会不一样。Part只需要 partNumber 和 etag 两个字段做合并,其余字段留空即可。
跑通这段之后,你手上就有了一个「一次成功」的版本。接下来要做的不是加并发,而是先解决「中途断了怎么办」——因为并发会把失败概率放大,没有恢复机制的并发版本只会让人更痛苦。
3. 断点续传的重心不在上传而在「记账」:uploadId 与分片状态的持久化
断点续传听起来像传输层的能力,实际是状态管理问题。服务端只认 uploadId 和已提交的分片,它不知道你本地传到了第几块;客户端如果只把进度放在内存里,进程一重启就全丢。所以这一章的核心不是「怎么传」,而是「记什么、记在哪、怎么和服务端对账」。记账做对了,续传就是几行循环;记账做错了,续传会把文件传成花屏。
3.1 断点状态存哪:三种来源的取舍
状态有三个可能来源,各自适用不同场景,我一般按「本地缓存为主、服务端查询为校准」的组合来用。
第一种是纯服务端查询,每次续传前调 listParts 拿已传分片列表。优点是永远不会和真实状态偏离,缺点是多一次网络往返,且 listParts 单次最多返回 1000 片,大文件要翻页,恢复流程会变复杂。
第二种是纯本地记录,把 uploadId、partSize、每个 partNumber 对应的 etag 写到一个状态文件里。优点是恢复快、零网络开销,缺点是本地文件可能丢、可能写坏、可能和实际不符(比如上传请求其实成功了但响应超时,本地没记上)。
第三种是两者结合:本地文件做快速恢复,启动时用 listParts 做一次对账,以服务端为准。这是我在生产环境用的方案,代价是恢复时多一次调用,换来的是「不传错」这个底线。
还有个容易被忽略的字段:partSize 必须一起记。因为分片边界由 partSize 决定,如果这次恢复用的 partSize 和上次不同,已传分片的字节区间就全错位了,合并出来的文件必然损坏。同理,文件本身的 size 和最后修改时间也要记,用来判断「是不是同一个文件」。
3.2 uploadId + partETag 的落盘与恢复
状态文件我用纯文本,一行一个分片,头部放元信息。选纯文本而不是 JSON 是为了少一层依赖,且出问题时肉眼可读。写入必须走「临时文件 + 原子移动」,否则进程在写一半时挂掉,状态文件就废了。
public final class UploadState { private final Path stateFile; private final String uploadId; private final long partSize; private final long fileSize; private final long fileMtime; private final Map<Integer, String> parts = new ConcurrentHashMap<>(); public UploadState(Path stateFile, String uploadId, long partSize, long fileSize, long fileMtime) { this.stateFile = stateFile; this.uploadId = uploadId; this.partSize = partSize; this.fileSize = fileSize; this.fileMtime = fileMtime; } public void markDone(int partNumber, String etag) { parts.put(partNumber, etag); } // 原子落盘:先写 .tmp,再 move 覆盖 public synchronized void flush() throws IOException { StringBuilder sb = new StringBuilder(1024); sb.append("v1\n") .append("uploadId=").append(uploadId).append('\n') .append("partSize=").append(partSize).append('\n') .append("fileSize=").append(fileSize).append('\n') .append("fileMtime=").append(fileMtime).append('\n'); for (Map.Entry<Integer, String> e : parts.entrySet()) { sb.append(e.getKey()).append(' ').append(e.getValue()).append('\n'); } Path tmp = stateFile.resolveSibling(stateFile.getFileName() + ".tmp"); Files.write(tmp, sb.toString().getBytes(StandardCharsets.UTF_8)); Files.move(tmp, stateFile, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); } public static UploadState load(Path stateFile) throws IOException { List<String> lines = Files.readAllLines(stateFile, StandardCharsets.UTF_8); if (lines.isEmpty() || !"v1".equals(lines.get(0))) { throw new IllegalStateException("状态文件版本不匹配"); } Map<String, String> head = new HashMap<>(); List<String> body = new ArrayList<>(); for (int i = 1; i < lines.size(); i++) { String line = lines.get(i); int eq = line.indexOf('='); if (eq > 0) head.put(line.substring(0, eq), line.substring(eq + 1)); else if (!line.isBlank()) body.add(line); } UploadState st = new UploadState(stateFile, head.get("uploadId"), Long.parseLong(head.get("partSize")), Long.parseLong(head.get("fileSize")), Long.parseLong(head.get("fileMtime"))); for (String line : body) { int sp = line.indexOf(' '); st.markDone(Integer.parseInt(line.substring(0, sp)), line.substring(sp + 1)); } return st; } }逻辑说明:flush 是同步方法,因为分片是多线程完成的,写文件必须串行化。先写 tmp 再 move,保证任何时刻磁盘上的状态文件要么是旧的完整版本,要么是新的完整版本,不会出现半截内容。load 里对首行做版本校验,将来格式升级时可以直接拒绝旧文件,而不是解析出错误状态。
参数说明:uploadId 原样保存,恢复时直接用;partSize 和 fileSize、fileMtime 三者共同构成「这个状态属于哪个文件」的指纹,恢复前逐一比对,任何一项不一致就说明分片边界不可复用,必须 abort 后重开。etag 里可能带引号,保存时不要做任何清洗,原样存原样用。
恢复流程就是:读状态文件 → 校验指纹 → 调 listParts → 取本地与服务的交集(partNumber 存在且 etag 一致)→ 从剩余的 partNumber 继续传。交集之外的分片,如果服务端有而本地没有,把服务端的 etag 补进本地;如果本地有而服务端没有,说明那一传其实没成功,删掉本地记录重传。
3.3 用 listParts 做服务端对账与残留清理
listParts 是断点续传里最值得多写几行的地方,因为它有两个容易踩的细节:分页和一致性。单次调用最多返回 1000 片,超过就要用 partNumberMarker 翻页,直到 isTruncated 为 false。如果你的文件是 5000 片,只取第一页就做对账,后面 4000 片会被误判为「未上传」,然后被重传一遍——不报错,但白传。
public static Map<Integer, String> listAllParts(MinioClient client, String bucket, String object, String uploadId) throws Exception { Map<Integer, String> result = new HashMap<>(); Integer marker = null; while (true) { ListPartsArgs.Builder b = ListPartsArgs.builder() .bucket(bucket).object(object).uploadId(uploadId) .maxParts(1000); if (marker != null) b.partNumberMarker(marker); ListPartsResult page = client.listParts(b.build()).result(); for (Part p : page.partList()) { result.put(p.partNumber(), p.etag()); } if (!page.isTruncated()) break; marker = page.nextPartNumberMarker(); } return result; }逻辑说明:用 while 循环把分页拉平成一个 Map,调用方不需要关心分页细节。marker 初始为 null,表示从头开始;每页结束后用 nextPartNumberMarker 作为下一页起点,isTruncated 为 false 时收尾。
参数说明:maxParts 设 1000 是服务端允许的上限,设小了只是多几次往返。partNumberMarker 的语义是「从该编号之后开始」,所以直接传上一页返回的 nextPartNumberMarker 即可,不要自己 +1。
对账完成后还有一件事:清理。未完成的分片上传在服务端是真实占空间的,客户端崩溃、网络中断、用户取消,都会留下残留。可以主动调用 listIncompleteUploads 遍历桶里所有未完成会话,对超过一定时间的执行 removeIncompleteUpload;更省事的做法是给桶配生命周期规则,让服务端自动回收。两条路我都用,前者用于及时释放,后者作为兜底。
4. 性能调优:并发、流式与连接池的真实参数
串行版本传 2GB 文件,在千兆内网大概要跑几分钟,瓶颈明显在「一片传完才发下一片」。并发化能把带宽吃满,但也会把内存、连接数、重试逻辑的问题同时放大。这一章讲三个参数面:并发度怎么反推、流式读取怎么不爆堆、OkHttp 连接池和超时怎么设。它们之间是乘法关系,任何一个设错,整条链路的表现都会被拖到最差的那个。
4.1 并发度怎么定:从带宽和分片大小反推
并发度的理论上限是「带宽 ÷ 单分片传输时间」,但工程上更实用的是先定在途数据量。假设出口带宽 200Mbps,单分片 8MiB,那么一片的理想传输时间是 8×8/200 ≈ 0.32 秒;如果并发 8,在途数据 64MiB,理论上能跑满带宽。但实际还要算上服务端写入、磁盘读、TCP 慢启动,所以我一般在理论值上打七折。
更稳的做法是做一次小规模压测:固定文件大小和分片大小,把并发从 4 逐步加到 32,记录吞吐和错误率。通常会出现一个拐点,拐点之后吞吐不再涨、超时开始增多,把并发定在拐点的 70% 左右。这个拐点在不同的服务端配置下差别很大,别人给的数字只能当起点。
并发实现用固定线程池加 CompletionService,谁先完成先收谁的结果,避免某个慢分片卡住整批:
ExecutorService pool = Executors.newFixedThreadPool(concurrency); CompletionService<Part> cs = new ExecutorCompletionService<>(pool); List<Future<Part>> futures = new ArrayList<>(); for (int pn : pendingPartNumbers) { // pending 来自对账结果 long offset = (long) (pn - 1) * partSize; long len = Math.min(partSize, fileSize - offset); futures.add(cs.submit(() -> { try (InputStream in = slice(file, offset, len)) { UploadPartResponse r = client.uploadPart(UploadPartArgs.builder() .bucket(bucket).object(object).uploadId(uploadId) .partNumber(pn).stream(in, len, -1).build()); state.markDone(pn, r.etag()); state.flush(); // 每片成功即落盘 return new Part(pn, r.etag()); } })); } for (int i = 0; i < futures.size(); i++) { Part done = cs.take().get(); // 谁先完成先处理 parts.add(done); } pool.shutdown();逻辑说明:提交时把每个分片的 offset 和 len 提前算好,闭包里只捕获不可变值,避免共享可变状态。每片成功后立刻 markDone 并 flush,这样即使进程在后续某片崩溃,已完成的分片也不会丢。收集阶段用 cs.take() 而不是遍历 Future.get(),前者按完成顺序返回,不会因为第一个 Future 慢而阻塞后面已完成的。
参数说明:concurrency 建议从 4 起步,按压测结果调;pool 的队列用默认无界队列即可,因为任务数就是分片数,不会无限增长。flush 每片一次会有磁盘 IO 开销,如果分片很多(几千片),可以改成「每 N 片或每 2 秒」批量落盘,代价是崩溃时可能丢最后几条记录,重传几片而已。
4.2 流式读取:别让分片把堆撑爆
第 2 章里的 slice 是流式的,但并发之后有个新问题:每个线程都在打开自己的 FileChannel,文件句柄数会随并发线性增长。一般不会成为瓶颈(几十个句柄很轻),但如果并发开到 64 以上,加上其他模块的句柄,就可能碰到系统的 ulimit。
另一个更隐蔽的问题是跳过。早期我用InputStream.skip(offset)定位,结果发现 skip 不保证跳满,某些实现会少跳,导致分片内容错位——文件能传完、大小也对,但内容校验不过。改成 FileChannel.position() 之后这个问题消失,因为它是精确定位。
堆内存这块,最省心的做法是给上传线程池配一个独立的、有界的执行器,并且严格控制并发数不超过「堆上限 ÷ (分片大小 × 2)」。比如堆 1GB,分片 8MiB,那么并发不要超过 32。超过之后不是立即 OOM,而是 GC 频率飙升,表现为上传速度忽快忽慢,这种玄学现象查起来很费时间。
还有一点:不要在上传线程里做完整文件的 md5 计算。有人为了「校验完整性」在每片上传时顺带更新一个全局 MessageDigest,结果这把锁把并发直接串行化了。真要校验,等服务端合并完成后用 statObject 拿 ETag,或者单独跑一遍离线校验。
4.3 连接池与超时:OkHttp 的三个参数
MinIO 的 Java SDK 底层用 OkHttp,所以连接复用和超时都由 OkHttp 管。默认配置在小文件场景够用,大分片场景必须调,否则会看到「传了几分钟突然 Read timed out」这种让人抓狂的现象。
OkHttpClient http = new OkHttpClient.Builder() .connectTimeout(10, TimeUnit.SECONDS) // 建连 .writeTimeout(120, TimeUnit.SECONDS) // 单次写,大分片要放宽 .readTimeout(120, TimeUnit.SECONDS) // 等响应 .connectionPool(new ConnectionPool(64, 5, TimeUnit.MINUTES)) .retryOnConnectionFailure(true) .build(); MinioClient client = MinioClient.builder() .endpoint("http://127.0.0.1:9000") .credentials(accessKey, secretKey) .httpClient(http) .build(); client.setTimeout(10_000, 120_000, 120_000); // 毫秒,与上面保持一致逻辑说明:连接池的 maxIdleConnections 要大于等于并发数,否则并发线程会排队等连接,表现成「并发设了 16 但实际只有 4 个在跑」。keepAlive 设 5 分钟,覆盖一次连续上传的时间窗,避免频繁重建连接。
参数说明:writeTimeout 和 readTimeout 要按「最大分片大小 ÷ 最慢网速」来估。8MiB 分片在 1Mbps 的链路上要 64 秒,所以 120 秒是个安全值;如果你把分片调到 64MiB,这个值就得跟着放大到 10 分钟级别。retryOnConnectionFailure 只对建连失败有效,对「传了一半断掉」无效,那部分要靠业务层重试——这也是为什么每片成功后必须立刻落盘。
调完这三个参数,同样的文件在同样的网络下,吞吐通常能翻倍。但别急着庆祝,接下来这些坑才是真正让上传链路在生产环境翻车的地方。
5. 避坑与排查:五个真实翻车现场
分片上传的失败模式有个共同特点:不报错,或者报一个和真实原因隔了很远的错。文件传完了但打不开、合并成功但大小不对、任务显示成功但桶里没有对象,这些都需要从现象倒推。下面五条是出现频率最高的,每条都按「看到什么 → 为什么 → 怎么修」写。
5.1 分片与合并环节的坑
现象一:completeMultipartUpload 报 InvalidPart,但每一片上传时都返回了 200。原因基本是两个:一是提交的 parts 列表没有按 partNumber 升序,二是 etag 被清洗过(比如去掉了引号)。S3 协议要求合并时严格升序,且 etag 必须与上传时返回的完全一致。解决方式很直接:合并前显式排序,etag 原样透传,不做 trim、不加引号、不去引号。我在状态文件里存 etag 时连空格都不碰,就是为了避开这类问题。
现象二:文件传完了,大小也对,但播放器打不开或者解压报错。这是最典型的分片错位,根因通常是 partSize 变了却复用了旧 uploadId。比如第一次用 8MiB 传了一半,改配置后重启用 16MiB 继续,服务端认为第 2 片是 8~16MiB,客户端却提交了 8~24MiB 的内容,合并出来的字节流就是乱的。修法是把 partSize、fileSize、fileMtime 一起写进状态文件,恢复前逐项比对,任何一项不匹配就 abort 重来。不要试图「智能兼容」,重传的代价远小于传出一个坏文件。
现象三:文件超过 80GB 后,传到一半报分片数超限或 EntityTooLarge。原因是用固定 8MiB 分片,片数撞到 10000 上限。解决办法就是第 2 章那个 choosePartSize,按文件大小动态算。这里有个衍生坑:动态算出来的分片大小必须持久化,否则下次恢复时重新计算可能因为文件被追加写而得到不同结果,又回到现象二。
5.2 状态与资源回收的坑
现象四:上传任务跑了十几分钟突然报 Read timed out,重试后又好了,但整体耗时翻倍。根因是 OkHttp 的 readTimeout 用的是默认值,而你的分片在慢速链路上传不完一片。SDK 默认超时对 8MiB 分片在正常网络下够用,但在跨机房或者带宽被限速的环境里就不够。修法是按「分片大小 ÷ 最低保证带宽」重算超时,同时把分片调小一点——分片小意味着单片失败重传的代价小,这在弱网下比调大超时更有效。
现象五:桶的占用空间一直在涨,但对象列表里看不到对应文件。这是未完成的分片上传在占空间,它们不出现在普通 list 里,只在 listIncompleteUploads 里可见。根因是 abort 没被调用:客户端进程被 kill、容器被重启、或者异常路径里没写回滚。修法分两层,业务层在所有失败路径上补 abort(用 finally 或者独立的补偿任务),存储层给桶配生命周期规则自动清理超期的未完成会话。我一般两层都上,因为补偿任务本身也可能挂。
还有一条值得单独提:listParts 的分页。前面 3.3 讲过,但它在排查时特别有迷惑性——你查服务端只看到 1000 片,误以为只传了 1000 片,实际可能有 5000 片。排查时先确认 isTruncated,再看总数,别被第一页骗了。
6. 收尾技巧:把上传做成可验证的幂等链路
分片上传写完、调通、压测过,还差最后一步:让「传成功」这件事可验证。我见过太多链路,接口返回 success,但桶里的对象大小是 0,或者内容是上一次的旧版本。原因往往不是上传本身,而是合并之后没有做一次确认。
我的习惯是在 completeMultipartUpload 返回后立刻做一次 statObject,比对 size 和 etag,同时把状态文件归档而不是直接删除:
StatObjectResponse stat = client.statObject( StatObjectArgs.builder().bucket(bucket).object(object).build()); if (stat.size() != fileSize) { // 大小不符,说明合并出了问题,直接删掉重传,不要留半成品 client.removeObject(RemoveObjectArgs.builder() .bucket(bucket).object(object).build()); throw new IllegalStateException("合并后大小不符: " + stat.size() + " != " + fileSize); } // 校验通过,状态文件改名归档,保留 7 天用于排查 Path archived = stateFile.resolveSibling( stateFile.getFileName() + ".done." + System.currentTimeMillis()); Files.move(stateFile, archived, StandardCopyOption.REPLACE_EXISTING);这段的价值在于把「上传成功」从「接口没报错」变成「服务端确认的对象属性符合预期」。stat.size() 是服务端真实值,和本地 fileSize 对比能抓住绝大多数合并异常。归档而不是删除状态文件,是为了在出问题时还能回溯「当时用了多大分片、哪些片重传过」。
幂等这块还有个细节:同一个对象重复上传时,如果上一次的 uploadId 还没 abort,两次会话是并存的,谁先 complete 谁生效,后完成的会覆盖。这在重试场景下是好事,但在「用户连续点两次上传」时会造成版本乱序。我的做法是在业务层用「对象名 + 文件指纹」做一次短锁,同一个指纹的上传请求串行化,不同指纹的允许并发。
另外一个实用技巧是把分片大小做成可配置,但配置的读取点只有一个——上传会话创建时。会话一旦建立,partSize 就冻结在状态文件里,后续所有逻辑都从状态文件读,不再看配置。这样改配置不会影响正在进行中的任务,避免了最难查的那类「配置改了之后历史任务全坏」的问题。
从那以后,我每次接新的对象存储上传需求,都强制走一遍「小文件串行 → 大文件串行 → 大文件并发 → kill 进程再恢复 → 校验 size 和 etag」这五步,少一步都不敢上线。分片上传的代码量不大,难的是把状态、超时、清理这三件事想在前面。希望帮到你。
本文还有配套的精品资源,点击获取