精品欧美一区二区三区在线观看 _久久久久国色av免费观看性色_国产精品久久在线观看_亚洲第一综合网站_91精品又粗又猛又爽_小泽玛利亚一区二区免费_91亚洲精品国偷拍自产在线观看 _久久精品视频在线播放_美女精品久久久_欧美日韩国产成人在线

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 的文件,觀察沒問題再縮小天數。
責任編輯:武曉燕 來源: 石杉的架構筆記
相關推薦

2025-07-29 09:36:51

2025-07-07 03:00:00

2022-07-14 08:36:28

NacosApollo長輪詢

2024-12-13 16:01:35

2021-04-22 09:56:32

MYSQL開發數據庫

2022-08-01 07:02:06

SpringEasyExcel場景

2022-05-31 09:42:49

工具編輯器

2024-05-11 09:38:05

React編譯器React 19

2025-09-10 07:57:44

SpringBootMinio存儲

2020-06-23 15:58:42

心電圖

2020-12-29 10:45:55

開發設計代碼

2022-09-06 10:52:04

正則庫HumrePython

2021-08-11 09:33:15

Vue 技巧 開發工具

2022-05-11 14:43:37

WindowsPython服務器

2021-09-10 10:15:24

Python人臉識別AI

2021-03-18 10:12:54

JavaCompletable字符串

2021-03-02 20:42:20

實戰策略

2020-11-10 06:11:59

工具軟件代碼

2022-07-25 06:42:24

分布式鎖Redis

2022-06-28 07:14:23

WizTree磁盤文件清理
點贊
收藏

51CTO技術棧公眾號

