![图片](https://p0-xtjj-private.juejin.cn/tos-cn-i-73owjymdk6/62105da0f2c54b3497b0

127 阅读19分钟

在项目开发中,我们使用Minio作为图片存储服务。随着时间推移,存储的图片文件越来越多,其中大量历史图片已不再需要。

为了优化存储空间并降低成本,需要实现一个定时清理功能,定期删除指定日期前的图片文件。

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

<!--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 方法代码

参数说明

参数类型说明
endExclusiveLocalDate截止日期(不含此日期)
retainSinceLocalDate保留起始日期(最早不删除的日期)
prefixStringMinio对象前缀(即目录路径)
private String bucketName="";//自定义就好
private LocalDate retainSince = LocalDate.of(202561);//用于判断的起始时
/**
 * 删除早于指定日期的所有日期目录(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 完整代码
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(202561);


    // 初始化 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

异常隔离

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

批大小限制

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

2、测试类:MinioTest

完整代码

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(20258,2));//这里的时间可以自定义,注意测试之前要先确定存在文件
        System.out.println(count);
    }
}

三、定时配置

1.注解实现

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

图片

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

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

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 位格式为例)
位置字段取值范围允许的特殊字符
10-59, - * /
20-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 秒一次
LLast(最后)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#22#2表示第2周的周一
工作日(周一到周五)9点执行0 0 9 * *MON-FRI MON-FRI定义范围
每10秒执行一次*/10 * * * * ?*/10表示秒字段的步长
1.4.5 在线验证工具

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

2.配置线程池

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

  • • 实现任务隔离
  • • 提高任务并行性
  • • 避免任务阻塞
  • • 提供更好的资源控制
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 线程池参数详解
参数类型默认值说明
poolSizeint1线程池大小,决定同时执行的任务数量
threadNamePrefixString“scheduler-”线程名前缀,方便日志跟踪和调试
awaitTerminationSecondsint0应用关闭时等待任务完成的秒数,0表示不等待
waitForTasksToCompleteOnShutdownbooleanfalse是否等待计划任务完成再关闭线程池
removeOnCancelPolicybooleanfalse取消任务时是否立即从队列中移除该任务。
errorHandlerErrorHandlernull任务执行异常时的处理器,用于自定义异常处理逻辑
rejectedExecutionHandlerRejectedExecutionHandlerAbortPolicy任务被拒绝时的处理策略,默认直接抛出异常
2.2 线程池任务拒绝策略
策略名称行为描述适用场景
AbortPolicy(默认)直接抛出 RejectedExecutionException,中断任务提交流程需要严格保证任务不丢失的场景,需显式处理异常
CallerRunsPolicy由提交任务的调用者线程直接执行被拒绝的任务(线程池未关闭时)需降低任务提交速度,避免完全阻塞的场景
DiscardPolicy静默丢弃被拒绝的任务,不触发任何异常或通知允许丢弃部分非关键任务的场景
DiscardOldestPolicy丢弃任务队列中最早未处理的任务,并尝试重新提交当前任务(可能仍被拒绝)允许牺牲旧任务以优先处理新任务的场景

3.异步执行

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

3.1异步线程池配置
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 拒绝策略选择
// 常用拒绝策略
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); // 抛出异常
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); // 静默丢弃
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 调用者执行
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardOldestPolicy()); // 丢弃最旧任务
3.2.3 动态配置
// 运行时调整核心参数
executor.setCorePoolSize(10);
executor.setMaxPoolSize(30);
executor.setQueueCapacity(200);
3.3 应用

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

图片

/**
 * 定时清理 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 添加配置信息
minio:
  clean:
    enabled: true          # 是否启用清理功能
    retain-days: 1        # 保留最近多少天的文件
    earliest-date: "2025/08/01" # 最早保留日期(避免误删重要文件)
    cron: "0 0 2 * * ?"    # 每天凌晨2点执行
4.1.2 配置类映射
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

图片

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

图片

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 测试

修改依赖配置文件

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

图片

4.2 接口方法
4.2.1 建表和插入数据
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 创建实体
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接口
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);
}

图片

图片

图片

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

图片

非常奈斯!!!

如果这篇文章对您有所帮助,或者有所启发的话,求一键三连:点赞、转发、在看。

关注公众号:woniuxgg,在公众号中回复:笔记  就可以获得蜗牛为你精心准备的java实战语雀笔记,回复面试、开发手册、有超赞的粉丝福利!