@Scheduled 定时任务是怎么工作的?为什么默认单线程会互相阻塞?
简化版
@Scheduled 是 Spring 提供的「声明式定时任务」——在方法上加 @Scheduled,配好触发规则(cron 表达式、fixedRate 固定频率、fixedDelay 固定延迟),Spring 就会到点自动调用这个方法,不用自己写 Timer/ScheduledExecutorService。怎么工作:需要 @EnableScheduling 开启,Spring 用一个 TaskScheduler(底层是 ScheduledThreadPoolExecutor)来调度所有定时任务。关键坑:默认是单线程的——所有 @Scheduled 方法共用一个线程执行。所以如果一个任务执行很慢(或多个任务触发时间撞上了),后面的任务会被阻塞、延迟执行甚至错过。解决:配置一个多线程的 TaskScheduler(如 ThreadPoolTaskScheduler 设 poolSize>1),或用 @Async 让任务异步执行。三种触发规则的区别:cron(按表达式,如「每天 0 点」);fixedRate(从上次开始算固定间隔,不管上次执行多久);fixedDelay(从上次结束算固定间隔,等上次跑完再等一段)。
详细版
三种触发方式对比:
| 方式 | 计时起点 | 含义 | 适用 |
|---|---|---|---|
cron | 按 cron 表达式 | 在特定时刻触发(如每天 2:00) | 定点任务 |
fixedRate | 上次开始时间 | 每隔 N ms 触发一次(不等上次结束) | 固定频率采样 |
fixedDelay | 上次结束时间 | 上次跑完后再等 N ms 触发 | 避免任务堆叠 |
initialDelay | — | 首次执行前的延迟(配合上面用) | 延迟启动 |
@Configuration
@EnableScheduling // ① 开启定时任务
public class ScheduleConfig { }
@Component
public class MyTasks {
// cron:每天 0 点执行(秒 分 时 日 月 周)
@Scheduled(cron = "0 0 0 * * ?")
public void daily() { }
// fixedRate:每 5 秒触发一次(从上次开始算,不管上次跑多久)
@Scheduled(fixedRate = 5000)
public void everyFiveSec() { }
// fixedDelay:上次跑完后再等 5 秒(从上次结束算)
@Scheduled(fixedDelay = 5000, initialDelay = 10000)
public void afterFinish() { }
}
解决单线程阻塞——配置多线程调度器:
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5); // 多线程,任务互不阻塞
scheduler.setThreadNamePrefix("sched-");
return scheduler;
}
⚠️ 最容易踩的坑:默认单线程导致任务互相拖累。Spring 的定时任务默认用单线程的
TaskScheduler——意味着所有@Scheduled方法排队用一个线程跑。后果:① 任务 A 跑了 10 秒,期间任务 B 到点了也只能干等;② 用fixedRate=1000但任务本身要跑 3 秒,任务会一直「追不上」进度、堆积。排查现象:「定时任务偶尔不按时执行 / 好几个任务只有一个在跑」,八成是单线程被某个慢任务占住了。解决:配poolSize>1的ThreadPoolTaskScheduler,或给任务加@Async(用独立线程池异步执行,不占调度线程)。
完整版教学
一、@Scheduled:声明式定时任务
@Scheduled 的价值是「声明式」——把「怎么调度」交给 Spring:
没有 @Scheduled 时,自己写定时任务:
用 Timer(老、单线程、异常会终止整个 Timer)
或 ScheduledExecutorService(要自己管理线程池、提交任务)
→ 样板代码多、易错
有了 @Scheduled:
方法上标 @Scheduled(cron="..."), 加 @EnableScheduling
→ Spring 自动到点调用,样板代码归零
→ 声明"什么时候执行",不用管"怎么调度"
开启方式:
@EnableScheduling(在配置类上)
Spring Boot 里也可,通常一个配置类加上即可
@Scheduled 是「声明式定时任务」——只需声明「什么时候执行」(cron/fixedRate/fixedDelay),不用自己写 Timer/ScheduledExecutorService 的样板代码和线程管理。需要 @EnableScheduling 开启。这是 Spring「声明式编程」的又一体现(像 @Transactional、@Cacheable)。理解「@Scheduled 声明式定时、只声明何时执行、@EnableScheduling 开启、替代 Timer/ScheduledExecutorService 样板代码」,就理解了它的定位。
二、三种触发方式:cron / fixedRate / fixedDelay
三种触发方式的区别是高频考点,关键在「计时起点」:
① cron:按 cron 表达式在"特定时刻"触发
"0 0 2 * * ?" = 每天凌晨 2:00
格式:秒 分 时 日 月 周(Spring 是 6 位,注意和 Linux 5 位不同)
适合:定点任务(每天/每周/每月固定时间)
② fixedRate:从"上次开始"算,每隔 N ms 触发
T=0 开始,fixedRate=5000 → T=0,5,10,15... 触发
★ 不管上次执行多久,到点就触发(可能上次没跑完)
适合:固定频率的采样、心跳
③ fixedDelay:从"上次结束"算,隔 N ms 触发
上次 T=0 开始跑到 T=3 结束,fixedDelay=5000
→ 下次在 T=3+5=8 触发
★ 保证两次执行之间有 N ms 间隔,不会堆叠
适合:任务不能重叠、要等上次干净结束
三种方式的核心区别是「计时起点」:cron 按表达式定点触发(注意 Spring 是 6 位含秒);fixedRate 从上次开始算固定间隔(不等上次结束,可能重叠);fixedDelay 从上次结束算固定间隔(保证两次之间有间隔,不堆叠)。选择:定点用 cron、固定频率采样用 fixedRate、任务不能重叠用 fixedDelay。理解「cron 定点(6位含秒)、fixedRate 从上次开始算(可能重叠)、fixedDelay 从上次结束算(不堆叠)、计时起点是关键区别」,就掌握了三种触发方式。
三、fixedRate vs fixedDelay:一个算例
用一个具体算例把 fixedRate 和 fixedDelay 的区别彻底讲清:
假设任务每次执行需要 3 秒,间隔参数都是 5 秒(5000ms):
fixedRate=5000(从上次开始算):
T=0 开始第1次(跑到 T=3 结束)
T=5 开始第2次(不管第1次,5秒到就开始)
T=10 开始第3次
→ 触发点:0, 5, 10, 15...(严格每 5 秒一次)
→ 如果任务要跑 6 秒(>5秒间隔):会来不及,任务堆积!
fixedDelay=5000(从上次结束算):
T=0 开始第1次(跑到 T=3 结束)
T=3+5=8 开始第2次(跑到 T=11 结束)
T=11+5=16 开始第3次
→ 触发点:0, 8, 16...(每次结束后再等5秒)
→ 无论任务多慢,两次之间必有 5 秒间隔,永不堆积
结论:
要"严格频率" → fixedRate(但任务要比间隔快,否则堆积)
要"稳定间隔、不堆叠" → fixedDelay(更安全)
这个算例揭示了本质:fixedRate 追求「严格频率」(每 5 秒一次,但若任务比间隔慢会堆积);fixedDelay 追求「稳定间隔」(每次结束后等 5 秒,永不堆积,更安全)。生产中如果任务耗时不稳定,fixedDelay 通常更稳妥(避免任务重叠/堆积)。理解「fixedRate 严格频率但慢任务会堆积、fixedDelay 每次结束后等固定间隔永不堆积、耗时不稳用 fixedDelay 更安全」,就彻底分清了两者。
四、底层:TaskScheduler 与单线程默认
@Scheduled 底层靠 TaskScheduler 调度,而它默认单线程——这是最大的坑:
底层机制:
@EnableScheduling → ScheduledAnnotationBeanPostProcessor
扫描所有 @Scheduled 方法,注册到 ScheduledTaskRegistrar
由一个 TaskScheduler 负责调度
TaskScheduler 底层是 ScheduledThreadPoolExecutor
★ 默认的坑:如果没配 TaskScheduler
Spring 用一个"单线程"的调度器(poolSize=1)
→ 所有 @Scheduled 方法共用这一个线程!
后果(单线程):
- 任务 A 跑 10 秒,期间任务 B 到点了 → B 只能等 A 跑完
- 多个定时任务"看起来只有一个在跑"
- fixedRate 的任务若比间隔慢 → 无限延迟追赶
排查信号:定时任务偶尔不按时、多个任务串行执行
底层是 TaskScheduler(ScheduledThreadPoolExecutor)调度所有 @Scheduled 方法。默认是单线程(poolSize=1)——所有定时任务共用一个线程,一个慢任务会阻塞其他任务。这导致「定时任务偶尔不按时、多个任务串行、只有一个在跑」的现象。这是 @Scheduled 最常见的生产坑。理解「@Scheduled 底层是 TaskScheduler(ScheduledThreadPoolExecutor)、默认单线程 poolSize=1、所有任务共用一个线程、慢任务阻塞其他任务」,就理解了单线程默认这个关键坑。
五、解决单线程阻塞
针对单线程阻塞,有两种解决方案:
方案一:配多线程的 TaskScheduler(推荐)
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler s = new ThreadPoolTaskScheduler();
s.setPoolSize(5); // 多个线程,任务并行、互不阻塞
return s;
}
或 Spring Boot 配置:spring.task.scheduling.pool.size=5
→ 不同任务用不同线程,慢任务不再拖累其他任务
方案二:给任务加 @Async(异步执行)
@Async
@Scheduled(fixedRate = 5000)
public void task() { ... }
→ 调度线程只负责"触发",任务实际在 @Async 的线程池里跑
→ 调度线程立刻空闲,能准时触发下一个
(需 @EnableAsync,且注意 @Async 的线程池也要配好)
选择:
任务多、要并行 → 配多线程 TaskScheduler
任务耗时长、想彻底解耦触发和执行 → @Async
解决单线程阻塞两方案:① 配多线程 TaskScheduler(ThreadPoolTaskScheduler 设 poolSize>1 或 spring.task.scheduling.pool.size,不同任务用不同线程并行);② 给任务加 @Async(调度线程只负责触发、任务在异步线程池执行,调度线程立刻空闲)。前者简单直接,后者彻底解耦触发和执行。理解「解决单线程阻塞:配多线程 TaskScheduler(poolSize>1) 或 @Async 让任务异步执行、调度线程只触发」,就掌握了实战解决方案。
六、定时任务的其他注意点
生产中用 @Scheduled 还有几个必须注意的点:
① 异常处理:
@Scheduled 方法抛异常,默认只打日志、不会终止后续调度
(比 Timer 好,Timer 一个异常终止整个 Timer)
但异常任务本次就失败了 → 要自己 try-catch 处理关键异常
② 分布式环境的重复执行(重要!):
多个实例都部署了同一个 @Scheduled 任务
→ 到点每个实例都执行 → 任务重复跑 N 次!
解决:分布式锁(Redis/Zookeeper)保证只有一个实例执行
或用分布式任务调度(XXL-Job、Quartz 集群、ElasticJob)
③ cron 表达式:Spring 是 6 位(含秒),Linux crontab 是 5 位,别抄错
④ 长任务与 fixedRate:任务比间隔慢会堆积,改用 fixedDelay
生产注意点:① 异常处理(@Scheduled 抛异常只打日志不终止调度,但本次任务失败,关键任务要 try-catch);② 分布式重复执行(多实例都会到点执行,需分布式锁或用 XXL-Job/Quartz 集群保证只跑一次——这是分布式部署的大坑);③ cron 是 6 位含秒(别抄 Linux 的 5 位);④ 长任务用 fixedDelay 避免堆积。其中「分布式重复执行」在生产中最容易被忽视。理解「@Scheduled 异常只打日志不终止、分布式多实例会重复执行需分布式锁/XXL-Job、cron 6 位含秒、长任务用 fixedDelay」,就掌握了定时任务的生产要点。
记忆钩子:「@Scheduled 声明式定时任务,需 @EnableScheduling;三方式:cron(定点,Spring 6位含秒)/fixedRate(从上次开始算,严格频率但慢任务堆积)/fixedDelay(从上次结束算,稳定间隔不堆积更安全);底层 TaskScheduler(ScheduledThreadPoolExecutor)默认单线程 poolSize=1→所有任务共用一线程、慢任务阻塞其他(最大坑);解决:配多线程 ThreadPoolTaskScheduler(poolSize>1)或 @Async 异步;生产坑:分布式多实例重复执行需分布式锁/XXL-Job」。
七、常见误区与追问
- 误区:@Scheduled 任务默认并行执行。 默认单线程(poolSize=1)——所有 @Scheduled 方法共用一个线程串行执行,一个慢任务会阻塞其他任务;要并行得配多线程 TaskScheduler 或加 @Async。
- 误区:fixedRate 和 fixedDelay 一样。 计时起点不同——fixedRate 从「上次开始」算(严格频率,任务比间隔慢会堆积);fixedDelay 从「上次结束」算(保证两次之间有固定间隔,永不堆积);耗时不稳的任务用 fixedDelay 更安全。
- 误区:Spring 的 cron 表达式和 Linux crontab 一样。 Spring 是 6 位(秒 分 时 日 月 周,含秒),Linux crontab 是 5 位(无秒);照抄 Linux 的 5 位表达式会解析错误。
- 误区:多实例部署时定时任务会自动只跑一次。 恰恰相反——每个实例都部署了任务、到点每个实例都执行,导致任务重复跑 N 次;要用分布式锁(Redis/ZK)或分布式调度框架(XXL-Job、Quartz 集群、ElasticJob)保证只有一个实例执行。
- 追问:为什么定时任务偶尔不按时执行? 大概率是默认单线程被某个慢任务占住了——某个 @Scheduled 方法执行很久,期间其他到点的任务只能排队等,导致延迟甚至错过;配多线程 TaskScheduler 或用 @Async 解决。
- 追问:@Scheduled 方法抛异常会怎样? 默认只记录日志,不会终止后续的定时调度(比老的 Timer 好,Timer 一个异常会终止整个 Timer);但本次任务失败了,关键任务应自己 try-catch 处理,避免静默失败。
- 追问:怎么让长耗时的定时任务不影响调度准时性? 给任务加 @Async(配合 @EnableAsync 和独立线程池)——调度线程只负责「到点触发」,任务实际在异步线程池里跑,调度线程触发后立刻空闲、能准时触发下一个任务,实现触发与执行解耦。
八、加强记忆
@Scheduled 是 Spring 的「声明式定时任务」——方法上标注 + 配触发规则,Spring 到点自动调用(需 @EnableScheduling 开启),替代 Timer/ScheduledExecutorService 样板代码。三种触发方式(关键区别是计时起点):cron(按表达式定点,Spring 是 6 位含秒,别抄 Linux 5 位);fixedRate(从上次开始算,严格频率,但任务比间隔慢会堆积);fixedDelay(从上次结束算,保证两次间有固定间隔,永不堆积、更安全)。底层是 TaskScheduler(ScheduledThreadPoolExecutor),默认单线程(poolSize=1)——所有 @Scheduled 方法共用一个线程,一个慢任务会阻塞其他任务(导致「偶尔不按时、多任务串行」,最常见的坑)。解决:配多线程 ThreadPoolTaskScheduler(poolSize>1 或 spring.task.scheduling.pool.size)、或给任务加 @Async(调度线程只触发、任务异步执行)。生产大坑:分布式多实例部署时每个实例都会到点执行导致重复跑,需分布式锁或用 XXL-Job/Quartz 集群/ElasticJob 保证只跑一次;异常默认只打日志不终止调度但本次失败。一句话「@Scheduled 声明式定时(@EnableScheduling);cron 定点(6位含秒)/fixedRate 从上次开始算(慢任务堆积)/fixedDelay 从上次结束算(不堆积更安全);底层默认单线程慢任务互相阻塞(最大坑),解决用多线程 TaskScheduler 或 @Async;分布式多实例重复执行需分布式锁/XXL-Job」。