亚洲性视频网站| fc2成人免费人成在线观看播放| 免费人成精品欧美精品| 夜夜嗨av一区二区三区免费区| 欧美午夜aaaaaa免费视频| 色老头视频在线观看| 国产精品影音先锋| 欧美重口另类videos人妖| 国产免费嫩草影院| 国产精品久久久网站| 欧美一级一片| 成人激情动漫在线观看| 日本精品视频在线| 黄色片在线观看网站| 神马香蕉久久| 欧美一级生活片| 在线成人中文字幕| 亚洲国产精品久久久久爰色欲| 3d成人动漫在线| 99这里只有精品| 91精品免费久久久久久久久| 800av免费在线观看| 999久久久91| 亚洲欧美三级在线| 日本天堂在线播放| 疯狂欧洲av久久成人av电影| 日韩欧亚中文在线| 福利视频一二区| 超碰个人在线| 欧美韩国日本综合| 麻豆av一区二区| 亚洲精品免费在线观看视频| 久久超碰97人人做人人爱| 日韩免费在线免费观看| 青青操免费在线视频| 亚洲最大黄网| 日韩小视频在线| www色com| 国产精品一区高清| 亚洲欧美成人网| 天天插天天射天天干| 91蝌蚪精品视频| 天堂成人娱乐在线视频免费播放网站 | 精品福利在线导航| 九九热精品在线播放| 天堂а√在线最新版中文在线| 亚洲免费电影在线| 欧美 另类 交| 日本三级视频在线播放| 中文字幕av一区二区三区高 | 欧美一级做性受免费大片免费| 国产专区欧美精品| 国产欧美日韩免费看aⅴ视频| 国产第一页在线观看| 性欧美暴力猛交另类hd| 欧美亚洲在线观看| 99久热在线精品996热是什么| 一本综合精品| 欧美在线视频a| 久久99国产综合精品免费| 羞羞答答国产精品www一本| 国产91精品高潮白浆喷水| aaa人片在线| 日韩精品91亚洲二区在线观看| 国产精品9999| 亚洲天堂aaa| 黑人精品欧美一区二区蜜桃| 91亚色免费| 国精品人妻无码一区二区三区喝尿| 激情久久99| 久久99九九99精品| 国产一区私人高清影院| 国产精品国产精品国产专区| 国内精品伊人久久久久av一坑| 91久久久久久久久| 精品国产九九九| 成人自拍视频网| 日本不卡中文字幕| 国产精品美女久久久久av超清| 波多野结衣家庭主妇| 日本成人在线不卡视频| 91麻豆国产精品| 欧美熟妇交换久久久久久分类| 久久99久久亚洲国产| 神马影院我不卡| 成人影院免费观看| 国产精品青草久久| 福利在线小视频| 国产不卡123| 在线观看三级视频欧美| 91蜜桃臀久久一区二区| 亚洲最大色网站| 久久av高潮av| 欧美成人资源| 欧美一区二区视频在线观看2020| 四虎国产精品免费| 丁香婷婷激情网| 国产乱码精品一区二区三区精东| 美国毛片一区二区三区| 91亚洲国产成人精品性色| av在线亚洲天堂| 91亚洲国产成人精品一区二三| 欧美一区二区三区成人久久片| 日本三级免费网站| 国产熟女精品视频| 成人黄色大片在线观看| 欧美日韩精品免费看| 毛片在线播放a| 午夜亚洲国产au精品一区二区| 免费日韩视频在线观看| 久久久精品区| 欧美aaa视频| 91精品国产一区二区| xfplay5566色资源网站| 欧美性感美女一区二区| 欧美日韩福利电影| 中文字幕一级片| 成人免费看黄yyy456| 国产日韩欧美视频| 亚洲精品卡一卡二| 亚洲精选久久| 国产精品视频区1| 日本wwwxxxx| 亚洲人成精品久久久久| 热久久精品国产| 国产成人福利av| 久久综合伊人77777蜜臀| 岛国av免费观看| 波多野结衣在线观看一区二区三区| 欧美极品美女视频网站在线观看免费 | 欧美特黄aaaaaa| 丁香花在线影院| 欧美疯狂性受xxxxx喷水图片| 国产美女免费无遮挡| 亚洲黄色免费| 国产精品12| 欧美一卡二卡| 日韩情涩欧美日韩视频| 91视频青青草| 激情综合网av| 91亚洲国产成人精品一区二区三 | 国产精品亚洲综合天堂夜夜| 日韩av一二区| 韩国欧美一区| 亚洲自拍偷拍区| www污在线观看| 亚洲免费资源| 久久精品99久久久久久久久 | 激情文学一区| 91麻豆精品秘密入口| 影音先锋在线播放| 91精品欧美一区二区三区综合在 | 无码成人精品区在线观看| 激情综合激情| 一本色道久久综合亚洲精品小说 | 一区二区高清视频在线观看| xxww在线观看| 亚洲天堂一区在线| 狠狠色综合播放一区二区| 亚洲福利av| 91p九色成人| 日韩小视频在线| 国产三级漂亮女教师| 中文字幕一区视频| 师生出轨h灌满了1v1| 亚洲天堂偷拍| 免费看成人av| av成人在线看| 欧美成人免费在线观看| 日本激情一区二区| 色综合天天综合网国产成人综合天 | 国产厕拍一区| 日韩av手机在线看| 男人的天堂在线视频免费观看| 91.com在线观看| 久久综合久久鬼| 26uuu另类欧美| 欧美在线aaa| 亚洲激情午夜| 日韩欧美一区二区视频在线播放| 9999精品视频| 68精品久久久久久欧美 | 日本一区二区综合亚洲| www激情五月| 欧美亚洲自偷自偷| 中文字幕一区二区三区四区五区六区| 精品国产一区二| 2019精品视频| 黄网站在线播放| 亚洲爱爱爱爱爱| 真实新婚偷拍xxxxx| 一区二区三区日本| xxx在线播放| 国产成a人亚洲| 亚洲综合在线网站| 欧美三级特黄| 亚洲一区二区三区加勒比| 国产一级成人av| 国产精品视频色| 日本黄色免费在线| 久久久999国产| 清纯唯美亚洲色图| 精品欧美一区二区在线观看| 羞羞色院91蜜桃| 精品国产精品三级精品av网址| 成年人视频软件| 久久综合九色综合97婷婷| 北条麻妃亚洲一区| 久久一区亚洲| 日本a视频在线观看| 色综合久久网| 欧洲亚洲一区二区三区四区五区| 国产精品白浆| 91免费高清视频| 欧美日一区二区三区| 97av在线视频| 成人免费一区二区三区牛牛| 中文字幕在线亚洲| 男女污污视频在线观看| 欧美精品一区二区三区在线 | 香蕉成人影院| 2023亚洲男人天堂| 欧美性爽视频| 久久综合电影一区| 91caoporn在线| 日韩精品在线免费播放| 亚洲国产精品国自产拍久久| 91精品国产一区二区三区蜜臀| 中文亚洲av片在线观看| 在线免费不卡视频| 亚洲另类在线观看| 午夜电影久久久| 18精品爽视频在线观看| 亚洲免费成人av| 欧美色图一区二区| 亚洲人xxxx| 日韩激情综合网| 亚洲色图视频网| 五月天色婷婷丁香| 综合久久综合久久| 亚洲xxxx3d动漫| 亚洲人成网站色在线观看| 国产一二三区精品| 日韩毛片高清在线播放| 日本 欧美 国产| 中文字幕在线一区二区三区| 中日韩一级黄色片| 亚洲人成网站色在线观看| 动漫性做爰视频| 亚洲美女偷拍久久| 久久久久久久国产视频| 亚洲一二三四在线观看| 国产一级中文字幕| 亚洲第一av色| 国产综合精品视频| 无吗不卡中文字幕| 日韩精品一区二区亚洲av| 日韩欧美国产免费播放| 伊人成年综合网| 欧美顶级少妇做爰| 99精品视频免费看| 日韩精品一区二区在线| 午夜在线视频免费| 亚洲午夜精品久久久久久性色| 在线免费黄色| 欧美成人小视频| 91九色porn在线资源| 午夜精品一区二区三区视频免费看| 成人影院入口| 国产欧美va欧美va香蕉在线| 久久久久久久久成人| 国产伦视频一区二区三区| 最近国产精品视频| 台湾成人av| 综合激情一区| 欧美性大战久久久久xxx| 日韩成人av影视| 手机在线观看日韩av| 99在线热播精品免费| 久久久久久久毛片| 亚洲欧美激情在线| 日本学生初尝黑人巨免费视频| 日本道色综合久久| 国产精品久久久久久免费播放| 精品噜噜噜噜久久久久久久久试看| 日韩电影网址| 俺去了亚洲欧美日韩| 毛片大全在线观看| 国产精品第一页在线| 日韩精品亚洲专区在线观看| 欧美一区二区三区电影在线观看 | 三上悠亚在线一区二区| 成人一区在线看| 谁有免费的黄色网址| 亚洲综合丁香婷婷六月香| 看黄色一级大片| 精品日韩一区二区三区| 18免费在线视频| 午夜免费久久久久| 国产精品白丝久久av网站| 就去色蜜桃综合| 午夜精品免费| 欧美一级xxxx| 国产无一区二区| 日韩成人av毛片| 91精品国产综合久久久久久久| 青青草手机在线| 九九九久久久久久| 国产精品第一国产精品| 久久青青草综合| 国内精品美女在线观看| 中文字幕日韩综合| 久久嫩草精品久久久精品| 精品无码久久久久久久久| 欧美久久高跟鞋激| 国产尤物视频在线| 78色国产精品| 97色成人综合网站| 正义之心1992免费观看全集完整版| 性色一区二区三区| 捆绑凌虐一区二区三区| 亚洲毛片av在线| 国产一区二区在线视频观看| 中文字幕不卡在线视频极品| 波多视频一区| 久久久久久国产精品mv| 伊人成人在线| 蜜桃视频无码区在线观看| 日韩码欧中文字| 一级做a爱片久久毛片| 伊人久久综合97精品| 在线亚洲人成| 另类欧美小说| 六月婷婷一区| 蜜桃av免费看| 91高清视频免费看| 久草福利在线视频| 日本久久久久久久久久久| 午夜先锋成人动漫在线| 99热自拍偷拍| 91免费看片在线观看| 中文字幕亚洲高清| 日韩av在线电影网| 无码小电影在线观看网站免费| 精品欧美日韩在线| 国产日韩免费| 国产全是老熟女太爽了| 日韩欧美在线第一页| 欧美黄色小说| 国产精品久久视频| 91日韩视频| 一级 黄 色 片一| 夜色激情一区二区| 欧美 日韩 国产 精品| 2019av中文字幕| 成人av动漫在线观看| 亚洲小视频网站| 亚洲精品乱码久久久久久黑人| www.av黄色| 69久久夜色精品国产69| 九九视频免费观看视频精品| 91小视频网站| 亚洲伦在线观看| 五十路在线视频| 国产精品精品国产| 99视频精品全部免费在线视频| 色婷婷综合在线观看| 亚洲成人你懂的| 春暖花开成人亚洲区| 91免费欧美精品| 精品动漫3d一区二区三区免费| 亚洲第一香蕉网| 色吊一区二区三区| 顶级网黄在线播放| 国产精品午夜av在线| 日韩精品一级中文字幕精品视频免费观看 | 午夜a一级毛片亚洲欧洲| 99热手机在线| 亚洲免费观看高清完整版在线 | av不卡一区| av免费网站观看| 一区二区三区色| 国产片在线观看| 91在线免费看片| 天堂一区二区在线| 青青草免费av| 亚洲欧美中文日韩在线| 精品一区二区三区四区五区| 波多野结衣家庭教师在线| 国产精品国产三级国产普通话三级 | 国产老肥熟一区二区三区| 日韩欧美性视频| 日韩日本欧美亚洲| 久久97久久97精品免视看秋霞| 我要看一级黄色大片| 亚洲一区免费在线观看| a视频网址在线观看| 国内精品视频在线播放| 精品一区在线看| 午夜婷婷在线观看|