原创

Spring Boot 定时任务详解:@Scheduled 用法、线程池配置与生产实践

什么是 Spring Boot 定时任务

Spring Boot 定时任务,本质上是 Spring 调度能力在应用层的封装使用。你只需要开启调度功能,再通过 @Scheduled 声明触发规则,Spring 就会按固定频率、固定间隔或 Cron 表达式定时执行目标方法。@EnableScheduling 用于启用调度,@Scheduled 用于定义具体任务规则。对于周期任务,fixedRatefixedDelaycron 三者通常三选一;一次性任务则可以只指定 initialDelay。(Home)

很多人把它理解成“Spring 自带一个定时器”,这个说法不算错,但不够准确。更准确地说,Spring 提供的是一套调度抽象,底层通过 TaskScheduler 等组件执行任务,而不是单纯靠注解“魔法生效”。Spring Framework 官方文档明确将任务执行与任务调度拆成了 TaskExecutorTaskScheduler 两套抽象。(Home)

为什么项目里经常使用它

在业务系统里,定时任务通常用于以下场景:

  • 定时同步第三方数据
  • 每天生成报表
  • 定时清理日志、缓存、临时文件
  • 定时检查订单超时、任务超时
  • 周期性刷新配置、预热数据
  • 延迟执行某些补偿逻辑

这类场景有一个共同点:不依赖用户实时触发,而是由系统在指定时间主动执行。如果任务规则简单、调度逻辑固定,Spring Boot 自带的定时任务已经足够用;如果涉及分片、持久化、失败恢复、集群协调,通常就要考虑 Quartz、XXL-JOB 之类的方案。

最小可用示例

1. 开启调度功能

package com.example.demo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableScheduling;

@Configuration
@EnableScheduling
public class SchedulingConfig {
}

2. 编写定时任务

package com.example.demo.task;

import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class OrderTask {

    @Scheduled(fixedRate = 5000)
    public void syncOrderStatus() {
        log.info("开始同步订单状态");
    }
}

启动应用后,这个任务会每 5 秒触发一次。

@Scheduled 的几种常见写法

1. fixedRate

@Scheduled(fixedRate = 5000)
public void task() {
    System.out.println("fixedRate task");
}

含义:以上一次任务开始时间为基准,每隔 5 秒触发一次。

如果任务执行耗时超过 5 秒,下一次调度是否并发执行,取决于调度线程池配置。默认单线程场景下,不会真正并发,只会排队等待。

2. fixedDelay

@Scheduled(fixedDelay = 5000)
public void task() {
    System.out.println("fixedDelay task");
}

含义:以上一次任务结束时间为基准,等待 5 秒后再执行下一次。

这类写法适合“上一轮做完,下一轮再开始”的场景,例如轮询拉取、批处理扫描。

3. initialDelay

@Scheduled(initialDelay = 10000, fixedDelay = 5000)
public void task() {
    System.out.println("应用启动 10 秒后开始执行");
}

含义:应用启动后先等待 10 秒,再按 fixedDelay 规则执行。

如果只写 initialDelay,Spring 也支持一次性任务。这个点在当前 @Scheduled 官方文档中有明确说明。(Home)

4. cron

@Scheduled(cron = "0 0 2 * * ?")
public void task() {
    System.out.println("每天凌晨 2 点执行");
}

这是企业项目里最常见的方式。它适合明确的日历时间规则,例如:

  • 每天 2 点执行
  • 每周一 8 点执行
  • 每月 1 号 0 点执行

Cron 表达式怎么理解

Spring 定时任务常见的 Cron 表达式由 6 位组成:

秒 分 时 日 月 周

例如:

0 */10 * * * ?

表示:每 10 分钟执行一次。

几个常见示例如下:

表达式 含义
0 0/5 * * * ? 每 5 分钟执行一次
0 0 0 * * ? 每天 0 点执行
0 0 8 ? * MON 每周一早上 8 点执行
0 0 2 1 * ? 每月 1 号凌晨 2 点执行

指定时区

@Scheduled(cron = "0 0 9 * * ?", zone = "Asia/Shanghai")
public void task() {
    System.out.println("按上海时区每天 9 点执行");
}

如果你的服务部署在海外服务器,或者容器时区与业务时区不一致,zone 很重要。否则“每天 9 点执行”很可能不是你以为的 9 点。

默认行为:为什么很多定时任务会“串行执行”

这是 Spring Boot 定时任务里最容易被忽略的问题。

如果你只是写了 @EnableScheduling@Scheduled,没有自定义调度线程池,那么默认往往是单线程调度。Spring Boot 的任务调度配置文档明确说明,调度线程池默认只有一个线程,可通过 spring.task.scheduling.* 配置调整。(Home)

