偷偷摘套内射激情视频,久久精品99国产国产精,中文字幕无线乱码人妻,中文在线中文a,性爽19p

SpringBoot + Minio 定時清理歷史文件,太好用了!

開發(fā) 前端
這套方案不僅能解決 Minio 文件清理的問題,還能作為 “定時任務(wù)” 的通用模板 —— 比如后續(xù)要做 “定時清理數(shù)據(jù)庫歷史數(shù)據(jù)”“定時生成報表”,都可以參考這套思路,改改核心邏輯就能用。

兄弟們,不知道你們有沒有過這種崩潰時刻:生產(chǎn)環(huán)境的 Minio 服務(wù)器用著用著,突然告警 “磁盤空間不足”,登上去一看 —— 好家伙,半年前的測試文件、過期的臨時緩存、還有同事誤傳的超大日志文件堆得像小山,手動刪不僅費(fèi)時間,還怕手抖刪錯生產(chǎn)數(shù)據(jù),簡直是 “刪也不是,不刪也不是” 的大型糾結(jié)現(xiàn)場。

我前陣子就踩過這坑,當(dāng)時連夜加班刪文件,刪到凌晨三點(diǎn)眼睛都花了,心里暗自發(fā)誓:必須整個全自動的清理方案!折騰了幾天,終于搞出了 “SpringBoot + Minio 定時清理歷史文件” 的一套組合拳,現(xiàn)在每天到點(diǎn)自動干活,再也不用跟一堆過期文件較勁。今天就把這套方案掰開揉碎了講,從基礎(chǔ)到進(jìn)階,保證大白話到底,就算是剛接觸 Minio 的新手也能跟著做,看完記得收藏,說不定下次你就用得上!

一、先嘮嘮:為啥非要用 Minio?又為啥要定時清理?

在講怎么實(shí)現(xiàn)之前,先跟大家掰扯清楚兩個事兒:Minio 到底好用在哪?還有為啥非得定時清理,手動刪不行嗎?

先說說 Minio,這玩意兒在對象存儲領(lǐng)域那可是 “輕量級王者”—— 不用裝復(fù)雜的集群環(huán)境,單機(jī)版雙擊就能跑,集群版幾行命令就能搭,而且跟 S3 協(xié)議兼容,以后想遷移到 AWS S3 也方便。咱們 Java 項(xiàng)目里用它存?zhèn)€用戶頭像、Excel 報表、日志文件啥的,簡直不要太順手。

但問題也來了:Minio 這東西 “記吃不記打”,你存多少文件它就留多少,哪怕是三個月前的測試數(shù)據(jù)、24 小時就過期的臨時二維碼,它也絕不主動刪。時間一長,磁盤空間就跟你手機(jī)相冊一樣,不知不覺就滿了。

有人說:“我手動刪不就行?” 兄弟,你要是天天有空盯著還行,要是趕上周末或者節(jié)假日,磁盤滿了直接影響業(yè)務(wù),你就得從被窩里爬起來遠(yuǎn)程處理 —— 我上次國慶就因?yàn)檫@事兒,在老家農(nóng)家樂對著手機(jī)改配置,老板還以為我在偷偷談大生意。更要命的是,手動刪容易出錯,我之前有個同事,想刪 “test_202401” 開頭的測試文件,結(jié)果手滑寫成了 “test_2024”,直接把 2024 年的正式文件全刪了,當(dāng)天就提著電腦去財(cái)務(wù)那結(jié)工資了,咱可別學(xué)他。

所以啊,搞個 SpringBoot 定時任務(wù),自動清理 Minio 里的歷史文件,不僅省時間,還能避免人為失誤,簡直是 “一勞永逸” 的好辦法。

二、基礎(chǔ)準(zhǔn)備:先把 Minio 和 SpringBoot 搭起來

要做定時清理,首先得讓 SpringBoot 能跟 Minio “對話”—— 也就是集成 Minio 客戶端。這一步不難,跟著我一步步來,保證不踩坑。

2.1 先整個 Minio 環(huán)境(本地測試用)

如果你還沒有 Minio 環(huán)境,先整個本地版玩玩,步驟超簡單:

  1. 去 Minio 官網(wǎng)下載對應(yīng)系統(tǒng)的安裝包(官網(wǎng)地址:https://min.io/ ,別下錯了,Windows 就下 exe,Linux 就下 tar.gz);
  2. 解壓后,打開命令行,進(jìn)入解壓目錄,執(zhí)行啟動命令:
  • 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 控制臺的端口,默認(rèn)是 9000,怕跟其他服務(wù)沖突,咱改個 9001。

  • 啟動成功后,命令行會顯示默認(rèn)賬號和密碼(都是 minioadmin),還有控制臺地址(http://localhost:9001);
  • 打開瀏覽器訪問控制臺,輸入賬號密碼登錄,然后創(chuàng)建一個桶(Bucket),比如叫 “file-bucket”—— 這就相當(dāng)于 Minio 里的 “文件夾”,以后咱們的文件都存在這里面。

搞定!本地 Minio 環(huán)境就搭好了,是不是比搭 MySQL 還簡單?

2.2 SpringBoot 集成 Minio 客戶端

接下來,讓 SpringBoot 能操作 Minio,核心是引入 Minio 的依賴,再配置客戶端。

2.2.1 引入 Minio 依賴

打開你的 SpringBoot 項(xiàng)目,在 pom.xml 里加 Minio 的依賴(注意:版本別太老,我這里用的是 8.5.2,是比較穩(wěn)定的版本):

<!-- 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,用穩(wěn)定版本 -->
<dependency>
    <groupId>com.squareup.okhttp3</groupId>
    <artifactId>okhttp</artifactId>
    <version>4.9.3</version>
</dependency>
<!-- SpringBoot的定時任務(wù)依賴(后面要用) -->
<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?因?yàn)橛行?SpringBoot starter(比如 spring-cloud-starter)也會引入 okhttp,版本不一樣容易沖突,手動指定一個穩(wěn)定版本更穩(wěn)妥。

2.2.2 配置 Minio 參數(shù)

然后在 application.yml(或 application.properties)里配置 Minio 的連接信息,別寫死在代碼里,以后改配置方便:

# Minio配置
minio:
  endpoint: http://localhost:9000  # Minio服務(wù)地址(不是控制臺地址!控制臺是9001,服務(wù)是9000)
  access-key: minioadmin          # 賬號
  secret-key: minioadmin          # 密碼
  bucket-name: file-bucket        # 要操作的桶名(就是剛才在控制臺創(chuàng)建的)
  # 清理規(guī)則配置
  clean:
    enabled: true                 # 是否開啟清理任務(wù)
    cron: 0 0 2 * * ?             # 清理時間(每天凌晨2點(diǎn),Cron表達(dá)式,不懂的話后面有解釋)
    expire-days: 30               # 文件過期天數(shù)(超過30天的文件會被清理)
    ignore-prefixes: test_,temp_  # 忽略的文件前綴(比如test_開頭的文件不清理,多個用逗號分隔)
    max-batch-size: 100           # 每次批量刪除的文件數(shù)量(避免一次刪太多導(dǎo)致Minio卡殼)

這里的配置項(xiàng)都加了注釋,應(yīng)該很好懂。重點(diǎn)提醒下:endpoint是 Minio 的服務(wù)地址,默認(rèn)端口是 9000,不是控制臺的 9001,別填錯了!我第一次就填成 9001,結(jié)果客戶端連不上,查了半小時才發(fā)現(xiàn)是端口錯了,血的教訓(xùn)。

2.2.3 配置 Minio 客戶端 Bean

接下來,寫個配置類,把 MinioClient 注冊成 Spring 的 Bean,這樣整個項(xiàng)目都能注入使用:

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 {
    // 從配置文件讀取的參數(shù)
    private String endpoint;
    private String accessKey;
    private String secretKey;
    private String bucketName;
    // 清理規(guī)則相關(guān)參數(shù)
    private CleanConfig clean;
    // 內(nèi)部類:封裝清理規(guī)則配置
    public static class CleanConfig {
        private boolean enabled;
        private String cron;
        private Integer expireDays;
        private String ignorePrefixes;
        private Integer maxBatchSize;
        // getter和setter(這里省略,實(shí)際項(xiàng)目里要加上,不然讀不到配置)
        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,只有當(dāng)清理功能開啟時才創(chuàng)建(ConditionalOnProperty)
    @Bean
    @ConditionalOnProperty(prefix = "minio.clean", name = "enabled", havingValue = "true")
    public MinioClient minioClient() {
        return MinioClient.builder()
                .endpoint(endpoint)
                .credentials(accessKey, secretKey)
                .build();
    }
    // 外部類的getter和setter(同樣省略,實(shí)際項(xiàng)目要加)
    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” 前綴的參數(shù)映射到這個類的屬性上,不用手動寫@Value注解,更簡潔。還有@ConditionalOnProperty,意思是只有當(dāng)minio.clean.enabled為 true 時,才創(chuàng)建 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() + ",創(chuàng)建時間:" + item.lastModified());
        }
    }
}

如果運(yùn)行測試后,能打印出桶里的文件信息,說明 SpringBoot 和 Minio 已經(jīng)成功 “牽手” 了;如果報錯,先檢查配置里的 endpoint、賬號密碼是不是錯了,桶名是不是存在。

三、核心實(shí)現(xiàn):定時清理任務(wù)怎么寫?

集成好 Minio 之后,就該搞核心的定時清理任務(wù)了。咱們的需求很明確:每天凌晨 2 點(diǎn),自動刪除 Minio 指定桶里 “超過 30 天” 且 “不是 ignore 前綴” 的文件,還要支持批量刪除,避免一次刪太多卡殼。

實(shí)現(xiàn)定時任務(wù),SpringBoot 里常用兩種方式:一種是簡單的@Scheduled注解,適合簡單的定時需求;另一種是 Quartz,適合復(fù)雜的定時策略(比如動態(tài)修改執(zhí)行時間、集群環(huán)境避免重復(fù)執(zhí)行)。咱們這里先講@Scheduled的實(shí)現(xiàn),后面再講 Quartz 的進(jìn)階方案,滿足不同場景的需求。

3.1 先搞個 Minio 工具類:封裝文件操作

