精品欧美一区二区三区在线观看 _久久久久国色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技術棧公眾號

六十路息与子猛烈交尾| 丰满爆乳一区二区三区| www.四虎在线观看| 韩国在线一区| 亚洲精品一区二三区不卡| 黄色片在线免费| gogo在线观看| 91蜜桃网址入口| 国产精品入口福利| 黄色一级片中国| 免费精品国产的网站免费观看| 欧美少妇性性性| 国产日韩欧美精品在线观看| 国产精品视频一区二区久久| 国产成人亚洲精品狼色在线| 国产精品露脸av在线| 欧美日韩在线国产| 欧美伦理影院| 精品国产乱码久久久久久图片| wwwwxxxx日韩| 亚洲妇女成熟| 一区二区成人在线视频 | 99久久精品国产色欲| 激情六月综合| 久久天天躁狠狠躁夜夜爽蜜月| 亚洲熟女乱综合一区二区三区| 日韩免费在线电影| 欧美性xxxx极品hd满灌| 青青草视频国产| 大地资源中文在线观看免费版| av在线免费不卡| 91精品视频在线看| 青青草视频在线观看免费| 亚洲一本视频| 欧美成人在线免费视频| 国产探花视频在线播放| 亚洲bt欧美bt精品777| 日韩欧美一区中文| 亚洲欧美日韩三级| 在线成人视屏| 91精品办公室少妇高潮对白| 欧美啪啪免费视频| 国产蜜臀一区二区打屁股调教| 中文字幕av资源一区| 欧美性bbwbbwbbwhd| 人妻少妇精品无码专区久久| 久久草av在线| 国产精品久久一| 无码一区二区三区| 久久av在线| 欧美一区三区三区高中清蜜桃| 精品无码人妻一区二区三区| 国产精品xvideos88| 另类视频在线观看| 国语对白在线播放| 中文av一区| 欧美刺激性大交免费视频| 亚洲波多野结衣| 亚洲影视一区| 欧美成人精品在线观看| 国产jizz18女人高潮| 国产大片一区| 久久视频国产精品免费视频在线| 欧美性x x x| 欧美极品一区二区三区| 九九精品视频在线观看| 欧美成人精品一区二区免费看片| 欧美日韩伊人| 久久久久久久网站| 国产一区二区三区影院| 欧美综合国产| 国产美女91呻吟求| 91成年人视频| 国产精品系列在线观看| 国产精品日韩一区二区| 天堂中文在线资源| 久久久久99精品国产片| 亚洲欧洲国产日韩精品| gogogogo高清视频在线| 亚洲超碰精品一区二区| 97视频在线免费播放| 精品欧美日韩精品| 91精品国产91综合久久蜜臀| 国产国语老龄妇女a片| 一区二区美女| 久久亚洲国产精品| 日韩伦人妻无码| 青青草原综合久久大伊人精品优势| 国产欧美精品一区二区三区介绍 | 日韩女优毛片在线| 欧美精品欧美极品欧美激情| 日韩www.| 国外成人免费在线播放| 中文字幕 视频一区| 国产精品一区二区黑丝| 久久精品国产精品国产精品污| 中文字幕在线观看日本| 亚洲国产精品一区二区久久 | 中文在线免费二区三区| 欧美另类z0zxhd电影| 国产精品亚洲一区二区无码| 欧美日韩激情| 欧美激情第一页xxx| 亚洲大尺度在线观看| 国产精品系列在线播放| 日韩三级电影| 激情网站在线| 欧美日韩精品是欧美日韩精品| 免费看三级黄色片| 成人网18免费网站| 国内外成人免费激情在线视频| 中文字幕91爱爱| www.亚洲激情.com| 影音先锋欧美资源| 一二三四视频在线中文| 日韩欧美自拍偷拍| 国产成人免费观看网站| 亚洲精品美女| 91精品免费| 色综合久久久久综合一本到桃花网| 婷婷亚洲久悠悠色悠在线播放| 午夜精品免费看| 九一国产精品| 欧美一区亚洲一区| 超碰在线观看99| 中文字幕一区二区三区av| 国产精品宾馆在线精品酒店| 9l亚洲国产成人精品一区二三| 色综合影院在线| 波多野结衣不卡| 91免费国产在线观看| 妞干网视频在线观看| 白嫩亚洲一区二区三区| 在线亚洲男人天堂| 国产亚洲欧美日韩高清| 99国产一区二区三精品乱码| 亚洲国产精品无码av| 精品久久亚洲| 久久久精品美女| 91精品国自产| 国产精品初高中害羞小美女文| 欧美污视频网站| 亚洲系列另类av| 欧美在线视频一区二区| 污污网站在线免费观看| 亚欧色一区w666天堂| 怡红院一区二区| 在线高清一区| 精品国产福利| 色在线视频观看| 日韩精品中文字| 日本视频在线观看免费| 国产网站一区二区| 国产一区亚洲二区三区| 国产精品亚洲片在线播放| 青青久久aⅴ北条麻妃| 男人久久精品| 日本韩国视频一区二区| 亚洲女优在线观看| 蜜桃视频在线一区| 人人妻人人澡人人爽精品欧美一区| 久久久加勒比| 久久精品99久久久久久久久| 国产手机视频在线| 亚洲国产综合视频在线观看| www.日本高清| 久久中文在线| 亚洲激情图片| 日韩精品免费视频一区二区三区 | 涩涩涩999| 久久爱.com| 美女少妇精品视频| 黄色小视频免费观看| 午夜激情一区二区三区| 短视频在线观看| 美女脱光内衣内裤视频久久影院| 中文字幕人成一区| 亚洲国产精品免费视频| 韩国三级电影久久久久久| 三级av在线播放| 欧美亚洲自拍偷拍| 国产精品久久久久久久精| 大胆亚洲人体视频| 男人亚洲天堂网| 天天天综合网| 国产在线一区二区三区播放| 欧美片第一页| 成年无码av片在线| 亚洲欧美日本在线观看| 欧美日韩国产一级二级| 久久婷婷综合国产| 国产亚洲福利社区一区| 日本女人黄色片| 羞羞视频在线观看欧美| 黄色高清视频网站| 日韩免费电影在线观看| 91在线视频导航| 自拍在线观看| 欧美日韩成人在线观看| av女优在线| 欧美精品一区二区在线播放| 中文无码精品一区二区三区| 亚洲成人在线观看视频| 日韩一区二区三区四区视频| 波多野结衣中文字幕一区| 最新中文字幕免费视频| 亚洲精品美女91| 中文字幕一区二区三区最新| 亚洲另类春色校园小说| 亚洲伊人一本大道中文字幕| 欧美男体视频| 国模私拍一区二区三区| 黄网址在线观看| 亚洲最新视频在线| 神马午夜电影一区二区三区在线观看| 欧美亚洲动漫精品| 日韩欧美亚洲视频| 亚洲男人的天堂网| 五月天精品在线| 99精品热视频| 亚洲欧美激情一区二区三区| 久久精品免费看| 免费激情视频在线观看| 亚洲人体偷拍| 免费在线看黄色片| 一区二区三区午夜探花| 亚洲人体一区| 精品国产乱码久久久久久蜜坠欲下| 国产自产精品| 99久热这里只有精品视频免费观看| 91精品综合久久久久久五月天| 日本精品在线中文字幕| 欧美综合在线第二页| sis001亚洲原创区| 色综合久综合久久综合久鬼88| 理论片午午伦夜理片在线播放| 中文字幕久精品免费视频| 黄色片免费在线| 亚洲欧美中文字幕| 瑟瑟在线观看| 日韩精品视频免费| 午夜福利视频一区二区| 精品91自产拍在线观看一区| 亚洲精品久久久蜜桃动漫| 欧美一区二区日韩| 性中国xxx极品hd| 日韩欧美的一区| 日本成人动漫在线观看| 亚洲电影在线观看| 色婷婷av一区二区三| 亚洲精品第一国产综合精品| 神马久久久久久久久久| 日韩精品视频免费| 久草福利在线| 最近2019中文字幕一页二页| 日本中文字幕伦在线观看| 精品国产欧美一区二区三区成人| 精品欧美色视频网站在线观看| 日韩亚洲第一页| av免费网站在线观看| 久久99热精品| 国产夫妻在线播放| 日本欧美精品在线| 日本精品另类| 91美女片黄在线观看游戏| 精品国产亚洲一区二区三区在线 | 一区二区三区视频免费观看| 欧美日韩国产精品一区二区| 欧美午夜精彩| 成人在线观看www| 很黄很黄激情成人| 久久美女福利视频| 日本在线不卡视频| 男女污污视频网站| 成人在线视频一区二区| av直播在线观看| 国产精品美女久久久久久久| 性色av无码久久一区二区三区| 无码av中文一区二区三区桃花岛| 亚洲黄色免费观看| 91精品国产综合久久福利| 人妻视频一区二区三区| 中文字幕最新精品| 免费在线播放电影| 国产精品999| 日韩一区二区三区色 | 日韩一区欧美| 精品国产av无码一区二区三区| 久久亚洲影院| 黄色a级三级三级三级| 久久婷婷综合激情| 日韩在线视频网址| 色综合视频在线观看| 国产丝袜视频在线观看| 亚洲美女激情视频| av片哪里在线观看| 国产www精品| y111111国产精品久久久| 天天好比中文综合网| 影音先锋日韩资源| 亚洲欧美日韩综合网| 成人激情文学综合网| 国产黄色片在线| 五月天国产精品| 国产精品久久久久久在线| 亚洲激情视频在线观看| 成人黄视频在线观看| 日本高清不卡的在线| 亚洲国产欧美在线观看| 亚洲一区精品视频| 性感少妇一区| 日本50路肥熟bbw| 综合色天天鬼久久鬼色| 特级做a爱片免费69| 欧美xxxx在线观看| 欧美精品日韩少妇| 日韩av电影中文字幕| 国产成人精品福利| 综合操久久久| 美女诱惑一区二区| a级在线观看视频| 亚洲一卡二卡三卡四卡无卡久久| 91麻豆视频在线观看| 国产午夜精品一区二区三区| 男人的天堂免费在线视频| 97人人模人人爽人人少妇| 91欧美在线| 性猛交ⅹ×××乱大交| 久久精品亚洲麻豆av一区二区| 国产一级视频在线| 日韩欧美国产一区二区在线播放 | 亚洲色图19p| 中文字幕在线观看国产| 亚洲欧美中文日韩在线v日本| 极品美鲍一区| 国产精品视频福利| 欧美激情在线| 伊人av在线播放| 亚洲少妇屁股交4| 国产精品久久久久久久免费| 日韩亚洲欧美中文在线| 青娱乐极品盛宴一区二区| 日韩欧美精品在线不卡| 日韩avvvv在线播放| 麻豆精品免费视频| 色偷偷久久一区二区三区| 免费在线观看一级毛片| 国产成+人+综合+亚洲欧洲 | 9999在线观看| 精品午夜久久福利影院| 成年人二级毛片| 欧美一级理论片| 性欧美ⅴideo另类hd| 91成人免费在线观看| 欧美国产91| 国产艳妇疯狂做爰视频| 精品国产31久久久久久| 性xxxx18| 国产精品国内视频| 日韩av密桃| 波多野结衣网页| 亚洲国产一区二区在线播放| 男人天堂手机在线观看| 欧美亚洲在线播放| 国语产色综合| 亚洲视频第二页| 一区二区成人在线视频| 五月婷婷深深爱| 国产精品久久久久久久久久尿 | 麻豆国产一区| 日韩视频在线视频| 91麻豆精品视频| 久久影视中文字幕| 久久久精品在线观看| a看欧美黄色女同性恋| 高清在线观看免费| 国产精品理论在线观看| 亚洲精品综合久久| 91av国产在线| 日韩欧美高清在线播放| 国产精品欧美性爱| 日韩欧美成人区| 免费高清完整在线观看| 国产欧美综合精品一区二区| 久久成人在线| 欧美做爰啪啪xxxⅹ性| 日韩av在线最新| 精品国产鲁一鲁****| 黄色影院一级片| 亚洲欧洲成人av每日更新| 国产91免费在线观看| 国产精品一区av| 亚洲日本国产| 国产精品国产三级国产传播| 精品国产91久久久久久久妲己 | 超碰个人在线| 麻豆视频成人| 国产成人精品一区二区三区四区 | 精品sm在线观看| 日本成人一区二区|