这意味着:

  • 多个定时任务之间可能互相阻塞
  • 一个任务执行太久,会影响其他任务按时触发
  • 看起来像“定时不准”,其实是线程不够

例如下面两个任务:

@Scheduled(fixedRate = 5000)
public void taskA() throws InterruptedException {
    Thread.sleep(10000);
    System.out.println("taskA");
}

@Scheduled(fixedRate = 5000)
public void taskB() {
    System.out.println("taskB");
}

在默认单线程下,taskB 不会真正每 5 秒独立执行,它会被 taskA 拖住。

正确做法:给定时任务配置线程池

方式一:使用配置文件

spring:
  task:
    scheduling:
      pool:
        size: 5
      thread-name-prefix: schedule-

这是一种最直接的方式,适合大部分简单项目。Spring Boot 官方文档支持通过 spring.task.scheduling 命名空间配置调度线程池。(Home)

方式二:手动声明 TaskScheduler

package com.example.demo.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;

@Configuration
public class SchedulerPoolConfig {

    @Bean
    public ThreadPoolTaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
        scheduler.setPoolSize(5);
        scheduler.setThreadNamePrefix("custom-schedule-");
        scheduler.setWaitForTasksToCompleteOnShutdown(true);
        scheduler.setAwaitTerminationSeconds(30);
        return scheduler;
    }
}

ThreadPoolTaskScheduler 是 Spring 官方提供的标准实现,底层封装的是 ScheduledThreadPoolExecutor。(Home)

这类写法适合你需要更细粒度控制时使用,比如:

  • 线程数
  • 关闭应用时是否等待任务执行完成
  • 线程名前缀
  • 拒绝策略、异常处理扩展

任务方法有哪些限制

1. 方法不能带参数

@Scheduled(fixedRate = 5000)
public void task(String name) {
}

这种写法不行。

@Scheduled 标注的方法应当是无参方法。官方文档对这一点有明确约束。(Home)

2. 返回值基本无意义

@Scheduled(fixedRate = 5000)
public String task() {
    return "ok";
}

即便能写,返回值也不会被调度器使用。对定时任务来说,重点是副作用执行,不是返回结果。

3. 不要在任务里吞异常

@Scheduled(fixedDelay = 5000)
public void task() {
    try {
        doBusiness();
    } catch (Exception e) {
        // 空处理
    }
}

这会让任务表面“正常运行”,但业务早就失败了,排查非常困难。

更合理的写法是:

@Scheduled(fixedDelay = 5000)
public void task() {
    try {
        doBusiness();
    } catch (Exception e) {
        log.error("定时任务执行失败", e);
    }
}

如果失败需要告警、重试或补偿,也应该在这里明确实现,而不是简单打印一句日志就结束。

fixedRatefixedDelay 到底怎么选

可以直接按下面的原则判断:

适合 fixedRate 的场景

要求“尽量按固定节奏触发”,比如:

  • 每 5 秒采集一次指标
  • 每 1 分钟拉取一次状态
  • 周期性心跳上报

核心关注点是频率稳定

适合 fixedDelay 的场景

要求“上一轮完成后再开始下一轮”,比如:

  • 分批扫描待处理数据
  • 定时跑批
  • 消费补偿任务
  • 文件清理任务

核心关注点是避免重叠执行

一个简单判断标准

如果你更关心“多久触发一次”,优先考虑 fixedRate。 如果你更关心“本轮结束后再做下一轮”,优先考虑 fixedDelay

动态定时任务:为什么 @Scheduled 不够用

@Scheduled 的触发规则通常是静态的,写在代码里或配置里。它适合规则固定的任务,但不适合下面这些需求:

  • 用户在后台修改 Cron 表达式后立即生效
  • 不同租户有不同调度规则
  • 任务启停需要动态控制
  • 任务列表需要运行时注册

这时就要使用编程式调度,典型做法是实现 SchedulingConfigurer。官方文档也明确说明,它常用于指定调度器或以编程方式注册任务,尤其适合 Trigger 类任务。(Home)

示例:动态注册任务

package com.example.demo.config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.Trigger;
import org.springframework.scheduling.TriggerContext;
import org.springframework.scheduling.annotation.EnableScheduling;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;

import java.time.Instant;
import java.util.Date;
import java.util.concurrent.Executors;

@Configuration
@EnableScheduling
public class DynamicScheduleConfig implements SchedulingConfigurer {

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        taskRegistrar.setScheduler(Executors.newScheduledThreadPool(3));

