☰
Spring Boot整合Quartz实战:从@Scheduled迁移到动态调度与集群部署
2026/10/14 4:03:58 网站建设 项目流程

这阵子手头一个系统改造,定时任务这块让我彻底换了个思路。以前图省事,全部用@Scheduled硬写,跑得也挺好。直到业务方提出三个要求:任务要能在管理后台动态创建、随时暂停、以后还要多节点部署不能重复执行。@Scheduled的先天不足一下就暴露了。换到Spring Boot整合Quartz,是Java生态里最顺理成章的选择。这篇不是我抄官方文档的笔记,而是基于自己整个落地过程整理的实战记录,从最小依赖配置到固定任务、动态调度、JDBC持久化、集群部署,最后是踩坑总结。适合两类人:一是刚接触Quartz,想直接把它以正确姿势接进Spring Boot的开发者;二是已经能跑固定任务,但正琢磨动态管理和集群化的团队。

1. 从@Scheduled到Quartz:你需要换调度引擎的信号

1.1 @Scheduled到底缺了什么

@Scheduled用起来确实舒服,一个注解就能定时执行。可它的问题在于——调度规则写死在代码里,生命周期不受控。业务没复杂到一定程度,这些短板能忍;一旦复杂起来,每一处都让人难受。

举我实际遇到的例子。有个积分结算任务,最初需求很简单:每天凌晨2点全量跑一次。后来运营搞大促,要求临时改成每半小时结算一次,活动结束再改回每天。每次我都要改cron表达式、重新打包、走发布流程。第三次被要求改时间的时候,运营直接说:“能不能在后台给我一个输入框,我自己填执行时间?”

这句话就是需求警报:你需要把“做什么”和“什么时候做”两个维度拆开,让调度规则具备运行时可变能力。@Scheduled做不到。

1.2 Quartz的核心竞争力

Quartz本身就是一个独立的作业调度框架,它补的正是@Scheduled这几个缺:

  • 调度与业务解耦:Job只写业务逻辑,JobDetail和Trigger负责注册与触发规则,完全可以运行时动态构建
  • 触发方式丰富:CronTrigger、SimpleTrigger、CalendarIntervalTrigger、DailyTimeIntervalTrigger,每种还带misfire补偿策略
  • 支持持久化:基于JDBC的JobStore可以把调度数据存进数据库,应用重启任务不丢
  • 内置集群机制:利用数据库行锁保证多节点部署时,同一个任务在同一时刻只有一个节点执行
  • 可插拔线程池:默认线程池可调,也可以根据场景替换

一条条看,这些不是花架子,全是真实业务系统演进过程中绕不开的硬需求。

1.3 一个关键的前提认知:Quartz与Spring的职责边界

初学者最容易栽在这一点上:Quartz只负责“什么时间触发哪个Job”,它根本不关心Spring的Bean管理。反过来,你的业务Service又归Spring管理,需要依赖注入。这两套体系天生互不相识,整合Spring Boot的本质就是搭一座桥——让Quartz创建的Job实例能拿到Spring容器里的Bean。这块桥没搭好,就会出现Job里Autowired为null这种灵异问题。后面第4部分我会详细写这座桥怎么搭,先把这个认知放脑子里。

写代码前先看两张表,把二者在关键场景的差别记住:

场景@ScheduledQuartz
固定写死的周期任务够用够用
动态修改执行时间需要改代码发版运行时改Trigger
暂停/恢复任务支持有限scheduler.pause/resume
多实例部署防重复需要自己加分布式锁JDBC JobStore集群内置
失败重试和补跑语义基本没有misfire策略 + 持久化

2. 环境准备:starter依赖与配置文件里的关键开关

2.1 引入依赖要留意Spring Boot版本

Spring Boot给我们铺好了路,一个starter就能把调度引擎带进来:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-quartz</artifactId> </dependency>

