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

在互联网业务的全链路开发中,非结构化数据(图片、视频、文档、安装包等)的存储与访问始终是核心需求之一。传统的本地文件存储、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命令即可一键启动:

bash 复制代码
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个独立驱动器:

bash 复制代码
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 复制代码
<?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等核心配置:

yaml 复制代码
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:

sql 复制代码
CREATE TABLE `file_info` (
  `id` bigint NOT NULL COMMENT '主键ID',
  `file_name` varchar(255) NOT NULL COMMENT '存储文件名(唯一)',
  `original_file_name` varchar(255) NOT NULL COMMENT '原始文件名',
  `bucket_name` varchar(100) NOT NULL COMMENT '存储桶名称',
  `file_path` varchar(500) NOT NULL COMMENT '文件存储路径',
  `file_size` bigint NOT NULL COMMENT '文件大小(字节)',
  `content_type` varchar(100) DEFAULT NULL COMMENT '文件MIME类型',
  `md5` varchar(32) NOT 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 核心常量定义

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

arduino 复制代码
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 统一异常与响应处理

自定义业务异常类

java 复制代码
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;
    }
}

统一响应结果类

typescript 复制代码
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);
    }
}

全局异常处理器

kotlin 复制代码
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层

文件信息实体类

less 复制代码
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接口

java 复制代码
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配置类

typescript 复制代码
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操作工具类

scss 复制代码
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,用于接口参数传递:

kotlin 复制代码
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;
}
less 复制代码
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;
}
kotlin 复制代码
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;
}
less 复制代码
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;
}
kotlin 复制代码
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;
}
less 复制代码
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;
}

定义业务接口:

java 复制代码
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);
}

业务接口实现类

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

ini 复制代码
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注解与参数校验,覆盖全场景文件操作能力:

less 复制代码
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<FileUploadVO> upload(
            @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<Boolean> delete(
            @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<String> getPresignedUrl(
            @Parameter(description = "存储文件名", required = true)
            @PathVariable String fileName) {
        return Result.success(fileService.getPresignedUrl(fileName));
    }

    @GetMapping("/check-exist/{md5}")
    @Operation(summary = "校验文件是否已存在(秒传)", description = "根据文件MD5值校验是否已上传,已上传直接返回文件信息")
    public Result<FileUploadVO> checkFileExist(
            @Parameter(description = "文件MD5值", required = true)
            @PathVariable String md5) {
        FileUploadVO vo = fileService.checkFileExist(md5);
        if (vo == null) {
            return Result.fail(404, "文件不存在");
        }
        return Result.success(vo);
    }
}
less 复制代码
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<MultipartUploadInitVO> initMultipartUpload(@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<MultipartUploadPartVO> uploadPart(@Valid MultipartUploadPartRequest request) {
        return Result.success(fileService.uploadPart(request));
    }

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

3.10 项目启动类与辅助配置

项目启动类

kotlin 复制代码
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自动填充配置

kotlin 复制代码
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等前端框架中:

javascript 复制代码
// 普通文件上传
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计算、断点续传、进度展示等核心能力:

ini 复制代码
// 分片大小 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全局跨域过滤器:
arduino 复制代码
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);
    }
}
  1. 在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项目的全流程整合,实现了从普通文件上传下载、大文件分片上传、断点续传、秒传等全场景能力,同时梳理了生产环境的最佳实践与常见问题解决方案。

相关推荐
冰暮流星1 小时前
flask之定义URL
后端·python·flask
程序员爱钓鱼2 小时前
为什么学习 Rust?Rust 能做什么?
后端·rust
二宝哥3 小时前
创建Gradle多模块SpringBoot项目
java·spring boot·后端
掘金者阿豪3 小时前
Docker命令太多记不住?用Portainer把容器管理变成可视化操作
后端
用户69371750013843 小时前
AI Agent 里的 Loop 到底是什么?
android·前端·后端
Rain的Java大神之路4 小时前
高并发下的抢优惠券如何设计
后端
钟智强4 小时前
AWS CloudGoat 实战:一个 SSRF 如何撬动整个云账户
后端
Ai拆代码的曹操4 小时前
@Transactional 写在 private 方法上,事务根本没生效
后端
云技纵横4 小时前
@Async 默认执行器又炸了:SimpleAsyncTaskExecutor 为什么不是线程池?
后端·面试
程序员Agions4 小时前
Fypro 新手快速上手与实战指南
后端