这篇是给 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)。
这篇讲两种对接方式:
公共读 Bucket (简单,文件 URL 直接可访问,适合开发环境)
私有 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;@Data @Configuration @ConfigurationProperties(prefix = "minio") public class MinioConfig { private String endpoint; private String accessKey; private String secretKey; private String bucket; @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 @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;@ConfigurationProperties(prefix = "minio") public class MinioConfig { private String endpoint; private String accessKey; private String secretKey; private String bucket; }
对比:
对比项
@Value
@ConfigurationProperties
读取方式
一个个 @Value("${xxx}")
设个 prefix,字段自动映射
适合场景
只读 1-2 个值
读一组相关配置(像 MinIO 这 4 个)
松散绑定
支持
支持
类型转换
基本支持
更强(支持 Map、List、嵌套对象)
校验
不支持
配合 @Validated 可校验
MinIO 这 4 个属性是一组的,用 @ConfigurationProperties 更清爽 ,不用写 4 行 @Value。
@Data 和 setter 注入 @ConfigurationProperties 把 yml 值注入字段,靠的是 setter 方法 。流程是:
Spring 启动,扫描到 MinioConfig 有 @ConfigurationProperties(prefix = "minio")
Spring 从 yml 读到 minio.endpoint = http://localhost:9000
Spring 调用 minioConfig.setEndpoint("http://localhost:9000") 把值塞进去
其他 3 个字段同理
但 setter 方法谁写的?Lombok 的 @Data 注解自动生成 。@Data 会给所有字段生成 getter 和 setter,所以不用手写:
1 2 3 4 5 6 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; }
没有 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 名用)
几个要点:
MinioConfig 本身也是个 Bean :它有 @Configuration 注解,会被 Spring 管理,所以 FileServiceImpl 里可以 @Autowired MinioConfig 拿到它(用来读 bucket 名)
MinioClient 是单例 :整个应用共用一个,线程安全。别每次上传都 new MinioClient(),会创建一堆 HTTP 连接,拖垮性能
启动时就把值注入好了 :不是用到才读 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; 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) { String originalFilename = file.getOriginalFilename(); String extension = originalFilename.substring(originalFilename.lastIndexOf("." ) + 1 ).toLowerCase(); validateFileType(extension, fileType); validateFileSize(file.getSize(), fileType); ensureBucketExists(); 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; 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()); } 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; } 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()); } 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()); } } }
几个关键点必须讲 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) ; 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()); } }
私有方式的关键差异
不要设公开读策略 :ensureBucketExists() 里把 setBucketPolicy 那段删掉,Bucket 默认就是私有
数据库存 objectName ,不存完整 URL(因为签名 URL 会过期)
访问时调 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); 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 );
再额外加一个换 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 小时。加:
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 全搞定)。难点在于:
Docker 部署的坑 :密码长度、端口、数据持久化
8.x API 和老教程不兼容 :网上 7.x 教程多,别抄错
访问方式选择 :公共读简单但不安全,签名 URL 安全但要处理过期
生产可靠性 :单机 MinIO 会丢数据,要分布式部署
这篇讲的是服务端直传 (前端→后端→MinIO)。和 OSS 一样,大文件建议改客户端直传(STS 临时凭证),后端只发凭证不传文件。
MinIO 和 OSS 的对接套路几乎一样,会一个就会另一个。区别只是:
MinIO:自建,要运维,免费
OSS:托管,省心,按量付费
代码层面,MinIO SDK 和 OSS SDK API 不同,但抽象成 StorageService 接口后,实现类换一下就行。我项目就是从 MinIO 迁到 OSS,代码改动主要在 FileServiceImpl,上层 Controller 几乎不动。
—— 完