你的定时任务真的靠谱吗?Spring Boot @Scheduled 全链路拆解

你的定时任务真的靠谱吗?Spring Boot @Scheduled 全链路拆解

@Scheduled(cron = "0 0 2 * * ?") 一写就完事了?线程池打满、任务重叠、异常吞掉、集群重复执行------生产环境的坑远比你想的多。

一、从一个"简单"定时任务说起

需求:每天凌晨 2 点同步客户数据。

java 复制代码
@Scheduled(cron = "0 0 2 * * ?")
public void syncCustomerData() {
    // 调用远程服务同步
    customerService.syncFromRemote();
}

上线一周后,业务反馈数据不对。查日志发现:

  1. 周二凌晨同步跑了 40 分钟,周三凌晨的同步等了 40 分钟才开始------任务重叠
  2. 有几次远程服务超时,整个任务静默失败,没有任何告警------异常被吞
  3. 更新 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 看似简单,三个注解就能跑,但生产环境需要考虑的远不止"定时触发":

  1. 线程池是第一道坎:默认单线程,多任务互相阻塞
  2. 任务重叠是第二道坎:Cron 模式不防重叠,需自己加锁
  3. 集群重复是第三道坎:多实例部署必须分布式锁
  4. 异常静默是最隐蔽的坑:任务失败无人知晓,数据静默不一致
  5. 优雅停机是运维的基本要求:K8s 环境下不加配置就是随机丢任务

一句话:@Scheduled 适合写 Demo,加上线程池配置 + 分布式锁 + 异常处理 + 优雅停机,才适合写生产。


相关阅读:

相关推荐
程序员天天困2 小时前
Spring Boot i18n 国际化实战:从资源文件到多语言接口完整指南
spring boot·后端·编程语言
Flittly21 小时前
【雕虫大技】Agent 动态 Skill 供应链安全加固(二):执行隔离沙箱实战
java·spring boot·spring
沐言人生1 天前
HelloAgentR工程手记1/100天——项目工作流搭建
spring boot·agent·vibecoding
陌上丨1 天前
Spring Boot 应用类型推断机制:一次从源码到原理的深度探索
java·spring boot·后端
云烟成雨TD1 天前
Micrometer 系列【33】Spring Boot Micrometer Metrics 自动配置模块解析
spring boot·云原生·micrometer
用户3126874877201 天前
接口太慢?Spring Boot 缓存体系 @Cacheable 全链路拆解
spring boot
snow@li1 天前
SpringBoot:AOP日志切面全景梳理/原理+流程+实战+避坑
java·开发语言·spring boot
snow@li1 天前
SpringBoot:全套生命周期全景详解/应用级+Bean级
java·spring boot·rpc
凤山老林2 天前
Spring Boot @Async 线上实战:从默认配置到生产级线程池治理
java·spring boot·后端