在寫定時任務(wù)之前,先封裝一個 Minio 工具類,把 “獲取文件列表”“判斷文件是否過期”“刪除文件” 這些常用操作抽出來,這樣定時任務(wù)里的代碼會更簡潔,也方便復(fù)用。

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 // 構(gòu)造器注入,比@Autowired更推薦
public class MinioUtils {
    private final MinioClient minioClient;
    private final MinioConfig minioConfig;
    /**
     * 獲取桶里所有需要清理的文件(過期且不在忽略列表中)
     * @param expireDays 過期天數(shù)(超過這個天數(shù)的文件需要清理)
     * @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;
                }
                // 檢查是否過期(當(dāng)前時間 - 文件創(chuàng)建時間 > 過期天數(shù))
                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("文件{}已過期(創(chuàng)建時間:{}),加入清理列表",
                            fileName, DateUtil.format(item.lastModified(), "yyyy-MM-dd HH:mm:ss"));
                }
            }
            log.info("本次清理任務(wù),共找到{}個過期文件", expiredFiles.size());
            return expiredFiles;
        } catch (Exception e) {
            log.error("獲取過期文件列表失敗", e);
            throw new RuntimeException("獲取過期文件列表失敗", e);
        }
    }
    /**
     * 批量刪除Minio里的文件
     * @param fileNames 要刪除的文件名列表
     * @param maxBatchSize 每次批量刪除的最大數(shù)量
     * @return 刪除結(jié)果(成功數(shù)量、失敗數(shù)量、失敗的文件名)
     */
    public DeleteResult batchDeleteFiles(List<String> fileNames, Integer maxBatchSize) {
        if (CollUtil.isEmpty(fileNames)) {
            log.info("沒有需要刪除的文件,直接返回");
            return new DeleteResult(0, 0, new ArrayList<>());
        }
        // 初始化返回結(jié)果
        int successCount = 0;
        int failCount = 0;
        List<String> failFiles = new ArrayList<>();
        // 分割列表,分批刪除(避免一次刪太多導(dǎo)致Minio壓力過大)
        List<List<String>> batchList = CollUtil.split(fileNames, maxBatchSize);
        log.info("共{}個文件,分{}批刪除,每批最多{}個",
                fileNames.size(), batchList.size(), maxBatchSize);
        for (List<String> batch : batchList) {
            try {
                // 轉(zhuǎn)換為Minio需要的DeleteObject列表
                List<DeleteObject> deleteObjects = batch.stream()
                        .map(DeleteObject::new)
                        .collect(Collectors.toList());
                // 執(zhí)行批量刪除
                Iterable<Result<DeleteError>> results = minioClient.deleteObjects(
                        DeleteObjectsArgs.builder()
                                .bucket(minioConfig.getBucketName())
                                .objects(deleteObjects)
                                .build()
                );
                // 處理刪除結(jié)果(如果有錯誤,會在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);
    }
    /**
     * 內(nèi)部類:封裝批量刪除結(jié)果
     */
    public static class DeleteResult {
        private int successCount; // 成功刪除數(shù)量
        private int failCount;    // 失敗數(shù)量
        private List<String> failFiles; // 失敗的文件名
        public DeleteResult(int successCount, int failCount, List<String> failFiles) {
            this.successCount = successCount;
            this.failCount = failCount;
            this.failFiles = failFiles;
        }
        // getter(省略,實(shí)際項(xiàng)目要加)
        public int getSuccessCount() { return successCount; }
        public int getFailCount() { return failCount; }
        public List<String> getFailFiles() { return failFiles; }
    }
}

這個工具類里有兩個核心方法:

  1. getExpiredFiles:根據(jù)過期天數(shù)和忽略前綴,篩選出需要清理的文件。這里要注意:Minio 里的目錄也是一種 Item,所以要跳過目錄(item.isDir()),不然會把目錄也刪了,導(dǎo)致后續(xù)文件找不到。
  2. batchDeleteFiles:批量刪除文件,支持分批刪除(maxBatchSize)。為啥要分批?因?yàn)槿绻淮蝿h幾千個文件,Minio 的 API 可能會超時,分批刪更穩(wěn)妥。而且還會返回刪除結(jié)果,方便后續(xù)排查失敗的文件。

工具類里用了lombok的@RequiredArgsConstructor,會自動生成構(gòu)造器,注入MinioClient和MinioConfig,比@Autowired更優(yōu)雅,推薦大家用這種方式注入。

3.2 用 @Scheduled 實(shí)現(xiàn)定時任務(wù)

工具類搞好了,接下來寫定時任務(wù)類。用@Scheduled注解的話,步驟很簡單:

  • 在啟動類上加@EnableScheduling注解,開啟定時任務(wù)功能;
  • 寫一個任務(wù)類,用@Scheduled(cron = "...")指定執(zhí)行時間,在方法里調(diào)用工具類的方法完成清理。

3.2.1 開啟定時任務(wù)

先在 SpringBoot 啟動類上加@EnableScheduling:

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling // 開啟定時任務(wù)
public class MinioCleanApplication {
    public static void main(String[] args) {
        SpringApplication.run(MinioCleanApplication.class, args);
    }
}

3.2.2 編寫定時任務(wù)類

然后寫定時任務(wù)類,這里要注意:只有當(dāng)minio.clean.enabled為 true 時,才啟用這個任務(wù),所以用@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文件定時清理任務(wù)(基于@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表達(dá)式,指定執(zhí)行時間
     */
    @Scheduled(cron = "${minio.clean.cron}")
    public void cleanExpiredFiles() {
        log.info("==================== Minio文件清理任務(wù)開始 ====================");
        try {
            // 1. 獲取清理規(guī)則配置
            MinioConfig.CleanConfig cleanConfig = minioConfig.getClean();
            Integer expireDays = cleanConfig.getExpireDays();
            String ignorePrefixesStr = cleanConfig.getIgnorePrefixes();
            Integer maxBatchSize = cleanConfig.getMaxBatchSize();
            // 校驗(yàn)配置(避免配置錯誤導(dǎo)致任務(wù)失?。?            if (expireDays == null || expireDays <= 0) {
                throw new RuntimeException("過期天數(shù)配置錯誤(必須大于0):" + expireDays);
            }
            if (maxBatchSize == null || maxBatchSize <= 0) {
                throw new RuntimeException("批量刪除數(shù)量配置錯誤(必須大于0):" + maxBatchSize);
            }
            // 處理忽略前綴(將字符串轉(zhuǎn)換為Set)
            Set<String> ignorePrefixes = StrUtil.isEmpty(ignorePrefixesStr)
                    ? CollUtil.newHashSet()
                    : Arrays.stream(ignorePrefixesStr.split(","))
                            .map(String::trim)
                            .collect(Collectors.toSet());
            // 2. 獲取需要清理的過期文件
            log.info("清理規(guī)則:過期天數(shù)={}天,忽略前綴={},批量刪除大小={}",
                    expireDays, ignorePrefixes, maxBatchSize);
            List<String> expiredFiles = minioUtils.getExpiredFiles(expireDays, ignorePrefixes);
            // 3. 批量刪除文件
            if (CollUtil.isEmpty(expiredFiles)) {
                log.info("沒有需要清理的過期文件,任務(wù)結(jié)束");
                return;
            }
            MinioUtils.DeleteResult deleteResult = minioUtils.batchDeleteFiles(expiredFiles, maxBatchSize);
            // 4. 輸出清理結(jié)果
            log.info("==================== Minio文件清理任務(wù)結(jié)束 ====================");
            log.info("清理結(jié)果匯總:");
            log.info("總過期文件數(shù):{}", expiredFiles.size());
            log.info("成功刪除數(shù):{}", deleteResult.getSuccessCount());
            log.info("失敗刪除數(shù):{}", deleteResult.getFailCount());
            if (CollUtil.isNotEmpty(deleteResult.getFailFiles())) {
                log.info("刪除失敗的文件:{}", deleteResult.getFailFiles());
            }
        } catch (Exception e) {
            log.error("Minio文件清理任務(wù)執(zhí)行失敗", e);
            throw new RuntimeException("Minio文件清理任務(wù)執(zhí)行失敗", e);
        }
    }
}

