Quartz 新手入门:定时任务的两个承诺

定时任务这个东西,入门版本人人都会:Spring 的 @Scheduled,方法上加个注解,每 5 分钟扫一次超时订单,十行代码搞定。然后你听说要用 Quartz,配置文件多出二十行,Job 类还不能直接 @Autowired------图什么?

这篇文章就回答这一个问题。先说结论:Quartz 比注解多出来的每一行配置、每一个坑,都是在兑现两个承诺------重启不丢、多机不重复。理解了这两个承诺,Quartz 的四个核心概念、七个新手坑、乃至它和 XXL-JOB 的取舍,全都挂得上号。

全文用同一个场景贯穿:每 5 分钟扫描一次超时未支付订单并关闭。所有代码来自 Spring Boot + Quartz 的完整可运行配置,类名原样保留。

一、先看简单的方案怎么死

方案零:@Scheduled

java 复制代码
@Scheduled(cron = "0 */5 * * * ?")
public void closeUnpaidOrders() {
    List<Long> orderIds = orderMapper.selectTimeoutUnpaidOrderIds();
    for (Long orderId : orderIds) {
        orderMapper.closeOrder(orderId);
    }
}

单机、任务丢了也无所谓,这就够了。但它有两个致命短板:

死法一:重启丢任务 。 任务清单只存在应用内存里。应用重启的那一刻,"每 5 分钟关一次超时订单"这件事就从世界上消失了------没人再去关单,超时订单一直挂着。得等人发现、改代码、重新部署,任务才会回来。

死法二:多机跑两遍 。 为了高可用,你部署了两台机器。@Scheduled 没有全局视野,每台机器的内存里都有自己那份任务清单,每台都会执行一遍------同一批订单被处理两次。关单这种操作重复执行可能问题不大,但换成"给客户发催收短信",就是双倍打扰。

把两个死法翻译成两个问题,就是定时任务的真正考题:

  1. 任务清单存在哪?(内存 → 重启就没)
  2. 谁来执行?(每台机器各自为政 → 重复执行)

方案一:Quartz,用两个机制回答这两个问题

  • 清单存进数据库 (QRTZ_ 开头的一组表)→ 重启后自动恢复,死法一解决;
  • 多台机器通过数据库抢锁,同一任务同一时刻只有一个节点执行 → 死法二解决。

代价就是你接下来看到的一切:要建表、要配 job-store-type: jdbc、要管 instanceId、Job 类被 Quartz 反射创建所以 Spring 注不进去......先记住这个对应关系,后文每遇到一个配置或坑,都回头看它是在兑现哪个承诺。

中间还有一个方案 XXL-JOB,本文最后一节讲------一句话预告:它不是比 Quartz 承诺更多,而是把兑现承诺的活外包给了平台。

二、四个核心概念:把"做什么"和"什么时候做"分开

Quartz 的设计思想就一句话:把「做什么」和「什么时候做」分开。

复制代码
Job(做什么的代码)
   │
   ▼
JobDetail(任务的定义:执行哪个类、叫什么名字)
   │
   ├── 关联 ───▶ Trigger(什么时候做:Cron 表达式)
   │
   ▼
Scheduler(调度器:大管家,把两者撮合起来,到点就执行)
概念 类比 作用
Job 员工 写业务逻辑的类,实现 Job 接口,逻辑放在 execute() 方法里
JobDetail 岗位说明书 任务的定义:绑定哪个 Job 类、唯一标识(名字+组)、可传参数
Trigger 闹钟 定义执行时间规则:CronTrigger(日历式规则)、SimpleTrigger(固定间隔)
Scheduler 领导 拿到 JobDetail 和 Trigger,到点就调用 Job 的 execute()

新手常见疑问:为什么 Job 和 Trigger 不合成一个东西,非要中间隔一个 JobDetail? 为了灵活:

  • 一个 JobDetail 可以绑定多个 Trigger------一个任务既每天早上跑,又每周一额外跑一次,Job 代码只有一份;
  • 换执行时间只换 Trigger,Job 代码一行不用改。

"做什么"稳定,"什么时候做"善变,分开存才不用连坐修改。这个拆分也直接体现在数据库里:JobDetail 和 Trigger 分别存表,重启恢复时靠唯一标识(名字+组)重新对上号。

三、完整代码:每一行都在兑现承诺

场景:每 5 分钟扫描一次超时未支付订单并关闭。

3.1 引入依赖(pom.xml)

xml 复制代码
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-quartz</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- 数据库驱动,比如 mysql-connector-j -->

