Spring Boot + Vue3 + MinIO 大文件分片上传实战:直传、断点续传与秒传
2026/9/24 18:45:20 网站建设 项目流程

我接手过好几个类似需求的项目,从早期做后台管理系统开始就一直在跟文件上传打交道。以前用传统的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 很适合封装上传这种复杂状态逻辑,refreactivecomputed可以很自然地管理分片列表、进度、错误重试这些状态。Element Plus 的el-upload虽然本身支持上传,但分片场景下我们只是把它当作"文件选择器"来用,真正的上传逻辑全部自己掌控。

对象存储选了 MinIO 8.2,这个决策我对比过 SeaweedFS。MinIO 最核心的优势是 S3 协议兼容,这意味着后端用的 Java SDK 生态非常成熟,而且 MinIO 本身的部署非常轻量,单个二进制文件就能跑起来,小团队维护成本极低。SeaweedFS 在超大规模场景下确实有优势,Filer 的架构设计很灵活,但如果只是做文件上传存储,MinIO 的社区活跃度、文档完整度、周边工具链都更好。另外 MinIO 控制台自带 Web 管理界面,查看 bucket 里的文件、生成临时访问链接都很方便,调试效率高很多。

1.3 系统整体架构设计

整个系统的数据流是这样的:

  1. 用户在前端选择文件,前端按固定大小(比如 10MB)将文件切分成多个分片
  2. 前端调用后端初始化接口,告诉后端"我要传这个文件,它有多大,分了多少片"
  3. 后端生成一个 uploadId,在 MinIO 里规划好分片对象的存储路径,返回给前端
  4. 前端逐个(或并发)向后端请求分片的预签名 URL,拿到后直接用 HTTP PUT 请求把分片上传到 MinIO
  5. 所有分片传完后,前端调用后端合并接口
  6. 后端检查分片完整性,调用 MinIO 的composeObject将分片对象合并成最终文件
  7. 合并成功后清理临时分片对象

这里最关键的设计是第 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 WebLombokValidation。核心额外依赖就一个 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.ts

vite.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 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-AlgorithmX-Amz-CredentialX-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签名时用reqParamsheaders参数携带
后端返回大文件列表超时文件对象过多使用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-TypeContent-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 调用方式而已。希望这篇复盘能帮你把分片上传这件事想透,少踩几个我踩过的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询