這個任務(wù)類的邏輯很清晰,分四步:

  1. 讀取配置:從MinioConfig里獲取過期天數(shù)、忽略前綴、批量大小等配置,還要校驗(yàn)配置(比如過期天數(shù)不能小于 0),避免配置錯誤導(dǎo)致任務(wù)崩潰;
  2. 篩選文件:調(diào)用MinioUtils的getExpiredFiles方法,找出需要清理的文件;
  3. 批量刪除:調(diào)用batchDeleteFiles方法,分批刪除文件;
  4. 輸出結(jié)果:打印清理結(jié)果,包括成功數(shù)、失敗數(shù)、失敗的文件名,方便后續(xù)排查問題。

這里解釋下 Cron 表達(dá)式:0 0 2 * * ? 表示每天凌晨 2 點(diǎn)執(zhí)行。如果想測試的話,可以改成0/30 * * * * ?(每 30 秒執(zhí)行一次),本地測試沒問題后再改回凌晨 2 點(diǎn)。Cron 表達(dá)式不會寫?沒關(guān)系,網(wǎng)上有很多 Cron 在線生成器(比如https://cron.qqe2.com/),輸入時間就能自動生成,不用記復(fù)雜的規(guī)則。

3.3 測試定時任務(wù)

寫好之后,怎么測試呢?有兩種方式:

3.3.1 本地測試(改 Cron 表達(dá)式)

把配置文件里的minio.clean.cron改成0/30 * * * * ?(每 30 秒執(zhí)行一次),然后啟動項(xiàng)目,看日志輸出:

==================== Minio文件清理任務(wù)開始 ====================
清理規(guī)則:過期天數(shù)=30天,忽略前綴=[test_,temp_],批量刪除大小=100
文件test_20240101.txt匹配忽略前綴,不清理
文件report_20240301.pdf已過期(創(chuàng)建時間:2024-03-01 10:00:00),加入清理列表
文件log_20240215.log已過期(創(chuàng)建時間:2024-02-15 15:30:00),加入清理列表
本次清理任務(wù),共找到2個過期文件
共2個文件,分1批刪除,每批最多100個
成功刪除第1批文件,共2個
==================== Minio文件清理任務(wù)結(jié)束 ====================
清理結(jié)果匯總:
總過期文件數(shù):2
成功刪除數(shù):2
失敗刪除數(shù):0

如果能看到這樣的日志,說明定時任務(wù)正常執(zhí)行,文件也成功刪除了。測試完記得把 Cron 改回凌晨 2 點(diǎn),別在生產(chǎn)環(huán)境每 30 秒執(zhí)行一次。

3.3.2 手動觸發(fā)任務(wù)(不用等 Cron 時間)

如果不想改 Cron 表達(dá)式,也可以手動觸發(fā)任務(wù),比如寫個接口:

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

import lombok.RequiredArgsConstructor;

/**
 * 手動觸發(fā)清理任務(wù)的接口(測試用)
 */
@RestController
@RequestMapping("/minio/clean")
@RequiredArgsConstructor
publicclass MinioCleanController {

    privatefinal MinioFileCleanTask minioFileCleanTask;

    @GetMapping("/trigger")
    public String triggerCleanTask() {
        try {
            minioFileCleanTask.cleanExpiredFiles();
            return"清理任務(wù)觸發(fā)成功,請查看日志";
        } catch (Exception e) {
            return"清理任務(wù)觸發(fā)失敗:" + e.getMessage();
        }
    }
}

啟動項(xiàng)目后,訪問http://localhost:8080/minio/clean/trigger,就能手動觸發(fā)清理任務(wù),方便測試。不過要注意:這個接口只是測試用,生產(chǎn)環(huán)境要刪掉,或者加權(quán)限控制,避免被惡意調(diào)用。

四、進(jìn)階優(yōu)化:讓清理任務(wù)更穩(wěn)定、更靈活

上面的基礎(chǔ)實(shí)現(xiàn)已經(jīng)能滿足大部分場景了,但在生產(chǎn)環(huán)境下,還需要做一些優(yōu)化,比如支持動態(tài)修改清理規(guī)則、集群環(huán)境避免重復(fù)執(zhí)行、清理失敗報警等。咱們一步步來優(yōu)化。

4.1 動態(tài)修改清理規(guī)則(不用重啟服務(wù))

之前的清理規(guī)則(過期天數(shù)、Cron 表達(dá)式)是寫在 application.yml 里的,要修改的話需要重啟服務(wù),很不方便。咱們可以用 Spring Cloud Config 或者 Nacos 來實(shí)現(xiàn)配置動態(tài)刷新,這里以 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官網(wǎng) -->
</dependency>

4.1.2 配置 Nacos 地址

在 bootstrap.yml(注意是 bootstrap.yml,不是 application.yml,因?yàn)?bootstrap 加載優(yōu)先級更高)里配置 Nacos 地址:

spring:
  application:
    name: minio-clean-service # 服務(wù)名,Nacos里的配置會根據(jù)這個名字找
  cloud:
    nacos:
      config:
        server-addr: localhost:8848 # Nacos服務(wù)地址
        file-extension: yml # 配置文件格式
        namespace: dev # 命名空間(區(qū)分開發(fā)、測試、生產(chǎn))
        group: DEFAULT_GROUP # 配置分組

4.1.3 在 Nacos 里配置清理規(guī)則

登錄 Nacos 控制臺,創(chuàng)建一個配置文件:

  • 數(shù)據(jù) ID:minio-clean-service.yml(格式:服務(wù)名。文件格式)
  • 分組:DEFAULT_GROUP
  • 配置內(nèi)容:把之前 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 開啟配置動態(tài)刷新

在MinioConfig類上加@RefreshScope注解,開啟配置動態(tài)刷新:

import org.springframework.cloud.context.config.annotation.RefreshScope; // 加這個注解