3.2 配置文件(application.yml)------两个承诺的主战场

yaml 复制代码
spring:
  datasource:
    url: jdbc:mysql://localhost:3306/yourdb?useUnicode=true&characterEncoding=utf8
    username: root
    password: root
  quartz:
    job-store-type: jdbc          # 承诺一:清单进数据库,重启不丢
    jdbc:
      initialize-schema: always   # 首次启动自动建 QRTZ_ 表;生产环境建议改为 never
    properties:
      org:
        quartz:
          scheduler:
            instanceName: MyScheduler
            instanceId: AUTO      # 承诺二:集群时每个节点自动分配唯一 ID
          jobStore:
            class: org.quartz.impl.jdbcjobstore.JobStoreTX
            driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate
            tablePrefix: QRTZ_
            isClustered: true     # 承诺二:开启集群,多机抢锁不重复执行
            clusterCheckinInterval: 10000
          threadPool:
            class: org.quartz.simpl.SimpleThreadPool
            threadCount: 10

对照着读:job-store-type: jdbc 兑现承诺一;isClustered: true + instanceId: AUTO 兑现承诺二。clusterCheckinInterval: 10000 是每个节点每 10 秒向数据库"报到"一次------集群靠它判断哪个节点还活着,哪个节点挂了需要接管任务。

3.3 Job 类:写业务逻辑的地方

java 复制代码
@DisallowConcurrentExecution   // 防止上一轮没跑完,下一轮又进来
public class UnpaidOrderJob implements Job {

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

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        // Job 是 Quartz 用反射创建的,不归 Spring 管,不能直接 @Autowired
        // 只能手动从 Spring 容器里"捞" Bean
        OrderMapper orderMapper = SpringContextUtil.getBean(OrderMapper.class);

        try {
            List<Long> orderIds = orderMapper.selectTimeoutUnpaidOrderIds();
            log.info("本次扫描到超时未支付订单 {} 条", orderIds.size());
            for (Long orderId : orderIds) {
                orderMapper.closeOrder(orderId);
            }
        } catch (Exception e) {
            throw new JobExecutionException(e, false); // false = 不立即重试
        }
    }
}

两个标注先圈出来,细节在后面两节展开:

  • @DisallowConcurrentExecution:任务执行比触发间隔慢时(比如某轮扫了 8 分钟),防止下一轮又开一个实例------这是对承诺二的补充,重复执行不止发生在多机之间,也发生在同一台机器的前后两轮之间。
  • SpringContextUtil.getBean(...) 那两行注释是新手第一大坑:Job 不是 Spring 创建的,@Autowired 默认失效。为什么、怎么破,第五节专门讲。

3.4 Spring 容器工具类

java 复制代码
@Component
public class SpringContextUtil implements ApplicationContextAware {
    private static ApplicationContext context;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) {
        SpringContextUtil.context = applicationContext;
    }

    public static <T> T getBean(Class<T> clazz) {
        return context.getBean(clazz);
    }
}

它做的事很简单:Spring 启动时把容器塞进来存住,之后任何不归 Spring 管的对象(比如 Quartz 创建的 Job)都能凭这个静态入口"捞" Bean。

3.5 Mapper 接口

java 复制代码
@Mapper
public interface OrderMapper {

    @Select("SELECT id FROM t_order WHERE status = 0 AND create_time < DATE_SUB(NOW(), INTERVAL 30 MINUTE)")
    List<Long> selectTimeoutUnpaidOrderIds();

    @Update("UPDATE t_order SET status = 2 WHERE id = #{id} AND status = 0")
    int closeOrder(Long id);
}

注意 closeOrder 的 WHERE 条件带了 AND status = 0------只有"待支付"才能被关掉。这不是 Quartz 的知识,但它和本文主题同源:执行环境不可控时(重复触发、并发扫描),把条件收进 SQL,让数据库做最后一道防线。

3.6 配置类:定义 JobDetail 和 Trigger

java 复制代码
@Configuration
public class QuartzConfig {

    /** 定义任务:做什么 */
    @Bean
    public JobDetail unpaidOrderJobDetail() {
        return JobBuilder.newJob(UnpaidOrderJob.class)
                .withIdentity("unpaidOrderJob", "orderGroup") // 唯一标识:名字+组
                .withDescription("扫描并关闭超时未支付订单")      // 描述,纯辅助信息
                .storeDurably()   // 没有 Trigger 时也保留 Job(默认会被删除)
                .build();
    }

