这篇是给 Spring Boot 初同学看的。讲清楚怎么用 Docker 跑一个 MinIO,然后 Spring Boot 对接它做文件上传。原理、为什么这么做、那些文档里不写的坑,都掰开讲。对着这篇一步步来,不用再翻别的资料。

背景:我手上的 IM 项目(FriendChat)原本就用 MinIO,后来迁到阿里云 OSS 了。这篇记录 MinIO 的对接过程,给想自建对象存储的人参考。


一、先说 MinIO 是个啥,为啥用它

MinIO 是个开源的对象存储服务,兼容 Amazon S3 API。简单说,你可以用它在自己服务器上搭一个”私有版阿里云 OSS”。

它和阿里云 OSS 的区别

对比项 MinIO(自建) 阿里云 OSS(云服务)
部署方式 自己 Docker 跑 云厂商托管
成本 服务器+磁盘成本,流量走自己带宽 按用量付费,流量走公网/CDN
运维 自己管(备份、扩容、监控) 云厂商管
可靠性 看你怎么部署,单机版会丢数据 11个9,基本不会丢
适用场景 本地开发、内网部署、对数据 sovereignty 敏感 生产环境、面向公网的应用

一句话:本地开发 / 内网 / 不想被云厂商绑死 → MinIO;生产环境面向公网 → OSS。 我项目就是本地开发用 MinIO,上线切 OSS。


二、先把概念搞懂

MinIO 的概念和 OSS 几乎一模一样(因为 S3 兼容):

  • Bucket(桶):装文件的容器,名字唯一
  • Object(对象):一个文件,有 key(路径)+ 内容 + 元数据
  • Endpoint:访问地址,比如 http://localhost:9000
  • AccessKey / SecretKey:访问密钥,SDK 调用时要带

权限策略也类似:私有(private)、公共读(public-read)、公共读写(public-read-write)。

这篇讲两种对接方式:

  1. 公共读 Bucket(简单,文件 URL 直接可访问,适合开发环境)
  2. 私有 Bucket + 签名 URL(安全,URL 带过期时间,和 OSS 那篇对应)

三、第 1 步:Docker 跑 MinIO

最快的方式是用 Docker。装好 Docker 后,一条命令起 MinIO:

1
2
3
4
5
6
7
8
docker run -d \
--name minio \
-p 9000:9000 \
-p 9001:9001 \
-e "MINIO_ROOT_USER=admin" \
-e "MINIO_ROOT_PASSWORD=12345678" \
-v minio-data:/data \
minio/minio server /data --console-address ":9001"

几个参数讲清楚:

  • -p 9000:9000:9000 是 API 端口,SDK 走这个
  • -p 9001:9001:9001 是控制台端口,浏览器访问管理界面
  • MINIO_ROOT_USER:管理员账号(老版本叫 MINIO_ACCESS_KEY)
  • MINIO_ROOT_PASSWORD:管理员密码(老版本叫 MINIO_SECRET_KEY),至少 8 位,少于 8 位启动会报错
  • -v minio-data:/data:把数据挂载到 Docker volume,容器删了数据还在
  • --console-address ":9001":指定控制台端口

跑起来后:

  • 浏览器开 http://localhost:9001,用 admin/12345678 登录,看到控制台
  • API 地址是 http://localhost:9000,SDK 对接用这个

创建 Bucket

两种方式:

方式 1:控制台创建(推荐新手)
登录控制台 → Buckets → Create Bucket → 起个名(比如 friendchat)→ Create

方式 2:代码创建(后面会讲,SDK 自动创建)

设置访问策略(公共读方式才需要)

如果要公共读(文件 URL 直接可访问),控制台里:

  • 进 Bucket → Access Policy → 改成 public

或者代码里设(后面讲)。私有 Bucket 方式跳过这步。


四、第 2 步:加依赖

pom.xml 加 MinIO SDK:

1
2
3
4
5
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.13</version>
</dependency>

版本说明: 8.x 是新 API,和 7.x 完全不兼容。网上很多老教程是 7.x 的,minioClient.putObject(...) 直接传字符串,8.x 改成 Builder 模式了。看教程一定要注意版本,不然抄的代码跑不通。这篇是 8.5.x,API 是新的。