@Configuration
@ConfigurationProperties(prefix = "minio")
@RefreshScope // 開啟配置動態(tài)刷新
public class MinioConfig {
    // 內(nèi)容不變,省略...
}

這樣一來,當(dāng)你在 Nacos 里修改清理規(guī)則(比如把expire-days改成 60),不用重啟服務(wù),配置會自動刷新,下一次定時任務(wù)就會用新的規(guī)則執(zhí)行。是不是很方便?

4.2 集群環(huán)境避免重復(fù)執(zhí)行(分布式鎖)

如果你的 SpringBoot 服務(wù)是集群部署(多臺機(jī)器),用@Scheduled的話,每臺機(jī)器都會執(zhí)行定時任務(wù),導(dǎo)致重復(fù)刪除文件 —— 比如 A 機(jī)器刪了文件,B 機(jī)器又去刪一遍,雖然 Minio 刪不存在的文件不會報錯,但會浪費(fèi)資源,還可能導(dǎo)致日志混亂。

解決這個問題的辦法是用 “分布式鎖”:讓多臺機(jī)器搶一把鎖,只有搶到鎖的機(jī)器才能執(zhí)行清理任務(wù),其他機(jī)器跳過。這里咱們用 Redis 實(shí)現(xiàn)分布式鎖(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     # 數(shù)據(jù)庫索引

4.2.3 實(shí)現(xiàn)分布式鎖工具類

寫一個 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;

    // 鎖的前綴(避免和其他業(yè)務(wù)的鎖沖突)
    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 鎖的過期時間(避免服務(wù)宕機(jī)導(dǎo)致鎖不釋放)
     * @param timeUnit 時間單位
     * @return true=搶到鎖,false=沒搶到
     */
    public boolean tryLock(String lockKey, String lockValue, long expireTime, TimeUnit timeUnit) {
        try {
            String fullLockKey = LOCK_PREFIX + lockKey;
            // 用setIfAbsent實(shí)現(xiàn)搶鎖(原子操作)
            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;
            // 執(zhí)行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:{}(可能鎖已過期或不是當(dāng)前鎖)", fullLockKey, lockValue);
            }
            return success;
        } catch (Exception e) {
            log.error("釋放鎖失敗", e);
            returnfalse;
        }
    }
}

這里要注意:釋放鎖必須用 Lua 腳本,因?yàn)?“判斷鎖是否是自己的” 和 “刪除鎖” 這兩個操作需要原子性,不然會出現(xiàn) “自己的鎖被別人釋放” 的問題。比如:A 機(jī)器搶到鎖,執(zhí)行任務(wù)時卡住了,鎖過期了,B 機(jī)器搶到鎖開始執(zhí)行,這時候 A 機(jī)器恢復(fù)了,直接刪鎖,就會把 B 機(jī)器的鎖刪了,導(dǎo)致 C 機(jī)器又能搶到鎖,重復(fù)執(zhí)行任務(wù)。用 Lua 腳本就能避免這個問題。

4.2.3 在定時任務(wù)里加分布式鎖

修改MinioFileCleanTask的cleanExpiredFiles方法,在執(zhí)行清理邏輯前搶鎖,執(zhí)行完后釋放鎖:

import java.util.UUID;

// 其他代碼不變,只修改cleanExpiredFiles方法
public void cleanExpiredFiles() {
    log.info("==================== Minio文件清理任務(wù)開始 ====================");
    // 1. 生成鎖的key和value(value用UUID,確保唯一)
    String lockKey = "minio_clean_task";
    String lockValue = UUID.randomUUID().toString();
    // 鎖的過期時間:30分鐘(根據(jù)清理任務(wù)的耗時調(diào)整,確保任務(wù)能執(zhí)行完)
    long lockExpireTime = 30;
    TimeUnit timeUnit = TimeUnit.MINUTES;

    try {
        // 2. 搶鎖
        boolean locked = redisLockUtils.tryLock(lockKey, lockValue, lockExpireTime, timeUnit);
        if (!locked) {
            log.info("沒有搶到鎖,跳過本次清理任務(wù)");
            return;
        }

        // 3. 執(zhí)行清理邏輯(和之前一樣,省略...)
        // ... 這里是之前的篩選文件、批量刪除邏輯 ...

    } catch (Exception e) {
        log.error("Minio文件清理任務(wù)執(zhí)行失敗", e);
        thrownew RuntimeException("Minio文件清理任務(wù)執(zhí)行失敗", e);
    } finally {
        // 4. 釋放鎖(不管任務(wù)成功還是失敗,都要釋放鎖)
        redisLockUtils.releaseLock(lockKey, lockValue);
        log.info("==================== Minio文件清理任務(wù)結(jié)束 ====================");
    }
}

這樣一來,就算是集群部署,也只有一臺機(jī)器能執(zhí)行清理任務(wù),避免重復(fù)執(zhí)行。

4.3 清理失敗報警(及時發(fā)現(xiàn)問題)