    /** 定义触发器:什么时候做(每 5 分钟) */
    @Bean
    public Trigger unpaidOrderJobTrigger(JobDetail unpaidOrderJobDetail) {
        return TriggerBuilder.newTrigger()
                .forJob(unpaidOrderJobDetail)          // 绑定到上面的 JobDetail
                .withIdentity("unpaidOrderJobTrigger", "orderGroup")
                .withSchedule(CronScheduleBuilder.cronSchedule("0 */5 * * * ?")
                        .withMisfireHandlingInstructionDoNothing()) // 错过不补跑
                .build();
    }
}

3.7 运行流程

  1. 启动应用,Spring Boot 自动配置调度器,Quartz 连接数据库(QRTZ_ 表);
  2. QuartzConfig 里的 JobDetail 和 Trigger 被注册进调度器;
  3. 每 5 分钟,Quartz 调用一次 UnpaidOrderJob.execute();
  4. 任务信息持久化在数据库,重启后自动恢复 ;多机部署时集群锁保证同一任务只被一个节点执行。

两个承诺,到此全部兑现。

四、新手最容易踩的坑:全是承诺的代价

把常见坑列出来,每一条问一句"为什么会有这个坑",答案几乎都指向那两个承诺------持久化意味着 Quartz 要按自己的方式创建对象、把定义存进表,很多 Spring 的默认行为在这里不成立。

坑 说明 代价来自
Job 类必须有无参构造函数 Quartz 通过反射实例化 Job,没有无参构造会在恢复任务时报错 持久化:恢复任务时 Quartz 按"类名"反射重建对象,不是 Spring 在创建
Job 里 @Autowired 默认失效 Job 由 Quartz 创建,不是 Spring Bean,要用 SpringContextUtil 或自定义 JobFactory(见第五节) 同上:谁创建对象,谁负责注入依赖
忘记 storeDurably() 没绑定 Trigger 的 JobDetail 会被自动删除 持久化:Quartz 只保存"有闹钟的任务",孤定义默认清掉
忘记 @DisallowConcurrentExecution 任务执行慢于触发间隔时,会并发跑多个实例 集群锁管多机,不管同机前后两轮------那一层要你自己声明
misfire 策略选错 错过触发(停机、线程池满)后:DoNothing 不补跑(适合周期扫描);FireNow 立即补跑(适合不能漏的业务) 持久化:重启后 Quartz 知道"错过过几次",必须由你决定补不补
生产环境 initialize-schema 改为 never 避免每次启动都尝试建表 持久化:表是基础设施,建一次就够了
集群 instanceId 重复 会导致数据库锁失效、任务重复执行,配 AUTO 即可 集群:锁靠"每个节点身份不同"才成立,身份撞了锁就形同虚设

特别说两个新手最容易懵的:

misfire 是什么 ? 承诺一的另一面。任务清单进了数据库,停机 10 分钟后重启,Quartz 一看表:这期间错过了两次触发。补不补?这是业务问题,Quartz 不替你决定------周期扫描类(关超时订单)错过就错过,下轮会扫到,选 DoNothing;不能漏的(比如每月一次的对账)选 FireNow 立即补跑。@Scheduled 没这个问题,因为它压根不知道自己错过过------没承诺,就没这个麻烦。

instanceId 为什么重要 ? 承诺二的底层是数据库锁,锁的前提是"每个节点是不同的节点"。两个节点报了同一个 ID,数据库以为它们是同一台机器,锁形同虚设,回到死法二。配 AUTO 让每个节点自动分配唯一 ID,一行配置的事,但要知道它在保什么。

五、Job 里想用 @Autowired 怎么办?

先把失效的原因讲透:Spring 的 @Autowired 只在"由 Spring 创建的 Bean"上生效。Quartz 出于持久化和集群的需要,用反射自己 new Job 实例(还要在恢复任务时按类名重建)------对象不是 Spring 生的,Spring 自然没机会往里注依赖。这不是 bug,是"谁创建、谁注入"的必然结果。

三种注入方式对比:

SpringContextUtil @Autowired final 构造器注入
谁在操作 你手动调 getBean Spring 自动注入 Spring 自动注入
在 Quartz Job 里能用吗 ✅ 能 ❌ 默认失效 ❌ 默认失效
代码风格 混入容器 API,不够优雅 干净 最规范(Spring 官方推荐)

想让 @Autowired 在 Job 里生效?自定义一个 JobFactory,在 Quartz 创建 Job 实例后、使用前,把对象递给 Spring 补一次注入------等于在两个世界之间架一座桥:

java 复制代码
public class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory {
    private final AutowireCapableBeanFactory beanFactory;

