这段配置通过区分普通异步、守护定时和关键定时三种模式,很好地覆盖了后端开发中的典型并发场景。
以下是针对这三个 Bean 的具体使用场景示例:
1. taskExecutor (通用异步线程池)
场景特点:高并发、执行快、对实时性有一定要求,允许在系统关闭时有短暂的等待。
-
发送通知:用户下单后,异步发送短信、邮件或推送通知。
-
日志记录/审计:将非核心的操作日志异步写入数据库或 Elasticsearch。
-
报表导出:用户点击“下载报表”,后台开始异步生成 Excel,完成后通过 WebSocket 或站内信通知。
-
图片/文件处理:上传图片后的缩略图生成或水印添加。
代码示例:
Java
@Async("taskExecutor")
public void sendVerificationCode(String mobile){
// 模拟耗时网络请求
smsService.send(mobile, "Your code is 1234");
}
2. daemonScheduler (守护线程定时任务池)
场景特点:后台辅助任务。所谓“守护线程”,意味着如果 JVM 中只剩下这些线程在运行,JVM 会直接退出。适合那些“丢了也不心疼”或“下次启动能自动恢复”的任务。
-
缓存预热/刷新:每隔 5 分钟从数据库更新一次 Redis 里的热门配置。
-
系统监控指标收集:每分钟采集当前内存、CPU 使用率并上报。
-
心跳检测:维护与外部 WebSocket 或中间件的活跃状态。
-
清理临时文件:每天凌晨清理服务器上的临时上传目录。
代码示例:
Java
@Resource(name = "daemonScheduler")
private ScheduledExecutorService daemonScheduler;
public void startHeartbeat(){
daemonScheduler.scheduleAtFixedRate(() -> {
log.info("Sending heartbeat to monitoring server...");
}, 0, 30, TimeUnit.SECONDS);
}
3. criticalScheduler (非守护线程定时任务池)
场景特点:核心业务逻辑。非守护线程会阻止 JVM 正常退出(直到任务完成或超时),配合 AbortPolicy 拒绝策略,确保任务的严肃性。
-
金融对账/结算:每日凌晨进行的交易对账,必须确保完整运行,不能因为系统重启而被轻易中断。
-
订单超时关闭:扫描数据库中超过 30 分钟未支付的订单并进行库存回退。
-
自动平仓/触发止损:在交易系统中,根据价格触发预设的买卖指令(这种任务绝对不能无故消失)。
-
多阶段状态轮询:例如调用第三方支付接口后,持续轮询最终支付结果。
代码示例:
Java
@Resource(name = "criticalScheduler")
private ScheduledExecutorService criticalScheduler;
public void scheduleOrderSettlement(){
criticalScheduler.execute(() -> {
log.info("Starting critical financial settlement...");
// 执行核心财务逻辑
});
}
总结建议
| 线程池 | 线程类型 | 拒绝策略 | 适合任务 | 优先级 |
| taskExecutor | 普通线程 | CallerRuns (调用者运行) | 业务异步化、高吞吐 | 中 |
| daemonScheduler | 守护线程 | CallerRuns (调用者运行) | 监控、缓存、辅助维护 | 低 |
| criticalScheduler | 非守护线程 | Abort (抛出异常) | 金融、结算、状态一致性 | 高 |
下一步建议:
由于 criticalScheduler 使用了 AbortPolicy,在高负载下会直接抛出 RejectedExecutionException。您是否需要我为您演示如何结合 Spring Retry 或自定义监听器来捕获并处理这些拒绝异常?
package com.coin.api.schedule;
import com.coin.business.service.MarketDataService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 交易数据1分钟清理任务
*/
@Component
@Slf4j
public class MarketDataCleanJob {
@Resource
MarketDataService marketDataService;
/**
* 交易数据清理任务,每8小时执行一次
*/
@Async("daemonScheduler")
@Scheduled(fixedRate = 8 * 60 * 60 * 1000)
public void execute() {
log.info("执行任务--TradeData1mCleanJob。。。。。。。。。 begin");
marketDataService.clean90Data();
marketDataService.cleanOldData();
log.info("执行任务--TradeData1mCleanJob。。。。。。。。。 end");
}
} 假设这种执行,如果上次没有执行完,或者就是线程数不够了,或者各种场景会怎么样
在 Spring 的 @Scheduled 配合自定义线程池的架构下,这种执行方式会根据具体的资源状况和任务耗时产生不同的行为。
我们需要先纠正一个代码层面的误区:@Async 注解默认是拦截方法调用的,但在同一个类中由 @Scheduled 直接触发时,如果配置不当,它的行为可能并不会如你预想的那样直接进入 daemonScheduler。 假设配置已正确生效(即任务确实交由 ScheduledThreadPoolExecutor 异步执行),以下是针对不同异常场景的深度解析:
1. 上次任务没执行完,下次任务时间到了
这是最常见的场景。由于你使用的是 fixedRate = 8小时:
-
如果任务是单线程串行的:Spring 的
ScheduledThreadPoolExecutor默认行为是阻塞。即使到了 8 小时,如果上一次clean90Data()还没跑完,下一次任务会进入队列等待,直到上一次结束后立即开始。 -
由于你加了
@Async:-
每次触发时,
@Scheduled只是负责“发起”调用。 -
@Async会把任务封装成一个Runnable丢进daemonScheduler的队列。 -
结果:会出现重叠执行。如果 8 小时到了,旧任务还在清理大批量数据,新任务会同时启动。这可能导致数据库死锁(两个任务同时删同一张表)或数据库 IO 瞬间飙升。
-
2. 线程数不够了(线程池满了)
在你的配置中,daemonScheduler 的核心线程数是 5,拒绝策略是 CallerRunsPolicy:
-
场景:假设有 5 个清理任务都在跑,且队列也塞满了。
-
后果:第 6 个任务触发时,由于触发者是 Spring 内部的定时调度线程(通常是
scheduling-1),CallerRunsPolicy会让调度线程自己去执行这个清理任务。 -
连锁反应:调度线程被占用了执行耗时的数据库清理,会导致整个应用中所有的
@Scheduled任务全部停摆,直到这个清理任务完成。
3. 任务执行过程中抛出异常
你在配置中重写了 afterExecute 并调用了 ThreadUtils.printException:
-
表现:如果
clean90Data()抛出RuntimeException(如数据库连接超时),线程不会死掉,它会被线程池回收。 -
日志:你会看到
ThreadUtils打印的错误堆栈,但任务会终止。 -
下次执行:8 小时后,定时器依然会尝试触发下一次执行,它不会因为上次失败就停止后续调度。
4. 关键场景对比表
| 场景 | 表现 | 潜在风险 |
| 执行耗时 > 8小时 | 新老任务并行运行 | 数据库压力倍增、逻辑重复、死锁 |
| 数据库压力过大 | 大量线程阻塞在 SQL 执行上 | 耗尽 daemonScheduler 线程池 |
| JVM 突然重启/关闭 | 由于是 daemon(true) |
任务会被立即强杀,可能导致数据清理只做了一半(事务未提交) |
| 线程池队列满 | 触发 CallerRuns |
全局调度阻塞,其他定时任务失效 |
优化建议
为了让这类清理任务更健壮,建议做以下改进:
-
防止重叠执行:
如果你不希望两个清理任务同时跑,去掉
@Async,或者在方法内部加一个“分布式锁”(如 Redis 锁)或简单的AtomicBoolean状态位。Java
private final AtomicBoolean isRunning = new AtomicBoolean(false); public void execute() { if (!isRunning.compareAndSet(false, true)) { log.warn("上次任务还在跑,跳过本次执行"); return; } try { // ... 清理逻辑 } finally { isRunning.set(false); } } -
改用
fixedDelay:将
fixedRate改为fixedDelay。-
fixedRate: 8 小时准点触发(不管上次完没完)。 -
fixedDelay: 上次跑完之后,再等 8 小时触发。这在清理大数据量的场景下更安全。
-
-
考虑事务与分片:
清理 90 天前的数据建议分批删除(例如每批删除 1000 条,循环进行),避免长事务锁定数据库表太久,也能减少线程长时间占用的风险。
你想让我帮你写一个带自旋锁和分批处理的健壮清理逻辑模板吗?