☰
Spring Boot集成Quartz:从@Scheduled到动态集群调度实践
2026/10/15 22:48:34 网站建设 项目流程

1. 为什么我在项目里用Quartz替换掉Spring自带的@Scheduled

一提到定时任务,Spring全家桶用户的第一反应往往是@Scheduled。这个注解确实简单,写一个方法加个注解就行,项目里的小需求完全够用。可一旦任务数量上来了、业务开始复杂,它那几个天生短板就会变成硬伤。最典型的三点:

  1. 运行状态全在内存里。应用一重启,任务就没了,下次触发时间重新算,没法保证“错过了就补”这种需求。
  2. 不能在运行期动态调整。CRON表达式是写死在代码里的,想改成每分钟一次,只能改代码重新发版。
  3. 没有集群感知。多个实例同时跑,同一个任务会每台机器各执行一次,没有分布式锁的概念。

我参与过一个内部运营系统,里面大概有二十几个定时任务,从凌晨的数据汇总到每个整点的缓存刷新都有。初期用@Scheduled完全没问题,可后来增加了一个“运营人员在前台页面直接新建定时任务,输入CRON表达式,绑定某个处理逻辑”的需求,这就尴尬了——因为每次CRON表达式变化都得改代码,任务的新增删除也无法热生效,更别提找个地方把任务历史执行记录存下来。

于是才认真考虑上Quartz。这个东西在Java领域算是老牌调度库了,和Spring没有任何绑定关系,纯Java就能跑。但它的核心设计很有针对性:把“干什么”和“什么时候干”彻底分开。Job是任务本体,Trigger是触发规则,Scheduler是总指挥。这个模型天然支持把规则存进数据库、在运行期修改规则、多个节点协同调度。Spring Boot从2.0开始还专门做了spring-boot-starter-quartz这个starter,把Bean装配的活儿基本做完了,接入成本比早年直接写Quartz原生配置低了一大截。

这篇就完整记录一下我这套整合过程,从依赖配置到持久化,再到现在能动态增删任务、支撑集群部署的方案。如果你现在的项目还在用@Scheduled,而且已经出现了“任务越来越多、改动越来越频繁、跑挂了没人知道”的苗头,可以考虑直接切过来。

实际上,在正式引入Quartz之前,我顺手验证过一个更轻的方案——在@Scheduled之外加一张任务配置表,启动的时候反射扫一遍需要调度的方法,再配合动态代理去刷新调度周期。做了一半发现这套东西其实就是在重造Quartz的轮子:定时触发要自己算、并发控制要自己写、持久化要自己搞。于是果断止损,老老实实接Quartz。

2. 最小可用整合:依赖、Bean配置与第一个Job跑通

2.1 依赖引入与Spring Boot版本的处理

Spring Boot 2.x和3.x都能用这个starter。我用的是Spring Boot 2.7.x配合Quartz 2.3.2(starter里自带的版本)。如果用的是Spring Boot 3.x,Quartz会升级到2.4.x以上,API层面基本兼容,但要注意javax到jakarta的包名变化,涉及定时任务里注入Service的代码要格外小心,不过这属于编译期就能暴露的问题,IDE一查就知道。

只需要引入一个starter:

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

这个starter会顺带把spring-context-support引进来,Quartz调度器和Spring的Bean管理就能打通了。这里有个前提要记住:spring-boot-starter-quartz是Spring Boot提供的统一封装,它把Quartz的调度器工厂、任务类的实例化方式都接管了,所以理论上我们自己写SchedulerFactoryBean的代码可以省略,直接用自动配置就行。

2.2 写一个Job的标准姿势

Quartz里的任务核心是一个接口:org.quartz.Job,只有一个方法execute(JobExecutionContext context)。Spring为了让我们能在Job里注入自己的Service,专门提供了QuartzJobBean这个抽象类,它会通过Spring容器来创建Job实例,而不是反射调用无参构造器。这一点非常关键,稍后我们会专门说到。

先写一个最简单的任务:

