Skip to content

Spring 定时任务

Spring 的 @Scheduled 是 Java 项目里最容易上手的定时任务方案。它适合单体应用、内部小任务、低频补偿任务和学习定时任务基础。

但要先说结论:

@Scheduled 适合简单单机任务;如果服务多实例部署、任务需要控制台、日志、重试、告警、分片,应该考虑 XXL-JOB 这类分布式调度框架。

快速入门

启用定时任务:

java
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableScheduling
@SpringBootApplication
public class OrderApplication {
    public static void main(String[] args) {
        SpringApplication.run(OrderApplication.class, args);
    }
}

编写任务:

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

@Slf4j
@Component
public class OrderTimeoutTask {

    @Scheduled(cron = "0 */5 * * * ?")
    public void closeTimeoutOrders() {
        log.info("start close timeout orders");
        // 查询超时未支付订单并关闭
        log.info("finish close timeout orders");
    }
}

这表示每 5 分钟执行一次。

三种常见触发方式

写法含义注意
cron按 Cron 表达式执行最常用,适合固定时间
fixedRate按固定频率执行从上一次开始时间计算
fixedDelay按固定延迟执行从上一次结束时间计算

示例:

java
@Scheduled(fixedRate = 5000)
public void fixedRateTask() {
    // 每隔 5 秒触发一次,不关心上次是否刚结束
}

@Scheduled(fixedDelay = 5000)
public void fixedDelayTask() {
    // 上次执行完成后,再等 5 秒执行下一次
}

如果任务可能执行很久,优先考虑 fixedDelay 或者使用分布式调度框架控制阻塞策略。

执行原理

mermaid
flowchart TD
    A["Spring 容器启动"] --> B["扫描 @Scheduled 方法"]
    B --> C["注册任务元数据"]
    C --> D["调度线程计算下次触发时间"]
    D --> E["到点提交任务"]
    E --> F["线程池执行方法"]
    F --> G["执行完成后等待下一次触发"]

@Scheduled 的本质是 Spring 在应用启动时扫描带注解的方法,把它注册到本地调度器里,到时间后由应用进程内的线程执行。

这意味着:

  1. 应用停了,任务就不会执行。
  2. 应用部署几个实例,任务就可能执行几次。
  3. 默认没有完整控制台、失败重试、分片和告警。

启动扫描全过程

@Scheduled 能生效,前提是你加了 @EnableScheduling。它不是一个装饰性注解,而是开启 Spring 定时任务基础设施的入口。

mermaid
flowchart TD
    A["应用启动"] --> B["读取 @EnableScheduling"]
    B --> C["导入定时任务配置"]
    C --> D["创建 ScheduledAnnotationBeanPostProcessor"]
    D --> E["Spring 创建业务 Bean"]
    E --> F["扫描 Bean 方法上的 @Scheduled"]
    F --> G["解析 cron、fixedRate、fixedDelay"]
    G --> H["注册到 ScheduledTaskRegistrar"]
    H --> I["应用启动完成后开始调度"]

@EnableScheduling 做了什么

可以把它理解成一句话:

告诉 Spring:容器里如果发现 @Scheduled 方法,就把它们收集起来,交给本地调度器按时间执行。

如果忘了加 @EnableScheduling,方法上的 @Scheduled 不会自动执行。这个问题很常见,因为代码编译不会报错,启动也可能不报错,只是任务一直不跑。

谁负责扫描 @Scheduled

Spring 内部负责扫描的是 ScheduledAnnotationBeanPostProcessor。它会在 Bean 创建完成后检查方法上有没有 @Scheduled

为什么是 Bean 创建完成后扫描?因为只有对象成为 Spring Bean,Spring 才能管理它、找到它的方法、在到点时调用它。

这也解释了一个常见坑:

java
public class BadTask {
    @Scheduled(cron = "0 */5 * * * ?")
    public void run() {
    }
}

如果这个类没有 @Component,也没有通过 @Bean 注册到 Spring 容器,Spring 根本不会扫描到它。

正确写法:

java
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class GoodTask {
    @Scheduled(cron = "0 */5 * * * ?")
    public void run() {
    }
}

为什么 @Scheduled 方法不能随便写参数

@Scheduled 方法通常应该是无参方法:

java
@Scheduled(cron = "0 */5 * * * ?")
public void closeTimeoutOrders() {
}

