基于Spring Boot的Minio图片定时清理实践总结

文章目录

  • 前言
  • 一、依赖坐标
  • 二、删除方法
    • 1、工具类:MinioUtil
      • [1.1 方法介绍](#1.1 方法介绍)
      • [1.2 方法代码](#1.2 方法代码)
      • [1.3 完整代码](#1.3 完整代码)
        • [1.3.1 性能陷阱](#1.3.1 性能陷阱)
        • [1.3.2 幂等性设计](#1.3.2 幂等性设计)
        • [1.3.3 常见错误码处理](#1.3.3 常见错误码处理)
    • 2、测试类:MinioTest
  • 三、定时配置
    • 1.注解实现
      • [1.1 在主启动类添加@EnableScheduling开启定时任务支持](#1.1 在主启动类添加@EnableScheduling开启定时任务支持)
      • [1.2 创建定时任务类](#1.2 创建定时任务类)
      • [1.3 @Scheduled 参数详解](#1.3 @Scheduled 参数详解)
      • [1.4 Cron表达式详解](#1.4 Cron表达式详解)
        • [1.4.1 常见格式](#1.4.1 常见格式)
        • [1.4.2 字段说明(以 6 位格式为例)](#1.4.2 字段说明(以 6 位格式为例))
        • [1.4.3 特殊字符详解](#1.4.3 特殊字符详解)
        • [1.4.4 常用示例](#1.4.4 常用示例)
        • [1.4.5 在线验证工具](#1.4.5 在线验证工具)
    • 2.配置线程池
      • [2.1 线程池参数详解](#2.1 线程池参数详解)
      • [2.2 线程池任务拒绝策略](#2.2 线程池任务拒绝策略)
    • 3.异步执行
      • 3.1异步线程池配置
      • [3.2 异步线程池配置建议](#3.2 异步线程池配置建议)
        • [3.2.1 队列选择策略](#3.2.1 队列选择策略)
        • [3.2.2 拒绝策略选择](#3.2.2 拒绝策略选择)
        • [3.2.3 动态配置](#3.2.3 动态配置)
      • [3.3 应用](#3.3 应用)
    • 4.扩展
      • [4.1 配置文件优化](#4.1 配置文件优化)
        • [4.1.1 添加配置信息](#4.1.1 添加配置信息)
        • [4.1.2 配置类映射](#4.1.2 配置类映射)
        • [4.1.3 修改 MinioUtil](#4.1.3 修改 MinioUtil)
        • [4.1.4 修改 MinioCleanTask](#4.1.4 修改 MinioCleanTask)
        • [4.1.5 测试](#4.1.5 测试)
      • [4.2 接口方法](#4.2 接口方法)
        • [4.2.1 建表和插入数据](#4.2.1 建表和插入数据)
        • [4.2.2 创建实体](#4.2.2 创建实体)
        • [4.2.3 Mapper接口](#4.2.3 Mapper接口)

前言

在项目开发中,我们使用Minio作为图片存储服务。随着时间推移,存储的图片文件越来越多,其中大量历史图片已不再需要。为了优化存储空间并降低成本,需要实现一个定时清理功能,定期删除指定日期前的图片文件。

一、依赖坐标

核心依赖:Minio 和 定时任务(SpringBoot的起步依赖就有)

xml 复制代码
<!--minio-->
<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.1</version>
</dependency>

依赖说明

组件 描述
Minio SDK 提供与 Minio 服务交互的 API,支持对象存储操作(如上传、下载文件)
Spring Boot Starter 内置定时任务支持(无需额外依赖),简化任务调度和后台处理

二、删除方法

1、工具类:MinioUtil

1.1 方法介绍

方法签名 作用描述 返回值类型 幂等性保证
deleteDateFoldersBefore(LocalDate endExclusive) 删除指定日期区间 [retainSince, endExclusive) 内的所有日期目录 实际删除的对象数量 多次调用结果一致
deleteSingleFolder(String prefix) 删除单个前缀路径下的全部对象 本次删除的对象数量 同上
  • 文件格式为: /bucketName/yyyy-MM-dd/xxx.jepg

1.2 方法代码

参数说明

参数 类型 说明
endExclusive LocalDate 截止日期(不含此日期)
retainSince LocalDate 保留起始日期(最早不删除的日期)
prefix String Minio对象前缀(即目录路径)
java 复制代码
private String bucketName="";//自定义就好
private LocalDate retainSince = LocalDate.of(2025, 6, 1);//用于判断的起始时
/**
 * 删除早于指定日期的所有日期目录(yyyy-MM-dd/)
 *
 * @param endExclusive 截止日期(不含)
 * @return 实际删除的对象总数
 */
public int deleteDateFoldersBefore(LocalDate endExclusive) {
    if (endExclusive == null) {
        throw new IllegalArgumentException("指定日期不能为空");
    }

    LocalDate today = LocalDate.now();
    if (!endExclusive.isBefore(today)) {
        return 0;
    }
    int totalDeleted = 0;
    // 从 endExclusive-1 天开始往前删
    for (LocalDate d = endExclusive.minusDays(1); !d.isBefore(retainSince); d = d.minusDays(1)) {
        totalDeleted += deleteSingleFolder(d.format(DateTimeFormatter.ISO_LOCAL_DATE) + "/");
    }
    return totalDeleted;
}

/**
 * 删除单个目录(前缀)下的全部对象
 */
private int deleteSingleFolder(String prefix) {
    try {
        List<DeleteObject> objects = new ArrayList<>();
        minioClient.listObjects(ListObjectsArgs.builder()
                        .bucket(bucketName)
                        .prefix(prefix)
                        .recursive(true)
                        .build())
                .forEach(r -> {
                    try {
                        objects.add(new DeleteObject(r.get().objectName()));
                    } catch (Exception ignored) {
                        log.warn("文件名获取失败");
                    }
                });
        if (objects.isEmpty()) {
            return 0;
        }
        Iterable<Result<DeleteError>> results = minioClient.removeObjects(
                RemoveObjectsArgs.builder()
                        .bucket(bucketName)
                        .objects(objects)
                        .build());


        for (Result<DeleteError> res : results) {
            DeleteError deleteError = res.get();// 无异常即成功
        }
        return objects.size();
    } catch (Exception e) {
        log.warn("删除目录 {} 失败: {}", prefix, e.toString());
        return 0;
    }
}

1.3 完整代码

java 复制代码
import cn.hutool.core.lang.UUID;
import com.fc.properties.MinioProperties;
import io.minio.*;
import io.minio.errors.ErrorResponseException;
import io.minio.http.Method;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.compress.utils.IOUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;

import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * 文件操作工具类
 */
@RequiredArgsConstructor
@Component
@Slf4j
public class MinioUtil {

    private final MinioProperties minioProperties;
    private MinioClient minioClient;
    private String bucketName;

    private LocalDate retainSince = LocalDate.of(2025, 6, 1);


    // 初始化 Minio 客户端
    @PostConstruct
    public void init() {
        try {
            //创建客户端
            minioClient = MinioClient.builder()
                    .endpoint(minioProperties.getUrl())
                    .credentials(minioProperties.getUsername(), minioProperties.getPassword())
                    .build();
            bucketName = minioProperties.getBucketName();

            // 检查桶是否存在,不存在则创建
            boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
            if (!bucketExists) {
                minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
            }
        } catch (Exception e) {
            throw new RuntimeException("Minio 初始化失败", e);
        }
    }

    /*
     * 上传文件
     */
    public String uploadFile(MultipartFile file, String extension) {
        if (file == null || file.isEmpty()) {
            throw new RuntimeException("上传文件不能为空");
        }

        try {
            // 生成唯一文件名
            String uniqueFilename = generateUniqueFilename(extension);

            // 上传文件
            minioClient.putObject(PutObjectArgs.builder()
                    .bucket(bucketName)
                    .object(uniqueFilename)
                    .stream(file.getInputStream(), file.getSize(), -1)
                    .contentType(file.getContentType())
                    .build());

            return "/" + bucketName + "/" + uniqueFilename;
        } catch (Exception e) {
            throw new RuntimeException("文件上传失败", e);
        }
    }


    /**
     * 上传已处理的图片字节数组到 MinIO
     *
     * @param imageData   处理后的图片字节数组
     * @param extension   文件扩展名(如 ".jpg", ".png")
     * @param contentType 文件 MIME 类型(如 "image/jpeg", "image/png")
     * @return MinIO 中的文件路径(格式:/bucketName/yyyy-MM-dd/uuid.extension)
     */
    public String uploadFileByte(byte[] imageData, String extension, String contentType) {
        if (imageData == null || imageData.length == 0) {
            throw new RuntimeException("上传的图片数据不能为空");
        }
        if (extension == null || extension.isEmpty()) {
            throw new IllegalArgumentException("文件扩展名不能为空");
        }
        if (contentType == null || contentType.isEmpty()) {
            throw new IllegalArgumentException("文件 MIME 类型不能为空");
        }

        try {
            // 生成唯一文件名
            String uniqueFilename = generateUniqueFilename(extension);

            // 上传到 MinIO
            minioClient.putObject(
                    PutObjectArgs.builder()
                            .bucket(bucketName)
                            .object(uniqueFilename)
                            .stream(new ByteArrayInputStream(imageData), imageData.length, -1)
                            .contentType(contentType)
                            .build()
            );

            return "/" + bucketName + "/" + uniqueFilename;
        } catch (Exception e) {
            throw new RuntimeException("处理后的图片上传失败", e);
        }
    }

    /**
     * 上传本地生成的 Excel 临时文件到 MinIO
     *
     * @param localFile 本地临时文件路径
     * @param extension 扩展名
     * @return MinIO 存储路径,格式:/bucketName/yyyy-MM-dd/targetName
     */
    public String uploadLocalExcel(Path localFile, String extension) {
        if (localFile == null || !Files.exists(localFile)) {
            throw new RuntimeException("本地文件不存在");
        }
        try (InputStream in = Files.newInputStream(localFile)) {
            String objectKey = generateUniqueFilename(extension); // 保留日期目录
            minioClient.putObject(
                    PutObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectKey)
                            .stream(in, Files.size(localFile), -1)
                            .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
                            .build());
            return "/" + bucketName + "/" + objectKey;
        } catch (Exception e) {
            throw new RuntimeException("Excel 上传失败", e);
        }
    }

    /*
     * 根据URL下载文件
     */
    public void downloadFile(HttpServletResponse response, String fileUrl) {
        if (fileUrl == null || !fileUrl.contains(bucketName + "/")) {
            throw new IllegalArgumentException("无效的文件URL");
        }

        try {
            // 从URL中提取对象路径和文件名
            String objectUrl = fileUrl.split(bucketName + "/")[1];
            String fileName = objectUrl.substring(objectUrl.lastIndexOf("/") + 1);

            // 设置响应头
            response.setContentType("application/octet-stream");
            String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
            response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedFileName + "\"");

            // 下载文件
            try (InputStream inputStream = minioClient.getObject(GetObjectArgs.builder()
                    .bucket(bucketName)
                    .object(objectUrl)
                    .build());
                 OutputStream outputStream = response.getOutputStream()) {

                // 用IOUtils.copy高效拷贝(内部缓冲区默认8KB)
                IOUtils.copy(inputStream, outputStream);
            }
        } catch (Exception e) {
            throw new RuntimeException("文件下载失败", e);
        }
    }

    /**
     * 根据 MinIO 路径生成带签名的直链
     *
     * @param objectUrl 已存在的 MinIO 路径(/bucketName/...)
     * @param minutes   链接有效期(分钟)
     * @return 可直接访问的 HTTPS 下载地址
     */
    public String parseGetUrl(String objectUrl, int minutes) {
        if (objectUrl == null || !objectUrl.startsWith("/" + bucketName + "/")) {
            throw new IllegalArgumentException("非法的 objectUrl");
        }
        String objectKey = objectUrl.substring(("/" + bucketName + "/").length());
        try {
            return minioClient.getPresignedObjectUrl(
                    GetPresignedObjectUrlArgs.builder()
                            .method(Method.GET)
                            .bucket(bucketName)
                            .object(objectKey)
                            .expiry(minutes, TimeUnit.MINUTES)
                            .build());
        } catch (Exception e) {
            throw new RuntimeException("生成直链失败", e);
        }
    }

    /*
     * 根据URL删除文件
     */
    public void deleteFile(String fileUrl) {
        try {
            // 从URL中提取对象路径
            String objectUrl = fileUrl.split(bucketName + "/")[1];
            minioClient.removeObject(RemoveObjectArgs.builder()
                    .bucket(bucketName)
                    .object(objectUrl)
                    .build());
        } catch (Exception e) {
            throw new RuntimeException("文件删除失败", e);
        }
    }

    /*
     * 检查文件是否存在
     */
    public boolean fileExists(String fileUrl) {
        if (fileUrl == null || !fileUrl.contains(bucketName + "/")) {
            return false;
        }

        try {
            String objectUrl = fileUrl.split(bucketName + "/")[1];
            minioClient.statObject(StatObjectArgs.builder()
                    .bucket(bucketName)
                    .object(objectUrl)
                    .build());
            return true;
        } catch (Exception e) {
            if (e instanceof ErrorResponseException && ((ErrorResponseException) e).errorResponse().code().equals("NoSuchKey")) {
                return false;
            }
            throw new RuntimeException("检查文件存在失败", e);
        }
    }


    /**
     * 生成唯一文件名(带日期路径 + UUID)
     */
    private String generateUniqueFilename(String extension) {
        String dateFormat = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        String uuid = UUID.randomUUID().toString().replace("-", ""); // 去掉 UUID 中的 "-"
        return dateFormat + "/" + uuid + extension;
    }

    /**
     * 删除早于指定日期的所有日期目录(yyyy-MM-dd/)
     *
     * @param endExclusive 截止日期(不含)
     * @return 实际删除的对象总数
     */
    public int deleteDateFoldersBefore(LocalDate endExclusive) {
        if (endExclusive == null) {
            throw new IllegalArgumentException("指定日期不能为空");
        }

        LocalDate today = LocalDate.now();
        if (!endExclusive.isBefore(today)) {
            return 0;
        }
        int totalDeleted = 0;
        // 从 endExclusive-1 天开始往前删
        for (LocalDate d = endExclusive.minusDays(1); !d.isBefore(retainSince); d = d.minusDays(1)) {
            totalDeleted += deleteSingleFolder(d.format(DateTimeFormatter.ISO_LOCAL_DATE) + "/");
        }
        return totalDeleted;
    }

    /**
     * 删除单个目录(前缀)下的全部对象
     */
    private int deleteSingleFolder(String prefix) {
        try {
            List<DeleteObject> objects = new ArrayList<>();
            minioClient.listObjects(ListObjectsArgs.builder()
                            .bucket(bucketName)
                            .prefix(prefix)
                            .recursive(true)
                            .build())
                    .forEach(r -> {
                        try {
                            objects.add(new DeleteObject(r.get().objectName()));
                        } catch (Exception ignored) {
                            log.warn("文件名获取失败");
                        }
                    });
            if (objects.isEmpty()) {
                return 0;
            }
            Iterable<Result<DeleteError>> results = minioClient.removeObjects(
                    RemoveObjectsArgs.builder()
                            .bucket(bucketName)
                            .objects(objects)
                            .build());


            for (Result<DeleteError> res : results) {
                DeleteError deleteError = res.get();// 无异常即成功
            }
            return objects.size();
        } catch (Exception e) {
            log.warn("删除目录 {} 失败: {}", prefix, e.toString());
            return 0;
        }
    }
}
1.3.1 性能陷阱

迭代器懒加载机制

  • listObjects 返回分页迭代器(每 1000 条自动分页),无需手动处理 marker

  • removeObjects 返回 Iterable<Result<DeleteError>>,需遍历结果才能触发 HTTP 请求(懒执行)

  • 海量对象场景:添加 .maxKeys(batchSize) 限制单次返回数量,避免 OOM

    java 复制代码
    // 示例:限制批大小
    minioClient.listObjects(ListObjectsArgs.builder().bucket("my-bucket").maxKeys(500).build());

异常隔离

  • r.get() 可能抛出 InsufficientDataException/InternalException 等异常
  • 处理策略:捕获异常后仅记录日志,确保当前批次继续执行

批大小限制

  • MinIO 服务端单次请求上限:1000 条对象
  • 超限错误:ErrorResponseException: DeleteObjects max keys 1000
1.3.2 幂等性设计
  • 重复删除同一路径:静默忽略,不报错
  • 已删除对象:自动跳过处理
1.3.3 常见错误码处理
错误码 原因 解决方案
NoSuchBucket 桶名错误 启动时校验桶是否存在
AccessDenied AK/SK 权限不足 补充 s3:DeleteObject 权限
SlowDown 服务端限流 指数退避重试策略

2、测试类:MinioTest

完整代码

java 复制代码
import com.fc.utils.MinioUtil;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDate;
@SpringBootTest
public class MinioTest {

    @Autowired
    private MinioUtil minioUtil;

    @Test
    public void testDelete() {
        int count = minioUtil.deleteDateFoldersBefore(LocalDate.of(2025,  8,2));//这里的时间可以自定义,注意测试之前要先确定存在文件
        System.out.println(count);
    }
}

三、定时配置

1.注解实现

1.1 在主启动类添加@EnableScheduling开启定时任务支持

java 复制代码
@SpringBootApplication
@EnableTransactionManagement //开启注解方式的事务管理
@EnableScheduling//开启定时i任务
@EnableCaching
public class VehicleApplication {
    public static void main(String[] args) {
        SpringApplication.run(VehicleApplication.class, args);
    }
}

1.2 创建定时任务类

使用@Component注册Bean,在方法上添加@Scheduled

java 复制代码
import com.fc.utils.MinioUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
@Component//注册Bean
@RequiredArgsConstructor
@Slf4j
public class MinioCleanTask {

    private final MinioUtil minioUtil;

    /**
     * 定时清理 MinIO 中早于当前日期的日期目录(格式:yyyy-MM-dd/)
     * 执行时间:每月1号凌晨3点
     */
    @Scheduled(cron = "0 0 3 1 * ?")
    public void minioClean() {
        try {
            log.info("MinIO 清理任务开始执行...");
            // 明确语义:删除早于今天的所有日期目录(不含今天)
            LocalDate today = LocalDate.now();
            log.info("当前日期:{}, 开始清理早于该日期的目录", today);
            int deleteCount = minioUtil.deleteDateFoldersBefore(today);
            log.info("MinIO 清理任务执行完成,共删除 {} 张图片", deleteCount);
        } catch (Exception e) {
            // 防止定时任务因异常停止
            log.error("MinIO 清理任务执行失败", e);
        }
    }
}

启动类需要能扫描到定时任务类,否则定时任务启动不起来(启动类位置位于定时任务类之上或者通过注解指定扫描包的位置)

1.3 @Scheduled 参数详解

参数 作用 特点 示例
fixedRate 上一次开始时间到下一次开始时间的间隔(毫秒) 无视任务执行时长 @Scheduled(fixedRate = 3000) // 每3秒执行一次
fixedDelay 上一次结束时间到下一次开始时间的间隔(毫秒) 等待上次任务完成 @Scheduled(fixedDelay = 4000) // 任务结束后4秒再执行
initialDelay 首次任务延迟时间(需配合fixedRate/fixedDelay) 仅首次生效 @Scheduled(initialDelay = 10000, fixedRate = 5000) // 首次延迟10秒,之后每5秒执行
cron 通过表达式定义复杂时间规则 支持灵活的时间组合 "0 15 10 * * ?" // 每天10:15执行

1.4 Cron表达式详解

Cron 表达式由 5-7 个字段组成,每个字段代表一个时间单位,字段之间用空格分隔

1.4.1 常见格式
字段数 格式 使用场景
5 位 分 时 日 月 周 Linux Crontab
6 位 秒 分 时 日 月 周 Spring/Quartz
7 位 秒 分 时 日 月 周 年 AWS EventBridge
1.4.2 字段说明(以 6 位格式为例)
位置 字段 取值范围 允许的特殊字符
1 0-59 , - * /
2 0-59 , - * /
3 小时 0-23 , - * /
4 日期 1-31 , - * / ? L W
5 月份 1-12 或 JAN-DEC , - * /
6 星期 0-7 或 SUN-SAT(0 和 7 均为星期日) , - * / ? L #
7 年份(可选) 1970-2099 , - * /
1.4.3 特殊字符详解
字符 含义 示例说明
* 任意值 在"小时"字段表示"每小时"
? 不指定值 用于"日期"和"星期"字段互斥使用
- 范围 9-17 表示从 9 到 17
, 枚举值 1,3,5 表示 1、3、5
/ 增量 0/15 表示从 0 开始,每 15 秒一次
L Last(最后) L 在"日期"中表示"当月最后一天"
W 工作日 15W 表示"离 15 号最近的工作日"
# 第几个星期几 6#3 表示"当月第 3 个星期五"
1.4.4 常用示例

基础定时任务

执行频率 Cron表达式 说明
每分钟执行一次 0 * * * * ? 每分钟的第0秒触发
每5分钟执行一次 0 */5 * * * ? 每隔5分钟的第0秒触发
每小时第30分钟执行 0 30 * * * ? 每小时的30分0秒触发
每天凌晨1点执行 0 0 1 * * ? 每天1:00:00触发
每月1号凌晨2点执行 0 0 2 1 * ? 每月1日的2:00:00触发
每周六凌晨3点执行 0 0 3 * * 6 每周六的3:00:00触发(数字6表示周六)
每周六凌晨3点执行 0 0 3 * * SAT 每周六的3:00:00触发(SAT为英文缩写)

高级用法

需求描述 Cron表达式 说明
每月最后一天23:59执行 0 59 23 L * ? L表示月份的最后一天
每月15号或最后一天执行 0 0 0 15,L * ? 逗号分隔多个日期
每月第2个星期一 0 0 0 ? * 2#2 2#2表示第2周的周一
工作日(周一到周五)9点执行 0 0 9 * * MON-FRI MON-FRI定义范围
每10秒执行一次 */10 * * * * ? */10表示秒字段的步长
1.4.5 在线验证工具

推荐使用以下工具测试 Cron 表达式:

2.配置线程池

当系统中有多个定时任务时,默认情况下它们共享同一个单线程。如果某个任务执行时间过长,会导致其他任务延迟执行。通过配置线程池可以:

  • 实现任务隔离
  • 提高任务并行性
  • 避免任务阻塞
  • 提供更好的资源控制
java 复制代码
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;

@Configuration
@Slf4j
public class SchedulerConfiguration {

    /**
     * 配置定时任务线程池
     *
     * @return 任务调度器实例
     */
    @Bean
    public TaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        // 核心参数配置
        scheduler.setPoolSize(3); // 线程池大小,建议设置为任务数+2
        scheduler.setThreadNamePrefix("minio-scheduler-"); // 线程名前缀
        scheduler.setAwaitTerminationSeconds(60); // 关闭时等待任务完成的时间(秒)
        scheduler.setWaitForTasksToCompleteOnShutdown(true); // 关闭时是否等待任务完成
        scheduler.setRemoveOnCancelPolicy(true); // 取消任务时是否立即移除
        scheduler.setErrorHandler(throwable ->
                log.error("定时任务执行异常", throwable)); // 异常处理器
        // 任务拒绝策略配置
        scheduler.setRejectedExecutionHandler((r, executor) -> {
            log.warn("定时任务被拒绝,任务队列已满");
            // 可添加自定义处理逻辑,如记录日志或发送告警
        });
        return scheduler;
    }
}

2.1 线程池参数详解

参数 类型 默认值 说明
poolSize int 1 线程池大小,决定同时执行的任务数量
threadNamePrefix String "scheduler-" 线程名前缀,方便日志跟踪和调试
awaitTerminationSeconds int 0 应用关闭时等待任务完成的秒数,0表示不等待
waitForTasksToCompleteOnShutdown boolean false 是否等待计划任务完成再关闭线程池
removeOnCancelPolicy boolean false 取消任务时是否立即从队列中移除该任务。
errorHandler ErrorHandler null 任务执行异常时的处理器,用于自定义异常处理逻辑
rejectedExecutionHandler RejectedExecutionHandler AbortPolicy 任务被拒绝时的处理策略,默认直接抛出异常

2.2 线程池任务拒绝策略

策略名称 行为描述 适用场景
AbortPolicy(默认) 直接抛出 RejectedExecutionException,中断任务提交流程 需要严格保证任务不丢失的场景,需显式处理异常
CallerRunsPolicy 由提交任务的调用者线程直接执行被拒绝的任务(线程池未关闭时) 需降低任务提交速度,避免完全阻塞的场景
DiscardPolicy 静默丢弃被拒绝的任务,不触发任何异常或通知 允许丢弃部分非关键任务的场景
DiscardOldestPolicy 丢弃任务队列中最早未处理的任务,并尝试重新提交当前任务(可能仍被拒绝) 允许牺牲旧任务以优先处理新任务的场景

3.异步执行

当定时任务包含阻塞操作(如网络IO、复杂计算)时,应使用异步模式避免阻塞调度线程

3.1异步线程池配置

java 复制代码
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;

@Configuration
@EnableAsync // 开启异步支持
public class AsyncConfiguration {

    /**
     * 创建异步任务线程池
     *
     */
    @Bean("taskExecutor")
    public Executor taskExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        // 核心参数
        executor.setCorePoolSize(3);
        executor.setMaxPoolSize(5);
        executor.setQueueCapacity(100);
        executor.setThreadNamePrefix("async-task-");
        executor.setKeepAliveSeconds(60);
        // 拒绝策略:由调用线程处理(避免任务丢失)
        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
        // 优雅停机配置
        executor.setWaitForTasksToCompleteOnShutdown(true);
        executor.setAwaitTerminationSeconds(60);
        executor.initialize();
        return executor;
    }
}

3.2 异步线程池配置建议

3.2.1 队列选择策略
队列类型 特点 适用场景
SynchronousQueue 无容量 高吞吐、短任务
LinkedBlockingQueue 无界队列 保证任务不丢失
ArrayBlockingQueue 有界队列 资源受限环境
PriorityBlockingQueue 优先级队列 任务优先级处理
3.2.2 拒绝策略选择
java 复制代码
// 常用拒绝策略
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); // 抛出异常
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); // 静默丢弃
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 调用者执行
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardOldestPolicy()); // 丢弃最旧任务
3.2.3 动态配置
java 复制代码
// 运行时调整核心参数
executor.setCorePoolSize(10);
executor.setMaxPoolSize(30);
executor.setQueueCapacity(200);

3.3 应用

在定时任务上添加@Async,标明该任务异步执行(所在类必须被Spring管理)

java 复制代码
/**
 * 定时清理 MinIO 中早于当前日期的日期目录(格式:yyyy-MM-dd/)
 * 执行时间:每月1号凌晨3点
 */
@Async(value = "taskExecutor")//添加异步注解
@Scheduled(cron = "0 0 3 1 * ?")
public void minioClean() {
    try {
        log.info("MinIO 清理任务开始执行...");
        // 明确语义:删除早于今天的所有日期目录(不含今天)
        LocalDate today = LocalDate.now();
        log.info("当前日期:{}, 开始清理早于该日期的目录", today);
        int deleteCount = minioUtil.deleteDateFoldersBefore(today);
        log.info("MinIO 清理任务执行完成,共删除 {} 张图片", deleteCount);
    } catch (Exception e) {
        // 防止定时任务因异常停止
        log.error("MinIO 清理任务执行失败", e);
    }
}

4.扩展

4.1 配置文件优化

4.1.1 添加配置信息
yml 复制代码
minio:
  clean:
    enabled: true          # 是否启用清理功能
    retain-days: 1        # 保留最近多少天的文件
    earliest-date: "2025/08/01" # 最早保留日期(避免误删重要文件)
    cron: "0 0 2 * * ?"    # 每天凌晨2点执行
4.1.2 配置类映射
java 复制代码
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
@Component
@Data
@ConfigurationProperties(prefix = "vehicle.minio.clean")
public class MinioCleanProperties {
    private boolean enabled;
    private int retainDays;
    private LocalDate earliestDate;
    private String cron;
}
4.1.3 修改 MinioUtil

修改 MinioUtil,移除硬编码的 retainSince

java 复制代码
import cn.hutool.core.lang.UUID;
import com.fc.properties.MinioCleanProperties;
import com.fc.properties.MinioProperties;
import io.minio.*;
import io.minio.errors.ErrorResponseException;
import io.minio.http.Method;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.compress.utils.IOUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

/**
 * 文件操作工具类
 */
@RequiredArgsConstructor
@Component
@Slf4j
public class MinioUtil {

    private final MinioProperties minioProperties;
    private final MinioCleanProperties minioCleanProperties;//添加映射配置类
    private MinioClient minioClient;
    private String bucketName;
    private LocalDate retainSince;


    // 初始化 Minio 客户端
    @PostConstruct
    public void init() {
        try {
            //创建客户端
            minioClient = MinioClient.builder()
                    .endpoint(minioProperties.getUrl())
                    .credentials(minioProperties.getUsername(), minioProperties.getPassword())
                    .build();
            bucketName = minioProperties.getBucketName();
            retainSince = minioCleanProperties.getEarliestDate();//获取动态参数

            // 检查桶是否存在,不存在则创建
            boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());
            if (!bucketExists) {
                minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());
            }
        } catch (Exception e) {
            throw new RuntimeException("Minio 初始化失败", e);
        }
    }

    /*
     * 上传文件
     */
    public String uploadFile(MultipartFile file, String extension) {
        if (file == null || file.isEmpty()) {
            throw new RuntimeException("上传文件不能为空");
        }

        try {
            // 生成唯一文件名
            String uniqueFilename = generateUniqueFilename(extension);

            // 上传文件
            minioClient.putObject(PutObjectArgs.builder()
                    .bucket(bucketName)
                    .object(uniqueFilename)
                    .stream(file.getInputStream(), file.getSize(), -1)
                    .contentType(file.getContentType())
                    .build());

            return "/" + bucketName + "/" + uniqueFilename;
        } catch (Exception e) {
            throw new RuntimeException("文件上传失败", e);
        }
    }


    /**
     * 上传已处理的图片字节数组到 MinIO
     *
     * @param imageData   处理后的图片字节数组
     * @param extension   文件扩展名(如 ".jpg", ".png")
     * @param contentType 文件 MIME 类型(如 "image/jpeg", "image/png")
     * @return MinIO 中的文件路径(格式:/bucketName/yyyy-MM-dd/uuid.extension)
     */
    public String uploadFileByte(byte[] imageData, String extension, String contentType) {
        if (imageData == null || imageData.length == 0) {
            throw new RuntimeException("上传的图片数据不能为空");
        }
        if (extension == null || extension.isEmpty()) {
            throw new IllegalArgumentException("文件扩展名不能为空");
        }
        if (contentType == null || contentType.isEmpty()) {
            throw new IllegalArgumentException("文件 MIME 类型不能为空");
        }

        try {
            // 生成唯一文件名
            String uniqueFilename = generateUniqueFilename(extension);

            // 上传到 MinIO
            minioClient.putObject(
                    PutObjectArgs.builder()
                            .bucket(bucketName)
                            .object(uniqueFilename)
                            .stream(new ByteArrayInputStream(imageData), imageData.length, -1)
                            .contentType(contentType)
                            .build()
            );

            return "/" + bucketName + "/" + uniqueFilename;
        } catch (Exception e) {
            throw new RuntimeException("处理后的图片上传失败", e);
        }
    }

    /**
     * 上传本地生成的 Excel 临时文件到 MinIO
     *
     * @param localFile 本地临时文件路径
     * @param extension 扩展名
     * @return MinIO 存储路径,格式:/bucketName/yyyy-MM-dd/targetName
     */
    public String uploadLocalExcel(Path localFile, String extension) {
        if (localFile == null || !Files.exists(localFile)) {
            throw new RuntimeException("本地文件不存在");
        }
        try (InputStream in = Files.newInputStream(localFile)) {
            String objectKey = generateUniqueFilename(extension); // 保留日期目录
            minioClient.putObject(
                    PutObjectArgs.builder()
                            .bucket(bucketName)
                            .object(objectKey)
                            .stream(in, Files.size(localFile), -1)
                            .contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
                            .build());
            return "/" + bucketName + "/" + objectKey;
        } catch (Exception e) {
            throw new RuntimeException("Excel 上传失败", e);
        }
    }

    /*
     * 根据URL下载文件
     */
    public void downloadFile(HttpServletResponse response, String fileUrl) {
        if (fileUrl == null || !fileUrl.contains(bucketName + "/")) {
            throw new IllegalArgumentException("无效的文件URL");
        }

        try {
            // 从URL中提取对象路径和文件名
            String objectUrl = fileUrl.split(bucketName + "/")[1];
            String fileName = objectUrl.substring(objectUrl.lastIndexOf("/") + 1);

            // 设置响应头
            response.setContentType("application/octet-stream");
            String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
            response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedFileName + "\"");

            // 下载文件
            try (InputStream inputStream = minioClient.getObject(GetObjectArgs.builder()
                    .bucket(bucketName)
                    .object(objectUrl)
                    .build());
                 OutputStream outputStream = response.getOutputStream()) {

                // 用IOUtils.copy高效拷贝(内部缓冲区默认8KB)
                IOUtils.copy(inputStream, outputStream);
            }
        } catch (Exception e) {
            throw new RuntimeException("文件下载失败", e);
        }
    }

    /**
     * 根据 MinIO 路径生成带签名的直链
     *
     * @param objectUrl 已存在的 MinIO 路径(/bucketName/...)
     * @param minutes   链接有效期(分钟)
     * @return 可直接访问的 HTTPS 下载地址
     */
    public String parseGetUrl(String objectUrl, int minutes) {
        if (objectUrl == null || !objectUrl.startsWith("/" + bucketName + "/")) {
            throw new IllegalArgumentException("非法的 objectUrl");
        }
        String objectKey = objectUrl.substring(("/" + bucketName + "/").length());
        try {
            return minioClient.getPresignedObjectUrl(
                    GetPresignedObjectUrlArgs.builder()
                            .method(Method.GET)
                            .bucket(bucketName)
                            .object(objectKey)
                            .expiry(minutes, TimeUnit.MINUTES)
                            .build());
        } catch (Exception e) {
            throw new RuntimeException("生成直链失败", e);
        }
    }

    /*
     * 根据URL删除文件
     */
    public void deleteFile(String fileUrl) {
        try {
            // 从URL中提取对象路径
            String objectUrl = fileUrl.split(bucketName + "/")[1];
            minioClient.removeObject(RemoveObjectArgs.builder()
                    .bucket(bucketName)
                    .object(objectUrl)
                    .build());
        } catch (Exception e) {
            throw new RuntimeException("文件删除失败", e);
        }
    }

    /*
     * 检查文件是否存在
     */
    public boolean fileExists(String fileUrl) {
        if (fileUrl == null || !fileUrl.contains(bucketName + "/")) {
            return false;
        }

        try {
            String objectUrl = fileUrl.split(bucketName + "/")[1];
            minioClient.statObject(StatObjectArgs.builder()
                    .bucket(bucketName)
                    .object(objectUrl)
                    .build());
            return true;
        } catch (Exception e) {
            if (e instanceof ErrorResponseException && ((ErrorResponseException) e).errorResponse().code().equals("NoSuchKey")) {
                return false;
            }
            throw new RuntimeException("检查文件存在失败", e);
        }
    }


    /**
     * 生成唯一文件名(带日期路径 + UUID)
     */
    private String generateUniqueFilename(String extension) {
        String dateFormat = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));
        String uuid = UUID.randomUUID().toString().replace("-", ""); // 去掉 UUID 中的 "-"
        return dateFormat + "/" + uuid + extension;
    }

    /**
     * 删除早于指定日期的所有日期目录(yyyy-MM-dd/)
     *
     * @param endExclusive 截止日期(不含)
     * @return 实际删除的对象总数
     */
    public int deleteDateFoldersBefore(LocalDate endExclusive) {
        if (endExclusive == null) {
            throw new IllegalArgumentException("指定日期不能为空");
        }

        LocalDate today = LocalDate.now();
        if (!endExclusive.isBefore(today)) {
            return 0;
        }
        int totalDeleted = 0;
        // 从 endExclusive-1 天开始往前删
        for (LocalDate d = endExclusive.minusDays(1); !d.isBefore(retainSince); d = d.minusDays(1)) {
            totalDeleted += deleteSingleFolder(d.format(DateTimeFormatter.ISO_LOCAL_DATE) + "/");
        }
        return totalDeleted;
    }

    /**
     * 删除单个目录(前缀)下的全部对象
     */
    private int deleteSingleFolder(String prefix) {
        try {
            List<DeleteObject> objects = new ArrayList<>();
            minioClient.listObjects(ListObjectsArgs.builder()
                            .bucket(bucketName)
                            .prefix(prefix)
                            .recursive(true)
                            .build())
                    .forEach(r -> {
                        try {
                            objects.add(new DeleteObject(r.get().objectName()));
                        } catch (Exception ignored) {
                            log.warn("文件名获取失败");
                        }
                    });
            if (objects.isEmpty()) {
                return 0;
            }
            Iterable<Result<DeleteError>> results = minioClient.removeObjects(
                    RemoveObjectsArgs.builder()
                            .bucket(bucketName)
                            .objects(objects)
                            .build());


            for (Result<DeleteError> res : results) {
                DeleteError deleteError = res.get();// 无异常即成功
            }
            return objects.size();
        } catch (Exception e) {
            log.warn("删除目录 {} 失败: {}", prefix, e.toString());
            return 0;
        }
    }

}
4.1.4 修改 MinioCleanTask
java 复制代码
import com.fc.properties.MinioCleanProperties;
import com.fc.utils.MinioUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDate;

@Component//注册Bean
@RequiredArgsConstructor
@Slf4j
public class MinioCleanTask {

    private final MinioUtil minioUtil;
    private final MinioCleanProperties minioCleanProperties;

    /**
     * 定时清理 MinIO 中早于当前日期的日期目录(格式:yyyy-MM-dd/)
     * 执行时间:每月1号凌晨3点
     */
    @Async(value = "taskExecutor")//添加异步注解
    @Scheduled(cron = "#{@minioCleanProperties.cron}")
    public void minioClean() {
        try {
            if (!minioCleanProperties.isEnabled()) {
                log.warn("清理【Minio】图片定时任务已关闭");
                return;
            }
            log.info("MinIO 清理任务开始执行...");
            LocalDate cutoff = LocalDate.now().minusDays(minioCleanProperties.getRetainDays());//获取保留自定义天数的时间日期
            log.info("清理截止日期:{},最早保留日期:{}", cutoff, minioCleanProperties.getEarliestDate());
            int deleteCount = minioUtil.deleteDateFoldersBefore(cutoff);
            log.info("MinIO 清理任务执行完成,共删除 {} 张图片", deleteCount);
        } catch (Exception e) {
            // 防止定时任务因异常停止
            log.error("MinIO 清理任务执行失败", e);
        }
    }
}

@Scheduled(cron = "#{@minioCleanProperties.cron}"):让 @Scheduled 注解的 cron 表达式从 Spring 容器里的某个 Bean 中动态取值,而不是写死在代码里

片段 含义
@Scheduled(cron = ...) Spring 定时任务的注解,指定 cron 表达式。
#{} SpEL(Spring 表达式语言),允许在注解里写动态表达式。
@minioCleanProperties 从 Spring 容器里按 Bean 名称 取出对应的 Bean。
.cron 取出该 Bean 的 getCron() 方法返回的字符串,即 cron 表达式。
4.1.5 测试

修改依赖配置文件

yml 复制代码
minio:
  clean:
    enabled: true          # 是否启用清理功能
    retain-days: 5        # 保留最近多少天的文件
    earliest-date: "2025/08/01" # 最早保留日期(避免误删重要文件)
    cron: "0/5 * * * * ?"    # 每天五秒执行一次

4.2 接口方法

4.2.1 建表和插入数据
sql 复制代码
CREATE TABLE corn (
	`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',
	`enabled` TINYINT NOT NULL COMMENT '是否开启定时任务,1-开启,0-关闭',
	`retain_days` INT NOT NULL COMMENT '保留最近多少天的文件',
	`earliest_date` DATE NOT NULL COMMENT '最早保留日期',
	`corn` VARCHAR ( 20 ) NOT NULL COMMENT 'CORN表达式(触发时间)',
	`create_time` DATETIME COMMENT '创建时间',
	`update_time` DATETIME COMMENT '更新时间' 
) ENGINE = INNODB DEFAULT CHARSET = UTF8MB4;

insert into corn values (1,true,90,'2025/08/01','0/5 * * * * ?',NOW(),NOW());
4.2.2 创建实体
java 复制代码
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CornDTO {
    private long id;
    private boolean enabled;
    private int retainDays;
    private LocalDate earliestDate;
    private String corn;
    private LocalDateTime createTime;
    private LocalDateTime updateTime;
}
4.2.3 Mapper接口
java 复制代码
import com.fc.dto.CornDTO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface CornMapper {
    @Select("select * from corn where id=#{id}")
    CornDTO selectCornById(long id);
}



关闭数据库的打印信息看一下


非常奈斯!!!

相关推荐
简放2 小时前
智能机票助手-接入Ollama本地模型-Spring-AI-Alibaba
spring boot·langchain·ollama
苹果醋33 小时前
Deep Dive React 4 How does React State actually work
java·运维·spring boot·mysql·nginx
橘子编程6 小时前
SpringBoot核心特性详解
java·jvm·spring boot·spring·spring cloud·tomcat
muyun28009 小时前
Spring Boot 3 中 WebFilter 的执行顺序控制
spring boot
麦兜*17 小时前
Spring Boot整合PyTorch Pruning工具链,模型瘦身手术
java·pytorch·spring boot·后端·spring cloud·ai编程·剪枝
杨荧20 小时前
基于大数据的美食视频播放数据可视化系统 Python+Django+Vue.js
大数据·前端·javascript·vue.js·spring boot·后端·python
架构师沉默1 天前
我用一个 Postgres 实现一整套后端架构!
java·spring boot·程序人生·架构·tdd
xiucai_cs1 天前
布隆过滤器原理与Spring Boot实战
java·spring boot·后端·布隆过滤器
巴拉巴巴巴拉1 天前
Spring Boot 中 YAML 配置文件详解
spring boot