Modern Architecture
& Coding Solutions

MinIO + Spring Boot:从零搭建分布式文件存储与大文件上传系统

做了几年 Java 后端,文件存储这块踩过的坑不算少。最开始用本地磁盘存,服务器一扩容就抓瞎——文件散落在不同节点上,用户上传的头像今天在这台机器上能访问,明天负载均衡到另一台就 404 了。后来用 FastDFS,部署复杂、运维成本高,社区活跃度也一般。再后来上了云厂商的 OSS,好用是好用,但账单一来才发现——光是文件存储和流量费,一个月就吃掉了一笔不小的预算。

直到接触了 MinIO,才算找到平衡点。它用 Go 写的,单文件部署,API 完全兼容 S3,从单机到分布式集群的迁移几乎是线性的。更关键的是,私有化部署意味着数据完全在自己手里,不用担心合规问题,成本也可控。

这篇文章会从零开始,讲清楚四件事:MinIO 集群怎么搭、Spring Boot 怎么集成、大文件分片上传和断点续传怎么实现、文件预览和缩略图怎么做。每一步都有可运行的代码和配置。

一、为什么选 MinIO

在动手之前,先聊聊技术选型。市面上做对象存储的方案不少,MinIO 能脱颖而出,主要有几个原因:

S3 兼容是最重要的。Amazon S3 已经成为对象存储领域的事实标准 API,MinIO 完全兼容 S3 协议。这意味着你写的代码以后想迁移到 AWS S3 或者阿里云 OSS,几乎不需要改动——改一个 endpoint 地址就行。反过来,你从云厂商迁移到自建 MinIO 也是一样。

部署简单。一个二进制文件,一条命令就能跑起来。分布式集群的部署也不复杂,官方提供了 Docker Compose 和 Kubernetes 的编排配置。相比之下,Ceph 的部署和运维门槛高了一个数量级。

性能不差。MinIO 官方数据显示,在合适硬件上,读写速度可以跑到 183GB/s 和 171GB/s。当然这是理想条件下的数据,但至少说明它在性能上不是瓶颈。

擦除编码是 MinIO 的另一个核心优势。它把数据切分成数据块和校验块,分布到不同的磁盘上。默认配置下,即使坏掉一半的磁盘,数据依然可以恢复。这对于需要保证数据可靠性的场景非常关键。

选型决策可以用下面这个表格来参考:

方案部署复杂度运维成本数据可控性S3 兼容适用场景
本地磁盘极低低完全❌单机小项目
FastDFS中中完全❌传统文件存储
MinIO低低完全✅私有化部署首选
AWS S3/阿里云 OSS无按量付费低✅无运维团队

如果你的项目需要私有化部署、对数据安全有要求、又不想花太多精力在运维上,MinIO 是当前最平衡的选择。

二、MinIO 集群部署

2.1 单机开发环境

开发阶段不需要上集群,用 Docker 单节点跑起来就行:

version: '3.8'
services:
  minio:
    image: minio/minio:RELEASE.2025-09-06T17-38-46Z
    container_name: minio
    ports:
      - "9000:9000"   # S3 API 端口
      - "9001:9001"   # Web 控制台端口
    environment:
      MINIO_ROOT_USER: minioadmin
      MINIO_ROOT_PASSWORD: minioadmin123
    volumes:
      - ./minio-data:/data
    command: server /data --console-address ":9001"
    healthcheck:
      test: ["CMD", "mc", "ready", "local"]
      interval: 5s
      timeout: 5s
      retries: 5

安全提醒:MINIO_ROOT_PASSWORD 至少要 8 个字符,生产环境千万不要用 minioadmin 这种默认密码。

启动后访问 http://localhost:9001,用配置的账号密码登录控制台,手动创建一个 Bucket(比如叫 app-files)。

2.2 分布式集群部署(生产环境)

生产环境需要至少 4 个节点才能发挥擦除编码的优势。MinIO 官方提供了 Docker Compose 编排方案,用椭圆写法 minio{1...4}/data{1...2} 一次性声明 4 个节点、每节点 2 块磁盘。

version: '3.8'

x-minio-common: &minio-common
  image: minio/minio:RELEASE.2025-09-06T17-38-46Z
  command: server --console-address ":9001" http://minio{1...4}/data{1...2}
  expose:
    - "9000"
    - "9001"
  healthcheck:
    test: ["CMD", "mc", "ready", "local"]
    interval: 5s
    timeout: 5s
    retries: 5

services:
  minio1:
    <<: *minio-common
    hostname: minio1
    volumes:
      - data1-1:/data1
      - data1-2:/data2

  minio2:
    <<: *minio-common
    hostname: minio2
    volumes:
      - data2-1:/data1
      - data2-2:/data2

  minio3:
    <<: *minio-common
    hostname: minio3
    volumes:
      - data3-1:/data1
      - data3-2:/data2

  minio4:
    <<: *minio-common
    hostname: minio4
    volumes:
      - data4-1:/data1
      - data4-2:/data2

  nginx:
    image: nginx:1.27-alpine
    ports:
      - "9000:9000"
      - "9001:9001"
    volumes:
      - ./nginx.conf:/etc/nginx/nginx.conf:ro
    depends_on:
      - minio1
      - minio2
      - minio3
      - minio4

volumes:
  data1-1:
  data1-2:
  data2-1:
  data2-2:
  data3-1:
  data3-2:
  data4-1:
  data4-2:

Nginx 负责将请求负载均衡到 4 个 MinIO 节点:

upstream minio_api {
    server minio1:9000;
    server minio2:9000;
    server minio3:9000;
    server minio4:9000;
}

upstream minio_console {
    server minio1:9001;
    server minio2:9001;
    server minio3:9001;
    server minio4:9001;
}

server {
    listen 9000;
    location / {
        proxy_pass http://minio_api;
        proxy_set_header Host $http_host;
        client_max_body_size 0;
        proxy_buffering off;
    }
}

server {
    listen 9001;
    location / {
        proxy_pass http://minio_console;
        proxy_set_header Host $http_host;
    }
}

client_max_body_size 0 这个配置很关键——它关闭了 Nginx 对请求体大小的限制,否则上传大文件时会被 Nginx 直接拦截返回 413。

三、Spring Boot 集成 MinIO

3.1 依赖配置

Spring Boot 3.5.8 配 MinIO Java SDK 8.5.9 是当前比较稳定的组合。

<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.9</version>
</dependency>

注意:MinIO Java SDK 8.x 和 7.x 的 API 差异很大,网上很多教程还是基于 7.x 的,直接抄会编译不过。一定要确认版本。

3.2 配置参数封装

@Data
@ConfigurationProperties(prefix = "minio")
public class MinioProperties {
    private String endpoint;
    private String accessKey;
    private String secretKey;
    private String bucketName;
    private String region = "us-east-1";
    private boolean secure = false;
}
minio:
  endpoint: http://localhost:9000
  access-key: minioadmin
  secret-key: minioadmin123
  bucket-name: app-files
  region: us-east-1

spring:
  servlet:
    multipart:
      max-file-size: 5GB
      max-request-size: 5GB

3.3 客户端 Bean

@Configuration
@RequiredArgsConstructor
public class MinioConfig {

    private final MinioProperties properties;

    @Bean
    public MinioClient minioClient() {
        MinioClient client = MinioClient.builder()
            .endpoint(properties.getEndpoint())
            .credentials(properties.getAccessKey(), properties.getSecretKey())
            .region(properties.getRegion())
            .build();

        // 设置超时:连接 30 秒,请求 5 分钟(大文件上传需要)
        client.setTimeout(
            TimeUnit.SECONDS.toMillis(30),
            TimeUnit.MINUTES.toMillis(5)
        );
        return client;
    }
}

生产环境建议设置合理的超时时间,默认的超时对于大文件上传来说太短了。

四、基础文件操作

4.1 自动创建 Bucket

应用启动时检查 Bucket 是否存在,不存在则自动创建:

@Slf4j
@Component
@RequiredArgsConstructor
public class BucketInitializer implements ApplicationRunner {