但这里要提醒你一个版本坑。Spring Boot 2.x底层对应Quartz 2.3.x,包名是javax。Spring Boot 3.2以后已经把底层的Quartz升到了3.x,包名换成了jakarta。如果你只是用starter,这些差异框架都帮你处理了;但如果因为某个扩展点需要手动引入Quartz原生依赖,一定要让版本和Boot匹配。我见过有人在Boot 3.1项目里手动加Quartz 3.0的jar,结果和starter内置版本冲突,启动就是NoSuchMethodError。

2.2 application.yml里的最小配置

先把最小配置跑起来,后面再逐个展开:

spring: quartz: job-store-type: memory auto-startup: true properties: org.quartz.scheduler.instanceName: MyScheduler org.quartz.scheduler.instanceId: AUTO org.quartz.threadPool.threadCount: 10 org.quartz.threadPool.threadPriority: 5

几个关键点:

  • job-store-type:memory对应RAMJobStore,任务配置在内存里,重启就丢,开发阶段用起来很爽;jdbc对应JDBCJobStore,生产环境必须切过去
  • org.quartz.scheduler.instanceName:调度器实例名,内存模式下无所谓,集群模式下同一个集群里的所有节点必须一致
  • org.quartz.scheduler.instanceId:单机可以写固定值,集群必须写AUTO,让Quartz从数据库自动生成唯一实例ID
  • threadPool.threadCount:Quartz专用线程池大小,默认10。千万别急着调大,后面实测部分我会说为什么

注意:spring.quartz.properties前缀下的所有配置都以org.quartz开头,这是Quartz原生Properties的映射,优先级很高。调试时如果发现代码里设置的参数没生效,先检查这里有没有同名配置。

2.3 数据源的隐藏依赖

job-store-type选择jdbc后,如果没有DataSource,应用启动时不会立刻报错,但你一保存任务就会在JobStore层抛异常。Spring Boot的Quartz自动配置会就近使用上下文中已有的DataSource,所以只要你有spring.datasource配置就行,不用单独给Quartz指定数据源。但如果项目里有多个数据源,自动配置就没法自动选择了,你需要自己定义@QuartzDataSource来指定Quartz专用数据源。这个我留到第6部分细说。

3. 弄懂四个核心接口,Quartz就懂了一半

3.1 把调度系统拆成三个角色来理解

很多教程上来就贴代码,代码里的名词却把人绕晕:Scheduler、Job、JobDetail、Trigger、JobBuilder、TriggerBuilder、JobDataMap……其实核心就三个角色:做什么、怎么触发、谁来统一管。

  • Job:真正要执行的业务逻辑。你实现org.quartz.Job接口,在execute方法里干活
  • JobDetail:Job的“档案”。它给Job取名、分组、绑定要执行的Job类、携带初始参数
  • Trigger:触发规则。决定Job在什么时间点被执行。CronTrigger对应cron表达式,SimpleTrigger对应固定间隔和次数

Scheduler是总指挥官,它统一管理所有JobDetail和Trigger,负责注册、触发、暂停、删除等一切操作。

3.2 用一句话记住API关系

把Job想成“干活的人”,JobDetail是这个人的“档案”,Trigger是“闹钟”,Scheduler是“管理中心”。你把档案和闹钟都交到管理中心登记,到点管理中心就会喊档案里的人去干活。Quartz所有操作,本质上都是在操作这四样东西。

3.3 三种常用Trigger的选型

Trigger类型典型场景核心构造
CronTrigger每天早上9点、每周一三五执行CronScheduleBuilder.cronSchedule("0 0 9 * * ?")
SimpleTrigger每5秒一次、执行20次SimpleScheduleBuilder.repeatSecondlyForTotalCount(20, 5)
DailyTimeIntervalTrigger工作日17点到18点每10分钟一次DailyTimeIntervalScheduleBuilder...