MinIO 8.x 依赖了 OkHttp、Kotlin 标准库等一堆东西,Spring Boot 一般能自动处理版本冲突。如果遇到 OkHttp 版本冲突,手动指定一下。


五、第 3 步:写配置

application.yml:

1
2
3
4
5
minio:
endpoint: http://localhost:9000
access-key: admin
secret-key: 12345678
bucket: friendchat

注意:

  • endpoint 要带 http://(MinIO SDK 要求带协议,OSS 不带,这里俩不一样)
  • 本地开发用 localhost,后端部署到服务器改成服务器 IP
  • access-key / secret-key 和 Docker 启动时的 MINIO_ROOT_USER / MINIO_ROOT_PASSWORD 一致

六、第 4 步:写配置类 MinioConfig

新建 src/main/java/top/wange/config/MinioConfig.java:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
package top.wange.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;

/**
* MinIO配置类
* 读取application.yml中的minio配置,创建MinioClient Bean
*/
@Data
@Configuration
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {

private String endpoint;
private String accessKey;
private String secretKey;
private String bucket;

/**
* 创建MinioClient实例
* MinioClient是线程安全的,整个应用共用一个,做成单例Bean
* @return MinioClient
*/
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
}

原理:yml 里的 key 怎么跑到 Java 字段里的

这块很多人抄完代码就跳过了,但搞不懂这个,换个配置就懵。我掰开讲。

先看 yml 配了哪些属性

1
2
3
4
5
minio:
endpoint: http://localhost:9000
access-key: admin
secret-key: 12345678
bucket: friendchat

每个属性的含义:

yml 属性 含义 对应 Java 字段
endpoint MinIO API 地址,SDK 走这个端口 endpoint
access-key 访问密钥 ID(Docker 启动时的 MINIO_ROOT_USER) accessKey
secret-key 访问密钥(Docker 启动时的 MINIO_ROOT_PASSWORD) secretKey
bucket Bucket 名 bucket

注意 yml 里是 access-key(短横线),Java 字段是 accessKey(驼峰)。这俩怎么对上的?靠的是 Spring 的松散绑定(Relaxed Binding)。

松散绑定规则

Spring Boot 把 yml 配置名映射到 Java 字段时,会自动做格式转换:

yml 写法(kebab-case) Java 字段(camelCase) 说明
access-key accessKey 短横线去掉,下一个字母大写
secret-key secretKey 同上
endpoint endpoint 单词没短横线,直接对应
bucket bucket 同上

规则:yml 用小写+短横线(kebab-case),Java 用驼峰(camelCase),Spring 自动匹配。 所以你不用管大小写转换,Spring 帮你做。

为什么用 @ConfigurationProperties 不用 @Value

把 yml 的值读进 Java,有两种方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 方式1:@Value 一个个读(麻烦,适合只读一两个值)
@Value("${minio.endpoint}")
private String endpoint;
@Value("${minio.access-key}")
private String accessKey;
@Value("${minio.secret-key}")
private String secretKey;
@Value("${minio.bucket}")
private String bucket;

// 方式2:@ConfigurationProperties 批量读(我们用的,适合一组配置)
@ConfigurationProperties(prefix = "minio")
public class MinioConfig {
private String endpoint; // 自动映射 minio.endpoint
private String accessKey; // 自动映射 minio.access-key
private String secretKey; // 自动映射 minio.secret-key
private String bucket; // 自动映射 minio.bucket
}

对比:

对比项 @Value @ConfigurationProperties
读取方式 一个个 @Value("${xxx}") 设个 prefix,字段自动映射
适合场景 只读 1-2 个值 读一组相关配置(像 MinIO 这 4 个)
松散绑定 支持 支持
类型转换 基本支持 更强(支持 Map、List、嵌套对象)
校验 不支持 配合 @Validated 可校验

MinIO 这 4 个属性是一组的,用 @ConfigurationProperties 更清爽,不用写 4 行 @Value。

@Data 和 setter 注入

@ConfigurationProperties 把 yml 值注入字段,靠的是 setter 方法。流程是:

  1. Spring 启动,扫描到 MinioConfig 有 @ConfigurationProperties(prefix = "minio")
  2. Spring 从 yml 读到 minio.endpoint = http://localhost:9000
  3. Spring 调用 minioConfig.setEndpoint("http://localhost:9000") 把值塞进去
  4. 其他 3 个字段同理

