使用 @Scheduled 处理 Spring Boot 中的定时任务

1. 概述

许多应用需要按照计划执行任务:清理过期订单、每 5 分钟同步库存、在早上 8 点发送报告。Spring 通过 @Scheduled 注解支持这一点,无需额外的库。

在本文中,你将启用定时任务调度并区分 fixedRate、fixedDelay 和 cron。你还将把调度计划提取到配置文件、配置线程池并为任务编写测试。文章的最后是不应该使用 @Scheduled 的情况。

2. 启用定时任务调度

@Scheduled 已包含在 spring-context 中。你只需要 Spring Boot 的基础 starter:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter</artifactId>
</dependency>

接下来,在一个配置类中添加 @EnableScheduling:

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

缺少 @EnableScheduling 是最常见的错误。应用仍然能正常启动,没有任何错误提示,但没有任何任务会执行。

注意: 带有 @Scheduled 的方法必须没有参数。它应该返回 void,因为返回值会被忽略。包含它的类必须是一个 bean,例如带有 @Component 或 @Service。

3. 按固定时间间隔运行

3.1. fixedRate

fixedRate 从前一次执行的开始时间计算周期:

@Component
public class InventoryJob {

    private static final Logger log = LoggerFactory.getLogger(InventoryJob.class);

    @Scheduled(fixedRate = 5000)
    public void syncInventory() {
        log.info("Đồng bộ tồn kho lúc {}", LocalTime.now().withNano(0));
    }
}

默认单位是毫秒。任务在应用启动时立即运行,然后每 5 秒运行一次:

INFO [   scheduling-1] InventoryJob : Đồng bộ tồn kho lúc 09:00:00
INFO [   scheduling-1] InventoryJob : Đồng bộ tồn kho lúc 09:00:05
INFO [   scheduling-1] InventoryJob : Đồng bộ tồn kho lúc 09:00:10

如果一次执行耗时超过 5 秒,下一次执行不会并行运行。它将在前一次完成后立即开始,因为默认只有一个线程(scheduling-1)。

3.2. fixedDelay

fixedDelay 从前一次执行的结束时间计算周期。这种方式适合不能重叠的任务,如调用外部 API 或处理批量数据:

@Component
public class EmailQueueJob {

    private static final Logger log = LoggerFactory.getLogger(EmailQueueJob.class);

    @Scheduled(fixedDelay = 3, initialDelay = 10, timeUnit = TimeUnit.SECONDS)
    public void sendPendingEmails() throws InterruptedException {
        log.info("Bắt đầu gửi email");
        Thread.sleep(2000); // 模拟耗时 2 秒的工作
        log.info("Gửi xong");
    }
}

说明:

  • initialDelay = 10:启动后等待 10 秒才首次运行,以避免启动时的性能下降。
  • timeUnit = TimeUnit.SECONDS:改变 fixedDelay 和 initialDelay 的单位。
  • 每次执行耗时 2 秒,然后休息 3 秒。两次连续执行的开始时间相隔 5 秒:
09:00:10 Bắt đầu gửi email
09:00:12 Gửi xong
09:00:15 Bắt đầu gửi email
09:00:17 Gửi xong

4. 使用 cron 进行调度

当需要在特定时间运行时,使用 cron。Spring 的 cron 表达式有6 个字段,比 Unix cron 表达式多一个秒字段在开头:

┌─ 秒 (0-59)
│ ┌─ 分钟 (0-59)
│ │ ┌─ 小时 (0-23)
│ │ │ ┌─ 日期(1-31)
│ │ │ │ ┌─ 月份(1-12 或 JAN-DEC)
│ │ │ │ │ ┌─ 星期(0-7 或 MON-SUN)
* * * * * *

例如,按越南时间每周一至周五的早上 8 点发送销售报告:

@Component
public class SalesReportJob {

    private final ReportService reportService;

    public SalesReportJob(ReportService reportService) {
        this.reportService = reportService;
    }

    @Scheduled(cron = "0 0 8 * * MON-FRI", zone = "Asia/Ho_Chi_Minh")
    public void sendDailyReport() {
        reportService.sendYesterdaySales();
    }
}

当服务器按 UTC 运行时,zone 属性非常重要,这在容器中很常见。如果省略,报告将在 15 点而不是 8 点送达。

