ElIauk

Spring Boot 定时任务与线程池

定时任务看起来很简单 加一个 @EnableScheduling,在方法上写 @Scheduled(cron = "0 0 3 * * ?"),定时任务就跑起来了。语法简单,导致很多人直接把它当调度中心用:日报、清理、对账、同步,十几个任务全塞在一个应用里。 真正上线后问题才开始:凌晨的报表任务跑了

评论
Spring Boot 定时任务与线程池

定时任务看起来很简单

加一个 @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. 长事务和长任务混在一起

在定时任务里开一个大事务,跑几十分钟不提交,会长期占用连接和锁。定时任务更适合分批处理,每批单独提交,失败后可以从断点继续。

排查步骤

任务"没按时执行"时,按这个顺序排查:

  1. 看日志里有没有任务开始和结束的记录,线程名是什么;
  2. 用 CronExpression.parse(expr).next(ZonedDateTime.now()) 打印下一次执行时间,确认表达式符合预期;
  3. jstack 看调度线程是不是卡在某个方法上,比如远程调用、数据库锁;
  4. 确认应用是不是多实例,任务是不是被别的节点执行了;
  5. 检查任务所在类有没有被 @Profile、@ConditionalOnProperty 排除。

多数问题不是调度器坏了,而是任务本身阻塞、时区错了,或者多实例重复执行。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者