基于Spring Boot的Minio图片定时清理实践总结
文章目录
- 前言
- 一、依赖坐标
- 二、删除方法
- 1、工具类:MinioUtil
- 1.1 方法介绍
- 1.2 方法代码
- 1.3 完整代码
- 1.3.1 性能陷阱
- 1.3.2 幂等性设计
- 1.3.3 常见错误码处理
- 2、测试类:MinioTest
- 三、定时配置
- 1.注解实现
- 1.1 在主启动类添加@EnableScheduling开启定时任务支持
- 1.2 创建定时任务类
- 1.3 @Scheduled 参数详解
- 1.4 Cron表达式详解
- 1.4.1 常见格式
- 1.4.2 字段说明(以 6 位格式为例)
- 1.4.3 特殊字符详解
- 1.4.4 常用示例
- 1.4.5 在线验证工具
- 2.配置线程池
- 2.1 线程池参数详解
- 2.2 线程池任务拒绝策略
- 3.异步执行
- 3.1异步线程池配置
- 3.2 异步线程池配置建议
- 3.2.1 队列选择策略
- 3.2.2 拒绝策略选择
- 3.2.3 动态配置
- 3.3 应用
- 4.扩展
- 4.1 配置文件优化
- 4.1.1 添加配置信息
- 4.1.2 配置类映射
- 4.1.3 修改 MinioUtil
- 4.1.4 修改 MinioCleanTask
- 4.1.5 测试
- 4.2 接口方法
- 4.2.1 建表和插入数据
- 4.2.2 创建实体
- 4.2.3 Mapper接口
前言
在项目开发中,我们使用Minio作为图片存储服务。随着时间推移,存储的图片文件越来越多,其中大量历史图片已不再需要。为了优化存储空间并降低成本,需要实现一个定时清理功能,定期删除指定日期前的图片文件。
一、依赖坐标
核心依赖:Minio 和 定时任务(SpringBoot的起步依赖就有)
<!--minio-->
<dependency><groupId>io.minio</groupId><artifactId>minio</artifactId><version>8.5.1</version>
</dependency>
依赖说明
组件 | 描述 |
---|---|
Minio SDK | 提供与 Minio 服务交互的 API,支持对象存储操作(如上传、下载文件) |
Spring Boot Starter | 内置定时任务支持(无需额外依赖),简化任务调度和后台处理 |
二、删除方法
1、工具类:MinioUtil
1.1 方法介绍
方法签名 | 作用描述 | 返回值类型 | 幂等性保证 |
---|---|---|---|
deleteDateFoldersBefore(LocalDate endExclusive) | 删除指定日期区间 [retainSince, endExclusive) 内的所有日期目录 | 实际删除的对象数量 | 多次调用结果一致 |
deleteSingleFolder(String prefix) | 删除单个前缀路径下的全部对象 | 本次删除的对象数量 | 同上 |
- 文件格式为:
/bucketName/yyyy-MM-dd/xxx.jepg
1.2 方法代码
参数说明
参数 | 类型 | 说明 |
---|---|---|
endExclusive | LocalDate | 截止日期(不含此日期) |
retainSince | LocalDate | 保留起始日期(最早不删除的日期) |
prefix | String | Minio对象前缀(即目录路径) |
private String bucketName="";//自定义就好
private LocalDate retainSince = LocalDate.of(2025, 6, 1);//用于判断的起始时
/*** 删除早于指定日期的所有日期目录(yyyy-MM-dd/)** @param endExclusive 截止日期(不含)* @return 实际删除的对象总数*/
public int deleteDateFoldersBefore(LocalDate endExclusive) {if (endExclusive == null) {throw new IllegalArgumentException("指定日期不能为空");}LocalDate today = LocalDate.now();if (!endExclusive.isBefore(today)) {return 0;}int totalDeleted = 0;// 从 endExclusive-1 天开始往前删for (LocalDate d = endExclusive.minusDays(1); !d.isBefore(retainSince); d = d.minusDays(1)) {totalDeleted += deleteSingleFolder(d.format(DateTimeFormatter.ISO_LOCAL_DATE) + "/");}return totalDeleted;
}/*** 删除单个目录(前缀)下的全部对象*/
private int deleteSingleFolder(String prefix) {try {List<DeleteObject> objects = new ArrayList<>();minioClient.listObjects(ListObjectsArgs.builder().bucket(bucketName).prefix(prefix).recursive(true).build()).forEach(r -> {try {objects.add(new DeleteObject(r.get().objectName()));} catch (Exception ignored) {log.warn("文件名获取失败");}});if (objects.isEmpty()) {return 0;}Iterable<Result<DeleteError>> results = minioClient.removeObjects(RemoveObjectsArgs.builder().bucket(bucketName).objects(objects).build());for (Result<DeleteError> res : results) {DeleteError deleteError = res.get();// 无异常即成功}return objects.size();} catch (Exception e) {log.warn("删除目录 {} 失败: {}", prefix, e.toString());return 0;}
}
1.3 完整代码
import cn.hutool.core.lang.UUID;
import com.fc.properties.MinioProperties;
import io.minio.*;
import io.minio.errors.ErrorResponseException;
import io.minio.http.Method;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.compress.utils.IOUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;/*** 文件操作工具类*/
@RequiredArgsConstructor
@Component
@Slf4j
public class MinioUtil {private final MinioProperties minioProperties;private MinioClient minioClient;private String bucketName;private LocalDate retainSince = LocalDate.of(2025, 6, 1);// 初始化 Minio 客户端@PostConstructpublic void init() {try {//创建客户端minioClient = MinioClient.builder().endpoint(minioProperties.getUrl()).credentials(minioProperties.getUsername(), minioProperties.getPassword()).build();bucketName = minioProperties.getBucketName();// 检查桶是否存在,不存在则创建boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());if (!bucketExists) {minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());}} catch (Exception e) {throw new RuntimeException("Minio 初始化失败", e);}}/** 上传文件*/public String uploadFile(MultipartFile file, String extension) {if (file == null || file.isEmpty()) {throw new RuntimeException("上传文件不能为空");}try {// 生成唯一文件名String uniqueFilename = generateUniqueFilename(extension);// 上传文件minioClient.putObject(PutObjectArgs.builder().bucket(bucketName).object(uniqueFilename).stream(file.getInputStream(), file.getSize(), -1).contentType(file.getContentType()).build());return "/" + bucketName + "/" + uniqueFilename;} catch (Exception e) {throw new RuntimeException("文件上传失败", e);}}/*** 上传已处理的图片字节数组到 MinIO** @param imageData 处理后的图片字节数组* @param extension 文件扩展名(如 ".jpg", ".png")* @param contentType 文件 MIME 类型(如 "image/jpeg", "image/png")* @return MinIO 中的文件路径(格式:/bucketName/yyyy-MM-dd/uuid.extension)*/public String uploadFileByte(byte[] imageData, String extension, String contentType) {if (imageData == null || imageData.length == 0) {throw new RuntimeException("上传的图片数据不能为空");}if (extension == null || extension.isEmpty()) {throw new IllegalArgumentException("文件扩展名不能为空");}if (contentType == null || contentType.isEmpty()) {throw new IllegalArgumentException("文件 MIME 类型不能为空");}try {// 生成唯一文件名String uniqueFilename = generateUniqueFilename(extension);// 上传到 MinIOminioClient.putObject(PutObjectArgs.builder().bucket(bucketName).object(uniqueFilename).stream(new ByteArrayInputStream(imageData), imageData.length, -1).contentType(contentType).build());return "/" + bucketName + "/" + uniqueFilename;} catch (Exception e) {throw new RuntimeException("处理后的图片上传失败", e);}}/*** 上传本地生成的 Excel 临时文件到 MinIO** @param localFile 本地临时文件路径* @param extension 扩展名* @return MinIO 存储路径,格式:/bucketName/yyyy-MM-dd/targetName*/public String uploadLocalExcel(Path localFile, String extension) {if (localFile == null || !Files.exists(localFile)) {throw new RuntimeException("本地文件不存在");}try (InputStream in = Files.newInputStream(localFile)) {String objectKey = generateUniqueFilename(extension); // 保留日期目录minioClient.putObject(PutObjectArgs.builder().bucket(bucketName).object(objectKey).stream(in, Files.size(localFile), -1).contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet").build());return "/" + bucketName + "/" + objectKey;} catch (Exception e) {throw new RuntimeException("Excel 上传失败", e);}}/** 根据URL下载文件*/public void downloadFile(HttpServletResponse response, String fileUrl) {if (fileUrl == null || !fileUrl.contains(bucketName + "/")) {throw new IllegalArgumentException("无效的文件URL");}try {// 从URL中提取对象路径和文件名String objectUrl = fileUrl.split(bucketName + "/")[1];String fileName = objectUrl.substring(objectUrl.lastIndexOf("/") + 1);// 设置响应头response.setContentType("application/octet-stream");String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedFileName + "\"");// 下载文件try (InputStream inputStream = minioClient.getObject(GetObjectArgs.builder().bucket(bucketName).object(objectUrl).build());OutputStream outputStream = response.getOutputStream()) {// 用IOUtils.copy高效拷贝(内部缓冲区默认8KB)IOUtils.copy(inputStream, outputStream);}} catch (Exception e) {throw new RuntimeException("文件下载失败", e);}}/*** 根据 MinIO 路径生成带签名的直链** @param objectUrl 已存在的 MinIO 路径(/bucketName/...)* @param minutes 链接有效期(分钟)* @return 可直接访问的 HTTPS 下载地址*/public String parseGetUrl(String objectUrl, int minutes) {if (objectUrl == null || !objectUrl.startsWith("/" + bucketName + "/")) {throw new IllegalArgumentException("非法的 objectUrl");}String objectKey = objectUrl.substring(("/" + bucketName + "/").length());try {return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder().method(Method.GET).bucket(bucketName).object(objectKey).expiry(minutes, TimeUnit.MINUTES).build());} catch (Exception e) {throw new RuntimeException("生成直链失败", e);}}/** 根据URL删除文件*/public void deleteFile(String fileUrl) {try {// 从URL中提取对象路径String objectUrl = fileUrl.split(bucketName + "/")[1];minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(objectUrl).build());} catch (Exception e) {throw new RuntimeException("文件删除失败", e);}}/** 检查文件是否存在*/public boolean fileExists(String fileUrl) {if (fileUrl == null || !fileUrl.contains(bucketName + "/")) {return false;}try {String objectUrl = fileUrl.split(bucketName + "/")[1];minioClient.statObject(StatObjectArgs.builder().bucket(bucketName).object(objectUrl).build());return true;} catch (Exception e) {if (e instanceof ErrorResponseException && ((ErrorResponseException) e).errorResponse().code().equals("NoSuchKey")) {return false;}throw new RuntimeException("检查文件存在失败", e);}}/*** 生成唯一文件名(带日期路径 + UUID)*/private String generateUniqueFilename(String extension) {String dateFormat = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));String uuid = UUID.randomUUID().toString().replace("-", ""); // 去掉 UUID 中的 "-"return dateFormat + "/" + uuid + extension;}/*** 删除早于指定日期的所有日期目录(yyyy-MM-dd/)** @param endExclusive 截止日期(不含)* @return 实际删除的对象总数*/public int deleteDateFoldersBefore(LocalDate endExclusive) {if (endExclusive == null) {throw new IllegalArgumentException("指定日期不能为空");}LocalDate today = LocalDate.now();if (!endExclusive.isBefore(today)) {return 0;}int totalDeleted = 0;// 从 endExclusive-1 天开始往前删for (LocalDate d = endExclusive.minusDays(1); !d.isBefore(retainSince); d = d.minusDays(1)) {totalDeleted += deleteSingleFolder(d.format(DateTimeFormatter.ISO_LOCAL_DATE) + "/");}return totalDeleted;}/*** 删除单个目录(前缀)下的全部对象*/private int deleteSingleFolder(String prefix) {try {List<DeleteObject> objects = new ArrayList<>();minioClient.listObjects(ListObjectsArgs.builder().bucket(bucketName).prefix(prefix).recursive(true).build()).forEach(r -> {try {objects.add(new DeleteObject(r.get().objectName()));} catch (Exception ignored) {log.warn("文件名获取失败");}});if (objects.isEmpty()) {return 0;}Iterable<Result<DeleteError>> results = minioClient.removeObjects(RemoveObjectsArgs.builder().bucket(bucketName).objects(objects).build());for (Result<DeleteError> res : results) {DeleteError deleteError = res.get();// 无异常即成功}return objects.size();} catch (Exception e) {log.warn("删除目录 {} 失败: {}", prefix, e.toString());return 0;}}
}
1.3.1 性能陷阱
迭代器懒加载机制
listObjects
返回分页迭代器(每 1000 条自动分页),无需手动处理 markerremoveObjects
返回Iterable<Result<DeleteError>>
,需遍历结果才能触发 HTTP 请求(懒执行)- 海量对象场景:添加
.maxKeys(batchSize)
限制单次返回数量,避免 OOM// 示例:限制批大小 minioClient.listObjects(ListObjectsArgs.builder().bucket("my-bucket").maxKeys(500).build());
异常隔离
r.get()
可能抛出InsufficientDataException
/InternalException
等异常- 处理策略:捕获异常后仅记录日志,确保当前批次继续执行
批大小限制
- MinIO 服务端单次请求上限:1000 条对象
- 超限错误:
ErrorResponseException: DeleteObjects max keys 1000
1.3.2 幂等性设计
- 重复删除同一路径:静默忽略,不报错
- 已删除对象:自动跳过处理
1.3.3 常见错误码处理
错误码 | 原因 | 解决方案 |
---|---|---|
NoSuchBucket | 桶名错误 | 启动时校验桶是否存在 |
AccessDenied | AK/SK 权限不足 | 补充 s3:DeleteObject 权限 |
SlowDown | 服务端限流 | 指数退避重试策略 |
2、测试类:MinioTest
完整代码
import com.fc.utils.MinioUtil;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.time.LocalDate;
@SpringBootTest
public class MinioTest {@Autowiredprivate MinioUtil minioUtil;@Testpublic void testDelete() {int count = minioUtil.deleteDateFoldersBefore(LocalDate.of(2025, 8,2));//这里的时间可以自定义,注意测试之前要先确定存在文件System.out.println(count);}
}
三、定时配置
1.注解实现
1.1 在主启动类添加@EnableScheduling开启定时任务支持
@SpringBootApplication
@EnableTransactionManagement //开启注解方式的事务管理
@EnableScheduling//开启定时i任务
@EnableCaching
public class VehicleApplication {public static void main(String[] args) {SpringApplication.run(VehicleApplication.class, args);}
}
1.2 创建定时任务类
使用@Component
注册Bean
,在方法上添加@Scheduled
import com.fc.utils.MinioUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
@Component//注册Bean
@RequiredArgsConstructor
@Slf4j
public class MinioCleanTask {private final MinioUtil minioUtil;/*** 定时清理 MinIO 中早于当前日期的日期目录(格式:yyyy-MM-dd/)* 执行时间:每月1号凌晨3点*/@Scheduled(cron = "0 0 3 1 * ?")public void minioClean() {try {log.info("MinIO 清理任务开始执行...");// 明确语义:删除早于今天的所有日期目录(不含今天)LocalDate today = LocalDate.now();log.info("当前日期:{}, 开始清理早于该日期的目录", today);int deleteCount = minioUtil.deleteDateFoldersBefore(today);log.info("MinIO 清理任务执行完成,共删除 {} 张图片", deleteCount);} catch (Exception e) {// 防止定时任务因异常停止log.error("MinIO 清理任务执行失败", e);}}
}
启动类需要能扫描到定时任务类,否则定时任务启动不起来(启动类位置位于定时任务类之上或者通过注解指定扫描包的位置)
1.3 @Scheduled 参数详解
参数 | 作用 | 特点 | 示例 |
---|---|---|---|
fixedRate | 上一次开始时间到下一次开始时间的间隔(毫秒) | 无视任务执行时长 | @Scheduled(fixedRate = 3000) // 每3秒执行一次 |
fixedDelay | 上一次结束时间到下一次开始时间的间隔(毫秒) | 等待上次任务完成 | @Scheduled(fixedDelay = 4000) // 任务结束后4秒再执行 |
initialDelay | 首次任务延迟时间(需配合fixedRate/fixedDelay) | 仅首次生效 | @Scheduled(initialDelay = 10000, fixedRate = 5000) // 首次延迟10秒,之后每5秒执行 |
cron | 通过表达式定义复杂时间规则 | 支持灵活的时间组合 | "0 15 10 * * ?" // 每天10:15执行 |
1.4 Cron表达式详解
Cron 表达式由 5-7 个字段组成,每个字段代表一个时间单位,字段之间用空格分隔
1.4.1 常见格式
字段数 | 格式 | 使用场景 |
---|---|---|
5 位 | 分 时 日 月 周 | Linux Crontab |
6 位 | 秒 分 时 日 月 周 | Spring/Quartz |
7 位 | 秒 分 时 日 月 周 年 | AWS EventBridge |
1.4.2 字段说明(以 6 位格式为例)
位置 | 字段 | 取值范围 | 允许的特殊字符 |
---|---|---|---|
1 | 秒 | 0-59 | , - * / |
2 | 分 | 0-59 | , - * / |
3 | 小时 | 0-23 | , - * / |
4 | 日期 | 1-31 | , - * / ? L W |
5 | 月份 | 1-12 或 JAN-DEC | , - * / |
6 | 星期 | 0-7 或 SUN-SAT(0 和 7 均为星期日) | , - * / ? L # |
7 | 年份(可选) | 1970-2099 | , - * / |
1.4.3 特殊字符详解
字符 | 含义 | 示例说明 |
---|---|---|
* | 任意值 | 在“小时”字段表示“每小时” |
? | 不指定值 | 用于“日期”和“星期”字段互斥使用 |
- | 范围 | 9-17 表示从 9 到 17 |
, | 枚举值 | 1,3,5 表示 1、3、5 |
/ | 增量 | 0/15 表示从 0 开始,每 15 秒一次 |
L | Last(最后) | L 在“日期”中表示“当月最后一天” |
W | 工作日 | 15W 表示“离 15 号最近的工作日” |
# | 第几个星期几 | 6#3 表示“当月第 3 个星期五” |
1.4.4 常用示例
基础定时任务
执行频率 | Cron表达式 | 说明 |
---|---|---|
每分钟执行一次 | 0 * * * * ? | 每分钟的第0秒触发 |
每5分钟执行一次 | 0 */5 * * * ? | 每隔5分钟的第0秒触发 |
每小时第30分钟执行 | 0 30 * * * ? | 每小时的30分0秒触发 |
每天凌晨1点执行 | 0 0 1 * * ? | 每天1:00:00触发 |
每月1号凌晨2点执行 | 0 0 2 1 * ? | 每月1日的2:00:00触发 |
每周六凌晨3点执行 | 0 0 3 * * 6 | 每周六的3:00:00触发(数字6表示周六) |
每周六凌晨3点执行 | 0 0 3 * * SAT | 每周六的3:00:00触发(SAT为英文缩写) |
高级用法
需求描述 | Cron表达式 | 说明 |
---|---|---|
每月最后一天23:59执行 | 0 59 23 L * ? | L 表示月份的最后一天 |
每月15号或最后一天执行 | 0 0 0 15,L * ? | 逗号分隔多个日期 |
每月第2个星期一 | 0 0 0 ? * 2#2 | 2#2 表示第2周的周一 |
工作日(周一到周五)9点执行 | 0 0 9 * * MON-FRI | MON-FRI 定义范围 |
每10秒执行一次 | */10 * * * * ? | */10 表示秒字段的步长 |
1.4.5 在线验证工具
推荐使用以下工具测试 Cron 表达式:
- https://crontab.guru/(常用)
- https://www.freeformatter.com/cron-expression-generator-quartz.html
2.配置线程池
当系统中有多个定时任务时,默认情况下它们共享同一个单线程。如果某个任务执行时间过长,会导致其他任务延迟执行。通过配置线程池可以:
- 实现任务隔离
- 提高任务并行性
- 避免任务阻塞
- 提供更好的资源控制
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;@Configuration
@Slf4j
public class SchedulerConfiguration {/*** 配置定时任务线程池** @return 任务调度器实例*/@Beanpublic TaskScheduler taskScheduler() {ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();// 核心参数配置scheduler.setPoolSize(3); // 线程池大小,建议设置为任务数+2scheduler.setThreadNamePrefix("minio-scheduler-"); // 线程名前缀scheduler.setAwaitTerminationSeconds(60); // 关闭时等待任务完成的时间(秒)scheduler.setWaitForTasksToCompleteOnShutdown(true); // 关闭时是否等待任务完成scheduler.setRemoveOnCancelPolicy(true); // 取消任务时是否立即移除scheduler.setErrorHandler(throwable ->log.error("定时任务执行异常", throwable)); // 异常处理器// 任务拒绝策略配置scheduler.setRejectedExecutionHandler((r, executor) -> {log.warn("定时任务被拒绝,任务队列已满");// 可添加自定义处理逻辑,如记录日志或发送告警});return scheduler;}
}
2.1 线程池参数详解
参数 | 类型 | 默认值 | 说明 |
---|---|---|---|
poolSize | int | 1 | 线程池大小,决定同时执行的任务数量 |
threadNamePrefix | String | “scheduler-” | 线程名前缀,方便日志跟踪和调试 |
awaitTerminationSeconds | int | 0 | 应用关闭时等待任务完成的秒数,0表示不等待 |
waitForTasksToCompleteOnShutdown | boolean | false | 是否等待计划任务完成再关闭线程池 |
removeOnCancelPolicy | boolean | false | 取消任务时是否立即从队列中移除该任务。 |
errorHandler | ErrorHandler | null | 任务执行异常时的处理器,用于自定义异常处理逻辑 |
rejectedExecutionHandler | RejectedExecutionHandler | AbortPolicy | 任务被拒绝时的处理策略,默认直接抛出异常 |
2.2 线程池任务拒绝策略
策略名称 | 行为描述 | 适用场景 |
---|---|---|
AbortPolicy(默认) | 直接抛出 RejectedExecutionException ,中断任务提交流程 | 需要严格保证任务不丢失的场景,需显式处理异常 |
CallerRunsPolicy | 由提交任务的调用者线程直接执行被拒绝的任务(线程池未关闭时) | 需降低任务提交速度,避免完全阻塞的场景 |
DiscardPolicy | 静默丢弃被拒绝的任务,不触发任何异常或通知 | 允许丢弃部分非关键任务的场景 |
DiscardOldestPolicy | 丢弃任务队列中最早未处理的任务,并尝试重新提交当前任务(可能仍被拒绝) | 允许牺牲旧任务以优先处理新任务的场景 |
3.异步执行
当定时任务包含阻塞操作(如网络IO、复杂计算)时,应使用异步模式避免阻塞调度线程
3.1异步线程池配置
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;
import java.util.concurrent.ThreadPoolExecutor;@Configuration
@EnableAsync // 开启异步支持
public class AsyncConfiguration {/*** 创建异步任务线程池**/@Bean("taskExecutor")public Executor taskExecutor() {ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();// 核心参数executor.setCorePoolSize(3);executor.setMaxPoolSize(5);executor.setQueueCapacity(100);executor.setThreadNamePrefix("async-task-");executor.setKeepAliveSeconds(60);// 拒绝策略:由调用线程处理(避免任务丢失)executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());// 优雅停机配置executor.setWaitForTasksToCompleteOnShutdown(true);executor.setAwaitTerminationSeconds(60);executor.initialize();return executor;}
}
3.2 异步线程池配置建议
3.2.1 队列选择策略
队列类型 | 特点 | 适用场景 |
---|---|---|
SynchronousQueue | 无容量 | 高吞吐、短任务 |
LinkedBlockingQueue | 无界队列 | 保证任务不丢失 |
ArrayBlockingQueue | 有界队列 | 资源受限环境 |
PriorityBlockingQueue | 优先级队列 | 任务优先级处理 |
3.2.2 拒绝策略选择
// 常用拒绝策略
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.AbortPolicy()); // 抛出异常
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy()); // 静默丢弃
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // 调用者执行
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardOldestPolicy()); // 丢弃最旧任务
3.2.3 动态配置
// 运行时调整核心参数
executor.setCorePoolSize(10);
executor.setMaxPoolSize(30);
executor.setQueueCapacity(200);
3.3 应用
在定时任务上添加@Async
,标明该任务异步执行(所在类必须被Spring管理)
/*** 定时清理 MinIO 中早于当前日期的日期目录(格式:yyyy-MM-dd/)* 执行时间:每月1号凌晨3点*/
@Async(value = "taskExecutor")//添加异步注解
@Scheduled(cron = "0 0 3 1 * ?")
public void minioClean() {try {log.info("MinIO 清理任务开始执行...");// 明确语义:删除早于今天的所有日期目录(不含今天)LocalDate today = LocalDate.now();log.info("当前日期:{}, 开始清理早于该日期的目录", today);int deleteCount = minioUtil.deleteDateFoldersBefore(today);log.info("MinIO 清理任务执行完成,共删除 {} 张图片", deleteCount);} catch (Exception e) {// 防止定时任务因异常停止log.error("MinIO 清理任务执行失败", e);}
}
4.扩展
4.1 配置文件优化
4.1.1 添加配置信息
minio:clean:enabled: true # 是否启用清理功能retain-days: 1 # 保留最近多少天的文件earliest-date: "2025/08/01" # 最早保留日期(避免误删重要文件)cron: "0 0 2 * * ?" # 每天凌晨2点执行
4.1.2 配置类映射
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
@Component
@Data
@ConfigurationProperties(prefix = "vehicle.minio.clean")
public class MinioCleanProperties {private boolean enabled;private int retainDays;private LocalDate earliestDate;private String cron;
}
4.1.3 修改 MinioUtil
修改 MinioUtil,移除硬编码的 retainSince
import cn.hutool.core.lang.UUID;
import com.fc.properties.MinioCleanProperties;
import com.fc.properties.MinioProperties;
import io.minio.*;
import io.minio.errors.ErrorResponseException;
import io.minio.http.Method;
import io.minio.messages.DeleteError;
import io.minio.messages.DeleteObject;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.compress.utils.IOUtils;
import org.springframework.stereotype.Component;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;/*** 文件操作工具类*/
@RequiredArgsConstructor
@Component
@Slf4j
public class MinioUtil {private final MinioProperties minioProperties;private final MinioCleanProperties minioCleanProperties;//添加映射配置类private MinioClient minioClient;private String bucketName;private LocalDate retainSince;// 初始化 Minio 客户端@PostConstructpublic void init() {try {//创建客户端minioClient = MinioClient.builder().endpoint(minioProperties.getUrl()).credentials(minioProperties.getUsername(), minioProperties.getPassword()).build();bucketName = minioProperties.getBucketName();retainSince = minioCleanProperties.getEarliestDate();//获取动态参数// 检查桶是否存在,不存在则创建boolean bucketExists = minioClient.bucketExists(BucketExistsArgs.builder().bucket(bucketName).build());if (!bucketExists) {minioClient.makeBucket(MakeBucketArgs.builder().bucket(bucketName).build());}} catch (Exception e) {throw new RuntimeException("Minio 初始化失败", e);}}/** 上传文件*/public String uploadFile(MultipartFile file, String extension) {if (file == null || file.isEmpty()) {throw new RuntimeException("上传文件不能为空");}try {// 生成唯一文件名String uniqueFilename = generateUniqueFilename(extension);// 上传文件minioClient.putObject(PutObjectArgs.builder().bucket(bucketName).object(uniqueFilename).stream(file.getInputStream(), file.getSize(), -1).contentType(file.getContentType()).build());return "/" + bucketName + "/" + uniqueFilename;} catch (Exception e) {throw new RuntimeException("文件上传失败", e);}}/*** 上传已处理的图片字节数组到 MinIO** @param imageData 处理后的图片字节数组* @param extension 文件扩展名(如 ".jpg", ".png")* @param contentType 文件 MIME 类型(如 "image/jpeg", "image/png")* @return MinIO 中的文件路径(格式:/bucketName/yyyy-MM-dd/uuid.extension)*/public String uploadFileByte(byte[] imageData, String extension, String contentType) {if (imageData == null || imageData.length == 0) {throw new RuntimeException("上传的图片数据不能为空");}if (extension == null || extension.isEmpty()) {throw new IllegalArgumentException("文件扩展名不能为空");}if (contentType == null || contentType.isEmpty()) {throw new IllegalArgumentException("文件 MIME 类型不能为空");}try {// 生成唯一文件名String uniqueFilename = generateUniqueFilename(extension);// 上传到 MinIOminioClient.putObject(PutObjectArgs.builder().bucket(bucketName).object(uniqueFilename).stream(new ByteArrayInputStream(imageData), imageData.length, -1).contentType(contentType).build());return "/" + bucketName + "/" + uniqueFilename;} catch (Exception e) {throw new RuntimeException("处理后的图片上传失败", e);}}/*** 上传本地生成的 Excel 临时文件到 MinIO** @param localFile 本地临时文件路径* @param extension 扩展名* @return MinIO 存储路径,格式:/bucketName/yyyy-MM-dd/targetName*/public String uploadLocalExcel(Path localFile, String extension) {if (localFile == null || !Files.exists(localFile)) {throw new RuntimeException("本地文件不存在");}try (InputStream in = Files.newInputStream(localFile)) {String objectKey = generateUniqueFilename(extension); // 保留日期目录minioClient.putObject(PutObjectArgs.builder().bucket(bucketName).object(objectKey).stream(in, Files.size(localFile), -1).contentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet").build());return "/" + bucketName + "/" + objectKey;} catch (Exception e) {throw new RuntimeException("Excel 上传失败", e);}}/** 根据URL下载文件*/public void downloadFile(HttpServletResponse response, String fileUrl) {if (fileUrl == null || !fileUrl.contains(bucketName + "/")) {throw new IllegalArgumentException("无效的文件URL");}try {// 从URL中提取对象路径和文件名String objectUrl = fileUrl.split(bucketName + "/")[1];String fileName = objectUrl.substring(objectUrl.lastIndexOf("/") + 1);// 设置响应头response.setContentType("application/octet-stream");String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");response.setHeader("Content-Disposition", "attachment; filename=\"" + encodedFileName + "\"");// 下载文件try (InputStream inputStream = minioClient.getObject(GetObjectArgs.builder().bucket(bucketName).object(objectUrl).build());OutputStream outputStream = response.getOutputStream()) {// 用IOUtils.copy高效拷贝(内部缓冲区默认8KB)IOUtils.copy(inputStream, outputStream);}} catch (Exception e) {throw new RuntimeException("文件下载失败", e);}}/*** 根据 MinIO 路径生成带签名的直链** @param objectUrl 已存在的 MinIO 路径(/bucketName/...)* @param minutes 链接有效期(分钟)* @return 可直接访问的 HTTPS 下载地址*/public String parseGetUrl(String objectUrl, int minutes) {if (objectUrl == null || !objectUrl.startsWith("/" + bucketName + "/")) {throw new IllegalArgumentException("非法的 objectUrl");}String objectKey = objectUrl.substring(("/" + bucketName + "/").length());try {return minioClient.getPresignedObjectUrl(GetPresignedObjectUrlArgs.builder().method(Method.GET).bucket(bucketName).object(objectKey).expiry(minutes, TimeUnit.MINUTES).build());} catch (Exception e) {throw new RuntimeException("生成直链失败", e);}}/** 根据URL删除文件*/public void deleteFile(String fileUrl) {try {// 从URL中提取对象路径String objectUrl = fileUrl.split(bucketName + "/")[1];minioClient.removeObject(RemoveObjectArgs.builder().bucket(bucketName).object(objectUrl).build());} catch (Exception e) {throw new RuntimeException("文件删除失败", e);}}/** 检查文件是否存在*/public boolean fileExists(String fileUrl) {if (fileUrl == null || !fileUrl.contains(bucketName + "/")) {return false;}try {String objectUrl = fileUrl.split(bucketName + "/")[1];minioClient.statObject(StatObjectArgs.builder().bucket(bucketName).object(objectUrl).build());return true;} catch (Exception e) {if (e instanceof ErrorResponseException && ((ErrorResponseException) e).errorResponse().code().equals("NoSuchKey")) {return false;}throw new RuntimeException("检查文件存在失败", e);}}/*** 生成唯一文件名(带日期路径 + UUID)*/private String generateUniqueFilename(String extension) {String dateFormat = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd"));String uuid = UUID.randomUUID().toString().replace("-", ""); // 去掉 UUID 中的 "-"return dateFormat + "/" + uuid + extension;}/*** 删除早于指定日期的所有日期目录(yyyy-MM-dd/)** @param endExclusive 截止日期(不含)* @return 实际删除的对象总数*/public int deleteDateFoldersBefore(LocalDate endExclusive) {if (endExclusive == null) {throw new IllegalArgumentException("指定日期不能为空");}LocalDate today = LocalDate.now();if (!endExclusive.isBefore(today)) {return 0;}int totalDeleted = 0;// 从 endExclusive-1 天开始往前删for (LocalDate d = endExclusive.minusDays(1); !d.isBefore(retainSince); d = d.minusDays(1)) {totalDeleted += deleteSingleFolder(d.format(DateTimeFormatter.ISO_LOCAL_DATE) + "/");}return totalDeleted;}/*** 删除单个目录(前缀)下的全部对象*/private int deleteSingleFolder(String prefix) {try {List<DeleteObject> objects = new ArrayList<>();minioClient.listObjects(ListObjectsArgs.builder().bucket(bucketName).prefix(prefix).recursive(true).build()).forEach(r -> {try {objects.add(new DeleteObject(r.get().objectName()));} catch (Exception ignored) {log.warn("文件名获取失败");}});if (objects.isEmpty()) {return 0;}Iterable<Result<DeleteError>> results = minioClient.removeObjects(RemoveObjectsArgs.builder().bucket(bucketName).objects(objects).build());for (Result<DeleteError> res : results) {DeleteError deleteError = res.get();// 无异常即成功}return objects.size();} catch (Exception e) {log.warn("删除目录 {} 失败: {}", prefix, e.toString());return 0;}}}
4.1.4 修改 MinioCleanTask
import com.fc.properties.MinioCleanProperties;
import com.fc.utils.MinioUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDate;@Component//注册Bean
@RequiredArgsConstructor
@Slf4j
public class MinioCleanTask {private final MinioUtil minioUtil;private final MinioCleanProperties minioCleanProperties;/*** 定时清理 MinIO 中早于当前日期的日期目录(格式:yyyy-MM-dd/)* 执行时间:每月1号凌晨3点*/@Async(value = "taskExecutor")//添加异步注解@Scheduled(cron = "#{@minioCleanProperties.cron}")public void minioClean() {try {if (!minioCleanProperties.isEnabled()) {log.warn("清理【Minio】图片定时任务已关闭");return;}log.info("MinIO 清理任务开始执行...");LocalDate cutoff = LocalDate.now().minusDays(minioCleanProperties.getRetainDays());//获取保留自定义天数的时间日期log.info("清理截止日期:{},最早保留日期:{}", cutoff, minioCleanProperties.getEarliestDate());int deleteCount = minioUtil.deleteDateFoldersBefore(cutoff);log.info("MinIO 清理任务执行完成,共删除 {} 张图片", deleteCount);} catch (Exception e) {// 防止定时任务因异常停止log.error("MinIO 清理任务执行失败", e);}}
}
@Scheduled(cron = "#{@minioCleanProperties.cron}")
:让 @Scheduled 注解的 cron 表达式从 Spring 容器里的某个 Bean 中动态取值,而不是写死在代码里
片段 | 含义 |
---|---|
@Scheduled(cron = ...) | Spring 定时任务的注解,指定 cron 表达式。 |
#{} | SpEL(Spring 表达式语言),允许在注解里写动态表达式。 |
@minioCleanProperties | 从 Spring 容器里按 Bean 名称 取出对应的 Bean。 |
.cron | 取出该 Bean 的 getCron() 方法返回的字符串,即 cron 表达式。 |
4.1.5 测试
修改依赖配置文件
minio:clean:enabled: true # 是否启用清理功能retain-days: 5 # 保留最近多少天的文件earliest-date: "2025/08/01" # 最早保留日期(避免误删重要文件)cron: "0/5 * * * * ?" # 每天五秒执行一次
4.2 接口方法
4.2.1 建表和插入数据
CREATE TABLE corn (`id` BIGINT NOT NULL AUTO_INCREMENT PRIMARY KEY COMMENT '主键ID',`enabled` TINYINT NOT NULL COMMENT '是否开启定时任务,1-开启,0-关闭',`retain_days` INT NOT NULL COMMENT '保留最近多少天的文件',`earliest_date` DATE NOT NULL COMMENT '最早保留日期',`corn` VARCHAR ( 20 ) NOT NULL COMMENT 'CORN表达式(触发时间)',`create_time` DATETIME COMMENT '创建时间',`update_time` DATETIME COMMENT '更新时间'
) ENGINE = INNODB DEFAULT CHARSET = UTF8MB4;insert into corn values (1,true,90,'2025/08/01','0/5 * * * * ?',NOW(),NOW());
4.2.2 创建实体
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CornDTO {private long id;private boolean enabled;private int retainDays;private LocalDate earliestDate;private String corn;private LocalDateTime createTime;private LocalDateTime updateTime;
}
4.2.3 Mapper接口
import com.fc.dto.CornDTO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface CornMapper {@Select("select * from corn where id=#{id}")CornDTO selectCornById(long id);
}
关闭数据库的打印信息看一下
非常奈斯!!!