ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot+MinIO实现大文件分片上传与断点续传完整指南

Spring Boot+MinIO实现大文件分片上传与断点续传完整指南 简介这是一个面向 Java 开发者的 MinIO 分片上传与断点续传实战示例适合需要在大文件传输场景中优化上传性能、提升传输稳定性的后端或全栈工程师。示例代码保持纯净后端仅引用必要 jar 包通过配置文件和前端 composeFile 注释即可快速接入自有 MinIO 服务降低了二次开发门槛。压缩包共 13 个文件、约 19KB包含 7 个 Java 源文件、2 个 JavaScript、1 个 HTML、1 个 CSS以及 xml 和 properties 配置文件其中 Java 代码实现分片与续传核心逻辑前端页面配合 spark-md5.js 与 upload.js 完成文件切片和校验。配套的前后端文件组织清晰便于对照阅读和移植改造。目前已有 20963 人学习示例可直接运行测试能够帮助开发者快速掌握 MinIO 分片上传中的切片策略、断点记录和合并机制等关键思路是一份轻量实用的入门参考。1. 项目概述1.1 你为什么会需要这个项目先聊个场景。我手头有个系统用户要上传大文件动辄几百兆到几个G从前台传到后台再用后台转存到对象存储。早期版本用的是简单上传文件一大就出问题要么请求超时要么传到一半网络断了用户骂骂咧咧提工单说我传了半个小时的视频突然报错了还得重来。后来我换了思路直接用 MinIO 的分片上传Multipart Upload能力在前端就把文件切成片一片一片传传完后台合并再配合断点续传逻辑整条链路一下子稳了很多。这个项目就是我当时沉淀下来的一个 Java 版本的分片上传、断点续传最佳实践示例。有网友后台私信我说网上一堆讲 MinIO 的教程都在讲 CRUD什么上传、下载、删除讲分片上传的少把断点续传和本地文件合并串起来的更少。所以我干脆把这套东西整理出来今天这篇博文就是把完整思路、核心代码、踩坑记录都给到大家。这套方案解决的核心问题有三个大文件上传时避免单次请求体过大减少超时和内存压力上传中断后不需要从头开始只需要续传未完成的分片前端直传或后端中转时都要保证文件最终完整合并到 MinIO不产生脏数据。适合谁看如果你是 Java 后端开发项目里正在用或者准备用 MinIO又被大文件上传困扰那这篇内容可以直接抄作业。1.2 项目最终长什么样先说清楚我没有把整个工程打成几百行的压缩包贴在文末那没意义。核心是一个可运行的最小示例包含Spring Boot 2.x MinIO Java SDK8.x项目骨架三个核心接口初始化分片上传、上传分片、合并分片一个本地模拟断点的处理逻辑记录已上传分片信息中断后查询已上传分片一个辅助工具类计算分片数量、分片大小、校验参数。其实分片上传本身并不神秘它本质上是把一个文件拆成若干小块逐个上传到服务端最后触发合并。MinIO 兼容亚马逊 S3 的 Multipart Upload 协议所以 Java SDK 里提供的 API 几乎是现成的剩下的工作就是把业务逻辑串起来。下面我从设计思路开始讲起然后是核心代码拆解、完整实现以及我实际测试中踩到的一堆问题。2. 整体设计思路拆解2.1 为什么不用简单上传很多人在最开始接触 MinIO 的时候第一个写出来的方法就是putObject传一个InputStream进去完事。这个做法在文件小于 100MB 的时候其实没什么毛病代码又短又直接。但文件一大问题就出来了上传是同步的如果文件有几 GB客户端和服务端之间的连接需要维持很久中间任何一次抖动都可能导致整个上传失败Java 后端把整个文件读入内存再做上传内存占用会非常夸张并发一高OOM 离你不远了没有进度控制能力用户看到的是一个转圈圈的按钮体验很差。所以生产环境里做大文件上传我这边默认就走分片上传。2.2 分片上传的核心流程MinIO或者说 S3 协议的分片上传流程可以拆成三大步客户端调initiateMultipartUpload拿到一个全局唯一的 uploadId客户端根据 uploadId 和分片编号逐个上传每个分片每传完一个分片服务端返回一个 ETag所有分片传完后客户端带着 uploadId 和所有分片编号ETag 的列表调completeMultipartUploadMinIO 会把这些分片合并成一个完整的对象。如果你传到一半不想传了或者网络断了可以调abortMultipartUpload把已经传的分片清理掉否则这些分片会一直占着存储空间时间久了会积累一堆垃圾数据。如果在合并之前客户端想知道哪些分片已经传过了可以调listParts它会返回已上传的分片编号和 ETag这就是断点续传的基础。2.3 前端直传还是后端中转这个点经常有人问我。先说结论如果前端是浏览器强烈建议用前端直传 后端签发临时凭证的方式——也就是前端直接向 MinIO 上传分片Java 后端只负责生成分片上传的初始化信息、查询分片列表、合并分片。这么做的好处是大文件的流量不经过应用服务器服务器的带宽和内存压力小很多。但如果你们的文件需要先经过后端做病毒扫描、格式校验或者文件来源不是浏览器而是某个后台服务那就用后端中转的模式。我下面贴的示例代码是后端中转的版本原因很简单Java 示例最容易看懂而且把分片上传的核心逻辑集中在了一起方便复现。实际生产里你只需要把接收分片改成生成预签名 URL或者用 STS 临时凭证就能切到前端直传模式。2.4 为什么用 MinIO 而不是自建 FTP老实说FTP 也能做断点续传但它的问题在于难管理、难监控、权限控制弱、对云原生不友好。MinIO 是纯 Go 写的部署就是一个二进制文件S3 兼容意味着后续想切到 AWS S3、阿里云 OSS、腾讯云 COS代码改动很小。另外 MinIO 的单机部署非常简单一条 Docker 命令就能跑起来很适合开发环境和个人项目。3. 核心细节解析与实操要点3.1 环境准备与 MinIO 启动本地开发我用的 Docker 启动 MinIO版本是RELEASE.2023-01-25T00-19-56Z镜像比较稳定新版本也差不多。docker run -d \ --name minio \ -p 9000:9000 \ -p 9001:9001 \ -e MINIO_ROOT_USERminioadmin \ -e MINIO_ROOT_PASSWORDminioadmin \ -v /data/minio:/data \ minio/minio server /data --console-address :9001注意两点9000 是 S3 API 端口Java SDK 连的是这个9001 是 Web 控制台端口新版本 MinIO 默认是通过环境变量MINIO_ROOT_USER和MINIO_ROOT_PASSWORD指定管理员账号老的写法是MINIO_ACCESS_KEY和MINIO_SECRET_KEY别记混了。3.2 Java SDK 和 Spring Boot 依赖我用的版本是 Spring Boot 2.7.xMinIO Java SDK 8.5.x。Maven 依赖如下dependency groupIdio.minio/groupId artifactIdminio/artifactId version8.5.7/version /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency如果你的项目还在用 7.x 的 MinIO SDK接口名有变化尤其注意initiateMultipartUpload几个方法的签名建议直接升到 8.x。3.3 配置文件和 MinioClient 初始化在application.yml里加配置minio: endpoint: http://127.0.0.1:9000 access-key: minioadmin secret-key: minioadmin bucket: my-bucket然后定义一个配置类把这个 client 注册成 BeanConfiguration public class MinioConfig { Value(${minio.endpoint}) private String endpoint; Value(${minio.access-key}) private String accessKey; Value(${minio.secret-key}) private String secretKey; Bean public MinioClient minioClient() { return MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .build(); } }这里有个细节如果 MinIO 跑在局域网endpoint 填http://127.0.0.1:9000没问题。但如果是给外部前端直传endpoint 必须填能从外部访问到的域名或 IP否则前端拿到预签名 URL 也连不上。3.4 先确保桶存在使用桶之前最好先检查它是否存在不存在就创建。我用一个小的初始化方法public void ensureBucket(String bucketName) throws Exception { boolean exists minioClient.bucketExists( BucketExistsArgs.builder().bucket(bucketName).build()); if (!exists) { minioClient.makeBucket( MakeBucketArgs.builder().bucket(bucketName).build()); } }注意创建桶的时候如果桶已经存在会抛BucketAlreadyExistsException所以判断不能少。3.5 分片大小选择经验分片大小直接影响上传性能和失败重试的成本。分片太小比如 1MB分片数量会非常多HTTP 请求数量暴增合并时 ETag 列表也长吞吐量反而下降分片太大比如 100MB一旦某个分片失败重新传这个分片的成本很高。我用了一个动态计算分片数量的策略默认每个分片 5MB这是 MinIO 底层推荐的较稳妥大小文件小于 5MB直接不分片走普通上传文件很大比如超过 5GB可以适当把分片调大比如 50MB 一片控制在 1000 个分片以内这是 S3 协议的上限。示例里我用的固定 5MB / 片开发测试完全够用。4. 实操过程与核心环节实现4.1 建立上传任务上下文第一步在前端发起请求的时候后端要初始化一个分片上传任务。这里的核心产出是 uploadId 和分片大小。Service public class MinioUploadService { Autowired private MinioClient minioClient; Value(${minio.bucket}) private String bucketName; // 每个分片大小5MB private static final long PART_SIZE 5 * 1024 * 1024L; public InitUploadResponse initUpload(String fileName) throws Exception { String objectName generateObjectName(fileName); // 1. 初始化分片上传拿到 uploadId InitiateMultipartUploadResponse initResponse minioClient.initiateMultipartUpload( InitiateMultipartUploadArgs.builder() .bucket(bucketName) .object(objectName) .build()); String uploadId initResponse.uploadId(); // 2. 封装返回结果 InitUploadResponse response new InitUploadResponse(); response.setUploadId(uploadId); response.setObjectName(objectName); response.setPartSize(PART_SIZE); return response; } }generateObjectName可以按业务需求生成唯一文件名避免同名覆盖。我常用的是UUID 原始文件名后缀private String generateObjectName(String originalFileName) { String suffix ; int index originalFileName.lastIndexOf(.); if (index 0) { suffix originalFileName.substring(index); } return UUID.randomUUID().toString().replace(-, ) suffix; }这里有一个和断点续传关系很大的点uploadId 必须和 objectName 绑定。如果你每次断点续传重新生成一个 objectName那之前的已上传分片就找不回来了。所以生产环境里一般在业务库中记录 fileId、objectName、uploadId 的关联关系续传时先从库里把这三项查出来。4.2 上传单个分片分片上传接口接收的参数包括 uploadId、partNumber、以及分片文件的字节流。public PartInfo uploadPart(String uploadId, String objectName, int partNumber, byte[] data) throws Exception { // 1. 先计算数据大小确保不为空 ByteArrayInputStream bais new ByteArrayInputStream(data); // 2. 调用 SDK 上传分片 UploadPartResponse uploadPartResponse minioClient.uploadPart( UploadPartArgs.builder() .bucket(bucketName) .object(objectName) .uploadId(uploadId) .partNumber(partNumber) .stream(bais, data.length, -1) .build()); // 3. 返回该分片的 ETag 给前端或业务层保存 PartInfo partInfo new PartInfo(); partInfo.setPartNumber(partNumber); partInfo.setEtag(uploadPartResponse.etag()); return partInfo; }这里的stream(bais, data.length, -1)三个参数分别是输入流、已知长度、分片大小。第三个参数传 -1 的意思是由 SDK 根据文件已知长度自动切分。实际上在分片上传中你传进来的 data 本身就是一片所以已知长度就是 data.length让 SDK 把它视为一个完整的 part 即可。前端或者调用方拿到这个返回值之后要把 partNumber 和 etag 记住最后合并的时候要用。注意好这个坑服务端接收分片的时候不能只存 InputStream直接把它上传到 MinIO 确实是最好的选择因为中间不落盘性能更高。但如果你传的是 MultipartFile别忘记调用file.getBytes()或者file.getInputStream()。如果接口设计的是一次传一个分片可以直接传 MultipartFile。4.3 合并分片所有分片传完之后调用合并接口。public String completeUpload(String uploadId, String objectName, ListPartInfo parts) throws Exception { // 1. 构造 Part 列表注意必须按分片编号升序 ListPart partList parts.stream() .sorted(Comparator.comparingInt(PartInfo::getPartNumber)) .map(part - new Part(part.getPartNumber(), part.getEtag())) .collect(Collectors.toList()); // 2. 调用合并接口 minioClient.completeMultipartUpload( CompleteMultipartUploadArgs.builder() .bucket(bucketName) .object(objectName) .uploadId(uploadId) .parts(partList) .build()); // 3. 返回文件访问路径按实际情况调整 return String.format(%s/%s/%s, endpoint, bucketName, objectName); }看起来很简单但有几个容易栽跟头的点Part 列表必须按照 partNumber 升序排序不然合并会失败ETag 必须和上传分片时返回的保持一致也不能缺漏partNumber 从 1 开始不能从 0 开始合并接口成功后uploadId 就失效了不能重复再用如果某个分片没传完就调合并MinIO 会报错提示缺少某个 part。4.4 断点续传——查询已上传分片断点续传的核心在于从中断处接着传那怎么知道中断处是哪里答案是调用listParts。public ListPartInfo listParts(String uploadId, String objectName) throws Exception { ListPartInfo partInfos new ArrayList(); // 默认 MinIO 一次最多返回 1000 条实际分片数通常不会超过这个值 ListPartsResponse partListResponse minioClient.listParts( ListPartsArgs.builder() .bucket(bucketName) .object(objectName) .uploadId(uploadId) .build()); for (Part part : partListResponse.result().partList()) { PartInfo info new PartInfo(); info.setPartNumber(part.partNumber()); info.setEtag(part.etag()); info.setSize(part.partSize()); partInfos.add(info); } return partInfos; }有了已上传分片列表续传的逻辑就很清楚了调用initUpload拿到 uploadId或者从业务库里查之前的 uploadId调用listParts拿到已传分片编号集合前端本地遍历所有分片跳过已上传的只传缺失的分片全部传完后调completeUpload。你可能想问前端怎么知道哪些编号已经传了很简单后端返回一个SetInteger前端做判断的时候用set.contains(partNumber)来决定是否跳过。4.5 Controller 层代码我把接口封装成 REST 接口方便前端调用RestController RequestMapping(/upload) public class UploadController { Autowired private MinioUploadService uploadService; /** * 初始化分片上传 */ PostMapping(/init) public RInitUploadResponse init(RequestParam(fileName) String fileName) throws Exception { return R.success(uploadService.initUpload(fileName)); } /** * 上传分片 */ PostMapping(/part) public RPartInfo uploadPart( RequestParam(uploadId) String uploadId, RequestParam(objectName) String objectName, RequestParam(partNumber) int partNumber, RequestParam(file) MultipartFile file) throws Exception { return R.success(uploadService.uploadPart(uploadId, objectName, partNumber, file.getBytes())); } /** * 查询已上传分片断点续传 */ GetMapping(/parts) public RListPartInfo listParts( RequestParam(uploadId) String uploadId, RequestParam(objectName) String objectName) throws Exception { return R.success(uploadService.listParts(uploadId, objectName)); } /** * 合并分片 */ PostMapping(/complete) public RString complete( RequestParam(uploadId) String uploadId, RequestParam(objectName) String objectName, RequestBody ListPartInfo parts) throws Exception { return R.success(uploadService.completeUpload(uploadId, objectName, parts)); } }这里的RT是我习惯用的统一返回包装类你们项目里可能有自己的Result或ApiResponse替换掉就行。4.6 一个完整的调用时序为了让大家看得更清楚我把整个分片上传的调用时序捋一遍前端选择文件计算文件大小请求后端/upload/init?fileNamexxx后端返回 uploadId、objectName、分片大小 partSize前端把文件按 partSize 切片得到多个分片对每个分片按顺序编号 partNumber从 1 开始前端依次请求/upload/part上传分片后端把每个分片存入 MinIO并返回 ETag如果上传中断前端重新请求/upload/init或从业务库恢复 uploadId再请求/upload/parts获取已上传分片列表前端只上传未完成的分片所有分片完成后前端携带 part 列表请求/upload/complete后端完成合并并返回文件地址。时序图其实逻辑很简单如果前端做了并发上传你只需要注意一点分片上传的顺序不分先后但合并时的 part 列表必须是有序的。4.7 关于并发上传分片很多同学会问分片可以并发上传吗可以。MinIO 的 multipart upload 设计上就允许分片乱序到达每个分片只依赖 partNumber 和 uploadId不依赖顺序。所以我这个示例虽然写得是串行遍历但是生产环境里用线程池并发上传分片完全没问题。不过并发要控制好数量。我实测下来并发太高比如 20 以上对 MinIO 的吞吐并没有更多帮助反而会带来大量连接开销推荐把并发线程数控制在 5~8 左右已经能把带宽跑满每个线程上传完一个分片后把 partNumber 和 ETag 存到一个线程安全的结构里比如ConcurrentSkipListSet或CopyOnWriteArrayList最后排序构造 Part 列表。4.8 合并前的完整性校验这里是我实际开发中很后悔没有一开始做的一件事合并之前不校验分片大小之和是否等于原始文件大小。后来加上了因为如果前端切片的逻辑有 bug或者网络传输过程中某个分片丢了数据MinIO 合并出的文件可能就是一个损坏的文件而且这种问题在后端日志里完全看不出来。校验逻辑很简单遍历 part 列表累加每个 part 的 size和原始文件总长度对比。如果不一致提前返回错误避免生成脏数据。long totalPartSize parts.stream().mapToLong(PartInfo::getSize).sum(); if (totalPartSize ! expectedSize) { throw new RuntimeException(分片大小总和与文件大小不一致请检查分片列表); }当然这个校验依赖前端在 init 时记住了totalSize所以InitUploadResponse里我会额外加一个totalSize字段并在后续传参时把它带回来。5. 常见问题与排查技巧实录5.1 invalid login access denied 问题排查思路先确认 accessKey 和 secretKey 是否配置正确再看账号是否有该桶的权限最后确认客户端连接配置的 endpoint 是否指向正确的 MinIO 服务。实际现象是启动项目调用接口返回类似Access denied的错误。多半是环境变量没对上或者密码改了没同步。我踩过一次很蠢的坑配置文件里有个不可见空格导致 key 后面多了一个空格排查了好久。提示配置文件里的 key 和 secret 别从网页直接复制有些网页会把字符转成全角导致认证失败。5.2 上传合并时报部分分片未找到这个问题的本质是completeMultipartUpload 时提供的 Part 列表里有 MinIO 中不存在的 partNumber或者某个 ETag 不匹配。常见原因有三个前端并发上传时某个分片上传失败但前端没有重试仍然把所有分片标记为成功分片编号从 0 开始而 MinIO 要求从 1 开始同一个 uploadId 下同一个 partNumber 被上传了两次但是两次的 ETag 不一样前端保存了错误的那个。解决方式是上传分片接口返回数据必须带上 partNumber、etag、size前端维护一个 Map 按 partNumber 覆盖写入合并前做一次完整性检查。5.3 分片上传卡住不动有的同学说前端分片上传的请求一旦多了就卡住不动像是假死。多半不是 MinIO 的问题而是连接池没配。MinIO Java SDK 底层用的 OkHttp如果你的并发请求很高默认连接池不够或者连接没复用TCP 连接建立的开销会拖慢上传。可以在初始化MinioClient时通过自定义 OkHttpClient 来调整连接池OkHttpClient okHttpClient new OkHttpClient.Builder() .connectTimeout(30, TimeUnit.SECONDS) .readTimeout(60, TimeUnit.SECONDS) .writeTimeout(60, TimeUnit.SECONDS) .build(); MinioClient minioClient MinioClient.builder() .endpoint(endpoint) .credentials(accessKey, secretKey) .httpClient(okHttpClient) .build();这样设置之后在弱网环境下上传体验会好很多至少不会动不动就超时。5.4 分片上传的垃圾数据清理很多人忽略了一个问题如果一个 uploadId 初始化了但一直没合并MinIO 里这些已上传的分片会一直存在白白占存储。我用了一个维护脚本定期扫描所有未完成的分片上传任务超过 7 天未合并的直接 abort。MinIO 也支持在桶上配置生命周期规则自动清理未完成的分片上传。控制台里可以设置也可以直接用 SDK 设置SetBucketLifecycleArgs args SetBucketLifecycleArgs.builder() .bucket(bucketName) .config(...) .build();我目前是每天凌晨跑一个定时任务调用listMultipartUploads找到过期任务并 abort代码不复杂但对生产环境很重要。5.5 断点续传在多端的实现差异如果你要做的不是浏览器端直传而是手机 App那么分片上传的思路基本一致但要注意App 端断点续传时本地要持久化分片状态不能只放在内存里否则 App 被杀掉进程后状态就丢了大文件在上传过程中如果网络从 5G 切到 Wi-Fi底层连接会断开应用层要自动检测并重试未完成的分片ETag 和 partNumber 的处理和 Java 后端保持一致建议用统一的生成规则。6. 优化空间与后续扩展6.1 增加文件秒传做上传功能如果没有秒传那用户传一个之前已经传过的文件还是得重新传一遍太亏了。秒传的思路是前端在上传前先计算文件的 MD5然后请求后端后端查表如果发现该 MD5 对应的文件已经存在直接返回文件地址不需要走上传流程。MinIO 本身不直接支持秒传但可以通过业务库记录文件的 MD5 和 objectName 来实现。6.2 对接前端直传如果不想让文件经过应用服务器可以做成前端直传到 MinIO。具体方案有几种后端生成预签名 URL前端直接 PUT 到 MinIO后端先用 STS 生成临时凭证前端用临时凭证初始化分片上传并直传后端只提供初始化、列举分片、合并三个接口上传分片接口改为返回一个可直接上传的预签名 URL。这样做的价值是传输链路缩短文件不经过 Java 进程内存压力大大减小。6.3 统一封装成 Starter当项目变多之后每个服务都拷贝一份分片上传逻辑显然不行。我的建议是把它拆成一个独立的minio-spring-boot-starter组件所有需要使用对象存储的服务统一依赖再通过配置项切换 bucket、endpoint 等。我目前就是把这段代码封装成了公司内部的基础组件和业务完全解耦方便了很多服务复用。7. 个人体会这一套分片上传功能写下来最深的感受是MinIO 的 API 并不难真正的难点在业务状态管理。uploadId 怎么存、分片状态怎么记、中断之后怎么恢复、合并之前怎么校验这些问题如果不在设计阶段想清楚写出来的代码只会越改越乱。我在实际项目中还踩过几次坑比如上传完成后才发现分片列表里有多余 part、前端并发控制没做好导致 OOM、生产环境 Bucket 生命周期策略没有配置导致垃圾分片积压。这些坑如果你提前知道可以少浪费很多时间。如果你也正在做类似的分片上传功能建议第一步别急着写代码先把你想要的业务流程画清楚把状态流转定义明白。然后从我这个示例出发把断点续传和完整性校验补上再考虑前端并发上传的优化。最后再分享一个小技巧把上传过程中的关键日志打印完整包括 uploadId、partNumber、ETag、耗时、失败原因这对后续排查线上问题帮助极大。别嫌日志多真出问题的时候你就知道这些日志有多香了。本文还有配套的精品资源点击获取
返回列表