吃透 MinIO:从底层架构到全场景文件上传下载实战,一篇搞定企业级对象存储

0 阅读13分钟

在互联网业务的全链路开发中,非结构化数据(图片、视频、文档、安装包等)的存储与访问始终是核心需求之一。传统的本地文件存储、FTP服务器在扩展性、高可用、访问性能上难以匹配云原生时代的业务诉求,而公有云对象存储又存在数据主权、成本、内网访问性能等问题。MinIO作为一款高性能、云原生、100%兼容S3协议的对象存储系统,凭借轻量部署、极简运维、无限扩展的特性,成为企业级自建对象存储的首选方案。

一、MinIO核心底层逻辑与架构解析

1.1 对象存储的核心定义与三大存储类型的本质区别

对象存储的核心存储单元是对象,每个对象由三部分组成:

  • 对象数据:文件本身的二进制内容
  • 元数据:描述对象的键值对信息,比如Content-Type、文件大小、MD5、自定义标签等
  • 唯一标识符:全局唯一的对象名,通过该标识符可精准定位对象,无需依赖树形目录结构

为了清晰区分易混淆的存储类型,这里对三大主流存储方案做核心对比:

存储类型核心结构访问方式核心优势典型适用场景
块存储固定大小的块(通常4KB)裸设备挂载极低延迟、随机读写性能强数据库、虚拟机磁盘
文件存储树形目录结构POSIX协议、SMB/NFS多节点共享、兼容传统文件操作办公文件共享、日志存储
对象存储扁平化键值对结构HTTP/HTTPS的RESTful API无限横向扩展、海量数据低成本存储、元数据能力强图片/视频/文档等非结构化数据、静态资源托管、数据备份

1.2 MinIO的核心架构设计

MinIO的设计核心理念是极简主义,一切皆对象,完全兼容S3协议,原生适配云原生环境。其核心架构如下:

MinIO分为单机部署和分布式部署两种模式,生产环境优先选择分布式部署,核心组件说明如下:

  • 节点(Server Node) :运行MinIO服务的服务器实例,集群内所有节点完全对等,无中心节点、无单点故障。
  • 驱动器(Drive) :节点上挂载的磁盘,是MinIO数据存储的最小物理单元,生产环境建议每个驱动器对应独立的物理磁盘。
  • 桶(Bucket) :对象的顶层容器,相当于文件系统的根目录,全局唯一,用于隔离不同业务的对象数据。
  • 纠删码(Erasure Code) :MinIO高可用的核心,基于Reed-Solomon算法实现,将对象切分为N个数据块和M个奇偶校验块,只要剩余可用块总数≥N,就能完整恢复数据。相比三副本机制,存储空间利用率提升一倍以上。
  • 分布式一致性:基于严格的读后写、写后读一致性模型,所有写操作必须写入超过半数的节点后才会返回成功,确保数据的强一致性。

1.3 MinIO的核心特性

  • 100% S3协议兼容:完全兼容亚马逊S3 API,现有基于S3开发的应用可无缝迁移到MinIO,无需修改业务代码。
  • 极致性能:基于Go语言开发,原生支持高并发,在标准硬件上可实现每秒数GB的吞吐量,延迟低至毫秒级。
  • 极简部署:单个二进制文件即可启动服务,支持Docker、Kubernetes、裸机等多种部署方式,运维成本极低。
  • 无限扩展:通过集群扩容可实现EB级别的存储容量扩展,性能随节点数量线性增长。
  • 丰富的企业级特性:支持版本控制、生命周期管理、WORM(一次写入多次读取)、数据加密、配额管理、事件通知等。

二、MinIO环境快速搭建

2.1 单机模式部署(开发测试环境)

单机模式适合开发测试场景,单节点单驱动器,无纠删码数据保护,执行以下Docker命令即可一键启动:

docker run -d \
  --name minio \
  -p 9000:9000 \
  -p 9001:9001 \
  -v /data/minio/data:/data \
  -v /data/minio/config:/root/.minio \
  -e "MINIO_ROOT_USER=minioadmin" \
  -e "MINIO_ROOT_PASSWORD=minioadmin@2026" \
  minio/minio server /data --console-address ":9001"

参数说明:

  • 9000端口:MinIO API服务端口,应用程序通过该端口与MinIO交互
  • 9001端口:MinIO Web控制台端口,用于可视化管理桶、对象、权限等配置
  • MINIO_ROOT_USER/MINIO_ROOT_PASSWORD:管理员账号密码,生产环境需设置高强度密码
  • 目录挂载:将容器内的数据目录挂载到宿主机,实现数据持久化

部署完成后,访问http://宿主机IP:9001,输入管理员账号密码即可进入控制台,创建业务桶(如demo-bucket)并配置访问策略。

2.2 分布式模式部署

分布式模式实现多节点多驱动器部署,通过纠删码提供数据高可用,无单点故障。以下是4节点8驱动器的Docker Compose部署方案,每个节点挂载2个独立驱动器:

version: '3.8'
services:
  minio1:
    image: minio/minio:latest
    hostname: minio1
    ports:
      - "9000:9000"
      - "9001:9001"
    volumes:
      - /data/minio1/drive1:/data1
      - /data/minio1/drive2:/data2
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin@2026
    command: server http://minio{1...4}/data{1...2} --console-address ":9001"
    healthcheck:
      test: ["CMD""curl""-f""http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3
  minio2:
    image: minio/minio:latest
    hostname: minio2
    ports:
      - "9002:9000"
      - "9003:9001"
    volumes:
      - /data/minio2/drive1:/data1
      - /data/minio2/drive2:/data2
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin@2026
    command: server http://minio{1...4}/data{1...2} --console-address ":9001"
    healthcheck:
      test: ["CMD""curl""-f""http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3
  minio3:
    image: minio/minio:latest
    hostname: minio3
    ports:
      - "9004:9000"
      - "9005:9001"
    volumes:
      - /data/minio3/drive1:/data1
      - /data/minio3/drive2:/data2
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin@2026
    command: server http://minio{1...4}/data{1...2} --console-address ":9001"
    healthcheck:
      test: ["CMD""curl""-f""http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3
  minio4:
    image: minio/minio:latest
    hostname: minio4
    ports:
      - "9006:9000"
      - "9007:9001"
    volumes:
      - /data/minio4/drive1:/data1
      - /data/minio4/drive2:/data2
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin@2026
    command: server http://minio{1...4}/data{1...2} --console-address ":9001"
    healthcheck:
      test: ["CMD""curl""-f""http://localhost:9000/minio/health/live"]
      interval: 30s
      timeout: 20s
      retries: 3

部署说明:

  • 4个节点共8个驱动器,MinIO会自动划分为4个数据块+4个校验块,最多可容忍4个驱动器同时故障,数据不丢失
  • 所有节点的管理员账号密码必须完全一致,否则集群无法正常启动
  • 生产环境建议每个驱动器挂载独立的物理磁盘,避免单磁盘故障导致多个驱动器失效
  • 集群前端需配置负载均衡,将请求均匀分发到各个节点,提升并发性能

三、Spring Boot项目整合MinIO

3.1 项目依赖配置

基于Spring Boot 3.2.5、JDK 17构建项目,pom.xml配置如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>
    <parent>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-parent</artifactId>
        <version>3.2.5</version>
        <relativePath/>
    </parent>
    <groupId>com.jam</groupId>
    <artifactId>minio-demo</artifactId>
    <version>0.0.1-SNAPSHOT</version>
    <name>minio-demo</name>
    <description>MinIO file upload and download demo project</description>
    <properties>
        <java.version>17</java.version>
        <minio.version>8.5.12</minio.version>
        <mybatis-plus.version>3.5.6</mybatis-plus.version>
        <fastjson2.version>2.0.52</fastjson2.version>
        <guava.version>33.1.0-jre</guava.version>
        <lombok.version>1.18.30</lombok.version>
    </properties>
    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-jdbc</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-validation</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-aop</artifactId>
        </dependency>
        <dependency>
            <groupId>io.minio</groupId>
            <artifactId>minio</artifactId>
            <version>${minio.version}</version>
        </dependency>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>com.baomidou</groupId>
            <artifactId>mybatis-plus-boot-starter</artifactId>
            <version>${mybatis-plus.version}</version>
        </dependency>
        <dependency>
            <groupId>org.springdoc</groupId>
            <artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
            <version>2.5.0</version>
        </dependency>
        <dependency>
            <groupId>com.alibaba.fastjson2</groupId>
            <artifactId>fastjson2</artifactId>
            <version>${fastjson2.version}</version>
        </dependency>
        <dependency>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
            <version>${guava.version}</version>
        </dependency>
        <dependency>
            <groupId>org.projectlombok</groupId>
            <artifactId>lombok</artifactId>
            <version>${lombok.version}</version>
            <scope>provided</scope>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
            <scope>test</scope>
        </dependency>
    </dependencies>
    <build>
        <plugins>
            <plugin>
                <groupId>org.springframework.boot</groupId>
                <artifactId>spring-boot-maven-plugin</artifactId>
                <configuration>
                    <excludes>
                        <exclude>
                            <groupId>org.projectlombok</groupId>
                            <artifactId>lombok</artifactId>
                        </exclude>
                    </excludes>
                </configuration>
            </plugin>
        </plugins>
    </build>
</project>

3.2 项目配置文件

application.yml配置文件,包含MinIO、数据源、MyBatis-Plus、Swagger3等核心配置:

server:
  port: 8080
  servlet:
    multipart:
      max-file-size: 100MB
      max-request-size: 100MB
spring:
  application:
    name: minio-demo
  datasource:
    driver-class-name: com.mysql.cj.jdbc.Driver
    url: jdbc:mysql://127.0.0.1:3306/minio_demo?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
    username: root
    password: root@2026
  jackson:
    date-format: yyyy-MM-dd HH:mm:ss
    time-zone: Asia/Shanghai
minio:
  endpoint: http://127.0.0.1:9000
  access-key: minioadmin
  secret-key: minioadmin@2026
  bucket-name: demo-bucket
mybatis-plus:
  mapper-locations: classpath*:/mapper/**/*.xml
  configuration:
    map-underscore-to-camel-case: true
    log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
  global-config:
    db-config:
      id-type: assign_id
      logic-delete-field: isDeleted
      logic-delete-value: 1
      logic-not-delete-value: 0
springdoc:
  api-docs:
    enabled: true
    path: /v3/api-docs
  swagger-ui:
    enabled: true
    path: /swagger-ui.html
    tags-sorter: alpha
    operations-sorter: alpha

3.3 数据库表结构设计

创建文件元数据表,存储上传文件的核心信息,用于业务查询与管理,MySQL 8.0执行以下SQL:

CREATE TABLE `file_info` (
  `id` bigint NOT NULL COMMENT '主键ID',
  `file_name` varchar(255NOT NULL COMMENT '存储文件名(唯一)',
  `original_file_name` varchar(255NOT NULL COMMENT '原始文件名',
  `bucket_name` varchar(100NOT NULL COMMENT '存储桶名称',
  `file_path` varchar(500NOT NULL COMMENT '文件存储路径',
  `file_size` bigint NOT NULL COMMENT '文件大小(字节)',
  `content_type` varchar(100DEFAULT NULL COMMENT '文件MIME类型',
  `md5` varchar(32NOT NULL COMMENT '文件MD5值',
  `is_deleted` tinyint NOT NULL DEFAULT '0' COMMENT '是否删除 0-未删除 1-已删除',
  `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
  `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_file_name` (`file_name`),
  KEY `idx_md5` (`md5`),
  KEY `idx_bucket_name` (`bucket_name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文件信息表';

3.4 核心常量定义

提取魔法值为常量,提升代码可维护性与可读性:

package com.jam.demo.constant;

/**
 * MinIO相关常量
 *
 * @author ken
 */
public final class MinioConstants {

    private MinioConstants() {
    }

    /**
     * 默认分片大小 5MB
     */
    public static final long DEFAULT_PART_SIZE = 5 * 1024 * 1024L;

    /**
     * 最大文件大小 1GB
     */
    public static final long MAX_FILE_SIZE = 1024 * 1024 * 1024L;

    /**
     * 预签名URL默认过期时间 1小时(秒)
     */
    public static final int DEFAULT_PRESIGNED_EXPIRY = 3600;

    /**
     * 最小分片数量
     */
    public static final int MIN_PART_COUNT = 1;

    /**
     * 最大分片数量
     */
    public static final int MAX_PART_COUNT = 10000;

    /**
     * 公共读桶策略模板
     */
    public static final String PUBLIC_READ_POLICY = """
            {
                "Version": "2012-10-17",
                "Statement": [
                    {
                        "Effect": "Allow",
                        "Principal": "*",
                        "Action": ["s3:GetObject"],
                        "Resource": ["arn:aws:s3:::%s/*"]
                    }
                ]
            }
            """;
}

3.5 统一异常与响应处理

自定义业务异常类

package com.jam.demo.exception;

import lombok.Getter;

/**
 * 业务异常
 *
 * @author ken
 */
@Getter
public class BusinessException extends RuntimeException {

    private final int code;

    public BusinessException(int code, String message) {
        super(message);
        this.code = code;
    }

    public BusinessException(String message) {
        super(message);
        this.code = 500;
    }
}

统一响应结果类

package com.jam.demo.common;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 统一响应结果
 *
 * @author ken
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "统一响应结果")
public class Result<T> {

    @Schema(description = "响应码", example = "200")
    private int code;

    @Schema(description = "响应信息", example = "操作成功")
    private String message;

    @Schema(description = "响应数据")
    private T data;

    public static <T> Result<T> success(T data) {
        return new Result<>(200"操作成功", data);
    }

    public static <T> Result<T> success(String message, T data) {
        return new Result<>(200, message, data);
    }

    public static <T> Result<T> fail(String message) {
        return new Result<>(500, message, null);
    }

    public static <T> Result<T> fail(int code, String message) {
        return new Result<>(code, message, null);
    }
}

全局异常处理器

package com.jam.demo.exception;

import com.jam.demo.common.Result;
import io.minio.errors.MinioException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.validation.BindException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.util.StringUtils;

import java.util.Objects;

/**
 * 全局异常处理器
 *
 * @author ken
 */
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {

    @ExceptionHandler(BusinessException.class)
    public Result<Void> handleBusinessException(BusinessException e) {
        log.error("业务异常:", e);
        return Result.fail(e.getCode(), e.getMessage());
    }

    @ExceptionHandler(MinioException.class)
    public Result<Void> handleMinioException(MinioException e) {
        log.error("MinIO操作异常:", e);
        return Result.fail("文件存储操作失败,请稍后重试");
    }

    @ExceptionHandler(MethodArgumentNotValidException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<Void> handleValidException(MethodArgumentNotValidException e) {
        log.error("参数校验异常:", e);
        String message = Objects.requireNonNull(e.getBindingResult().getFieldError()).getDefaultMessage();
        return Result.fail(400, StringUtils.hasText(message) ? message : "参数校验失败");
    }

    @ExceptionHandler(BindException.class)
    @ResponseStatus(HttpStatus.BAD_REQUEST)
    public Result<Void> handleBindException(BindException e) {
        log.error("参数绑定异常:", e);
        String message = Objects.requireNonNull(e.getBindingResult().getFieldError()).getDefaultMessage();
        return Result.fail(400, StringUtils.hasText(message) ? message : "参数绑定失败");
    }

    @ExceptionHandler(Exception.class)
    public Result<Void> handleException(Exception e) {
        log.error("系统异常:", e);
        return Result.fail("系统内部错误,请稍后重试");
    }
}

3.6 实体类与Mapper层

文件信息实体类

package com.jam.demo.entity;

import com.baomidou.mybatisplus.annotation.*;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;

import java.time.LocalDateTime;

/**
 * 文件信息实体
 *
 * @author ken
 */
@Data
@TableName("file_info")
@Schema(description = "文件信息实体")
public class FileInfo {

    @Schema(description = "主键ID")
    @TableId(type = IdType.ASSIGN_ID)
    private Long id;

    @Schema(description = "存储文件名(唯一)")
    private String fileName;

    @Schema(description = "原始文件名")
    private String originalFileName;

    @Schema(description = "存储桶名称")
    private String bucketName;

    @Schema(description = "文件存储路径")
    private String filePath;

    @Schema(description = "文件大小(字节)")
    private Long fileSize;

    @Schema(description = "文件MIME类型")
    private String contentType;

    @Schema(description = "文件MD5值")
    private String md5;

    @Schema(description = "是否删除 0-未删除 1-已删除")
    @TableLogic
    private Integer isDeleted;

    @Schema(description = "创建时间")
    @TableField(fill = FieldFill.INSERT)
    private LocalDateTime createTime;

    @Schema(description = "更新时间")
    @TableField(fill = FieldFill.INSERT_UPDATE)
    private LocalDateTime updateTime;
}

Mapper接口

package com.jam.demo.mapper;

import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jam.demo.entity.FileInfo;
import org.apache.ibatis.annotations.Mapper;

/**
 * 文件信息Mapper
 *
 * @author ken
 */
@Mapper
public interface FileInfoMapper extends BaseMapper<FileInfo> {
}

3.7 MinIO核心配置与工具类

MinIO配置类

package com.jam.demo.config;

import io.minio.MinioClient;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
import com.jam.demo.exception.BusinessException;

/**
 * MinIO配置类
 *
 * @author ken
 */
@Data
@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {

    private String endpoint;

    private String accessKey;

    private String secretKey;

    private String bucketName;

    /**
     * 构建MinioClient客户端并注入Spring容器
     */
    @Bean
    public MinioClient minioClient() {
        if (!StringUtils.hasText(endpoint)) {
            throw new BusinessException("MinIO endpoint不能为空");
        }
        if (!StringUtils.hasText(accessKey)) {
            throw new BusinessException("MinIO accessKey不能为空");
        }
        if (!StringUtils.hasText(secretKey)) {
            throw new BusinessException("MinIO secretKey不能为空");
        }
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
    }
}

MinIO操作工具类

package com.jam.demo.util;

import com.jam.demo.constant.MinioConstants;
import com.jam.demo.exception.BusinessException;
import io.minio.*;
import io.minio.http.Method;
import io.minio.messages.Part;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;

import java.io.InputStream;
import java.util.concurrent.TimeUnit;

/**
 * MinIO操作工具类
 *
 * @author ken
 */
@Slf4j
public final class MinioUtil {

    private MinioUtil() {
    }

    /**
     * 检查桶是否存在
     *
     * @param minioClient MinIO客户端
     * @param bucketName  桶名称
     * @return 桶是否存在
     */
    public static boolean bucketExists(MinioClient minioClient, String bucketName) {
        if (!StringUtils.hasText(bucketName)) {
            throw new BusinessException("桶名称不能为空");
        }
        try {
            return minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
        } catch (Exception e) {
            log.error("检查桶是否存在失败,bucketName:{}", bucketName, e);
            throw new BusinessException("检查桶是否存在失败");
        }
    }

    /**
     * 创建桶
     *
     * @param minioClient MinIO客户端
     * @param bucketName  桶名称
     */
    public static void createBucket(MinioClient minioClient, String bucketName) {
        if (bucketExists(minioClient, bucketName)) {
            return;
        }
        try {
            minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
        } catch (Exception e) {
            log.error("创建桶失败,bucketName:{}", bucketName, e);
            throw new BusinessException("创建桶失败");
        }
    }

    /**
     * 设置桶公共读策略
     *
     * @param minioClient MinIO客户端
     * @param bucketName  桶名称
     */
    public static void setBucketPublicReadPolicy(MinioClient minioClient, String bucketName) {
        if (!StringUtils.hasText(bucketName)) {
            throw new BusinessException("桶名称不能为空");
        }
        try {
            String policy = String.format(MinioConstants.PUBLIC_READ_POLICY, bucketName);
            minioClient.setBucketPolicy(SetBucketPolicyArgs.builder().bucket(bucketName).config(policy).build());
        } catch (Exception e) {
            log.error("设置桶策略失败,bucketName:{}", bucketName, e);
            throw new BusinessException("设置桶策略失败");
        }
    }

    /**
     * 上传文件
     *
     * @param minioClient  MinIO客户端
     * @param bucketName   桶名称
     * @param objectName   对象名称(存储文件名)
     * @param inputStream  文件输入流
     * @param contentType  文件MIME类型
     */
    public static void uploadFile(MinioClient minioClient, String bucketName, String objectName,
                                   InputStream inputStream, String contentType) {
        if (!bucketExists(minioClient, bucketName)) {
            createBucket(minioClient, bucketName);
        }
        try {
            minioClient.putObject(PutObjectArgs.builder()
                    .bucket(bucketName)
                    .object(objectName)
                    .stream(inputStream, inputStream.available(), -1)
                    .contentType(contentType)
                    .build());
        } catch (Exception e) {
            log.error("上传文件失败,bucketName:{},objectName:{}", bucketName, objectName, e);
            throw new BusinessException("上传文件失败");
        }
    }

    /**
     * 下载文件
     *
     * @param minioClient MinIO客户端
     * @param bucketName  桶名称
     * @param objectName  对象名称
     * @return 文件输入流
     */
    public static InputStream downloadFile(MinioClient minioClient, String bucketName, String objectName) {
        if (!bucketExists(minioClient, bucketName)) {
            throw new BusinessException("桶不存在");
        }
        try {
            return minioClient.getObject(GetObjectArgs.builder()
                    .bucket(bucketName)
                    .object(objectName)
                    .build());
        } catch (Exception e) {
            log.error("下载文件失败,bucketName:{},objectName:{}", bucketName, objectName, e);
            throw new BusinessException("下载文件失败");
        }
    }

    /**
     * 删除文件
     *
     * @param minioClient MinIO客户端
     * @param bucketName  桶名称
     * @param objectName  对象名称
     */
    public static void deleteFile(MinioClient minioClient, String bucketName, String objectName) {
        if (!bucketExists(minioClient, bucketName)) {
            throw new BusinessException("桶不存在");
        }
        try {
            minioClient.removeObject(RemoveObjectArgs.builder()
                    .bucket(bucketName)
                    .object(objectName)
                    .build());
        } catch (Exception e) {
            log.error("删除文件失败,bucketName:{},objectName:{}", bucketName, objectName, e);
            throw new BusinessException("删除文件失败");
        }
    }

    /**
     * 获取预签名访问URL
     *
     * @param minioClient MinIO客户端
     * @param bucketName  桶名称
     * @param objectName  对象名称
     * @param expiry      过期时间(秒)
     * @param method      HTTP请求方法
     * @return 预签名URL
     */
    public static String getPresignedObjectUrl(MinioClient minioClient, String bucketName, String objectName,
                                                 int expiry, Method method) {
        if (!bucketExists(minioClient, bucketName)) {
            throw new BusinessException("桶不存在");
        }
        if (expiry <= 0) {
            expiry = MinioConstants.DEFAULT_PRESIGNED_EXPIRY;
        }
        try {
            return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder()
                    .bucket(bucketName)
                    .object(objectName)
                    .expiry(expiry, TimeUnit.SECONDS)
                    .method(method)
                    .build());
        } catch (Exception e) {
            log.error("获取预签名URL失败,bucketName:{},objectName:{}", bucketName, objectName, e);
            throw new BusinessException("获取预签名URL失败");
        }
    }

    /**
     * 初始化分片上传
     *
     * @param minioClient MinIO客户端
     * @param bucketName  桶名称
     * @param objectName  对象名称
     * @param contentType 文件MIME类型
     * @return uploadId 分片上传ID
     */
    public static String initMultipartUpload(MinioClient minioClient, String bucketName, String objectName,
                                               String contentType) {
        if (!bucketExists(minioClient, bucketName)) {
            createBucket(minioClient, bucketName);
        }
        try {
            CreateMultipartUploadResponse response = minioClient.createMultipartUpload(
                    CreateMultipartUploadArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .contentType(contentType)
                            .build());
            return response.result().uploadId();
        } catch (Exception e) {
            log.error("初始化分片上传失败,bucketName:{},objectName:{}", bucketName, objectName, e);
            throw new BusinessException("初始化分片上传失败");
        }
    }

    /**
     * 上传分片
     *
     * @param minioClient MinIO客户端
     * @param bucketName  桶名称
     * @param objectName  对象名称
     * @param uploadId    分片上传ID
     * @param partNumber  分片序号(从1开始)
     * @param inputStream 分片输入流
     * @param partSize    分片大小
     * @return 分片的etag值
     */
    public static String uploadPart(MinioClient minioClient, String bucketName, String objectName,
                                     String uploadId, int partNumber, InputStream inputStream, long partSize) {
        try {
            UploadPartResponse response = minioClient.uploadPart(
                    UploadPartArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .uploadId(uploadId)
                            .partNumber(partNumber)
                            .stream(inputStream, partSize, -1)
                            .build());
            return response.etag();
        } catch (Exception e) {
            log.error("上传分片失败,bucketName:{},objectName:{},partNumber:{}", bucketName, objectName, partNumber, e);
            throw new BusinessException("上传分片失败");
        }
    }

    /**
     * 合并分片
     *
     * @param minioClient MinIO客户端
     * @param bucketName  桶名称
     * @param objectName  对象名称
     * @param uploadId    分片上传ID
     * @param parts       分片数组(包含partNumber和etag)
     */
    public static void completeMultipartUpload(MinioClient minioClient, String bucketName, String objectName,
                                                 String uploadId, Part[] parts) {
        if (ObjectUtils.isEmpty(parts)) {
            throw new BusinessException("分片列表不能为空");
        }
        try {
            minioClient.completeMultipartUpload(
                    CompleteMultipartUploadArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .uploadId(uploadId)
                            .parts(parts)
                            .build());
        } catch (Exception e) {
            log.error("合并分片失败,bucketName:{},objectName:{},uploadId:{}", bucketName, objectName, uploadId, e);
            throw new BusinessException("合并分片失败");
        }
    }

    /**
     * 取消分片上传
     *
     * @param minioClient MinIO客户端
     * @param bucketName  桶名称
     * @param objectName  对象名称
     * @param uploadId    分片上传ID
     */
    public static void abortMultipartUpload(MinioClient minioClient, String bucketName, String objectName,
                                              String uploadId) {
        try {
            minioClient.abortMultipartUpload(
                    AbortMultipartUploadArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .uploadId(uploadId)
                            .build());
        } catch (Exception e) {
            log.error("取消分片上传失败,bucketName:{},objectName:{},uploadId:{}", bucketName, objectName, uploadId, e);
            throw new BusinessException("取消分片上传失败");
        }
    }

    /**
     * 查询已上传的分片列表
     *
     * @param minioClient MinIO客户端
     * @param bucketName  桶名称
     * @param objectName  对象名称
     * @param uploadId    分片上传ID
     * @return 已上传的分片数组
     */
    public static Part[] listParts(MinioClient minioClient, String bucketName, String objectName, String uploadId) {
        try {
            ListPartsResponse response = minioClient.listParts(
                    ListPartsArgs.builder()
                            .bucket(bucketName)
                            .object(objectName)
                            .uploadId(uploadId)
                            .maxParts(MinioConstants.MAX_PART_COUNT)
                            .build());
            return response.result().partList().toArray(new Part[0]);
        } catch (Exception e) {
            log.error("查询分片列表失败,bucketName:{},objectName:{},uploadId:{}", bucketName, objectName, uploadId, e);
            throw new BusinessException("查询分片列表失败");
        }
    }
}

3.8 业务层实现

业务接口与VO定义

首先定义核心请求与响应VO,用于接口参数传递:

package com.jam.demo.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

import java.time.LocalDateTime;

/**
 * 文件上传响应VO
 *
 * @author ken
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "文件上传响应VO")
public class FileUploadVO {

    @Schema(description = "文件ID")
    private Long id;

    @Schema(description = "存储文件名")
    private String fileName;

    @Schema(description = "原始文件名")
    private String originalFileName;

    @Schema(description = "文件大小(字节)")
    private Long fileSize;

    @Schema(description = "文件访问URL")
    private String url;

    @Schema(description = "上传时间")
    private LocalDateTime uploadTime;
}
package com.jam.demo.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

/**
 * 分片上传初始化请求VO
 *
 * @author ken
 */
@Data
@Schema(description = "分片上传初始化请求VO")
public class MultipartUploadInitRequest {

    @NotBlank(message = "原始文件名不能为空")
    @Schema(description = "原始文件名", requiredMode = Schema.RequiredMode.REQUIRED)
    private String originalFileName;

    @NotBlank(message = "文件MD5值不能为空")
    @Schema(description = "文件MD5值", requiredMode = Schema.RequiredMode.REQUIRED)
    private String md5;

    @NotNull(message = "文件大小不能为空")
    @Schema(description = "文件大小(字节)", requiredMode = Schema.RequiredMode.REQUIRED)
    private Long fileSize;

    @Schema(description = "文件MIME类型")
    private String contentType;
}
package com.jam.demo.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 分片上传初始化响应VO
 *
 * @author ken
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "分片上传初始化响应VO")
public class MultipartUploadInitVO {

    @Schema(description = "存储文件名")
    private String fileName;

    @Schema(description = "分片上传ID")
    private String uploadId;

    @Schema(description = "分片大小(字节)")
    private Long partSize;

    @Schema(description = "总分片数")
    private Integer totalPartCount;
}
package com.jam.demo.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.springframework.web.multipart.MultipartFile;

/**
 * 分片上传请求VO
 *
 * @author ken
 */
@Data
@Schema(description = "分片上传请求VO")
public class MultipartUploadPartRequest {

    @NotBlank(message = "存储文件名不能为空")
    @Schema(description = "存储文件名", requiredMode = Schema.RequiredMode.REQUIRED)
    private String fileName;

    @NotBlank(message = "分片上传ID不能为空")
    @Schema(description = "分片上传ID", requiredMode = Schema.RequiredMode.REQUIRED)
    private String uploadId;

    @NotNull(message = "分片序号不能为空")
    @Schema(description = "分片序号(从1开始)", requiredMode = Schema.RequiredMode.REQUIRED)
    private Integer partNumber;

    @NotNull(message = "分片文件不能为空")
    @Schema(description = "分片文件", requiredMode = Schema.RequiredMode.REQUIRED)
    private MultipartFile file;

    @Schema(description = "文件MD5值")
    private String md5;
}
package com.jam.demo.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;

/**
 * 分片上传响应VO
 *
 * @author ken
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
@Schema(description = "分片上传响应VO")
public class MultipartUploadPartVO {

    @Schema(description = "分片序号")
    private Integer partNumber;

    @Schema(description = "分片ETag值")
    private String etag;
}
package com.jam.demo.vo;

import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;

import java.util.List;

/**
 * 合并分片请求VO
 *
 * @author ken
 */
@Data
@Schema(description = "合并分片请求VO")
public class MultipartUploadCompleteRequest {

    @NotBlank(message = "存储文件名不能为空")
    @Schema(description = "存储文件名", requiredMode = Schema.RequiredMode.REQUIRED)
    private String fileName;

    @NotBlank(message = "分片上传ID不能为空")
    @Schema(description = "分片上传ID", requiredMode = Schema.RequiredMode.REQUIRED)
    private String uploadId;

    @NotBlank(message = "文件MD5值不能为空")
    @Schema(description = "文件MD5值", requiredMode = Schema.RequiredMode.REQUIRED)
    private String md5;

    @NotNull(message = "文件大小不能为空")
    @Schema(description = "文件大小(字节)", requiredMode = Schema.RequiredMode.REQUIRED)
    private Long fileSize;

    @NotBlank(message = "原始文件名不能为空")
    @Schema(description = "原始文件名", requiredMode = Schema.RequiredMode.REQUIRED)
    private String originalFileName;

    @NotEmpty(message = "分片列表不能为空")
    @Schema(description = "分片列表", requiredMode = Schema.RequiredMode.REQUIRED)
    private List<MultipartUploadPartVO> parts;
}

定义业务接口:

package com.jam.demo.service;

import com.baomidou.mybatisplus.extension.service.IService;
import com.jam.demo.entity.FileInfo;
import com.jam.demo.vo.*;
import org.springframework.web.multipart.MultipartFile;

import jakarta.servlet.http.HttpServletResponse;

/**
 * 文件服务接口
 *
 * @author ken
 */
public interface FileService extends IService<FileInfo> {

    /**
     * 普通文件上传
     *
     * @param file 上传的文件
     * @return 文件上传结果
     */
    FileUploadVO upload(MultipartFile file);

    /**
     * 文件下载
     *
     * @param fileName 存储文件名
     * @param response 响应对象
     */
    void download(String fileName, HttpServletResponse response);

    /**
     * 删除文件
     *
     * @param fileName 存储文件名
     * @return 删除结果
     */
    Boolean delete(String fileName);

    /**
     * 获取文件预签名访问URL
     *
     * @param fileName 存储文件名
     * @return 预签名URL
     */
    String getPresignedUrl(String fileName);

    /**
     * 初始化分片上传
     *
     * @param request 初始化请求参数
     * @return 分片上传初始化结果
     */
    MultipartUploadInitVO initMultipartUpload(MultipartUploadInitRequest request);

    /**
     * 上传分片
     *
     * @param request 分片上传请求参数
     * @return 分片上传结果
     */
    MultipartUploadPartVO uploadPart(MultipartUploadPartRequest request);

    /**
     * 合并分片
     *
     * @param request 合并分片请求参数
     * @return 合并结果
     */
    FileUploadVO completeMultipartUpload(MultipartUploadCompleteRequest request);

    /**
     * 校验文件是否已上传(秒传)
     *
     * @param md5 文件MD5值
     * @return 文件信息,若未上传返回null
     */
    FileUploadVO checkFileExist(String md5);
}

业务接口实现类

采用编程式事务保证文件上传与元数据存储的一致性,异常时自动回滚:

package com.jam.demo.service.impl;

import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.jam.demo.config.MinioConfig;
import com.jam.demo.constant.MinioConstants;
import com.jam.demo.entity.FileInfo;
import com.jam.demo.exception.BusinessException;
import com.jam.demo.mapper.FileInfoMapper;
import com.jam.demo.service.FileService;
import com.jam.demo.util.MinioUtil;
import com.jam.demo.vo.*;
import io.minio.MinioClient;
import io.minio.http.Method;
import io.minio.messages.Part;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.TransactionTemplate;
import org.springframework.util.DigestUtils;
import org.springframework.util.ObjectUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;

import jakarta.servlet.http.HttpServletResponse;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.UUID;

/**
 * 文件服务实现类
 *
 * @author ken
 */
@Slf4j
@Service
public class FileServiceImpl extends ServiceImpl<FileInfoMapper, FileInfo> implements FileService {

    private final MinioClient minioClient;

    private final MinioConfig minioConfig;

    private final TransactionTemplate transactionTemplate;

    public FileServiceImpl(MinioClient minioClient, MinioConfig minioConfig, TransactionTemplate transactionTemplate) {
        this.minioClient = minioClient;
        this.minioConfig = minioConfig;
        this.transactionTemplate = transactionTemplate;
    }

    @Override
    public FileUploadVO upload(MultipartFile file) {
        if (file.isEmpty()) {
            throw new BusinessException("上传文件不能为空");
        }
        if (file.getSize() > MinioConstants.MAX_FILE_SIZE) {
            throw new BusinessException("文件大小超过最大限制");
        }
        String originalFileName = file.getOriginalFilename();
        if (!StringUtils.hasText(originalFileName)) {
            throw new BusinessException("文件名不能为空");
        }
        try {
            String md5 = DigestUtils.md5DigestAsHex(file.getInputStream());
            FileUploadVO existFile = checkFileExist(md5);
            if (!ObjectUtils.isEmpty(existFile)) {
                return existFile;
            }
            String suffix = originalFileName.substring(originalFileName.lastIndexOf("."));
            String fileName = UUID.randomUUID().toString().replace("-""") + suffix;
            String contentType = file.getContentType();
            MinioUtil.uploadFile(minioClient, minioConfig.getBucketName(), fileName, file.getInputStream(), contentType);
            String url = MinioUtil.getPresignedObjectUrl(minioClient, minioConfig.getBucketName(), fileName, MinioConstants.DEFAULT_PRESIGNED_EXPIRY, Method.GET);
            FileInfo fileInfo = new FileInfo();
            fileInfo.setFileName(fileName);
            fileInfo.setOriginalFileName(originalFileName);
            fileInfo.setBucketName(minioConfig.getBucketName());
            fileInfo.setFilePath(minioConfig.getBucketName() + "/" + fileName);
            fileInfo.setFileSize(file.getSize());
            fileInfo.setContentType(contentType);
            fileInfo.setMd5(md5);
            Boolean saveResult = transactionTemplate.execute(status -> {
                try {
                    return save(fileInfo);
                } catch (Exception e) {
                    status.setRollbackOnly();
                    MinioUtil.deleteFile(minioClient, minioConfig.getBucketName(), fileName);
                    log.error("保存文件信息失败,回滚文件上传", e);
                    throw new BusinessException("保存文件信息失败");
                }
            });
            if (ObjectUtils.isEmpty(saveResult) || !saveResult) {
                MinioUtil.deleteFile(minioClient, minioConfig.getBucketName(), fileName);
                throw new BusinessException("保存文件信息失败");
            }
            FileUploadVO vo = new FileUploadVO();
            vo.setId(fileInfo.getId());
            vo.setFileName(fileName);
            vo.setOriginalFileName(originalFileName);
            vo.setFileSize(file.getSize());
            vo.setUrl(url);
            vo.setUploadTime(fileInfo.getCreateTime());
            return vo;
        } catch (BusinessException e) {
            throw e;
        } catch (Exception e) {
            log.error("文件上传失败", e);
            throw new BusinessException("文件上传失败");
        }
    }

    @Override
    public void download(String fileName, HttpServletResponse response) {
        if (!StringUtils.hasText(fileName)) {
            throw new BusinessException("文件名不能为空");
        }
        FileInfo fileInfo = getOne(new LambdaQueryWrapper<FileInfo>().eq(FileInfo::getFileName, fileName));
        if (ObjectUtils.isEmpty(fileInfo)) {
            throw new BusinessException("文件不存在");
        }
        try (InputStream inputStream = MinioUtil.downloadFile(minioClient, fileInfo.getBucketName(), fileName);
             OutputStream outputStream = response.getOutputStream()) {
            response.setContentType(fileInfo.getContentType());
            response.setContentLengthLong(fileInfo.getFileSize());
            response.setHeader("Content-Disposition", "attachment;filename=" + URLEncoder.encode(fileInfo.getOriginalFileName(), StandardCharsets.UTF_8.name()));
            response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate");
            response.setHeader("Pragma", "no-cache");
            response.setHeader("Expires", "0");
            byte[] buffer = new byte[8192];
            int len;
            while ((len = inputStream.read(buffer)) != -1) {
                outputStream.write(buffer, 0, len);
            }
            outputStream.flush();
        } catch (BusinessException e) {
            throw e;
        } catch (Exception e) {
            log.error("文件下载失败,fileName:{}", fileName, e);
            throw new BusinessException("文件下载失败");
        }
    }

    @Override
    public Boolean delete(String fileName) {
        if (!StringUtils.hasText(fileName)) {
            throw new BusinessException("文件名不能为空");
        }
        FileInfo fileInfo = getOne(new LambdaQueryWrapper<FileInfo>().eq(FileInfo::getFileName, fileName));
        if (ObjectUtils.isEmpty(fileInfo)) {
            throw new BusinessException("文件不存在");
        }
        return transactionTemplate.execute(status -> {
            try {
                boolean removeResult = removeById(fileInfo.getId());
                if (!removeResult) {
                    status.setRollbackOnly();
                    return false;
                }
                MinioUtil.deleteFile(minioClient, fileInfo.getBucketName(), fileName);
                return true;
            } catch (Exception e) {
                status.setRollbackOnly();
                log.error("删除文件失败,fileName:{}", fileName, e);
                throw new BusinessException("删除文件失败");
            }
        });
    }

    @Override
    public String getPresignedUrl(String fileName) {
        if (!StringUtils.hasText(fileName)) {
            throw new BusinessException("文件名不能为空");
        }
        FileInfo fileInfo = getOne(new LambdaQueryWrapper<FileInfo>().eq(FileInfo::getFileName, fileName));
        if (ObjectUtils.isEmpty(fileInfo)) {
            throw new BusinessException("文件不存在");
        }
        return MinioUtil.getPresignedObjectUrl(minioClient, fileInfo.getBucketName(), fileName, MinioConstants.DEFAULT_PRESIGNED_EXPIRY, Method.GET);
    }

    @Override
    public MultipartUploadInitVO initMultipartUpload(MultipartUploadInitRequest request) {
        if (request.getFileSize() > MinioConstants.MAX_FILE_SIZE) {
            throw new BusinessException("文件大小超过最大限制");
        }
        FileUploadVO existFile = checkFileExist(request.getMd5());
        if (!ObjectUtils.isEmpty(existFile)) {
            throw new BusinessException("文件已存在,无需重复上传", 200);
        }
        String originalFileName = request.getOriginalFileName();
        String suffix = originalFileName.substring(originalFileName.lastIndexOf("."));
        String fileName = UUID.randomUUID().toString().replace("-""") + suffix;
        long partSize = MinioConstants.DEFAULT_PART_SIZE;
        long fileSize = request.getFileSize();
        int totalPartCount = (int) Math.ceil((double) fileSize / partSize);
        if (totalPartCount < MinioConstants.MIN_PART_COUNT) {
            totalPartCount = MinioConstants.MIN_PART_COUNT;
        }
        if (totalPartCount > MinioConstants.MAX_PART_COUNT) {
            partSize = (long) Math.ceil((double) fileSize / MinioConstants.MAX_PART_COUNT);
            totalPartCount = MinioConstants.MAX_PART_COUNT;
        }
        String uploadId = MinioUtil.initMultipartUpload(minioClient, minioConfig.getBucketName(), fileName, request.getContentType());
        MultipartUploadInitVO vo = new MultipartUploadInitVO();
        vo.setFileName(fileName);
        vo.setUploadId(uploadId);
        vo.setPartSize(partSize);
        vo.setTotalPartCount(totalPartCount);
        return vo;
    }

    @Override
    public MultipartUploadPartVO uploadPart(MultipartUploadPartRequest request) {
        if (request.getFile().isEmpty()) {
            throw new BusinessException("分片文件不能为空");
        }
        try {
            String etag = MinioUtil.uploadPart(
                    minioClient,
                    minioConfig.getBucketName(),
                    request.getFileName(),
                    request.getUploadId(),
                    request.getPartNumber(),
                    request.getFile().getInputStream(),
                    request.getFile().getSize()
            );
            MultipartUploadPartVO vo = new MultipartUploadPartVO();
            vo.setPartNumber(request.getPartNumber());
            vo.setEtag(etag);
            return vo;
        } catch (BusinessException e) {
            throw e;
        } catch (Exception e) {
            log.error("分片上传失败,fileName:{},partNumber:{}", request.getFileName(), request.getPartNumber(), e);
            throw new BusinessException("分片上传失败");
        }
    }

    @Override
    public FileUploadVO completeMultipartUpload(MultipartUploadCompleteRequest request) {
        try {
            Part[] parts = request.getParts().stream()
                    .sorted((p1, p2) -> p1.getPartNumber() - p2.getPartNumber())
                    .map(vo -> new Part(vo.getPartNumber(), vo.getEtag()))
                    .toArray(Part[]::new);
            MinioUtil.completeMultipartUpload(
                    minioClient,
                    minioConfig.getBucketName(),
                    request.getFileName(),
                    request.getUploadId(),
                    parts
            );
            String url = MinioUtil.getPresignedObjectUrl(minioClient, minioConfig.getBucketName(), request.getFileName(), MinioConstants.DEFAULT_PRESIGNED_EXPIRY, Method.GET);
            FileInfo fileInfo = new FileInfo();
            fileInfo.setFileName(request.getFileName());
            fileInfo.setOriginalFileName(request.getOriginalFileName());
            fileInfo.setBucketName(minioConfig.getBucketName());
            fileInfo.setFilePath(minioConfig.getBucketName() + "/" + request.getFileName());
            fileInfo.setFileSize(request.getFileSize());
            fileInfo.setContentType("application/octet-stream");
            fileInfo.setMd5(request.getMd5());
            Boolean saveResult = transactionTemplate.execute(status -> {
                try {
                    return save(fileInfo);
                } catch (Exception e) {
                    status.setRollbackOnly();
                    MinioUtil.deleteFile(minioClient, minioConfig.getBucketName(), request.getFileName());
                    log.error("保存文件信息失败,回滚合并操作", e);
                    throw new BusinessException("保存文件信息失败");
                }
            });
            if (ObjectUtils.isEmpty(saveResult) || !saveResult) {
                MinioUtil.deleteFile(minioClient, minioConfig.getBucketName(), request.getFileName());
                throw new BusinessException("保存文件信息失败");
            }
            FileUploadVO vo = new FileUploadVO();
            vo.setId(fileInfo.getId());
            vo.setFileName(request.getFileName());
            vo.setOriginalFileName(request.getOriginalFileName());
            vo.setFileSize(request.getFileSize());
            vo.setUrl(url);
            vo.setUploadTime(fileInfo.getCreateTime());
            return vo;
        } catch (BusinessException e) {
            throw e;
        } catch (Exception e) {
            log.error("合并分片失败,fileName:{}", request.getFileName(), e);
            throw new BusinessException("合并分片失败");
        }
    }

    @Override
    public FileUploadVO checkFileExist(String md5) {
        if (!StringUtils.hasText(md5)) {
            throw new BusinessException("文件MD5值不能为空");
        }
        FileInfo fileInfo = getOne(new LambdaQueryWrapper<FileInfo>().eq(FileInfo::getMd5, md5));
        if (ObjectUtils.isEmpty(fileInfo)) {
            return null;
        }
        String url = MinioUtil.getPresignedObjectUrl(minioClient, fileInfo.getBucketName(), fileInfo.getFileName(), MinioConstants.DEFAULT_PRESIGNED_EXPIRY, Method.GET);
        FileUploadVO vo = new FileUploadVO();
        vo.setId(fileInfo.getId());
        vo.setFileName(fileInfo.getFileName());
        vo.setOriginalFileName(fileInfo.getOriginalFileName());
        vo.setFileSize(fileInfo.getFileSize());
        vo.setUrl(url);
        vo.setUploadTime(fileInfo.getCreateTime());
        return vo;
    }
}

3.9 控制层实现

基于RESTful规范设计接口,添加Swagger3注解与参数校验,覆盖全场景文件操作能力:

package com.jam.demo.controller;

import com.jam.demo.common.Result;
import com.jam.demo.service.FileService;
import com.jam.demo.vo.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;

import jakarta.servlet.http.HttpServletResponse;

/**
 * 文件操作控制器
 *
 * @author ken
 */
@Slf4j
@RestController
@RequestMapping("/file")
@Tag(name = "文件操作接口", description = "包含普通文件上传、下载、删除、预签名URL获取、秒传校验等功能")
public class FileController {

    private final FileService fileService;

    public FileController(FileService fileService) {
        this.fileService = fileService;
    }

    @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    @Operation(summary = "普通文件上传", description = "支持单文件上传,内置秒传能力,文件大小限制1GB")
    public Result<FileUploadVOupload(
            @Parameter(description = "上传的文件", required = true)
            @RequestPart("file") MultipartFile file) {
        return Result.success(fileService.upload(file));
    }

    @GetMapping("/download/{fileName}")
    @Operation(summary = "文件下载", description = "根据存储文件名下载文件,自动设置响应头与文件名编码")
    public void download(
            @Parameter(description = "存储文件名", required = true)
            @PathVariable String fileName,
            HttpServletResponse response) {
        fileService.download(fileName, response);
    }

    @DeleteMapping("/{fileName}")
    @Operation(summary = "删除文件", description = "根据存储文件名删除文件,同时删除MinIO中的对象与数据库元数据")
    public Result<Booleandelete(
            @Parameter(description = "存储文件名", required = true)
            @PathVariable String fileName) {
        return Result.success(fileService.delete(fileName));
    }

    @GetMapping("/presigned-url/{fileName}")
    @Operation(summary = "获取文件预签名访问URL", description = "生成带签名的临时访问URL,默认有效期1小时")
    public Result<StringgetPresignedUrl(
            @Parameter(description = "存储文件名", required = true)
            @PathVariable String fileName) {
        return Result.success(fileService.getPresignedUrl(fileName));
    }

    @GetMapping("/check-exist/{md5}")
    @Operation(summary = "校验文件是否已存在(秒传)", description = "根据文件MD5值校验是否已上传,已上传直接返回文件信息")
    public Result<FileUploadVOcheckFileExist(
            @Parameter(description = "文件MD5值", required = true)
            @PathVariable String md5) {
        FileUploadVO vo = fileService.checkFileExist(md5);
        if (vo == null) {
            return Result.fail(404"文件不存在");
        }
        return Result.success(vo);
    }
}
package com.jam.demo.controller;

import com.jam.demo.common.Result;
import com.jam.demo.service.FileService;
import com.jam.demo.vo.*;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;

/**
 * 分片上传控制器
 *
 * @author ken
 */
@Slf4j
@RestController
@RequestMapping("/multipart")
@Tag(name = "分片上传接口", description = "大文件分片上传、断点续传相关接口,支持GB级大文件上传")
public class MultipartUploadController {

    private final FileService fileService;

    public MultipartUploadController(FileService fileService) {
        this.fileService = fileService;
    }

    @PostMapping("/init")
    @Operation(summary = "初始化分片上传", description = "初始化分片上传任务,生成uploadId、分片大小、总分片数等信息")
    public Result<MultipartUploadInitVOinitMultipartUpload(@Valid @RequestBody MultipartUploadInitRequest request) {
        return Result.success(fileService.initMultipartUpload(request));
    }

    @PostMapping(value = "/upload-part", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
    @Operation(summary = "上传单个分片", description = "上传文件的单个分片,返回分片的ETag值,用于最终合并")
    public Result<MultipartUploadPartVOuploadPart(@Valid MultipartUploadPartRequest request) {
        return Result.success(fileService.uploadPart(request));
    }

    @PostMapping("/complete")
    @Operation(summary = "合并分片", description = "所有分片上传完成后,调用此接口合并分片,生成完整文件并保存元数据")
    public Result<FileUploadVOcompleteMultipartUpload(@Valid @RequestBody MultipartUploadCompleteRequest request) {
        return Result.success(fileService.completeMultipartUpload(request));
    }
}

3.10 项目启动类与辅助配置

项目启动类

package com.jam.demo;

import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

/**
 * 项目启动类
 *
 * @author ken
 */
@SpringBootApplication
@MapperScan("com.jam.demo.mapper")
public class MinioDemoApplication {

    public static void main(String[] args) {
        SpringApplication.run(MinioDemoApplication.class, args);
    }
}

MyBatis-Plus自动填充配置

package com.jam.demo.config;

import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

/**
 * MyBatis-Plus字段自动填充配置
 *
 * @author ken
 */
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {

    @Override
    public void insertFill(MetaObject metaObject) {
        this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
        this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
    }
}

四、核心业务流程深度解析

4.1 普通文件上传全流程

普通文件上传适用于100MB以内的小文件场景,流程简单、开发成本低,核心流程如下:

核心逻辑说明:

  1. 秒传能力:通过文件MD5值实现秒传,避免重复上传相同文件,节省存储空间与带宽资源
  2. 事务一致性:采用编程式事务保证MinIO文件上传与数据库元数据保存的一致性,异常时自动回滚,避免脏数据
  3. 文件名唯一性:通过UUID生成全局唯一的存储文件名,避免同名文件覆盖问题

4.2 大文件分片上传与断点续传全流程

分片上传适用于100MB以上的大文件场景,解决了大文件上传超时、网络中断后需重新上传的痛点,核心流程如下:

核心逻辑说明:

  1. 断点续传实现:客户端记录已上传成功的分片,网络中断或页面刷新后,只需上传未完成的分片,无需从头开始
  2. 分片规则:默认分片大小5MB,最小分片数1,最大分片数10000,适配MinIO的分片上传规范,支持最大50GB的文件上传
  3. ETag校验:每个分片上传后返回唯一的ETag值,合并时需携带所有分片的ETag与序号,MinIO会校验分片的完整性,避免分片错乱或损坏
  4. 资源清理:未完成的分片上传任务,MinIO会保留未合并的分片,可通过生命周期规则自动清理过期的分片数据,避免存储空间浪费

五、前端对接实战示例

5.1 普通文件上传前端示例

基于原生JavaScript实现,适配主流浏览器,可直接集成到Vue、React等前端框架中:

// 普通文件上传
async function uploadFile(event) {
    const file = event.target.files[0];
    if (!file) {
        alert("请选择要上传的文件");
        return;
    }
    const formData = new FormData();
    formData.append("file", file);
    try {
        const response = await fetch("http://localhost:8080/file/upload", {
            method"POST",
            body: formData
        });
        const result = await response.json();
        if (result.code === 200) {
            console.log("上传成功", result.data);
            alert("上传成功,文件访问地址:" + result.data.url);
        } else {
            alert("上传失败:" + result.message);
        }
    } catch (error) {
        console.error("上传异常", error);
        alert("上传异常,请检查网络");
    }
}

// 页面绑定
document.getElementById("fileInput").addEventListener("change", uploadFile);

5.2 大文件分片上传前端示例

实现文件切片、MD5计算、断点续传、进度展示等核心能力:

// 分片大小 5MB,与后端保持一致
const PART_SIZE = 5 * 1024 * 1024;
// 已上传的分片信息
let uploadedParts = [];
// 分片上传任务信息
let uploadTask = null;
// 文件MD5值
let fileMd5 = "";

// 计算文件MD5
async function calculateFileMD5(file) {
    return new Promise((resolve) => {
        const spark = new SparkMD5.ArrayBuffer();
        const fileReader = new FileReader();
        let loaded = 0;
        const chunkSize = 2 * 1024 * 1024;
        const totalChunks = Math.ceil(file.size / chunkSize);
        let currentChunk = 0;

        fileReader.onload = function (e) {
            spark.append(e.target.result);
            currentChunk++;
            loaded += e.target.result.byteLength;
            const progress = Math.floor((loaded / file.size) * 100);
            console.log("MD5计算进度:" + progress + "%");
            if (currentChunk < totalChunks) {
                loadNextChunk();
            } else {
                resolve(spark.end());
            }
        };

        function loadNextChunk() {
            const start = currentChunk * chunkSize;
            const end = Math.min(start + chunkSize, file.size);
            fileReader.readAsArrayBuffer(file.slice(start, end));
        }
        loadNextChunk();
    });
}

// 秒传校验
async function checkFileExist(md5) {
    const response = await fetch(`http://localhost:8080/file/check-exist/${md5}`);
    const result = await response.json();
    return result.code === 200 ? result.data : null;
}

// 初始化分片上传
async function initMultipartUpload(file, md5) {
    const response = await fetch("http://localhost:8080/multipart/init", {
        method: "POST",
        headers: {
            "Content-Type": "application/json"
        },
        body: JSON.stringify({
            originalFileName: file.name,
            md5: md5,
            fileSize: file.size,
            contentType: file.type
        })
    });
    const result = await response.json();
    if (result.code !== 200) {
        throw new Error(result.message);
    }
    return result.data;
}

// 上传单个分片
async function uploadPart(chunk, partNumber) {
    const formData = new FormData();
    formData.append("fileName", uploadTask.fileName);
    formData.append("uploadId", uploadTask.uploadId);
    formData.append("partNumber", partNumber);
    formData.append("file", chunk);
    const response = await fetch("http://localhost:8080/multipart/upload-part", {
        method: "POST",
        body: formData
    });
    const result = await response.json();
    if (result.code !== 200) {
        throw new Error(result.message);
    }
    return result.data;
}

// 合并分片
async function completeMultipartUpload(file) {
    const response = await fetch("http://localhost:8080/multipart/complete", {
        method: "POST",
        headers: {
            "Content-Type": "application/json"
        },
        body: JSON.stringify({
            fileName: uploadTask.fileName,
            uploadId: uploadTask.uploadId,
            md5: fileMd5,
            fileSize: file.size,
            originalFileName: file.name,
            parts: uploadedParts
        })
    });
    const result = await response.json();
    if (result.code !== 200) {
        throw new Error(result.message);
    }
    return result.data;
}

// 主上传方法
async function uploadLargeFile(event) {
    const file = event.target.files[0];
    if (!file) {
        alert("请选择要上传的文件");
        return;
    }
    try {
        // 1. 计算文件MD5
        console.log("开始计算文件MD5...");
        fileMd5 = await calculateFileMD5(file);
        console.log("文件MD5:", fileMd5);

        // 2. 秒传校验
        const existFile = await checkFileExist(fileMd5);
        if (existFile) {
            console.log("文件已存在,秒传成功", existFile);
            alert("秒传成功,文件访问地址:" + existFile.url);
            return;
        }

        // 3. 初始化分片上传
        console.log("初始化分片上传...");
        uploadTask = await initMultipartUpload(file, fileMd5);
        console.log("分片上传初始化完成", uploadTask);

        // 4. 切割文件
        const totalChunks = uploadTask.totalPartCount;
        const chunks = [];
        for (let i = 0; i < totalChunks; i++) {
            const start = i * uploadTask.partSize;
            const end = Math.min(start + uploadTask.partSize, file.size);
            chunks.push(file.slice(start, end));
        }

        // 5. 循环上传分片
        console.log("开始上传分片,总分片数:", totalChunks);
        uploadedParts = [];
        for (let i = 0; i < chunks.length; i++) {
            const partNumber = i + 1;
            try {
                const partResult = await uploadPart(chunks[i], partNumber);
                uploadedParts.push(partResult);
                const progress = Math.floor(((i + 1) / totalChunks) * 100);
                console.log(`分片${partNumber}/${totalChunks}上传成功,进度:${progress}%`);
            } catch (error) {
                console.error(`分片${partNumber}上传失败`, error);
                alert(`分片${partNumber}上传失败:${error.message}`);
                return;
            }
        }

        // 6. 合并分片
        console.log("所有分片上传完成,开始合并...");
        const finalResult = await completeMultipartUpload(file);
        console.log("文件上传完成", finalResult);
        alert("上传成功,文件访问地址:" + finalResult.url);

    } catch (error) {
        console.error("大文件上传异常", error);
        alert("上传失败:" + error.message);
    }
}

// 页面绑定
document.getElementById("largeFileInput").addEventListener("change", uploadLargeFile);

六、生产环境最佳实践

6.1 权限管控最佳实践

  1. 最小权限原则:禁止在业务代码中使用管理员账号,应通过MinIO控制台创建专属业务用户,仅分配对应桶的读写权限,避免权限泄露导致的数据安全问题
  2. 预签名URL优先:业务访问优先使用预签名URL,避免将桶设置为公共读,防止数据被恶意爬取或篡改
  3. 临时凭证管控:前端直传场景使用STS临时凭证,设置较短的有效期,避免永久凭证泄露
  4. 访问策略精细化:通过桶策略限制IP访问、请求来源、文件大小、文件类型,禁止上传可执行文件、脚本文件等危险类型

6.2 性能优化最佳实践

  1. 分片大小优化:小文件使用普通上传,大文件使用分片上传,100MB-1GB文件分片大小设置为5MB,1GB-10GB文件分片大小设置为10MB,10GB以上文件分片大小设置为50MB,平衡分片数量与上传性能
  2. 并发上传优化:客户端分片上传采用并发上传,控制并发数在3-5个,避免并发过高导致服务端压力过大
  3. 部署架构优化:MinIO节点与业务服务部署在同一内网,减少网络延迟;生产环境使用SSD磁盘,提升IO性能;前端配置CDN加速静态资源访问
  4. 连接池优化:MinIO客户端配置合理的连接池参数,设置最大连接数、连接超时时间、读写超时时间,避免连接泄露

6.3 数据高可用与安全最佳实践

  1. 纠删码配置:分布式部署时,数据块与校验块的比例建议设置为1:1,平衡存储空间利用率与数据安全性;至少部署4个节点,确保集群高可用
  2. 数据备份:定期对核心数据进行跨集群备份,开启MinIO的版本控制功能,防止误删除导致的数据丢失
  3. 数据加密:敏感数据开启服务端加密(SSE-S3),传输过程使用HTTPS协议,确保数据传输与存储的安全性
  4. 生命周期管理:配置生命周期规则,自动清理过期文件、临时分片、历史版本文件,降低存储成本

6.4 监控与运维最佳实践

  1. 监控指标采集:通过Prometheus+Grafana采集MinIO的核心监控指标,包括节点健康状态、磁盘使用率、请求吞吐量、延迟、错误率等,设置告警阈值
  2. 日志审计:开启MinIO的访问日志,记录所有操作请求,用于安全审计与问题排查
  3. 定期巡检:定期检查集群节点健康状态、磁盘健康状态、数据一致性,及时更换故障磁盘,避免集群可用性下降
  4. 版本升级:定期升级MinIO到最新稳定版本,修复已知漏洞与问题,提升性能与稳定性

七、常见问题排查与解决方案

7.1 跨域问题

问题现象:前端调用接口时出现CORS跨域错误 解决方案

  1. 配置Spring Boot全局跨域过滤器:
package com.jam.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;

import java.util.Arrays;

/**
 * 全局跨域配置
 *
 * @author ken
 */
@Configuration
public class CorsConfig {

    @Bean
    public CorsFilter corsFilter() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOriginPatterns(Arrays.asList("*"));
        config.setAllowedMethods(Arrays.asList("GET""POST""PUT""DELETE""OPTIONS"));
        config.setAllowedHeaders(Arrays.asList("*"));
        config.setAllowCredentials(true);
        config.setMaxAge(3600L);
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return new CorsFilter(source);
    }
}

2. 在MinIO控制台配置桶的跨域规则,允许前端域名的跨域请求

7.2 大文件上传超时问题

问题现象:大文件上传时出现请求超时、连接重置错误 解决方案

  1. 调整Spring Boot的文件上传大小限制与超时时间
  2. 调整Nginx反向代理的超时配置,设置proxy_connect_timeoutproxy_send_timeoutproxy_read_timeout为300s以上
  3. 开启分片上传,降低单个请求的文件大小,避免单次请求超时
  4. 调整MinIO客户端的超时参数,设置合理的读写超时时间

7.3 预签名URL失效问题

问题现象:生成的预签名URL访问时出现签名不匹配、过期错误 解决方案

  1. 确保MinIO服务端与业务服务端的系统时间一致,时间差超过15分钟会导致签名校验失败
  2. 预签名URL的有效期设置合理,避免过期时间过短
  3. 确保生成预签名URL的endpoint与客户端访问的endpoint一致,内网与外网endpoint混用会导致签名不匹配
  4. 避免在预签名URL中包含特殊字符,文件名使用URL编码

7.4 分片合并失败问题

问题现象:所有分片上传完成后,合并分片时出现分片不存在、ETag不匹配错误 解决方案

  1. 确保分片序号从1开始,连续且不重复,合并时按序号升序排列
  2. 确保每个分片的ETag值正确,与上传时返回的ETag完全一致
  3. 确保合并时使用的uploadId、bucketName、fileName与初始化时完全一致
  4. 检查未合并的分片是否被生命周期规则清理,延长临时分片的保留时间

八、总结

MinIO作为一款轻量、高性能、兼容S3协议的对象存储系统,完美适配了云原生时代企业级非结构化数据存储的需求。本文从MinIO的底层架构出发,拆解了其核心设计理念与特性,通过Spring Boot项目的全流程整合,实现了从普通文件上传下载、大文件分片上传、断点续传、秒传等全场景能力,同时梳理了生产环境的最佳实践与常见问题解决方案。