我接手过好几个类似需求的项目,从早期做后台管理系统开始就一直在跟文件上传打交道。以前用传统的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 17,spring-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.yml:
version: '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_USER=minioadmin MINIO_ROOT_PASSWORD=minioadmin123 \ nohup /usr/local/bin/minio server /data/minio --console-address ":9001" > /tmp/minio.log 2>&1 & # 设置开机自启(使用 systemd 服务文件) sudo tee /etc/systemd/system/minio.service > /dev/null <<EOF [Unit] Description=MinIO After=network.target [Service] ExecStart=/usr/local/bin/minio server /data/minio --console-address ":9001" Environment="MINIO_ROOT_USER=minioadmin" Environment="MINIO_ROOT_PASSWORD=minioadmin123" Restart=always [Install] WantedBy=multi-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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>io.minio</groupId> <artifactId>minio</artifactId> <version>8.5.7</version> </dependency> <dependency> <groupId>org.projectlombok</groupId> <artifactId>lombok</artifactId> <optional>true</optional> </dependency> </dependencies>application.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 vite@latest 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/init | POST | 初始化上传任务,返回 uploadId 和分片信息 |
/api/upload/presign | GET | 获取指定分片的预签名 URL |
/api/upload/status | GET | 查询已上传的分片序号,用于断点续传 |
/api/upload/merge | POST | 合并全部分片为完整文件 |
交互时序是这样的:前端拿到文件后先调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 Map<String, 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 Map<String, Map<String, Object>> uploadMetaMap = new ConcurrentHashMap<>(); public Map<String, Object> initUpload(String filename, Long fileSize, Integer chunkSize) { String uploadId = UUID.randomUUID().toString().replace("-", ""); int chunkCount = (int) Math.ceil((double) fileSize / chunkSize); Map<String, 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); Map<String, Object> result = new HashMap<>(); result.put("uploadId", uploadId); result.put("chunkSize", chunkSize); result.put("chunkCount", chunkCount); result.put("filename", filename); return result; } public Map<String, 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 List<Integer> listUploadedChunks(String uploadId) { List<Integer> uploadedChunks = new ArrayList<>(); try { Iterable<Result<Item>> results = minioClient.listObjects( ListObjectsArgs.builder() .bucket(bucket) .prefix("uploads/" + uploadId + "/") .build() ); for (Result<Item> 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 Map<String, Object> mergeChunks(String uploadId) { Map<String, 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 { // 检查实际分片数是否与预期一致 List<Integer> uploadedChunks = listUploadedChunks(uploadId); if (uploadedChunks.size() != chunkCount) { throw new RuntimeException("分片不完整,已上传 " + uploadedChunks.size() + " / " + chunkCount); } // 构建源对象列表(需要按序号排序) List<ComposeSource> 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); Map<String, 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 到 5GB),10MB 是一个不会出错的起步值。
在 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 runWithConcurrency<T>( tasks: (() => Promise<T>)[], limit: number ): Promise<T[]> { const results: T[] = [] const executing: Promise<void>[] = [] 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 class="upload-panel"> <el-upload drag :auto-upload="false" :show-file-list="false" :on-change="onFileChange" accept="video/*,.zip,.sql" > <div class="upload-tip"> <p>拖拽文件到此处,或点击选择文件</p> <p class="sub-tip">支持视频、压缩包、数据库备份等大文件</p> </div> </el-upload> <div v-if="uploadState.filename" class="file-info"> <span>{{ uploadState.filename }}</span> <span>{{ formatSize(fileSize) }}</span> </div> <el-progress v-if="uploadState.totalChunks > 0" :percentage="overallProgress" :status="uploadState.status === 'done' ? 'success' : ''" /> <div class="upload-actions"> <el-button type="primary" :disabled="!uploadState.filename || uploadState.status === 'uploading'" @click="startUpload" > {{ uploadState.status === 'paused' ? '继续上传' : '开始上传' }} </el-button> <el-button v-if="uploadState.status === 'uploading'" @click="pauseUpload" > 暂停 </el-button> </div> </div> </template>onFileChange里拿到文件后,本地做一次文件大小判断:超过阈值(比如 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 Map<String, 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()); Map<String, 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 作为 uploadId |
| MinIO 直传时加自定义 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 调用方式而已。希望这篇复盘能帮你把分片上传这件事想透,少踩几个我踩过的坑。