十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

Spring Boot + Vue3 + MinIO 大文件分片上传实战:直传、断点续传与秒传

Spring Boot + Vue3 + MinIO 大文件分片上传实战:直传、断点续传与秒传 我接手过好几个类似需求的项目从早期做后台管理系统开始就一直在跟文件上传打交道。以前用传统的MultipartFile整包上传文件小的时候还行一旦涉及到几百MB甚至几个GB的视频、压缩包、数据库备份文件问题就全冒出来了服务端内存直接被打满、请求超时、传一半断网就得从头再来。后来我把整套方案换成了 Spring Boot 3 Vue 3 Element Plus MinIO 8.2 的分片直传架构才算真正把大文件上传这件事做踏实了。这篇文章我会把整个项目的设计思路和落地细节完整复盘一遍重点讲清楚分片上传的核心链路前端怎么切片、后端怎么签发预签名地址、MinIO 上面分片对象怎么管理、最后怎么合并出完整文件以及断点续传秒传这些加分项的实现思路。内容偏实战代码部分可以直接参考适合正在做类似上传模块的 Java 全栈开发也适合想从传统整包上传改造为分片直传的朋友。1. 项目概述与技术选型复盘1.1 为什么必须分片上传先聊一个最基础的问题为什么大文件不能直接传根源在于传统的上传路径是浏览器 - 应用服务器 - 对象存储文件内容要完整经过应用服务器中转。这里有个很大的瓶颈应用服务器的 Servlet 容器Tomcat 也好Jetty 也好处理请求时文件流会占用线程内存。一个 2GB 的文件传进来后端要临时缓冲、转写磁盘Tomcat 默认的max-post-size是 2MB虽然可以调大但调大后意味着每个上传请求都会长期占用一个线程和大量内存。一旦并发上来应用服务器基本就瘫了。另外还有一个体验问题整包上传是没有进度恢复能力的。网速波动、浏览器卡顿、用户切走标签页任何一个意外中断整个文件就得重新传。对运营人员来说传一个 1GB 的素材视频到 80% 断了这已经不是技术问题了是用户要摔键盘的问题。分片上传把大文件切成若干个几 MB 的小块逐块上传解决了三个关键点单块失败只需要重传这一块不用整文件重来多块可以并发上传速度比串行快很多上传进度可以持久化刷新页面、重启浏览器之后还能接着传这个项目落地时我把分片和直传绑定在了一起也就是文件数据流不经过后端应用服务器前端直接从浏览器把分片上传到 MinIO。后端只负责一件事给前端签发通行证预签名 URL和最终的合并指令。这样应用服务器的压力几乎可以忽略不计带宽瓶颈只存在于用户端和 MinIO 之间。1.2 技术栈选型的几个关键考量技术栈选型其实是围绕稳定、够用、团队熟悉这三个原则来的。后端选了 Spring Boot 3一方面是新项目的技术债得从这个版本开始另一方面 Spring Boot 3 基于 JDK 17spring-boot-starter-web里内嵌的 Tomcat 10.1 性能比之前的老版本好很多。虽然在分片直传架构里后端不承担文件流中转但接口的并发响应速度还是会影响上传的「节奏感」Spring Boot 3 在这个场景下完全够用。前端自然是 Vue 3 Element Plus。Vue 3 的组合式 API 很适合封装上传这种复杂状态逻辑ref、reactive、computed可以很自然地管理分片列表、进度、错误重试这些状态。Element Plus 的el-upload虽然本身支持上传但分片场景下我们只是把它当作文件选择器来用真正的上传逻辑全部自己掌控。对象存储选了 MinIO 8.2这个决策我对比过 SeaweedFS。MinIO 最核心的优势是 S3 协议兼容这意味着后端用的 Java SDK 生态非常成熟而且 MinIO 本身的部署非常轻量单个二进制文件就能跑起来小团队维护成本极低。SeaweedFS 在超大规模场景下确实有优势Filer 的架构设计很灵活但如果只是做文件上传存储MinIO 的社区活跃度、文档完整度、周边工具链都更好。另外 MinIO 控制台自带 Web 管理界面查看 bucket 里的文件、生成临时访问链接都很方便调试效率高很多。1.3 系统整体架构设计整个系统的数据流是这样的用户在前端选择文件前端按固定大小比如 10MB将文件切分成多个分片前端调用后端初始化接口告诉后端我要传这个文件它有多大分了多少片后端生成一个 uploadId在 MinIO 里规划好分片对象的存储路径返回给前端前端逐个或并发向后端请求分片的预签名 URL拿到后直接用 HTTP PUT 请求把分片上传到 MinIO所有分片传完后前端调用后端合并接口后端检查分片完整性调用 MinIO 的composeObject将分片对象合并成最终文件合并成功后清理临时分片对象这里最关键的设计是第 4 步前端直传 MinIO。文件内容从头到尾没有经过应用服务器应用服务器只处理签 URL和合并这两个轻量级操作。这也是标题里 minio8.2大文件分片上传 这套组合能扛住大文件并发上传的原因。再说 bucket 结构。我在 MinIO 里规划了两个区域uploads/目录存放临时分片对象files/目录存放最终合并完成的文件。分片对象命名规则是uploads/{uploadId}/part-{index}这样同一个文件的所有分片都在同一个前缀下后面用listObjects查询、批量删除都非常方便。2. 环境准备与基础工程搭建2.1 MinIO 8.2 服务端部署MinIO 的部署方式非常简单生产环境我用的是 Linux 二进制单机部署开发环境建议直接用 Docker Compose省时省力。下面是我开发环境用的docker-compose.ymlversion: 3.8 services: minio: image: minio/minio:RELEASE.2023-09-04T19-57-37Z container_name: minio-upload ports: - 9000:9000 - 9001:9001 environment: MINIO_ROOT_USER: minioadmin MINIO_ROOT_PASSWORD: minioadmin123 volumes: - ./data:/data command: server /data --console-address :9001 restart: always端口 9000 是 API 端口前端直传分片就走这个端口9001 是 Web 控制台端口。启动后访问http://localhost:9001用minioadmin / minioadmin123登录先在控制台手动创建一个 bucket比如upload-bucket。如果是 Linux 服务器上直接部署相关热搜词里linux安装minio详细步骤问的挺多流程同样简单# 下载二进制文件 wget https://dl.min.io/server/minio/release/linux-amd64/minio # 赋予执行权限并移动到系统目录 chmod x minio sudo mv minio /usr/local/bin/ # 创建数据目录 sudo mkdir -p /data/minio # 启动服务指定端口和控制台端口 MINIO_ROOT_USERminioadmin MINIO_ROOT_PASSWORDminioadmin123 \ nohup /usr/local/bin/minio server /data/minio --console-address :9001 /tmp/minio.log 21 # 设置开机自启使用 systemd 服务文件 sudo tee /etc/systemd/system/minio.service /dev/null EOF [Unit] DescriptionMinIO Afternetwork.target [Service] ExecStart/usr/local/bin/minio server /data/minio --console-address :9001 EnvironmentMINIO_ROOT_USERminioadmin EnvironmentMINIO_ROOT_PASSWORDminioadmin123 Restartalways [Install] WantedBymulti-user.target EOF sudo systemctl daemon-reload sudo systemctl enable minio sudo systemctl start minio这里要注意一个细节MinIO 的版本号迭代比较快我上面 docker 镜像打的 tag 是 2023 年 9 月的一个版本实际使用中建议拉取最新的稳定版。标题里说的 MinIO 8.2 更多是指 SDK 版本也就是 Java 侧的minio依赖。SDK 与服务端的版本兼容性整体做得不错8.2 以上版本配合服务端多版本都能正常工作。2.2 Spring Boot 3 后端工程初始化后端工程用 Spring Initializr 创建JDK 版本选 17依赖选择Spring Web、Lombok、Validation。核心额外依赖就一个 MinIO SDK。下面是pom.xml的关键部分dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdio.minio/groupId artifactIdminio/artifactId version8.5.7/version /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency /dependenciesapplication.yml里配置 MinIO 连接信息minio: endpoint: http://localhost:9000 access-key: minioadmin secret-key: minioadmin123 bucket: upload-bucket server: port: 8080然后定义一个配置类把MinioClient作为 Bean 注入package com.example.upload.config; import io.minio.MinioClient; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration 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(); } }这里要注意endpoint地址决定了前端直传时访问 MinIO 的地址。如果后端跑在服务器上、前端在用户浏览器里那么这个地址必须是前端浏览器能直接访问到的地址。开发环境localhost没问题部署到服务器后要改成服务器 IP 或域名同时注意 MinIO 的 9000 端口要在安全组/防火墙里对用户侧放行。2.3 Vue 3 Vite 前端工程初始化前端我用的 Vite 构建工具创建命令如下npm create vitelatest upload-web -- --template vue-ts cd upload-web npm install npm install element-plus npm install axios基础目录结构如下upload-web/ ├── src/ │ ├── api/ │ │ └── upload.ts # 后端接口封装 │ ├── components/ │ │ └── ChunkUpload.vue # 分片上传核心组件 │ ├── App.vue │ └── main.ts ├── index.html └── vite.config.tsvite.config.ts里配置开发环境代理避免跨域import { defineConfig } from vite import vue from vitejs/plugin-vue export default defineConfig({ plugins: [vue()], server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true } } } })这样前端请求/api/xxx会自动代理到后端的localhost:8080绕开开发环境的跨域限制。不过要注意这里代理只解决前端请求后端接口的跨域前端直传 MinIO 的跨域问题需要单独在 MinIO 侧配置 CORS后面会专门讲。3. 后端核心分片上传接口的设计与实现3.1 接口规划与交互时序后端一共规划了 4 个接口接口方法作用/api/upload/initPOST初始化上传任务返回 uploadId 和分片信息/api/upload/presignGET获取指定分片的预签名 URL/api/upload/statusGET查询已上传的分片序号用于断点续传/api/upload/mergePOST合并全部分片为完整文件交互时序是这样的前端拿到文件后先调init拿到 uploadId 和总分片数然后逐个分片调presign获取 PUT 地址直传 MinIO每传完一个分片前端本地标记完成全部完成后调merge后端把分片对象合并成最终文件。如果中途断网或用户刷新页面重新走一遍init拿到相同的 uploadId再调status就能知道哪些分片已经传过了只补传缺失的就可以。3.2 初始化接口与元数据管理init接口接收文件名、文件大小、分片大小返回 uploadId、分片总数等信息。uploadId 我用UUID生成同时把文件的元信息文件名、大小、分片大小、总分片数暂存在后端内存里。package com.example.upload.controller; import com.example.upload.service.UploadService; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import lombok.Data; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; import java.util.Map; RestController RequestMapping(/api/upload) RequiredArgsConstructor public class UploadController { private final UploadService uploadService; PostMapping(/init) public MapString, Object init(RequestBody InitRequest request) { return uploadService.initUpload( request.getFilename(), request.getFileSize(), request.getChunkSize() ); } Data public static class InitRequest { NotBlank private String filename; NotNull private Long fileSize; NotNull private Integer chunkSize; } }对应的 Service 实现package com.example.upload.service; import io.minio.MinioClient; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import java.util.HashMap; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; Service RequiredArgsConstructor public class UploadService { private final MinioClient minioClient; Value(${minio.bucket}) private String bucket; // 内存中保存上传任务的元信息 private final MapString, MapString, Object uploadMetaMap new ConcurrentHashMap(); public MapString, Object initUpload(String filename, Long fileSize, Integer chunkSize) { String uploadId UUID.randomUUID().toString().replace(-, ); int chunkCount (int) Math.ceil((double) fileSize / chunkSize); MapString, Object meta new HashMap(); meta.put(filename, filename); meta.put(fileSize, fileSize); meta.put(chunkSize, chunkSize); meta.put(chunkCount, chunkCount); meta.put(uploadId, uploadId); uploadMetaMap.put(uploadId, meta); MapString, Object result new HashMap(); result.put(uploadId, uploadId); result.put(chunkSize, chunkSize); result.put(chunkCount, chunkCount); result.put(filename, filename); return result; } public MapString, Object getMeta(String uploadId) { return uploadMetaMap.get(uploadId); } }内存 Map 存储元信息的方案适合单机部署和功能演示。生产环境如果担心服务重启丢状态可以换 Redis 存储或者干脆不存内存、直接通过 MinIO 的对象列表来反推元信息。这里有个思路分片大小是可以根据文件大小和对象数量计算出来的只要把分片大小作为一个固定的约定值比如前后端都默认 10MB那么chunkCount可以通过listObjects查出来filename可以编码进对象名里。后面讲断点续传时会再展开。3.3 预签名上传地址生成presign接口是后端最核心的一段逻辑。前端告诉后端 uploadId 和分片序号后端用 MinIO SDK 生成一个带签名、限时有效的 PUT URL。public String presignChunkUrl(String uploadId, int chunkIndex) { String objectName uploads/ uploadId /part- chunkIndex; try { return minioClient.getPresignedObjectUrl( GetPresignedObjectUrlArgs.builder() .method(Method.PUT) .bucket(bucket) .object(objectName) .expiry(10 * 60) // 10分钟有效 .build() ); } catch (Exception e) { throw new RuntimeException(生成预签名URL失败, e); } }这段代码背后的原理值得多说两句。MinIO 的预签名 URL 本质上是在 URL 后面拼上一串X-Amz-Algorithm、X-Amz-Credential、X-Amz-Signature参数。这串签名是服务端用 AccessKey 和 SecretKey 对请求方法 对象路径 过期时间计算出来的任何人拿到这个 URL在有效期内都可以以指定方法访问指定对象。因为我在生成时指定了Method.PUT所以别人拿到 URL 只能上传这个对象不能覆盖别的路径安全性是有保障的。过期时间的设置我建议 10 分钟左右。太短了不行如果你的分片因为网络波动重试了几次第一个签名可能还没上传就过期了太长也不行签名 URL 本质上是一把临时钥匙暴露时间越久风险越高。10 分钟对单个 10MB 分片来说绰绰有余就算用户网速再慢重试一两次也够了。前端拿到这个 URL 后直接发 PUT 请求await axios.put(presignUrl, chunkBlob, { headers: { Content-Type: application/octet-stream } })这里有个性能细节presignChunkUrl接口是每个分片都调用一次会产生大量 HTTP 请求。如果文件有 200 个分片就是 200 次后端请求。并发控制做好的前提下这个量在后端完全承受得住因为接口本身很轻。不过要注意一定不能让前端把预签名 URL 缓存太久后一次性全部请求完而是要上传一个分片、获取一个签名、立刻使用防止签名过期。3.4 查询已上传分片断点续传的核心依赖就是status接口。后端扫描 MinIO 的uploads/{uploadId}/前缀下的对象解析出已上传的分片序号返回给前端。public ListInteger listUploadedChunks(String uploadId) { ListInteger uploadedChunks new ArrayList(); try { IterableResultItem results minioClient.listObjects( ListObjectsArgs.builder() .bucket(bucket) .prefix(uploads/ uploadId /) .build() ); for (ResultItem result : results) { Item item result.get(); String objectName item.objectName(); // 从 uploads/{uploadId}/part-1 中提取序号 1 String partNum objectName.substring(objectName.lastIndexOf(-) 1); uploadedChunks.add(Integer.parseInt(partNum)); } } catch (Exception e) { throw new RuntimeException(查询分片状态失败, e); } return uploadedChunks; }这个接口的巧妙之处在于它不需要额外的数据库来记录哪些分片传过了MinIO 本身就是一个天然的状态存储。分片对象存在就说明这个分片传成功了不存在就是还没传或者传失败了。前端拿到已上传的分片列表后跟本地要上传的全部分片序号做差集只补传缺失的部分。3.5 合并分片与清理所有分片上传完成后前端调用merge接口。后端的合并逻辑用的是 MinIO 的composeObject它可以把多个源对象合并成一个目标对象不经过应用服务器在 MinIO 服务端直接完成。public MapString, Object mergeChunks(String uploadId) { MapString, Object meta uploadMetaMap.get(uploadId); if (meta null) { throw new RuntimeException(上传任务不存在或已过期); } String filename (String) meta.get(filename); int chunkCount (int) meta.get(chunkCount); String finalObjectName files/ uploadId / filename; try { // 检查实际分片数是否与预期一致 ListInteger uploadedChunks listUploadedChunks(uploadId); if (uploadedChunks.size() ! chunkCount) { throw new RuntimeException(分片不完整已上传 uploadedChunks.size() / chunkCount); } // 构建源对象列表需要按序号排序 ListComposeSource sources new ArrayList(); uploadedChunks.stream().sorted().forEach(i - { sources.add(ComposeSource.builder() .bucket(bucket) .object(uploads/ uploadId /part- i) .build()); }); if (sources.size() 1) { // 只有一个分片时直接用复制代替合并 minioClient.copyObject( CopyObjectArgs.builder() .bucket(bucket) .object(finalObjectName) .source(CopySource.builder() .bucket(bucket) .object(uploads/ uploadId /part-1) .build()) .build() ); } else { minioClient.composeObject( ComposeObjectArgs.builder() .bucket(bucket) .object(finalObjectName) .sources(sources) .build() ); } // 异步清理临时分片对象 cleanUploadParts(uploadId); MapString, Object result new HashMap(); result.put(url, /api/upload/download?object finalObjectName); result.put(objectName, finalObjectName); result.put(filename, filename); return result; } catch (Exception e) { throw new RuntimeException(合并分片失败, e); } }这里有三点实操经验都是踩过坑才总结出来的第一合并前一定要检查分片数量是否完整。如果前端说 200 个分片都传完了实际 MinIO 里只有 199 个直接合并会导致文件损坏。这个检查在并发上传场景下尤其重要因为可能存在前端请求合并时最后一个分片还没真正落盘的时序问题。第二composeObject要求至少两个源对象如果文件很小只切了一个分片需要走copyObject分支否则 SDK 会报错。这个边界条件很容易被忽略。第三composeObject一次性合并的对象数量有上限10000 个。如果分片特别多比如文件超大、分片大小又设得很小需要合并成多批。好在正常业务里 10MB 一个分片、100GB 文件也只有 10240 个分片卡在边界附近。我的建议是分片大小不要低于 5MB既避免对象数量超限也减少请求次数。4. 前端核心Vue 3 Element Plus 上传交互实现4.1 文件分片与状态管理前端拿到文件后用File.slice()方法将文件切成多个 Blob。这里的分片大小必须跟后端init接口传的 chunkSize 一致。// 约定分片大小10MB const CHUNK_SIZE 10 * 1024 * 1024 function getChunks(file: File) { const count Math.ceil(file.size / CHUNK_SIZE) const chunks: Blob[] [] for (let i 0; i count; i) { chunks.push(file.slice(i * CHUNK_SIZE, Math.min((i 1) * CHUNK_SIZE, file.size))) } return chunks }分片大小为多少合适这取决于你的业务场景。10MB 是我在多个项目里试下来比较均衡的数值。分片太小比如 1MB请求次数暴增签名接口的压力也大分片太大比如 50MB断点续传的粒度变粗一旦某一片传失败重传的成本就高了。对于视频素材类业务单文件 1GB 到 5GB10MB 是一个不会出错的起步值。在 Vue 3 里管理上传状态我用reactive定义一个响应式的上传状态对象interface ChunkStatus { index: number status: pending | uploading | success | error progress: number } const uploadState reactive({ uploadId: , filename: , totalChunks: 0, chunks: [] as ChunkStatus[], currentConcurrency: 0, status: idle // idle | uploading | merging | done | error })状态用status字段驱动界面变化chunks数组记录每个分片的实时状态。Element Plus 的进度条组件可以直接绑定已完成分片数/总分片数来展示整体进度。4.2 并发控制与进度展示分片并发上传并不是并发数越大越好。浏览器同一个域名的并发连接数是有限制的通常 HTTP/1.1 下是 6 个左右你开 10 个并发任务后面的请求也要排队。而且并发数过高会让单个分片的带宽被摊薄单分片上传时间拉长很容易超过预签名 URL 的有效期。我在项目里默认用 3 个并发这套参数实测下来比较稳定。并发控制的实现可以不用引入额外的库用一个任务队列 固定数量执行器的简单模型搞定async function runWithConcurrencyT( tasks: (() PromiseT)[], limit: number ): PromiseT[] { const results: T[] [] const executing: Promisevoid[] [] for (const task of tasks) { const p Promise.resolve().then(() task()).then(res { results.push(res) }) executing.push(p) if (executing.length limit) { await Promise.race(executing) } } await Promise.all(executing) return results }调用这个函数时tasks是所有分片上传任务limit是并发数 3。每完成一个任务就从执行队列里踢出一个补充一个新任务进来保证同时只有 3 个上传请求在飞。每个分片的上传任务内部还要处理失败重试。我的重试策略是单个分片失败后最多重试 3 次每次间隔指数退避1 秒、2 秒、4 秒。如果 3 次都失败就把这个分片标记为error并暂停整个队列把控制权交还给用户让用户决定是重试还是放弃。这里暂停整个队列比继续传其他分片更合理因为一个分片反复失败通常意味着网络有问题继续传其他分片大概率也会失败。4.3 Element Plus 组件集成方式Element Plus 的el-upload在这个场景下我建议只把它当作文件选择器使用上传逻辑完全自己掌控。template div classupload-panel el-upload drag :auto-uploadfalse :show-file-listfalse :on-changeonFileChange acceptvideo/*,.zip,.sql div classupload-tip p拖拽文件到此处或点击选择文件/p p classsub-tip支持视频、压缩包、数据库备份等大文件/p /div /el-upload div v-ifuploadState.filename classfile-info span{{ uploadState.filename }}/span span{{ formatSize(fileSize) }}/span /div el-progress v-ifuploadState.totalChunks 0 :percentageoverallProgress :statusuploadState.status done ? success : / div classupload-actions el-button typeprimary :disabled!uploadState.filename || uploadState.status uploading clickstartUpload {{ uploadState.status paused ? 继续上传 : 开始上传 }} /el-button el-button v-ifuploadState.status uploading clickpauseUpload 暂停 /el-button /div /div /templateonFileChange里拿到文件后本地做一次文件大小判断超过阈值比如 200MB走分片逻辑小文件直接走普通上传也可以但从代码统一性考虑我建议所有文件都走分片只是分片大小按文件大小动态调整小文件可以不分片即分片大小 文件大小。async function startUpload() { const file selectedFile.value if (!file) return // 初始化上传任务 const initRes await uploadApi.init({ filename: file.name, fileSize: file.size, chunkSize: CHUNK_SIZE }) uploadState.uploadId initRes.uploadId uploadState.totalChunks initRes.chunkCount // 查询已上传分片断点续传 const uploadedChunks await uploadApi.status(initRes.uploadId) const uploadedSet new Set(uploadedChunks) // 构建待上传任务 const tasks [] for (let i 0; i uploadState.totalChunks; i) { if (uploadedSet.has(i)) continue tasks.push(() uploadSingleChunk(file, i)) } await runWithConcurrency(tasks, 3) await mergeChunks() }uploadSingleChunk内部负责向后端请求预签名 URL、用 PUT 上传 Blob、更新分片状态、失败重试。async function uploadSingleChunk(file: File, chunkIndex: number) { const chunkBlob getChunk(file, chunkIndex) uploadState.chunks[chunkIndex].status uploading // 获取预签名 URL const presignRes await uploadApi.presign(uploadState.uploadId, chunkIndex) // 使用 axios 发起 PUT 请求 await axios.put(presignRes.url, chunkBlob, { headers: { Content-Type: application/octet-stream }, timeout: 5 * 60 * 1000 // 5分钟超时 }) uploadState.chunks[chunkIndex].status success uploadState.chunks[chunkIndex].progress 100 }axios 的 timeout 要设置得宽松一些10MB 分片在慢速网络下传几分钟都是正常的如果沿用默认的几秒钟超时会频繁触发重试甚至导致任务一直失败。4.4 断点续传的恢复逻辑断点续传的实现在拿到init返回的 uploadId 后先调status接口查询已上传分片然后跳过这些分片。但这里有个隐藏问题如果用户刷新页面之前的 uploadId 就丢了怎么办我的方案是用文件内容生成一个唯一的指纹比如 MD5 或 SHA-1把这个指纹作为 uploadId 的入参。后端init时如果发现同一个指纹已经存在上传任务就复用之前的 uploadId而不是新生成一个。public MapString, Object initUpload(String filename, Long fileSize, Integer chunkSize, String fileMd5) { // 根据文件MD5实现秒传和断点续传的基础 String finalObjectName files/ fileMd5 / filename; // 如果文件已经存在说明之前已经传过完整文件直接秒传 boolean exists minioClient.bucketExists(BucketExistsArgs.builder() .bucket(bucket).build()); if (exists) { try { minioClient.statObject(StatObjectArgs.builder() .bucket(bucket) .object(finalObjectName) .build()); MapString, Object existResult new HashMap(); existResult.put(uploadId, ); existResult.put(chunkSize, chunkSize); existResult.put(chunkCount, 0); existResult.put(filename, filename); existResult.put(finished, true); return existResult; } catch (Exception e) { // 对象不存在继续走正常上传流程 } } String uploadId upload- fileMd5; // ... 省略后续元信息存储逻辑 }前端在status接口返回finished: true时直接显示上传完成这就实现了秒传。这个功能在某些场景下非常实用比如运营反复上传同一个素材文件浪费的带宽和时间都可以省掉。断点续传的用户体验细节用户刷新页面后重新选择同一个文件前端先计算 MD5后端根据 MD5 返回同一个 uploadId前端再调status查询已上传分片就能精确恢复到上次中断的位置。这个方案的关键在于 MD5 计算前端读取大文件做 MD5 会有点耗时1GB 文件大概需要 2-3 秒但相比重新上传整个文件这点时间完全可以接受。5. 常见问题与避坑实录5.1 问题速查表实际开发中踩过的各种坑整理成一张速查表现象原因解决方案前端 PUT 请求报 CORS 错误MinIO 未配置 CORS 规则在 MinIO 控制台或通过配置添加 CORS 规则预签名 URL 上传 403签名过期或密钥不匹配缩短单分片上传时间检查 AccessKey/SecretKey合并时提示分片不完整前端并发上传最后一个分片尚未落盘合并前加一个短暂延迟或调用status确认composeObject报对象数量超限分片数量超过 10000 个调大分片大小或分批合并上传大文件时浏览器卡死一次性把所有分片读入内存用File.slice()按需读取不要new Blob()全量读刷新页面后断点续传失效uploadId 是随机生成的 UUID改用文件 MD5 作为 uploadIdMinIO 直传时加自定义 Header 失败预签名 URL 签名时未包含对应 Header签名时用reqParams或headers参数携带后端返回大文件列表超时文件对象过多使用listObjects分页或按前缀过滤5.2 值得单独说的三个坑第一个坑是 CORS 配置。前端浏览器直传 MinIO 时如果 MinIO 没有正确的 CORS 规则浏览器会拦截 PUT 请求报Access to XMLHttpRequest at ... from origin ... has been blocked by CORS policy。这个错误最坑人的地方在于你用 curl 或者 Postman 测试签名 URL 是好的但在浏览器里就是不行。解决方法是给 MinIO 添加 CORS 规则允许来源、方法和 Headers。在 MinIO 控制台的 Bucket - Access Policy 里可以设置如下配置{ Version: 2012-10-17, Statement: [ { Effect: Allow, Principal: { AWS: [*] }, Action: [s3:PutObject, s3:GetObject], Resource: [arn:aws:s3:::upload-bucket/uploads/*] } ] }同时需要在 MinIO 的 CORS 配置里显式允许 PUT 方法。如果是部署在 Linux 服务器上也可以直接用mc命令配置mc alias set myminio http://localhost:9000 minioadmin minioadmin123 mc admin config set myminio api cors_allow_origin*第二个坑是composeObject的对象数量上限。MinIO 源码里对composeObject的单次合并对象数有限制超过 10000 个对象会直接报EntityTooLarge错误。如果你的业务场景确实需要超大文件比如 100GB 以上建议把分片大小提高到 20MB 甚至 50MB从源头减少分片数量。另外如果真的遇到对象数量超限可以分多批合并先把前 5000 个分片合并成一个临时对象再把剩下 5000 个分片和临时对象一起合并成最终文件。第三个坑是预签名 URL 的 Header 问题。如果你在 PUT 请求里带了自定义 Header比如Content-Type、Content-MD5但生成预签名 URL 时没有把这些 Header 纳入签名计算MinIO 会返回SignatureDoesNotMatch错误。一开始我用 axios 默认加了Content-Type: application/octet-stream结果一直报签名错误排查了很久才发现问题。解决方法是签名时指定headers参数或者在 axios 请求里不要设置任何自定义 Header让浏览器用默认的application/octet-stream两者保持一致就不会有问题。结语最后再分享几个实测数据。我用这套方案在本地环境传过一个 4.2GB 的视频文件分片大小 10MB并发 3 个千兆局域网环境下总耗时 40 秒左右整个过程后端应用服务器的 CPU 占用不到 5%内存占用几乎没有变化。对比之前用MultipartFile整包上传的方案同样文件传输过程中后端内存直接飙到 2GB 以上——差距非常明显。另外这套架构的可扩展性也不错。如果以后文件量级上来了MinIO 可以随时从单机模式平滑扩展到分布式模式后端代码完全不用动。前端的分片上传组件也已经封装成了一个独立的 Vue 组件新项目里可以直接拿去用只需要把接口地址配置一下就行。分片上传这个需求表面上看是个文件处理问题本质上是个架构思路问题。把文件数据流从业务链路里剥离出来让应用服务器只做逻辑控制、不做数据传输系统的稳定性和扩展性一下子就上来了。这套思路不光适用于 MinIO换成阿里云 OSS、腾讯云 COS 或者其他 S3 协议兼容的对象存储核心逻辑都是一样的只是换了 SDK 调用方式而已。希望这篇复盘能帮你把分片上传这件事想透少踩几个我踩过的坑。
返回列表