@Component public class SampleJob extends QuartzJobBean { @Override protected void executeInternal(JobExecutionContext context) throws JobExecutionException { // 模拟业务处理:从上下文里取出传入的自定义参数 String jobName = context.getJobDetail().getKey().getName(); JobDataMap dataMap = context.getMergedJobDataMap(); System.out.println("Job [" + jobName + "] 开始执行,参数: " + dataMap.getString("batchNo")); } }

注意QuartzJobBean底层还是通过execute方法调用executeInternal,这是框架替我们做好的转换。

然后定义JobDetail和Trigger:

@Configuration public class QuartzConfig { @Bean public JobDetail sampleJobDetail() { JobDataMap jobDataMap = new JobDataMap(); jobDataMap.put("batchNo", "20250309-001"); return JobBuilder.newJob(SampleJob.class) .withIdentity("sampleJob") .setJobData(jobDataMap) .storeDurably(true) .build(); } @Bean public Trigger sampleJobTrigger() { return TriggerBuilder.newTrigger() .forJob("sampleJob") .withIdentity("sampleTrigger") .withSchedule(CronScheduleBuilder.cronSchedule("0 0/1 * * * ?")) .build(); } }

关键点有几个:

  • withIdentity给任务和触发器都起了名字,这个名字在Quartz内部是主键,持久化后存库,集群时争抢也靠它区分。
  • storeDurably(true):把没有关联Trigger的JobDetail保存下来,这为后面的动态新增任务打基础。
  • Cron表达式用的是Quartz七段式,最后多了一个?,比Spring的@Scheduled六段式多了一位秒。网上很多新手把CRON直接复制过来、最后一位没有?,运行时倒是不会报错,但规则会跟你想要的不一致。

写完这两个Bean,Spring Boot自动配置的SchedulerFactoryBean会自动把它们组装进Scheduler。任务就会按每分钟一次的节奏跑起来。这个时候我们已经完成了最基础的工作——一个能跑的Quartz任务。

2.3 使用@Component和@Bean各自会发生什么

这段有必要单独说,因为这是整合时第一个容易迷路的点。

如果你把SampleJob用@Component标注,同时又在@Configuration里通过new SampleJob的方式构造JobDetail,那么Quartz在执行的时候,会向Spring询问SampleJob的Bean,并优先使用Spring容器中的实例。这会和我们手上这个new出来的JobDetail产生一个让人困惑的差异:JobDetail里引用的Job类名一致,但实例来源不同。

具体来说,spring-boot-starter-quartz默认会通过Spring容器来解析Job实例。最终效果:即便你把SampleJob声明为@Component,触发器激活的那一瞬间,Spring依然会用ApplicationContext.getBean(SampleJob.class)去获取一个Bean来执行executeInternal。如果类路径上存在多个候选Bean,可能启动就直接报错。

所以实际项目中我的习惯是:像SampleJob这种会被多个JobDetail引用的任务,直接不标@Component,通过JobBuilder.newJob(xxxJob.class)传入类型,交给Quartz维护一份自己的实例。需要注入依赖时,在Job里直接@Autowired即可,因为Spring还是会帮我们做依赖处理。至于单例还是多例的问题,Quartz默认每个JobDetail在每次执行时都会创建一个新的Job实例,相当于prototype。

不过这个默认行为也直接引出了下一个坑:**无状态Job不带锁,同一时刻如果上一次还没跑完,下一次触发又到了,会并发执行。**业务上如果不想重复处理数据,务必在Job类上标注@DisallowConcurrentExecution,并且考虑使用带状态的持久化存储配合锁。

3. 生产级关键一步:JDBC持久化与重复触发问题

3.1 先搞清Quartz的RAM和JDBC两种存储

默认情况下,Quartz的任务明细保存在内存里,叫RAMJobStore。重启后任务配置全部丢失,触发记录也没了。生产上绝对不能这样。Quartz自带JDBC JobStore,把JobDetail、Trigger、Calendar、执行日志全部落到数据库表里。一旦持久化之后,应用重启或者某台机器挂了,调度信息依然存在,集群中存活的节点可以继续接管。

要把JDBC存储打开,需要两样东西:对应的数据库表和一份quartz.properties配置。

建表脚本Quartz官方自带,在quartz jar包里能翻到,路径大概是org/quartz/impl/jdbcjobstore/,里面有各种数据库方言的建表SQL,比如tables_mysql_innodb.sql。注意:不同Quartz版本脚本略有差异,一定要解压当前引用的jar包去取对应脚本,不要从网上随便复制一个旧版本的,否则可能出现字段缺失、创建索引失败。

把Quartz十张核心表的概念先讲清楚,这对后面排查集群问题帮助特别大:

  • QRTZ_JOB_DETAILS:JobDetail明细,存放任务类型、描述和JobDataMap序列化结果。
  • QRTZ_TRIGGERS:触发器主表,记录触发器类型、前后屏蔽时间、状态、关联的JobDetail。
  • QRTZ_CRON_TRIGGERS:Cron触发器专用表,存CRON表达式和时区。
  • QRTZ_SIMPLE_TRIGGERS:Simple触发器专用表,存重复次数、间隔。
  • QRTZ_BLOB_TRIGGERS:触发器Blob类型,存自定义触发器的二进制数据。
  • QRTZ_FIRED_TRIGGERS:正在执行的触发器记录,执行完清除。
  • QRTZ_SCHEDULER_STATE:集群节点心跳状态表,配合SCHED_NAME实现集群感知。
  • QRTZ_LOCKS:行级锁表,使用数据库锁实现分布式锁。
  • QRTZ_PAUSED_TRIGGER_GRPS:暂停的触发器组。
  • QRTZ_CALENDARS:日历,用于排除非工作日等场景。

这些表本质上就是Quartz运行时的完整状态集。搞懂它们之后,我们就知道数据库里哪些字段是可以在线修改、从而影响调度的。

3.2 quartz.properties配置实操

在src/main/resources下新增一个quartz.properties:

org.quartz.scheduler.instanceName = MyScheduler org.quartz.scheduler.instanceId = AUTO org.quartz.scheduler.skipUpdateCheck = true org.quartz.threadPool.class = org.quartz.simpl.SimpleThreadPool org.quartz.threadPool.threadCount = 10 org.quartz.threadPool.threadPriority = 5 org.quartz.jobStore.class = org.quartz.impl.jdbcjobstore.JobStoreTX org.quartz.jobStore.driverDelegateClass = org.quartz.impl.jdbcjobstore.StdJDBCDelegate org.quartz.jobStore.tablePrefix = QRTZ_ org.quartz.jobStore.misfireThreshold = 120000 org.quartz.jobStore.isClustered = true org.quartz.jobStore.clusterCheckinInterval = 15000 org.quartz.jobStore.txIsolationLevelSerializable = true org.quartz.dataSource.myQuartzDS.driver = com.mysql.cj.jdbc.Driver org.quartz.dataSource.myQuartzDS.URL = jdbc:mysql://127.0.0.1:3306/quartz?useSSL=false&characterEncoding=utf8 org.quartz.dataSource.myQuartzDS.user = root org.quartz.dataSource.myQuartzDS.password = xxxxxx org.quartz.dataSource.myQuartzDS.maxConnections = 10

注意,Quartz自己维护了一套独立数据源配置,和Spring的DataSource互不干扰。至于是否要和业务库共用数据源,我的建议是单独建一个quartz库或独立schema,避免业务锁和Quartz锁互相影响。比如QRTZ_LOCKS表的行锁粒度很粗,同一时刻一个节点只能跑有限的调度操作,如果混在业务库,高峰期可能出现莫名的锁等待。

配置项里最容易被忽略的是misfireThreshold,也就是“触发迟到多久算misfire(触发丢失)”。默认60000毫秒。如果任务很短又很多,建议调大一些到两分钟。否则应用GC停顿或者某台机器暂时无响应时,恢复后Quartz发现之前有大量trigger“错过”触发时间,会按照MISFIRE策略猛补跑一次甚至几次,一点都不符合预期。

3.3 持久化后,我踩过的“任务重复执行”的坑

这个踩坑经历值得记录下来。某次我给一个内部数据同步任务升级到JDBC存储,顺手把并发限制加上了@DisallowConcurrentExecution,本以为万事大吉。结果上线第二天凌晨,发现同一批数据被两个实例同时处理了。看日志发现两个Pod都执行了,而且都认为自己拿到了锁。

原因要拆成两层:

第一,@DisallowConcurrentExecution的语义是通过JobDetail维度的锁来避免同一个JobDetail在同一时刻被并发执行。但是集群环境下,如果仅仅用了RAM存储或者JDBC存储配置里没有开启isClustered=true,那么两个节点各自持有调度器,彼此感知不到对方,这个注解的锁只在单进程内有效,跨进程就失效了。

第二,我当时明明配了isClustered=true,为什么还重复?查下去发现是数据源指向了不同schema。我的Quartz数据源配置里URL写的是127.0.0.1:3306/quartz,而另一个环境的配置是node1的本地库。两个节点的Scheduler实际上各自连着独立的quartz库,互相之间完全不可见。它们都认为自己是唯一的调度器。

确认原因后就好办了,把两个节点指向同一个数据库连接,再确认每台机器启动时日志中出现集群模式开启、能检测到其他节点的信息。另外集群模式下实例ID设置为AUTO,Quartz会通过数据库生成唯一实例ID,配合QRTZ_SCHEDULER_STATE表做心跳检测。一个节点挂了之后,集群的故障转移默认也要等一个检查周期,所以clusterCheckinInterval不能设得太小,否则网络抖动会导致误杀;设太大则故障接管慢。15秒这个值在绝大部分内网环境是合理的。

后来我把调度逻辑改成这样:对于“必须只执行一次”的任务,在Job里用分布式锁再做一次业务层的幂等。**Quartz只能保证调度不重复见过,不能保证你的处理逻辑天然幂等。**把调度层的保证和业务层的幂等分开对待,才是正确姿势。

3.4 misfire策略选不对,错过的时间也会补

Quartz的Trigger都支持misfire策略,尤其CronTrigger。简单说就是:如果到了触发时间,可用线程不够或者调度器刚好宕机导致没触发成功,恢复后会按策略决定是否补偿。默认的策略是FireOnceNow——立即补一次。这个策略对很多场景其实很危险:比如一个任务每小时跑一次数据汇总,凌晨宕机两小时,恢复后补跑一次是合理的;但如果任务是每30秒刷新一次缓存,补跑意义不大,反而打乱节奏。

我的习惯做法:

  • 只希望最新的下一次触发执行、不要补的,用withMisfireHandlingInstructionDoNothing()。
  • 希望错过的周期不补、但从现在开始继续按原规则走的,是ignoreMisfirePolicy。
  • 希望漏一次补一次的,用withMisfireHandlingInstructionFireAndProceed()。

具体在TriggerBuilder里就是这样写:

CronScheduleBuilder cronSchedule = CronScheduleBuilder.cronSchedule("0 0 2 * * ?") .withMisfireHandlingInstructionDoNothing();

注意,trigger在创建时就带上了misfire策略,后续如果通过rescheduleJob重新调度,还要重新指定。这个坑我在代码里吃过亏:程序启动时初始化好的trigger配置好了misfire策略,但后台上运营人员改CRON的时候,我用rescheduleJob去覆盖,结果没显式设置misfire策略,旧策略直接丢掉了,恢复默认的FireOnceNow。某次凌晨数据库故障恢复后,所有任务全量补跑了一遍,把下游接口打爆了。现在我的写法是每次reschedule时都明确带上策略:

CronTrigger newTrigger = TriggerBuilder.newTrigger() .withIdentity(oldTrigger.getKey()) .withSchedule(CronScheduleBuilder.cronSchedule("0 0 3 * * ?") .withMisfireHandlingInstructionDoNothing()) .build(); scheduler.rescheduleJob(oldTrigger.getKey(), newTrigger);

4. 动态调度接口:页面增删改定时任务的全套实现

4.1 为什么必须有动态调度

回到开头那个运营需求——“运营人员要在后台新建定时任务”。这意味着JobDetail、Trigger都不能写死在@Configuration里,而是运行时从数据库读取任务配置,动态创建。Quartz的Scheduler结构天然满足这一点,只是我们要自己把“业务配置表”和“Quartz任务”对应起来。

我采用的方案是:业务侧单独建一张sch_job_config表,存任务名、CRON表达式、任务回调类型、参数JSON、状态等。后台页面操作这张配置表,同时调用调度服务把变更同步到Quartz。这样配置有源可查,和Quartz的执行状态分离,审计日志也方便。

4.2 动态新增任务的完整代码

调度服务核心方法大概长这样(省略了部分校验和日志):

@Service public class QuartzManagerService { private final Scheduler scheduler; public QuartzManagerService(Scheduler scheduler) { this.scheduler = scheduler; } public void addJob(JobConfigDTO config) throws Exception { // 1. 校验任务名是否已存在 if (scheduler.checkExists(new JobKey(config.getJobName(), config.getJobGroup()))) { throw new BizException("任务已存在: " + config.getJobName()); } // 2. 根据回调类型选择执行器 Class<? extends Job> jobClass = chooseJobClass(config.getHandlerType()); // 3. 构造JobDetail,附带业务参数 JobDataMap dataMap = new JobDataMap(); dataMap.put("handlerType", config.getHandlerType()); dataMap.put("params", config.getParamsJson()); dataMap.put("timeout", config.getTimeoutSeconds()); JobDetail jobDetail = JobBuilder.newJob(jobClass) .withIdentity(config.getJobName(), config.getJobGroup()) .usingJobData(dataMap) .storeDurably(true) .requestRecovery(true) .build(); // 4. 构造Cron触发器 CronTrigger trigger = TriggerBuilder.newTrigger() .withIdentity(config.getJobName() + "_trigger", config.getJobGroup()) .withSchedule(CronScheduleBuilder.cronSchedule(config.getCronExpression()) .withMisfireHandlingInstructionDoNothing()) .build(); // 5. 注册到调度器 scheduler.scheduleJob(jobDetail, trigger); // 6. 持久化业务配置 saveConfig(config); } }

几个值得说的细节:

  • requestRecovery(true):持久化模式下,如果任务正在执行而调度器挂了,Quartz会在恢复后重新调度这个Job执行。否则任务执行一半丢失可能没有任何补偿。
  • 任务组group:我一般会用业务线作为group,比如daily、report、scheduler。组是Quartz天生的任务分类维度,后面批量暂停、批量删除都可以按组操作。
  • 任务名和触发器名要一一对应且唯一,方便运维时通过任务名直接定位trigger。如果混乱,后续维护就是灾难。
  • 判断任务是否已存在用scheduler.checkExists(new JobKey(...)),这个方法在JDBC存储模式下会查库,注意不要在高频循环里调用。

4.3 更新、暂停、恢复、删除的完整实现

动态调度要应对的完整场景远不止新增。我维护了下面这几个方法的实现,每个都踩过相应的坑:

暂停任务:

scheduler.pauseJob(new JobKey(jobName, jobGroup));

恢复任务:

scheduler.resumeJob(new JobKey(jobName, jobGroup));

删除任务:

scheduler.deleteJob(new JobKey(jobName, jobGroup));

注意,deleteJob不仅会删除JobDetail,还会移除所有关联的Trigger。如果只想改触发规则而不动任务本身,不要用deleteJob,要使用rescheduleJob。更新CRON则要先拿到旧触发器:

public void rescheduleJob(String jobName, String jobGroup, String newCron) throws Exception { JobKey jobKey = new JobKey(jobName, jobGroup); if (!scheduler.checkExists(jobKey)) { throw new BizException("任务不存在"); } // 获取已有触发器,这里有个前提:一个JobDetail通常绑定一个Trigger // 如果绑了多个,需要遍历 triggersOfJob List<? extends Trigger> triggers = scheduler.getTriggersOfJob(jobKey); if (triggers.isEmpty()) { // 说明是仅有JobDetail没有触发器的持久任务,可以再生成一个trigger CronTrigger newTrigger = TriggerBuilder.newTrigger() .forJob(jobKey) .withIdentity(jobName + "_trigger", jobGroup) .withSchedule(CronScheduleBuilder.cronSchedule(newCron) .withMisfireHandlingInstructionDoNothing()) .build(); scheduler.scheduleJob(newTrigger); return; } TriggerKey oldTriggerKey = triggers.get(0).getKey(); CronTrigger newTrigger = TriggerBuilder.newTrigger() .withIdentity(oldTriggerKey) .withSchedule(CronScheduleBuilder.cronSchedule(newCron) .withMisfireHandlingInstructionDoNothing()) .build(); scheduler.rescheduleJob(oldTriggerKey, newTrigger); }

这里有两个很容易忽略的坑:

第一个,修改CRON后旧trigger的misfire策略丢失。我在3.4里已经说过了,这里再强调一次。reschedule时如果builder里没写misfire策略,它默认变回FireOnceNow,这会让错过时间的trigger疯狂补跑。

第二个,任务更新期间如果业务方同时操作了任务,可能造成状态不一致。我的解决方案是用配置表的乐观锁版本号去控制更新,在事务提交之后,再调用调度器真正执行reschedule。事务不要和Quartz调度操作混在同一个事务里,因为Quartz自身的数据库写入在它自己的事务里,一旦调度器执行成功而业务事务回滚,也会出现配置与调度不一致的情况。我的标准动作是:业务配置表先做UPDATE,在自己的事务里提交成功后,再调scheduler方法。这样即使调度操作失败,下次任务周期内也可以做一个“配置与调度对账”任务弥补。

4.4 使用JobDataMap传参的正确打开方式

JobDataMap本质是一个和JobDetail/Trigger关联的Map,可以被持久化。每次触发的时候,executeInternal里通过context.getMergedJobDataMap()拿到的数据,是JobDetail的JobDataMap和Trigger的JobDataMap合并后的结果。两个地方都设置了同名key时,触发器的值会覆盖JobDetail的值。

这里要强调一个使用规则:JobDataMap虽然能存任意可序列化对象,但强烈建议只存“类型标识、配置键、ID”这类轻量参数。比如传一个数据库主键ID,任务执行时再根据ID去查全量数据。把重量级对象丢进去,序列化成本高,集群模式下还可能因为对象内部状态变化导致数据不一致。

还有,JobDataMap在任务执行时是“快照”使用的。运行时修改context.getJobDetail().getJobDataMap()的数据不会影响已经生成的trigger记录,但会影响下一次执行的取值。所以如果想做“本次执行成功后才把某个参数标记为已完成”,不要在内存里改,应该更新业务表,下次任务启动时自己查。

5. 集群部署时必须处理的另外三件事

5.1 每个任务在业务层尽量做幂等

这个观点我在前面反复提过,但值得单独展开。Quartz集群保证的是“调度层面的不重复”——同一个trigger在同一时刻只会分配给一个节点的线程池执行。但业务处理本身的幂等,Quartz不负责。比如一个任务要调第三方接口拉数据,网络超时后重试;或者两个任务逻辑上处理同一批数据,先后都触发了。幂等最简单有效的实现是给业务表加一个“批号/业务ID”唯一约束,执行时insert或update时捕获冲突,直接忽略。

举例:我在做对外数据同步任务时,数据源表会生成batch_number,目标表里对batch_number建唯一索引。任务里带上这个batch,执行时先尝试insert,重复键就跳过。这样即使Quartz或者运维手动补跑,也不会造出脏数据。

5.2 有状态Job如何配合@PersistJobDataAfterExecution

默认情况下,Job实例每次执行是全新对象,execute方法里对JobDataMap做的修改不会自动保存。如果希望本次执行修改的参数(比如重试次数、游标位置)能被下一次执行看到,需要:

  1. 在Job类上加上@PersistJobDataAfterExecution注解,告诉Quartz在该Job执行完后,将JobDataMap写回JobDetail。
  2. 同时配合@DisallowConcurrentExecution使用,官方文档也比较推荐。原因很直白:如果你允许并发执行同一个JobDetail,一个实例写回JobDataMap,另一个实例也写回,就互相覆盖了。

实际工作中,我见过不少人把这两个注解当成“任务必须串行”的银弹,只挂注解,却没有真正搞懂它们的语义。它们在单进程内效果确实明显,但集群下仍需配合isClustered配置,并且业务幂等不能省。

5.3 注意Quartz自身的数据库锁和连接池

JDBC JobStore的分布式锁实现是靠QRTZ_LOCKS表行锁。Quartz会先通过select for update锁住某一行,再执行调度状态变更,这个锁的粒度是调度器级别的。这意味着集群节点越多、调度频率越高,QRTZ_LOCKS表的锁竞争可能越严重。所以我给Quartz单独配了连接池,避免和业务DB连接互相饿死。

另外,Quartz的秒级高频任务(比如每5秒触发一次)如果量大,数据库IO会非常夸张。我们线上任务CRON基本都是分钟级以上,秒级任务不是Quartz擅长的主场。真要秒级频率,建议考虑专业消息队列或者专门的流处理框架,别硬塞进Quartz。

6. 可直接抄走的落地配置清单

这节给一份可以直接“抄作业”的落地清单,按顺序执行基本不会出问题:

  1. 建库:单独建quartz库,执行当前Quartz版本对应的tables_mysql_innodb.sql。
  2. 依赖:引入spring-boot-starter-quartz。
  3. 资源:把quartz.properties放在classpath下。
  4. 业务任务类:写一个继承QuartzJobBean的类,业务要求串行时标注@DisallowConcurrentExecution。
  5. 启动入口任务:用@Configuration定义几个固定的JobDetail和Trigger,用于系统预置任务。
  6. 动态任务接口:新增一个Facade服务,把addJob、rescheduleJob、pauseJob、resumeJob、deleteJob这些调度操作封装好。
  7. 幂等兜底:Job执行方法里,用唯一约束或分布式锁做业务幂等,不指望Quartz单独保证。
  8. 监控:定时扫描QRTZ_FIRED_TRIGGERS和业务配置表,找出“配置了却没被调度”的任务,以及“执行时间过长”的任务,及时报警。

这一段不是总结,而是实实在在的交付清单。我在接手过的若干个定时任务改造项目里,都是按这个清单往下推进的,基本不踩坑。具体到每个公司的基础设施不同,数据库用Oracle的、PostgreSQL的,或者用Spring的Hikari连接池接Quartz的,只需要在quartz.properties里改对应驱动类和方言就行。

还有一个小提醒:别自作聪明给Quartz的库表加索引之外的扩展字段。Quartz的ORM逻辑很死板,表结构一改动,它的SQL可能直接报错,或者出现隐藏的并发问题。业务相关的扩展字段放自己的配置表,通过JobDataMap传ID关联即可。这是我最初踩过的一个坑,给QRTZ_JOB_DETAILS加过一列,后来升级Quartz版本时建表脚本跑不过去,折腾了好久才撤销。

7. 我自己用下来的几点体会

Quartz这个东西一开始接触会觉得API有点绕,JobDetail、Trigger、Scheduler三层结构,还有注解、监听器、misfire策略一堆概念。但只要完整跑通一次集成过程,会发现它的设计是真的经得起推敲。Job解决“做什么”,Trigger解决“什么时候做”,Scheduler解决“怎么做并管理状态”。两层分离意味着任务和触发规则可以单独替换,这在动态需求面前太重要了。

我个人的体会是别贪图快,直接照抄配置就完事。其实先花半小时把QRTZ表结构和那几条SQL看一遍,后面排查问题会快很多。Quartz遇到诡异问题时,最好的调试工具就是查数据库里各张表当前存了什么状态。QRTZ_TRIGGERS表里TRIGGER_STATE从WAITING变成ACQUIRED再变成EXECUTING,这个状态流转看懂之后,任何任务“该跑没跑”“多台都跑”“没按点跑”的问题,都能在库里明确对应出来。

这篇把整合到集群部署的思路和细节都过了一遍。最后再分享一个小技巧:如果你在测试环境经常反复启动应用,记得把org.quartz.scheduler.skipUpdateCheck=true加上,否则Quartz每次启动都会往Quartz官网发一次版本检查请求,虽然不影响功能,但会让启动日志多出一些无意义的网络超时提示,看着很碍眼。

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

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

立即咨询