Spring @Scheduled 定时任务突然停跑、不再执行全场景分析
Spring Scheduled 定时任务突然停跑、不再执行全场景分析先记住核心底层Spring Scheduled 底层是ScheduledThreadPoolExecutorSpring 会对任务加 try-catch 吞异常单纯业务抛异常不会停任务一旦任务永久卡住、线程耗尽、调度器关闭才会彻底不再执行。一、最常见任务执行时间过长线程池耗尽生产最高发原理Spring 默认定时线程池核心线程数只有 1单线程调度池。单线程串行执行所有Scheduled任务某一个任务执行耗时极长大量IO、同步HTTP、大事务、大批量数据循环无分页、死锁、慢SQL调度线程被永久占用无空闲线程处理下一轮调度等到下一个 cron/fixedRate 触发点没有可用线程任务直接被丢弃不再执行。举例复现// 每5秒执行但内部sleep 20秒Scheduled(fixedRate5000)publicvoidlongTask()throwsInterruptedException{System.out.println(开始执行);Thread.sleep(20000);// 长时间阻塞调度线程}现象执行几次后彻底看不到日志定时卡死。区分两个配置fixedRate按固定间隔发起调度线程忙则堆积丢弃fixedDelay等上一次完全执行完再间隔N毫秒执行不会堆积但长时间阻塞依然会卡线程。解决自定义定时线程池增大核心线程ConfigurationpublicclassScheduleConfigimplementsSchedulingConfigurer{OverridepublicvoidconfigureTasks(ScheduledTaskRegistrartaskRegistrar){taskRegistrar.setScheduler(Executors.newScheduledThreadPool(10));}}二、任务内部出现永久阻塞线程卡死不释放Spring 只会捕获Exception但阻塞不会抛异常线程一直占用无限等待锁、分布式锁未释放Redisson lock 忘记 unlock、异常分支没释放锁阻塞IOhttp 调用无超时、数据库连接不释放、阻塞队列 take()死锁多表更新顺序不一致、多线程互等锁外部接口无限等待未设置 connect/read 超时。典型坑分布式锁漏释放Scheduled(cron0/10 * * * * ?)publicvoidtask(){RLocklockredissonClient.getLock(task_lock);lock.lock();// 业务抛异常没有finally解锁dbBiz();lock.unlock();}一旦dbBiz()抛异常unlock不执行锁永久持有下一次进来lock()永久阻塞调度线程。三、调度器实例被销毁/上下文关闭Spring 容器关闭应用正常/异常停机、kill -15调度池 shutdown所有定时停止动态删除定时任务代码中手动ScheduledTaskRegistrar移除任务、销毁触发器热更新/动态刷新上下文部分配置中心刷新 Bean、销毁重建定时相关 Bean旧调度被关闭多实例部署 分布式锁单点抢占单实例抢到锁后长期阻塞其他实例本身不执行看起来“任务没跑”。四、线程内部抛出 Error非 ExceptionSpring 无法捕获Spring 包装任务只 catchExceptionError 不会捕获会直接终止调度线程OOMOutOfMemoryErrorStackOverflowErrorNoClassDefFoundError一旦抛出 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单线程池直接报废。九、区分假象不是任务停了是日志没打印日志级别调整为 ERRORINFO 打印被屏蔽日志输出缓冲、磁盘满日志落盘失败看不到执行记录日志切面拦截异常吞掉执行日志误以为未执行。快速排查步骤生产定位流程查看服务器线程堆栈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(fixedRate10000)publicvoidsafeTask(){try{// 业务逻辑doBiz();}catch(Throwablet){log.error(定时任务执行异常,t);// 告警推送}}Java定时任务抛未捕获异常后任务是否继续执行分三种主流定时框架分别说明核心结论先概括普通单线程定时未捕获异常会终止当前任务线程后续调度是否执行取决于框架实现ScheduledExecutorService、Spring Task、Quartz行为各不相同。一、JDK原生ScheduledExecutorService最基础定时1. 代码示例ScheduledExecutorServicepoolExecutors.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{privateintcount0;Scheduled(fixedRate1000)publicvoidtask(){count;System.out.println(第count次执行);if(count2){thrownewRuntimeException(报错);}}}输出第1次执行 第2次执行 ERROR 异常堆栈 第3次执行 第4次执行 ...持续循环三、Quartz 定时框架复杂分布式定时行为由JobDetail、Trigger、异常策略控制分两种场景Job执行抛未捕获异常抛出 JobExecutionException若设置setRefireImmediately(true)立刻重试本次任务若不设置重试本次失败等待下一个 cron/间隔周期正常执行若直接抛出普通RuntimeException未包装JobExecutionExceptionQuartz会捕获标记本次执行失败不影响后续调度到点继续执行特殊配置故障恢复、错过任务补偿时会有额外重试逻辑。四、拓展Timer老旧废弃定时类不推荐使用TimertimernewTimer();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。