如果清理任務(wù)失敗了(比如 Minio 連接不上、刪除文件失?。?,怎么及時發(fā)現(xiàn)?總不能天天盯著日志看吧。咱們可以加個報警功能,比如用釘釘機(jī)器人、企業(yè)微信機(jī)器人或者郵件報警,這里以釘釘機(jī)器人為例(配置簡單,消息觸達(dá)快)。

4.3.1 配置釘釘機(jī)器人

  • 打開釘釘,創(chuàng)建一個群,然后在群設(shè)置里找到 “智能群助手”→“添加機(jī)器人”→“自定義機(jī)器人”;
  • 給機(jī)器人起個名字(比如 “Minio 清理報警”),復(fù)制 Webhook 地址(這個地址很重要,別泄露了),然后完成創(chuàng)建;
  1. 在 Nacos 配置里加釘釘機(jī)器人的配置:
dingtalk:
  robot:
    webhook: https://oapi.dingtalk.com/robot/send?access_token=xxx # 你的Webhook地址
    secret: xxx # 如果開啟了簽名驗(yàn)證,這里填secret(可選,推薦開啟)

4.3.2 實(shí)現(xiàn)釘釘報警工具類

寫一個釘釘報警工具類,發(fā)送報警消息:

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;

/**
 * 釘釘機(jī)器人報警工具類
 */
@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; }

    /**
     * 發(fā)送文本報警消息
     * @param content 報警內(nèi)容
     */
    publicvoid sendTextAlarm(String content) {
        try {
            // 1. 如果有secret,需要計(jì)算簽名(避免機(jī)器人被惡意調(diào)用)
            String finalWebhook = webhook;
            if (secret != null && !secret.isEmpty()) {
                long timestamp = System.currentTimeMillis();
                String stringToSign = timestamp + "\n" + secret;
                // 計(jì)算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. 構(gòu)造請求參數(shù)(釘釘機(jī)器人的文本消息格式)
            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. 發(fā)送POST請求
            String jsonBody = JSONUtil.toJsonStr(requestBody);
            HttpResponse response = HttpRequest.post(finalWebhook)
                    .body(jsonBody, "application/json;charset=UTF-8")
                    .execute();

            // 4. 處理響應(yīng)
            if (response.isOk()) {
                log.info("釘釘報警消息發(fā)送成功,內(nèi)容:{}", content);
            } else {
                log.error("釘釘報警消息發(fā)送失敗,響應(yīng):{}", response.body());
            }

        } catch (Exception e) {
            log.error("釘釘報警消息發(fā)送異常", e);
        }
    }
}

4.3.3 在定時任務(wù)里加報警邏輯

修改MinioFileCleanTask的cleanExpiredFiles方法,在任務(wù)失敗或刪除文件失敗時發(fā)送報警:

public void cleanExpiredFiles() {
    log.info("==================== Minio文件清理任務(wù)開始 ====================");
    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("沒有搶到鎖,跳過本次清理任務(wù)");
            return;
        }

        // 執(zhí)行清理邏輯
        MinioConfig.CleanConfig cleanConfig = minioConfig.getClean();
        // ... 省略配置校驗(yàn)、篩選文件的邏輯 ...

        List<String> expiredFiles = minioUtils.getExpiredFiles(expireDays, ignorePrefixes);
        MinioUtils.DeleteResult deleteResult = minioUtils.batchDeleteFiles(expiredFiles, maxBatchSize);

        // 5. 如果有刪除失敗的文件,發(fā)送報警
        if (deleteResult.getFailCount() > 0) {
            String alarmContent = String.format(
                    "清理任務(wù)執(zhí)行完成,但部分文件刪除失??!\n" +
                    "總過期文件數(shù):%d\n" +
                    "成功刪除數(shù):%d\n" +
                    "失敗刪除數(shù):%d\n" +
                    "失敗文件列表:%s",
                    expiredFiles.size(),
                    deleteResult.getSuccessCount(),
                    deleteResult.getFailCount(),
                    deleteResult.getFailFiles()
            );
            dingTalkAlarmUtils.sendTextAlarm(alarmContent);
        }

    } catch (Exception e) {
        log.error("Minio文件清理任務(wù)執(zhí)行失敗", e);
        // 任務(wù)執(zhí)行失敗,發(fā)送報警
        String alarmContent = "清理任務(wù)執(zhí)行失?。≡颍? + e.getMessage();
        dingTalkAlarmUtils.sendTextAlarm(alarmContent);
        thrownew RuntimeException("Minio文件清理任務(wù)執(zhí)行失敗", e);
    } finally {
        redisLockUtils.releaseLock(lockKey, lockValue);
        log.info("==================== Minio文件清理任務(wù)結(jié)束 ====================");
    }
}

這樣一來,只要清理任務(wù)失敗或者有文件刪除失敗,釘釘就會收到報警消息,你就能及時處理問題,不用天天盯日志了。

五、用 Quartz 實(shí)現(xiàn)更復(fù)雜的定時任務(wù)

之前用@Scheduled實(shí)現(xiàn)定時任務(wù),雖然簡單,但有個缺點(diǎn):如果想動態(tài)修改 Cron 表達(dá)式(比如今天想改成凌晨 3 點(diǎn)執(zhí)行,明天改回 2 點(diǎn)),即使配置刷新了,@Scheduled也不會生效,因?yàn)锧Scheduled的 Cron 表達(dá)式是在 Bean 初始化時確定的,后續(xù)修改配置不會更新。

這時候就需要用 Quartz 了 ——Quartz 是一個強(qiáng)大的定時任務(wù)框架,支持動態(tài)修改任務(wù)的執(zhí)行時間、暫停 / 恢復(fù)任務(wù)、集群部署等功能。咱們來看看怎么用 Quartz 實(shí)現(xiàn) Minio 清理任務(wù)。

5.1 配置 Quartz

SpringBoot 已經(jīng)集成了 Quartz,咱們只需要配置 Quartz 的數(shù)據(jù)源(用 MySQL 存儲任務(wù)信息,避免服務(wù)重啟后任務(wù)丟失)和任務(wù)詳情。

5.1.1 配置 Quartz 數(shù)據(jù)源

在 Nacos 配置里加 Quartz 的數(shù)據(jù)源配置(用 MySQL 存儲任務(wù)信息):

# Quartz配置
spring:
quartz:
    # 任務(wù)存儲方式:數(shù)據(jù)庫(JDBC)
    job-store-type: JDBC
    # 啟用任務(wù)調(diào)度器
    auto-startup:true
    # 任務(wù)執(zhí)行線程池配置
    scheduler:
      instance-id: AUTO # 實(shí)例ID自動生成
      instance-name: MinioCleanScheduler # 調(diào)度器名稱
    # JDBC配置(用MySQL存儲任務(wù)信息)
    jdbc:
      initialize-schema: NEVER # 不自動初始化表結(jié)構(gòu)(手動執(zhí)行SQL腳本)
    # 數(shù)據(jù)源配置(可以用單獨(dú)的數(shù)據(jù)源,也可以復(fù)用項(xiàng)目的數(shù)據(jù)源,這里復(fù)用項(xiàng)目的)
    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# 開啟集群(避免重復(fù)執(zhí)行)
            clusterCheckinInterval:10000# 集群節(jié)點(diǎn)檢查間隔(毫秒)
          threadPool:
            class: org.quartz.simpl.SimpleThreadPool
            threadCount:5# 線程池大小
            threadPriority:5 # 線程優(yōu)先級

5.1.2 創(chuàng)建 Quartz 數(shù)據(jù)庫表

Quartz 需要在 MySQL 里創(chuàng)建一些表來存儲任務(wù)信息,官網(wǎng)提供了 SQL 腳本,地址:https://github.com/quartz-scheduler/quartz/blob/main/quartz-core/src/main/resources/org/quartz/impl/jdbcjobstore/tables_mysql_innodb.sql

下載這個 SQL 腳本,在 MySQL 里創(chuàng)建一個數(shù)據(jù)庫(比如叫quartz_db),然后執(zhí)行腳本,會創(chuàng)建 11 張表(比如QRTZ_JOB_DETAILS、QRTZ_TRIGGERS等)。

5.2 實(shí)現(xiàn) Quartz Job

Quartz 的核心是 Job(任務(wù))和 Trigger(觸發(fā)器):Job 是要執(zhí)行的任務(wù)邏輯,Trigger 是任務(wù)的執(zhí)行時間規(guī)則。咱們先實(shí)現(xiàn) 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任務(wù)開始 ====================");
        String lockKey = "minio_clean_quartz_task";
        String lockValue = UUID.randomUUID().toString();
        long lockExpireTime = 30;
        TimeUnit timeUnit = TimeUnit.MINUTES;

        try {
            // 搶分布式鎖(集群環(huán)境避免重復(fù)執(zhí)行)
            boolean locked = redisLockUtils.tryLock(lockKey, lockValue, lockExpireTime, timeUnit);
            if (!locked) {
                log.info("沒有搶到鎖,跳過本次Quartz清理任務(wù)");
                return;
            }

            // 執(zhí)行清理邏輯(和之前一樣,省略...)
            MinioConfig.CleanConfig cleanConfig = minioConfig.getClean();
            // ... 配置校驗(yàn)、篩選文件、批量刪除、報警邏輯 ...

        } catch (Exception e) {
            log.error("Minio文件清理Quartz任務(wù)執(zhí)行失敗", e);
            String alarmContent = "Quartz清理任務(wù)執(zhí)行失?。≡颍? + e.getMessage();
            dingTalkAlarmUtils.sendTextAlarm(alarmContent);
            thrownew JobExecutionException("Minio文件清理Quartz任務(wù)執(zhí)行失敗", e);
        } finally {
            redisLockUtils.releaseLock(lockKey, lockValue);
            log.info("==================== Minio文件清理Quartz任務(wù)結(jié)束 ====================");
        }
    }
}

這個 Job 的邏輯和之前的定時任務(wù)邏輯差不多,只是實(shí)現(xiàn)了 Quartz 的Job接口,重寫了execute方法。

5.3 初始化 Quartz 任務(wù)和觸發(fā)器

接下來,寫一個配置類,初始化 Quartz 的 JobDetail(任務(wù)詳情)和 CronTrigger(Cron 觸發(fā)器):

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任務(wù)配置類:初始化Job和Trigger
 */
@Configuration
@ConditionalOnProperty(prefix = "minio.clean", name = "enabled", havingValue = "true")
publicclass MinioCleanQuartzConfig {

    @Autowired
    private MinioConfig minioConfig;

    /**
     * 創(chuàng)建JobDetail(任務(wù)詳情)
     */
    @Bean
    public JobDetail minioCleanJobDetail() {
        return JobBuilder.newJob(MinioCleanQuartzJob.class)
                .withIdentity("minioCleanJob", "minioCleanGroup") // 任務(wù)ID和組名
                .storeDurably() // 即使沒有觸發(fā)器,也保存任務(wù)
                .build();
    }

    /**
     * 創(chuàng)建CronTrigger(Cron觸發(fā)器)
     */
    @Bean
    public Trigger minioCleanCronTrigger(JobDetail minioCleanJobDetail) {
        // 從配置文件讀取Cron表達(dá)式
        String cron = minioConfig.getClean().getCron();
        return TriggerBuilder.newTrigger()
                .forJob(minioCleanJobDetail) // 關(guān)聯(lián)JobDetail
                .withIdentity("minioCleanTrigger", "minioCleanGroup") // 觸發(fā)器ID和組名
                .withSchedule(CronScheduleBuilder.cronSchedule(cron)) // 設(shè)置Cron表達(dá)式
                .build();
    }

    /**
     * 配置SchedulerFactoryBean(Quartz調(diào)度器)
     */
    @Bean
    public SchedulerFactoryBean schedulerFactoryBean(Trigger minioCleanCronTrigger) {
        SchedulerFactoryBean schedulerFactoryBean = new SchedulerFactoryBean();
        // 關(guān)聯(lián)觸發(fā)器
        schedulerFactoryBean.setTriggers(minioCleanCronTrigger);
        // 允許Spring的Bean注入到Quartz Job中
        schedulerFactoryBean.setAutoStartup(true);
        return schedulerFactoryBean;
    }
}

5.4 動態(tài)修改 Quartz 任務(wù)的 Cron 表達(dá)式

Quartz 的優(yōu)勢在于支持動態(tài)修改任務(wù)的執(zhí)行時間。咱們寫一個接口,實(shí)現(xiàn) “修改 Cron 表達(dá)式”“暫停任務(wù)”“恢復(fù)任務(wù)” 的功能:

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任務(wù)管理接口(動態(tài)修改任務(wù))
 */