    private final MinioClient minioClient;
    private final MinioProperties properties;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        String bucketName = properties.getBucketName();
        boolean exists = minioClient.bucketExists(
            BucketExistsArgs.builder().bucket(bucketName).build()
        );
        if (!exists) {
            minioClient.makeBucket(
                MakeBucketArgs.builder().bucket(bucketName).build()
            );
            log.info("Bucket 创建成功: {}", bucketName);
        } else {
            log.info("Bucket 已存在: {}", bucketName);
        }
    }
}

4.2 文件上传与下载

@Slf4j
@Service
@RequiredArgsConstructor
public class MinioFileService {

    private final MinioClient minioClient;
    private final MinioProperties properties;

    /**
     * 上传文件
     */
    public String uploadFile(String objectName, InputStream stream,
                             long size, String contentType) {
        try {
            minioClient.putObject(
                PutObjectArgs.builder()
                    .bucket(properties.getBucketName())
                    .object(objectName)
                    .stream(stream, size, -1)
                    .contentType(contentType)
                    .build()
            );
            log.info("文件上传成功: {}", objectName);
            return objectName;
        } catch (Exception e) {
            log.error("文件上传失败: {}", objectName, e);
            throw new RuntimeException("文件上传失败", e);
        }
    }

    /**
     * 下载文件
     */
    public InputStream downloadFile(String objectName) {
        try {
            return minioClient.getObject(
                GetObjectArgs.builder()
                    .bucket(properties.getBucketName())
                    .object(objectName)
                    .build()
            );
        } catch (Exception e) {
            log.error("文件下载失败: {}", objectName, e);
            throw new RuntimeException("文件下载失败", e);
        }
    }

    /**
     * 删除文件
     */
    public void deleteFile(String objectName) {
        try {
            minioClient.removeObject(
                RemoveObjectArgs.builder()
                    .bucket(properties.getBucketName())
                    .object(objectName)
                    .build()
            );
        } catch (Exception e) {
            log.error("文件删除失败: {}", objectName, e);
            throw new RuntimeException("文件删除失败", e);
        }
    }
}

五、大文件分片上传与断点续传

这是整篇文章最核心的部分,也是面试和实际项目中经常被问到的地方。

5.1 为什么需要分片上传

一个 2GB 的文件,如果一次性上传,会遇到几个问题:

  • 网络抖动导致全量重传:传到 99% 断了,前面全白费
  • 请求超时:HTTP 请求默认超时时间通常在 30 秒到几分钟,大文件传输很容易超时
  • 服务器内存压力:如果服务端把整个文件读到内存里处理,几个并发上传就能把 JVM 撑爆

分片上传的思路很直接:把大文件切成固定大小的小分片(通常 5MB-15MB),逐个上传,全部完成后服务端合并。断点续传则是在这个基础上加了一个“进度记录”——上传中断后,下次只上传还没成功的分片。

5.2 MinIO 的分片上传 API

MinIO 的 S3 兼容接口提供了完整的分片上传 API:

API作用
CreateMultipartUpload初始化分片上传,返回唯一 uploadId
UploadPart上传单个分片,需携带 partNumber 和 uploadId
CompleteMultipartUpload所有分片上传完成后,合并成完整文件
ListParts查询已上传的分片列表(断点续传的关键)
AbortMultipartUpload中止上传,清理已上传的分片

关键点:MinIO 只提供了基础的 API,断点续传的“状态管理”需要业务层自己实现。这也是为什么很多教程只讲了分片上传,没讲断点续传——因为后者需要设计数据模型。

5.3 数据模型设计

断点续传需要一个地方记录“哪些分片已经上传成功了”。可以用数据库表来实现:

CREATE TABLE multipart_upload (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    upload_id VARCHAR(255) NOT NULL COMMENT 'MinIO 返回的上传会话ID',
    file_key VARCHAR(255) NOT NULL COMMENT '文件唯一标识(MD5)',
    file_name VARCHAR(255) NOT NULL,
    total_parts INT NOT NULL COMMENT '总分片数',
    uploaded_parts TEXT COMMENT '已上传分片编号,逗号分隔',
    status VARCHAR(20) DEFAULT 'UPLOADING' COMMENT 'UPLOADING/COMPLETED/ABORTED',
    created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
    updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    UNIQUE KEY uk_file_key (file_key)
) COMMENT '分片上传会话表';

5.4 完整的分片上传流程

步骤 1:初始化上传

@PostMapping("/init")
public InitUploadResponse initUpload(@RequestBody InitUploadRequest request) {
    String fileKey = request.getFileKey();  // 前端计算的文件 MD5
    String fileName = request.getFileName();
    long fileSize = request.getFileSize();
    int chunkSize = 10 * 1024 * 1024;       // 10MB 分片
    int totalParts = (int) Math.ceil((double) fileSize / chunkSize);

    // 查询数据库,判断是否已有上传记录
    MultipartUpload existing = uploadRepository.findByFileKey(fileKey);

    if (existing != null && "UPLOADING".equals(existing.getStatus())) {
        // 断点续传:查询 MinIO 已上传的分片
        ListPartsResponse partsResponse = minioClient.listParts(
            ListPartsArgs.builder()
                .bucket(properties.getBucketName())
                .object(fileName)
                .uploadId(existing.getUploadId())
                .build()
        );

        Set<Integer> uploadedParts = partsResponse.result().partList()
            .stream()
            .map(Part::partNumber)
            .collect(Collectors.toSet());

        return InitUploadResponse.builder()
            .uploadId(existing.getUploadId())
            .totalParts(totalParts)
            .uploadedParts(uploadedParts)
            .chunkSize(chunkSize)
            .build();
    }

    // 新建上传会话
    CreateMultipartUploadResponse response = minioClient.createMultipartUpload(
        CreateMultipartUploadArgs.builder()
            .bucket(properties.getBucketName())
            .object(fileName)
            .contentType(request.getContentType())
            .build()
    );

    // 保存记录到数据库
    MultipartUpload upload = new MultipartUpload();
    upload.setUploadId(response.result().uploadId());
    upload.setFileKey(fileKey);
    upload.setFileName(fileName);
    upload.setTotalParts(totalParts);
    uploadRepository.save(upload);

    return InitUploadResponse.builder()
        .uploadId(response.result().uploadId())
        .totalParts(totalParts)
        .uploadedParts(Collections.emptySet())
        .chunkSize(chunkSize)
        .build();
}

步骤 2:上传分片

@PostMapping("/uploadPart")
public void uploadPart(
        @RequestParam("file") MultipartFile file,
        @RequestParam("uploadId") String uploadId,
        @RequestParam("partNumber") int partNumber,
        @RequestParam("fileKey") String fileKey) throws Exception {

    // 上传分片到 MinIO
    UploadPartResponse partResponse = minioClient.uploadPart(
        UploadPartArgs.builder()
            .bucket(properties.getBucketName())
            .object(getObjectName(uploadId))
            .uploadId(uploadId)
            .partNumber(partNumber)
            .stream(file.getInputStream(), file.getSize(), -1)
            .build()
    );

    // 更新数据库中的已上传分片记录
    MultipartUpload upload = uploadRepository.findByFileKey(fileKey);
    String uploaded = upload.getUploadedParts() == null 
        ? "" : upload.getUploadedParts() + ",";
    upload.setUploadedParts(uploaded + partNumber);
    uploadRepository.save(upload);
}

步骤 3:完成上传

@PostMapping("/complete")
public String completeUpload(@RequestParam String uploadId,
                              @RequestParam String fileKey) throws Exception {
    MultipartUpload upload = uploadRepository.findByFileKey(fileKey);

    // 查询所有已上传的分片,构造 Part 列表
    ListPartsResponse partsResponse = minioClient.listParts(
        ListPartsArgs.builder()
            .bucket(properties.getBucketName())
            .object(upload.getFileName())
            .uploadId(uploadId)
            .build()
    );

    List<Part> parts = partsResponse.result().partList().stream()
        .map(p -> new Part(p.partNumber(), p.etag()))
        .collect(Collectors.toList());

    // 合并分片
    minioClient.completeMultipartUpload(
        CompleteMultipartUploadArgs.builder()
            .bucket(properties.getBucketName())
            .object(upload.getFileName())
            .uploadId(uploadId)
            .parts(parts)
            .build()
    );

    // 更新状态
    upload.setStatus("COMPLETED");
    uploadRepository.save(upload);

    // 生成访问链接
    return minioClient.getPresignedObjectUrl(
        GetPresignedObjectUrlArgs.builder()
            .method(Method.GET)
            .bucket(properties.getBucketName())
            .object(upload.getFileName())
            .expiry(7, TimeUnit.DAYS)
            .build()
    );
}

