你以为文件上传就是
multipartFile.transferTo()?当文件变成2GB的视频、10GB的数据包时,一切都变了。------ 一篇用代码流程拆解大文件上传的实战手册
阅读时间:约40分钟 | 涵盖:分片上传原理 / Presigned URL / 断点续传 / 前端切片 / Spring Boot集成 / 生产Checklist
📑 目录
- 序章:为什么大文件上传是个"老大难"
- [第一幕:MinIO --- 你的私有S3](#第一幕:MinIO — 你的私有S3)
- [第二幕:单次上传 vs 分片上传](#第二幕:单次上传 vs 分片上传)
- 第三幕:分片上传完整流程拆解
- 第四幕:前端切片与并行上传
- [第五幕:Presigned URL --- 让前端直连MinIO](#第五幕:Presigned URL — 让前端直连MinIO)
- [第六幕:断点续传 --- 上传到一半断了怎么办](#第六幕:断点续传 — 上传到一半断了怎么办)
- [第七幕:Spring Boot集成实战](#第七幕:Spring Boot集成实战)
- [第八幕:性能调优 --- 把速度拉满](#第八幕:性能调优 — 把速度拉满)
- 第九幕:生产环境Checklist
- 参考文献
🎬 序章:为什么大文件上传是个"老大难"

传统文件上传的困境
在Web应用中,文件上传是最常见的功能之一。但当文件变大时,传统的上传方式就会遇到一系列问题:
场景还原: 你在做一个视频平台,用户要上传一个2.5GB的视频文件。
传统做法:
java
@PostMapping("/upload")
public String upload(@RequestParam("file") MultipartFile file) {
file.transferTo(new File("/data/videos/" + file.getOriginalFilename()));
return "success";
}
这段代码会遇到什么问题?
- 请求超时 :2.5GB的文件在网络上传输需要很长时间,Nginx默认的
client_max_body_size是1MB,Tomcat默认也有大小限制 - 内存溢出 :Spring的
MultipartFile默认会把整个文件读入内存或临时文件,2.5GB直接把内存撑爆 - 网络中断:传到一半断网了,得从头再来。用户上传了2GB,断了,重传2.5GB------用户体验极差
- 后端瓶颈:所有数据都经过后端服务器,后端成了带宽瓶颈
- 没有进度:用户不知道上传了多少,只能看着转圈
结论:大文件上传(>100MB)必须用分片上传(Multipart Upload)。
本文的目标
读完这篇文章,你将掌握:
- MinIO的分片上传原理和API
- 前端如何切片、并行上传、显示进度
- 如何用Presigned URL让前端直传MinIO(后端不做代理)
- 如何实现断点续传(暂停/恢复上传)
- 如何用Spring Boot集成MinIO实现完整的上传服务
- 生产环境的安全、性能、可靠性Checklist
🏠 第一幕:MinIO --- 你的私有S3

1.1 MinIO是什么?
MinIO是一个高性能的对象存储服务,兼容Amazon S3 API。你可以把它理解为"私有部署的S3"。
为什么选MinIO而不是直接用AWS S3?
| 对比维度 | MinIO | AWS S3 |
|---|---|---|
| 部署方式 | 自建(Docker/K8s) | 云服务 |
| 数据控制 | 完全自控 | 存在AWS |
| 成本 | 硬件成本 | 按量付费(流量贵) |
| S3兼容 | 100%兼容 | 原生 |
| 性能 | 极高(32GiB/s读取) | 高 |
| 适用场景 | 私有云、内网、合规要求 | 公有云、全球化 |
1.2 快速部署MinIO
bash
# Docker一键部署
docker run -d \
--name minio \
-p 9000:9000 \
-p 9001:9001 \
-e "MINIO_ROOT_USER=admin" \
-e "MINIO_ROOT_PASSWORD=admin123456" \
-v /data/minio:/data \
minio/minio server /data --console-address ":9001"
# 访问控制台: http://localhost:9001
# API地址: http://localhost:9000
1.3 核心概念
| 概念 | 说明 | 类比 |
|---|---|---|
| Bucket | 存储桶,文件的顶层容器 | 文件系统的根目录 |
| Object | 对象,一个文件就是一个对象 | 文件 |
| Key | 对象的唯一标识(路径) | 文件路径 |
| Presigned URL | 预签名URL,临时授权访问 | 一次性通行证 |
| Multipart Upload | 分片上传 | 分段快递 |
⚖️ 第二幕:单次上传 vs 分片上传

2.1 两种上传方式对比
表1:单次上传 vs 分片上传全面对比
| 对比维度 | 单次上传 (PUT Object) | 分片上传 (Multipart Upload) |
|---|---|---|
| 最大文件大小 | 5GB (MinIO限制) | 5 TiB |
| 请求超时风险 | 高(文件越大越容易超时) | 低(每个分片独立请求) |
| 断点续传 | ❌ 不支持 | ✅ 支持(跳过已完成分片) |
| 并行上传 | ❌ 单请求 | ✅ 多分片并行 |
| 进度追踪 | 粗粒度(整体进度) | 细粒度(每个分片进度) |
| 后端代理 | 必须(文件经过后端) | 可选(Presigned URL直传) |
| 内存占用 | 高(整个文件缓存) | 低(每次只处理一个分片) |
| 适用场景 | 小文件(<100MB) | 大文件(>100MB) |
| API调用次数 | 1次 | N+1次(N个分片 + 1次完成) |
| MinIO API | putObject() |
createMultipartUpload() + uploadPart() + completeMultipartUpload() |
2.2 分片上传的三步曲
分片上传的核心流程只有三步:
1. Init(初始化)
-> createMultipartUpload(bucket, key)
<- 返回 uploadId
2. Upload Parts(上传分片)
-> uploadPart(bucket, key, uploadId, partNumber, data)
<- 返回 ETag(每个分片的校验值)
3. Complete(合并完成)
-> completeMultipartUpload(bucket, key, uploadId, parts[])
<- 对象创建完成
📦 快递类比: 就像寄一个大件家具。你不能整个塞进一个快递箱,而是拆成几件分别寄。收件人收到所有件后,组装成完整的家具。
uploadId就是快递单号,ETag就是每个包裹的签收确认。
🔄 第三幕:分片上传完整流程拆解

3.1 完整时序(12步)
让我们追踪一个2.5GB视频文件的完整上传流程:
Step 1:用户选择文件
前端: 用户通过 <input type="file"> 选择 video.mp4 (2.5GB)
Step 2:前端请求后端初始化上传
http
POST /api/upload/init
Content-Type: application/json
{
"filename": "video.mp4",
"filesize": 2684354560,
"fileHash": "sha256:a1b2c3d4..."
}
Step 3:后端调用MinIO创建分片上传
java
// 后端代码
CreateMultipartUploadResponse response = minioClient.createMultipartUpload(
CreateMultipartUploadArgs.builder()
.bucket("videos")
.object("2025/07/video.mp4")
.contentType("video/mp4")
.build()
);
String uploadId = response.result().uploadId();
Step 4:后端生成Presigned URL
java
// 为每个分片生成一个预签名URL
List<String> presignedUrls = new ArrayList<>();
for (int i = 1; i <= totalParts; i++) {
String url = minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.PUT)
.bucket("videos")
.object("2025/07/video.mp4")
.extraQueryParams(Map.of(
"partNumber", String.valueOf(i),
"uploadId", uploadId
))
.expiry(1, TimeUnit.HOURS)
.build()
);
presignedUrls.add(url);
}
Step 5:后端返回给前端
json
{
"uploadId": "abc-123-def-456",
"partSize": 10485760,
"totalParts": 250,
"presignedUrls": [
"https://minio.example.com/videos/2025/07/video.mp4?partNumber=1&uploadId=abc-123&X-Amz-...",
"https://minio.example.com/videos/2025/07/video.mp4?partNumber=2&uploadId=abc-123&X-Amz-...",
...
]
}
Step 6:前端切片
javascript
const CHUNK_SIZE = 10 * 1024 * 1024; // 10MB
const chunks = [];
for (let start = 0; start < file.size; start += CHUNK_SIZE) {
chunks.push(file.slice(start, start + CHUNK_SIZE));
}
// chunks.length = 250
Step 7-9:前端并行上传分片到MinIO
javascript
// 3个并发
const concurrency = 3;
const results = [];
for (let i = 0; i < chunks.length; i += concurrency) {
const batch = chunks.slice(i, i + concurrency);
const promises = batch.map((chunk, idx) => {
const partNumber = i + idx + 1;
return axios.put(presignedUrls[partNumber - 1], chunk, {
headers: { 'Content-Type': 'application/octet-stream' }
}).then(res => ({
partNumber,
etag: res.headers.etag
}));
});
results.push(...await Promise.all(promises));
// 更新进度: results.length / totalParts * 100 + '%'
}
Step 10:前端通知后端上传完成
http
POST /api/upload/complete
Content-Type: application/json
{
"uploadId": "abc-123-def-456",
"parts": [
{"partNumber": 1, "etag": "etag-1"},
{"partNumber": 2, "etag": "etag-2"},
...
]
}
Step 11:后端调用MinIO合并分片
java
List<Part> parts = partList.stream()
.map(p -> new Part(p.getPartNumber(), p.getEtag()))
.collect(Collectors.toList());
minioClient.completeMultipartUpload(
CompleteMultipartUploadArgs.builder()
.bucket("videos")
.object("2025/07/video.mp4")
.uploadId(uploadId)
.parts(parts)
.build()
);
Step 12:对象创建完成!
MinIO将所有分片合并成一个完整的对象。用户可以通过URL访问这个视频了。
3.2 完整UploadService实现
下面是后端 UploadService 的完整代码,包含初始化、完成、中止、查询已上传分片等所有方法:
java
@Service
@Slf4j
public class UploadService {
@Autowired
private MinioClient minioClient;
@Autowired
private FileRecordMapper fileRecordMapper;
@Autowired
private StringRedisTemplate redisTemplate;
private static final String BUCKET = "videos";
private static final long PART_SIZE = 10 * 1024 * 1024; // 10MB
/**
* 初始化分片上传
*/
public UploadInitVO initMultipartUpload(String filename, long fileSize, String fileHash) {
// 1. 秒传检查:文件哈希是否已存在
FileRecord existing = fileRecordMapper.selectByHash(fileHash);
if (existing != null) {
return UploadInitVO.duplicate(existing.getUrl());
}
// 2. 检查是否有未完成的上传(断点续传)
String cachedUploadId = redisTemplate.opsForValue().get("upload:progress:" + fileHash);
if (cachedUploadId != null) {
// 查询MinIO已上传的分片
List<Integer> uploadedParts = getUploadedParts(cachedUploadId, generateKey(filename));
if (!uploadedParts.isEmpty()) {
return UploadInitVO.resume(cachedUploadId, generateKey(filename), uploadedParts);
}
}
// 3. 创建新的分片上传
String objectKey = generateKey(filename);
CreateMultipartUploadResponse response = minioClient.createMultipartUpload(
CreateMultipartUploadArgs.builder()
.bucket(BUCKET)
.object(objectKey)
.contentType(getContentType(filename))
.build()
);
String uploadId = response.result().uploadId();
// 4. 计算分片数
int totalParts = (int) Math.ceil((double) fileSize / PART_SIZE);
// 5. 生成Presigned URL(批量)
List<String> presignedUrls = generatePresignedUrls(objectKey, uploadId, totalParts);
// 6. 缓存上传进度
redisTemplate.opsForValue().set(
"upload:progress:" + fileHash,
uploadId,
24, TimeUnit.HOURS
);
return UploadInitVO.newUpload(uploadId, objectKey, PART_SIZE, totalParts, presignedUrls);
}
/**
* 批量生成Presigned URL
*/
private List<String> generatePresignedUrls(String objectKey, String uploadId, int totalParts) {
List<String> urls = new ArrayList<>();
for (int i = 1; i <= totalParts; i++) {
String url = minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.PUT)
.bucket(BUCKET)
.object(objectKey)
.extraQueryParams(Map.of(
"partNumber", String.valueOf(i),
"uploadId", uploadId
))
.expiry(1, TimeUnit.HOURS)
.build()
);
urls.add(url);
}
return urls;
}
/**
* 完成分片上传
*/
public String completeMultipartUpload(String uploadId, String objectKey, List<PartInfo> parts) {
// 1. 构建Part列表
List<Part> partList = parts.stream()
.map(p -> new Part(p.getPartNumber(), p.getEtag()))
.sorted(Comparator.comparing(Part::partNumber))
.collect(Collectors.toList());
// 2. 调用MinIO合并分片
minioClient.completeMultipartUpload(
CompleteMultipartUploadArgs.builder()
.bucket(BUCKET)
.object(objectKey)
.uploadId(uploadId)
.parts(partList)
.build()
);
// 3. 生成访问URL
String url = minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(BUCKET)
.object(objectKey)
.expiry(7, TimeUnit.DAYS)
.build()
);
// 4. 保存文件记录到数据库
FileRecord record = new FileRecord();
record.setObjectKey(objectKey);
record.setUrl(url);
record.setUploadId(uploadId);
record.setPartCount(partList.size());
record.setStatus("completed");
record.setCreatedAt(LocalDateTime.now());
fileRecordMapper.insert(record);
// 5. 清理Redis缓存
redisTemplate.delete("upload:progress:" + objectKey);
log.info("Multipart upload completed: {} ({} parts)", objectKey, partList.size());
return url;
}
/**
* 中止分片上传
*/
public void abortMultipartUpload(String uploadId, String objectKey) {
try {
minioClient.abortMultipartUpload(
AbortMultipartUploadArgs.builder()
.bucket(BUCKET)
.object(objectKey)
.uploadId(uploadId)
.build()
);
log.info("Multipart upload aborted: {}", objectKey);
} catch (Exception e) {
log.warn("Failed to abort upload: {}", e.getMessage());
}
}
/**
* 查询已上传的分片列表
*/
public List<Integer> getUploadedParts(String uploadId, String objectKey) {
ListPartsResponse response = minioClient.listParts(
ListPartsArgs.builder()
.bucket(BUCKET)
.object(objectKey)
.uploadId(uploadId)
.build()
);
return response.result().partList().stream()
.map(Part::partNumber)
.collect(Collectors.toList());
}
/**
* 生成对象Key(按日期分目录)
*/
private String generateKey(String filename) {
String date = LocalDate.now().format(DateTimeFormatter.ofPattern("yyyy/MM/dd"));
String uuid = UUID.randomUUID().toString().replace("-", "");
String ext = filename.contains(".") ? filename.substring(filename.lastIndexOf(".")) : "";
return date + "/" + uuid + ext;
}
/**
* 根据文件名推断Content-Type
*/
private String getContentType(String filename) {
String ext = filename.contains(".") ? filename.substring(filename.lastIndexOf(".")).toLowerCase() : "";
return switch (ext) {
case ".mp4" -> "video/mp4";
case ".jpg", ".jpeg" -> "image/jpeg";
case ".png" -> "image/png";
case ".pdf" -> "application/pdf";
case ".zip" -> "application/zip";
default -> "application/octet-stream";
};
}
}
3.3 DTO/VO定义
java
// 请求DTO
@Data
public class UploadInitDTO {
private String filename; // 原始文件名
private long fileSize; // 文件大小(字节)
private String fileHash; // 文件哈希(秒传+断点续传用)
private String contentType; // MIME类型
}
@Data
public class UploadCompleteDTO {
private String uploadId;
private String objectKey;
private List<PartInfo> parts;
}
@Data
public class PartInfo {
private int partNumber;
private String etag;
}
// 响应VO
@Data
public class UploadInitVO {
private String uploadId;
private String objectKey;
private long partSize;
private int totalParts;
private List<String> presignedUrls;
private boolean duplicate; // 是否秒传
private boolean resume; // 是否断点续传
private List<Integer> uploadedParts; // 已上传的分片(断点续传时有值)
private String existingUrl; // 秒传时的已有URL
// 工厂方法
public static UploadInitVO newUpload(String uploadId, String objectKey, long partSize, int totalParts, List<String> urls) { ... }
public static UploadInitVO duplicate(String url) { ... }
public static UploadInitVO resume(String uploadId, String objectKey, List<Integer> parts) { ... }
}
3.4 数据库设计
sql
CREATE TABLE file_record (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
object_key VARCHAR(500) NOT NULL COMMENT 'MinIO对象Key',
original_name VARCHAR(255) NOT NULL COMMENT '原始文件名',
file_hash VARCHAR(64) NOT NULL COMMENT '文件哈希(SHA-256)',
file_size BIGINT NOT NULL COMMENT '文件大小(字节)',
content_type VARCHAR(100) COMMENT 'MIME类型',
upload_id VARCHAR(100) COMMENT '分片上传ID',
part_count INT COMMENT '分片数量',
status VARCHAR(20) DEFAULT 'uploading' COMMENT 'uploading/completed/failed',
url VARCHAR(1000) COMMENT '访问URL',
user_id BIGINT COMMENT '上传用户ID',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_hash (file_hash),
INDEX idx_user (user_id),
INDEX idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
3.5 关键点:为什么用Presigned URL?
传统方式(经过后端代理):
前端 --[2.5GB]--> 后端 --[2.5GB]--> MinIO
后端承受了2倍的带宽压力,而且内存可能被撑爆。
Presigned URL方式(直传):
前端 --[请求URL]--> 后端(返回URL,几百字节)
前端 --[2.5GB]--> MinIO(直传,不经过后端)
后端只处理几十字节的签名请求,大文件直接传到MinIO。
✂️ 第四幕:前端切片与并行上传

4.1 File.slice() --- 浏览器原生切片
HTML5的 File 对象继承自 Blob,提供了 slice() 方法,可以在不读取整个文件的情况下切出一块:
javascript
/**
* 切片文件
* @param {File} file - 原始文件
* @param {number} chunkSize - 分片大小(字节)
* @returns {Blob[]} - 分片数组
*/
function chunkFile(file, chunkSize = 10 * 1024 * 1024) {
const chunks = [];
let start = 0;
while (start < file.size) {
const end = Math.min(start + chunkSize, file.size);
chunks.push(file.slice(start, end));
start = end;
}
return chunks;
}
关键点:
slice()不会复制数据,只是创建一个指向原文件指定区域的引用- 每个chunk是一个
Blob对象,可以直接作为HTTP请求体发送 - 切片操作几乎是瞬时的(O(1),不读取文件内容)
4.2 并行上传队列
javascript
/**
* 并行上传管理器
*/
class ParallelUploader {
constructor(options = {}) {
this.concurrency = options.concurrency || 3; // 并发数
this.retryCount = options.retryCount || 3; // 重试次数
this.onProgress = options.onProgress || (() => {});
}
async upload(chunks, presignedUrls) {
const results = [];
let completed = 0;
// 创建上传任务队列
const tasks = chunks.map((chunk, i) => ({
chunk,
partNumber: i + 1,
url: presignedUrls[i],
retries: 0
}));
// 并行执行
const executing = [];
for (const task of tasks) {
const promise = this.uploadPart(task).then(result => {
completed++;
this.onProgress(completed, chunks.length);
results.push(result);
});
executing.push(promise);
if (executing.length >= this.concurrency) {
await Promise.race(executing);
executing.splice(executing.findIndex(p => p === promise), 1);
}
}
await Promise.all(executing);
return results.sort((a, b) => a.partNumber - b.partNumber);
}
async uploadPart(task) {
for (let i = 0; i <= this.retryCount; i++) {
try {
const response = await axios.put(task.url, task.chunk, {
headers: { 'Content-Type': 'application/octet-stream' },
timeout: 300000 // 5分钟超时
});
return {
partNumber: task.partNumber,
etag: response.headers.etag
};
} catch (err) {
if (i === this.retryCount) throw err;
// 指数退避
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i)));
}
}
}
}
4.3 完整Vue3上传组件
下面是一个生产级的Vue3上传组件,包含切片、并行上传、进度条、暂停/恢复功能:
typescript
// composables/useMultipartUpload.ts
import { ref, computed } from 'vue';
import axios from 'axios';
import SparkMD5 from 'spark-md5';
interface UploadState {
fileHash: string;
uploadId: string;
objectKey: string;
totalParts: number;
completedParts: number[];
status: 'idle' | 'hashing' | 'uploading' | 'paused' | 'completed' | 'error';
progress: number;
speed: number; // bytes per second
}
export function useMultipartUpload() {
const state = ref<UploadState>({
fileHash: '',
uploadId: '',
objectKey: '',
totalParts: 0,
completedParts: [],
status: 'idle',
progress: 0,
speed: 0,
});
const isPaused = ref(false);
const abortController = ref<AbortController | null>(null);
// 计算文件哈希(Web Worker)
async function computeFileHash(file: File): Promise<string> {
return new Promise((resolve) => {
const worker = new Worker('/workers/hash-worker.js');
worker.postMessage({ file });
worker.onmessage = (e) => {
resolve(e.data.hash);
worker.terminate();
};
});
}
// 切片
function chunkFile(file: File, chunkSize: number): Blob[] {
const chunks: Blob[] = [];
for (let start = 0; start < file.size; start += chunkSize) {
chunks.push(file.slice(start, Math.min(start + chunkSize, file.size)));
}
return chunks;
}
// 主上传函数
async function upload(file: File) {
state.value.status = 'hashing';
// 1. 计算文件哈希
const fileHash = await computeFileHash(file);
state.value.fileHash = fileHash;
// 2. 检查秒传
const checkRes = await axios.get('/api/upload/check', { params: { fileHash } });
if (checkRes.data.data.exists) {
state.value.status = 'completed';
state.value.progress = 100;
return checkRes.data.data.url;
}
// 3. 初始化上传
state.value.status = 'uploading';
const initRes = await axios.post('/api/upload/init', {
filename: file.name,
fileSize: file.size,
fileHash,
});
const { uploadId, objectKey, partSize, totalParts, presignedUrls, uploadedParts } = initRes.data.data;
state.value.uploadId = uploadId;
state.value.objectKey = objectKey;
state.value.totalParts = totalParts;
state.value.completedParts = uploadedParts || [];
// 4. 切片
const chunks = chunkFile(file, partSize);
// 5. 并行上传(跳过已完成的)
const startTime = Date.now();
const concurrency = 3;
const pending: number[] = [];
for (let i = 0; i < chunks.length; i++) {
if (!state.value.completedParts.includes(i + 1)) {
pending.push(i);
}
}
let uploaded = state.value.completedParts.length;
const executing: Promise<void>[] = [];
for (const chunkIdx of pending) {
if (isPaused.value) break;
const promise = uploadPart(
presignedUrls[chunkIdx],
chunks[chunkIdx],
chunkIdx + 1
).then((result) => {
state.value.completedParts.push(result.partNumber);
uploaded++;
state.value.progress = Math.round((uploaded / totalParts) * 100);
state.value.speed = file.size / ((Date.now() - startTime) / 1000) * (uploaded / totalParts);
});
executing.push(promise);
if (executing.length >= concurrency) {
await Promise.race(executing);
executing.splice(executing.findIndex(p => p === promise), 1);
}
}
await Promise.all(executing);
// 6. 完成
if (!isPaused.value) {
const completeRes = await axios.post('/api/upload/complete', {
uploadId,
objectKey,
parts: state.value.completedParts.map(n => ({ partNumber: n, etag: '' })),
});
state.value.status = 'completed';
return completeRes.data.data;
}
}
// 单分片上传(带重试)
async function uploadPart(url: string, chunk: Blob, partNumber: number, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
const res = await axios.put(url, chunk, {
headers: { 'Content-Type': 'application/octet-stream' },
timeout: 300000,
});
return { partNumber, etag: res.headers.etag };
} catch (err) {
if (i === retries - 1) throw err;
await new Promise(r => setTimeout(r, 1000 * Math.pow(2, i)));
}
}
throw new Error('Upload failed after retries');
}
// 暂停
function pause() {
isPaused.value = true;
state.value.status = 'paused';
// 保存状态到localStorage
localStorage.setItem(`upload_${state.value.fileHash}`, JSON.stringify({
fileHash: state.value.fileHash,
uploadId: state.value.uploadId,
objectKey: state.value.objectKey,
completedParts: state.value.completedParts,
totalParts: state.value.totalParts,
}));
}
// 恢复
function resume() {
isPaused.value = false;
state.value.status = 'uploading';
}
return { state, upload, pause, resume, isPaused };
}
4.4 Web Worker计算文件哈希
javascript
// public/workers/hash-worker.js
importScripts('https://cdn.jsdelivr.net/npm/spark-md5@3.0.2/spark-md5.min.js');
self.onmessage = function(e) {
const file = e.data.file;
const chunkSize = 2 * 1024 * 1024; // 2MB
const spark = new SparkMD5.ArrayBuffer();
const reader = new FileReader();
let currentChunk = 0;
const totalChunks = Math.ceil(file.size / chunkSize);
reader.onload = function(e) {
spark.append(e.target.result);
currentChunk++;
if (currentChunk < totalChunks) {
loadNext();
} else {
// 加上文件大小作为因子,避免不同文件相同内容的哈希碰撞
spark.append(new TextEncoder().encode(String(file.size)));
const hash = spark.end();
self.postMessage({ hash });
}
};
function loadNext() {
const start = currentChunk * chunkSize;
const end = Math.min(start + chunkSize, file.size);
reader.readAsArrayBuffer(file.slice(start, end));
}
loadNext();
};
4.5 分片大小选择
表2:分片大小对上传的影响
| 分片大小 | 分片数量(1GB文件) | 单片上传时间(100Mbps) | 内存占用 | 失败重传代价 | 推荐场景 |
|---|---|---|---|---|---|
| 1MB | 1024 | ~0.08s | 低 | 极低 | 不推荐(API调用太多) |
| 5MB | 204 | ~0.4s | 低 | 低 | 弱网环境 |
| 10MB | 102 | ~0.8s | 低 | 低 | 推荐(通用) |
| 25MB | 41 | ~2s | 中 | 中 | 稳定网络 |
| 50MB | 21 | ~4s | 中 | 中高 | 高速内网 |
| 100MB | 11 | ~8s | 高 | 高 | 不推荐 |
MinIO限制:
- 最小分片大小:5MB(最后一个分片可以小于5MB)
- 最大分片数:10,000
- 最大对象大小:5 TiB
🔐 第五幕:Presigned URL --- 让前端直连MinIO

