SpringBoot + Minio 定時清理歷史文件,太好用了!
兄弟們,不知道你們有沒有過這種崩潰時刻:生產(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)境,先整個本地版玩玩,步驟超簡單:
- 去 Minio 官網(wǎng)下載對應(yīng)系統(tǒng)的安裝包(官網(wǎng)地址:https://min.io/ ,別下錯了,Windows 就下 exe,Linux 就下 tar.gz);
- 解壓后,打開命令行,進(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; }
}
}這個工具類里有兩個核心方法:
- getExpiredFiles:根據(jù)過期天數(shù)和忽略前綴,篩選出需要清理的文件。這里要注意:Minio 里的目錄也是一種 Item,所以要跳過目錄(item.isDir()),不然會把目錄也刪了,導(dǎo)致后續(xù)文件找不到。
- 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ù)類的邏輯很清晰,分四步:
- 讀取配置:從MinioConfig里獲取過期天數(shù)、忽略前綴、批量大小等配置,還要校驗(yàn)配置(比如過期天數(shù)不能小于 0),避免配置錯誤導(dǎo)致任務(wù)崩潰;
- 篩選文件:調(diào)用MinioUtils的getExpiredFiles方法,找出需要清理的文件;
- 批量刪除:調(diào)用batchDeleteFiles方法,分批刪除文件;
- 輸出結(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: 1004.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)建;
- 在 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):
- 基礎(chǔ)功能完善:支持按過期天數(shù)、忽略前綴清理文件,批量刪除避免 Minio 壓力過大;
- 配置動態(tài)刷新:用 Nacos 實(shí)現(xiàn)配置動態(tài)修改,不用重啟服務(wù);
- 集群安全執(zhí)行:用 Redis 分布式鎖避免集群環(huán)境重復(fù)執(zhí)行;
- 問題及時發(fā)現(xiàn):用釘釘機(jī)器人報警,任務(wù)失敗或文件刪除失敗時及時通知;
- 復(fù)雜場景支持:用 Quartz 實(shí)現(xiàn)動態(tài)修改執(zhí)行時間、暫停 / 恢復(fù)任務(wù),滿足復(fù)雜需求。
這套方案不僅能解決 Minio 文件清理的問題,還能作為 “定時任務(wù)” 的通用模板 —— 比如后續(xù)要做 “定時清理數(shù)據(jù)庫歷史數(shù)據(jù)”“定時生成報表”,都可以參考這套思路,改改核心邏輯就能用。
最后,再給大家提幾個生產(chǎn)環(huán)境的小建議:
- 測試充分:上線前一定要在測試環(huán)境模擬大量文件(比如 1 萬個),測試清理任務(wù)的性能和穩(wěn)定性;
- 日志詳細(xì):把清理過程的關(guān)鍵步驟都記日志,方便后續(xù)排查問題;
- 備份重要文件:如果有重要文件,清理前最好先備份,避免誤刪(可以在清理前把文件復(fù)制到另一個桶);
- 逐步放量:第一次執(zhí)行清理任務(wù)時,可以先把expire-days設(shè)大一點(diǎn)(比如 180 天),先清理 oldest 的文件,觀察沒問題再縮小天數(shù)。
































