使用 @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。