第一章:Quartz 基本原理与运行机制
1.1 核心组件详解
在 Spring Boot Web 开发中,处理一个 HTTP 请求通常涉及 Controller(接收请求)、Service(业务逻辑)和 ThreadPool(Tomcat 线程池)。Quartz 的设计与此有异曲同工之妙,但它是面向"时间维度"的调度。
Quartz 的运行时架构由以下四大核心组件支撑:
1. Job & JobDetail:逻辑与定义的解耦
在 Quartz 中,任务的具体执行逻辑 和任务的定义数据是完全分离的。
-
Job (接口/类):
- 定义 :它是业务逻辑的载体,相当于 Spring 中的
Service方法逻辑。 - 特性 :它是无状态的。Quartz 在每次触发任务时,都会通过反射(Reflection)创建一个新的 Job 实例,执行完毕后立即销毁。因此,你不能在 Job 类成员变量中存储状态。
- 代码示例:
java// 定义一个具体要做什么逻辑的类 public class ReportJob extends QuartzJobBean { @Override protected void executeInternal(JobExecutionContext context) { // 具体的业务逻辑,例如生成报表 System.out.println("执行报表生成逻辑..."); } } - 定义 :它是业务逻辑的载体,相当于 Spring 中的
-
JobDetail (对象实例):
- 定义 :它是 Job 的实例定义,包含了任务的唯一标识(Identity)和静态数据(JobDataMap)。它相当于 HTTP 请求中的"请求参数" + "URL 路径"。
- 作用:它告诉 Quartz:"我要用哪个 Job 类,并且这次执行时带上哪些参数。"
- 代码示例:
javaJobDetail jobDetail = JobBuilder.newJob(ReportJob.class) // 指定逻辑类 .withIdentity("monthly_report", "finance_group") // 唯一标识(name, group) .usingJobData("reportType", "PDF") // 传入参数,存入 JobDataMap .storeDurably() // 即使没有 Trigger 关联也不删除 .build();
2. Trigger:时间触发规则
-
定义 :Trigger 定义了任务"在什么时候执行"。它与 JobDetail 是解耦的,一个 JobDetail 可以被多个 Trigger 触发。
-
类比:如果 JobDetail 是"API 接口",那么 Trigger 就是"调用该 API 的定时脚本"。
-
类型:
- SimpleTrigger:用于简单的"每隔几秒重复几次"或"立即执行"。
- CronTrigger(最常用):基于 Unix Cron 表达式,用于复杂的日历调度(如"每月最后一天上午10点")。
-
代码示例:
java
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity("trigger_1", "finance_group")
.startNow()
.withSchedule(CronScheduleBuilder.cronSchedule("0 0 10 L * ?")) // 每月最后一日10点
.forJob(jobDetail) // 绑定到上面的 JobDetail
.build();
3. Scheduler:调度控制器
-
定义 :Scheduler 是 Quartz 的核心容器,相当于 Spring 的
ApplicationContext或 MVC 中的DispatcherServlet。 -
作用:
- 它维护了一个注册表(JobStore),记录了所有的 JobDetail 和 Trigger。
- 它负责接收开发者的指令(add/delete/pause/resume)。
- 它驱动整个系统的运行,负责将到期的 Trigger 对应的 Job 丢给线程池执行。
-
Spring Boot 集成 :在 Spring Boot 中,
Scheduler通常作为一个单例 Bean 被自动注入。 -
代码示例:
java
@Autowired
private Scheduler scheduler;
public void initTask() {
// 将定义好的 Job 和 Trigger 注册进调度器
// 这一步会将元数据写入数据库(如果配置了 JDBC Store)
scheduler.scheduleJob(jobDetail, trigger);
}
4. ThreadPool:专用执行线程池
-
定义 :Quartz 拥有独立的线程池(通常是
SimpleThreadPool),专门用于执行定时任务。 -
关键区别:
- Web 线程池(如 Tomcat):负责响应外部 HTTP 请求(Controller 层)。
- Quartz 线程池:负责执行内部定时任务(Job 层)。
-
运作机制 :当 Trigger 触发时间到达时,Scheduler 会从这个线程池中获取一个空闲线程,实例化 Job 类,并执行其
execute方法。 -
配置示例 :在
application.yml或quartz.properties中配置并发度。
yaml
spring:
quartz:
properties:
org:
quartz:
threadPool:
class: org.quartz.simpl.SimpleThreadPool
threadCount: 20 # 意味着系统同一时刻最多能并行跑 20 个定时任务
threadPriority: 5
1.2 架构心脏:Scheduler 与 Storage
在 Web 开发中,我们习惯关注"无状态"的服务,因为数据通常都在数据库里。Quartz 的架构设计也遵循这一原则:Scheduler 是逻辑引擎,JobStore 是状态存储,QuartzSchedulerThread 是驱动引擎。
1. JobStore:状态存储策略
JobStore 是 Quartz 的数据访问层(DAO),它负责管理所有 JobDetail、Trigger 的状态以及相关的日历信息。在 Spring Boot 配置中,这对应 spring.quartz.job-store-type 选项。
-
RAMJobStore (内存存储)
-
原理 :这是 Quartz 的默认配置。所有数据都保存在 JVM 堆内存中,底层可以理解为维护了一系列的
ConcurrentHashMap。 -
Web 开发类比 :这就好比你在 Spring Service 中定义了一个
private Map<String, Task> cache,直接操作内存变量。 -
特性:
- 速度极快:没有网络 IO 和磁盘 IO。
- 易丢失:一旦 Spring Boot 应用重启或崩溃,所有的定时任务定义和执行进度都会丢失。
- 不支持集群:因为内存数据无法在不同服务器间共享。
-
配置示例:
yamlspring: quartz: job-store-type: memory -
-
JDBCJobStore (数据库存储)
-
原理 :所有数据通过 JDBC 保存到关系型数据库中。Quartz 自带了一套标准表结构(通常是 11 张表,以
QRTZ_开头)。 -
Web 开发类比:这相当于整合了 MyBatis 或 JPA,将任务对象序列化(Serialization)后存入 MySQL 表中。Scheduler 启动时会从数据库加载数据。
-
特性:
- 持久化:应用重启后,Quartz 会自动从数据库恢复未完成的任务。
- 支持集群:多个节点连接同一个数据库,依靠数据库锁机制实现分布式调度。
- 序列化要求 :存入
JobDataMap的对象必须实现Serializable接口,因为它们是以BLOB格式存入数据库的。
-
配置示例:
yamlspring: quartz: job-store-type: jdbc jdbc: initialize-schema: always # 自动初始化建表语句 -
2. QuartzSchedulerThread:后台轮询机制
在 Spring Boot Web 容器(如 Tomcat)中,线程通常是被动 等待 HTTP 请求的(Request-Response 模型)。而 Quartz 的主线程 QuartzSchedulerThread 是主动的工作模式(Loop-Polling 模型)。
这个线程在后台运行一个无限循环,其核心逻辑可以简化为以下三个步骤(伪代码描述):
步骤 A:查询待执行任务 (Peek)
主线程不断询问 JobStore:"未来 X 毫秒内有哪些 Trigger 要触发?"
如果是 JDBCJobStore,这底层对应一条 SQL 查询:
sql
-- 伪 SQL:查询下次触发时间小于(当前时间 + 预读窗口)的任务
SELECT * FROM QRTZ_TRIGGERS
WHERE NEXT_FIRE_TIME < (NOW() + 30000ms)
AND TRIGGER_STATE = 'WAITING'
ORDER BY NEXT_FIRE_TIME ASC;
步骤 B:获取与锁定 (Acquire)
主线程获取到 Trigger 后,会通知 JobStore 将其状态标记为"占用"或"执行中"。
- 在内存模式下,这只是修改对象属性。
- 在数据库模式下,这涉及事务开启和行锁更新(
UPDATE ... SET STATE = 'ACQUIRED'),这是保证任务不重复执行的关键。
步骤 C:分发执行 (Fire)
主线程拿到 Trigger 对应的 JobDetail 信息后,不会亲自执行任务逻辑,而是:
- 从
ThreadPool(工人工坊)中借用一个空闲的工作线程。 - 将任务上下文(Context)交给工作线程。
- 工作线程执行
job.execute(),而主线程立即返回循环,继续寻找下一个任务。
1.3 运行全流程:从"待命"到"执行"
理解 Quartz 的运行流程,本质上就是理解数据在 JVM 内存、数据库表和线程池之间的流转过程。我们可以将其拆解为三个标准阶段:
1. 注册阶段 (Registration)
这是任务的"持久化"过程。在 Spring Boot 应用启动或通过接口动态添加任务时触发。
-
动作 :调用
scheduler.scheduleJob(jobDetail, trigger)。 -
底层行为 :这是一个原子性的事务操作。
- Quartz 解析
JobDetail,将其序列化(Serialization)为二进制流,插入QRTZ_JOB_DETAILS表。 - Quartz 解析
Trigger,计算出第一次执行时间 (First Fire Time),将其插入QRTZ_TRIGGERS表,状态标记为WAITING。
- Quartz 解析
-
结果:此时任务数据已落库,即使应用立刻停止,任务也不会丢失。
java
// 代码视角:注册即落库
// 此时 QRTZ_TRIGGERS 表中多了一条记录,NEXT_FIRE_TIME = 2026-05-01 10:00:00, STATE = WAITING
scheduler.scheduleJob(jobDetail, trigger);
2. 轮询阶段 (Polling)
这是任务的"查找"过程。由后台守护线程 QuartzSchedulerThread 主导。
-
动作:主线程执行无限循环(Loop),不断扫描数据库。
-
逻辑 :
- 查询(Peek) :计算当前时间 + 预读时间窗口(默认 30秒)。查询
QRTZ_TRIGGERS表中NEXT_FIRE_TIME在此范围内的记录。
SELECT * FROM QRTZ_TRIGGERS WHERE NEXT_FIRE_TIME < ? AND STATE = 'WAITING' ...
- 获取(Acquire) :选中即将执行的 Trigger,并在数据库中将其状态修改为
ACQUIRED。这一步是为了防止其他 Quartz 节点(如果是集群模式)重复获取该任务。
- 查询(Peek) :计算当前时间 + 预读时间窗口(默认 30秒)。查询
3. 触发阶段 (Firing)
这是任务的"实例化与执行"过程。
- 动作 :主线程拿到
ACQUIRED状态的 Trigger 信息后,将其交给JobRunShell(执行壳层)。 - 逻辑 :
- 申请线程 :从
SimpleThreadPool中申请一个空闲的工作线程。 - 实例化 Job (关键) :利用反射机制
Class.forName("...").newInstance()创建一个新的 Job 对象实例 。- Spring Boot 注意事项 :在 Spring 集成环境下,这里通常由
SpringBeanJobFactory接管,它不仅负责new对象,还会自动执行@Autowired依赖注入,让你的 Job 能调用 Service。
- Spring Boot 注意事项 :在 Spring 集成环境下,这里通常由
- 构造 Context :将 JobDetail 中的
JobDataMap和 Trigger 信息打包成JobExecutionContext。 - 执行 :调用
jobInstance.execute(context)。 - 善后 :
- 任务执行结束后,Quartz 会根据 Cron 表达式计算下一次执行时间。
- 更新数据库
QRTZ_TRIGGERS表的NEXT_FIRE_TIME字段。 - 将状态重置为
WAITING,等待下一轮轮询。
- 申请线程 :从
第二章:集群模式下的抢占原理
本章解释多节点部署时,如何保证任务不重复执行、不漏执行。
2.1 关键组件:QRTZ_LOCKS 表
在 Spring Boot 微服务架构中,为了防止多个实例同时修改同一份数据,我们通常会使用 Redis (Redisson) 或 Zookeeper 来实现分布式锁。
Quartz 采用了一种更为简单且不依赖外部组件的策略:基于数据库的悲观锁(Pessimistic Locking) 。这套机制的核心就在于 QRTZ_LOCKS 表。这张表通常只有两行固定的数据,分别对应 Quartz 内部两种并发场景的"互斥信号量"。
1. TRIGGER_ACCESS:任务触发锁(核心业务)
这是 Quartz 集群中最繁忙的一把锁。它控制着所有节点对待执行任务的争抢权限。
-
业务场景 :
假设集群中有 Node A 和 Node B,数据库中有一条 Trigger 记录定于 10:00:00 执行。
当时间到达 10:00:00 时,Node A 和 Node B 的调度线程都会试图去获取这个任务。
-
技术实现 :
Quartz 会开启数据库事务,并执行如下 SQL:
sqlSELECT * FROM QRTZ_LOCKS WHERE LOCK_NAME = 'TRIGGER_ACCESS' FOR UPDATE; -
互斥性 :数据库(如 MySQL InnoDB)会对该行记录加排他锁(X锁)。如果 Node A 先执行成功,Node B 的这条 SQL 就会被阻塞(Block),直到 Node A 提交事务。
-
作用 :获得这把锁的节点,拥有了"查询并修改
QRTZ_TRIGGERS表"的唯一权限。它保证了同一时刻,只有一个节点能执行"获取即将到期任务"的操作,从而彻底避免了任务被重复触发。
2. STATE_ACCESS:集群维护锁(后台管理)
这把锁与具体的任务执行无关,它专门用于维护集群节点的健康状态。
-
业务场景 :Quartz 集群中有一个后台线程叫
ClusterManager。它的工作包括:- 心跳汇报 :定期更新
QRTZ_SCHEDULER_STATE表,证明"我还活着"。 - 故障恢复:检查其他节点是否超时(挂机)。如果发现 Node B 挂了,Node A 需要接管 Node B 尚未完成的任务。
- 心跳汇报 :定期更新
-
技术实现 :
当进行故障检测或状态更新时,线程会申请这把锁:
sqlSELECT * FROM QRTZ_LOCKS WHERE LOCK_NAME = 'STATE_ACCESS' FOR UPDATE; -
作用:它保证了在进行"故障恢复"这种敏感操作时,不会有其他节点同时在修改集群列表,防止数据状态错乱。
2.2 抢占具体流程
在 Spring Boot 开发中,我们处理并发扣减库存时,常使用"开启事务 -> 锁行 -> 更新 -> 提交"的模式。Quartz 的抢占机制完全遵循这一标准范式。
假设集群中有 Node A 和 Node B 两个实例,同时试图触发同一个任务(Trigger ID: TASK_01,预定时间 10:00:00)。
Step 1: 加锁
这是整个并发控制的起点。Quartz 的调度线程(QuartzSchedulerThread)在每一轮调度周期开始时,首先会开启一个新的 JDBC 事务。
-
技术动作:Node A 率先执行了针对锁表的查询语句。
sql-- 事务开启 (Set AutoCommit = false) SELECT * FROM QRTZ_LOCKS WHERE SCHED_NAME = 'MyScheduler' AND LOCK_NAME = 'TRIGGER_ACCESS' FOR UPDATE; -
运行状态:
- Node A:成功获得数据库行级排他锁(X锁),继续执行后续代码。
- Node B:执行同一条 SQL 时,被数据库阻塞(Block)。它必须在 JDBC 连接上挂起等待,直到 Node A 的事务结束。
- 意义:这一步强制将并行的分布式环境在数据库层面压平为串行执行。
Step 2: 宣示主权
获得锁的 Node A 拥有了操作 Trigger 表的"独家通行证"。它开始从数据库中筛选并"预订"任务。
-
技术动作 1:筛选 ,Node A 查询符合条件的 Trigger。
sqlSELECT * FROM QRTZ_TRIGGERS WHERE NEXT_FIRE_TIME < 1777647600000 -- 当前时间 + 预读窗口 AND TRIGGER_STATE = 'WAITING' -- 关键条件:必须是等待中 ORDER BY NEXT_FIRE_TIME ASC LIMIT 1;假设查询到了
TASK_01。 -
技术动作 2:预订 ,Node A 立即在当前事务内 修改该 Trigger 的状态。
sqlUPDATE QRTZ_TRIGGERS SET TRIGGER_STATE = 'ACQUIRED' -- 状态变更:从 WAITING -> ACQUIRED WHERE SCHED_NAME = 'MyScheduler' AND TRIGGER_NAME = 'TASK_01';- 运行状态 :
此时,TASK_01在数据库中的物理记录已经被标记为ACQUIRED。
注意:虽然事务还没提交,但因为 Node B 还在 Step 1 处被锁阻塞,所以 Node B 此时还不可见,但这不重要,因为 Node B 根本没有机会去查 Trigger 表。
- 运行状态 :
Step 3: 释放与执行
这是最关键的一步,涉及"调度逻辑"与"业务执行"的解耦。
-
技术动作 1:提交事务 (Commit) ,Node A 执行
connection.commit()。- 后果 A(释放锁) :
QRTZ_LOCKS上的行锁释放。 - 后果 B(唤醒输家) :一直在 Step 1 等待的 Node B 终于拿到了锁。Node B 赶紧去查 Trigger 表(重复 Step 2 的查询 SQL)。但是 ,因为它要求
STATE = 'WAITING',而TASK_01刚刚已经被 Node A 改成了ACQUIRED,所以 Node B 查询结果为空。Node B 只能空手而归,提交事务释放锁。
- 后果 A(释放锁) :
-
技术动作 2:异步执行 (Fire)。事务提交后,Node A 的调度线程(Scheduler Thread)拿着已经在内存中准备好的 Trigger 和 JobDetail 数据,调用线程池:
java// 伪代码:主线程只负责派发,不负责执行 TriggerFiredBundle bundle = createBundle(trigger, jobDetail); SimpleThreadPool.runInThread(new JobRunShell(bundle));- 意义 :业务逻辑的执行(可能耗时 10 秒)是在事务外部 、异步线程中进行的。这保证了调度线程不会被长任务阻塞,能立刻进入下一轮循环去抢新的任务。
第三章:基于 Spring Boot 的代码设计
本章讲解企业级如何封装 Quartz
3.1 物理存储:数据库表设计概览
当在 application.yml 中配置 spring.quartz.job-store-type: jdbc 时,Quartz 会与数据库进行大量的交互。理解这三张表的数据结构,是排查任务调度问题(如任务卡死、参数丢失)的基础。
1. QRTZ_JOB_DETAILS:静态元数据表
这张表存储任务的静态定义 ,对应 Java 代码中的 JobDetail 对象。它相当于一个"类定义"或"配置模版"。
-
核心字段解析:
JOB_NAME&JOB_GROUP:联合主键。Quartz 通过这两个字段唯一确定一个任务。JOB_CLASS_NAME:存储全限定类名(如com.example.job.JobHandlerInvoker)。Quartz 在执行时会读取该字段,通过反射Class.forName()加载类。JOB_DATA(关键) :类型为BLOB(二进制大对象)。- 作用 :存储
JobDataMap中的数据。 - 机制 :当你在代码中调用
.usingJobData("params", val)时,Quartz 会使用 Java 标准序列化机制(ObjectOutputStream)将 Map 转换为字节流存入此字段。 - 约束 :这也是为什么存入 JobDataMap 的自定义对象必须实现
Serializable接口的原因。
- 作用 :存储
-
数据行示例:
JOB_NAME JOB_GROUP JOB_CLASS_NAME JOB_DATA daily_reportreport_group...JobHandlerInvoker0xACED...(含handlerName=demoJob)email_taskops_group...JobHandlerInvoker0xACED...(含handlerName=emailJob)
2. QRTZ_TRIGGERS:运行时状态表
这张表存储任务的调度逻辑 和生命周期状态 ,对应 Java 代码中的 Trigger 对象。它是调度线程(Scheduler Thread)轮询扫描的主要目标。
-
核心字段解析:
-
TRIGGER_NAME&TRIGGER_GROUP:联合主键。 -
JOB_NAME&JOB_GROUP:外键,指向该触发器具体要执行的 Job。 -
NEXT_FIRE_TIME(核心索引) :类型为BIGINT。- 作用:存储下一次执行的时间戳(毫秒级)。
- 原理 :调度查询
SELECT * FROM ... WHERE NEXT_FIRE_TIME < NOW完全依赖此字段。任务执行完一次后,Quartz 会计算出新的时间覆盖此字段。
-
TRIGGER_STATE(状态机):字符串类型。 -
WAITING:等待执行(正常状态)。 -
ACQUIRED:已被某个节点抢占,准备执行。 -
EXECUTING:正在执行中(通常只在设置了@DisallowConcurrentExecution时出现)。 -
PAUSED:任务被暂停。 -
ERROR:上次执行出现严重错误(如 ClassNotFound)。
-
-
数据行示例:
TRIGGER_NAME NEXT_FIRE_TIME TRIGGER_STATE JOB_NAME trigger_11777647600000WAITINGdaily_report
3. QRTZ_LOCKS:分布式锁表
这张表极其简单,仅作为互斥信号量存在,不存储任何业务数据。它支撑了 Quartz 的集群功能。
-
核心字段解析:
LOCK_NAME:主键,锁的名称。
-
运作机制:Quartz 不依赖 Update 语句的行数判断,而是显式使用数据库的行级锁特性。
- 当节点需要抢占任务时,执行
SELECT ... WHERE LOCK_NAME = 'TRIGGER_ACCESS' FOR UPDATE。 - 数据库(如 MySQL InnoDB)会阻塞其他试图选取同一行的事务,直到当前事务提交。
- 当节点需要抢占任务时,执行
-
数据行示例(通常表中只有这两行固定数据):
SCHED_NAME LOCK_NAME 备注 MySchedulerTRIGGER_ACCESS用于抢占任务触发权(高频竞争) MySchedulerSTATE_ACCESS用于集群节点管理与故障恢复(低频竞争)
3.2 逻辑设计:代理分发模式
为了将 Quartz 实现解耦 ,我们采用"代理分发模式"。
这种架构的核心设计理念是:将调度控制与业务执行在物理层面完全分离。
1. 架构核心思想:1 对 N 的映射
- 传统原生模式 :
Quartz 的 Job 类与业务逻辑是 1:1 强耦合 的。每新增一个业务类型(如"发邮件"、"清理日志"),就必须编写一个新的 Java 类去实现org.quartz.Job接口。 - 代理分发模式 :
Quartz 的 Job 类与业务逻辑是 1:N 解耦 的。我们只向 Quartz 注册唯一 一个通用的代理类(JobHandlerInvoker)。当 Quartz 触发任务时,这个代理类根据传入的参数(Bean Name),在 Spring 容器中动态查找具体的业务 Bean 并执行。
2. 角色分工详解
在这种架构下,系统被划分为三个清晰的层级:
A. SchedulerManager(门面层)
这是应用层与 Quartz 框架交互的唯一入口。
- 职责 :屏蔽 Quartz 复杂的 API(如
JobBuilder、TriggerBuilder)。 - 行为 :
- 对外提供简化的接口,例如
addJob(String handlerName, String cron)。 - 对内负责组装 Quartz 原生对象。它将业务方传入的
handlerName(如"demoJob")封装进JobDataMap,并将 Job 的 Class 指向固定的代理类(JobHandlerInvoker.class)。
- 对外提供简化的接口,例如
B. JobHandlerInvoker(基础设施层)
这是 Quartz 调度器直接调用的目标,也是连接 Quartz 与 Spring 容器的桥梁。
-
职责:负责"路由分发"和"横切关注点(Cross-cutting Concerns)"的处理。
-
运行机制:
- 上下文解析 :从 Quartz 传递的
JobExecutionContext中读取之前存储的 Bean 名称(handlerName)和参数。 - Bean 查找 :利用 Spring 的
ApplicationContext,根据 Bean 名称获取具体的 Bean 实例。 - 动态调用:调用目标 Bean 的统一执行方法。
- 上下文解析 :从 Quartz 传递的
-
附加价值 :由于所有任务执行都要经过这个类,它是实现统一日志记录 、全局异常捕获 、失败自动重试 的最佳位置(相当于 Spring MVC 中的
Interceptor或 AOP 切面)。
C. JobHandler(业务逻辑层)
这是纯粹的业务逻辑实现。
-
职责:只关注具体的业务功能(如查询数据库、发送 HTTP 请求)。
-
技术特征:
- 它是一个标准的 Spring
@Component或@Service。 - 它不需要依赖 Quartz 的任何包 。它只需要实现一个通用的业务接口(例如定义一个
execute(String param)方法)。
- 它是一个标准的 Spring
-
设计优势:
- 可测试性:因为不依赖 Quartz,你可以像测试普通 Service 一样直接编写 JUnit 单元测试。
- 复用性:这个 Bean 既可以被 Quartz 调度调用,也可以在 Controller 中注入并直接调用。
- 方便Bean注入:传统模式的Job类不归Spring管,注入Bean很麻烦。代理模式中JobHandler可以直接注入Service以及Mapper的Bean
3. 运行时调用链路图解
- Quartz 线程 :唤醒并实例化
JobHandlerInvoker。 - JobHandlerInvoker :
- 读取配置:
Target Bean = "reportService"。 - 查找 Bean:
SpringContext.getBean("reportService")。
- 读取配置:
- Spring 容器 :返回
ReportService实例。 - JobHandlerInvoker :调用
ReportService.execute()。 - ReportService:执行具体业务。
通过这种设计,我们成功地将 Quartz 退化为一个单纯的"定时触发器",而将核心业务逻辑完全交还给了 Spring 容器管理。
3.3 核心代码实现解析
在代理分发模式下,代码结构不再是散乱的 Job 类,而是呈现出高度的分层架构 。核心逻辑在于如何利用 Quartz 的 JobDataMap 传递元数据,以及如何利用 Spring 的 ApplicationContext 动态加载业务 Bean。
1. 任务定义层 (SchedulerManager)
这一层主要负责配置组装 与持久化。它的核心职责是将业务参数(如"要调用的 Bean 名字"、"业务参数")封装进 Quartz 的标准对象中。
核心代码解析 (SchedulerManager.addJob):
java
@Component
public class SchedulerManager {
@Autowired
private Scheduler scheduler;
/**
* @param jobHandlerName Spring Bean 的名称 (例如 "demoJob")
* @param jobHandlerParam 业务参数 (例如 "2023-10-01")
*/
public void addJob(Long jobId, String jobHandlerName, String jobHandlerParam,
String cronExpression, Integer retryCount, Integer retryInterval)
throws SchedulerException {
// 1. 构建 JobDetail (核心设计点)
// 注意:这里绑定的 Class 永远是 JobHandlerInvoker.class (代理类),而非具体的业务类
JobDetail jobDetail = JobBuilder.newJob(JobHandlerInvoker.class)
.withIdentity(jobHandlerName) // 使用 handlerName 作为 Quartz 内部的 JobKey
// 【关键点】将路由信息和配置参数写入 JobDataMap
.usingJobData(JobDataKeyEnum.JOB_ID.name(), jobId)
.usingJobData(JobDataKeyEnum.JOB_HANDLER_NAME.name(), jobHandlerName)
.usingJobData(JobDataKeyEnum.JOB_HANDLER_PARAM.name(), jobHandlerParam)
.usingJobData(JobDataKeyEnum.JOB_RETRY_COUNT.name(), retryCount)
.usingJobData(JobDataKeyEnum.JOB_RETRY_INTERVAL.name(), retryInterval)
.build();
// 2. 构建 Trigger
Trigger trigger = TriggerBuilder.newTrigger()
.withIdentity(jobHandlerName)
.withSchedule(CronScheduleBuilder.cronSchedule(cronExpression))
.build();
// 3. 原子性调度 (持久化)
// 这一步会开启数据库事务,将 JobDetail 和 Trigger 的数据序列化后写入 QRTZ_JOB_DETAILS 和 QRTZ_TRIGGERS 表
scheduler.scheduleJob(jobDetail, trigger);
}
}
- 数据流转解析:
- 通过
.usingJobData()方法,我们将业务层面的元数据(Bean名、参数、重试策略)"序列化"到了 Quartz 的数据库中。 - 此时,Quartz 并不知道
demoJob是什么,它只知道有一个叫JobHandlerInvoker的任务需要执行,并且携带了一堆参数。
2. 任务调度层 (JobHandlerInvoker)
这是系统的核心中枢。当 Trigger 触发时,Quartz 会实例化这个类。它是连接 Quartz 线程与 Spring 容器的桥梁。
核心代码解析 (JobHandlerInvoker.executeInternal):
java
@DisallowConcurrentExecution // 禁止同一个任务并发执行(上一轮没跑完,下一轮必须等待)
@Component
public class JobHandlerInvoker extends QuartzJobBean {
@Resource
private ApplicationContext applicationContext; // 注入 Spring 上下文,这是获取 Bean 的关键
@Resource
private JobLogFrameworkService jobLogFrameworkService;
@Override
protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
// --- A. 数据解包 (Context 解析) ---
// JobExecutionContext 是 Quartz 运行时自动生成的环境对象
// getMergedJobDataMap() 会自动合并 JobDetail 和 Trigger 中的参数
JobDataMap dataMap = context.getMergedJobDataMap();
String jobHandlerName = dataMap.getString(JobDataKeyEnum.JOB_HANDLER_NAME.name());
String jobHandlerParam = dataMap.getString(JobDataKeyEnum.JOB_HANDLER_PARAM.name());
int retryCount = dataMap.getInt(JobDataKeyEnum.JOB_RETRY_COUNT.name());
// ... 获取其他参数
// --- B. 统一日志切面 (前置) ---
long startTime = System.currentTimeMillis();
// 记录任务开始,状态为"运行中"
Long logId = jobLogFrameworkService.createJobLog(..., jobHandlerName, ...);
try {
// --- C. 代理调用 (核心逻辑) ---
executeInternal(jobHandlerName, jobHandlerParam);
// --- D. 统一日志切面 (成功) ---
jobLogFrameworkService.updateJobLogResultAsync(logId, ..., true, "执行成功");
} catch (Throwable ex) {
// --- E. 统一日志切面 (失败) ---
jobLogFrameworkService.updateJobLogResultAsync(logId, ..., false, ex.getMessage());
// --- F. 异常重试机制 ---
handleException(ex, context.getRefireCount(), retryCount);
}
}
// 私有方法:利用 Spring 容器分发任务
private void executeInternal(String handlerName, String param) throws Exception {
// 1. 根据名字从 Spring 容器获取 Bean
// 这一步解决了原生 Quartz 无法注入 Spring Bean 的问题
JobHandler jobHandler = applicationContext.getBean(handlerName, JobHandler.class);
// 2. 调用统一的接口方法
jobHandler.execute(param);
}
// 私有方法:重试控制
private void handleException(Throwable ex, int currentRetry, int maxRetry) throws JobExecutionException {
if (currentRetry < maxRetry) {
// 通过 sleep 实现重试间隔
ThreadUtil.sleep(retryInterval);
// 【关键】抛出带 true 参数的异常,告诉 Quartz 立即重新触发一次该任务
throw new JobExecutionException(ex, true);
}
// 超过重试次数,抛出普通异常,任务标记为 Error
throw new JobExecutionException(ex);
}
}
- 关键点解析:
JobExecutionContext是什么?
它是 Quartz 在反射创建Invoker实例后,自动注入的"运行环境包"。它包含了本次调度的所有信息,包括我们在addJob阶段存入的JobDataMap。- 如何利用
ApplicationContext?
由于Invoker本身被 Spring 管理(通过@Component或SpringBeanJobFactory),它可以直接注入ApplicationContext。通过getBean(name)方法,我们将纯字符串的handlerName变成了活生生的 Spring Bean 对象。 - 统一管控能力的体现 :
日志记录和异常重试逻辑全部收敛在Invoker中。业务开发者无需在每个 Job 里写 try-catch 或记录日志,实现了真正的 AOP 效果。
3. 业务执行层 (JobHandler 实现)
这一层回归了最纯粹的 Spring 开发模式。
核心代码解析 (DemoJob):
java
// 定义统一接口,规范所有任务的入口
public interface JobHandler {
String execute(String param) throws Exception;
}
// 具体的业务实现
@Component("demoJob") // 必须指定 Bean 名称,与 addJob 时的 handlerName 对应
public class DemoJob implements JobHandler {
@Resource
private AdminUserMapper adminUserMapper; // 【优势】可以直接注入其他 Service/Mapper
@Override
@TenantJob // 支持自定义注解(如多租户切换)
public String execute(String param) {
// 这里完全不需要引用 Quartz 的任何类
System.out.println("执行业务逻辑,参数:" + param);
List<AdminUserDO> users = adminUserMapper.selectList();
return "处理用户数:" + users.size();
}
}
- 设计优势:
- 无侵入性 :
DemoJob不需要继承QuartzJobBean,也不需要处理JobExecutionContext。它只关心业务参数String param。 - 依赖注入 :因为它是标准的 Spring Bean,所有的
@Resource、@Transactional都能正常工作。
4. Web 接入层 (JobController & JobService)
它的核心职责不仅仅是接收 HTTP 请求,更重要的是保证本地业务数据Local DB 与Quartz 调度数据Quartz DB的一致性。
核心逻辑解析:
-
双重存储的设计哲学 :
你可能会发现,我们在
JobMapper(本地业务表infra_job)存了一份任务数据,然后又通过SchedulerManager往 Quartz(QRTZ_JOB_DETAILS)里存了一份。 -
为什么这么做? Quartz 的内部表(
QRTZ_开头)只适合给机器读,字段生涩且难以扩展。我们需要一张本地业务表来存储"任务描述"、"负责人"、"告警配置"等 Quartz 根本不关心的业务字段,同时也方便在管理后台进行 CRUD 展示。 -
创建流程的事务控制 (
JobService.createJob):java@Transactional(rollbackFor = Exception.class) // 【关键】开启事务 public Long createJob(JobSaveReqVO createReqVO) throws SchedulerException { // 1. 落库本地表 (Local DB) // 先将任务保存到 infra_job 表,状态标记为 INIT (初始化) JobDO job = BeanUtils.toBean(createReqVO, JobDO.class); job.setStatus(JobStatusEnum.INIT.getStatus()); jobMapper.insert(job); // 2. 提交给 Quartz (Quartz DB) // 调用 Manager,将精简后的参数 (ID, HandlerName, Cron) 写入 Quartz 表 // 如果这一步 Quartz 报错(例如 Cron 表达式非法),事务会回滚,本地表的记录也会消失 schedulerManager.addJob(job.getId(), job.getHandlerName(), ...); // 3. 确认生效 // Quartz 接受成功后,将本地表状态更新为 NORMAL (运行中) jobMapper.updateById(..., JobStatusEnum.NORMAL.getStatus()); } -
解析 :这里利用 Spring 的
@Transactional巧妙地绑定了两个异构系统的写入操作。它保证了"要么两边都有,要么两边都没有",防止出现"本地显示有任务,但 Quartz 实际没在跑"的幽灵任务。 -
启动自愈机制 (
init/syncJob) :在
JobServiceImpl中,你会看到一个@PostConstruct注解的方法。java@PostConstruct public void init() throws SchedulerException { // 防止 Quartz 表数据意外丢失或与本地表不一致 // 应用启动时,以本地表 (infra_job) 为准,强制刷新一遍 Quartz 中的任务 this.syncJob(); } -
这是工程化实践中的兜底策略。无论运维误删了 Quartz 表,还是数据库迁移导致数据丢失,只要应用重启,代码就会自动把"本地记录在案"的任务重新注册到 Quartz 中,大大增强了系统的健壮性。
第四章:Q&A
4.1 线程模型辨析
- Q:Quartz 线程池与 HTTP 请求(Web容器)线程池是同一个吗?
- A:绝对不是,它们是同一个 JVM 进程下两个完全物理隔离的线程池。 Web 容器(如 Tomcat)的线程池专门负责监听端口和响应外部 HTTP 请求,追求高并发与低延迟;而 Quartz 自己维护一个独立的线程池(通常是 SimpleThreadPool),仅听从调度器指挥去执行内部的定时任务逻辑,不处理任何网络请求。
4.2 架构模式对比
-
Q:代理分发模式(Invoker) vs 原生模式,优势在哪?
-
A :核心优势在于"完美解决 Spring 注入难题"和"统一管控能力"。
首先,它是 Spring 集成的最佳实践 。原生 Quartz 创建的 Job 对象不归 Spring 管理,导致
@Autowired注入 Service 时往往为空(NullPointer),需要复杂的额外配置。而代理模式下,实际执行业务的是标准的 Spring Bean,所有的依赖注入、事务注解(@Transactional)均可开箱即用。其次,它实现了架构的解耦与收敛 。原生模式需要为每个任务写一个独立的 Job 类,导致类数量爆炸且逻辑分散。代理模式通过一个
Invoker统一接管所有调度,不仅复用了现有的 Service 逻辑,还能在这个统一入口轻松实现全局的日志记录、异常捕获和失败重试,相当于为所有定时任务加了一层天然的 AOP 切面。
4.3 数据传递机制
- Q:JobDetail 和 Trigger 都有
usingJobData,区别是什么? - A :区别在于"作用域"和"优先级"。
作用域不同 :JobDetail中的数据是静态默认值 (相当于类成员变量),它跟随任务定义的生命周期,适用于所有触发场景;而Trigger中的数据是运行时参数 (相当于方法局部变量),它仅针对当前这一特定的调度规则生效。
优先级不同 :当两者存在同名 Key 时,Trigger 的数据会覆盖 JobDetail 的数据。这赋予了系统极大的灵活性:你可以定义一个通用的 Job 模板(如"发送邮件"),然后通过不同的 Trigger 传入不同的参数(如"发给用户A"或"发给管理员B"),实现"一个逻辑,多种用途"的复用。
4.4 集群原理:分布式锁机制
- Q:Quartz 集群是如何防止任务被重复执行的?
QRTZ_LOCKS表本身就是锁吗? - A :核心在于利用数据库的行级排他锁(Row Lock)实现"强制串行化"。
表的作用 :QRTZ_LOCKS表本身并不是锁,表中的记录(如TRIGGER_ACCESS)仅仅是所有节点共同争抢的物理锚点 。
锁的本质 :Quartz 通过执行SELECT ... FOR UPDATE语句,迫使数据库锁定该行记录。此时,其他所有试图获取任务的节点都会在数据库层面被阻塞(Block) ,直到当前节点修改完 Trigger 状态(WAITING->ACQUIRED)并提交事务。
代价 :这是一种全局粗粒度锁。即使节点 A 和节点 B 想要获取的是完全不相关的两个任务,它们在"抢锁"这一步也必须排队,这保证了绝对的数据安全,但也限制了超高并发下的吞吐量。
4.5 配置辨析:锁表数据的可配置性
- Q:
QRTZ_LOCKS表中的SCHED_NAME和LOCK_NAME字段值可以自定义吗? - A :
SCHED_NAME可以(且建议)配置,但LOCK_NAME是硬编码常量。
SCHED_NAME(调度器名称) :可配置 。通过org.quartz.scheduler.instanceName设置。它的作用是实现命名空间隔离 ,允许在同一个数据库中同时运行多套隔离的 Quartz 集群(例如"订单调度器"和"报表调度器")。它们通过不同的SCHED_NAME区分各自的锁和任务,互不干扰,就像在一个大商场里开了两家独立的店铺。
LOCK_NAME(锁名称) :不可配置 。TRIGGER_ACCESS和STATE_ACCESS是 Quartz 源码内部硬编码的协议常量。引擎在运行时会精确匹配这两个字符串来争抢锁资源,如果手动修改数据库中的这些值,引擎发出的 SQL 将无法匹配到记录,导致锁机制完全失效。