因为它不是被 HTTP 请求调用,也不是被消息队列传参调用,而是由 Spring 调度器在固定时间反射调用。调度器只知道“到点调用这个方法”,并不知道该给方法传什么业务参数。

如果需要参数,应该放在配置、数据库任务表或方法内部读取:

java
@Scheduled(cron = "${task.order-close.cron}")
public void closeTimeoutOrders() {
    int batchSize = orderTaskProperties.getBatchSize();
}

任务注册全过程

Spring 扫描到 @Scheduled 后,会把它封装成一个可调度任务。

mermaid
flowchart TD
    A["发现 @Scheduled 方法"] --> B["读取注解属性"]
    B --> C{"触发类型"}
    C -- "cron" --> D["创建 CronTrigger"]
    C -- "fixedRate" --> E["创建固定频率任务"]
    C -- "fixedDelay" --> F["创建固定延迟任务"]
    D --> G["封装 Runnable"]
    E --> G
    F --> G
    G --> H["注册到任务调度器"]

Runnable 里包的是什么

可以把 @Scheduled 方法想象成被包装成下面这种东西:

java
Runnable task = () -> {
    orderTimeoutTask.closeTimeoutOrders();
};

真实实现会处理反射调用、错误处理和调度上下文,但核心就是“到点调用目标 Bean 的目标方法”。

为什么同一个应用里多个任务会互相影响

所有 @Scheduled 任务最终都要进入本地调度器线程池。如果线程池只有 1 个线程,一个慢任务就会挡住后面的任务。

mermaid
flowchart TD
    A["任务 A 执行 10 分钟"] --> B["占住唯一调度线程"]
    C["任务 B 到点"] --> D["只能等待"]
    E["任务 C 到点"] --> D
    D --> F["任务延迟越来越明显"]

所以生产环境不要依赖默认线程资源,至少要显式配置线程池,并且监控任务耗时。

触发时间计算过程

cronfixedRatefixedDelay 最大的区别,不在写法,而在“下一次时间从哪里开始算”。

mermaid
flowchart TD
    A["一次任务触发"] --> B{"触发方式"}
    B -- "fixedRate" --> C["按上次开始时间 + 间隔"]
    B -- "fixedDelay" --> D["按上次结束时间 + 延迟"]
    B -- "cron" --> E["按 Cron 表达式计算下一次时间"]
    C --> F["可能追赶频率"]
    D --> G["不会和上次重叠"]
    E --> H["适合固定时间点"]

fixedRate 为什么容易出问题

假设:

java
@Scheduled(fixedRate = 5000)
public void task() {
    // 执行 8 秒
}

理论触发节奏是:

时间点事件
00:00第 1 次开始
00:05第 2 次应该开始
00:08第 1 次结束
00:10第 3 次应该开始

如果线程池允许并发,可能出现同一任务重叠执行;如果线程池不允许并发,就会出现延迟排队。无论哪种,都要确认业务是否能接受。

适合 fixedRate 的任务:

  1. 采集本机指标。
  2. 刷新无状态缓存。
  3. 任务耗时稳定且明显小于间隔。

不适合:

  1. 扫描大表。
  2. 调用慢接口。
  3. 订单、结算、发券等不能重叠的任务。

fixedDelay 为什么更稳

fixedDelay 从上一次结束后开始计算。

java
@Scheduled(fixedDelay = 5000)
public void task() {
    // 执行结束后再等 5 秒
}

如果任务执行 8 秒,下一次会在结束后再等 5 秒,也就是至少 13 秒后才再次开始。它牺牲了固定频率,换来不追赶、不重叠,更适合补偿类任务。

Cron 为什么适合固定业务时间

Cron 适合表达“每天凌晨 2 点”“每 5 分钟”“每周一 1 点”这类业务时间。

java
@Scheduled(cron = "0 0 2 * * ?")
public void dailySettle() {
    // 每天凌晨 2 点对账
}

Cron 的风险:

风险说明
字段数量不同Linux crontab 通常 5 位,Spring 常见 6 位
时区问题多地域部署时要确认时区
整点洪峰很多任务都配 0 点、整点,会同时打数据库
任务错过应用停机期间不会自动补偿所有错过触发

线程执行全过程

当触发时间到了,Spring 会把任务交给线程执行。

mermaid
flowchart TD
    A["触发时间到达"] --> B["调度器取出 Runnable"]
    B --> C["提交到调度线程池"]
    C --> D["线程调用目标方法"]
    D --> E{"是否抛异常"}
    E -- "否" --> F["记录本次完成"]
    E -- "是" --> G["错误处理器记录异常"]
    F --> H["等待下一次触发"]
    G --> H

