Spring 定时任务
Spring 的 @Scheduled 是 Java 项目里最容易上手的定时任务方案。它适合单体应用、内部小任务、低频补偿任务和学习定时任务基础。
但要先说结论:
@Scheduled适合简单单机任务;如果服务多实例部署、任务需要控制台、日志、重试、告警、分片,应该考虑 XXL-JOB 这类分布式调度框架。
快速入门
启用定时任务:
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);
}
}编写任务:
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 | 按固定延迟执行 | 从上一次结束时间计算 |
示例:
@Scheduled(fixedRate = 5000)
public void fixedRateTask() {
// 每隔 5 秒触发一次,不关心上次是否刚结束
}
@Scheduled(fixedDelay = 5000)
public void fixedDelayTask() {
// 上次执行完成后,再等 5 秒执行下一次
}如果任务可能执行很久,优先考虑 fixedDelay 或者使用分布式调度框架控制阻塞策略。
执行原理
flowchart TD
A["Spring 容器启动"] --> B["扫描 @Scheduled 方法"]
B --> C["注册任务元数据"]
C --> D["调度线程计算下次触发时间"]
D --> E["到点提交任务"]
E --> F["线程池执行方法"]
F --> G["执行完成后等待下一次触发"]@Scheduled 的本质是 Spring 在应用启动时扫描带注解的方法,把它注册到本地调度器里,到时间后由应用进程内的线程执行。
这意味着:
- 应用停了,任务就不会执行。
- 应用部署几个实例,任务就可能执行几次。
- 默认没有完整控制台、失败重试、分片和告警。
启动扫描全过程
@Scheduled 能生效,前提是你加了 @EnableScheduling。它不是一个装饰性注解,而是开启 Spring 定时任务基础设施的入口。
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 才能管理它、找到它的方法、在到点时调用它。
这也解释了一个常见坑:
public class BadTask {
@Scheduled(cron = "0 */5 * * * ?")
public void run() {
}
}如果这个类没有 @Component,也没有通过 @Bean 注册到 Spring 容器,Spring 根本不会扫描到它。
正确写法:
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class GoodTask {
@Scheduled(cron = "0 */5 * * * ?")
public void run() {
}
}为什么 @Scheduled 方法不能随便写参数
@Scheduled 方法通常应该是无参方法:
@Scheduled(cron = "0 */5 * * * ?")
public void closeTimeoutOrders() {
}因为它不是被 HTTP 请求调用,也不是被消息队列传参调用,而是由 Spring 调度器在固定时间反射调用。调度器只知道“到点调用这个方法”,并不知道该给方法传什么业务参数。
如果需要参数,应该放在配置、数据库任务表或方法内部读取:
@Scheduled(cron = "${task.order-close.cron}")
public void closeTimeoutOrders() {
int batchSize = orderTaskProperties.getBatchSize();
}任务注册全过程
Spring 扫描到 @Scheduled 后,会把它封装成一个可调度任务。
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 方法想象成被包装成下面这种东西:
Runnable task = () -> {
orderTimeoutTask.closeTimeoutOrders();
};真实实现会处理反射调用、错误处理和调度上下文,但核心就是“到点调用目标 Bean 的目标方法”。
为什么同一个应用里多个任务会互相影响
所有 @Scheduled 任务最终都要进入本地调度器线程池。如果线程池只有 1 个线程,一个慢任务就会挡住后面的任务。
flowchart TD
A["任务 A 执行 10 分钟"] --> B["占住唯一调度线程"]
C["任务 B 到点"] --> D["只能等待"]
E["任务 C 到点"] --> D
D --> F["任务延迟越来越明显"]所以生产环境不要依赖默认线程资源,至少要显式配置线程池,并且监控任务耗时。
触发时间计算过程
cron、fixedRate、fixedDelay 最大的区别,不在写法,而在“下一次时间从哪里开始算”。
flowchart TD
A["一次任务触发"] --> B{"触发方式"}
B -- "fixedRate" --> C["按上次开始时间 + 间隔"]
B -- "fixedDelay" --> D["按上次结束时间 + 延迟"]
B -- "cron" --> E["按 Cron 表达式计算下一次时间"]
C --> F["可能追赶频率"]
D --> G["不会和上次重叠"]
E --> H["适合固定时间点"]fixedRate 为什么容易出问题
假设:
@Scheduled(fixedRate = 5000)
public void task() {
// 执行 8 秒
}理论触发节奏是:
| 时间点 | 事件 |
|---|---|
| 00:00 | 第 1 次开始 |
| 00:05 | 第 2 次应该开始 |
| 00:08 | 第 1 次结束 |
| 00:10 | 第 3 次应该开始 |
如果线程池允许并发,可能出现同一任务重叠执行;如果线程池不允许并发,就会出现延迟排队。无论哪种,都要确认业务是否能接受。
适合 fixedRate 的任务:
- 采集本机指标。
- 刷新无状态缓存。
- 任务耗时稳定且明显小于间隔。
不适合:
- 扫描大表。
- 调用慢接口。
- 订单、结算、发券等不能重叠的任务。
fixedDelay 为什么更稳
fixedDelay 从上一次结束后开始计算。
@Scheduled(fixedDelay = 5000)
public void task() {
// 执行结束后再等 5 秒
}如果任务执行 8 秒,下一次会在结束后再等 5 秒,也就是至少 13 秒后才再次开始。它牺牲了固定频率,换来不追赶、不重叠,更适合补偿类任务。
Cron 为什么适合固定业务时间
Cron 适合表达“每天凌晨 2 点”“每 5 分钟”“每周一 1 点”这类业务时间。
@Scheduled(cron = "0 0 2 * * ?")
public void dailySettle() {
// 每天凌晨 2 点对账
}Cron 的风险:
| 风险 | 说明 |
|---|---|
| 字段数量不同 | Linux crontab 通常 5 位,Spring 常见 6 位 |
| 时区问题 | 多地域部署时要确认时区 |
| 整点洪峰 | 很多任务都配 0 点、整点,会同时打数据库 |
| 任务错过 | 应用停机期间不会自动补偿所有错过触发 |
线程执行全过程
当触发时间到了,Spring 会把任务交给线程执行。
flowchart TD
A["触发时间到达"] --> B["调度器取出 Runnable"]
B --> C["提交到调度线程池"]
C --> D["线程调用目标方法"]
D --> E{"是否抛异常"}
E -- "否" --> F["记录本次完成"]
E -- "是" --> G["错误处理器记录异常"]
F --> H["等待下一次触发"]
G --> H这里有两个关键认知:
@Scheduled不会自动开启事务,事务要靠你在业务方法或 Service 上写@Transactional。@Scheduled不会自动重试,方法失败了通常只是记录异常,下一次到点再执行。
异常为什么不能只靠日志
错误示例:
@Scheduled(cron = "0 */5 * * * ?")
public void syncProductToEs() {
productSyncService.syncFailedRecords();
}如果这里抛异常但没有告警,你可能第二天才发现 ES 已经一天没同步。
更稳的写法:
@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 任务的线程资源有限。生产环境要显式配置线程池,并给线程命名,方便排查。
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 容器和本地调度器。
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所以多实例下要么接受重复,要么加协调机制。
哪些任务可以多实例都跑
| 任务 | 是否可以 | 原因 |
|---|---|---|
| 本地缓存刷新 | 可以 | 每台机器刷新自己的缓存 |
| 本机指标采集 | 可以 | 每台机器采自己的指标 |
| 清理本机临时文件 | 可以 | 每台机器清自己的文件 |
| 订单关单 | 不建议 | 会重复扫描和竞争更新 |
| 发券任务 | 不可以 | 可能重复发放 |
| 对账结算 | 不可以 | 可能重复生成结果 |
用锁后为什么仍然要幂等
分布式锁只能降低重复执行概率,不能把业务正确性全部托付给锁。
flowchart TD
A["实例 A 拿到锁"] --> B["开始处理订单"]
B --> C["JVM 长时间 GC 或任务执行超时"]
C --> D["锁过期"]
D --> E["实例 B 拿到锁"]
E --> F["实例 B 处理同一批订单"]
B --> G["实例 A 恢复继续处理"]
F --> H["如果业务不幂等就重复处理"]
G --> H正确做法是:锁负责减少并发,业务状态条件负责最终正确。
例如关单必须这样写:
update t_order
set status = 'CLOSED'
where id = ?
and status = 'WAIT_PAY';而不是:
update t_order
set status = 'CLOSED'
where id = ?;后者可能把已经支付成功的订单关闭。
配置线程池
默认情况下,任务线程资源有限。多个任务互相阻塞时,可能导致后面的任务迟迟不执行。
推荐显式配置线程池:
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:
flowchart TD
A["订单服务实例 A"] --> D["扫描超时订单"]
B["订单服务实例 B"] --> D
C["订单服务实例 C"] --> D
D --> E["同一批订单可能被重复处理"]解决思路:
- 任务逻辑本身保证幂等。
- 使用 Redis、数据库或 Zookeeper 做分布式锁。
- 改用 XXL-JOB,由调度中心控制执行器。
Redis 锁示例
下面是学习版示例,真实项目建议使用 Redisson 或成熟封装。
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
分页扫描超时订单:
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:
select id
from t_order
where id > ?
and status = 'WAIT_PAY'
and created_at < now() - interval 30 minute
order by id
limit ?;幂等更新 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 是学习定时任务最好的入口,但生产上不要被“写起来简单”迷惑。
上线前至少确认:
- 服务是否多实例。
- 任务是否幂等。
- 是否有执行日志。
- 是否有失败告警。
- 数据量大时是否分页。
- 任务慢时是否会阻塞其他任务。
这些问题超过 @Scheduled 的舒适区时,就应该引入 XXL-JOB。
