你的定时任务真的靠谱吗?Spring Boot @Scheduled 全链路拆解
@Scheduled(cron = "0 0 2 * * ?")一写就完事了?线程池打满、任务重叠、异常吞掉、集群重复执行------生产环境的坑远比你想的多。
一、从一个"简单"定时任务说起
需求:每天凌晨 2 点同步客户数据。
java
@Scheduled(cron = "0 0 2 * * ?")
public void syncCustomerData() {
// 调用远程服务同步
customerService.syncFromRemote();
}
上线一周后,业务反馈数据不对。查日志发现:
- 周二凌晨同步跑了 40 分钟,周三凌晨的同步等了 40 分钟才开始------任务重叠
- 有几次远程服务超时,整个任务静默失败,没有任何告警------异常被吞
- 更新 K8s 部署时,两个 Pod 同时跑了一遍同步------集群重复执行
一个 @Scheduled 就能搞定的事?生产环境远没有这么简单。
二、@Scheduled 启用原理:三个关键步骤
2.1 开关:@EnableScheduling
java
@SpringBootApplication
@EnableScheduling
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
@EnableScheduling 做了什么?导入 SchedulingConfiguration,注册 ScheduledAnnotationBeanPostProcessor。
2.2 扫描:BeanPostProcessor 处理 @Scheduled
java
// ScheduledAnnotationBeanPostProcessor 核心逻辑(简化)
public class ScheduledAnnotationBeanPostProcessor {
public Object postProcessAfterInitialization(Object bean, String beanName) {
// 遍历 bean 的所有方法
for (Method method : bean.getClass().getDeclaredMethods()) {
Scheduled scheduled = method.getAnnotation(Scheduled.class);
if (scheduled != null) {
// 注册到 TaskScheduler
processScheduled(scheduled, method, bean);
}
}
return bean;
}
}
关键点:在 Bean 初始化后扫描,AOP 代理已经生成 。所以 @Scheduled 方法上的 @Transactional 等代理注解是能生效的。
2.3 调度:TaskScheduler 执行
Spring 内部通过 TaskScheduler 调度任务,默认实现是 ThreadPoolTaskScheduler。
css
┌──────────────────────────────────────────────┐
│ ScheduledAnnotationBeanPostProcessor│
│ │
│ 扫描 @Scheduled → 注册到 TaskScheduler │
│ │
│ ┌────────────────────────────────────────┐ │
│ │ ThreadPoolTaskScheduler │ │
│ │ │ │
│ │ ScheduledThreadPoolExecutor │ │
│ │ ┌──────┐ ┌──────┐ ┌──────┐ │ │
│ │ │ 线程1 │ │ 线程2 │ │ 线程N │ ← 默认1 │ │
│ │ └──────┘ └──────┘ └──────┘ │ │
│ └────────────────────────────────────────┘ │
└──────────────────────────────────────────────┘
默认线程池大小 = 1。这就是第一个坑的根源。
三、三种调度模式
3.1 fixedRate:固定频率
java
@Scheduled(fixedRate = 5000) // 每 5 秒执行一次
public void heartbeat() {
// 从上次开始时间算起,每 5 秒触发
}
时间线:
ini
时间轴: 0s 5s 10s 15s 20s
触发: ↓ ↓ ↓ ↓ ↓
执行: [===] [===] [===] [===] [===]
注意:如果任务执行超过 5 秒,下一次不会等上一次结束,而是在可用线程上立即触发。线程池只有 1 个线程时,任务会排队等待。
3.2 fixedDelay:固定延迟
java
@Scheduled(fixedDelay = 5000) // 上次执行完毕后等 5 秒
public void cleanup() {
// 任务结束后再等 5 秒
}
时间线(任务耗时 3 秒):
makefile
时间轴: 0s 8s 16s
触发: ↓ ↓ ↓
执行: [===] [===] [===]
等待: ···5s··· ···5s···
3.3 cron:Cron 表达式
java
@Scheduled(cron = "0 0 2 * * ?") // 每天凌晨 2 点
public void dailySync() {}
| 模式 | 触发逻辑 | 适用场景 |
|---|---|---|
fixedRate |
从上次开始时间算固定间隔 | 心跳、监控采集 |
fixedDelay |
从上次结束时间算固定间隔 | 清理、同步 |
cron |
按 Cron 表达式触发 | 定时报表、每日同步 |
四、生产环境五大坑
坑1:线程池只有 1 个线程
默认的 ThreadPoolTaskScheduler 线程池大小为 1。所有 @Scheduled 方法共享这个线程。
java
@Scheduled(fixedRate = 1000)
public void taskA() { Thread.sleep(5000); } // 执行5秒
@Scheduled(fixedRate = 1000)
public void taskB() { /* 瞬间完成 */ }
// taskB 必须等 taskA 执行完才能跑
解决方案:自定义线程池。
java
@Configuration
public class SchedulingConfig {
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(4); // 根据任务数量调整
scheduler.setThreadNamePrefix("sched-");
scheduler.setAwaitTerminationSeconds(60);
scheduler.setWaitForTasksToCompleteOnShutdown(true);
return scheduler;
}
}
坑2:任务重叠
Cron 任务默认不防止重叠。如果上一次没跑完,下一次照样触发。
java
@Scheduled(cron = "0 0/5 * * * ?") // 每5分钟
public void syncData() {
// 如果远程服务慢,5分钟跑不完
// 下一轮又在另一个线程上启动了
remoteService.sync(); // 可能执行8分钟
}
解决方案:
方案一:fixedDelay 替代 cron(如果业务允许)
方案二:加锁
java
@Scheduled(cron = "0 0/5 * * * ?")
public void syncData() {
if (!lock.tryLock()) {
log.warn("syncData 上次还没跑完,跳过本次");
return;
}
try {
remoteService.sync();
} finally {
lock.unlock();
}
}
方案三:分布式锁(集群场景,见坑5)
坑3:异常被吞
@Scheduled 方法抛异常时,Spring 默认行为是记录错误日志后静默跳过,不会中断调度,也不会通知任何人。
java
@Scheduled(cron = "0 0 2 * * ?")
public void dailySync() {
customerService.syncFromRemote(); // 抛出 RemoteException
// 下次定时触发照常运行,但没人知道上次失败了
}
解决方案 :自定义 ErrorHandler。
java
@Bean
public TaskScheduler taskScheduler() {
ThreadPoolTaskScheduler scheduler = new ThreadPoolTaskScheduler();
scheduler.setPoolSize(4);
scheduler.setErrorHandler(t -> {
log.error("定时任务执行异常", t);
alertService.sendAlert("定时任务异常: " + t.getMessage());
});
return scheduler;
}
或者用 try-catch 包一层:
java
@Scheduled(cron = "0 0 2 * * ?")
public void dailySync() {
try {
customerService.syncFromRemote();
} catch (Exception e) {
log.error("同步客户数据失败", e);
alertService.sendAlert("同步失败: " + e.getMessage());
throw e; // 如果需要中断调度,重新抛出
}
}
坑4:优雅停机丢失任务
默认关闭行为是立即终止线程池,正在执行的任务直接被打断。
解决方案:
yaml
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
加上前面配置的 setWaitForTasksToCompleteOnShutdown(true) 和 setAwaitTerminationSeconds(60),K8s 滚动更新时 Pod 会等待任务完成再关闭。
坑5:集群重复执行
最隐蔽的坑。2 个 Pod 都跑同一个 @Scheduled,Cron 到了,两边同时执行。
方案一:分布式锁(推荐)
java
@Scheduled(cron = "0 0 2 * * ?")
public void dailySync() {
// Redis 分布式锁,30 分钟超时兜底
String lockKey = "lock:dailySync";
boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofMinutes(30));
if (!locked) {
log.info("其他实例正在执行 dailySync,跳过");
return;
}
try {
customerService.syncFromRemote();
} finally {
redisTemplate.delete(lockKey);
}
}
方案二:ShedLock 框架(更优雅)
xml
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-spring</artifactId>
<version>6.3.0</version>
</dependency>
<dependency>
<groupId>net.javacrumbs.shedlock</groupId>
<artifactId>shedlock-provider-redis-spring</artifactId>
<version>6.3.0</version>
</dependency>
java
@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = "PT30M")
public class SchedulingConfig {}
@Scheduled(cron = "0 0 2 * * ?")
@SchedulerLock(name = "dailySync", lockAtLeastFor = "PT1M", lockAtMostFor = "PT30M")
public void dailySync() {
// 只有一个实例能拿到锁
customerService.syncFromRemote();
}
| 方案 | 优点 | 缺点 |
|---|---|---|
| 手写 Redis 锁 | 简单直接 | 锁超时、续期、异常释放需自己处理 |
| ShedLock | 声明式、自动续期、支持多种存储 | 引入额外依赖 |
五、高级配置
5.1 动态 Cron:运行时修改调度规则
java
@Service
public class DynamicCronService implements SchedulingConfigurer {
private String cronExpression = "0 0 2 * * ?";
public void updateCron(String newCron) {
this.cronExpression = newCron;
}
@Override
public void configureTasks(ScheduledTaskRegistrar registrar) {
registrar.addTriggerTask(
() -> customerService.syncFromRemote(),
triggerContext -> {
CronTrigger trigger = new CronTrigger(cronExpression);
return trigger.nextExecution(triggerContext);
}
);
}
}
5.2 条件执行:根据配置开关
java
@Scheduled(cron = "0 0 2 * * ?")
@ConditionalOnProperty(name = "sync.enabled", havingValue = "true")
public void dailySync() {
// 只在 sync.enabled=true 时注册
}
注意:@ConditionalOnProperty 作用在 Bean 级别,不是方法级别。要实现方法级条件,可以用 @Profile 或在方法内判断。
5.3 初始化延迟
java
@Scheduled(initialDelay = 30000, fixedRate = 60000)
// 应用启动后等 30 秒再执行第一次
public void warmupTask() {}
避免启动时大量定时任务同时触发,拖慢应用就绪。
六、@Scheduled vs Quartz vs XXL-Job:怎么选?
| 维度 | @Scheduled | Quartz | XXL-Job |
|---|---|---|---|
| 复杂度 | 零配置 | 中等 | 较高(需要调度中心) |
| 集群支持 | 需自己加锁 | 内置 | 内置 |
| 动态修改 | 需编程式 | API 支持 | 控制台操作 |
| 可视化 | 无 | 无 | 有 |
| 失败重试 | 需自己实现 | 有 misfire 机制 | 有 |
| 适用场景 | 简单轻量任务 | 中等复杂度 | 分布式大规模 |
选型建议:
- 5 个以内的简单定时任务 :
@Scheduled+ ShedLock 足够 - 需要动态管理、失败重试、misfire 策略:Quartz
- 几十上百个任务、跨服务调度、需要监控大屏:XXL-Job
七、最佳实践 Checklist
| 实践 | 原因 |
|---|---|
| 自定义线程池,poolSize ≥ 任务数 | 避免任务排队 |
| 长任务加锁防重叠 | @Scheduled 默认不防重叠 |
| 集群环境必须加分布式锁 | 防止多实例重复执行 |
| 自定义 ErrorHandler | 异常不能被静默吞掉 |
| 开启优雅停机 | K8s 滚动更新不丢任务 |
加 initialDelay |
避免启动时任务扎堆 |
| 关键任务加监控 | 至少记录执行时长和成功/失败 |
| Cron 表达式用常量或配置文件 | 不要散落在代码各处 |
八、总结
@Scheduled 看似简单,三个注解就能跑,但生产环境需要考虑的远不止"定时触发":
- 线程池是第一道坎:默认单线程,多任务互相阻塞
- 任务重叠是第二道坎:Cron 模式不防重叠,需自己加锁
- 集群重复是第三道坎:多实例部署必须分布式锁
- 异常静默是最隐蔽的坑:任务失败无人知晓,数据静默不一致
- 优雅停机是运维的基本要求:K8s 环境下不加配置就是随机丢任务
一句话:@Scheduled 适合写 Demo,加上线程池配置 + 分布式锁 + 异常处理 + 优雅停机,才适合写生产。
相关阅读: