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
