但 setter 方法谁写的?Lombok 的 @Data 注解自动生成。@Data 会给所有字段生成 getter 和 setter,所以不用手写:

1
2
3
4
5
6
// @Data 相当于自动生成了这些:
public void setEndpoint(String endpoint) { this.endpoint = endpoint; }
public void setAccessKey(String accessKey) { this.accessKey = accessKey; }
public void setSecretKey(String secretKey) { this.secretKey = secretKey; }
public void setBucket(String bucket) { this.bucket = bucket; }
// 还有 getter...

没有 setter,Spring 就没法注入值。所以 @Data 不是摆设,它是注入能跑起来的关键。

完整时序:从 yml 到 MinioClient

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
sequenceDiagram
participant yml as application.yml
participant Spring as Spring 启动
participant Config as MinioConfig
participant Bean as @Bean minioClient()
participant IoC as IoC 容器
participant Service as FileServiceImpl

Note over yml: minio.endpoint=http://localhost:9000<br/>minio.access-key=admin<br/>minio.secret-key=12345678<br/>minio.bucket=friendchat

Spring->>yml: 扫描到 @ConfigurationProperties(prefix="minio")
yml-->>Spring: 读取 minio.* 配置项

Note over Spring,Config: 第1步:注入配置值(靠 setter)
Spring->>Config: setEndpoint("http://localhost:9000")
Spring->>Config: setAccessKey("admin")
Spring->>Config: setSecretKey("12345678")
Spring->>Config: setBucket("friendchat")

Note over Spring,Bean: 第2步:调 @Bean 方法创建 MinioClient
Spring->>Config: 调用 minioClient() 方法
Config->>Config: MinioClient.builder()<br/>.endpoint(endpoint)<br/>.credentials(accessKey, secretKey)<br/>.build()
Config-->>Spring: 返回 MinioClient 实例

Spring->>IoC: 注册 MinioClient 为单例 Bean

Note over IoC,Service: 第3步:其他组件 @Autowired 拿到
Service->>IoC: @Autowired MinioClient
IoC-->>Service: 返回同一个 MinioClient 实例
Service->>IoC: @Autowired MinioConfig
IoC-->>Service: 返回 MinioConfig(读 bucket 名用)

几个要点:

  1. MinioConfig 本身也是个 Bean:它有 @Configuration 注解,会被 Spring 管理,所以 FileServiceImpl 里可以 @Autowired MinioConfig 拿到它(用来读 bucket 名)
  2. MinioClient 是单例:整个应用共用一个,线程安全。别每次上传都 new MinioClient(),会创建一堆 HTTP 连接,拖垮性能
  3. 启动时就把值注入好了:不是用到才读 yml,是 Spring 启动时一次性注入,之后字段值不变

七、第 5 步:写上传服务

这是核心。我分两种方式讲:公共读 和 私有+签名URL。

7.1 公共读方式(简单,推荐开发环境)

FileService 接口:

1
2
3
public interface FileService {
MediaFile uploadFile(MultipartFile file, Integer userId, String fileType);
}

FileServiceImpl:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
package top.wange.service.impl;

import io.minio.BucketExistsArgs;
import io.minio.MakeBucketArgs;
import io.minio.MinioClient;
import io.minio.PutObjectArgs;
import io.minio.SetBucketPolicyArgs;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import top.wange.config.MinioConfig;
import top.wange.entity.MediaFile;
import top.wange.mapper.MediaFileMapper;
import top.wange.service.FileService;

import java.io.InputStream;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.Arrays;
import java.util.List;
import java.util.UUID;

