任务调度与异步
本教程共 48 篇 · 第 47 篇 · 更新于 2026-08-13 · 约 5 分钟阅读
本节目标:掌握 @Scheduled 定时任务、@Async 异步方法的写法,看懂线程池自动配置,并能用 Quartz 实现更复杂的调度需求。
定时任务:@Scheduled
很多业务需要「到点干活」:每天凌晨生成报表、每小时清理缓存。Spring 的定时任务注解就能干这个。
先给主类加上 @EnableScheduling,打开调度开关:
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling // 开启定时任务
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
然后在任意 Spring Bean 的方法上标 @Scheduled,方法就会按规则执行:
package com.example.demo.task;
import java.time.LocalDateTime;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
@Component
public class ReportTask {
// cron 表达式:秒 分 时 日 月 星期
@Scheduled(cron = "0 0 13 * * ?")
public void dailyReport() {
System.out.println("生成日报:" + LocalDateTime.now());
}
// 固定间隔:每隔 5 秒执行一次
@Scheduled(fixedRate = 5000)
public void heartbeat() {
System.out.println("心跳:" + LocalDateTime.now());
}
// 固定延迟:上次执行完 3 秒后再执行,启动后先等 10 秒
@Scheduled(fixedDelay = 3000, initialDelay = 10000)
public void slowJob() {
System.out.println("慢任务:" + LocalDateTime.now());
}
}
三种方式差别要分清:
cron:按日历时间触发,最灵活。fixedRate:按固定频率触发,不等待上次完成。任务超时会重叠执行。fixedDelay:上次执行完才开始计时,任务永远不会重叠。
cron 表达式有 6 段:秒、分、时、日、月、星期。? 表示不指定,日和星期不能同时给值。
0 0 13 * * ?:每天 13:00。0 0/5 * * * ?:每 5 分钟。0 0 0 * * MON-FRI:周一到周五的零点。
服务部署在服务器上时,cron 用的是 JVM 默认时区。多时区部署要显式指定 zone,否则任务会在意想不到的时间跑:
// 指定北京时间触发,服务器在哪个时区都不受影响
@Scheduled(cron = "0 0 13 * * ?", zone = "Asia/Shanghai")
public void dailyReport() {
System.out.println("生成日报:" + LocalDateTime.now());
}
fixedRate 和 fixedDelay 除了毫秒数字,还支持 ISO-8601 时长字符串,可读性更好:
@Scheduled(fixedDelay = "PT30S") // 等价的写法:上次跑完 30 秒后再跑
public void slowJob() {
// 任务体
}
Tip定时任务方法默认在单线程调度器里跑。任务耗时太长会堵住后面的任务,见下一节线程池配置。
定时 + 异步:别让任务堵车
@Scheduled 和 @Async 可以叠用。方法是异步执行,调度线程只负责到点派单,不会因为某个任务跑得久而耽误下一个任务:
@Component
public class ReportTask {
@Async // 到点后丢进线程池执行,不占调度线程
@Scheduled(cron = "0 0 13 * * ?")
public void heavyReport() {
// 这里可能跑几分钟
}
}
任务抛异常会中止本次执行,但不会影响下一次触发。生产环境记得在方法里捕获异常并记录日志,定时任务失败往往是最难发现的故障。
调度器线程池
Spring Boot 自动配置了一个调度器:默认是 ThreadPoolTaskScheduler,只有 1 个线程。并发定时任务多的时候,用配置调大:
spring:
task:
scheduling:
thread-name-prefix: "scheduling-" # 线程名前缀,方便排查日志
pool:
size: 4 # 调度线程数
注意:pool.size 管的是同时能跑几个定时任务,不是任务的执行频率。
异步方法:@Async
定时任务是「按计划跑」,异步是「调用后立刻返回,活儿后台干」。适合发邮件、写日志、调第三方接口这类不着急的操作。
用法分两步:主类加 @EnableAsync,方法加 @Async。
package com.example.demo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
@SpringBootApplication
@EnableAsync // 开启异步方法
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
package com.example.demo.service;
import java.util.concurrent.CompletableFuture;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@Service
public class MailService {
// 无返回值:调用立刻返回,方法体在别的线程执行
@Async
public void sendWelcomeMail(String email) {
System.out.println("发送邮件给 " + email + ",线程:" + Thread.currentThread().getName());
}
// 有返回值:用 CompletableFuture 包一层,调用方可以等结果
@Async
public CompletableFuture<String> fetchPrice() {
return CompletableFuture.completedFuture("价格数据");
}
}
调用方不用改任何代码,普通方法调用即可。CompletableFuture 支持 thenApply、whenComplete 等组合操作,适合并发请求多个数据源再汇总。
Warning@Async 有个经典坑:同类内部方法互相调用不生效。比如 A 方法里直接调本类的 B 方法,B 上的 @Async 会被忽略。因为异步靠代理实现,同类调用绕过了代理。解决办法是把异步方法放到另一个 Bean 里。
TaskExecutor:异步线程池
@Async 方法默认在自动配置的 AsyncTaskExecutor 上执行。没有虚拟线程时,它是 ThreadPoolTaskExecutor:8 个核心线程,按负载伸缩。用 spring.task.execution 调整:
spring:
task:
execution:
pool:
max-size: 16 # 最大线程数
queue-capacity: 100 # 队列容量,满了才扩线程
keep-alive: 10s # 空闲线程回收时间
想完全自定义线程池,注册一个名为 taskExecutor 的 Executor Bean 即可,自动配置会自动让位:
package com.example.demo.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
@Configuration(proxyBeanMethods = false)
public class AsyncConfig {
@Bean("taskExecutor")
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setThreadNamePrefix("async-");
executor.setCorePoolSize(4);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(200);
return executor;
}
}
Java 21 用户还可以开虚拟线程:设置 spring.threads.virtual.enabled=true,异步执行器自动换成基于虚拟线程的实现,线程池参数会失效,但代码一行不用改。
Quartz 集成
@Scheduled 够用,但有两个短板:任务定义写死在代码里,重启丢状态;不支持集群环境下多实例协调。Quartz 是专业的调度框架,Spring Boot 提供了 starter 直接集成。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
Quartz 有三个核心概念:JobDetail 描述任务,Trigger 描述触发时间,Job 是任务本身。Spring Boot 会自动收集容器里的 JobDetail 和 Trigger Bean。
先写任务类,继承 QuartzJobBean:
package com.example.demo.quartz;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.springframework.scheduling.quartz.QuartzJobBean;
public class CleanupJob extends QuartzJobBean {
// 普通 Bean 可以直接注入,setter 方式
private SomeService someService;
// JobDataMap 里的属性也能注入
private String bucket;
public void setSomeService(SomeService someService) {
this.someService = someService;
}
public void setBucket(String bucket) {
this.bucket = bucket;
}
@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
someService.cleanup(bucket);
}
}
再写配置类,注册 JobDetail 和 Trigger:
package com.example.demo.config;
import com.example.demo.quartz.CleanupJob;
import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import org.quartz.CronScheduleBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
public class QuartzConfig {
@Bean
public JobDetail cleanupJobDetail() {
return JobBuilder.newJob(CleanupJob.class)
.withIdentity("cleanupJob")
.usingJobData("bucket", "logs") // 传给 Job 的属性
.storeDurably()
.build();
}
@Bean
public Trigger cleanupTrigger(JobDetail cleanupJobDetail) {
return TriggerBuilder.newTrigger()
.forJob(cleanupJobDetail)
.withIdentity("cleanupTrigger")
.withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?"))
.build();
}
}
这样每天凌晨 2 点,CleanupJob 就会被触发。任务、触发时间都成了可配置的 Bean,比注解灵活。
持久化 JobStore
Quartz 默认把任务存在内存里,重启就没了。要持久化,配置 JDBC 存储:
spring:
quartz:
job-store-type: "jdbc" # 改用数据库存储
jdbc:
initialize-schema: "always" # 启动时自动建表
overwrite-existing-jobs: true # 配置里的任务覆盖库里同名任务
配合数据源,任务定义和触发状态会落库。多实例部署时,集群里的实例可以协调执行,避免重复触发。这是 @Scheduled 做不到的。
NoteQuartz 的 JDBC 表结构由官方脚本初始化。生产环境建议把
initialize-schema改为never,由 DBA 手动执行脚本,防止每次启动都重建表、丢触发记录。
怎么选
简单的周期任务,@Scheduled 一行注解搞定。需要持久化、集群调度、复杂触发规则(工作日、节假日排除)时,上 Quartz。异步处理则记住一句话:@EnableAsync 加 @Async,线程池交给自动配置,出问题再自定义。
| 需求 | 方案 |
|---|---|
| 固定间隔/固定时间的简单任务 | @Scheduled |
| 不阻塞调用方的耗时操作 | @Async + 线程池 |
| 任务落库、多实例集群调度 | Quartz + JDBC JobStore |
| 精确控制并发与队列 | 自定义 ThreadPoolTaskExecutor |
小结
这章学了三条线:@Scheduled 写定时任务,@Async 写异步方法,Quartz 处理复杂调度。要点有三个:cron 表达式六段格式、@Async 的自调用陷阱、线程池配置项。把定时任务、异步、线程池这三板斧组合起来,日常的后台任务需求基本都能覆盖。