@RestController
@RequestMapping("/minio/quartz")
@Slf4j
publicclass QuartzManageController {

    @Autowired
    private Scheduler scheduler;

    // 任務(wù)和觸發(fā)器的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";

    /**
     * 動態(tài)修改Cron表達(dá)式
     */
    @PostMapping("/updateCron")
    publicString updateCron(@RequestBody CronUpdateDTO dto) {
        try {
            // 1. 獲取觸發(fā)器
            TriggerKey triggerKey = TriggerKey.triggerKey(TRIGGER_NAME, TRIGGER_GROUP);
            CronTrigger trigger = (CronTrigger) scheduler.getTrigger(triggerKey);
            if (trigger == null) {
                return"觸發(fā)器不存在";
            }

            // 2. 修改Cron表達(dá)式
            String newCron = dto.getNewCron();
            CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.cronSchedule(newCron);
            trigger = trigger.getTriggerBuilder()
                    .withIdentity(triggerKey)
                    .withSchedule(scheduleBuilder)
                    .build();

            // 3. 重新部署觸發(fā)器
            scheduler.rescheduleJob(triggerKey, trigger);
            log.info("成功修改Quartz任務(wù)的Cron表達(dá)式,舊Cron:{},新Cron:{}",
                    trigger.getCronExpression(), newCron);
            return"修改Cron表達(dá)式成功,新Cron:" + newCron;

        } catch (Exception e) {
            log.error("修改Quartz任務(wù)Cron表達(dá)式失敗", e);
            return"修改失?。? + e.getMessage();
        }
    }

    /**
     * 暫停任務(wù)
     */
    @PostMapping("/pauseJob")
    publicString pauseJob() {
        try {
            JobKey jobKey = JobKey.jobKey(JOB_NAME, JOB_GROUP);
            scheduler.pauseJob(jobKey);
            log.info("成功暫停Quartz任務(wù):{}:{}", JOB_GROUP, JOB_NAME);
            return"暫停任務(wù)成功";
        } catch (Exception e) {
            log.error("暫停Quartz任務(wù)失敗", e);
            return"暫停失?。? + e.getMessage();
        }
    }

    /**
     * 恢復(fù)任務(wù)
     */
    @PostMapping("/resumeJob")
    publicString resumeJob() {
        try {
            JobKey jobKey = JobKey.jobKey(JOB_NAME, JOB_GROUP);
            scheduler.resumeJob(jobKey);
            log.info("成功恢復(fù)Quartz任務(wù):{}:{}", JOB_GROUP, JOB_NAME);
            return"恢復(fù)任務(wù)成功";
        } catch (Exception e) {
            log.error("恢復(fù)Quartz任務(wù)失敗", e);
            return"恢復(fù)失敗:" + e.getMessage();
        }
    }

    /**
     * DTO:修改Cron表達(dá)式的請求參數(shù)
     */
    @Data
    publicstaticclass CronUpdateDTO {
        privateString newCron; // 新的Cron表達(dá)式
    }
}

這樣一來,你就可以通過調(diào)用/minio/quartz/updateCron接口,動態(tài)修改清理任務(wù)的執(zhí)行時間,不用重啟服務(wù)。比如把 Cron 從0 0 2 * * ?改成0 0 3 * * ?,任務(wù)就會從凌晨 2 點(diǎn)改成 3 點(diǎn)執(zhí)行。

六、總結(jié):一套完整的 Minio 清理方案

到這里,咱們的 “SpringBoot + Minio 定時清理歷史文件” 方案就完整了,總結(jié)一下這套方案的核心亮點(diǎn):

  1. 基礎(chǔ)功能完善:支持按過期天數(shù)、忽略前綴清理文件,批量刪除避免 Minio 壓力過大;
  2. 配置動態(tài)刷新:用 Nacos 實(shí)現(xiàn)配置動態(tài)修改,不用重啟服務(wù);
  3. 集群安全執(zhí)行:用 Redis 分布式鎖避免集群環(huán)境重復(fù)執(zhí)行;
  4. 問題及時發(fā)現(xiàn):用釘釘機(jī)器人報警,任務(wù)失敗或文件刪除失敗時及時通知;
  5. 復(fù)雜場景支持:用 Quartz 實(shí)現(xiàn)動態(tài)修改執(zhí)行時間、暫停 / 恢復(fù)任務(wù),滿足復(fù)雜需求。

這套方案不僅能解決 Minio 文件清理的問題,還能作為 “定時任務(wù)” 的通用模板 —— 比如后續(xù)要做 “定時清理數(shù)據(jù)庫歷史數(shù)據(jù)”“定時生成報表”,都可以參考這套思路,改改核心邏輯就能用。

最后,再給大家提幾個生產(chǎn)環(huán)境的小建議:

  1. 測試充分:上線前一定要在測試環(huán)境模擬大量文件(比如 1 萬個),測試清理任務(wù)的性能和穩(wěn)定性;
  2. 日志詳細(xì):把清理過程的關(guān)鍵步驟都記日志,方便后續(xù)排查問題;
  3. 備份重要文件:如果有重要文件,清理前最好先備份,避免誤刪(可以在清理前把文件復(fù)制到另一個桶);
  4. 逐步放量:第一次執(zhí)行清理任務(wù)時,可以先把expire-days設(shè)大一點(diǎn)(比如 180 天),先清理 oldest 的文件,觀察沒問題再縮小天數(shù)。
責(zé)任編輯:武曉燕 來源: 石杉的架構(gòu)筆記
相關(guān)推薦

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

2022-08-01 07:02:06

SpringEasyExcel場景

2021-04-22 09:56:32

MYSQL開發(fā)數(shù)據(jù)庫

2024-05-11 09:38:05

React編譯器React 19

2022-05-31 09:42:49

工具編輯器

2025-09-10 07:57:44

SpringBootMinio存儲

2020-06-23 15:58:42

心電圖

2020-12-29 10:45:55

開發(fā)設(shè)計(jì)代碼

2022-09-06 10:52:04

正則庫HumrePython

2021-08-11 09:33:15

Vue 技巧 開發(fā)工具

2021-09-10 10:15:24

Python人臉識別AI

2022-05-11 14:43:37

WindowsPython服務(wù)器

2021-03-18 10:12:54

JavaCompletable字符串

2021-03-02 20:42:20

實(shí)戰(zhàn)策略

2020-11-10 06:11:59

工具軟件代碼

2022-06-28 07:14:23

WizTree磁盤文件清理

2022-07-25 06:42:24

分布式鎖Redis
點(diǎn)贊
收藏

51CTO技術(shù)棧公眾號