@Slf4j
@Service
public class FileServiceImpl implements FileService {

@Autowired
private MinioClient minioClient;

@Autowired
private MinioConfig minioConfig;

@Autowired
private MediaFileMapper mediaFileMapper;

// 文件大小和类型白名单(省略,和OSS那篇一样)
private static final List<String> ALLOWED_IMAGE_TYPES = Arrays.asList("jpg", "jpeg", "png", "gif", "webp");

@Override
public MediaFile uploadFile(MultipartFile file, Integer userId, String fileType) {
// 1. 校验(省略细节)
String originalFilename = file.getOriginalFilename();
String extension = originalFilename.substring(originalFilename.lastIndexOf(".") + 1).toLowerCase();
validateFileType(extension, fileType);
validateFileSize(file.getSize(), fileType);

// 2. 确保Bucket存在(不存在则创建+设公开读策略)
ensureBucketExists();

// 3. 生成objectName
String uniqueFileName = UUID.randomUUID().toString() + "." + extension;
LocalDate now = LocalDate.now();
String datePath = String.format("%d/%02d/%02d", now.getYear(), now.getMonthValue(), now.getDayOfMonth());
String objectName = fileType.toLowerCase() + "/" + datePath + "/" + uniqueFileName;

// 4. 上传到MinIO
try (InputStream inputStream = file.getInputStream()) {
minioClient.putObject(
PutObjectArgs.builder()
.bucket(minioConfig.getBucket())
.object(objectName)
.stream(inputStream, file.getSize(), -1)
.contentType(file.getContentType())
.build()
);
} catch (Exception e) {
log.error("文件上传到MinIO失败", e);
throw new RuntimeException("文件上传失败: " + e.getMessage());
}

// 5. 存数据库(公共读方式,filePath存objectName,URL现场拼)
MediaFile mediaFile = new MediaFile();
mediaFile.setUserId(userId);
mediaFile.setFileName(originalFilename);
mediaFile.setFilePath(objectName);
mediaFile.setFileSize(file.getSize());
mediaFile.setFileType(fileType.toUpperCase());
mediaFile.setUploadTime(LocalDateTime.now());
mediaFileMapper.insert(mediaFile);

log.info("文件上传MinIO成功: userId={}, objectName={}", userId, objectName);
return mediaFile;
}

/**
* 确保Bucket存在,不存在则创建,并设置公开读策略
* 这步只在公共读方式需要;私有Bucket方式不用设策略
*/
private void ensureBucketExists() {
try {
boolean exists = minioClient.bucketExists(
BucketExistsArgs.builder()
.bucket(minioConfig.getBucket())
.build()
);
if (!exists) {
minioClient.makeBucket(
MakeBucketArgs.builder()
.bucket(minioConfig.getBucket())
.build()
);
log.info("创建MinIO Bucket: {}", minioConfig.getBucket());
}

// 设置Bucket公开读策略(S3标准策略JSON)
String policy = "{\n" +
" \"Version\": \"2012-10-17\",\n" +
" \"Statement\": [\n" +
" {\n" +
" \"Effect\": \"Allow\",\n" +
" \"Principal\": \"*\",\n" +
" \"Action\": [\"s3:GetObject\"],\n" +
" \"Resource\": [\"arn:aws:s3:::" + minioConfig.getBucket() + "/*\"]\n" +
" }\n" +
" ]\n" +
"}";
minioClient.setBucketPolicy(
SetBucketPolicyArgs.builder()
.bucket(minioConfig.getBucket())
.config(policy)
.build()
);
} catch (Exception e) {
log.error("检查/创建Bucket失败", e);
throw new RuntimeException("MinIO Bucket初始化失败: " + e.getMessage());
}
}

// validateFileType / validateFileSize 省略
}

几个关键点必须讲

putObject 的参数

1
2
3
4
5
6
7
8
minioClient.putObject(
PutObjectArgs.builder()
.bucket(minioConfig.getBucket())
.object(objectName)
.stream(inputStream, file.getSize(), -1)
.contentType(file.getContentType())
.build()
);

8.x API 全是 Builder 模式。几个参数:

  • bucket:Bucket 名
  • object:objectName(文件路径)
  • stream(inputStream, file.getSize(), -1):三个参数 = 输入流、文件大小、分片大小。第三个 -1 是关键,表示用默认分片大小(5MB)。如果你知道文件小于 5MB,可以传 file.getSize() 让它不分片;不确定就传 -1 让 SDK 自己决定
  • contentType:MIME 类型,浏览器访问时根据这个判断怎么显示

那段策略 JSON 是啥

1
2
3
4
5
6
7
8
9
10
11
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": "*",
"Action": ["s3:GetObject"],
"Resource": ["arn:aws:s3:::friendchat/*"]
}
]
}

