资讯详情

资讯详情

MinIO Java分片上传与断点续传落地实践

简介这份压缩包面向需要在 Java 项目中落地 MinIO 大文件分片上传与断点续传的开发者尤其适合已了解对象存储基本概念、希望直接参考可运行前后端示例的中级读者。包内共 13 个文件以 7 个 Java 源码为核心配合 2 个 JavaScript 脚本、1 个 HTML 页面、1 个 CSS 样式表以及 1 个 XML 构建配置和 1 个 properties 配置文件压缩后约 19KB体量轻巧依赖与模块划分保持精简。示例将后端上传逻辑、前端分片计算与页面交互拆开呈现前端借助 MD5 计算辅助实现分片与断点信息组织后端仅引入必要依赖便于对照阅读并迁移到实际业务。启动时需保证配置中的服务信息与 MinIO 环境一致前端分片函数附近附有注释说明上传一个文件即可验证完整链路。目前已有 20970 人学习下载可作为理解分片上传流程、排查续传问题的参考样例。1. 从一次 8GB 上传超时说起MinIO Java 分片上传与断点续传的落地边界某图像处理 Demo 需要把 8GB 的模型包推到 MinIO最早用的是单次putObject结果在网关 30 分钟超时那里直接翻车重传三次都卡在同一段。后来换成 MinIO Java 分片上传文件切成固定大小的 Part逐片上传失败只重传单片断点续传再把uploadId和已完成分片落盘进程重启后先listParts对齐远端再继续补未完成的分片。这套东西适合 Java 后端、对象存储接入、大文件导入导出也适合需要“传一半断了还能接着传”的桌面端或批处理任务。要注意SDK 自带的uploadObject能自动分片但它不管断点状态真要断点续传uploadId、分片号、ETag 和源文件指纹都得自己管。2. 拆开 S3 分片协议uploadId、Part 与 MinIO Java SDK 的接口边界MinIO 兼容 S3 的分片上传语义核心不是“把文件切小”这么简单而是一组有状态请求先创建上传会话拿到uploadId再按partNumber上传 Part最后用 Part 列表合并。很多人第一次写会把它当成普通流式上传结果在恢复、合并、清理这三个环节踩坑。先把协议层请求次数和 SDK 接口边界看清楚后面写断点续传才不会把本地状态当成黑匣子。2.1 一次分片上传在协议层到底发生了几次请求一个文件从开始传到可读至少经历 3 类请求CreateMultipartUpload创建会话UploadPart上传每个分片CompleteMultipartUpload按分片号合并。中途失败可以AbortMultipartUpload清理恢复时用ListParts查远端已有哪些分片。分片号从 1 开始最大 10000除最后一片外每片最小 5MiB最大 5GiB。最后一片可以小于 5MiB但如果倒数第二片也小于 5MiB合并时就会报EntityTooSmall。请求作用关键参数常见失败CreateMultipartUpload创建上传会话bucket、object、contentType权限不足、桶不存在UploadPart上传单个分片uploadId、partNumber、流长度超时、分片过小、流被复用ListParts查远端已完成分片uploadId、maxPartsuploadId 过期CompleteMultipartUpload合并分片Part 列表、ETagInvalidPart、顺序错AbortMultipartUpload清理未完成会话uploadId已合并后调用无效这张表里最容易被忽略的是ListParts。断点续传不能只信本地 JSON因为进程可能在上传成功后、写状态前崩掉本地记录没写远端其实已经有这个分片。恢复时以远端ListParts为准本地状态只做加速和人工排查。另一个边界是uploadId有生命周期桶上如果配了未完成分片清理规则隔太久再恢复会直接NoSuchUpload这时只能重新初始化不能无限重试。2.2 MinIO Java SDK 里该用哪组 APIMinIO Java SDK 有两层用法UploadObjectArgs适合“一次调用自动分片”的场景内部会帮你切片、并发、合并但它不暴露可恢复的uploadId和分片级状态手动分片 API 适合断点续传、限速、分片级重试和进度上报。下面这组是手动链路的最小骨架桶名、对象名、端点都用占位符实际替换成项目配置。// 依赖按项目锁定的 MinIO Java SDK 引入版本用属性管理避免散落 MinioClient client MinioClient.builder() .endpoint(endpoint) // 对象存储服务地址 .credentials(accessKey, secretKey) // 访问凭证 .build(); // 1. 创建分片上传会话 CreateMultipartUploadResponse created client.createMultipartUpload( CreateMultipartUploadArgs.builder() .bucket(bucket) .object(objectKey) .contentType(application/octet-stream) .build() ); String uploadId created.result().uploadId(); // 2. 上传一个分片partNumber 从 1 开始 try (InputStream partStream new PartInputStream(file, offset, partSize)) { UploadPartResponse partResp client.uploadPart( UploadPartArgs.builder() .bucket(bucket) .object(objectKey) .uploadId(uploadId) .partNumber(partNumber) .stream(partStream, partSize, -1) .build() ); String etag partResp.etag(); } // 3. 查远端已完成分片恢复时先做这一步 ListPartsResponse listed client.listParts( ListPartsArgs.builder() .bucket(bucket) .object(objectKey) .uploadId(uploadId) .maxParts(10000) .build() ); // 4. 合并Part 必须按 partNumber 升序 client.completeMultipartUpload( CompleteMultipartUploadArgs.builder() .bucket(bucket) .object(objectKey) .uploadId(uploadId) .parts(parts) .build() ); // 5. 失败清理避免未完成分片长期占空间 client.abortMultipartUpload( AbortMultipartUploadArgs.builder() .bucket(bucket) .object(objectKey) .uploadId(uploadId) .build() );这段代码的逻辑顺序不能乱先创建会话再上传分片恢复时先listParts最后合并。partNumber是分片身份不是数组下标stream的第三个参数在不同小版本里可能有差异核心是让 SDK 知道这一片读多长不要让流读到文件尾。UploadPartResponse.etag()要和服务端返回的分片号一起存合并时Part列表必须按分片号升序否则InvalidPart很常见。abort只在确认不再续传时调用已经合并成功再调用没有意义还会把状态文件搞乱。2.3 分片大小与并发数的选型公式分片大小先由文件大小倒推再用网络和内存修正。硬约束是分片数不超过 10000除最后一片外不小于 5MiB。一个够用的起步公式是partSize max(5MiB, ceil(fileSize / 10000))然后向上对齐到 1MiB 或 8MiB。比如 8GB 文件按 10000 片算每片约 0.8MiB低于下限所以至少 5MiB实际更常用 16MiB 或 32MiB因为 5MiB 片太多请求数会上来。并发数不是越大越好单机出口带宽、对象存储服务端限流、JVM 内存都会卡住每个并发分片至少要占用partSize的读缓冲再算上 SDK 内部缓冲内存大概是partSize × 并发数 × 2。文件规模推荐分片并发起步说明100MB 以内5-8MiB2-4分片太小请求多收益有限1-10GB16-32MiB4-8兼顾重传成本和吞吐10-50GB32-64MiB6-8先压测再调别拍脑袋50GB 以上64-128MiB4-6单片失败重传成本高要限流提示分片大小一旦确定并开始上传恢复时不能改。改了之后 partNumber 和远端分片对不上只能 abort 重来。3. 把断点续传写进 Java 代码状态文件、分片流与合并校验断点续传的难点不在“上传”而在“状态一致性”。本地状态文件、远端listParts、源文件指纹三者要对得上否则会出现重复上传、合并失败、文件大小不对。我一般把状态文件设计成可读 JSON出问题时能直接打开看而不是塞进数据库里变成黑匣子。下面按上下文、分片读取、并发上传、合并校验四块拆。3.1 上传上下文与本地断点状态怎么设计上下文至少要有 bucket、object、uploadId、fileSize、lastModified、partSize、completedParts。fileSize和lastModified是源文件指纹恢复前先比对不一致说明文件被替换不能续传。completedParts存分片号和 ETag恢复时还要和远端listParts合并远端为准。状态文件写在源文件同目录或专用状态目录命名带对象 key 的哈希避免路径穿越。写入用临时文件加原子移动进程崩了不会留下半截 JSON。public class UploadContext { public String bucket; public String object; public String uploadId; public long fileSize; public long lastModified; public long partSize; public MapInteger, String completedParts new LinkedHashMap(); } // 状态落盘先写 tmp再原子替换 Path state stateDir.resolve(objectHash .upload.json); Path tmp state.resolveSibling(state.getFileName() .tmp); mapper.writeValue(tmp.toFile(), ctx); Files.move(tmp, state, StandardCopyOption.REPLACE_EXISTING, StandardCopyOption.ATOMIC_MOVE); // 恢复先校验源文件再查远端分片 UploadContext ctx mapper.readValue(state.toFile(), UploadContext.class); if (ctx.fileSize ! file.length() || ctx.lastModified ! file.lastModified()) { throw new IllegalStateException(源文件已变化不能续传); } ListPartsResponse listed client.listParts(ListPartsArgs.builder() .bucket(ctx.bucket).object(ctx.object).uploadId(ctx.uploadId) .maxParts(10000).build()); MapInteger, String remote listed.result().partList().stream() .collect(Collectors.toMap(Part::partNumber, Part::etag)); ctx.completedParts.putAll(remote); // 远端覆盖本地避免漏记这段代码的关键是“先校验、后信任”。fileSize和lastModified只能挡住大部分替换场景如果文件被原地修改但大小和 mtime 没变需要更严格的哈希指纹大文件全量哈希成本高常见做法是抽头部、尾部、中间三段算摘要或者业务侧保证上传期间文件只读。ATOMIC_MOVE要求 tmp 和 state 在同一文件系统跨盘会退化成非原子别把状态目录放到另一个挂载点。listParts的maxParts给 10000因为分片上限就是 10000如果返回被截断要按partNumberMarker继续翻页不能只取第一页。3.2 分片读取RandomAccessFile 与限定长度 InputStream分片上传最常见的翻车点是共享FileInputStream。多个线程同时从一个流里读skip和read互相踩最后分片内容错位合并出来的文件大小对但内容损坏。每个分片必须独立打开文件按 offset 定位只读 length 字节。RandomAccessFile配合一个限定长度的InputStream就够用注意read(byte[], off, len)要限制不超过剩余长度close必须释放文件句柄。public final class PartInputStream extends InputStream { private final RandomAccessFile raf; private long remaining; public PartInputStream(File file, long offset, long length) throws IOException { this.raf new RandomAccessFile(file, r); this.raf.seek(offset); this.remaining length; } Override public int read() throws IOException { if (remaining 0) return -1; int b raf.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 raf.read(b, off, (int) Math.min(len, remaining)); if (n 0) remaining - n; return n; } Override public void close() throws IOException { raf.close(); } }这个流只做一件事把[offset, offsetlength)暴露成标准输入流。offset用(partNumber - 1) * partSize计算最后一片的length用fileSize - offset不要统一给partSize否则最后一片会读越界或补零。每个分片任务自己 new 一个PartInputStream放在 try-with-resources 里确保 SDK 读完或异常时关闭。文件句柄数量等于并发数并发 8 就是 8 个句柄不要开到几百。3.3 并发上传分片与失败重试并发层用固定线程池加CompletionService谁先完成先收谁避免按提交顺序等慢分片。每个分片任务内部做有限重试指数退避重试前重新打开PartInputStream不要复用已经读过的流。重试只对UploadPart安全因为同一个partNumber重复上传会覆盖CompleteMultipartUpload不要盲目重试先statObject看是否已经合并成功。线程池大小等于并发数队列不要无界失败时把已完成分片写回状态文件再退出。ExecutorService pool Executors.newFixedThreadPool(concurrency, r - { Thread t new Thread(r, minio-part-uploader); t.setDaemon(true); return t; }); CompletionServicePartResult completion new ExecutorCompletionService(pool); for (PartTask task : tasks) { completion.submit(() - uploadOnePart(client, ctx, file, task)); } for (int i 0; i tasks.size(); i) { FuturePartResult future completion.take(); PartResult result future.get(); // 任一失败会在这里抛出 ctx.completedParts.put(result.partNumber(), result.etag()); persist(ctx); // 每完成一片就落盘崩了最多丢一片 } private PartResult uploadOnePart(MinioClient client, UploadContext ctx, File file, PartTask task) throws Exception { int attempt 0; while (true) { try (InputStream in new PartInputStream(file, task.offset(), task.length())) { UploadPartResponse resp client.uploadPart(UploadPartArgs.builder() .bucket(ctx.bucket).object(ctx.object).uploadId(ctx.uploadId) .partNumber(task.partNumber()) .stream(in, task.length(), -1) .build()); return new PartResult(task.partNumber(), resp.etag()); } catch (Exception e) { if (attempt 3) throw e; Thread.sleep(200L * (1L attempt)); // 400ms、800ms 退避 } } }concurrency建议先从 4 起步压测后加到 8超过 8 要先看出口带宽和内存。persist(ctx)每完成一片就写写的是小 JSON成本可控如果状态文件很大可以改成追加日志加定期压缩。future.get()抛出异常后要pool.shutdownNow()并保留已完成分片下一次恢复从listParts对齐。Thread.sleep的重试要响应中断生产代码里捕获InterruptedException后恢复中断位并退出不要吞掉。3.4 合并、校验与清理所有分片完成后把completedParts按分片号升序转成Part列表调用completeMultipartUpload。合并成功后用statObject校验对象大小和源文件fileSize比对大小不对说明分片长度算错或漏片不要删状态文件先排查。合并失败如果确认不再续传调用abortMultipartUpload清理远端未完成分片再删本地状态。注意分片上传的 ETag 不是整个文件的 MD5带-N后缀别拿它和本地 MD5 直接比。ListPart parts ctx.completedParts.entrySet().stream() .sorted(Map.Entry.comparingByKey()) .map(e - new Part(e.getKey(), e.getValue())) .collect(Collectors.toList()); client.completeMultipartUpload(CompleteMultipartUploadArgs.builder() .bucket(ctx.bucket).object(ctx.object).uploadId(ctx.uploadId) .parts(parts).build()); StatObjectResponse stat client.statObject(StatObjectArgs.builder() .bucket(ctx.bucket).object(ctx.object).build()); if (stat.size() ! ctx.fileSize) { throw new IllegalStateException(合并后大小不一致: stat.size()); } Files.deleteIfExists(state);这段代码里sorted不能省Part列表顺序错会直接InvalidPart。stat.size()是合并后的对象大小不是分片大小如果业务需要内容校验建议上传前算一个 SHA-256 存到状态文件下载或消费端再校验别在上传主链路里全量算 MD5 拖慢吞吐。abort之后本地状态文件也要删否则下次恢复会拿一个已失效的uploadId去listParts直接NoSuchUpload。4. 性能不是把并发拉满MinIO Java 分片上传的五个参数分片上传的性能变量不止并发数。连接池、分片大小、超时、重试、监控任何一个设错都会让“最佳性能”变成“最不稳”。我一般先把单分片吞吐压出来再调并发最后看 P95 耗时和重试率。下面五个参数按影响从大到小排代码片段只保留关键配置版本差异按项目锁定的 SDK 对齐。4.1 连接池与 HTTP 客户端MinIO Java SDK 底层走 HTTP 客户端默认连接池和超时不一定适合大分片。并发 8 时连接池最大连接数至少给 16留出重试和元数据请求余量writeTimeout要大于单个分片上传时间callTimeout要大于“建连 上传 响应”的总时间。超时太短会把正常慢分片打成失败重试又把带宽打满形成恶性循环。连接池空闲回收时间可以给 5 分钟别让连接频繁重建。OkHttpClient httpClient new OkHttpClient.Builder() .connectTimeout(Duration.ofSeconds(10)) .writeTimeout(Duration.ofMinutes(5)) .readTimeout(Duration.ofMinutes(5)) .callTimeout(Duration.ofMinutes(30)) .connectionPool(new ConnectionPool(16, 5, TimeUnit.MINUTES)) .build(); MinioClient client MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .httpClient(httpClient) .build();ConnectionPool的第一个参数是最大空闲连接数不是最大并发数真正限制并发的是线程池。callTimeout覆盖整个调用分片很大时给 30 分钟不算夸张如果网络差宁可分片小一点、超时短一点让失败重传成本可控。writeTimeout是写数据到 socket 的时间不是服务端处理时间别把它设成 10 秒。4.2 分片大小与内存的平衡分片越大请求数越少但单片失败重传成本越高分片越小并发调度更灵活但请求数和元数据开销上来。16MiB 到 32MiB 是大多数内网场景的甜点区公网或跨区域先压测。内存估算按partSize × 并发 × 2打底再加 JVM 堆外和 SDK 缓冲。8 并发 32MiB 大约 512MiB 打底如果堆只有 1GB很容易 GC 抖动表现是吞吐上不去、P95 飙高。分片大小并发 4 内存打底并发 8 内存打底适用场景8MiB64MiB128MiB带宽小、重传敏感16MiB128MiB256MiB通用内网32MiB256MiB512MiB高吞吐内网64MiB512MiB1024MiB大带宽、低失败率注意表里的内存只是读缓冲估算实际还要算 JVM 堆、直接内存和 SDK 内部缓冲压测时看 GC 日志比拍脑袋可靠。4.3 重试与限流重试要分错误类型连接超时、RequestTimeout可以退避重试NoSuchUpload、EntityTooSmall、InvalidPart重试没意义直接失败并保留状态。限流用令牌桶控制总带宽避免上传把业务接口打满。每个分片任务在读取前申请令牌按字节数扣减限流粒度放在分片层比放在 HTTP 层简单也够用。重试次数不要超过 3 次超过说明网络或服务端有问题继续重试只会放大故障。// 简化令牌桶按字节限速rateBytesPerSecond 例如 100MB/s RateLimiter limiter RateLimiter.create(rateBytesPerSecond); try (InputStream in new PartInputStream(file, offset, length)) { limiter.acquire((int) Math.min(length, Integer.MAX_VALUE)); UploadPartResponse resp client.uploadPart(/* ... */); }RateLimiter.acquire的参数是许可数按字节申请时要注意 int 上限大分片可以分段申请。限流会拉长单分片时间callTimeout要相应放大否则限流把请求拖超时重试又绕过限流。更稳的做法是把限流做在PartInputStream.read里读多少扣多少但实现复杂一点起步阶段在分片层做够用。4.4 监控指标每个分片的耗时、吞吐、重试次数没有分片级指标调参就是玄学。至少记录partNumber、字节数、耗时、重试次数、ETag 和异常类型。吞吐按bytes / elapsed算看 P95 而不是平均值平均值会被快分片拉高掩盖慢分片。重试率超过 5% 就要查网络或超时配置不要继续加并发。日志里带uploadId前 8 位和对象 key 哈希方便和状态文件对照。long start System.nanoTime(); int retry 0; try { // uploadPart ... } finally { long costMs (System.nanoTime() - start) / 1_000_000; double mbps bytes / 1024.0 / 1024.0 / (costMs / 1000.0); log.info(part{} bytes{} costMs{} retry{} mbps{}, partNumber, bytes, costMs, retry, String.format(%.2f, mbps)); }这段日志放在finally里失败也能记录耗时。retry在重试循环里累加别只记最终成功次数。mbps是单分片吞吐不是全局吞吐全局吞吐用总字节除以总墙钟时间。日志量大的时候采样或者只对慢分片打 INFO其余打 DEBUG。5. 避坑排查断点续传最常见的五条血泪记录断点续传的坑大多不是 SDK 不会用而是状态不一致。下面五条是实际排查里出现频率最高的每条按“现象 → 原因 → 解决”写。看到类似现象先对照不要一上来就怀疑网络。5.1 恢复后 complete 报 InvalidPart现象上传到 60% 中断重启后继续传完合并时返回InvalidPart但每个分片单独看都成功了。原因通常是本地completedParts里的 ETag 和远端不一致或者恢复时只信本地状态没有用listParts覆盖也可能是分片号排序错Part列表顺序和上传顺序不一致。解决恢复时先listParts用远端 ETag 覆盖本地合并前按partNumber升序排序如果仍然失败打印本地和远端的 Part 列表逐项比对别猜。5.2 EntityTooSmall最后一片之外的片小于 5MiB现象小文件上传正常大文件合并时报EntityTooSmall。原因分片大小算成fileSize / 10000后没取 5MiB 下限或者最后一片判断错把倒数第二片也切成了小片。解决partSize max(5MiB, ceil(fileSize / 10000))最后一片用fileSize - offset单独算上传前打印每个分片的 offset 和 length确认除最后一片外都大于等于 5MiB。如果文件小于 5MiB直接单次上传不要走分片。5.3 NoSuchUploaduploadId 过期或被清理现象隔天恢复上传listParts直接NoSuchUpload。原因桶上配置了未完成分片清理规则或者服务端生命周期把旧会话清掉了也可能是上一次已经abort本地状态没删。解决捕获NoSuchUpload后不要重试把本地状态标记为失效重新createMultipartUpload从头上传如果业务允许调大未完成分片保留时间。状态文件里记录创建时间超过阈值主动放弃别让用户等一个永远恢复不了的会话。5.4 上传成功但文件大小不对/内容损坏现象complete成功statObject大小和源文件一致但下载后解压失败或图片打不开。原因多个线程共享同一个FileInputStream或者PartInputStream的remaining没限制住读到下一片的数据也可能是最后一片 length 算多了补了脏数据。解决每个分片独立打开文件用RandomAccessFile.seek(offset)定位read严格限制remaining合并后做一次抽样校验重要文件上传前存 SHA-256消费端校验。5.5 本地状态文件与远端不一致导致重复上传现象进程崩溃后恢复明明远端已有 40% 分片本地状态却只有 10%于是重复上传前 30%。原因状态文件写入不是原子操作崩溃时留下半截 JSON或者每片完成才写写之前崩了。解决临时文件加ATOMIC_MOVE恢复时以listParts为准本地状态只做参考每完成一片就落盘写失败要重试并记录不能吞异常。状态文件损坏时直接根据远端分片重建不要从零开始。6. 进阶验证用 listParts 做恢复演练与压测收尾断点续传写完不算完必须做一次“杀进程恢复”演练。我会选一个 2GB 左右的测试文件上传到 40% 时直接kill -9重启后先读状态文件再listParts对齐继续传完最后statObject比对大小。演练时故意把本地状态文件改错一个 ETag看代码是否以远端为准再故意把分片大小改大确认会拒绝恢复而不是悄悄传错。这个演练比看日志有用能提前暴露状态文件原子性和分片排序问题。压测不要只跑一个并发。固定文件大小分别用 4、8、16 并发和 16MiB、32MiB 分片跑矩阵记录总耗时、P95 分片耗时、重试率和 GC 次数。如果 16 并发比 8 并发吞吐还低先看连接池和 GC不要继续加线程。最后用listMultipartUploads检查有没有遗留的未完成会话有就abort清理避免存储空间被慢慢吃掉。# 恢复演练的观察点上传中杀进程重启后看状态文件和远端分片是否对齐 # 这里只示意流程具体命令按项目启动方式替换 java -jar upload-demo.jar --file ./test-2g.bin --object demo/test-2g.bin # 上传到约 40% 时强制终止然后重新执行同一条命令从那以后我每次接大文件上传都会强制走一遍“传一半杀进程再恢复”的流程不通过就不让上线。这份 MinIO Java 分片上传、断点续传示例里状态管理、分片读取、并发上传和完成校验是分开的按第 3 章的代码顺序就能拼起来改配置就能跑。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →