云计算百科
云计算领域专业知识百科平台

MinIO大文件上传深度拆解:从原理到生产落地的完整指南

你以为文件上传就是 multipartFile.transferTo()?当文件变成2GB的视频、10GB的数据包时,一切都变了。 —— 一篇用代码流程拆解大文件上传的实战手册

阅读时间:约40分钟 | 涵盖:分片上传原理 / Presigned URL / 断点续传 / 前端切片 / Spring Boot集成 / 生产Checklist


📑 目录

  • 序章:为什么大文件上传是个"老大难"
  • 第一幕:MinIO — 你的私有S3
  • 第二幕:单次上传 vs 分片上传
  • 第三幕:分片上传完整流程拆解
  • 第四幕:前端切片与并行上传
  • 第五幕:Presigned URL — 让前端直连MinIO
  • 第六幕:断点续传 — 上传到一半断了怎么办
  • 第七幕:Spring Boot集成实战
  • 第八幕:性能调优 — 把速度拉满
  • 第九幕:生产环境Checklist
  • 参考文献

  • 🎬 序章:为什么大文件上传是个"老大难"

    在这里插入图片描述

    传统文件上传的困境

    在Web应用中,文件上传是最常见的功能之一。但当文件变大时,传统的上传方式就会遇到一系列问题:

    场景还原: 你在做一个视频平台,用户要上传一个2.5GB的视频文件。

    传统做法:

    @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?

    对比维度MinIOAWS S3
    部署方式 自建(Docker/K8s) 云服务
    数据控制 完全自控 存在AWS
    成本 硬件成本 按量付费(流量贵)
    S3兼容 100%兼容 原生
    性能 极高(32GiB/s读取)
    适用场景 私有云、内网、合规要求 公有云、全球化

    1.2 快速部署MinIO

    # 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:前端请求后端初始化上传

    POST /api/upload/init
    Content-Type: application/json

    {
    "filename": "video.mp4",
    "filesize": 2684354560,
    "fileHash": "sha256:a1b2c3d4…"
    }

    Step 3:后端调用MinIO创建分片上传

    // 后端代码
    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

    // 为每个分片生成一个预签名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:后端返回给前端

    {
    "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:前端切片

    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

    // 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:前端通知后端上传完成

    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合并分片

    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 的完整代码,包含初始化、完成、中止、查询已上传分片等所有方法:

    @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定义

    // 请求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 数据库设计

    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() 方法,可以在不读取整个文件的情况下切出一块:

    /**
    * 切片文件
    * @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 并行上传队列

    /**
    * 并行上传管理器
    */

    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上传组件,包含切片、并行上传、进度条、暂停/恢复功能:

    // 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计算文件哈希

    // 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。


    ?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

    @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):

    // 上传前,先检查有没有未完成的上传
    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 恢复上传逻辑

    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 文件哈希计算

    文件哈希用于标识文件的唯一性。相同的文件 = 相同的哈希 = 可以复用上传状态。

    /**
    * 使用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 服务端去重

    /**
    * 检查文件是否已存在(秒传)
    */

    @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 依赖配置

    <!– pom.xml –>
    <dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.7</version>
    </dependency>

    # application.yml
    minio:
    endpoint: http://localhost:9000
    access-key: admin
    secret-key: admin123456
    bucket: videos

    7.2 MinioClient配置

    @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

    @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请求:

    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部署

    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: yourstrongpasswordhere
    volumes:
    /data/minio:/data
    command: server /data consoleaddress ":9001"
    restart: unlessstopped
    healthcheck:
    test: ["CMD", "mc", "ready", "local"]
    interval: 30s
    timeout: 20s
    retries: 3

    7.8 MinIO Bucket Lifecycle配置

    清理过期的未完成上传,避免存储空间浪费:

    # 使用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:

    /**
    * 生成文件下载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 前端优化技巧

    // 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

  • 赞(0)
    未经允许不得转载:网硕互联帮助中心 » MinIO大文件上传深度拆解:从原理到生产落地的完整指南
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!