这里有两个关键认知:

  1. @Scheduled 不会自动开启事务,事务要靠你在业务方法或 Service 上写 @Transactional
  2. @Scheduled 不会自动重试,方法失败了通常只是记录异常,下一次到点再执行。

异常为什么不能只靠日志

错误示例:

java
@Scheduled(cron = "0 */5 * * * ?")
public void syncProductToEs() {
    productSyncService.syncFailedRecords();
}

如果这里抛异常但没有告警,你可能第二天才发现 ES 已经一天没同步。

更稳的写法:

java
@Scheduled(cron = "0 */5 * * * ?")
public void syncProductToEs() {
    long start = System.currentTimeMillis();
    try {
        int count = productSyncService.syncFailedRecords();
        log.info("sync product es success, count={}, cost={}ms",
                count, System.currentTimeMillis() - start);
    } catch (Exception ex) {
        log.error("sync product es failed", ex);
        alarmService.send("商品 ES 补偿任务失败:" + ex.getMessage());
    }
}

对于订单、支付、库存这种核心任务,还应该把失败批次写入数据库,而不是只发告警。

线程池配置原理

默认情况下,@Scheduled 任务的线程资源有限。生产环境要显式配置线程池,并给线程命名,方便排查。

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

@Configuration
public class ScheduleThreadPoolConfig {

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

线程数怎么估算

不要机械地写 50 或 100。先看任务类型:

任务类型线程数思路原因
CPU 计算接近 CPU 核心数线程太多只会争抢 CPU
扫数据库小线程 + 小批次保护数据库
调第三方接口看接口限流和超时防止把对方打爆
缓存刷新少量线程即可多数任务很短

如果 5 个任务都可能跑 10 分钟,线程池只有 2 个线程,后面的任务就会明显延迟。此时不应该只加线程,还要拆任务、缩小批次、优化 SQL 或迁移到 XXL-JOB 分片处理。

多实例重复执行的完整过程

多实例重复不是 bug,而是 @Scheduled 的天然行为。因为每个 JVM 都有自己的 Spring 容器和本地调度器。

mermaid
flowchart TD
    A["订单服务实例 A 启动"] --> D["注册 closeTimeoutOrders"]
    B["订单服务实例 B 启动"] --> E["注册 closeTimeoutOrders"]
    C["订单服务实例 C 启动"] --> F["注册 closeTimeoutOrders"]
    D --> G["10:00 到点执行"]
    E --> H["10:00 到点执行"]
    F --> I["10:00 到点执行"]
    G --> J["同一批订单被多个实例扫描"]
    H --> J
    I --> J

所以多实例下要么接受重复,要么加协调机制。

哪些任务可以多实例都跑

任务是否可以原因
本地缓存刷新可以每台机器刷新自己的缓存
本机指标采集可以每台机器采自己的指标
清理本机临时文件可以每台机器清自己的文件
订单关单不建议会重复扫描和竞争更新
发券任务不可以可能重复发放
对账结算不可以可能重复生成结果

用锁后为什么仍然要幂等

分布式锁只能降低重复执行概率,不能把业务正确性全部托付给锁。

mermaid
flowchart TD
    A["实例 A 拿到锁"] --> B["开始处理订单"]
    B --> C["JVM 长时间 GC 或任务执行超时"]
    C --> D["锁过期"]
    D --> E["实例 B 拿到锁"]
    E --> F["实例 B 处理同一批订单"]
    B --> G["实例 A 恢复继续处理"]
    F --> H["如果业务不幂等就重复处理"]
    G --> H

正确做法是:锁负责减少并发,业务状态条件负责最终正确。

例如关单必须这样写:

sql
update t_order
set status = 'CLOSED'
where id = ?
  and status = 'WAIT_PAY';

而不是:

sql
update t_order
set status = 'CLOSED'
where id = ?;

后者可能把已经支付成功的订单关闭。

配置线程池

默认情况下,任务线程资源有限。多个任务互相阻塞时,可能导致后面的任务迟迟不执行。

推荐显式配置线程池:

java
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;

import java.util.concurrent.Executors;

@Configuration
public class ScheduleConfig implements SchedulingConfigurer {

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

线程数不是越大越好,要看任务数量、任务耗时、数据库承载能力和下游接口限流。

多实例重复执行问题

假设订单服务部署 3 个实例,每个实例都有同一个 @Scheduled

mermaid
flowchart TD
    A["订单服务实例 A"] --> D["扫描超时订单"]
    B["订单服务实例 B"] --> D
    C["订单服务实例 C"] --> D
    D --> E["同一批订单可能被重复处理"]

解决思路:

  1. 任务逻辑本身保证幂等。
  2. 使用 Redis、数据库或 Zookeeper 做分布式锁。
  3. 改用 XXL-JOB,由调度中心控制执行器。

Redis 锁示例

下面是学习版示例,真实项目建议使用 Redisson 或成熟封装。

java
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

import java.time.Duration;
import java.util.UUID;

@Component
public class EsSyncCompensateTask {

    private final StringRedisTemplate redisTemplate;

    public EsSyncCompensateTask(StringRedisTemplate redisTemplate) {
        this.redisTemplate = redisTemplate;
    }

    @Scheduled(cron = "0 */10 * * * ?")
    public void compensateProductSearchIndex() {
        String lockKey = "lock:task:product-es-compensate";
        String lockValue = UUID.randomUUID().toString();

        Boolean locked = redisTemplate.opsForValue()
                .setIfAbsent(lockKey, lockValue, Duration.ofMinutes(9));
        if (!Boolean.TRUE.equals(locked)) {
            return;
        }

        try {
            // 扫描最近变更但 ES 未同步成功的商品,重新写入 ES
        } finally {
            String value = redisTemplate.opsForValue().get(lockKey);
            if (lockValue.equals(value)) {
                redisTemplate.delete(lockKey);
            }
        }
    }
}

这个锁只能解决“尽量只有一个实例执行”。任务内部仍然要幂等,因为锁过期、网络抖动、进程停顿都可能导致重复执行。

订单关单 Demo

分页扫描超时订单:

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

import java.util.List;

@Slf4j
@Component
@RequiredArgsConstructor
public class CloseTimeoutOrderTask {

    private final OrderRepository orderRepository;

    @Scheduled(cron = "0 */5 * * * ?")
    public void closeTimeoutOrders() {
        long lastId = 0L;
        int pageSize = 500;

        while (true) {
            List<Long> orderIds = orderRepository.findTimeoutWaitPayOrders(lastId, pageSize);
            if (orderIds.isEmpty()) {
                break;
            }

            for (Long orderId : orderIds) {
                boolean closed = orderRepository.closeIfWaitPay(orderId);
                if (closed) {
                    log.info("close timeout order success, orderId={}", orderId);
                }
            }

            lastId = orderIds.get(orderIds.size() - 1);
        }
    }
}

查询 SQL:

sql
select id
from t_order
where id > ?
  and status = 'WAIT_PAY'
  and created_at < now() - interval 30 minute
order by id
limit ?;

幂等更新 SQL:

sql
update t_order
set status = 'CLOSED',
    close_reason = 'PAY_TIMEOUT',
    updated_at = now()
where id = ?
  and status = 'WAIT_PAY';

为什么分页用 id > lastId,不用 offset:数据量大时 offset 越往后越慢,而且扫描过程中数据变化会导致跳过或重复。游标式分页更稳定。

@Scheduled 的适用边界

需求是否适合 @Scheduled
单实例应用的小任务适合
简单缓存刷新适合
多实例都执行也没影响适合
需要页面动态修改 cron不适合
需要任务日志和失败重试不太适合
需要分片并行处理百万数据不适合
需要负责人告警和运维治理不适合

常见坑

问题原因解决
多实例重复执行每个实例都会注册本地任务分布式锁或 XXL-JOB
任务阻塞单个任务执行太久占住线程配置线程池,拆分任务
任务失败没感知只打日志没有告警增加监控和告警
一次扫太多数据大事务、长时间占用 DB分页、小批量、限流
重复生成数据没做幂等约束唯一索引、状态机、条件更新

本章小结

Spring @Scheduled 是学习定时任务最好的入口,但生产上不要被“写起来简单”迷惑。

上线前至少确认:

  1. 服务是否多实例。
  2. 任务是否幂等。
  3. 是否有执行日志。
  4. 是否有失败告警。
  5. 数据量大时是否分页。
  6. 任务慢时是否会阻塞其他任务。

这些问题超过 @Scheduled 的舒适区时,就应该引入 XXL-JOB。