首页 / Spring Boot 入门教程 / 任务调度与异步

Spring Boot 入门教程

任务调度与异步

本教程共 48 篇 · 第 47 篇 · 更新于 2026-08-13 · 约 5 分钟阅读

定时任务@Scheduled@AsyncTaskExecutorQuartz线程池Cron虚拟线程

本节目标:掌握 @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());
}

fixedRatefixedDelay 除了毫秒数字,还支持 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 支持 thenApplywhenComplete 等组合操作,适合并发请求多个数据源再汇总。

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 会自动收集容器里的 JobDetailTrigger 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 做不到的。

Note

Quartz 的 JDBC 表结构由官方脚本初始化。生产环境建议把 initialize-schema 改为 never,由 DBA 手动执行脚本,防止每次启动都重建表、丢触发记录。

怎么选

简单的周期任务,@Scheduled 一行注解搞定。需要持久化、集群调度、复杂触发规则(工作日、节假日排除)时,上 Quartz。异步处理则记住一句话:@EnableAsync@Async,线程池交给自动配置,出问题再自定义。

需求方案
固定间隔/固定时间的简单任务@Scheduled
不阻塞调用方的耗时操作@Async + 线程池
任务落库、多实例集群调度Quartz + JDBC JobStore
精确控制并发与队列自定义 ThreadPoolTaskExecutor

小结

这章学了三条线:@Scheduled 写定时任务,@Async 写异步方法,Quartz 处理复杂调度。要点有三个:cron 表达式六段格式、@Async 的自调用陷阱、线程池配置项。把定时任务、异步、线程池这三板斧组合起来,日常的后台任务需求基本都能覆盖。