做了几年 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 值得认真考虑。
系列拓展阅读
- 《从 Docker 到 K8s:Spring Boot 应用部署与自动伸缩实战》 —— MinIO 集群在 Kubernetes 中的部署
- 《Spring Boot 3.5 AOT 与 GraalVM 原生镜像:启动时间从 3 秒到 0.1 秒》 —— 文件服务编译原生镜像的注意事项
- 《Spring Boot + Kafka 深度实战:可靠消息传递与 Exactly-Once 语义》 —— 用消息队列异步处理缩略图生成
- 《Java 应用接入 Prometheus + Grafana 全记录》 —— MinIO 集群的监控方案
参考文献
- MinIO Docker Compose 分布式部署详解. GitCode, 2026-09-04. https://blog.gitcode.com/fef76218bbd13ad279c71ba2c8083185.html
- Spring Boot与MinIO整合实践:构建高效对象存储服务. 0539edu, 2026-09-10. http://www.0539edu.com/news/3940914/
- Java实现大文件断点续传和分片上传原理. CSDN, 2026-01-20. https://blog.csdn.net/qq_24923619/article/details/157181332
- MinIO在SpringBoot中的高级应用:如何实现文件分片上传与断点续传. CSDN, 2026-02-24. https://blog.csdn.net/weixin_29200485/article/details/158340762
- 保姆级教程:SpringBoot整合MinIO 8.3.5,手把手实现带断点续传的大文件分片上传. CSDN, 2026-04-12. https://blog.csdn.net/weixin_28366053/article/details/160072153
- SpringBoot × MinIO 极速开发指南:对象存储服务高可用实战. 阿里云开发者社区, 2025-05-20. https://developer.aliyun.com/article/1663959
- MinIO Java SDK 8.x 分片上传全流程源码拆解. CSDN, 2026-07-04.
- Spring Boot 3.X 整合 MinIO 存储原生方案. 腾讯云开发者社区, 2025-12-22. https://cloud.tencent.com.cn/developer/article/2605805








