1. 背景:为什么需要对象存储?
传统文件上传方案是将文件存入服务器本地磁盘或 NFS 共享目录,这在单机时代勉强可行,但到了微服务架构下暴露出三个核心痛点:
- 存储耦合:文件与业务容器绑定,扩容/迁移时文件丢失或路径漂移
- 容量瓶颈:单机磁盘有限,清理不及时导致磁盘写满,应用直接宕机
- 访问链路:静态文件经过应用服务器转发,浪费 Tomcat 线程处理 IO
对象存储(如 MinIO、AWS S3、阿里 OSS)将文件托管到独立存储层,应用只需存储并返回访问 URL,实现计算与存储分离。本文以 MinIO(兼容 S3 协议)为例,完整走通 Spring Boot 集成链路。
2. MinIO 部署与基础配置
生产环境建议使用 Docker 独立部署 MinIO。以下是一个生产级启动配置:
# docker-compose.yml
version: "3.8"
services:
minio:
image: minio/minio:RELEASE.2026-09-01T00-00-00Z
container_name: minio-server
environment:
MINIO_ROOT_USER: admin
MINIO_ROOT_PASSWORD: <强密码, 至少8位>
command: server /data --console-address ":9001"
ports:
- "9000:9000"
- "9001:9001"
volumes:
- minio-data:/data
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
interval: 30s
timeout: 10s
retries: 3
volumes:
minio-data:
driver: local
注意点:MinIO 的 Web 控制台(9001 端口)用于管理 Bucket 和 Access Key,生产环境应通过反向代理加 HTTPS 暴露,或仅允许内网访问。
启动后登录控制台创建两个元素:
- Bucket:
app-files(存储业务上传文件) - Access Key:一对
ACCESS_KEY / SECRET_KEY,用于程序 sdk 调用
3. Spring Boot 集成 MinIO 客户端
3.1 引入依赖
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.12</version>
</dependency>
<!-- 可选: S3 协议兼容的 AWS SDK 包装 -->
<dependency>
<groupId>software.amazon.awssdk</groupId>
<artifactId>s3</artifactId>
<version>2.27.21</version>
</dependency>
3.2 配置类
@Configuration
public class MinioConfig {
@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;
@Bean
public MinioClient minioClient() {
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
}
@Bean
public String defaultBucket() {
return bucket;
}
}
3.3 application.yml 配置
minio:
endpoint: http://192.168.1.100:9000
access-key: 你的ACCESS_KEY
secret-key: 你的SECRET_KEY
bucket: app-files
⚠️ 密码和 Access Key 绝对不要明文提交到 Git。推荐的做法是:
- 本地开发用
application-local.yml(加.gitignore) - 测试/生产环境通过环境变量注入:
MINIO_ACCESS_KEY=${MINIO_AK} - 或接入配置中心(Nacos/Apollo)远程管理
4. 文件上传接口实战
4.1 基础上传(单文件)
@RestController
@RequestMapping("/api/file")
public class FileController {
@Autowired
private MinioClient minioClient;
@Value("${minio.bucket}")
private String bucket;
@PostMapping("/upload")
public Result<FileVo> upload(@RequestParam("file") MultipartFile file) {
// 1. 文件校验
String originalName = file.getOriginalFilename();
if (originalName == null || originalName.isBlank()) {
return Result.error("文件名为空");
}
// 限制大小 50MB
if (file.getSize() > 50 * 1024 * 1024) {
return Result.error("文件不能超过 50MB");
}
// 2. 生成唯一文件名,防重名
String extension = originalName.substring(originalName.lastIndexOf("."));
String objectName = UUID.randomUUID().toString().replace("-", "") + extension;
// 3. 上传到 MinIO
try {
PutObjectArgs args = PutObjectArgs.builder()
.bucket(bucket)
.object(objectName)
.stream(file.getInputStream(), file.getSize(), -1)
.contentType(file.getContentType())
.build();
minioClient.putObject(args);
} catch (Exception e) {
log.error("上传 MinIO 失败: {}", e.getMessage());
return Result.error("文件上传失败");
}
// 4. 返回访问 URL
String url = getFileUrl(objectName);
return Result.ok(new FileVo(objectName, url, originalName, file.getSize()));
}
private String getFileUrl(String objectName) {
// 生产环境优先用带签名的临时 URL 做访问控制
try {
GetPresignedObjectUrlArgs args = GetPresignedObjectUrlArgs.builder()
.bucket(bucket)
.object(objectName)
.method(Method.GET)
.expiry(7, TimeUnit.DAYS)
.build();
return minioClient.getPresignedObjectUrl(args);
} catch (Exception e) {
// 降级为直接拼接(需要 Bucket 公开读,不推荐)
return String.format("%s/%s/%s", endpoint, bucket, objectName);
}
}
}
4.2 分片上传(大文件支持)
超过 100MB 的文件不适合一次性上传,要使用分片(Multipart Upload):
@PostMapping("/upload/multipart")
public Result<String> initiateMultipartUpload(
@RequestParam("filename") String filename,
@RequestParam("partCount") int partCount) {
String extension = filename.substring(filename.lastIndexOf("."));
String objectName = UUID.randomUUID().toString().replace("-", "") + extension;
try {
// 初始化分片上传,返回 uploadId
CreateMultipartUploadResponse response = minioClient.createMultipartUpload(
CreateMultipartUploadArgs.builder()
.bucket(bucket)
.object(objectName)
.build());
String uploadId = response.result().uploadId();
// 生成每个分片的预签名上传 URL
List<String> partUrls = new ArrayList<>();
for (int i = 1; i <= partCount; i++) {
Map<String, String> headers = new HashMap<>();
headers.put("Content-Type", "application/octet-stream");
String presignedUrl = minioClient.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder()
.bucket(bucket)
.object(objectName)
.method(Method.PUT)
.extraQueryParams(Map.of("uploadId", uploadId, "partNumber", String.valueOf(i)))
.expiry(1, TimeUnit.HOURS)
.build());
partUrls.add(presignedUrl);
}
return Result.ok(Map.of(
"uploadId", uploadId,
"objectName", objectName,
"partUrls", partUrls
));
} catch (Exception e) {
log.error("初始化分片上传失败", e);
return Result.error("初始化失败");
}
}
// 前端拿到 partUrls 后,逐个上传分片,最后调用合并接口
@PostMapping("/upload/complete")
public Result<FileVo> completeMultipartUpload(
@RequestParam("objectName") String objectName,
@RequestParam("uploadId") String uploadId,
@RequestBody List<PartUploadResult> parts) {
try {
minioClient.completeMultipartUpload(
CompleteMultipartUploadArgs.builder()
.bucket(bucket)
.object(objectName)
.uploadId(uploadId)
.parts(parts.stream()
.map(p -> new Part(p.getPartNumber(), p.getEtag()))
.toList())
.build());
return Result.ok(new FileVo(objectName, getFileUrl(objectName)));
} catch (Exception e) {
log.error("合并分片失败", e);
return Result.error("合并失败");
}
}
5. 文件访问与生命周期管理
5.1 预签名 URL 鉴权
生产环境强烈不建议将 Bucket 设为公开读。正确的做法是:
- Bucket 设置为 私有
- 每次客户端需要查看文件时,由后端生成 预签名临时 URL(7天或按需过期)
- URL 自带签名参数,MinIO 验证通过后放行,用户无法穷举他人的文件
5.2 定时清理过期文件
上传的临时文件(如用户头像审核未通过、过期报表)需要定时清理。用 Spring Boot 的 @Scheduled 实现:
@Service
@Slf4j
public class FileCleanupJob {
@Autowired
private MinioClient minioClient;
@Value("${minio.bucket}")
private String bucket;
@Value("${minio.cleanup-days:90}")
private int cleanupDays;
/**
* 每天凌晨 2 点执行,清理超过 N 天的文件
*/
@Scheduled(cron = "0 0 2 * * ?")
public void cleanupExpiredFiles() {
Calendar cal = Calendar.getInstance();
cal.add(Calendar.DAY_OF_YEAR, -cleanupDays);
Date threshold = cal.getTime();
try {
Iterable<Result<Item>> results = minioClient.listObjects(
ListObjectsArgs.builder().bucket(bucket).build());
int removed = 0;
for (Result<Item> result : results) {
Item item = result.get();
Date lastModified = item.lastModified();
if (lastModified != null && lastModified.before(threshold)) {
minioClient.removeObject(
RemoveObjectArgs.builder()
.bucket(bucket)
.object(item.objectName())
.build());
removed++;
}
}
log.info("定时清理完成,共删除 {} 个过期文件", removed);
} catch (Exception e) {
log.error("定时清理任务异常", e);
}
}
}
6. 生产踩坑记录
6.1 临时目录磁盘写满
现象:Spring Boot 默认将 MultipartFile 写入 /tmp,文件上传量大时 /tmp 被写满,请求报 IOException: No space left on device。
解决:配置 spring.servlet.multipart.location=/var/data/tmp,将该路径挂载到大容量磁盘,并定期清理。
6.2 MinIO 客户端连接池耗尽
现象:高并发上传时偶发 Timed out waiting for connection from pool。
解决:MinIO Java SDK 默认使用 OkHttpClient,需自定义连接池:
@Bean
public MinioClient minioClient() {
OkHttpClient httpClient = new OkHttpClient.Builder()
.connectionPool(new ConnectionPool(50, 5, TimeUnit.MINUTES))
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.build();
return MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.httpClient(httpClient)
.build();
}
6.3 反向代理上传大小限制
如果前端通过 Nginx/Caddy 反向代理转发上传请求,默认限制 1MB 请求体,需要额外配置:
# Caddyfile 示例
app.example.com {
reverse_proxy localhost:8080
# 上传文件大小限制上调到 70MB
request_body /api/file/* {
max_size 70MB
}
}
注意:Nginx 还需要调整 client_max_body_size 和 proxy_request_buffering off。
7. 最佳实践总结
- 存储分离:业务容器不存文件,全部委托对象存储
- 访问控制:Bucket 私有 + 预签名 URL,不要公开读
- 大文件策略:超过 100MB 必须分片上传,避免内存 OOM
- 生命周期:定时清理过期文件,设置文件保留策略(ILM)
- 监控告警:监控 Bucket 存储量、上传失败率,磁盘占满前告警