凌晨3点,线上告警突然炸了------我们的SpringBoot应用在发布后,CPU飙到90%以上,而流量几乎为零。你猜罪魁祸首是谁?正是你认为最人畜无害的@Scheduled定时任务。这次我想聊聊SpringBoot任务调度的那些暗坑,以及如何真正驾驭它。
一、当定时任务变成CPU杀手
场景还原:一个日均百万订单的电商系统,有一个@Scheduled(fixedDelay = 5000)的促销状态同步任务。在测试环境跑得风平浪静,上线后却突然把容器CPU吃满。通过arthas抓取堆栈,发现线程池里塞满了ThreadPoolTaskScheduler的worker线程。
- 根因分析*:SpringBoot默认的定时任务线程池竟然只有单线程!当任务执行时间超过间隔时间时,任务会排队堆积。比如你设了5秒间隔,但某次任务执行了10秒,下一次任务会立即启动,最终线程池里堆积的任务越来越多。
java
// 错误写法:默认单线程池,长任务会阻塞
@Scheduled(fixedDelay = 5000)
public void syncPromotionStatus() {
// 耗时10秒的数据库操作
}
// 正确写法:显式配置线程池
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("custom-scheduler-");
return scheduler;
}
数据对比:使用默认配置时,500次任务调用平均延迟高达8秒;配置5线程池后,延迟降至1秒内。
二、Cron表达式的时区陷阱
你以为0 0 12 * * ?肯定在中午12点执行?当你的应用跑在K8s集群里,而Pod时区是UTC时,这个任务会在北京时间20点触发。我们曾因此错过了一场大促的价格预热。
- 原理 *:Spring的
CronTrigger底层用的是java.util.CronExpression,它依赖系统默认时区。Docker镜像默认时区往往是UTC,与物理机时区不同。
java
// 危险写法:依赖系统时区
@Scheduled(cron = "0 0 12 * * ?")
// 安全写法:强制指定时区
@Scheduled(cron = "0 0 12 * * ?", zone = "Asia/Shanghai")
三、优雅关闭与任务丢失
当你的应用发布时,正在执行的定时任务会被暴力终止。我们因此丢失过库存扣减记录------任务执行到一半被kill,但业务状态已变更。
- 机制 *:SpringBoot的
SmartLifecycle机制默认等待30秒关闭,但如果你用的@Scheduled,它根本不会等待任务完成!关键在于实现SmartLifecycle接口:
java
@Component
public class GracefulShutdown implements SmartLifecycle {
private volatile boolean running;
@Override
public void stop(Runnable callback) {
this.running = false;
// 等待现有任务完成
taskScheduler().getScheduledThreadPoolExecutor()
.awaitTermination(60, TimeUnit.SECONDS);
callback.run();
}
}
四、避坑清单:定时任务必知的5条军规
- 线程池隔离 :永远不要用默认线程池,核心任务和普通任务要用不同的
TaskScheduler - 幂等设计:假设你的任务会被重复执行,做好数据库乐观锁/状态机校验
- 监控埋点:用Micrometer记录任务执行时间和异常次数
- 避免长事务:任务方法里别开半小时的事务,Spring会挂起事务线程
- 分布式锁:多实例部署时用Redis锁防止重复执行
五、最佳实践:像数据库连接池一样管理任务
真正可靠的任务调度应该像这样:
java
@Bean
public TaskScheduler criticalTaskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(10);
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(60);
scheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
return scheduler;
}
// 使用时指定调度器
@Scheduled(fixedRate = 30000, scheduler = "criticalTaskScheduler")
public void inventorySync() {
// 关键业务逻辑
}
- 核心结论*:SpringBoot的任务调度不是玩具------把它当核心基础设施对待,用对待数据库连接池的谨慎态度来配置线程模型。
你在任务调度上踩过哪些坑?有没有更好的优雅关闭方案?评论区等你来战。