这是 S3 标准的 Bucket Policy 格式,MinIO 兼容 S3 所以能用。含义:

  • Effect: Allow:允许
  • Principal: "*":所有人(匿名)
  • Action: ["s3:GetObject"]:只能读(Get),不能写
  • Resource: ["arn:aws:s3:::friendchat/*"]:对 friendchat 这个 Bucket 下所有文件(/*)

合起来:允许任何人匿名读取这个 Bucket 下的文件。这就是”公共读”。

注意 arn:aws:s3:::friendchat/* 里的 Bucket 名要换成你自己的。 这是个坑,直接抄容易忘改。

objectName 设计

和 OSS 那篇一样:fileType/yyyy/MM/dd/uuid.ext。理由不重复,看 OSS 那篇。

7.2 公共读方式怎么访问文件

公共读 Bucket,文件 URL 直接拼:

1
http://localhost:9000/friendchat/image/2026/08/02/uuid.jpg

格式:{endpoint}/{bucket}/{objectName}

Controller 里这样拼:

1
String fileUrl = minioConfig.getEndpoint() + "/" + minioConfig.getBucket() + "/" + mediaFile.getFilePath();

公共读的好处:URL 永不过期,数据库可以直接存完整 URL,简单。
坏处:URL 泄露谁都能访问,没隐私。开发环境够用,生产环境不安全。


八、第 6 步:私有 Bucket + 签名 URL(进阶,生产推荐)

如果不想文件被随便访问,用私有 Bucket + 签名 URL。原理和 OSS 那篇一样:URL 带签名,过期失效。

改 FileService 加签名 URL 方法

1
2
3
4
5
6
7
8
9
10
11
public interface FileService {
MediaFile uploadFile(MultipartFile file, Integer userId, String fileType);

/**
* 生成签名URL(私有Bucket访问用)
* @param objectName 文件路径
* @param expire 过期时间(秒)
* @return 带签名的临时URL
*/
String generateSignedUrl(String objectName, int expire);
}

实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public String generateSignedUrl(String objectName, int expire) {
try {
String url = minioClient.getPresignedObjectUrl(
io.minio.GetPresignedObjectUrlArgs.builder()
.method(io.minio.HttpMethod.GET)
.bucket(minioConfig.getBucket())
.object(objectName)
.expiry(expire) // 过期时间(秒)
.build()
);
return url;
} catch (Exception e) {
log.error("生成签名URL失败: objectName={}", objectName, e);
throw new RuntimeException("生成文件访问URL失败: " + e.getMessage());
}
}

私有方式的关键差异

  1. 不要设公开读策略:ensureBucketExists() 里把 setBucketPolicy 那段删掉,Bucket 默认就是私有
  2. 数据库存 objectName,不存完整 URL(因为签名 URL 会过期)
  3. 访问时调 generateSignedUrl 现场生成 URL

签名 URL 原理

和 OSS 一模一样(MinIO 兼容 S3 签名算法 v4):

1
2
3
4
5
6
7
http://localhost:9000/friendchat/image/2026/08/02/uuid.jpg
?X-Amz-Algorithm=AWS4-HMAC-SHA256
&X-Amz-Credential=admin/20260802/us-east-1/s3/aws4_request
&X-Amz-Date=20260802T100000Z
&X-Amz-Expires=3600
&X-Amz-Signature=abc123...
&X-Amz-SignedHeaders=host
  • X-Amz-Signature:用 SecretKey 算的签名
  • X-Amz-Expires:有效期(秒)
  • 签名算法:HMAC-SHA256(比 OSS 的 HMAC-SHA1 新)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
flowchart LR
A[构造待签名串] --> B[用 SecretKey 做 HMAC-SHA256]
B --> C[得到 Signature]
C --> D[拼到URL参数里]
D --> E[返回给前端]
E --> F[前端直接GET URL]
F --> G[MinIO验签]
G --> H{签名对且未过期?}
H -->|是| I[返回文件]
H -->|否| J[403 Forbidden]

style B fill:#ef4444,color:#fff
style C fill:#8b5cf6,color:#fff
style H fill:#f59e0b,color:#fff
style I fill:#10b981,color:#fff
style J fill:#ef4444,color:#fff

SecretKey 从不出现在 URL 里,只用来算签名。MinIO 服务端用存的 SecretKey 同样算一遍,对比签名。


九、第 7 步:写 Controller

