分类:后端开发 · 作者:管理员 · 发布于 2026-09-23 · 阅读 0 · 标签:Spring Boot / MinIO / 对象存储 / 文件上传 / 预签名URL

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 存储量、上传失败率,磁盘占满前告警