Spring @Scheduled 定时任务突然停跑、不再执行全场景分析
Spring @Scheduled 定时任务突然停跑、不再执行全场景分析
先记住核心底层:Spring @Scheduled 底层是ScheduledThreadPoolExecutor,Spring 会对任务加 try-catch 吞异常,单纯业务抛异常不会停任务;一旦任务永久卡住、线程耗尽、调度器关闭,才会彻底不再执行。
一、最常见:任务执行时间过长,线程池耗尽(生产最高发)
原理
Spring 默认定时线程池核心线程数只有 1(单线程调度池)。
- 单线程串行执行所有
@Scheduled任务; - 某一个任务执行耗时极长(大量IO、同步HTTP、大事务、大批量数据循环无分页、死锁、慢SQL);
- 调度线程被永久占用,无空闲线程处理下一轮调度;
- 等到下一个 cron/fixedRate 触发点,没有可用线程,任务直接被丢弃,不再执行。
举例复现
// 每5秒执行,但内部sleep 20秒@Scheduled(fixedRate=5000)publicvoidlongTask()throwsInterruptedException{System.out.println("开始执行");Thread.sleep(20000);// 长时间阻塞调度线程}现象:执行几次后彻底看不到日志,定时卡死。
区分两个配置
fixedRate:按固定间隔发起调度,线程忙则堆积丢弃;fixedDelay:等上一次完全执行完,再间隔N毫秒执行,不会堆积,但长时间阻塞依然会卡线程。
解决
自定义定时线程池,增大核心线程:
@ConfigurationpublicclassScheduleConfigimplementsSchedulingConfigurer{@OverridepublicvoidconfigureTasks(ScheduledTaskRegistrartaskRegistrar){taskRegistrar.setScheduler(Executors.newScheduledThreadPool(10));}}二、任务内部出现永久阻塞(线程卡死不释放)
Spring 只会捕获Exception,但阻塞不会抛异常,线程一直占用:
- 无限等待锁、分布式锁未释放(Redisson lock 忘记 unlock、异常分支没释放锁);
- 阻塞IO:http 调用无超时、数据库连接不释放、阻塞队列 take();
- 死锁(多表更新顺序不一致、多线程互等锁);
- 外部接口无限等待,未设置 connect/read 超时。
典型坑:分布式锁漏释放
@Scheduled(cron="0/10 * * * * ?")publicvoidtask(){RLocklock=redissonClient.getLock("task_lock");lock.lock();// 业务抛异常,没有finally解锁dbBiz();lock.unlock();}一旦dbBiz()抛异常,unlock不执行,锁永久持有,下一次进来lock()永久阻塞调度线程。
三、调度器实例被销毁/上下文关闭
- Spring 容器关闭:应用正常/异常停机、kill -15,调度池 shutdown,所有定时停止;
- 动态删除定时任务:代码中手动
ScheduledTaskRegistrar移除任务、销毁触发器; - 热更新/动态刷新上下文:部分配置中心刷新 Bean、销毁重建定时相关 Bean,旧调度被关闭;
- 多实例部署 + 分布式锁单点抢占:单实例抢到锁后长期阻塞,其他实例本身不执行,看起来“任务没跑”。
四、线程内部抛出 Error(非 Exception,Spring 无法捕获)
Spring 包装任务只 catchException,Error 不会捕获,会直接终止调度线程:
- OOM
OutOfMemoryError StackOverflowErrorNoClassDefFoundError
一旦抛出 Error,调度线程直接死亡,默认线程池不会自动重建线程,该定时永久停止。
示例:循环递归无终止条件,栈溢出,定时直接停。
五、cron表达式配置错误 / 时区问题(看起来“停了”)
- cron 写错:比如
0 0 25 * * ?不存在25点,永远不触发; - 时区不匹配:Spring 定时默认服务器时区,集群时区不一致、配置文件指定错误时区,触发时间偏移到凌晨/不存在的时刻;
- 项目重启后 cron 下一次触发间隔极长,误以为任务挂了。
六、第三方中间件资源耗尽阻塞任务
- MySQL 连接池耗尽:定时批量查询/更新不释放连接,
getConnection()阻塞; - Redis 连接打满,Jedis/Redisson 获取连接阻塞;
- MQ 消费者阻塞,同步拉取消息无限等待。
七、异步/事务注解干扰,间接阻塞调度线程
@Transactional长事务,锁表、占用数据库连接;- 错误在定时方法上加
@Async,大量异步任务打满应用线程池,间接导致数据库/IO资源耗尽,下次定时执行阻塞; - 事务死锁,线程卡死。
八、特殊:JDK 版本 bug / ScheduledThreadPool 线程失效
低版本 JDKScheduledThreadPoolExecutor存在已知 bug:
当任务抛出 Error、线程异常退出后,线程池不会补充新工作线程,核心线程数永久少1,单线程池直接报废。
九、区分假象:不是任务停了,是日志没打印
- 日志级别调整为 ERROR,INFO 打印被屏蔽;
- 日志输出缓冲、磁盘满,日志落盘失败,看不到执行记录;
- 日志切面拦截异常吞掉执行日志,误以为未执行。
快速排查步骤(生产定位流程)
- 查看服务器线程堆栈
jstack pid- 搜索
scheduled调度线程,看是否处于WAITING/BLOCKED(锁阻塞); - 查看线程执行栈,定位卡在哪个代码行(数据库、锁、HTTP调用)。
- 搜索
- 查看 GC 日志,判断是否频繁 FullGC、OOM。
- 核对定时线程池核心大小,确认是否为默认1。
- 检查任务内锁、数据库、HTTP 是否全部设置超时、finally释放资源。
- 查看系统 error 日志,是否存在 OOM、StackOverflow 等 Error 堆栈。
- 核对 cron 时区与表达式正确性。
生产强制规范,杜绝定时停跑
- 自定义定时线程池,核心线程数 ≥5;
- 所有外部调用(HTTP、Redis、DB)强制设置超时时间;
- 分布式锁、数据库连接必须 finally 释放;
- 定时方法最外层包裹 try-catch(Throwable),捕获 Exception + Error,防止线程死亡;
- 大批量操作分页处理,避免单次任务长时间执行;
- 定时执行增加监控埋点:执行次数、耗时、失败告警;
- 禁止无限循环、无超时阻塞代码写在定时内。
兜底安全模板(推荐所有定时统一使用)
@Scheduled(fixedRate=10000)publicvoidsafeTask(){try{// 业务逻辑doBiz();}catch(Throwablet){log.error("定时任务执行异常",t);// 告警推送}}Java定时任务抛未捕获异常后任务是否继续执行?分三种主流定时框架分别说明
核心结论先概括:普通单线程定时,未捕获异常会终止当前任务线程,后续调度是否执行取决于框架实现;ScheduledExecutorService、Spring Task、Quartz行为各不相同。
一、JDK原生:ScheduledExecutorService(最基础定时)
1. 代码示例
ScheduledExecutorServicepool=Executors.newSingleThreadScheduledExecutor();// 每隔1秒执行一次pool.scheduleAtFixedRate(()->{System.out.println("任务执行");thrownewRuntimeException("未捕获异常");},0,1,TimeUnit.SECONDS);2. 现象:任务只会执行1次,之后永久不再调度
3. 底层原因
scheduleAtFixedRate/scheduleWithFixedDelay提交的是循环任务Runnable;- 任务内部抛出未捕获异常,线程池执行时会捕获异常并标记任务执行失败;
- 单线程调度池(newSingleThreadScheduledExecutor):
任务抛出异常后,该循环任务直接被丢弃,调度器不会再重试、不会再触发下一次; - 多线程Scheduled池同理:单个失败的循环任务永久终止,其他互不影响;
- 底层源码逻辑:ScheduledFutureTask.run() 中,异常抛出会进入
cancel(false),循环标记置为false,调度终止。
补充:如果是一次性任务schedule(),抛异常只是单次执行失败,不存在后续调度问题。
二、Spring 自带定时 @Scheduled(业务最常用)
1. 现象:本次任务终止,下一次调度依旧正常执行
2. 原因
Spring定时底层封装了ScheduledExecutorService,但做了全局异常捕获兜底:
- Spring会给用户的任务方法套一层代理包装;
- 任务方法抛出未捕获异常时,Spring内部try-catch捕获异常、打印错误日志;
- 异常不会向上抛给ScheduledExecutor,底层调度线程不会感知异常;
- 调度计时器不受影响,到下一个触发时间点,会再次执行该定时方法。
关键区别对比JDK原生
- JDK原生裸写Runnable抛异常 → 任务永久停;
- Spring @Scheduled方法抛异常 → 仅本次失败,下次照常跑。
验证示例
@ComponentpublicclassDemoTask{privateintcount=0;@Scheduled(fixedRate=1000)publicvoidtask(){count++;System.out.println("第"+count+"次执行");if(count==2){thrownewRuntimeException("报错");}}}输出:
第1次执行 第2次执行 ERROR 异常堆栈 第3次执行 第4次执行 ...持续循环三、Quartz 定时框架(复杂分布式定时)
行为由JobDetail、Trigger、异常策略控制,分两种场景:
- Job执行抛未捕获异常,抛出 JobExecutionException
- 若设置
setRefireImmediately(true):立刻重试本次任务; - 若不设置重试:本次失败,等待下一个 cron/间隔周期正常执行;
- 若设置
- 若直接抛出普通RuntimeException(未包装JobExecutionException):
Quartz会捕获,标记本次执行失败,不影响后续调度,到点继续执行;
特殊:配置故障恢复、错过任务补偿时会有额外重试逻辑。
四、拓展:Timer(老旧废弃定时类,不推荐使用)
Timertimer=newTimer();timer.schedule(newTimerTask(){@Overridepublicvoidrun(){thrownewRuntimeException();}},0,1000);现象:整个Timer线程直接崩溃,所有定时任务全部停止
原因:Timer只有单个守护线程,无异常捕获,一旦抛异常线程直接终止,整个Timer报废。
统一总结表格
| 定时实现方式 | 未捕获异常后果 | 后续是否继续执行 |
|---|---|---|
| JDK Timer(废弃) | 唯一执行线程崩溃,全部任务停 | 否,全部终止 |
| JDK ScheduledExecutor 原生循环任务 | 当前任务永久终止,其他任务不受影响 | 否,该任务不再跑 |
| Spring @Scheduled | 仅本次执行失败,日志打印异常,调度不受影响 | 是,下次正常执行 |
| Quartz | 本次失败,默认等待周期重新执行,可配置重试策略 | 是,默认继续 |
生产环境规范建议
- 所有定时任务方法必须手动try-catch捕获全部异常,打印业务日志+异常堆栈;
- JDK原生Scheduled场景,兜底捕获防止任务彻底卡死;
- 异常时增加告警(短信/钉钉),避免任务静默失败无人感知;
- 禁止使用Timer,统一使用Scheduled线程池或Spring Task。