公共读方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
@RestController
@RequestMapping("/upload")
public class FileController {

@Autowired
private FileService fileService;

@Autowired
private JwtUtil jwtUtil;

@Autowired
private MinioConfig minioConfig;

@PostMapping
public Result<Map<String, Object>> uploadFile(
@RequestHeader("Authorization") String token,
@RequestParam("file") MultipartFile file,
@RequestParam("fileType") String fileType) {

String actualToken = token.replace("Bearer ", "");
Integer userId = jwtUtil.getUserIdFromToken(actualToken);

MediaFile mediaFile = fileService.uploadFile(file, userId, fileType);

// 公共读:直接拼URL
String fileUrl = minioConfig.getEndpoint() + "/" + minioConfig.getBucket() + "/" + mediaFile.getFilePath();

Map<String, Object> result = new HashMap<>();
result.put("id", mediaFile.getId());
result.put("fileName", mediaFile.getFileName());
result.put("fileSize", mediaFile.getFileSize());
result.put("fileType", mediaFile.getFileType());
result.put("uploadTime", mediaFile.getUploadTime());
result.put("url", fileUrl);
return Result.success(result);
}
}

私有 + 签名 URL 方式,把拼 URL 那行换成:

1
String fileUrl = fileService.generateSignedUrl(mediaFile.getFilePath(), 3600);  // 1小时过期

再额外加一个换 URL 的接口(签名 URL 过期后用):

1
2
3
4
5
6
7
8
9
10
11
@GetMapping("/url/{id}")
public Result<Map<String, String>> getSignedUrl(@PathVariable Integer id) {
MediaFile mediaFile = mediaFileMapper.selectById(id);
if (mediaFile == null) {
return Result.error("文件不存在");
}
String url = fileService.generateSignedUrl(mediaFile.getFilePath(), 3600);
Map<String, String> result = new HashMap<>();
result.put("url", url);
return Result.success(result);
}

十、Docker 部署 MinIO 的坑

这块文档不写,你会踩。

1. 密码至少 8 位

1
ERROR: MINIO_ROOT_PASSWORD cannot be shorter than 8 characters

MinIO 新版本强制密码至少 8 位。用 12345678,别用 123456。

2. 控制台端口必须指定

老版本 MinIO 控制台自动选端口,新版本要手动指定 --console-address ":9001",不然控制台起不来或者随机端口。

3. 数据持久化

一定要 -v minio-data:/data 挂载 volume。不挂的话,容器删了数据全没。生产环境用宿主机目录挂载:

1
-v /opt/minio/data:/data

4. 时区

容器默认 UTC,日志时间和宿主差 8 小时。加:

1
-e "TZ=Asia/Shanghai"

5. 生产环境不要用 root 用户跑

Docker 里 MinIO 默认用 root,生产建议加 --user 指定非 root 用户。但挂载的目录要有对应权限,不然启动报 permission denied。

6. 多节点部署

单机 MinIO 数据没冗余,磁盘坏就丢数据。生产环境要部署多节点多磁盘的分布式 MinIO(至少 4 块盘),这个复杂,看官方文档。所以我说生产环境直接用 OSS 省心。


十一、怎么测试

启动 MinIO

1
2
3
4
docker run -d --name minio -p 9000:9000 -p 9001:9001 \
-e "MINIO_ROOT_USER=admin" -e "MINIO_ROOT_PASSWORD=12345678" \
-v minio-data:/data \
minio/minio server /data --console-address ":9001"

浏览器开 http://localhost:9001,admin/12345678 登录,确认能进控制台。

启动 Spring Boot

application.yml 配好 MinIO 地址,启动应用。

Postman 测上传

POST http://localhost:10086/api/upload

  • Headers: Authorization: Bearer <token>
  • Body(form-data): file(选图片) + fileType=IMAGE

成功返回:

1
2
3
4
5
6
{
"code": 200,
"data": {
"url": "http://localhost:9000/friendchat/image/2026/08/02/uuid.jpg"
}
}

把 url 复制到浏览器(公共读方式),能打开图片就成功。

验证控制台

登录 MinIO 控制台 → Buckets → friendchat → Browse,能看到上传的文件,路径和数据库存的 objectName 一致。


十二、常见报错

1. io.minio.errors.ErrorResponseException: The Access Key Id you provided does not exist

原因:access-key 或 secret-key 写错了,和 Docker 启动时的不一致。
解决:对一下 MINIO_ROOT_USER / MINIO_ROOT_PASSWORD。

2. ErrorResponseException: The specified bucket does not exist