5.1 Presigned URL的本质
Presigned URL是一个带有AWS Signature V4签名的URL,它允许任何人用这个URL在限定时间内执行指定的操作(PUT/GET),而不需要Access Key。
https://minio.example.com/videos/video.mp4
?partNumber=1
&uploadId=abc-123
&X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Credential=admin/20250713/us-east-1/s3/aws4_request
&X-Amz-Date=20250713T080000Z
&X-Amz-Expires=3600
&X-Amz-SignedHeaders=host
&X-Amz-Signature=abcdef1234567890...
安全特性:
- 时效性:过期后URL失效(默认7天,建议1小时)
- 绑定操作:只能用于指定的HTTP方法(PUT/GET)
- 绑定路径:只能操作指定的bucket和key
- 不可篡改:签名覆盖了所有参数,修改任何参数都会导致签名验证失败
5.2 后端生成Presigned URL
java
@Service
public class UploadService {
@Autowired
private MinioClient minioClient;
/**
* 初始化分片上传,返回uploadId和预签名URL列表
*/
public UploadInitResult initMultipartUpload(String filename, long fileSize) {
String objectKey = generateObjectKey(filename); // 2025/07/uuid.mp4
String bucket = "videos";
// 1. 创建分片上传
CreateMultipartUploadResponse response = minioClient.createMultipartUpload(
CreateMultipartUploadArgs.builder()
.bucket(bucket)
.object(objectKey)
.contentType(getContentType(filename))
.build()
);
String uploadId = response.result().uploadId();
// 2. 计算分片数
long partSize = 10 * 1024 * 1024; // 10MB
int totalParts = (int) Math.ceil((double) fileSize / partSize);
// 3. 为每个分片生成Presigned URL
List<String> presignedUrls = new ArrayList<>();
for (int i = 1; i <= totalParts; i++) {
String url = minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.PUT)
.bucket(bucket)
.object(objectKey)
.extraQueryParams(Map.of(
"partNumber", String.valueOf(i),
"uploadId", uploadId
))
.expiry(1, TimeUnit.HOURS)
.build()
);
presignedUrls.add(url);
}
return new UploadInitResult(uploadId, objectKey, partSize, totalParts, presignedUrls);
}
}
🔁 第六幕:断点续传 --- 上传到一半断了怎么办

6.1 断点续传的核心思想
断点续传的关键:记住哪些分片已经上传成功了,恢复时只上传剩余的分片。
状态保存(localStorage):
javascript
// 上传前,先检查有没有未完成的上传
function getUploadState(fileHash) {
const state = localStorage.getItem(`upload_${fileHash}`);
return state ? JSON.parse(state) : null;
}
// 保存上传状态
function saveUploadState(fileHash, uploadId, completedParts, totalParts) {
localStorage.setItem(`upload_${fileHash}`, JSON.stringify({
fileHash,
uploadId,
completedParts, // [1, 2, 3, ...] 已完成的分片编号
totalParts,
timestamp: Date.now()
}));
}
// 清除上传状态(上传完成或取消时)
function clearUploadState(fileHash) {
localStorage.removeItem(`upload_${fileHash}`);
}
6.2 恢复上传逻辑
javascript
async function resumeUpload(file) {
// 1. 计算文件哈希(用于标识文件)
const fileHash = await computeFileHash(file);
// 2. 检查是否有未完成的上传
const state = getUploadState(fileHash);
if (state) {
// 有未完成的上传,询问用户是否继续
const confirmed = await confirm('检测到未完成的上传,是否继续?');
if (confirmed) {
// 3. 从后端获取已上传的分片信息
const serverParts = await api.getUploadedParts(state.uploadId);
// 4. 合并:已确认的 + 服务器端的
const completedPartNumbers = new Set([
...state.completedParts,
...serverParts.map(p => p.partNumber)
]);
// 5. 只上传未完成的分片
const remainingChunks = [];
const remainingUrls = [];
for (let i = 0; i < totalParts; i++) {
if (!completedPartNumbers.has(i + 1)) {
remainingChunks.push(chunks[i]);
remainingUrls.push(newPresignedUrls[i]);
}
}
// 6. 继续上传
await uploadChunks(remainingChunks, remainingUrls);
}
} else {
// 全新上传
await freshUpload(file, fileHash);
}
}
6.3 文件哈希计算
文件哈希用于标识文件的唯一性。相同的文件 = 相同的哈希 = 可以复用上传状态。
javascript
/**
* 使用spark-md5计算文件哈希(Web Worker中执行,不阻塞主线程)
* 只读取每个分片的首尾各2MB + 文件大小,快速计算"模糊哈希"
*/
async function computeFileHash(file) {
const CHUNK_SIZE = 2 * 1024 * 1024; // 2MB
const chunks = [];
// 取首片
chunks.push(file.slice(0, CHUNK_SIZE));
// 取中间片
const mid = Math.floor(file.size / 2);
chunks.push(file.slice(mid, mid + CHUNK_SIZE));
// 取尾片
chunks.push(file.slice(Math.max(0, file.size - CHUNK_SIZE)));
// 加上文件大小作为因子
const blob = new Blob([...chunks, new Blob([String(file.size)])]);
const buffer = await blob.arrayBuffer();
const hash = spark_md5.ArrayBuffer.hash(buffer);
return hash;
}
💡 性能提示: 不要对整个2.5GB文件计算MD5------太慢了(可能要几十秒)。用"模糊哈希"(只读取首尾和中间的一小部分),速度快,冲突率极低。
6.4 服务端去重
java
/**
* 检查文件是否已存在(秒传)
*/
@GetMapping("/api/upload/check")
public UploadCheckResult checkFile(@RequestParam String fileHash) {
// 查数据库:有没有相同哈希的文件?
FileRecord record = fileRecordMapper.selectByHash(fileHash);
if (record != null) {
// 文件已存在,直接返回URL(秒传!)
return UploadCheckResult.exists(record.getUrl());
}
return UploadCheckResult.notExists();
}
🧩 第七幕:Spring Boot集成实战

7.1 依赖配置
xml
<!-- pom.xml -->
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.7</version>
</dependency>
yaml
# application.yml
minio:
endpoint: http://localhost:9000
access-key: admin
secret-key: admin123456
bucket: videos
7.2 MinioClient配置
java
@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();
}
}
7.3 完整Controller
java
@RestController
@RequestMapping("/api/upload")
public class UploadController {
@Autowired
private UploadService uploadService;
/**
* 1. 初始化分片上传
*/
@PostMapping("/init")
public Result<UploadInitVO> initUpload(@RequestBody UploadInitDTO dto) {
// 参数校验
if (dto.getFileSize() > 5L * 1024 * 1024 * 1024) {
return Result.fail("文件大小不能超过5GB");
}
if (!ALLOWED_TYPES.contains(dto.getContentType())) {
return Result.fail("不支持的文件类型");
}
UploadInitVO result = uploadService.initMultipartUpload(
dto.getFilename(),
dto.getFileSize(),
dto.getFileHash()
);
return Result.ok(result);
}
/**
* 2. 完成分片上传
*/
@PostMapping("/complete")
public Result<String> completeUpload(@RequestBody UploadCompleteDTO dto) {
String url = uploadService.completeMultipartUpload(
dto.getUploadId(),
dto.getObjectKey(),
dto.getParts()
);
return Result.ok(url);
}
/**
* 3. 取消/中止上传
*/
@PostMapping("/abort")
public Result<Void> abortUpload(@RequestBody UploadAbortDTO dto) {
uploadService.abortMultipartUpload(dto.getUploadId(), dto.getObjectKey());
return Result.ok();
}
/**
* 4. 检查文件是否已存在(秒传)
*/
@GetMapping("/check")
public Result<UploadCheckVO> checkFile(@RequestParam String fileHash) {
UploadCheckVO result = uploadService.checkFileExists(fileHash);
return Result.ok(result);
}
}
表3:API接口汇总
| 接口 | 方法 | 说明 | 请求体 | 响应 |
|---|---|---|---|---|
/api/upload/init |
POST | 初始化分片上传 | filename, fileSize, fileHash | uploadId, presignedUrls\[\] |
/api/upload/complete |
POST | 完成上传 | uploadId, objectKey, parts\[\] | 文件URL |
/api/upload/abort |
POST | 取消上传 | uploadId, objectKey | - |
/api/upload/check |
GET | 秒传检查 | fileHash | exists, url |
/api/upload/parts |
GET | 已上传分片 | uploadId, objectKey | partNumbers\[\] |
❌ 第七幕半:错误处理与重试策略

7.5.1 常见错误类型
| 错误类型 | HTTP状态码 | 原因 | 处理策略 |
|---|---|---|---|
| NetworkTimeout | 0 | 网络超时 | 重试(换URL) |
| SlowUpload | 0 | 带宽不足 | 重试(降低并发) |
| 5xx ServerError | 500/502/503 | MinIO临时故障 | 重试(指数退避) |
| 403 Forbidden | 403 | Presigned URL过期 | 重新获取URL后重试 |
| ChecksumMismatch | - | 数据传输损坏 | 重试(新URL) |
| QuotaExceeded | 507 | Bucket容量满 | 报错,通知管理员 |
| EntityTooSmall | 400 | 分片小于5MB | 报错,调整分片大小 |
上传过程中的常见错误
在大文件上传过程中,网络抖动、服务端临时故障等都可能导致分片上传失败。关键原则是:分片级重试,而不是整个文件重试。
指数退避(Exponential Backoff):
重试间隔 = base * 2^(attempt-1),其中 base = 1秒。
- 第1次重试:等1秒
- 第2次重试:等2秒
- 第3次重试:等4秒
- 最大等待:30秒
为什么不用固定间隔? 如果很多客户端同时重试(比如MinIO临时重启),固定间隔会导致"惊群效应"------所有客户端同时涌回来,再次压垮服务端。指数退避让重试时间分散开,减轻服务端压力。
7.6 Nginx配置
当前端直传MinIO时,Nginx不需要代理上传流量。但你仍然需要配置Nginx来代理API请求:
nginx
server {
listen 80;
server_name api.example.com;
# API请求代理到Spring Boot
location /api/ {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# 重要:API请求体较小(只有元数据),保持默认限制即可
client_max_body_size 1m;
}
# 不要代理MinIO的上传请求!前端直连MinIO
# 如果必须代理(不推荐),需要设置:
# client_max_body_size 0; # 不限制
# proxy_request_buffering off; # 不缓冲请求体
}
7.7 MinIO Docker Compose部署
yaml
version: '3.8'
services:
minio:
image: minio/minio:latest
container_name: minio
ports:
- "9000:9000" # API端口
- "9001:9001" # 控制台端口
environment:
MINIO_ROOT_USER: admin
MINIO_ROOT_PASSWORD: your-strong-password-here
volumes:
- /data/minio:/data
command: server /data --console-address ":9001"
restart: unless-stopped
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 30s
timeout: 20s
retries: 3
7.8 MinIO Bucket Lifecycle配置
清理过期的未完成上传,避免存储空间浪费:
bash
# 使用mc命令行工具配置lifecycle
mc alias set myminio http://localhost:9000 admin your-password
# 设置规则:7天后自动清理未完成的分片上传
mc ilm rule add myminio/videos \
--expire-delete-marker \
--noncurrent-expire-days 7
# 或者使用API
# 通过MinIO控制台: Buckets -> videos -> Lifecycle -> Add Rule
# Rule name: abort-incomplete-uploads
# Abort incomplete multipart uploads after: 7 days
7.9 文件下载 --- Presigned GET URL
上传完成后,文件是私有的。需要下载时,生成临时的Presigned GET URL:
java
/**
* 生成文件下载URL(临时有效)
*/
public String generateDownloadUrl(String objectKey, int expiryHours) {
return minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.method(Method.GET)
.bucket(BUCKET)
.object(objectKey)
.expiry(expiryHours, TimeUnit.HOURS)
.build()
);
}
// 前端直接用这个URL下载,不需要经过后端
// window.location.href = downloadUrl;
⚡ 第八幕:性能调优 --- 把速度拉满