    public AutowiringSpringBeanJobFactory(ApplicationContext context) {
        this.beanFactory = context.getAutowireCapableBeanFactory();
    }

    @Override
    protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception {
        Object job = super.createJobInstance(bundle);
        beanFactory.autowireBean(job); // 关键:让 Spring 给 Job 注入依赖
        return job;
    }
}

把它设置给 SchedulerFactoryBean 后,Job 里就能直接写 @Autowired 了。

怎么选:

  • 不想改配置、能跑就行 → 用 SpringContextUtil;
  • 希望代码风格统一 → 加自定义 JobFactory;
  • 如果逻辑写在普通 @Service 里(@Scheduled 方案),直接用构造器注入即可,根本没这些麻烦------再次验证:麻烦全部来自 Quartz 的承诺。

六、任务多了怎么办?

每个任务都需要一对 Bean:1 个 JobDetail + 1 个 Trigger。3 个任务就是 6 个 @Bean,10 个任务配置类会非常臃肿。

解法:动态注册------把任务定义抽成数据,循环注册,新增任务只需加一行:

java 复制代码
@Component
public class QuartzJobInitializer implements ApplicationRunner {

    @Autowired
    private Scheduler scheduler;

    private final List<JobConfig> jobConfigs = List.of(
        new JobConfig(UnpaidOrderJob.class, "unpaidOrderJob", "orderGroup", "0 */5 * * * ?"),
        new JobConfig(CleanLogJob.class,    "cleanLogJob",   "logGroup",   "0 0 2 * * ?"),
        new JobConfig(DailyReportJob.class, "dailyReportJob","reportGroup","0 0 8 * * ?")
    );

    @Override
    public void run(ApplicationArguments args) throws Exception {
        for (JobConfig config : jobConfigs) {
            JobKey jobKey = JobKey.jobKey(config.getJobName(), config.getJobGroup());

            // 重要!持久化模式下重启后数据库里已有任务,不判断会重复注册报错
            if (!scheduler.checkExists(jobKey)) {
                JobDetail jobDetail = JobBuilder.newJob(config.getJobClass())
                        .withIdentity(jobKey)
                        .storeDurably()
                        .build();
                Trigger trigger = TriggerBuilder.newTrigger()
                        .forJob(jobDetail)
                        .withIdentity(config.getJobName() + "Trigger", config.getJobGroup())
                        .withSchedule(CronScheduleBuilder.cronSchedule(config.getCron()))
                        .build();
                scheduler.scheduleJob(jobDetail, trigger);
            }
        }
    }
}

看那行 checkExists 注释------又是承诺一的代价:任务已经持久化在数据库里,重启后代码再注册一遍,同名任务冲突直接报错。先查再注册,幂等。

怎么选:

  • 任务少(三五个)且固定 → @Bean 方式直白、好排查;
  • 任务多、频繁增删、想做成配置化(存数据库/yml)→ 动态注册。

七、Cron 表达式速查

Quartz 的 Cron 是 6 位起步(秒 分 时 日 月 周),可加第 7 位「年」。⚠️ 和 Linux crontab(5 位、无秒)不一样,直接抄会错位!

字段含义

位置 字段 允许值 特殊字符
1 秒 0-59 , - * /
2 分 0-59 , - * /
3 时 0-23 , - * /
4 日 1-31 , - * ? / L W
5 月 1-12 或 JAN-DEC , - * /
6 周 1-7(1=周日)或 SUN-SAT , - * ? / L #
7 年(可选) 1970-2099 , - * /

特殊字符

字符 含义 例子
* 任意值 秒位写 * = 每秒
? 不指定(日和周必须有一个是 ?)
- 范围 10-12 = 10、11、12
, 枚举 MON,WED,FRI
/ 步长 */5 = 每 5 个单位;10/5 = 从 10 开始每 5
L 最后 日位 L = 当月最后一天
W 最近工作日 15W = 离 15 号最近的工作日
# 第几个(仅周位) 6#3 = 当月第 3 个周五

高频例子

表达式 含义
0 */5 * * * ? 每 5 分钟(全天 288 次)
0 0/5 14 * * ? 只在 14 点这一小时内每 5 分钟(12 次)
0 0 12 * * ? 每天中午 12 点
0 15 10 ? * MON-FRI 周一到周五 10:15
0 15 10 15 * ? 每月 15 号 10:15
0 15 10 L * ? 每月最后一天 10:15
0 15 10 ? * 6#3 每月第 3 个周五 10:15

*/5 vs 0/5 :分字段里两者等价(起始值都是 0)。区别只在起始值不是最小值时体现,如 10/5 = 从 10 分开始每 5 分钟。

易错点:

  • 日和周不能同时写具体值,必须有一个是 ?;
  • 周字段 1 = 周日 (不是周一),写 MON-FRI 更不容易错;
  • 1/5 在日字段是「从 1 号开始每 5 天」,跨月会重置,不支持精确的「每隔 N 天」语义。

八、和 XXL-JOB 怎么选?

核心区别:Quartz 是库(写代码配置),XXL-JOB 是平台(Web 界面管理) 。用本文的话说:Quartz 让你自己兑现两个承诺 ,XXL-JOB 把兑现的活收走,你只管写业务逻辑。

维度 Quartz XXL-JOB
架构 嵌入式,调度逻辑在你应用里 调度中心 + 执行器分离
任务管理 写代码定义 Job/Trigger Web 界面在线增删改查、启停,即时生效
集群 数据库锁实现,配置复杂 原生分布式,支持轮询、故障转移等路由策略
监控告警 基础较弱,需自行扩展 内置实时日志、失败重试、邮件告警
Cron 6-7 位 兼容 Quartz 写法,可直接复用
上手成本 较高 低(写个注解方法 + 后台配一下)

XXL-JOB 的代码极其简单------Job 就是一个普通 Spring Bean,@Autowired 直接用:

java 复制代码
@Component
public class UnpaidOrderJob {

    @Autowired
    private OrderMapper orderMapper;   // 正常注入,无需 SpringContextUtil

    @XxlJob("unpaidOrderHandler")      // 值要与调度中心配置的 JobHandler 一致
    public void checkUnpaidOrders() {
        List<Long> orderIds = orderMapper.selectTimeoutUnpaidOrderIds();
        for (Long orderId : orderIds) {
            orderMapper.closeOrder(orderId);
        }
    }
}

配置(application.yml):

yaml 复制代码
xxl:
  job:
    admin:
      addresses: http://127.0.0.1:8080/xxl-job-admin  # 调度中心地址
    executor:
      appname: order-executor                          # 执行器应用名
      port: 9999

然后在 Web 后台:新建执行器(AppName = order-executor)→ 新建任务(JobHandler = unpaidOrderHandler,Cron = 0 */5 * * * ?)→ 启动。

注意对比第四节的坑表:无参构造、@Autowired 失效、instanceId、misfire------XXL-JOB 里全部消失。不是这些麻烦不存在了,是调度中心(平台)替你兑现了那两个承诺,代价变成多运维一个调度中心服务。库和平台的取舍,本质是"承诺自己兑现还是外包":

  • 任务少、不想多运维一个服务 → Quartz,承诺自己兑现;
  • 任务多、多机部署、要可视化管理监控 → XXL-JOB,承诺外包给平台。

收尾

回到开头那个问题:Quartz 比 @Scheduled 多出来的复杂度,图什么?图两个承诺------重启不丢、多机不重复。四个概念是围绕承诺的组织方式,七个坑是承诺的代价,misfire 是承诺带来的新决策,XXL-JOB 是承诺的外包。

选型的判据也因此变得简单:你的任务丢得起吗?跑两遍要紧吗?两个都无所谓,@Scheduled 十行代码是最好的方案;有一个要紧,就为它付 Quartz 的代价,或者把代价转嫁给 XXL-JOB。没有免费的定时任务,只有你还没被问到的两个问题。

相关推荐
weixin_437961921 小时前
Docker安装(windows)
windows·docker·容器
wewowilling1 小时前
LangChain入门
python·ai·langchain·prompt
\光辉岁月/1 小时前
2.异常类
java·开发语言·异常类
淡海水1 小时前
05-03-源码-Boehm GC分配与碎片管理
开发语言·算法·unity·c#·游戏引擎·gc
kyle~1 小时前
Windows 多 AP 同名 Wi-Fi 网络异常问题复盘
网络·windows
努力实干1 小时前
C++ 面向对象与工程架构
开发语言·c++·架构
持敬chijing1 小时前
白盒测试与代码审计-Claude Code Windows环境搭建
windows·安全·网络安全·网络攻击模型
十年Java程序媛1 小时前
JVM 基础与 GC 垃圾回收详解|可达性分析、分代模型、垃圾收集器、STW
java·开发语言·jvm·spring boot·后端·面试
haluhalu.1 小时前
深入理解AIAgent(1.3)--- 设计模式
人工智能·python·设计模式·langchain