SpringBoot + Minio 定时清理历史文件,太好用了!

这套方案不仅能解决 Minio 文件清理的问题,还能作为 “定时任务” 的通用模板 —— 比如后续要做 “定时清理数据库历史数据”“定时生成报表”,都可以参考这套思路,改改核心逻辑就能用。

兄弟们,不知道你们有没有过这种崩溃时刻:生产环境的 Minio 服务器用着用着,突然告警 “磁盘空间不足”,登上去一看 —— 好家伙,半年前的测试文件、过期的临时缓存、还有同事误传的超大日志文件堆得像小山,手动删不仅费时间,还怕手抖删错生产数据,简直是 “删也不是,不删也不是” 的大型纠结现场。

我前阵子就踩过这坑,当时连夜加班删文件,删到凌晨三点眼睛都花了,心里暗自发誓:必须整个全自动的清理方案!折腾了几天,终于搞出了 “SpringBoot + Minio 定时清理历史文件” 的一套组合拳,现在每天到点自动干活,再也不用跟一堆过期文件较劲。今天就把这套方案掰开揉碎了讲,从基础到进阶,保证大白话到底,就算是刚接触 Minio 的新手也能跟着做,看完记得收藏,说不定下次你就用得上!

一、先唠唠:为啥非要用 Minio?又为啥要定时清理?

在讲怎么实现之前,先跟大家掰扯清楚两个事儿:Minio 到底好用在哪?还有为啥非得定时清理,手动删不行吗?

先说说 Minio,这玩意儿在对象存储领域那可是 “轻量级王者”—— 不用装复杂的集群环境,单机版双击就能跑,集群版几行命令就能搭,而且跟 S3 协议兼容,以后想迁移到 AWS S3 也方便。咱们 Java 项目里用它存个用户头像、Excel 报表、日志文件啥的,简直不要太顺手。

但问题也来了:Minio 这东西 “记吃不记打”,你存多少文件它就留多少,哪怕是三个月前的测试数据、24 小时就过期的临时二维码,它也绝不主动删。时间一长,磁盘空间就跟你手机相册一样,不知不觉就满了。

有人说:“我手动删不就行?” 兄弟,你要是天天有空盯着还行,要是赶上周末或者节假日,磁盘满了直接影响业务,你就得从被窝里爬起来远程处理 —— 我上次国庆就因为这事儿,在老家农家乐对着手机改配置,老板还以为我在偷偷谈大生意。更要命的是,手动删容易出错,我之前有个同事,想删 “test_202401” 开头的测试文件,结果手滑写成了 “test_2024”,直接把 2024 年的正式文件全删了,当天就提着电脑去财务那结工资了,咱可别学他。

所以啊,搞个 SpringBoot 定时任务,自动清理 Minio 里的历史文件,不仅省时间,还能避免人为失误,简直是 “一劳永逸” 的好办法。

二、基础准备:先把 Minio 和 SpringBoot 搭起来

要做定时清理,首先得让 SpringBoot 能跟 Minio “对话”—— 也就是集成 Minio 客户端。这一步不难,跟着我一步步来,保证不踩坑。

2.1 先整个 Minio 环境(本地测试用)

