昨晚凌晨 2 点,阿强(是的,还是那个还没拿到 Offer 的阿强)突然给我打电话,声音都在抖。
“Fox 老师,出大事了!我们线上的‘每日财务报表’任务,今天居然没跑!老板早上要看数据,我现在后台看日志,没有任何报错,没有任何异常,就像这个任务凭空消失了一样!”
我问他:“你是不是同一个项目里写了多个@Scheduled任务?”
阿强说:“对啊,除了报表,还有一个每分钟跑一次的心跳检测,还有一个每小时同步一次的第三方数据。”
我接着问:“那你配置自定义线程池了吗?”
阿强懵了:“啊?@Scheduled不是 Spring 自带的吗?还需要配线程池?它不应该是多线程自动跑的吗?”
面试官听到这儿,估计要把简历扔碎纸机里了。Spring 的@Scheduled默认是单线程的!这就是导致任务“凭空消失”的罪魁祸首。 今天 Fox 带你拆解 Spring 定时任务在生产环境下的 3 个“隐形杀手”。
💣 地雷一:默认单线程 = 堵车现场
这是 90% 的新手都会踩的坑。
【惨案现场】
阿强的代码是这样的:
@Component publicclass MyTasks { // 任务A:每分钟跑一次,模拟耗时操作(比如卡住了) @Scheduled(cron = "0 * * * * ?") public void heartbeat() { // 假设这里调第三方接口超时,卡了 10 分钟 Thread.sleep(600000); } // 任务B:每天凌晨1点跑(关键业务) @Scheduled(cron = "0 0 1 * * ?") public void dailyReport() { log.info("开始生成报表..."); // 永远不会执行到这里 } }【P7 视角拆解】
Spring Boot 默认的ThreadPoolTaskScheduler核心线程数(poolSize)是1。 这意味着,整个应用里所有的@Scheduled任务,都挤在同一条车道上排队!
场景还原:
01:00:00,本该执行“每日报表”。
但是!这时候“心跳任务”正在执行,而且被卡住了(比如网络超时)。
因为只有一个线程,“每日报表”任务只能干等。
等到心跳任务跑完,可能已经过了报表任务的执行窗口,或者任务积压导致严重延时。
结论:只要有一个任务卡死,全站的定时任务全部陪葬。
✅ 王者级解法:自定义线程池
必须实现SchedulingConfigurer接口,给调度器配备“多车道”。
@Configuration publicclass ScheduledConfig implements SchedulingConfigurer { @Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler(); // 核心线程数:根据你的任务数量和耗时来定,比如 CPU核数 * 2 taskScheduler.setPoolSize(10); taskScheduler.setThreadNamePrefix("my-scheduled-task-"); // 关键:设置等待终止时间,保证优雅停机 taskScheduler.setWaitForTasksToCompleteOnShutdown(true); taskScheduler.initialize(); taskRegistrar.setTaskScheduler(taskScheduler); } }注意:别简单地在application.properties里配spring.task.scheduling.pool.size,低版本的 Spring Boot 可能不生效,代码配置最稳妥。
💣 地雷二:集群部署 = 灾难性重复
如果你解决了地雷一,恭喜你,你的代码在本地跑没问题了。 但当你把代码部署到生产环境(假设有 3 台服务器做负载均衡),新的灾难来了。
【惨案现场】
老板问:“阿强,为什么今天早上我也收到了 3 封一模一样的邮件?用户的积分也发了 3 份?” 阿强:“我代码里只写了一次发送逻辑啊!”
【P7 视角拆解】
@Scheduled是应用层的调度。 当你部署了 3 个实例(Instance A, B, C),这 3 个 JVM 进程是完全独立的。 到了时间点,A 会跑,B 会跑,C 也会跑。
如果是“发送优惠券”、“银行扣款”这种非幂等操作,这就是P0级生产事故。
✅ 王者级解法:ShedLock 分布式锁
如果不想引入复杂的中间件,可以用ShedLock。它利用数据库或 Redis,保证同一时间只有一个节点能抢到任务。
// 加上 @SchedulerLock @Scheduled(cron = "...") @SchedulerLock(name = "dailyReport", lockAtMostFor = "10m", lockAtLeastFor = "1m") public void dailyReport() { // 只有抢到锁的节点才会执行 }这里有两个参数至关重要:
**lockAtLeastFor**:最少锁多久。防止节点 A 刚跑完释放锁,节点 B 因为时间差又抢到锁跑了一次(防抖动)。**lockAtMostFor**:这是兜底机制!万一抢到锁的节点 A 突然挂了(断电、OOM),锁会在 10 分钟后自动释放。如果没有这个机制,这把锁将永远被死掉的节点 A 占着,任务就真的“死锁”了。
💣 地雷三:异常吞没 = 沉默的杀手
你有没有遇到过这种情况:任务跑着跑着,任务虽然没停,但业务逻辑全是错的,甚至老板不问你都不知道?
【惨案现场】
@Scheduled(fixedRate = 5000) public void syncData() { // 假如这里抛出了一个 RuntimeException(比如空指针,或者数据库连不上) throw new RuntimeException("DB挂了"); }【P7 视角拆解】
Spring 的默认机制虽然会打印 Error 日志,但在生产环境海量的INFO日志海洋中,这几行报错瞬间就被淹没了。 更可怕的是:
日志被淹没:运维和开发如果不主动查日志,根本不知道任务失败了。
严重故障:如果发生的是
OutOfMemoryError这种严重错误,线程可能直接暴毙,后续任务真的就再也不会触发了。
你以为它在跑,其实它早就“死”了,或者在“裸奔”报错,而且连报警短信都没有。
✅ 王者级解法:AOP 统一拦截 + 监控
任何定时任务,必须在最外层包裹try-catch,或者使用 AOP 切面做统一异常监控。
@Aspect @Component @Slf4j public class ScheduledExceptionAspect { // 拦截所有 @Scheduled 方法 @AfterThrowing(pointcut = "@annotation(org.springframework.scheduling.annotation.Scheduled)", throwing = "e") public void handleException(JoinPoint joinPoint, Throwable e) { // 1. 打印关键堆栈 log.error("【严重】定时任务执行异常: method={}", joinPoint.getSignature().getName(), e); // 2. 发送报警(钉钉/邮件) AlertUtils.send("生产环境定时任务失败!请立即检查!"); } }💡 架构师的“防杠”指南(面试必背)
下次面试官问:“Spring 定时任务有什么坑?” 你直接扔出这套组合拳:
“面试官,Spring 的@Scheduled适合单机轻量级任务,但在生产级架构中,我有三条铁律:
第一,拒绝默认线程池:默认的单线程模型极易导致任务级联阻塞,必须实现SchedulingConfigurer配置自定义线程池。
第二,集群防重:在微服务多实例部署下,必须引入分布式锁(如 ShedLock)。特别是要配置好lockAtMostFor参数,防止节点宕机导致的死锁。
第三,异常兜底:定时任务也是后台线程,Spring 虽然不会轻易终止线程,但我们不能依赖‘日志’来发现问题。必须建立 AOP 统一拦截机制,对接 Prometheus 或钉钉报警,防止任务‘静默失败’。”
老哥最后再唠两句
定时任务就像系统的心脏,平时感觉不到它的存在,一旦停跳,整个系统就挂了。 别为了偷懒少写几行配置代码,给自己的周末埋下加班的雷。
https://mp.weixin.qq.com/s/geJdaeHWeJmqBlmuLafxIg