一些常用的表达式:

0 */15 * * * *        每 15 分钟
0 0 2 * * *           每天早上 2 点
0 0 0 1 * *           每月第一天的午夜
0 30 9 * * SAT,SUN    周六和周日的 9:30
@daily                等同于 0 0 0 * * *
@hourly               等同于 0 0 * * * *

注意: 如果从 Linux crontab 复制 5 字段表达式,如 0 8 * * *,Spring 启动时会抛出 IllegalStateException。你需要在开头添加秒字段。

5. 将调度计划提取到配置文件

在代码中硬编码调度计划会迫使你每次修改都重新构建。使用占位符。对于数值类型的属性,使用 …String 变体:

@Component
public class OrderCleanupJob {

    private final OrderRepository orderRepository;
    private final AtomicInteger runCount = new AtomicInteger();

    public OrderCleanupJob(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    @Scheduled(fixedDelayString = "${jobs.order-cleanup.delay-ms:60000}")
    public void removeExpiredOrders() {
        orderRepository.deleteExpiredBefore(Instant.now().minus(Duration.ofHours(24)));
        runCount.incrementAndGet();
    }

    public int getRunCount() {
        return runCount.get();
    }
}
jobs.order-cleanup.delay-ms=30000
jobs.sales-report.cron=0 0 8 * * MON-FRI

冒号后的值是默认值,在属性未声明时使用。对于 cron,你写成 @Scheduled(cron = “${jobs.sales-report.cron}”)。

要在不修改代码的情况下关闭 cron 任务,你设置值为 -:

jobs.sales-report.cron=-

Spring 识别 -(常量 Scheduled.CRON_DISABLED)并不注册该任务。当你想在开发或测试环境中禁用任务时,这很方便。

6. 配置线程池

默认情况下,Spring Boot 创建一个只有一个线程的 ThreadPoolTaskScheduler。所有任务共用这个线程。如果 SalesReportJob 挂起 10 分钟,InventoryJob 也必须等待 10 分钟。

你可以增加池的大小:

spring.task.scheduling.pool.size=4
spring.task.scheduling.thread-name-prefix=shop-job-

增加后,不同的任务可以并行运行:

INFO [   shop-job-1] InventoryJob   : Đồng bộ tồn kho lúc 09:00:05
INFO [   shop-job-2] EmailQueueJob  : Bắt đầu gửi email

从 Spring Boot 3.2 开始,在 Java 21 上运行时,你可以使用虚拟线程:

spring.threads.virtual.enabled=true

此时 Boot 使用 SimpleAsyncTaskScheduler,每次任务执行都在单独的虚拟线程上运行。属性 pool.size 不再有效。

注意: 增加池大小不会导致同一任务与自身重叠执行。每个 @Scheduled 方法仍然顺序执行。如果想让执行重叠,你必须添加 @Async(配合 @EnableAsync)。几乎不应该这样做,因为容易导致数据处理重复。

7. 异常处理

当任务使用 fixedRate 或 fixedDelay 抛出异常时,Spring 记录错误后仍然在下次运行。cron 也是如此。任务不会完全停止,但如果不监控日志,错误很容易被忽视。

最安全的方法是在任务内捕获错误并有意处理:

@Scheduled(fixedRate = 5, timeUnit = TimeUnit.MINUTES)
public void syncInventory() {
    try {
        inventoryClient.pullLatestStock();
    } catch (RestClientException ex) {
        log.warn("Không gọi được kho hàng, sẽ thử lại ở lần sau: {}", ex.getMessage());
        alertService.notifyOps("inventory-sync-failed");
    }
}

如果想为所有任务有一个统一的错误处理,你可以通过 Boot 的 customizer 注册 ErrorHandler:

@Configuration
public class SchedulingConfig {

    @Bean
    ThreadPoolTaskSchedulerCustomizer schedulerErrorHandler(AlertService alertService) {
        return scheduler -> scheduler.setErrorHandler(ex ->
                alertService.notifyOps("scheduled-job-failed: " + ex.getMessage()));
    }
}

此 customizer 仅适用于 ThreadPoolTaskScheduler。当你启用虚拟线程时,它不起作用。

8. 为任务编写测试

将测试分为两层。第一层检查业务逻辑。第二层检查调度器是否真的调用了任务。

业务逻辑应该像普通方法一样测试,直接调用且无需等待:

class OrderCleanupJobUnitTest {

