定时任务这个东西,入门版本人人都会: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 没有全局视野,每台机器的内存里都有自己那份任务清单,每台都会执行一遍------同一批订单被处理两次。关单这种操作重复执行可能问题不大,但换成"给客户发催收短信",就是双倍打扰。
把两个死法翻译成两个问题,就是定时任务的真正考题:
- 任务清单存在哪?(内存 → 重启就没)
- 谁来执行?(每台机器各自为政 → 重复执行)
方案一: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 运行流程
- 启动应用,Spring Boot 自动配置调度器,Quartz 连接数据库(
QRTZ_表); QuartzConfig里的 JobDetail 和 Trigger 被注册进调度器;- 每 5 分钟,Quartz 调用一次
UnpaidOrderJob.execute(); - 任务信息持久化在数据库,重启后自动恢复 ;多机部署时集群锁保证同一任务只被一个节点执行。
两个承诺,到此全部兑现。
四、新手最容易踩的坑:全是承诺的代价
把常见坑列出来,每一条问一句"为什么会有这个坑",答案几乎都指向那两个承诺------持久化意味着 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。没有免费的定时任务,只有你还没被问到的两个问题。