原因:Bucket 名写错,或者没创建。
解决:

  • 确认 yml 里 bucket 名对
  • 代码里 ensureBucketExists() 会自动创建,如果没这方法就去控制台手动建

3. ErrorResponseException: Access Denied

原因:

  • 私有 Bucket 没用签名 URL 直接访问
  • 或者签名 URL 过期了
    解决:用 getPresignedObjectUrl 生成签名 URL 访问。

4. 上传成功但 URL 访问 403

原因:Bucket 是私有的,但用了公共读的 URL 拼接方式。
解决:要么设公开读策略,要么改用签名 URL。

5. java.net.ConnectException: Connection refused

原因:MinIO 没启动,或者 endpoint 端口不对。
解决:

  • docker ps 确认 MinIO 容器在跑
  • 确认 endpoint 是 http://localhost:9000(API 端口,不是控制台的 9001)

6. OkHttp 版本冲突

原因:MinIO 依赖 OkHttp,项目里其他依赖也引入 OkHttp,版本打架。
解决:mvn dependency:tree | grep okhttp 看版本,手动在 pom 里指定 OkHttp 版本。

7. 上传大文件超时

原因:文件太大,网络慢,或者 MinIO 资源不够。
解决:

  • 用分片上传(MinIO SDK 有 uploadObject 支持大文件)
  • 调大 Spring Boot 的 multipart 限制:spring.servlet.multipart.max-file-size: 100MB

8. setBucketPolicy 报错

原因:策略 JSON 格式错,或者 Bucket 名没替换。
解决:对照 S3 策略 JSON 格式,确认 arn:aws:s3:::你的bucket名/*。


十三、MinIO vs 阿里云 OSS 怎么选

场景 推荐 理由
本地开发 MinIO 免费、不联网、速度快
公司内网应用 MinIO 数据不出内网,安全合规
面向公网的生产应用 OSS 省带宽、CDN 加速、高可靠
数据敏感、不能上云 MinIO 自己掌控数据
小团队、不想运维 OSS 云厂商管,省心
成本敏感、流量大 看情况 MinIO 省流量费但要服务器;OSS 流量费贵但有免费额度

我的建议: 本地开发用 MinIO,生产上 OSS。代码层抽象一下(StorageService 接口),切换零成本。不过如果是小项目直接用一种也行,别过度设计。


十四、整个流程串起来

公共读方式的完整流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
sequenceDiagram
participant 前端
participant 后端
participant MinIO
participant DB

Note over 前端,MinIO: 启动时
后端->>MinIO: bucketExists? 不存在则创建+设公开读策略

Note over 前端,MinIO: 上传文件
前端->>后端: POST /upload (token+文件)
后端->>后端: 校验文件类型/大小
后端->>MinIO: putObject(bucket, objectName, 流)
MinIO-->>后端: 上传成功
后端->>DB: insert media_files (存objectName)
后端->>后端: 拼URL: endpoint/bucket/objectName
后端-->>前端: 返回文件信息+URL

Note over 前端,MinIO: 访问文件(公共读)
前端->>MinIO: GET URL (直接访问,不经过后端)
MinIO-->>前端: 返回文件内容

私有 + 签名 URL 方式多了”生成签名 URL”和”过期换 URL”两步,和 OSS 那篇的时序图一样,这里不重复画了。


写在最后

MinIO 对接本身不难,API 比 OSS 还简单点(就一个 MinioClient 全搞定)。难点在于:

  1. Docker 部署的坑:密码长度、端口、数据持久化
  2. 8.x API 和老教程不兼容:网上 7.x 教程多,别抄错
  3. 访问方式选择:公共读简单但不安全,签名 URL 安全但要处理过期
  4. 生产可靠性:单机 MinIO 会丢数据,要分布式部署

这篇讲的是服务端直传(前端→后端→MinIO)。和 OSS 一样,大文件建议改客户端直传(STS 临时凭证),后端只发凭证不传文件。

MinIO 和 OSS 的对接套路几乎一样,会一个就会另一个。区别只是:

  • MinIO:自建,要运维,免费
  • OSS:托管,省心,按量付费

代码层面,MinIO SDK 和 OSS SDK API 不同,但抽象成 StorageService 接口后,实现类换一下就行。我项目就是从 MinIO 迁到 OSS,代码改动主要在 FileServiceImpl,上层 Controller 几乎不动。

—— 完