    @Test
    void shouldDeleteOrdersOlderThan24Hours() {
        OrderRepository repo = mock(OrderRepository.class);
        OrderCleanupJob job = new OrderCleanupJob(repo);

        job.removeExpiredOrders();

        ArgumentCaptor<Instant> captor = ArgumentCaptor.forClass(Instant.class);
        verify(repo).deleteExpiredBefore(captor.capture());
        assertThat(captor.getValue())
                .isBefore(Instant.now().minus(Duration.ofHours(23)));
        assertThat(job.getRunCount()).isEqualTo(1);
    }
}

要检查调度,你使用 Awaitility。不应该使用 Thread.sleep,因为测试会很慢且不稳定。Awaitility 版本已由 Spring Boot 管理,所以你不需要指定版本:

<dependency>
    <groupId>org.awaitility</groupId>
    <artifactId>awaitility</artifactId>
    <scope>test</scope>
</dependency>
@SpringBootTest(properties = "jobs.order-cleanup.delay-ms=100")
class OrderCleanupJobSchedulingTest {

    @MockitoBean
    OrderRepository orderRepository;

    @Autowired
    OrderCleanupJob job;

    @Test
    void shouldRunRepeatedly() {
        await().atMost(Duration.ofSeconds(2))
               .untilAsserted(() -> assertThat(job.getRunCount()).isGreaterThanOrEqualTo(3));
    }
}

此测试通过 properties 将周期降至 100 毫秒。然后它最多等待 2 秒,直到任务至少运行 3 次。Awaitility 反复检查并在条件满足时立即返回,因此测试通常在 300 毫秒内完成。@MockitoBean 从 Spring Boot 3.4 开始提供。对于更旧的版本,你使用 @MockBean。

在其他集成测试中,应禁用调度,以防任务在后台运行并破坏数据。有两种方法。第一种方法是将 cron 设为 -。第二种方法是将 @EnableScheduling 移到一个单独的条件配置类:

@Configuration
@EnableScheduling
@ConditionalOnProperty(name = "jobs.enabled", havingValue = "true", matchIfMissing = true)
public class SchedulingConfig { }

然后,测试只需声明 jobs.enabled=false。

9. 何时不应使用 @Scheduled

@Scheduled 很简单,但有明确的限制:

  • 运行多个实例。 在 3 个 Kubernetes pod 中,任务会同时运行 3 次,报告会被发送 3 次。你需要分布式锁,例如 ShedLock 库使用数据库表,或将任务分离为单独运行的服务。
  • 需要保存任务状态。 如果应用在 8 点恰好关闭,该次运行将丢失并且不会补偿。当需要 misfire 处理、运行历史或运行时动态调度时,使用 Quartz。
  • 任务非常繁重或运行数小时。 应该转向 Spring Batch 或消息队列系统以获得重试、批处理和进度跟踪。
  • 用户创建的日程,例如“提醒我在 15:00”。注解仅适用于启动时的固定计划。动态计划应存储在数据库中并使用编程的 TaskScheduler,或使用 Quartz。

还有一些常见的错误:

  • 在私有方法或非 bean 类的方法上放置 @Scheduled。
  • 忘记 zone,导致任务在 UTC 服务器上运行时间偏差。
  • 在同一方法上放置 @Scheduled 和 @Transactional 可能会工作。不过,将逻辑提取到有 @Transactional 的服务会更容易测试和阅读。

10. 总结

在本文中,你已经:

  • 通过 @EnableScheduling 启用定时任务调度;
  • 区分了 fixedRate(从开始时间计算)和 fixedDelay(从结束时间计算);
  • 编写了带时区的 6 字段 cron 表达式;
  • 将调度计划提取到配置文件并使用 - 禁用任务;
  • 增加线程池或使用虚拟线程来防止任务相互阻塞;
  • 处理异常并使用 Awaitility 编写测试。

对于运行单个实例且有固定计划的应用,@Scheduled 就足够了。当系统扩展到多个实例或需要更高的可靠性时,在生产环境遇到问题之前,考虑 ShedLock 或 Quartz。

spring-bootschedulingcronjava