我项目里90%的调度都用CronTrigger,因为业务需求几乎都靠cron表达式表达。只有两种场景我特意换SimpleTrigger:一种是需要严格控制总执行次数,比如重试3次自动停止;另一种是频率固定、没有精确时间点,比如每30秒刷一次缓存。

3.4 JobDataMap:给任务传参数的正确姿势

给Job传参数,最忌讳的做法是把参数拼进成员变量,指望Quartz复用同一个实例。Quartz默认每次触发都会通过反射new一个全新的Job实例,所以成员变量完全不可靠。正确姿势是用JobDataMap:

  • 构建JobDetail时调用.usingJobData("key", value)塞参数
  • Job执行时通过context.getMergedJobDataMap().getString("key")取参数

注意它叫“merged”——Trigger上的JobDataMap和JobDetail上的JobDataMap会合并,Trigger优先级更高。做动态任务时我的习惯是:JobDetail存放任务本身的信息,比如任务名、参数;Trigger上存放本次触发的临时信息,比如手动触发的来源标记。这么分层,管理起来非常清晰。

4. 第一版整合:配置一个固定任务的完整代码

4.1 Job实现类怎么写

先定义业务任务,这里用积分结算做例子:

@Component @DisallowConcurrentExecution public class PointsSettlementJob implements Job { @Autowired private PointsService pointsService; @Override public void execute(JobExecutionContext context) throws JobExecutionException { String jobName = context.getJobDetail().getKey().getName(); log.info("任务[{}]开始执行", jobName); try { pointsService.settleDailyPoints(); } catch (Exception e) { log.error("积分结算失败", e); throw new JobExecutionException(e); } } }

两个细节必须说:

  • @Component不写也能跑,但如果要往Job里注入Spring的Service,这个注解就很有用了,配合第4.3节的jobFactory设置
  • @DisallowConcurrentExecution强烈建议加上:它保证同一时间只允许该Job的一个实例执行。如果任务执行时间比触发间隔长,而没有这个注解,Quartz会并发再起一个实例跑同一个任务。缓存清理、对账这类任务一旦并发,轻则重复处理,重则锁表。我这个注解几乎逢任务必加。

4.2 配置JobDetail和Trigger

固定任务直接用@Bean声明:

@Configuration public class QuartzConfig { @Bean public JobDetail pointsSettlementJobDetail() { return JobBuilder.newJob(PointsSettlementJob.class) .withIdentity("pointsSettlementJob", "bizGroup") .withDescription("每日积分结算") .storeDurably() .build(); } @Bean public Trigger pointsSettlementTrigger(@Qualifier("pointsSettlementJobDetail") JobDetail jobDetail) { return TriggerBuilder.newTrigger() .forJob(jobDetail) .withIdentity("pointsSettlementTrigger", "bizGroup") .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?")) .build(); } }

Spring Boot会自动感知容器里的JobDetail和Trigger Bean,把它们注册进Scheduler。

4.3 让Job能够注入Spring Bean:桥怎么搭

直接抄上面的代码去跑,你大概率会遇到一个诡异场景:Job里@Autowired注入的Service有时有值、有时为null。原因就是Quartz在创建Job实例时,走的是JobDetail里指定的Class反射newInstance,根本不经过Spring的容器管理。除非你给Scheduler配置了自定义的JobFactory,让它在创建实例后主动去Spring容器里补齐依赖。

用Spring Boot的starter时,自动配置其实已经帮你把这个jobFactory设置好了。但如果你是自己手动new的SchedulerFactoryBean,就得自己搭桥:

@Bean public SchedulerFactoryBean schedulerFactoryBean(AutowireCapableBeanFactory beanFactory) { SchedulerFactoryBean factory = new SchedulerFactoryBean(); factory.setJobFactory(new SpringBeanJobFactory() { @Override protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception { Object job = super.createJobInstance(bundle); beanFactory.autowireBean(job); return job; } }); return factory; }

这里的顺序很关键:Quartz先反射创建Job实例,Spring再调用autowireBean把依赖填进去。先有实例,再有注入,顺序反了就报错。

4.4 启动验证看什么

启动应用,留意日志。正常会看到类似Quartz Scheduler v2.3.x created的启动信息,然后到点任务日志就打出来了。唯一要注意的是:Quartz的cron表达式跟Spring的@Scheduled有区别。最典型的是Quartz必须写“秒”位,星期字段用1~7表示周日到周六,整体多一位。比如“每5分钟一次”:

  • Spring的写法:0 0/5 * * * ?
  • Quartz的写法:0 0/5 * * * ?

如果你把某个不带“秒”位的cron直接丢给Quartz,会看到类似Support for specifying both a day-of-week AND a day-of-month parameter is not implemented的报错。

5. 把调度管理权交给业务方:动态增删改查任务

5.1 为什么固定配置最终要走向动态调度

固定任务配置好,只是第一步。前面提到的积分结算那个项目,运营最后真上了后台管理页面。她们需要一个界面:创建一次性活动任务、调整已有任务周期、临时暂停某个报表生成。这些需求背后只有一个要求——调度信息不能写死在配置类里,而要存进数据库,由运行时代码去操作Scheduler。

我在一张任务表里存了jobName、jobGroup、cron表达式、Job类全限定名、状态字段。管理端每次增删改都操作这张表,同时同步调用Scheduler的API,两边保持一致。

5.2 动态注册任务的Service

核心代码如下:

@Service public class QuartzManagerService { private static final String TRIGGER_GROUP = "dynamicGroup"; private static final String JOB_GROUP = "dynamicGroup"; private final Scheduler scheduler; public QuartzManagerService(Scheduler scheduler) { this.scheduler = scheduler; } public void addCronJob(String jobKey, Class<? extends Job> jobClass, String cron, Map<String, Object> params) throws SchedulerException { if (scheduler.checkExists(JobKey.jobKey(jobKey, JOB_GROUP))) { throw new IllegalArgumentException("任务已存在: " + jobKey); } JobDetail jobDetail = JobBuilder.newJob(jobClass) .withIdentity(jobKey, JOB_GROUP) .storeDurably() .setJobData(new JobDataMap(params)) .build(); CronTrigger trigger = TriggerBuilder.newTrigger() .withIdentity(jobKey + "_trigger", TRIGGER_GROUP) .forJob(jobDetail) .withSchedule(CronScheduleBuilder.cronSchedule(cron) .withMisfireHandlingInstructionDoNothing()) .build(); scheduler.scheduleJob(jobDetail, trigger); } public void updateCronJob(String jobKey, String cron) throws SchedulerException { TriggerKey triggerKey = TriggerKey.triggerKey(jobKey + "_trigger", TRIGGER_GROUP); CronTrigger newTrigger = TriggerBuilder.newTrigger() .withIdentity(triggerKey) .forJob(JobKey.jobKey(jobKey, JOB_GROUP)) .withSchedule(CronScheduleBuilder.cronSchedule(cron)) .build(); scheduler.rescheduleJob(triggerKey, newTrigger); } public void pauseJob(String jobKey) throws SchedulerException { scheduler.pauseJob(JobKey.jobKey(jobKey, JOB_GROUP)); } public void resumeJob(String jobKey) throws SchedulerException { scheduler.resumeJob(JobKey.jobKey(jobKey, JOB_GROUP)); } public void deleteJob(String jobKey) throws SchedulerException { scheduler.deleteJob(JobKey.jobKey(jobKey, JOB_GROUP)); } }

关键点逐一说明:

  • storeDurably():表示没有关联Trigger时也保留JobDetail。动态场景下经常要删掉Trigger但保Job,这个设置能防止误删
  • checkExists防重复:如果业务场景允许覆盖,就先delete再add,或者干脆直接调用scheduleJob前判断
  • triggerKey命名:我用jobKey + "_trigger"拼接,好处是从任务key就能算出triggerKey,不用维护额外的关联关系
  • 更新cron用rescheduleJob:替换Trigger后,Job实例不重建,正在执行的实例不受影响,下次触发走新时间

5.3 时序问题,别忽略深坑

动态调度有个特别容易踩的时序问题。业务方在凌晨1点58分把一个原定2点执行的任务改成了3点执行。当时Quartz的rescheduleJob会怎么处理?如果新Trigger的下次执行时间早于当前时刻,它会立即安排一次触发。也就是说,可能出现“刚改完配置就立马执行了一次”的现象。如果不希望这样,管理界面上应该禁止对即将触发任务做修改,或者加一个“下次执行时间确认”的交互提醒。

还有misfire策略的坑。运营在活动开跑前5分钟把任务cron从“每5分钟”改成“每10分钟”,由于我配置的是withMisfireHandlingInstructionDoNothing,历史错过的触发全部跳过不补跑。但如果换成withMisfireHandlingInstructionIgnoreMisfires,Quartz会先把错过的触发补执行,再接着当前时间继续,结果就是运营看到刚改完配置,任务在短时间内连续跑了好几次。对账类任务遇到这种补跑就是灾难。我的建议:绝大多数场景用DoNothing,除非你明确知道任务需要补偿执行。

5.4 任务状态怎么同步到数据库

Scheduler的pause/resume只影响内存里的调度器状态,重启后会丢。如果业务上要知道任务的启停状态,我自己维护了一个状态字段,每次管理端操作成功后同步更新数据库。这里要特别区分两类状态:JobStore里的调度状态和业务状态。比如一个任务调度状态是运行中,但业务上因上游系统故障想暂停几天,那就应该修改业务状态字段,同时调用scheduler的pauseJob挂起Trigger,防止两端不同步。很多失败的调度管理,就是没想清楚这两层状态的差异。

6. 持久化与集群:任务不丢、不重复执行的进阶配置

6.1 从memory切到jdbc

开发阶段用memory挺舒服,重启就丢无心理负担。但生产环境必须可靠:应用宕机、发版重启、多节点部署,任务数据一个都不能丢。这时候就要切JDBC JobStore。

spring: quartz: job-store-type: jdbc jdbc: initialize-schema: never properties: org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate

先说一个极容易踩的坑:Spring Boot不会自动创建Quartz的数据库表。官方文档明确说了,Quartz的schema不会自动执行,需要你自己手动初始化。SQL脚本在Quartz jar包里的org/quartz/impl/jdbcjobstore/目录下,MySQL对应tables_mysql_innodb.sql。执行后生成11张表,最常用的是QRTZ_JOB_DETAILS、QRTZ_TRIGGERS、QRTZ_CRON_TRIGGERS、QRTZ_SIMPLE_TRIGGERS、QRTZ_BLOB_TRIGGERS、QRTZ_LOCKS。

其中QRTZ_LOCKS表是集群锁的核心,Quartz靠它保证多个节点不会同时触发同一个Job。这张表默认只有一行两列:LOCK_NAME和LOCK_TYPE。有人清理库表时手动删了它,结果集群模式直接失效,出现各种锁超时和重复触发。

6.2 集群配置三板斧

spring: quartz: job-store-type: jdbc properties: org.quartz.scheduler.instanceName: ClusterScheduler org.quartz.scheduler.instanceId: AUTO org.quartz.jobStore.isClustered: true org.quartz.jobStore.clusterCheckinInterval: 15000 org.quartz.jobStore.tablePrefix: QRTZ_

三个关键配置必须说透:

  • instanceName:同一个集群里所有节点必须完全一致,不一致Quartz不认为它们属于同一个调度器
  • instanceId = AUTO:每个节点启动时从数据库生成唯一ID,保证集群内不冲突
  • isClustered = true:开启集群模式,调度器的任务获取和锁管理全部走数据库

6.3 集群部署后的真实变化

集群模式下的行为,很多人在理解上有偏差。它做的是:所有节点共享同一份任务表,任意节点都能调度所有任务;触发时刻,Quartz通过数据库行锁让抢到锁的节点执行;某个节点宕机,不影响其他节点继续调度;同一时刻只有一个节点执行,而不是所有节点都执行。

实测下来,3节点集群在正常网络下完全够用。需要注意:节点时钟差异不能太大,超过clusterCheckinInterval的一半就会出现节点互相误判宕机的现象。部署环境一定要统一做系统时间同步。

还有一个与数据源隔离级别相关的坑:集群模式下Quartz执行锁查询对事务要求比较高,MySQL默认REPEATABLE READ没问题,但如果你在数据源配置里把事务隔离级别改成了READ COMMITTED,或者连接池设置了readOnly等奇葩参数,Quartz获取锁的时候可能出现死锁。排查这类问题,优先看QRTZ_LOCKS相关日志,然后检查数据源配置。

6.4 动态任务在持久化模式下的注意点

切到JDBC JobStore后,动态任务的Job类必须是public且有无参构造器,否则数据库反序列化那一步会炸。Job类里依赖的Spring Bean照常注入,但要留神:如果代码里删掉了某个Job类,数据库里还有对应的JobDetail记录,Quartz启动时反序列化会直接报错。所以删除无用任务时,一定先清理数据库记录,再删代码,顺序不能反。

7. 实测总结:我踩过的坑和给你的建议

7.1 线程池配置不当引发的雪崩

一次我手贱把threadCount从10调到50,觉得任务这么多,线程多点肯定快。结果任务里有大量数据库操作,数据源连接池只有20个连接,50个任务同时跑起来全在等数据库连接,应用整体变慢,接口跟着遭殃。Quartz的线程池不是越大越好,要跟数据源连接池大小、任务自身耗时匹配。我的恢复经验:threadCount先按默认10用,除非确定任务多数是CPU密集且不依赖外部IO。任务量大应该做队列和分批,而不是单纯加线程。

7.2 Job里拿不到Spring Bean的排查思路

如果你没有用Spring Boot的starter,而是自己手写SchedulerFactoryBean,最容易遇到的就是Job里@Autowired为null。先别怀疑Quartz,先确认SchedulerFactoryBean里有没有配置JobFactory。没有它,Spring生命周期根本不会走到Job实例上。我遇到过最迷惑的现象:任务偶尔执行成功、偶尔NPE,最后发现是因为没有加@DisallowConcurrentExecution,Quartz并发生成了两个Job实例,一个被Spring注入过,一个没注入。加上防并发注解并正确配置JobFactory后,问题彻底消失。

7.3 任务执行时间比触发周期还长怎么办

加上@DisallowConcurrentExecution,同一个Job上一轮没执行完时,触发事件会延后处理,注意不是跳过,而是排队等锁。延后时间超过misfire阈值就会触发misfire策略。所以光加注解还不够,要把misfire策略设置明白,并且在任务内部做好“上一次未完成就跳过本次”的判定逻辑。我在代码里通常用一个分布式锁或者任务表的状态字段做标记,双重保险。

7.4 集成思路上的最终建议

如果只是固定几个定时任务,用@Scheduled确实更轻。但一旦有动态管理、状态查询、集群部署需求,直接上Quartz,别想着以后再迁。从内存调度迁移到JDBC调度,最好的时机是在项目初期就把表结构和代码分层规划好。我自己落地这套东西的路径是:先单机memory模式跑固定任务,同时把调度管理Service写好;逻辑稳定后再切jdbc、上集群。每一步改动范围都小,回退成本也低。整个过程走完,收获最大的不是Quartz那些API怎么用,而是想清楚了一个系统里这种横向能力,应该在架构层面怎么设计,才能撑着业务一路跑下去。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询