        taskRegistrar.addTriggerTask(
            () -> System.out.println("执行动态任务: " + Instant.now()),
            new Trigger() {
                @Override
                public Date nextExecutionTime(TriggerContext triggerContext) {
                    // 示例:每 10 秒执行一次
                    Instant last = triggerContext.lastCompletionTime() == null
                            ? Instant.now()
                            : triggerContext.lastCompletionTime().toInstant();
                    return Date.from(last.plusSeconds(10));
                }
            }
        );
    }
}

这种方式比 @Scheduled 灵活得多,但代码复杂度也更高。实际项目里,如果动态调度需求很多,通常会进一步引入专门的任务调度平台。

集群部署下的核心问题:任务会重复执行

这是 Spring Boot 定时任务在生产环境里最关键的问题之一。

如果你的服务部署了 3 个实例,而每个实例里都启用了相同的 @Scheduled 任务,那么这 3 个实例都会执行同一份逻辑。Spring 自带定时任务不负责集群唯一执行,它只负责“当前 JVM 内部按规则调度”。

这会导致:

  • 重复发券
  • 重复生成报表
  • 重复推送消息
  • 重复执行补偿逻辑
  • 数据状态被并发修改

常见解决方案

1. 数据库分布式锁

例如基于 MySQL 的唯一记录加锁,抢到锁的实例才执行。

建表 SQL 如下:

CREATE TABLE schedule_lock (
    lock_name VARCHAR(100) NOT NULL COMMENT '锁名称',
    lock_until DATETIME NOT NULL COMMENT '锁失效时间',
    locked_at DATETIME NOT NULL COMMENT '加锁时间',
    locked_by VARCHAR(100) NOT NULL COMMENT '持有锁的实例标识',
    PRIMARY KEY (lock_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='定时任务分布式锁表';

示意逻辑:

  • 执行前先抢锁
  • 抢锁成功才继续
  • 执行完成后释放锁或续期
  • 通过过期时间避免死锁

2. Redis 分布式锁

适合已经大量使用 Redis 的系统,但要注意:

  • 锁过期时间
  • 续锁机制
  • 误删别人的锁
  • 主从切换一致性问题

3. 使用现成方案

例如 ShedLock、Quartz 集群模式、XXL-JOB、ElasticJob 等。

如果只是“Spring Boot + 多实例 + 防重复执行”,ShedLock 通常是成本较低的方案;如果是复杂调度平台需求,往往直接用专业调度框架更合适。

事务与定时任务:一个很容易踩坑的点

很多人会把数据库事务逻辑直接写进 @Scheduled 方法里,这本身没有问题,但要注意两件事:

1. 不要误以为“定时任务天然具备事务”

@Scheduled 只负责触发,不负责事务。事务是否生效,仍然取决于 Spring AOP 代理是否真正介入。

2. 同类方法内调用可能导致事务失效

例如:

@Component
public class OrderTask {

    @Scheduled(cron = "0 */5 * * * ?")
    public void run() {
        saveData();
    }

    @Transactional
    public void saveData() {
        // 数据库操作
    }
}

这里 run() 直接调用本类的 saveData(),属于同类内部调用,事务代理可能不会生效。这不是定时任务独有的问题,而是 Spring AOP 的通用限制。

更稳妥的做法是把事务方法拆到独立的 Service 中:

@Component
public class OrderTask {

    private final OrderService orderService;

    public OrderTask(OrderService orderService) {
        this.orderService = orderService;
    }

    @Scheduled(cron = "0 */5 * * * ?")
    public void run() {
        orderService.saveData();
    }
}
@Service
public class OrderService {

    @Transactional
    public void saveData() {
        // 数据库操作
    }
}

异常、幂等、超时,是生产环境必须考虑的三件事

异常处理

定时任务失败不能只靠控制台日志,至少要做到:

  • 错误日志可检索
  • 关键任务有告警
  • 可区分临时失败和永久失败

幂等设计

定时任务经常会重试、补跑、重复触发,所以核心业务逻辑要具备幂等性。例如:

  • 根据业务唯一键防重
  • 更新时判断状态流转是否合法
  • 插入时建立唯一索引

例如订单补偿表:

CREATE TABLE order_compensation (
    id BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键',
    biz_no VARCHAR(64) NOT NULL COMMENT '业务单号',
    task_type VARCHAR(32) NOT NULL COMMENT '任务类型',
    status TINYINT NOT NULL COMMENT '处理状态 0待处理 1成功 2失败',
    retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数',
    last_retry_time DATETIME DEFAULT NULL COMMENT '最后重试时间',
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
    PRIMARY KEY (id),
    UNIQUE KEY uk_biz_task (biz_no, task_type)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单补偿任务表';

这个唯一索引就是一种典型的幂等保障。

超时控制

定时任务里如果包含远程调用,必须考虑超时。否则一个慢接口就可能把整个调度线程池拖死,最终导致大量任务堆积。

常见误区

误区一:加了 @Scheduled 就一定会执行

不一定。常见原因有:

  • 没有加 @EnableScheduling
  • 任务类没有被 Spring 管理
  • 方法不是 public 虽然有时可运行,但不建议这样写
  • 启动类扫描不到目标 Bean
  • Cron 表达式写错
  • 调度线程被长期阻塞

误区二:本地执行正常,生产就一定正常

不成立。生产环境通常比本地多出这些变量:

  • 多实例部署
  • 容器时区差异
  • 数据量更大
  • 远程依赖更慢
  • 线程池资源更紧张

本地跑通只说明“能执行”,不说明“在生产能稳定执行”。

误区三:定时任务适合所有后台任务

不适合。

如果任务具有以下特征,单纯使用 @Scheduled 就可能不够:

  • 任务很多,规则复杂
  • 需要手工暂停/恢复
  • 需要执行历史、失败重试、运行日志
  • 需要集群协调
  • 需要任务分片
  • 需要可视化运维

这时候应考虑调度平台,而不是继续堆业务代码。

推荐的工程实践

1. 任务与业务逻辑分层

不要把所有代码都写在 @Scheduled 方法里。

更合理的结构是:

  • Task:只负责触发
  • Service:负责业务
  • Repository / Mapper:负责数据访问

2. 为任务起清晰名字

例如线程名前缀、日志标识、任务类名,都应表达业务含义,便于排查。

3. 打印关键日志

至少包含:

  • 任务开始时间
  • 任务结束时间
  • 本次处理数据量
  • 是否成功
  • 异常堆栈
  • 任务耗时

4. 为关键任务做监控

包括:

  • 最近一次执行时间
  • 最近一次成功时间
  • 连续失败次数
  • 平均耗时
  • 超时次数

5. 控制单次任务处理量

例如一次最多处理 500 条,而不是把整张表全扫完。这样更容易控制执行时间,也更利于失败恢复。

Spring Boot 2.x 与 3.x / 当前 Spring 文档中的差异说明

共同点

@EnableScheduling@ScheduledfixedRatefixedDelaycron 这些核心概念在 Spring Boot 2.x 和 3.x 中并没有本质变化,日常使用方式基本一致。Spring 官方当前文档仍然沿用这套调度模型。(Home)

配置层面的共识

Spring Boot 官方文档仍保留通过 spring.task.scheduling.* 配置调度线程池的能力,默认线程池规模仍需要特别关注,不能想当然认为它会自动并发扩展。(Home)

当前文档中的补充点

当前 Spring Framework 文档对 @Scheduled 的说明比很多旧文章更完整,明确提到:

  • 周期任务通常使用 cronfixedDelayfixedRate
  • 一次性任务可以只设置 initialDelay
  • @Scheduled 可重复声明在同一方法上
  • 编程式注册任务可通过 SchedulingConfigurer 完成 (Home)

如果你平时参考的是较早期博客,建议按当前官方文档的描述理解,而不是照搬旧文章里的经验结论。

什么时候该继续用 Spring Boot 定时任务,什么时候该升级方案

继续用 @Scheduled 就够了

适合:

  • 单体应用或单实例服务
  • 规则固定
  • 任务数量不多
  • 对可视化运维要求不高
  • 失败后人工介入即可

该考虑 Quartz / XXL-JOB / 其他调度平台了

适合:

  • 集群环境
  • 任务多且复杂
  • 需要动态配置
  • 需要分片执行
  • 需要失败重试与执行记录
  • 需要统一运维平台

判断标准很简单: 如果你的问题已经不是“怎么触发”,而是“怎么可靠地管理大量任务”,那就不应该只靠 @Scheduled 了。

总结

Spring Boot 定时任务不复杂,真正复杂的是生产环境里的执行语义。

写一个能跑的定时任务很简单:

  • 开启 @EnableScheduling
  • 在方法上加 @Scheduled

但写一个能长期稳定运行的定时任务,还要额外解决这些问题:

  • 线程池是否合理
  • 任务是否会互相阻塞
  • 异常是否可观测
  • 逻辑是否幂等
  • 集群下是否重复执行
  • 事务是否真的生效
  • 是否需要动态调度和统一运维

把这些问题想清楚,Spring Boot 定时任务才能真正进入生产可用状态;否则它就只是一个“看起来能跑”的注解功能。

正文到此结束
评论插件初始化中...
Loading...