如果你还没有 Minio 环境,先整个本地版玩玩,步骤超简单:

  1. 去 Minio 官网下载对应系统的安装包(官网地址:https://min.io/ ,别下错了,Windows 就下 exe,Linux 就下 tar.gz);
  2. 解压后,打开命令行,进入解压目录,执行启动命令:
  • Windows:minio.exe server D:\minio-data --console-address ":9001"
  • Linux:./minio server /home/minio-data --console-address ":9001"

这里解释下:D:\minio-data是 Minio 存储文件的目录,你可以改成自己的路径;--console-address ":9001"是 Minio 控制台的端口,默认是 9000,怕跟其他服务冲突,咱改个 9001。

  • 启动成功后,命令行会显示默认账号和密码(都是 minioadmin),还有控制台地址(http://localhost:9001);
  • 打开浏览器访问控制台,输入账号密码登录,然后创建一个桶(Bucket),比如叫 “file-bucket”—— 这就相当于 Minio 里的 “文件夹”,以后咱们的文件都存在这里面。

搞定!本地 Minio 环境就搭好了,是不是比搭 MySQL 还简单?

2.2 SpringBoot 集成 Minio 客户端

接下来,让 SpringBoot 能操作 Minio,核心是引入 Minio 的依赖,再配置客户端。

2.2.1 引入 Minio 依赖

打开你的 SpringBoot 项目,在 pom.xml 里加 Minio 的依赖(注意:版本别太老,我这里用的是 8.5.2,是比较稳定的版本):

<!-- Minio客户端依赖 -->
<dependency>
    <groupId>io.minio</groupId>
    <artifactId>minio</artifactId>
    <version>8.5.2</version>
    <!-- 排除自带的okhttp,避免版本冲突 -->
    <exclusions>
        <exclusion>
            <groupId>com.squareup.okhttp3</groupId>
            <artifactId>okhttp</artifactId>
        </exclusion>
    </exclusions>
</dependency>
<!-- 手动引入okhttp,用稳定版本 -->
<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.9.3</version>
</dependency>
<!-- SpringBoot的定时任务依赖(后面要用) -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
<!-- 工具类依赖(处理时间、字符串啥的) -->
<dependency>
    <groupId>cn.hutool</groupId>
    <artifactId>hutool-all</artifactId>
    <version>5.8.20</version>
</dependency>

这里插一句:为啥要排除 Minio 自带的 okhttp?因为有些 SpringBoot starter(比如 spring-cloud-starter)也会引入 okhttp,版本不一样容易冲突,手动指定一个稳定版本更稳妥。

2.2.2 配置 Minio 参数

然后在 application.yml(或 application.properties)里配置 Minio 的连接信息,别写死在代码里,以后改配置方便:

# Minio配置
minio:
  endpoint: http://localhost:9000  # Minio服务地址(不是控制台地址!控制台是9001,服务是9000)
  access-key: minioadmin          # 账号
  secret-key: minioadmin          # 密码
  bucket-name: file-bucket        # 要操作的桶名(就是刚才在控制台创建的)
  # 清理规则配置
  clean:
    enabled: true                 # 是否开启清理任务
    cron: 0 0 2 * * ?             # 清理时间(每天凌晨2点,Cron表达式,不懂的话后面有解释)
    expire-days: 30               # 文件过期天数(超过30天的文件会被清理)
    ignore-prefixes: test_,temp_  # 忽略的文件前缀(比如test_开头的文件不清理,多个用逗号分隔)
    max-batch-size: 100           # 每次批量删除的文件数量(避免一次删太多导致Minio卡壳)

这里的配置项都加了注释,应该很好懂。重点提醒下:endpoint是 Minio 的服务地址,默认端口是 9000,不是控制台的 9001,别填错了!我第一次就填成 9001,结果客户端连不上,查了半小时才发现是端口错了,血的教训。

2.2.3 配置 Minio 客户端 Bean

接下来,写个配置类,把 MinioClient 注册成 Spring 的 Bean,这样整个项目都能注入使用:

import io.minio.MinioClient;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
 * Minio配置类
 * 把MinioClient交给Spring管理,方便注入使用
 */
@Configuration
@ConfigurationProperties(prefix = "minio") // 读取前缀为minio的配置
public class MinioConfig {
    // 从配置文件读取的参数
    private String endpoint;
    private String accessKey;
    private String secretKey;
    private String bucketName;
    // 清理规则相关参数
    private CleanConfig clean;
    // 内部类:封装清理规则配置
    public static class CleanConfig {
        private boolean enabled;
        private String cron;
        private Integer expireDays;
        private String ignorePrefixes;
        private Integer maxBatchSize;
        // getter和setter(这里省略,实际项目里要加上,不然读不到配置)
        public boolean isEnabled() { return enabled; }
        public void setEnabled(boolean enabled) { this.enabled = enabled; }
        public String getCron() { return cron; }
        public void setCron(String cron) { this.cron = cron; }
        public Integer getExpireDays() { return expireDays; }
        public void setExpireDays(Integer expireDays) { this.expireDays = expireDays; }
        public String getIgnorePrefixes() { return ignorePrefixes; }
        public void setIgnorePrefixes(String ignorePrefixes) { this.ignorePrefixes = ignorePrefixes; }
        public Integer getMaxBatchSize() { return maxBatchSize; }
        public void setMaxBatchSize(Integer maxBatchSize) { this.maxBatchSize = maxBatchSize; }
    }
    // 注册MinioClient Bean,只有当清理功能开启时才创建(ConditionalOnProperty)
    @Bean
    @ConditionalOnProperty(prefix = "minio.clean", name = "enabled", havingValue = "true")
    public MinioClient minioClient() {
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
    }
    // 外部类的getter和setter(同样省略,实际项目要加)
    public String getEndpoint() { return endpoint; }
    public void setEndpoint(String endpoint) { this.endpoint = endpoint; }
    public String getAccessKey() { return accessKey; }
    public void setAccessKey(String accessKey) { this.accessKey = accessKey; }
    public String getSecretKey() { return secretKey; }
    public void setSecretKey(String secretKey) { this.secretKey = secretKey; }
    public String getBucketName() { return bucketName; }
    public void setBucketName(String bucketName) { this.bucketName = bucketName; }
    public CleanConfig getClean() { return clean; }
    public void setClean(CleanConfig clean) { this.clean = clean; }
}

这里用了@ConfigurationProperties注解,能自动把配置文件里 “minio” 前缀的参数映射到这个类的属性上,不用手动写@Value注解,更简洁。还有@ConditionalOnProperty,意思是只有当minio.clean.enabled为 true 时,才创建 MinioClient Bean,灵活控制是否开启清理功能。到这里,SpringBoot 和 Minio 的集成就搞定了。咱们可以写个简单的测试类,看看能不能连接上 Minio:

import io.minio.MinioClient;
import io.minio.ListObjectsArgs;
import io.minio.Result;
import io.minio.messages.Item;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.Iterator;
@SpringBootTest
public class MinioTest {
    @Autowired
    private MinioClient minioClient;
    @Autowired
    private MinioConfig minioConfig;
    @Test
    public void testListFiles() throws Exception {
        // 列出桶里的所有文件
        Iterator<Result<Item>> iterator = minioClient.listObjects(
                ListObjectsArgs.builder()
                        .bucket(minioConfig.getBucketName())
                        .recursive(true) // 是否递归查询子目录
                        .build()
        ).iterator();
        while (iterator.hasNext()) {
            Item item = iterator.next().get();
            System.out.println("文件名:" + item.objectName() + ",创建时间:" + item.lastModified());
        }
    }
}

如果运行测试后,能打印出桶里的文件信息,说明 SpringBoot 和 Minio 已经成功 “牵手” 了;如果报错,先检查配置里的 endpoint、账号密码是不是错了,桶名是不是存在。

三、核心实现:定时清理任务怎么写?

集成好 Minio 之后,就该搞核心的定时清理任务了。咱们的需求很明确:每天凌晨 2 点,自动删除 Minio 指定桶里 “超过 30 天” 且 “不是 ignore 前缀” 的文件,还要支持批量删除,避免一次删太多卡壳。

实现定时任务,SpringBoot 里常用两种方式:一种是简单的@Scheduled注解,适合简单的定时需求;另一种是 Quartz,适合复杂的定时策略(比如动态修改执行时间、集群环境避免重复执行)。咱们这里先讲@Scheduled的实现,后面再讲 Quartz 的进阶方案,满足不同场景的需求。

3.1 先搞个 Minio 工具类:封装文件操作

在写定时任务之前,先封装一个 Minio 工具类,把 “获取文件列表”“判断文件是否过期”“删除文件” 这些常用操作抽出来,这样定时任务里的代码会更简洁,也方便复用。

复制

import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.date.DateUtil;
import io.minio.DeleteObjectsArgs;
import io.minio.ListObjectsArgs;
import io.minio.MinioClient;
import io.minio.Result;
import io.minio.errors.MinioException;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import io.minio.messages.Item;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.Iterator;
import java.util.Set;
import java.util.stream.Collectors;
/**
 * Minio工具类:封装文件查询、删除等操作
 */
@Component
@Slf4j
@RequiredArgsConstructor // 构造器注入,比@Autowired更推荐
public class MinioUtils {
    private final MinioClient minioClient;
    private final MinioConfig minioConfig;
    /**
     * 获取桶里所有需要清理的文件(过期且不在忽略列表中)
     * @param expireDays 过期天数(超过这个天数的文件需要清理)
     * @param ignorePrefixes 忽略的文件前缀(这些前缀的文件不清理)
     * @return 需要清理的文件列表(文件名)
     */
    public List<String> getExpiredFiles(Integer expireDays, Set<String> ignorePrefixes) {
        List<String> expiredFiles = new ArrayList<>();
        try {
            // 1. 列出桶里的所有文件(递归查询子目录)
            Iterator<Result<Item>> iterator = minioClient.listObjects(
                    ListObjectsArgs.builder()
                            .bucket(minioConfig.getBucketName())
                            .recursive(true)
                            .build()
            ).iterator();
            // 2. 遍历文件,判断是否需要清理
            while (iterator.hasNext()) {
                Item item = iterator.next().get();
                // 跳过目录(Minio里目录也是一种Item,需要排除)
                if (item.isDir()) {
                    continue;
                }
                String fileName = item.objectName();
                // 检查是否在忽略前缀列表中
                boolean isIgnore = ignorePrefixes.stream()
                        .anyMatch(prefix -> fileName.startsWith(prefix));
                if (isIgnore) {
                    log.info("文件{}匹配忽略前缀,不清理", fileName);
                    continue;
                }
                // 检查是否过期(当前时间 - 文件创建时间 > 过期天数)
                long createTime = item.lastModified().getTime();
                long nowTime = System.currentTimeMillis();
                long expireMs = expireDays * 24 * 60 * 60 * 1000L; // 过期时间(毫秒)
                if (nowTime - createTime > expireMs) {
                    expiredFiles.add(fileName);
                    log.info("文件{}已过期(创建时间:{}),加入清理列表",
                            fileName, DateUtil.format(item.lastModified(), "yyyy-MM-dd HH:mm:ss"));
                }
            }
            log.info("本次清理任务,共找到{}个过期文件", expiredFiles.size());
            return expiredFiles;
        } catch (Exception e) {
            log.error("获取过期文件列表失败", e);
            throw new RuntimeException("获取过期文件列表失败", e);
        }
    }
    /**
     * 批量删除Minio里的文件
     * @param fileNames 要删除的文件名列表
     * @param maxBatchSize 每次批量删除的最大数量
     * @return 删除结果(成功数量、失败数量、失败的文件名)
     */
    public DeleteResult batchDeleteFiles(List<String> fileNames, Integer maxBatchSize) {
        if (CollUtil.isEmpty(fileNames)) {
            log.info("没有需要删除的文件,直接返回");
            return new DeleteResult(0, 0, new ArrayList<>());
        }
        // 初始化返回结果
        int successCount = 0;
        int failCount = 0;
        List<String> failFiles = new ArrayList<>();
        // 分割列表,分批删除(避免一次删太多导致Minio压力过大)
        List<List<String>> batchList = CollUtil.split(fileNames, maxBatchSize);
        log.info("共{}个文件,分{}批删除,每批最多{}个",
                fileNames.size(), batchList.size(), maxBatchSize);
        for (List<String> batch : batchList) {
            try {
                // 转换为Minio需要的DeleteObject列表
                List<DeleteObject> deleteObjects = batch.stream()
                        .map(DeleteObject::new)
                        .collect(Collectors.toList());
                // 执行批量删除
                Iterable<Result<DeleteError>> results = minioClient.deleteObjects(
                        DeleteObjectsArgs.builder()
                                .bucket(minioConfig.getBucketName())
                                .objects(deleteObjects)
                                .build()
                );
                // 处理删除结果(如果有错误,会在results里返回)
                boolean hasError = false;
                for (Result<DeleteError> result : results) {
                    DeleteError error = result.get();
                    log.error("删除文件{}失败,原因:{}", error.objectName(), error.message());
                    failCount++;
                    failFiles.add(error.objectName());
                    hasError = true;
                }
                // 如果没有错误,说明这一批都删除成功
                if (!hasError) {
                    successCount += batch.size();
                    log.info("成功删除第{}批文件,共{}个",
                            batchList.indexOf(batch) + 1, batch.size());
                }
            } catch (Exception e) {
                log.error("批量删除文件失败(批次:{})", batchList.indexOf(batch) + 1, e);
                failCount += batch.size();
                failFiles.addAll(batch);
            }
        }
        log.info("本次批量删除完成:成功{}个,失败{}个", successCount, failCount);
        return new DeleteResult(successCount, failCount, failFiles);
    }
    /**
     * 内部类:封装批量删除结果
     */
    public static class DeleteResult {
        private int successCount; // 成功删除数量
        private int failCount;    // 失败数量
        private List<String> failFiles; // 失败的文件名
        public DeleteResult(int successCount, int failCount, List<String> failFiles) {
            this.successCount = successCount;
            this.failCount = failCount;
            this.failFiles = failFiles;
        }
        // getter(省略,实际项目要加)
        public int getSuccessCount() { return successCount; }
        public int getFailCount() { return failCount; }
        public List<String> getFailFiles() { return failFiles; }
    }
}

这个工具类里有两个核心方法:

  1. getExpiredFiles:根据过期天数和忽略前缀,筛选出需要清理的文件。这里要注意:Minio 里的目录也是一种 Item,所以要跳过目录(item.isDir()),不然会把目录也删了,导致后续文件找不到。
  2. batchDeleteFiles:批量删除文件,支持分批删除(maxBatchSize)。为啥要分批?因为如果一次删几千个文件,Minio 的 API 可能会超时,分批删更稳妥。而且还会返回删除结果,方便后续排查失败的文件。

工具类里用了lombok的@RequiredArgsConstructor,会自动生成构造器,注入MinioClient和MinioConfig,比@Autowired更优雅,推荐大家用这种方式注入。

3.2 用 @Scheduled 实现定时任务

工具类搞好了,接下来写定时任务类。用@Scheduled注解的话,步骤很简单:

  • 在启动类上加@EnableScheduling注解,开启定时任务功能;
  • 写一个任务类,用@Scheduled(cron = "...")指定执行时间,在方法里调用工具类的方法完成清理。
3.2.1 开启定时任务

先在 SpringBoot 启动类上加@EnableScheduling:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling // 开启定时任务
public class MinioCleanApplication {
    public static void main(String[] args) {
        SpringApplication.run(MinioCleanApplication.class, args);
    }
}
3.2.2 编写定时任务类

然后写定时任务类,这里要注意:只有当minio.clean.enabled为 true 时,才启用这个任务,所以用@ConditionalOnProperty控制:

import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.Set;
import java.util.stream.Collectors;
/**
 * Minio文件定时清理任务(基于@Scheduled)
 */
@Component
@Slf4j
@RequiredArgsConstructor
@ConditionalOnProperty(prefix = "minio.clean", name = "enabled", havingValue = "true")
public class MinioFileCleanTask {
    private final MinioUtils minioUtils;
    private final MinioConfig minioConfig;
    /**
     * 定时清理Minio过期文件
     * @Scheduled(cron = "${minio.clean.cron}"):从配置文件读取Cron表达式,指定执行时间
     */
    @Scheduled(cron = "${minio.clean.cron}")
    public void cleanExpiredFiles() {
        log.info("==================== Minio文件清理任务开始 ====================");
        try {
            // 1. 获取清理规则配置
            MinioConfig.CleanConfig cleanConfig = minioConfig.getClean();
            Integer expireDays = cleanConfig.getExpireDays();
            String ignorePrefixesStr = cleanConfig.getIgnorePrefixes();
            Integer maxBatchSize = cleanConfig.getMaxBatchSize();
            // 校验配置(避免配置错误导致任务失败)
            if (expireDays == null || expireDays <= 0) {
                throw new RuntimeException("过期天数配置错误(必须大于0):" + expireDays);
            }
            if (maxBatchSize == null || maxBatchSize <= 0) {
                throw new RuntimeException("批量删除数量配置错误(必须大于0):" + maxBatchSize);
            }
            // 处理忽略前缀(将字符串转换为Set)
            Set<String> ignorePrefixes = StrUtil.isEmpty(ignorePrefixesStr)
                    ? CollUtil.newHashSet()
                    : Arrays.stream(ignorePrefixesStr.split(","))
                            .map(String::trim)
                            .collect(Collectors.toSet());
            // 2. 获取需要清理的过期文件
            log.info("清理规则:过期天数={}天,忽略前缀={},批量删除大小={}",
                    expireDays, ignorePrefixes, maxBatchSize);
            List<String> expiredFiles = minioUtils.getExpiredFiles(expireDays, ignorePrefixes);
            // 3. 批量删除文件
            if (CollUtil.isEmpty(expiredFiles)) {
                log.info("没有需要清理的过期文件,任务结束");
                return;
            }
            MinioUtils.DeleteResult deleteResult = minioUtils.batchDeleteFiles(expiredFiles, maxBatchSize);
            // 4. 输出清理结果
            log.info("==================== Minio文件清理任务结束 ====================");
            log.info("清理结果汇总:");
            log.info("总过期文件数:{}", expiredFiles.size());
            log.info("成功删除数:{}", deleteResult.getSuccessCount());
            log.info("失败删除数:{}", deleteResult.getFailCount());
            if (CollUtil.isNotEmpty(deleteResult.getFailFiles())) {
                log.info("删除失败的文件:{}", deleteResult.getFailFiles());
            }
        } catch (Exception e) {
            log.error("Minio文件清理任务执行失败", e);
            throw new RuntimeException("Minio文件清理任务执行失败", e);
        }
    }
}

这个任务类的逻辑很清晰,分四步:

  1. 读取配置:从MinioConfig里获取过期天数、忽略前缀、批量大小等配置,还要校验配置(比如过期天数不能小于 0),避免配置错误导致任务崩溃;
  2. 筛选文件:调用MinioUtils的getExpiredFiles方法,找出需要清理的文件;
  3. 批量删除:调用batchDeleteFiles方法,分批删除文件;
  4. 输出结果:打印清理结果,包括成功数、失败数、失败的文件名,方便后续排查问题。

这里解释下 Cron 表达式:0 0 2 * * ? 表示每天凌晨 2 点执行。如果想测试的话,可以改成0/30 * * * * ?(每 30 秒执行一次),本地测试没问题后再改回凌晨 2 点。Cron 表达式不会写?没关系,网上有很多 Cron 在线生成器(比如https://cron.qqe2.com/),输入时间就能自动生成,不用记复杂的规则。

3.3 测试定时任务

写好之后,怎么测试呢?有两种方式:

3.3.1 本地测试(改 Cron 表达式)

把配置文件里的minio.clean.cron改成0/30 * * * * ?(每 30 秒执行一次),然后启动项目,看日志输出:

==================== Minio文件清理任务开始 ====================
清理规则:过期天数=30天,忽略前缀=[test_,temp_],批量删除大小=100
文件test_20240101.txt匹配忽略前缀,不清理
文件report_20240301.pdf已过期(创建时间:2024-03-01 10:00:00),加入清理列表
文件log_20240215.log已过期(创建时间:2024-02-15 15:30:00),加入清理列表
本次清理任务,共找到2个过期文件
共2个文件,分1批删除,每批最多100个
成功删除第1批文件,共2个
==================== Minio文件清理任务结束 ====================
清理结果汇总:
总过期文件数:2
成功删除数:2
失败删除数:0

如果能看到这样的日志,说明定时任务正常执行,文件也成功删除了。测试完记得把 Cron 改回凌晨 2 点,别在生产环境每 30 秒执行一次。

3.3.2 手动触发任务(不用等 Cron 时间)

如果不想改 Cron 表达式,也可以手动触发任务,比如写个接口:

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import lombok.RequiredArgsConstructor;

/**
 * 手动触发清理任务的接口(测试用)
 */
@RestController
@RequestMapping("/minio/clean")
@RequiredArgsConstructor
publicclass MinioCleanController {

    privatefinal MinioFileCleanTask minioFileCleanTask;

    @GetMapping("/trigger")
    public String triggerCleanTask() {
        try {
            minioFileCleanTask.cleanExpiredFiles();
            return"清理任务触发成功,请查看日志";
        } catch (Exception e) {
            return"清理任务触发失败:" + e.getMessage();
        }
    }
}

启动项目后,访问http://localhost:8080/minio/clean/trigger,就能手动触发清理任务,方便测试。不过要注意:这个接口只是测试用,生产环境要删掉,或者加权限控制,避免被恶意调用。

四、进阶优化:让清理任务更稳定、更灵活

上面的基础实现已经能满足大部分场景了,但在生产环境下,还需要做一些优化,比如支持动态修改清理规则、集群环境避免重复执行、清理失败报警等。咱们一步步来优化。

4.1 动态修改清理规则(不用重启服务)

之前的清理规则(过期天数、Cron 表达式)是写在 application.yml 里的,要修改的话需要重启服务,很不方便。咱们可以用 Spring Cloud Config 或者 Nacos 来实现配置动态刷新,这里以 Nacos 为例(如果不用 Nacos,用 Config 也类似)。

4.1.1 引入 Nacos 依赖

在 pom.xml 里加 Nacos 配置中心的依赖:

<!-- Nacos配置中心依赖 -->
<dependency>
    <groupId>com.alibaba.cloud</groupId>
    <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
    <version>2021.0.5.0</version> <!-- 版本要和SpringBoot版本匹配,具体看Nacos官网 -->
</dependency>
    4.1.2 配置 Nacos 地址

    在 bootstrap.yml(注意是 bootstrap.yml,不是 application.yml,因为 bootstrap 加载优先级更高)里配置 Nacos 地址:

    spring:
      application:
        name: minio-clean-service # 服务名,Nacos里的配置会根据这个名字找
      cloud:
        nacos:
          config:
            server-addr: localhost:8848 # Nacos服务地址
            file-extension: yml # 配置文件格式
            namespace: dev # 命名空间(区分开发、测试、生产)
            group: DEFAULT_GROUP # 配置分组
    4.1.3 在 Nacos 里配置清理规则

    登录 Nacos 控制台,创建一个配置文件:

    • 数据 ID:minio-clean-service.yml(格式:服务名。文件格式)
    • 分组:DEFAULT_GROUP
    • 配置内容:把之前 application.yml 里的 minio 配置挪到这里:
    minio:
    endpoint: http://localhost:9000
    access-key: minioadmin
    secret-key: minioadmin
    bucket-name: file-bucket
    clean:
        enabled: true
        cron: 002 * * ?
        expire-days: 30
        ignore-prefixes: test_,temp_
        max-batch-size: 100
    4.1.4 开启配置动态刷新

    在MinioConfig类上加@RefreshScope注解,开启配置动态刷新:

    import org.springframework.cloud.context.config.annotation.RefreshScope; // 加这个注解
    
    @Configuration
    @ConfigurationProperties(prefix = "minio")
    @RefreshScope // 开启配置动态刷新
    public class MinioConfig {
        // 内容不变,省略...
    }

    这样一来,当你在 Nacos 里修改清理规则(比如把expire-days改成 60),不用重启服务,配置会自动刷新,下一次定时任务就会用新的规则执行。是不是很方便?

    4.2 集群环境避免重复执行(分布式锁)

    如果你的 SpringBoot 服务是集群部署(多台机器),用@Scheduled的话,每台机器都会执行定时任务,导致重复删除文件 —— 比如 A 机器删了文件,B 机器又去删一遍,虽然 Minio 删不存在的文件不会报错,但会浪费资源,还可能导致日志混乱。

    解决这个问题的办法是用 “分布式锁”:让多台机器抢一把锁,只有抢到锁的机器才能执行清理任务,其他机器跳过。这里咱们用 Redis 实现分布式锁(Redis 比较常用,部署也简单)。

    4.2.1 引入 Redis 依赖

    在 pom.xml 里加 Redis 依赖:

    <!-- Redis依赖 -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-data-redis</artifactId>
    </dependency>
    4.2.2 配置 Redis

    在 Nacos 配置里加 Redis 配置:

    spring:
      redis:
        host: localhost # Redis地址
        port: 6379      # Redis端口
        password: # Redis密码(没有的话留空)
        database: 0     # 数据库索引
    4.2.3 实现分布式锁工具类

    写一个 Redis 分布式锁工具类,封装 “抢锁” 和 “释放锁” 的逻辑:

    import org.springframework.data.redis.core.StringRedisTemplate;
    import org.springframework.data.redis.core.script.DefaultRedisScript;
    import org.springframework.stereotype.Component;
    
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    
    import java.util.Collections;
    import java.util.concurrent.TimeUnit;
    
    /**
     * Redis分布式锁工具类
     */
    @Component
    @Slf4j
    @RequiredArgsConstructor
    publicclass RedisLockUtils {
    
        privatefinal StringRedisTemplate stringRedisTemplate;
    
        // 锁的前缀(避免和其他业务的锁冲突)
        privatestaticfinal String LOCK_PREFIX = "minio:clean:lock:";
        // 释放锁的Lua脚本(保证原子性,避免误释放别人的锁)
        privatestaticfinal String RELEASE_LOCK_SCRIPT = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
    
        /**
         * 抢锁
         * @param lockKey 锁的key(比如“minio_clean_task”)
         * @param lockValue 锁的value(用UUID,避免误释放别人的锁)
         * @param expireTime 锁的过期时间(避免服务宕机导致锁不释放)
         * @param timeUnit 时间单位
         * @return true=抢到锁,false=没抢到
         */
        public boolean tryLock(String lockKey, String lockValue, long expireTime, TimeUnit timeUnit) {
            try {
                String fullLockKey = LOCK_PREFIX + lockKey;
                // 用setIfAbsent实现抢锁(原子操作)
                Boolean success = stringRedisTemplate.opsForValue()
                        .setIfAbsent(fullLockKey, lockValue, expireTime, timeUnit);
                // 注意:Boolean可能为null,所以要判断是否为true
                boolean result = Boolean.TRUE.equals(success);
                if (result) {
                    log.info("成功抢到锁,锁key:{},锁value:{}", fullLockKey, lockValue);
                } else {
                    log.info("抢锁失败,锁key:{}已被占用", fullLockKey);
                }
                return result;
            } catch (Exception e) {
                log.error("抢锁失败", e);
                returnfalse;
            }
        }
    
        /**
         * 释放锁(用Lua脚本保证原子性)
         * @param lockKey 锁的key
         * @param lockValue 锁的value(必须和抢锁时的value一致,才能释放)
         * @return true=释放成功,false=释放失败
         */
        public boolean releaseLock(String lockKey, String lockValue) {
            try {
                String fullLockKey = LOCK_PREFIX + lockKey;
                // 执行Lua脚本
                DefaultRedisScript<Long> script = new DefaultRedisScript<>(RELEASE_LOCK_SCRIPT, Long.class);
                Long result = stringRedisTemplate.execute(
                        script,
                        Collections.singletonList(fullLockKey), // KEYS[1]
                        lockValue // ARGV[1]
                );
                // result=1表示释放成功,0表示锁不是自己的或者已过期
                boolean success = Long.valueOf(1).equals(result);
                if (success) {
                    log.info("成功释放锁,锁key:{},锁value:{}", fullLockKey, lockValue);
                } else {
                    log.info("释放锁失败,锁key:{},锁value:{}(可能锁已过期或不是当前锁)", fullLockKey, lockValue);
                }
                return success;
            } catch (Exception e) {
                log.error("释放锁失败", e);
                returnfalse;
            }
        }
    }

    这里要注意:释放锁必须用 Lua 脚本,因为 “判断锁是否是自己的” 和 “删除锁” 这两个操作需要原子性,不然会出现 “自己的锁被别人释放” 的问题。比如:A 机器抢到锁,执行任务时卡住了,锁过期了,B 机器抢到锁开始执行,这时候 A 机器恢复了,直接删锁,就会把 B 机器的锁删了,导致 C 机器又能抢到锁,重复执行任务。用 Lua 脚本就能避免这个问题。

    4.2.3 在定时任务里加分布式锁

    修改MinioFileCleanTask的cleanExpiredFiles方法,在执行清理逻辑前抢锁,执行完后释放锁:

    import java.util.UUID;
    
    // 其他代码不变,只修改cleanExpiredFiles方法
    public void cleanExpiredFiles() {
        log.info("==================== Minio文件清理任务开始 ====================");
        // 1. 生成锁的key和value(value用UUID,确保唯一)
        String lockKey = "minio_clean_task";
        String lockValue = UUID.randomUUID().toString();
        // 锁的过期时间:30分钟(根据清理任务的耗时调整,确保任务能执行完)
        long lockExpireTime = 30;
        TimeUnit timeUnit = TimeUnit.MINUTES;
    
        try {
            // 2. 抢锁
            boolean locked = redisLockUtils.tryLock(lockKey, lockValue, lockExpireTime, timeUnit);
            if (!locked) {
                log.info("没有抢到锁,跳过本次清理任务");
                return;
            }
    
            // 3. 执行清理逻辑(和之前一样,省略...)
            // ... 这里是之前的筛选文件、批量删除逻辑 ...
    
        } catch (Exception e) {
            log.error("Minio文件清理任务执行失败", e);
            thrownew RuntimeException("Minio文件清理任务执行失败", e);
        } finally {
            // 4. 释放锁(不管任务成功还是失败,都要释放锁)
            redisLockUtils.releaseLock(lockKey, lockValue);
            log.info("==================== Minio文件清理任务结束 ====================");
        }
    }

    这样一来,就算是集群部署,也只有一台机器能执行清理任务,避免重复执行。

    4.3 清理失败报警(及时发现问题)

    如果清理任务失败了(比如 Minio 连接不上、删除文件失败),怎么及时发现?总不能天天盯着日志看吧。咱们可以加个报警功能,比如用钉钉机器人、企业微信机器人或者邮件报警,这里以钉钉机器人为例(配置简单,消息触达快)。

    4.3.1 配置钉钉机器人
    • 打开钉钉,创建一个群,然后在群设置里找到 “智能群助手”→“添加机器人”→“自定义机器人”;
    • 给机器人起个名字(比如 “Minio 清理报警”),复制 Webhook 地址(这个地址很重要,别泄露了),然后完成创建;
    1. 在 Nacos 配置里加钉钉机器人的配置:
    dingtalk:
      robot:
        webhook: https://oapi.dingtalk.com/robot/send?access_token=xxx # 你的Webhook地址
        secret: xxx # 如果开启了签名验证,这里填secret(可选,推荐开启)
    4.3.2 实现钉钉报警工具类

    写一个钉钉报警工具类,发送报警消息:

    import cn.hutool.http.HttpRequest;
    import cn.hutool.http.HttpResponse;
    import cn.hutool.json.JSONObject;
    import cn.hutool.json.JSONUtil;
    import lombok.RequiredArgsConstructor;
    import lombok.extern.slf4j.Slf4j;
    import org.springframework.boot.context.properties.ConfigurationProperties;
    import org.springframework.stereotype.Component;
    
    import java.nio.charset.StandardCharsets;
    import java.util.Base64;
    import java.util.HashMap;
    import java.util.Map;
    
    /**
     * 钉钉机器人报警工具类
     */
    @Component
    @Slf4j
    @RequiredArgsConstructor
    @ConfigurationProperties(prefix = "dingtalk.robot")
    publicclass DingTalkAlarmUtils {
    
        privateString webhook;
        privateString secret;
    
        // getter和setter(省略)
        publicvoid setWebhook(String webhook) { this.webhook = webhook; }
        publicvoid setSecret(String secret) { this.secret = secret; }
    
        /**
         * 发送文本报警消息
         * @param content 报警内容
         */
        publicvoid sendTextAlarm(String content) {
            try {
                // 1. 如果有secret,需要计算签名(避免机器人被恶意调用)
                String finalWebhook = webhook;
                if (secret != null && !secret.isEmpty()) {
                    long timestamp = System.currentTimeMillis();
                    String stringToSign = timestamp + "\n" + secret;
                    // 计算HmacSHA256签名
                    javax.crypto.Mac mac = javax.crypto.Mac.getInstance("HmacSHA256");
                    mac.init(new javax.crypto.spec.SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
                    byte[] signData = mac.doFinal(stringToSign.getBytes(StandardCharsets.UTF_8));
                    String sign = URLEncoder.encode(Base64.getEncoder().encodeToString(signData), StandardCharsets.UTF_8.name());
                    // 拼接最终的Webhook地址
                    finalWebhook += "×tamp=" + timestamp + "&sign=" + sign;
                }
    
                // 2. 构造请求参数(钉钉机器人的文本消息格式)
                Map<String, Object> requestBody = new HashMap<>();
                requestBody.put("msgtype", "text");
                Map<String, String> text = new HashMap<>();
                text.put("content", "【Minio文件清理报警】\n" + content); // 加上前缀,方便识别
                requestBody.put("text", text);
    
                // 3. 发送POST请求
                String jsonBody = JSONUtil.toJsonStr(requestBody);
                HttpResponse response = HttpRequest.post(finalWebhook)
                        .body(jsonBody, "application/json;charset=UTF-8")
                        .execute();
    
                // 4. 处理响应
                if (response.isOk()) {
                    log.info("钉钉报警消息发送成功,内容:{}", content);
                } else {
                    log.error("钉钉报警消息发送失败,响应:{}", response.body());
                }
    
            } catch (Exception e) {
                log.error("钉钉报警消息发送异常", e);
            }
        }
    }
    4.3.3 在定时任务里加报警逻辑

    修改MinioFileCleanTask的cleanExpiredFiles方法,在任务失败或删除文件失败时发送报警:

    public void cleanExpiredFiles() {
        log.info("==================== Minio文件清理任务开始 ====================");
        String lockKey = "minio_clean_task";
        String lockValue = UUID.randomUUID().toString();
        long lockExpireTime = 30;
        TimeUnit timeUnit = TimeUnit.MINUTES;
    
        try {
            boolean locked = redisLockUtils.tryLock(lockKey, lockValue, lockExpireTime, timeUnit);
            if (!locked) {
                log.info("没有抢到锁,跳过本次清理任务");
                return;
            }
    
            // 执行清理逻辑
            MinioConfig.CleanConfig cleanConfig = minioConfig.getClean();
            // ... 省略配置校验、筛选文件的逻辑 ...
    
            List<String> expiredFiles = minioUtils.getExpiredFiles(expireDays, ignorePrefixes);
            MinioUtils.DeleteResult deleteResult = minioUtils.batchDeleteFiles(expiredFiles, maxBatchSize);
    
            // 5. 如果有删除失败的文件,发送报警
            if (deleteResult.getFailCount() > 0) {
                String alarmContent = String.format(
                        "清理任务执行完成,但部分文件删除失败!\n" +
                        "总过期文件数:%d\n" +
                        "成功删除数:%d\n" +
                        "失败删除数:%d\n" +
                        "失败文件列表:%s",
                        expiredFiles.size(),
                        deleteResult.getSuccessCount(),
                        deleteResult.getFailCount(),
                        deleteResult.getFailFiles()
                );
                dingTalkAlarmUtils.sendTextAlarm(alarmContent);
            }
    
        } catch (Exception e) {
            log.error("Minio文件清理任务执行失败", e);
            // 任务执行失败,发送报警
            String alarmContent = "清理任务执行失败!原因:" + e.getMessage();
            dingTalkAlarmUtils.sendTextAlarm(alarmContent);
            thrownew RuntimeException("Minio文件清理任务执行失败", e);
        } finally {
            redisLockUtils.releaseLock(lockKey, lockValue);
            log.info("==================== Minio文件清理任务结束 ====================");
        }
    }

    这样一来,只要清理任务失败或者有文件删除失败,钉钉就会收到报警消息,你就能及时处理问题,不用天天盯日志了。

    五、用 Quartz 实现更复杂的定时任务

    之前用@Scheduled实现定时任务,虽然简单,但有个缺点:如果想动态修改 Cron 表达式(比如今天想改成凌晨 3 点执行,明天改回 2 点),即使配置刷新了,@Scheduled也不会生效,因为@Scheduled的 Cron 表达式是在 Bean 初始化时确定的,后续修改配置不会更新。

    这时候就需要用 Quartz 了 ——Quartz 是一个强大的定时任务框架,支持动态修改任务的执行时间、暂停 / 恢复任务、集群部署等功能。咱们来看看怎么用 Quartz 实现 Minio 清理任务。

    5.1 配置 Quartz

    SpringBoot 已经集成了 Quartz,咱们只需要配置 Quartz 的数据源(用 MySQL 存储任务信息,避免服务重启后任务丢失)和任务详情。

    5.1.1 配置 Quartz 数据源

    在 Nacos 配置里加 Quartz 的数据源配置(用 MySQL 存储任务信息):

    # Quartz配置
    spring:
    quartz:
        # 任务存储方式:数据库(JDBC)
        job-store-type: JDBC
        # 启用任务调度器
        auto-startup:true
        # 任务执行线程池配置
        scheduler:
          instance-id: AUTO # 实例ID自动生成
          instance-name: MinioCleanScheduler # 调度器名称
        # JDBC配置(用MySQL存储任务信息)
        jdbc:
          initialize-schema: NEVER # 不自动初始化表结构(手动执行SQL脚本)
        # 数据源配置(可以用单独的数据源,也可以复用项目的数据源,这里复用项目的)
        properties:
          org:
            quartz:
              dataSource:
                quartzDataSource:
                  driver: com.mysql.cj.jdbc.Driver
                  URL:jdbc:mysql://localhost:3306/quartz_db?useSSL=false&serverTimezone=UTC&allowPublicKeyRetrieval=true
                  user: root
                  password:123456
                  maxConnections:10
              scheduler:
                instanceId: AUTO
              jobStore:
                class: org.quartz.impl.jdbcjobstore.JobStoreTX
                dataSource: quartzDataSource
                tablePrefix: QRTZ_# 表前缀
                isClustered:true# 开启集群(避免重复执行)
                clusterCheckinInterval:10000# 集群节点检查间隔(毫秒)
              threadPool:
                class: org.quartz.simpl.SimpleThreadPool
                threadCount:5# 线程池大小
                threadPriority:5 # 线程优先级
    5.1.2 创建 Quartz 数据库表

    Quartz 需要在 MySQL 里创建一些表来存储任务信息,官网提供了 SQL 脚本,地址:https://github.com/quartz-scheduler/quartz/blob/main/quartz-core/src/main/resources/org/quartz/impl/jdbcjobstore/tables_mysql_innodb.sql

    下载这个 SQL 脚本,在 MySQL 里创建一个数据库(比如叫quartz_db),然后执行脚本,会创建 11 张表(比如QRTZ_JOB_DETAILS、QRTZ_TRIGGERS等)。

    5.2 实现 Quartz Job

    Quartz 的核心是 Job(任务)和 Trigger(触发器):Job 是要执行的任务逻辑,Trigger 是任务的执行时间规则。咱们先实现 Job:

    import org.quartz.Job;
    import org.quartz.JobExecutionContext;
    import org.quartz.JobExecutionException;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.stereotype.Component;
    
    import lombok.extern.slf4j.Slf4j;
    
    /**
     * Minio文件清理的Quartz Job
     */
    @Component
    @Slf4j
    publicclass MinioCleanQuartzJob implements Job {
    
        // 这里用@Autowired注入,Quartz会自动装配Spring的Bean
        @Autowired
        private MinioUtils minioUtils;
    
        @Autowired
        private MinioConfig minioConfig;
    
        @Autowired
        private RedisLockUtils redisLockUtils;
    
        @Autowired
        private DingTalkAlarmUtils dingTalkAlarmUtils;
    
        @Override
        public void execute(JobExecutionContext context) throws JobExecutionException {
            log.info("==================== Minio文件清理Quartz任务开始 ====================");
            String lockKey = "minio_clean_quartz_task";
            String lockValue = UUID.randomUUID().toString();
            long lockExpireTime = 30;
            TimeUnit timeUnit = TimeUnit.MINUTES;
    
            try {
                // 抢分布式锁(集群环境避免重复执行)
                boolean locked = redisLockUtils.tryLock(lockKey, lockValue, lockExpireTime, timeUnit);
                if (!locked) {
                    log.info("没有抢到锁,跳过本次Quartz清理任务");
                    return;
                }
    
                // 执行清理逻辑(和之前一样,省略...)
                MinioConfig.CleanConfig cleanConfig = minioConfig.getClean();
                // ... 配置校验、筛选文件、批量删除、报警逻辑 ...
    
            } catch (Exception e) {
                log.error("Minio文件清理Quartz任务执行失败", e);
                String alarmContent = "Quartz清理任务执行失败!原因:" + e.getMessage();
                dingTalkAlarmUtils.sendTextAlarm(alarmContent);
                thrownew JobExecutionException("Minio文件清理Quartz任务执行失败", e);
            } finally {
                redisLockUtils.releaseLock(lockKey, lockValue);
                log.info("==================== Minio文件清理Quartz任务结束 ====================");
            }
        }
    }

    这个 Job 的逻辑和之前的定时任务逻辑差不多,只是实现了 Quartz 的Job接口,重写了execute方法。

    5.3 初始化 Quartz 任务和触发器

    接下来,写一个配置类,初始化 Quartz 的 JobDetail(任务详情)和 CronTrigger(Cron 触发器):

    import org.quartz.CronScheduleBuilder;
    import org.quartz.JobBuilder;
    import org.quartz.JobDetail;
    import org.quartz.Trigger;
    import org.quartz.TriggerBuilder;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
    import org.springframework.context.annotation.Bean;
    import org.springframework.context.annotation.Configuration;
    import org.springframework.scheduling.quartz.SchedulerFactoryBean;
    
    /**
     * Quartz任务配置类:初始化Job和Trigger
     */
    @Configuration
    @ConditionalOnProperty(prefix = "minio.clean", name = "enabled", havingValue = "true")
    publicclass MinioCleanQuartzConfig {
    
        @Autowired
        private MinioConfig minioConfig;
    
        /**
         * 创建JobDetail(任务详情)
         */
        @Bean
        public JobDetail minioCleanJobDetail() {
            return JobBuilder.newJob(MinioCleanQuartzJob.class)
                    .withIdentity("minioCleanJob", "minioCleanGroup") // 任务ID和组名
                    .storeDurably() // 即使没有触发器,也保存任务
                    .build();
        }
    
        /**
         * 创建CronTrigger(Cron触发器)
         */
        @Bean
        public Trigger minioCleanCronTrigger(JobDetail minioCleanJobDetail) {
            // 从配置文件读取Cron表达式
            String cron = minioConfig.getClean().getCron();
            return TriggerBuilder.newTrigger()
                    .forJob(minioCleanJobDetail) // 关联JobDetail
                    .withIdentity("minioCleanTrigger", "minioCleanGroup") // 触发器ID和组名
                    .withSchedule(CronScheduleBuilder.cronSchedule(cron)) // 设置Cron表达式
                    .build();
        }
    
        /**
         * 配置SchedulerFactoryBean(Quartz调度器)
         */
        @Bean
        public SchedulerFactoryBean schedulerFactoryBean(Trigger minioCleanCronTrigger) {
            SchedulerFactoryBean schedulerFactoryBean = new SchedulerFactoryBean();
            // 关联触发器
            schedulerFactoryBean.setTriggers(minioCleanCronTrigger);
            // 允许Spring的Bean注入到Quartz Job中
            schedulerFactoryBean.setAutoStartup(true);
            return schedulerFactoryBean;
        }
    }

    5.4 动态修改 Quartz 任务的 Cron 表达式

    Quartz 的优势在于支持动态修改任务的执行时间。咱们写一个接口,实现 “修改 Cron 表达式”“暂停任务”“恢复任务” 的功能:

    import org.quartz.CronScheduleBuilder;
    import org.quartz.CronTrigger;
    import org.quartz.JobKey;
    import org.quartz.Scheduler;
    import org.quartz.TriggerKey;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.web.bind.annotation.PostMapping;
    import org.springframework.web.bind.annotation.RequestBody;
    import org.springframework.web.bind.annotation.RequestMapping;
    import org.springframework.web.bind.annotation.RestController;
    
    import lombok.Data;
    import lombok.extern.slf4j.Slf4j;
    
    /**
     * Quartz任务管理接口(动态修改任务)
     */
    @RestController
    @RequestMapping("/minio/quartz")
    @Slf4j
    publicclass QuartzManageController {
    
        @Autowired
        private Scheduler scheduler;
    
        // 任务和触发器的ID、组名(要和配置类里的一致)
        privatestatic final String JOB_NAME = "minioCleanJob";
        privatestatic final String JOB_GROUP = "minioCleanGroup";
        privatestatic final String TRIGGER_NAME = "minioCleanTrigger";
        privatestatic final String TRIGGER_GROUP = "minioCleanGroup";
    
        /**
         * 动态修改Cron表达式
         */
        @PostMapping("/updateCron")
        publicString updateCron(@RequestBody CronUpdateDTO dto) {
            try {
                // 1. 获取触发器
                TriggerKey triggerKey = TriggerKey.triggerKey(TRIGGER_NAME, TRIGGER_GROUP);
                CronTrigger trigger = (CronTrigger) scheduler.getTrigger(triggerKey);
                if (trigger == null) {
                    return"触发器不存在";
                }
    
                // 2. 修改Cron表达式
                String newCron = dto.getNewCron();
                CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.cronSchedule(newCron);
                trigger = trigger.getTriggerBuilder()
                        .withIdentity(triggerKey)
                        .withSchedule(scheduleBuilder)
                        .build();
    
                // 3. 重新部署触发器
                scheduler.rescheduleJob(triggerKey, trigger);
                log.info("成功修改Quartz任务的Cron表达式,旧Cron:{},新Cron:{}",
                        trigger.getCronExpression(), newCron);
                return"修改Cron表达式成功,新Cron:" + newCron;
    
            } catch (Exception e) {
                log.error("修改Quartz任务Cron表达式失败", e);
                return"修改失败:" + e.getMessage();
            }
        }
    
        /**
         * 暂停任务
         */
        @PostMapping("/pauseJob")
        publicString pauseJob() {
            try {
                JobKey jobKey = JobKey.jobKey(JOB_NAME, JOB_GROUP);
                scheduler.pauseJob(jobKey);
                log.info("成功暂停Quartz任务:{}:{}", JOB_GROUP, JOB_NAME);
                return"暂停任务成功";
            } catch (Exception e) {
                log.error("暂停Quartz任务失败", e);
                return"暂停失败:" + e.getMessage();
            }
        }
    
        /**
         * 恢复任务
         */
        @PostMapping("/resumeJob")
        publicString resumeJob() {
            try {
                JobKey jobKey = JobKey.jobKey(JOB_NAME, JOB_GROUP);
                scheduler.resumeJob(jobKey);
                log.info("成功恢复Quartz任务:{}:{}", JOB_GROUP, JOB_NAME);
                return"恢复任务成功";
            } catch (Exception e) {
                log.error("恢复Quartz任务失败", e);
                return"恢复失败:" + e.getMessage();
            }
        }
    
        /**
         * DTO:修改Cron表达式的请求参数
         */
        @Data
        publicstaticclass CronUpdateDTO {
            privateString newCron; // 新的Cron表达式
        }
    }

    这样一来,你就可以通过调用/minio/quartz/updateCron接口,动态修改清理任务的执行时间,不用重启服务。比如把 Cron 从0 0 2 * * ?改成0 0 3 * * ?,任务就会从凌晨 2 点改成 3 点执行。

    六、总结:一套完整的 Minio 清理方案

    到这里,咱们的 “SpringBoot + Minio 定时清理历史文件” 方案就完整了,总结一下这套方案的核心亮点:

    1. 基础功能完善:支持按过期天数、忽略前缀清理文件,批量删除避免 Minio 压力过大;
    2. 配置动态刷新:用 Nacos 实现配置动态修改,不用重启服务;
    3. 集群安全执行:用 Redis 分布式锁避免集群环境重复执行;
    4. 问题及时发现:用钉钉机器人报警,任务失败或文件删除失败时及时通知;
    5. 复杂场景支持:用 Quartz 实现动态修改执行时间、暂停 / 恢复任务,满足复杂需求。

    这套方案不仅能解决 Minio 文件清理的问题,还能作为 “定时任务” 的通用模板 —— 比如后续要做 “定时清理数据库历史数据”“定时生成报表”,都可以参考这套思路,改改核心逻辑就能用。

    最后,再给大家提几个生产环境的小建议:

    1. 测试充分:上线前一定要在测试环境模拟大量文件(比如 1 万个),测试清理任务的性能和稳定性;
    2. 日志详细:把清理过程的关键步骤都记日志,方便后续排查问题;
    3. 备份重要文件:如果有重要文件,清理前最好先备份,避免误删(可以在清理前把文件复制到另一个桶);
    4. 逐步放量:第一次执行清理任务时,可以先把expire-days设大一点(比如 180 天),先清理 oldest 的文件,观察没问题再缩小天数。

    AI大模型学习福利

    作为一名热心肠的互联网老兵,我决定把宝贵的AI知识分享给大家。 至于能学习到多少就看你的学习毅力和能力了 。我已将重要的AI大模型资料包括AI大模型入门学习思维导图、精品AI大模型学习书籍手册、视频教程、实战学习等录播视频免费分享出来。

    一、全套AGI大模型学习路线

    AI大模型时代的学习之旅:从基础到前沿,掌握人工智能的核心技能!

    因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获取

    二、640套AI大模型报告合集

    这套包含640份报告的合集,涵盖了AI大模型的理论研究、技术实现、行业应用等多个方面。无论您是科研人员、工程师,还是对AI大模型感兴趣的爱好者,这套报告合集都将为您提供宝贵的信息和启示。

    因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获

    三、AI大模型经典PDF籍

    随着人工智能技术的飞速发展,AI大模型已经成为了当今科技领域的一大热点。这些大型预训练模型,如GPT-3、BERT、XLNet等,以其强大的语言理解和生成能力,正在改变我们对人工智能的认识。 那以下这些PDF籍就是非常不错的学习资源。


    因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获

    四、AI大模型商业化落地方案

    因篇幅有限,仅展示部分资料,需要点击文章最下方名片即可前往获

    作为普通人,入局大模型时代需要持续学习和实践,不断提高自己的技能和认知水平,同时也需要有责任感和伦理意识,为人工智能的健康发展贡献力量

    评论
    添加红包

    请填写红包祝福语或标题

    红包个数最小为10个

    红包金额最低5元

    当前余额3.43前往充值 >
    需支付:10.00
    成就一亿技术人!
    领取后你会自动成为博主和红包主的粉丝 规则
    hope_wisdom
    发出的红包
    实付
    使用余额支付
    点击重新获取
    扫码支付
    钱包余额 0

    抵扣说明:

    1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
    2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

    余额充值