8.1 分片大小调优
表4:不同网络环境下的分片大小建议
| 网络环境 | 带宽 | 推荐分片大小 | 推荐并发数 | 理由 |
|---|---|---|---|---|
| 4G移动 | 10-50Mbps | 5MB | 2-3 | 小片减少重传代价 |
| 家庭宽带 | 50-200Mbps | 10MB | 3 | 平衡性能和可靠性 |
| 企业专线 | 200Mbps-1Gbps | 25MB | 3-5 | 大片减少API调用开销 |
| 内网 | 1-10Gbps | 50MB | 5-10 | 最大化吞吐量 |
| 弱网/不稳定 | <10Mbps | 5MB | 1-2 | 小片+低并发,减少失败 |
8.2 并发数调优
并发数不是越大越好。受限于:
- 浏览器并发连接限制:同一域名通常6个
- 带宽瓶颈:并发再多,带宽就那么多
- MinIO服务端压力:过多并发可能触发限流
推荐:3-5个并发,在大多数场景下都能获得不错的性能。
8.3 基准测试
表5:1GB文件上传性能测试(100Mbps网络)
| 配置 | 分片大小 | 并发数 | 上传时间 | API调用次数 | 推荐度 |
|---|---|---|---|---|---|
| 单次上传 | - | 1 | ~80s | 1 | ❌ 超时风险 |
| 分片(串行) | 10MB | 1 | ~82s | 101 | ❌ 太慢 |
| 分片(3并发) | 10MB | 3 | ~30s | 101 | ✅ 推荐 |
| 分片(5并发) | 10MB | 5 | ~18s | 101 | ✅ 最佳 |
| 分片(10并发) | 10MB | 10 | ~15s | 101 | ⚠️ 收益递减 |
| 分片(大块) | 50MB | 5 | ~20s | 21 | ⚠️ 重传代价高 |
8.4 前端优化技巧
javascript
// 1. Web Worker计算哈希(不阻塞UI)
const worker = new Worker('hash-worker.js');
worker.postMessage({ file });
worker.onmessage = (e) => {
console.log('File hash:', e.data.hash);
};
// 2. 流式进度上报(节流)
const throttledProgress = throttle((loaded, total) => {
updateProgressBar(loaded / total * 100);
}, 500); // 每500ms更新一次
// 3. 失败快速重试(不等所有分片完成)
async function uploadWithQuickRetry(chunk, url, retries = 3) {
for (let i = 0; i < retries; i++) {
try {
return await axios.put(url, chunk, { timeout: 300000 });
} catch (err) {
if (i === retries - 1) throw err;
await sleep(1000 * Math.pow(2, i)); // 指数退避
}
}
}
✅ 第九幕:生产环境Checklist