5.5 几个容易踩的坑

分片大小不是越小越好。分片过小会导致 HTTP 请求数量暴增,每个请求都有网络开销和签名计算开销。分片过大则失去分片的意义,单个分片失败重传成本高。Web 场景下 5MB-15MB 是比较平衡的区间。

上传 ID 的生命周期。uploadId 有有效期,长时间不活动会被 MinIO 回收。如果用户暂停上传后隔了几天才回来续传,uploadId 可能已经失效了,需要重新初始化。处理方式是:调用 listParts 时如果报错说 uploadId 不存在,就丢弃旧记录重新开始。

分片不是必须按顺序上传。前端可以用 Promise.all 并发上传多个分片,后端只要保证 partNumber 正确就行。MinIO 在合并时会按 partNumber 排序,不需要关心上传顺序。

ETag 需要保存。MinIO 在 uploadPart 返回的 ETag 是分片的唯一标识,合并时 CompleteMultipartUpload 需要用到。可以把 ETag 和分片编号一起存到数据库里,或者合并前用 listParts 重新查一遍。

六、文件预览与缩略图

6.1 临时访问链接

MinIO 的预签名 URL(Presigned URL)是最实用的功能之一。它允许你在不暴露密钥的情况下,生成一个有有效期限制的临时访问链接。

public String getPreviewUrl(String objectName, int expirySeconds) {
    try {
        return minioClient.getPresignedObjectUrl(
            GetPresignedObjectUrlArgs.builder()
                .method(Method.GET)
                .bucket(properties.getBucketName())
                .object(objectName)
                .expiry(expirySeconds, TimeUnit.SECONDS)
                .build()
        );
    } catch (Exception e) {
        throw new RuntimeException("生成预览链接失败", e);
    }
}

图片和 PDF 可以直接在浏览器里打开,视频和音频也可以直接播放。对于需要在线预览的文档(Word、Excel、PPT),可以搭配 kkFileView 这类服务,把 MinIO 的预签名 URL 传给 kkFileView 的 onlinePreview 接口即可。

6.2 图片缩略图

用户上传图片后,生成缩略图可以显著提升列表页的加载速度。用 Google 开源的 Thumbnailator 库,一行代码就能搞定:

public byte[] generateThumbnail(InputStream inputStream, int width, int height) 
        throws IOException {
    ByteArrayOutputStream output = new ByteArrayOutputStream();
    Thumbnails.of(inputStream)
        .size(width, height)
        .outputFormat("jpg")
        .toOutputStream(output);
    return output.toByteArray();
}

生成的缩略图可以用一个新的 objectName 上传到 MinIO(比如 thumbnails/ 前缀下),和原图分开存储。

6.3 视频缩略图

视频缩略图需要用到 FFmpeg。可以通过 JavaCV 或 ffmpeg-wrapper 来调用:

public byte[] generateVideoThumbnail(File videoFile, int width, int height) 
        throws IOException, InterruptedException {
    File outputFile = File.createTempFile("thumb-", ".jpg");
    String cmd = String.format(
        "ffmpeg -i %s -ss 00:00:01 -vframes 1 -vf scale=%d:%d %s",
        videoFile.getAbsolutePath(), width, height, outputFile.getAbsolutePath()
    );
    Process process = Runtime.getRuntime().exec(cmd);
    process.waitFor();
    return Files.readAllBytes(outputFile.toPath());
}

生产环境不建议每次上传都同步生成缩略图,可以放到消息队列里异步处理。上传完原文件后发一条消息,消费者负责生成缩略图并回写。

七、生产环境建议

Bucket 策略。开发环境一个 Bucket 就够了,生产环境建议按业务拆分——用户头像一个 Bucket、业务附件一个 Bucket、系统日志一个 Bucket。不同 Bucket 可以设置不同的生命周期策略和访问权限。

访问控制。不要把 Bucket 设为公开可读。所有文件访问都应该通过预签名 URL,并设置合理的过期时间(比如 1 小时到 7 天)。公开的 Bucket 等于把你的文件存储暴露给了全世界。

Nginx 反向代理。生产环境不要在应用里直接暴露 MinIO 的地址,用 Nginx 做一层代理。好处是可以统一域名、配置 HTTPS、限制请求大小、加访问日志。

健康检查与监控。MinIO 提供了 /minio/health/live 和 /minio/health/ready 两个健康检查端点,可以接入 Prometheus + Grafana 做监控。关注磁盘使用率、请求延迟、错误率这几个关键指标。

备份策略。MinIO 的擦除编码可以应对磁盘故障,但不能应对误删除。建议定期把关键 Bucket 的数据备份到另一个存储介质上。

八、总结

回到开头那个问题——文件存储方案怎么选。

MinIO 给出的答案是:用标准协议(S3)降低迁移成本,用擦除编码保证数据可靠性,用简单的部署和运维降低使用门槛。它不追求大而全,而是把对象存储这件事做到足够好、足够简单。

Spring Boot 集成 MinIO 的过程也不复杂——一个客户端 Bean、一个配置类、一个 Service,基础功能就能跑起来。真正需要花心思的是大文件分片上传和断点续传,因为这部分的状态管理需要业务层自己设计。

本文涉及的几个关键点:

  • 集群部署用 Docker Compose + Nginx,4 节点起步,擦除编码保证数据安全
  • 客户端配置注意版本差异(8.x vs 7.x),超时参数要调大
  • 分片上传核心是 uploadId 的状态管理,数据库记录已上传分片
  • 断点续传通过 listParts 查询已上传分片,只补传缺失的部分
  • 临时链接用预签名 URL,不要暴露 Bucket

如果你正在找一个私有化部署、成本可控、API 兼容性好的文件存储方案,MinIO 值得认真考虑。

系列拓展阅读

参考文献

  1. MinIO Docker Compose 分布式部署详解. GitCode, 2026-09-04. https://blog.gitcode.com/fef76218bbd13ad279c71ba2c8083185.html
  2. Spring Boot与MinIO整合实践:构建高效对象存储服务. 0539edu, 2026-09-10. http://www.0539edu.com/news/3940914/
  3. Java实现大文件断点续传和分片上传原理. CSDN, 2026-01-20. https://blog.csdn.net/qq_24923619/article/details/157181332
  4. MinIO在SpringBoot中的高级应用:如何实现文件分片上传与断点续传. CSDN, 2026-02-24. https://blog.csdn.net/weixin_29200485/article/details/158340762
  5. 保姆级教程:SpringBoot整合MinIO 8.3.5,手把手实现带断点续传的大文件分片上传. CSDN, 2026-04-12. https://blog.csdn.net/weixin_28366053/article/details/160072153
  6. SpringBoot × MinIO 极速开发指南:对象存储服务高可用实战. 阿里云开发者社区, 2025-05-20. https://developer.aliyun.com/article/1663959
  7. MinIO Java SDK 8.x 分片上传全流程源码拆解. CSDN, 2026-07-04.
  8. Spring Boot 3.X 整合 MinIO 存储原生方案. 腾讯云开发者社区, 2025-12-22. https://cloud.tencent.com.cn/developer/article/2605805
赞(0) 打赏
未经允许不得转载:MACS Dev Hub » MinIO + Spring Boot:从零搭建分布式文件存储与大文件上传系统

觉得文章有用就打赏一下文章作者

非常感谢你的打赏,我们将继续提供更多优质内容,让我们一起创建更加美好的网络世界!

支付宝扫一扫

微信扫一扫

登录

找回密码

注册