定时任务看起来很简单
加一个 @EnableScheduling,在方法上写 @Scheduled(cron = "0 0 3 * * ?"),定时任务就跑起来了。语法简单,导致很多人直接把它当调度中心用:日报、清理、对账、同步,十几个任务全塞在一个应用里。
真正上线后问题才开始:凌晨的报表任务跑了四十分钟,后面每五分钟一次的对账任务一直没触发;本地测试时任务准点执行,放到容器里却差了八个小时;应用部署了两台机器,同一条数据被处理了两遍。这些问题都和"调度器怎么配、任务跑在哪个线程、集群里谁来执行"有关。
三种调度方式
@Scheduled 支持三种时间配置:
| 方式 | 含义 | 计时起点 | 适用场景 |
|---|---|---|---|
fixedRate |
固定频率执行 | 上一次开始时间 | 间隔稳定的轮询 |
fixedDelay |
固定延迟执行 | 上一次结束时间 | 任务耗时不确定,避免重叠 |
cron |
按日历表达式执行 | 日历时间 | 每天固定时刻执行 |
fixedRate 在任务耗时超过间隔时不会并发执行,而是顺延,实际频率会低于设定值。需要"必须错开、不能堆叠"的场景,用 fixedDelay 更稳。
cron 表达式在 Spring 里是 6 位:秒、分、时、日、月、周。和 Quartz 的 7 位(最后一位是年)不一样,从 Quartz 迁过来的人经常在这里踩坑。
默认只有一个线程
Spring 默认的调度线程池大小是 1。所有 @Scheduled 方法共用这一个线程,一个任务阻塞,其他任务全部排队。日志里看到线程名是 scheduling-1,基本就是这个原因。
最省事的调整方式是通过配置:
spring:
task:
scheduling:
pool:
size: 5
thread-name-prefix: sched-
需要更细的控制(比如异常处理、停机等待)时,自己声明一个 ThreadPoolTaskScheduler:
@Slf4j
@Configuration
public class ScheduleConfig {
@Bean
public ThreadPoolTaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(5);
scheduler.setThreadNamePrefix("sched-");
scheduler.setWaitForTasksToCompleteOnShutdown(true);
scheduler.setAwaitTerminationSeconds(30);
scheduler.setErrorHandler(e -> log.error("定时任务执行异常", e));
return scheduler;
}
}
Bean 名称必须是 taskScheduler,Spring Boot 的自动配置才会让位。名字写成 myScheduler,默认调度器仍然生效,你会以为配置没起作用。
任务方法本身建议这样写:
@Scheduled(cron = "0 0 3 * * ?", zone = "Asia/Shanghai")
public void syncOrder() {
log.info("开始同步订单");
try {
orderSyncService.sync();
} catch (Exception e) {
log.error("同步订单失败", e);
}
}
方法必须无参。返回值没有意义,通常写成 void。异常要自己兜住或者交给 ErrorHandler,否则错误只留在日志里,没人发现任务已经连续失败一周。
常见坑
1. 单线程导致任务互相阻塞
最典型的现象是"某个任务偶尔不执行"。打开日志看线程名,如果全是 scheduling-1,说明任务在排队。把池大小调到任务数量以上,同时给每个任务加上耗时日志,才能看出谁在拖后腿。
2. cron 写成 7 位
Spring 不接受 Quartz 的年份位,启动时会直接抛 IllegalArgumentException。从其他框架复制表达式时,先确认位数。
3. 时区不一致
容器默认时区常常是 UTC,0 0 3 * * ? 在 UTC 下执行,对应北京时间上午 11 点。显式指定 zone = "Asia/Shanghai",或者在容器里设置 TZ=Asia/Shanghai,不要依赖默认值。
4. 集群里重复执行
@Scheduled 没有分布式协调能力,应用部署几台,任务就执行几遍。简单做法是用配置开关只让一台机器跑;更稳妥的是用 Redis 分布式锁或者 ShedLock 这类组件,确保同一时刻只有一个节点在执行。
5. 任务执行时间超过间隔
fixedRate 下,任务不会并发,但会一直延后,看起来像"任务堆积"。如果业务上允许重叠,必须换成线程池手动提交;如果不允许,就缩短单次任务耗时或者调大间隔。
6. 停机时任务被中断
默认情况下应用关闭不会等待任务执行完,正在写库的任务可能执行到一半。设置 waitForTasksToCompleteOnShutdown 和 awaitTerminationSeconds,并确保任务本身是幂等的。
7. 长事务和长任务混在一起
在定时任务里开一个大事务,跑几十分钟不提交,会长期占用连接和锁。定时任务更适合分批处理,每批单独提交,失败后可以从断点继续。
排查步骤
任务"没按时执行"时,按这个顺序排查:
- 看日志里有没有任务开始和结束的记录,线程名是什么;
- 用
CronExpression.parse(expr).next(ZonedDateTime.now())打印下一次执行时间,确认表达式符合预期; jstack看调度线程是不是卡在某个方法上,比如远程调用、数据库锁;- 确认应用是不是多实例,任务是不是被别的节点执行了;
- 检查任务所在类有没有被
@Profile、@ConditionalOnProperty排除。
多数问题不是调度器坏了,而是任务本身阻塞、时区错了,或者多实例重复执行。
评论