9.1 安全Checklist
| # | 检查项 | 说明 | 优先级 |
|---|---|---|---|
| 1 | 文件类型校验 | 后端校验MIME type,不要只靠前端 | P0 |
| 2 | 文件大小限制 | 后端限制最大文件大小(如5GB) | P0 |
| 3 | Presigned URL短过期 | 建议1小时,最长不超过1天 | P0 |
| 4 | 病毒扫描 | 上传完成后用ClamAV等扫描 | P1 |
| 5 | 访问频率限制 | 每用户每天限制上传次数 | P1 |
| 6 | Bucket私有策略 | 默认private,需要时生成临时GET URL | P0 |
| 7 | 分离环境Bucket | dev/staging/prod用不同bucket | P1 |
| 8 | 访问日志 | 开启MinIO access log | P2 |
9.2 可靠性Checklist
| # | 检查项 | 说明 | 优先级 |
|---|---|---|---|
| 1 | 分片级重试 | 失败的分片单独重试,不重传整个文件 | P0 |
| 2 | 指数退避 | 重试间隔: 1s, 2s, 4s | P0 |
| 3 | 状态持久化 | localStorage保存uploadId和已完成分片 | P0 |
| 4 | 清理过期上传 | 设置lifecycle规则,自动abort 7天前的未完成上传 | P1 |
| 5 | ETag校验 | 每个分片上传后验证ETag | P1 |
| 6 | MinIO健康检查 | 配置 /minio/health/live 探针 | P1 |
| 7 | 监控告警 | Prometheus + Grafana监控上传成功率 | P1 |
| 8 | 备份策略 | mc mirror 定期同步到远程 | P2 |
9.3 性能Checklist
| # | 检查项 | 说明 | 优先级 |
|---|---|---|---|
| 1 | 分片大小选择 | 根据网络环境选择5-25MB | P0 |
| 2 | 并发数控制 | 3-5个并发,不要超过10个 | P0 |
| 3 | 直传MinIO | 用Presigned URL,不经过后端代理 | P0 |
| 4 | Web Worker哈希 | 在Worker线程计算文件哈希 | P1 |
| 5 | 批量获取Presign | 一次请求获取所有分片的URL | P1 |
| 6 | Nginx配置 | client_max_body_size 0(不限制) |
P1 |
| 7 | 内核参数调优 | TCP buffer size, 文件描述符限制 | P2 |
| 8 | CDN加速 | 可选:用CDN加速上传(跨地域场景) | P2 |
🌟 结语
大文件上传看似简单,实则涉及前后端协作、网络传输、错误处理、性能优化等多个维度。MinIO的分片上传机制为我们提供了一个可靠的基础,但要在生产环境中用好它,还需要:
- Presigned URL让前端直连MinIO,后端不做带宽代理
- 前端切片+并行上传充分利用带宽
- 断点续传让用户不再害怕网络中断
- 文件哈希+秒传让重复文件瞬间完成
"好的文件上传体验,是用户感知不到上传的存在。进度条在走,后台在传,断了能续,重了能跳。等用户注意到的时候,上传已经完成了。"
📚 参考文献
- MinIO Official Documentation. "Multipart Upload API." https://min.io/docs/minio/linux/developers/java/API.html
- Amazon S3 Documentation. "Uploading and copying objects using multipart upload." https://docs.aws.amazon.com/AmazonS3/latest/userguide/mpuoverview.html
- MinIO Official Documentation. "Presigned URLs." https://min.io/docs/minio/linux/developers/java/API.html#getPresignedObjectUrl
- MinIO Official Documentation. "Erasure Coding." https://min.io/docs/minio/linux/overview/architecture.html
- MDN Web Docs. "File.slice() - Web APIs." https://developer.mozilla.org/en-US/docs/Web/API/Blob/slice
- RFC 9110. "HTTP Semantics." IETF, 2022. https://www.rfc-editor.org/rfc/rfc9110
- AWS Documentation. "Signature Calculations for the Authorization Header." https://docs.aws.amazon.com/AmazonS3/latest/API/sig-v4-header-based-auth.html
- MinIO Official Blog. "MinIO Performance on NVMe." https://blog.min.io/minio-performance-on-nvme/
- Spring Boot Documentation. "File Upload with Spring Boot." https://spring.io/guides/gs/uploading-files/
- spark-md5 Library. "Fast MD5 for large files in JavaScript." https://github.com/nicktomlin/spark-md5