做数据中台那会儿,我接手过一个被@Scheduled塞满的调度模块:二十多个定时注解散落在各个Service里,改一次执行时间要走发版流程,产品提了个“动态配置任务”的需求,开发组内部先吵了两周。后来我们把调度能力从业务代码里整体剥出来,选型就是Quartz。改造完成之后,调度相关的事故率明显降下来了。这篇文章把Spring Boot整合Quartz的方法完整梳理一遍,从依赖配置、动态任务管理到持久化集群,以及我在落地过程中踩过的各种坑,都摊开讲清楚。适合正在做定时任务选型、或者项目里调度需求开始失控的同学参考。
1. 为什么有了@Scheduled还要引入Quartz——调度边界和选型逻辑
1.1 @Scheduled解决不了的三类问题
Spring自带的@Scheduled注解用起来确实简单,一个注解写上cron表达式就能跑,但项目规模一旦上来,问题会一个接一个地冒出来。
第一类问题是任务状态不可管理。任务启动之后,你没有办法在运行期把它暂停、恢复、删除,更不可能动态新建一个任务。想调整执行频率,唯一的办法是改代码重新发版。业务方跟你说“这个任务先停一下别跑了”,你只能回答“等下个版本”。
第二类问题是任务信息散落在代码里。二十个@Scheduled注解分布在不同的类里,没有统一的任务清单。想统计一下全项目到底有多少个定时任务、每个任务上次执行时间、成功失败情况,基本靠人肉翻代码。
第三类问题是集群环境会重复执行。@Scheduled没有跨实例协同机制,如果服务部署多个实例,同一个任务在每个节点上都会触发一次。要么用分布式锁硬扛,要么用ShedLock这类额外组件,但这已经超出了@Scheduled的能力边界。
1.2 Quartz能带来什么
Quartz是Java生态里老牌的作业调度框架,核心组件就四个:Scheduler(调度器)、JobDetail(任务定义)、Trigger(触发器)、JobStore(任务存储)。它把“做什么”和“什么时候做”彻底拆开——JobDetail只描述执行什么逻辑,Trigger只负责计算触发时间,两者可以独立创建、独立管理。
动态调度能力是Quartz最值钱的部分。运行过程中可以随时通过Scheduler接口注册新任务、修改触发时间、暂停恢复,这些操作全部在内存或者数据库中完成,不需要改代码。同时它支持持久化到数据库,任务定义和执行状态在应用重启后不丢失,配合集群部署还能做到同一个任务在多实例之间只执行一次。
调度时间计算也比@Scheduled可靠得多。Quartz内部实现了完整的cron表达式引擎,支持秒级别的触发粒度,还带有misfire(错过触发)处理策略。任务因为线程池满或者宕机而没执行时,可以按事先配置的策略决定是立即补跑还是丢弃,这一点对于对账、推送类任务非常关键。
1.3 整合方式的选择:starter优先
Spring Boot整合Quartz常见三种做法:直接用原生Quartz API、用spring-boot-starter-quartz、或者引入第三方封装工具如xxl-job、ElasticJob。
原生API的麻烦在于需要自己管理SchedulerFactory的创建和生命周期,还要手动处理与Spring容器的对接,不是不能做,但重复劳动太多。第三方分布式调度框架功能确实更全,带了控制台和管理界面,但如果你只需要在单应用内部做好任务调度、不想要额外的服务端组件,把它们引进来又显得重。
spring-boot-starter-quartz是官方starter,自动配置了SchedulerFactoryBean,把Scheduler对象注入到Spring容器里,你拿到的是已经配置好的调度器实例,直接往里面注册任务就行。我现在做项目默认用这种方式,性能足够、侵入小,后面万一要迁移到独立调度平台,核心业务逻辑也不用改。
提示:如果你的预期是“未来一定会有多个微服务共享调度中心”,那就别折腾Quartz了,直接上带控制台的分布式调度框架。Quartz的集群方案属于应用内协同,不是独立的调度中台。
2. 工程结构与依赖准备:从零搭建调度模块
2.1 引入依赖:最小可用组合
Spring Boot项目里整合Quartz,依赖非常省事。以Spring Boot 2.7.x为例,pom.xml里加一个官方starter就够了:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-quartz</artifactId> </dependency>这个starter会把Quartz核心依赖带进来,org.quartz-scheduler:quartz,版本由Spring Boot统一管理,不需要自己指定。
如果后续要走数据库持久化,还需要一个数据源相关依赖。项目里已经引入了spring-boot-starter-jdbc或者mybatis-plus之类的持久层依赖,那数据源就不缺了;如果没有,加一个:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-jdbc</artifactId> </dependency>顺带说一句,Quartz持久化使用的是JDBC访问数据库,跟项目里用不用MyBatis、JPA没有关系,它只认DataSource。
2.2 application.yml基础配置
引入依赖之后,Scheduler这个Bean确实会自动装配好,但如果你直接拿默认配置去跑生产环境,大概率会踩到线程池和持久化的坑。我一般在application.yml里显式配一遍基础项:
spring: quartz: scheduler-name: bizScheduler wait-for-jobs-to-complete-on-shutdown: true overwrite-existing-jobs: true job-store-type: memory properties: org.quartz.threadPool.threadCount: 10 org.quartz.threadPool.threadPriority: 5 org.quartz.jobStore.misfireThreshold: 60000解释一下这几个关键配置:
scheduler-name:调度器实例名称,集群环境里每个节点名称要唯一。如果没指定,默认会生成随机名称,日志排查时会很难受。wait-for-jobs-to-complete-on-shutdown:应用关闭时是等正在执行的任务跑完再关,还是直接强制关。对于不允许中断的任务(比如发钱、删数据),必须设为true。overwrite-existing-jobs:注册相同JobKey的任务时是否覆盖,建议设true,开发阶段热更新很方便。properties.org.quartz.threadPool.*:Quartz自己的线程池配置。这里需要特别提醒,Quartz的线程池和Tomcat的连接池是两个池子,不要混淆。threadCount默认是10,如果项目里任务超过20个,这个值基本要往上调,后面第6节会细说。misfireThreshold:触发时间超过多久算misfire,单位毫秒。默认60000,也就是1分钟。
job-store-type这里先配成memory,对应内存存储。等做到持久化那一步再改成jdbc。
2.3 调度模块的目录规范
很多项目采购进来之后调度代码四处乱丢,任务实现类散落在service、controller、utils各个包里,统一管理和排查全靠运气。我推荐在项目里单独划出一个job包,并且按照职责再细分:
com.example.project ├── job │ ├── config # 调度相关配置类,如JobFactory、Scheduler定制 │ ├── manager # 调度管理服务,统一封装任务注册、暂停、删除 │ ├── job # 具体任务实现类,一个业务任务一个类 │ ├── listener # Quartz监听器,做执行日志和告警 │ └── model # 任务参数、任务信息实体这个结构的核心思想是:调度API的调用收敛到manager层,任务实现类不直接操作Scheduler。业务方如果想新增一个任务,只需要在job包下新建一个实现类,然后通过manager注册;如果只是想调整cron,也不需要碰任务类。
四层架构如果套到调度模块上,就是controller接收请求、service处理业务逻辑、job执行具体的定时任务、基础设施层提供数据源和调度器。Quartz作为调度基础设施不要渗透到service层,service只知道“有一个任务会在某个时间被调用”,具体何时触发、是否并发执行是调度框架的事情。
3. 让Quartz认识Spring:解决Job实例与Bean注入脱节
3.1 最隐蔽的坑:Job里的@Autowired为什么是null
刚把Quartz引入项目的时候,我犯过第一个低级错误:在Job实现类里@Autowired一个Service,任务一跑直接空指针。排查下来发现,Quartz默认通过org.quartz.simpl.SimpleJobFactory创建Job实例,用的是反射newInstance(),这个对象不是Spring容器管理的,所以Spring的依赖注入完全不会生效。
这个问题在官方文档里其实有说明,但很多人第一次整合时都会踩一次。解决方案需要自己实现一个JobFactory,让Quartz创建Job实例时走Spring容器的AutowireCapableBeanFactory。Spring Boot自动配置的SchedulerFactoryBean默认会设置SpringBeanJobFactory,但那只是让Job能拿到Spring底层的属性解析,业务Service的@Autowired仍然不保证注入。
注意:判断你项目里的Job是否被Spring容器接管,最快的办法是在Job构造方法里打印一行日志,或者直接看一眼
Job实例的类名和hashCode。如果每次触发hashCode都不一样,多半没有走Spring的创建链路。
3.2 自定义JobFactory接管实例创建
我的做法是写一个JobFactory实现类,继承SpringBeanJobFactory并重写newJob方法,用AutowireCapableBeanFactory对创建出来的Job做一次依赖填充:
@Component public class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory implements JobFactory { private final AutowireCapableBeanFactory beanFactory; public AutowiringSpringBeanJobFactory(AutowireCapableBeanFactory beanFactory) { this.beanFactory = beanFactory; } @Override public Job newJob(TriggerFiredBundle bundle, Scheduler scheduler) throws SchedulerException { Job job = super.newJob(bundle, scheduler); beanFactory.autowireBean(job); return job; } }然后定义一个SchedulerFactoryBean,覆盖Spring Boot的自动配置,把自定义的JobFactory塞进去:
@Configuration public class QuartzJobFactoryConfig { @Bean public SchedulerFactoryBean schedulerFactoryBean(AutowireCapableBeanFactory beanFactory) { SchedulerFactoryBean factory = new SchedulerFactoryBean(); factory.setJobFactory(new AutowiringSpringBeanJobFactory(beanFactory)); return factory; } }注意一点:自定义SchedulerFactoryBean之后,application.yml里的spring.quartz.properties依然会生效,因为Spring Boot的QuartzAutoConfiguration在创建SchedulerFactoryBean时会把配置文件里的属性合并进来。但spring.quartz.scheduler-name这类前缀属性如果在代码里没有对应setter,需要你自己调用factory.setSchedulerName("bizScheduler"),否则可能出现过自定义Bean后名称失效的情况。更稳妥的做法是直接在配置类里把所有要定制的属性全部显式设置。
改造完之后,Job类里的@Autowired就能正常注入了,而且Job实例的创建和销毁还是由Quartz管理,不会跟Spring容器冲突。
3.3 任务参数如何传:JobDataMap的序列化陷阱
Job和Spring Bean的注入问题解决之后,下一个高频问题是任务参数怎么传。比如我有一个订单超时任务,同一个Job类要处理不同渠道的订单,执行逻辑一样但参数不同,这时就需要通过JobDataMap把参数传递给Job实例。
JobDetail jobDetail = JobBuilder.newJob(OrderTimeoutJob.class) .withIdentity(jobKey) .usingJobData("channel", "taobao") .usingJobData("timeoutMinutes", 30) .build();在Job里通过context.getJobDetail().getJobDataMap().getString("channel")取出来。
但这里有个非常隐蔽的序列化问题:当Quartz使用JDBC持久化时,JobDataMap会被序列化后存进数据库。如果JobDataMap里放了一个自定义对象,但对象没有实现java.io.Serializable,任务一到持久化阶段就报NotSerializableException。所以放进JobDataMap的要么是基本类型和字符串,要么确保对象实现了Serializable接口。
我现在的习惯是:JobDataMap里只放基本类型、枚举或JSON字符串。需要用复杂对象的时候,先把对象序列化成JSON字符串放进去,在Job里反序列化出来。这样既规避了序列化问题,也方便日后修改字段结构而不影响已经持久化的任务。
4. 动态任务管理:运行时增删改查才是真需求
4.1 固定Cron写死的模式适合哪种项目
如果你的任务清单非常固定,比如每天凌晨两点跑一次数据备份,下周不会改、下个月也不会改,那用@Scheduled在代码里写死是最省事的。
但真实业务中定时任务往往是动态的:运营后台要配置一场活动的开始时间、用户在系统里自定义报表的推送周期、风控规则要临时调整重跑频率。这些场景里任务的发生时间由数据驱动,不可能每来一个新配置就发一次版。这时候用Quartz的动态注册能力,把任务的启动、暂停、恢复、修改、删除全部做成接口,业务方就能自助管理调度行为。
这一节的核心,就是设计一个ScheduleManager来封装这些操作。
4.2 ScheduleManager:一套可复用的调度服务
我习惯把动态调度的所有操作收敛到一个ScheduleManager组件里,业务代码不直接操作Scheduler,只依赖ScheduleManager暴露的方法。这样做的好处很直接:调度器的调用逻辑只在这一处维护,以后换了调度框架,改动范围就限定在这个类里。
核心方法清单:
@Service public class ScheduleManager { private final Scheduler scheduler; public ScheduleManager(Scheduler scheduler) { this.scheduler = scheduler; } /** * 注册一个定时任务,如果jobKey已存在则重新调度 */ public void registerJob(String jobKey, String jobGroup, String cron, Class<? extends Job> jobClass, JobDataMap jobDataMap) { JobKey key = JobKey.jobKey(jobKey, jobGroup); JobDetail jobDetail = JobBuilder.newJob(jobClass) .withIdentity(key) .usingJobData(jobDataMap) .storeDurably(true) .build(); Trigger trigger = TriggerBuilder.newTrigger() .withIdentity(TriggerKey.triggerKey(jobKey + "Trigger", jobGroup)) .withSchedule(CronScheduleBuilder.cronSchedule(cron)) .build(); try { if (scheduler.checkExists(key)) { scheduler.addJob(jobDetail, true); Trigger oldTrigger = scheduler.getTrigger(trigger.getKey()); if (oldTrigger != null) { scheduler.rescheduleJob(oldTrigger.getKey(), trigger); } else { scheduler.scheduleJob(trigger); } } else { scheduler.scheduleJob(jobDetail, trigger); } } catch (SchedulerException e) { throw new IllegalStateException("register job failed: " + jobKey, e); } } }说几个容易忽略的细节:
storeDurably(true)必须设置。当JobDetail没有关联Trigger时,Quartz默认会把无触发器的任务删掉。动态任务场景中,任务可能先注册、后绑定触发器,如果没设置这个参数,注册完Job再挂Trigger的时候任务已经被清理了。- 已经存在的
jobKey需要走rescheduleJob而不是scheduleJob,否则会抛出ObjectAlreadyExistsException。 - 触发器名称不能跟Job名称完全一样,虽然是不同命名空间,但为了日志清晰,我习惯用
jobKey + "Trigger"命名触发器。 - 注册成功之后一定要打印日志,记录jobKey、group、cron、创建时间,排查线上问题时这条日志能省很多事。
4.3 暂停、恢复、删除与立即执行的状态机
有了registerJob,还需要配套的方法管理任务生命周期。Quartz里任务相关操作分两个维度:Job维度和Trigger维度。
暂停一个任务,可以只暂停触发器(pauseTrigger),但我的实践是直接暂停整个Job(pauseJob),因为业务上“暂停任务”的语义就是整个业务停掉,而不是暂时不触发。恢复、删除就对称了:
public void pauseJob(String jobKey, String jobGroup) { scheduler.pauseJob(JobKey.jobKey(jobKey, jobGroup)); } public void resumeJob(String jobKey, String jobGroup) { scheduler.resumeJob(JobKey.jobKey(jobKey, jobGroup)); } public void deleteJob(String jobKey, String jobGroup) { scheduler.deleteJob(JobKey.jobKey(jobKey, jobGroup)); }Job在Quartz里的生命周期状态主要有:NONE(不存在)、NORMAL(正常)、PAUSED(暂停)、COMPLETE(已完结)、ERROR(错误)、BLOCKED(阻塞)。判断任务当前状态用scheduler.getJobDetail()和scheduler.getTriggerState(triggerKey)组合判断,光看JobDetail只能知道存在不存在,看不到是暂停还是正常。我写了个简单方法方便封装:
public String getJobState(String jobKey, String jobGroup) { TriggerKey triggerKey = TriggerKey.triggerKey(jobKey + "Trigger", jobGroup); try { Trigger.TriggerState state = scheduler.getTriggerState(triggerKey); return state.name(); } catch (SchedulerException e) { return Trigger.TriggerState.NONE.name(); } }另一个很常用的功能是“立即执行一次”,也叫手动触发:
public void triggerNow(String jobKey, String jobGroup, JobDataMap extraData) { JobKey key = JobKey.jobKey(jobKey, jobGroup); if (extraData == null) { scheduler.triggerJob(key); } else { scheduler.triggerJob(key, extraData); } }注意,triggerJob不会替代已有的Trigger,它只是额外触发一次。这时候如果Job里读取的是JobDataMap里的参数,手动触发时传入的extraData参数会和JobDetail自带的参数合并,如果key相同,以triggerJob传入的为准。
4.4 从数据库里初始化任务:一个完整闭环
动态任务管理最终要落到数据上。我的做法是设计一张sys_job表,字段包括jobKey、jobGroup、cron表达式、任务类名、参数JSON、状态、创建人、创建时间等。应用启动时,监听ApplicationReadyEvent事件,把表中状态为RUNNING的任务批量注册到Quartz:
@Component public class JobInitializer implements ApplicationRunner { private final ScheduleManager scheduleManager; private final SysJobMapper sysJobMapper; @Override public void run(ApplicationArguments args) { List<SysJob> activeJobs = sysJobMapper.selectRunningList(); for (SysJob job : activeJobs) { Class<? extends Job> jobClass = resolveJobClass(job.getJobClass()); JobDataMap dataMap = new JobDataMap(); dataMap.put("jobConfig", job.getParams()); scheduleManager.registerJob(job.getJobKey(), job.getJobGroup(), job.getCron(), jobClass, dataMap); } } }这套闭环做到之后,新增一个定时任务的工作量就降低了:页面上填cron、选任务类、配参数,数据落库,服务重启后自动恢复。业务部门再也不用来回提工单改代码,调度模块从“开发维护模式”升级成了“自助配置模式”。
5. 持久化与集群:JobStore选型、达梦适配与锁机制
5.1 RAMJobStore重启即失忆的代价
Quartz的JobStore负责任务和触发器的存储,默认是RAMJobStore,所有数据放内存。好处是快、配置简单、性能高,坏处是应用一重启,所有任务、触发器、执行状态全部清空。
开发环境用RAMJobStore完全没问题,但生产环境配合动态任务功能就有个大坑:管理员在页面上配置好的任务,服务一重启就全部没了,又得重新配置一遍。我最初在测试环境踩过一次,当时还以为是数据库没连上,查了半天才发现是存储模式的问题。
解决思路就是切换成JDBCJobStore,把任务数据落库。Spring Boot配置很简单,把job-store-type改成jdbc:
spring: quartz: job-store-type: jdbc但光改配置不够,还需要让Quartz知道自己用的是哪张表、怎么访问。Quartz官方提供了各数据库的建表脚本,位于Quartz发行包的docs/dbTables目录下,例如tables_mysql_innodb.sql、tables_oracle.sql。
5.2 JDBCJobStore:从建表到达梦方言适配
spring.quartz.job-store-type: jdbc配置好之后,如果使用的是主数据源,Spring Boot会直接用项目里的DataSource。但要注意:Quartz的锁机制依赖短事务,如果项目里用的是同一个数据源,而主数据源上挂了Seata、ShardingSphere之类的分布式事务中间件,Quartz的锁会被这些中间件处理,容易出现锁被长时间占用的问题。稳妥的做法是给Quartz单独配置一个@QuartzDataSource。
@Configuration public class QuartzDataSourceConfig { @Bean @QuartzDataSource @ConfigurationProperties(prefix = "spring.datasource.quartz") public DataSource quartzDataSource() { return DataSourceBuilder.create().build(); } }建表脚本方面,如果用的是MySQL,直接执行tables_mysql_innodb.sql就行。国内很多政企项目用的是达梦数据库,Quartz官方脚本里没有达梦专用版本,需要手动适配。
达梦兼容Oracle语法比较成熟,我一般以Oracle版本脚本为底子,把其中几个类型改成达梦支持的类型:
BLOB类型改成VARBINARYCLOB类型改成TEXT- 表名、列名本身不用改,因为Quartz对列名的访问是通过JDBC
ResultSetMetaData动态获取的,不是写死的表结构。 - 日期字段用
TIMESTAMP或DATETIME,达梦都支持。
核心的表有11张,最重要的三张是:
qrtz_job_details:JobDetail信息,包括jobKey、Job类名、JobDataMap序列化数据。qrtz_triggers:触发器信息,包括triggerKey、jobKey、cron表达式、开始结束时间。qrtz_cron_triggers:cron触发器扩展信息,存放cron表达式和时区。
配置达梦数据源和JDBC驱动:
spring: datasource: quartz: driver-class-name: dm.jdbc.driver.DmDriver jdbc-url: jdbc:dm://127.0.0.1:5236?schema=QUARTZ_DB username: quartz password: quartz如果Quartz的建表语句在达梦里执行报错,先检查两点:数据库字符集是不是UTF-8、用户有没有建表权限。达梦对schema的处理跟MySQL不一样,连哪个schema就建在哪个schema下,指定清楚即可。
建表脚本初始化方式,开发环境可以依赖Spring Boot的自动初始化:
spring: quartz: jdbc: initialize-schema: always生产环境不要开always,第一次手动建表之后改成never,防止应用启动时重复建表出问题。
5.3 集群模式下的锁机制与事务问题
Quartz集群的原理并不复杂:多个节点共用一个数据库,每个节点启动时注册自己的实例信息(存储在qrtz_scheduler_state表),通过数据库的行锁来保证同一个任务在同一时刻只被一个节点触发。
启用集群需要额外配置:
spring: quartz: properties: org.quartz.jobStore.isClustered: true org.quartz.scheduler.instanceId: AUTO org.quartz.scheduler.instanceName: clusterScheduler org.quartz.jobStore.clusterCheckinInterval: 15000几个要点说明:
instanceId设为AUTO时,每个节点启动会自动生成基于主机名和时间戳的唯一ID,不需要手工指定。clusterCheckinInterval是节点心跳检查间隔,默认15000毫秒。如果某个节点宕机,其他节点至少要等这个时间才会发现它不在了,然后接管它的任务。- 集群模式下,
qrtz_locks表是锁的载体。Quartz会在执行任务前插入一条锁记录,执行完删除。保证锁事务能被正确提交非常重要,如果用的是不带事务的底层连接,可能出现锁释放不掉、任务整体卡死的现象。
集群场景还有一个事务误用的坑。Quartz每次调度都默认走一个短事务,如果项目里的DataSource被配置成了REQUIRES_NEW传播行为,或者全局开启了类似readOnly拦截,可能引发锁超时。我遇到过一次线上任务突然全部停摆,日志报Deadlock found when trying to get lock,最后排查下来是某个切面把所有DAO方法包了一层事务,导致Quartz的锁查询和释放逻辑被嵌套到长事务里,锁被长时间持有。后来把Quartz的数据源独立出来、事务传播设置为默认级别,问题才解决。
提醒:Quartz集群只解决“同一个任务不重复同时执行”的问题,不解决“任务分片处理”的问题。如果任务量特别大需要分到多个节点并行跑,还是考虑分布式任务框架更合适。
6. 线上经验:并发、Misfire、时区与监控
6.1 线程池缩小了任务会排队排死
Quartz线程池的大小直接决定任务并发执行能力。一个很容易被忽视的事实是:线程池里的线程是被所有Job共享的,不是每个Job一个线程。假设配置了10个线程,同时触发10个任务,每个任务耗时60秒,这时第11个任务就只能排队等待。
在业务项目中我一般按两个口径评估线程池大小:
- 任务总量和触发频率。如果项目里有80个任务,集中在整点触发,那么10个线程明显不够。
- 单个任务的平均耗时和是否允许并发执行。如果任务都是短任务,10个线程能扛住几十个任务;如果任务里有耗时的外部调用,线程池就要大一些。
一个常用的估算公式:threadCount >= 同一时刻期望并发执行的任务数 * 单任务最大耗时 / 任务触发周期。比如期望同一时刻最多跑10个任务,每个任务平均耗时30秒,触发周期为1分钟,那10个线程勉强够,留点余量配15或者20更好。
频繁调整线程池可能导致任务触发时间被推迟,甚至出现Misfire。在我负责的调度模块上,threadCount从默认10调到20,再配合misfire策略,整点任务扎堆的问题基本消失。
6.2 @DisallowConcurrentExecution的正确理解
Quartz里@DisallowConcurrentExecution注解经常被误解,很多人以为只要加上它,所有同一个Job类就不会并发执行。实际上这个注解的作用范围是同一个JobKey,而不是同一个Job类。
举个例子:你注册了10个OrderTimeoutJob,每个任务的JobKey不同。其中任何一个任务在执行,其他9个任务照样可以同时触发执行。只有当同一个JobKey对应的任务上一次还没跑完、下一次触发时间又到了时,@DisallowConcurrentExecution才会让下一次触发等待上一次完成,或者被下次触发合并掉。
还有一点要特别注意:加了@DisallowConcurrentExecution之后,如果某次执行卡住了,后续所有该任务的调度都会被堵住。排查问题时看到某个任务超过预期时间很久没执行,先看上一次是不是还没结束。
@PersistJobDataAfterExecution这个注解也经常跟它成对出现。它的作用是Job执行完成后,把修改过的JobDataMap持久化回JobDetail,这样下次触发时拿到的参数是上次执行完的最终值。两个注解放一起用最安全:先持久化数据,再保证并发不会同时修改同一份数据。
6.3 Cron表达式与时区:集群环境最容易翻车
Cron表达式写错的概率远比你想象的高。Quartz的cron表达式支持7段格式(秒 分 时 日 月 周 年),比Linux crontab多了一个秒字段。我刚接触Quartz时习惯性写5段,结果Quartz把第一位当成秒,导致任务触发时间完全不对。
几个高频错误:
- 第6位星期字段和第4位日期字段互斥。Cron表达式里同时指定了“某日”和“星期几”时,两个条件都不能为
?的话表达式直接不合法。所以日期用了具体数字,星期就必须写?,反过来也一样。 - 秒字段没写对。
0 0 2 * * ?表示每天凌晨2点执行,如果把秒字段漏了,写成0 2 * * ?,含义变成每小时的第2分第0秒执行,完全不沾边。 - 时区问题。Quartz的cron触发器默认走
TimeZone.getDefault(),也就是应用服务器所在时区。如果服务器时区配错了,任务执行时间会整体偏移。集群环境下所有节点必须保证时区一致,否则会出现同一个任务在不同节点的触发时间不一致。
CronScheduleBuilder.cronSchedule(cron) .inTimeZone(TimeZone.getTimeZone("Asia/Shanghai")) .build();这个写法在ScheduleManager里我建议默认加上,显式指定业务时区,避免服务器时区差异导致事故。
6.4 用Listener和Actuator盯住调度状态
定时任务不像在线请求,出了问题用户不会立刻发现,往往要等下游发来对账失败或者业务方反馈才暴露。所以给调度器加上监听器和监控非常必要。
Quartz三个监听器:
JobListener:监听Job执行事件,包括jobToBeExecuted、jobExecutionVetoed、jobWasExecuted。TriggerListener:监听触发器触发事件,常用于判断是否会misfire。SchedulerListener:监听调度器的添加、暂停、关闭等全局事件。
我一般注册一个JobListener,在jobWasExecuted里记录执行耗时和异常结果,把失败信息发到告警群。这个简单的做法帮助我们在测试环境发现过好几次任务类抛异常但没人知道的情况。
@Component public class JobLogListener implements JobListener { private static final Logger log = LoggerFactory.getLogger(JobLogListener.class); @Override public String getName() { return "jobLogListener"; } @Override public void jobWasExecuted(JobExecutionContext context, JobExecutionException jobException) { JobDataMap map = context.getJobDetail().getJobDataMap(); long cost = context.getJobRunTime(); if (jobException != null) { log.error("job execute failed, key={}, cost={}ms", context.getJobDetail().getKey(), cost, jobException); } else { log.info("job execute finish, key={}, cost={}ms", context.getJobDetail().getKey(), cost); } } }监控方面,Spring Boot Actuator的/actuator/health默认不会展示Quartz的调度器状态,但Scheduler本身实现了SmartLifecycle,Spring Boot启动成功后调度器会随之启动。如果调度器启动失败,应用的健康检查是能体现出来的。结合micrometer-registry-prometheus把调度器的线程池指标暴露出来,可以清楚地看到线程活跃数、队列积压数。遇到调度任务大面积延迟时,先看这个指标比翻日志快得多。
关于Memcached和redis记一笔就行了。如果你的项目里已经使用了Spring Boot Actuator,可以直接加依赖:
<dependency> <groupId>io.micrometer</groupId> <artifactId>micrometer-registry-prometheus</artifactId> </dependency>然后在Prometheus里配置抓取/actuator/prometheus,就能看到Quartz线程池的使用情况quartz_scheduler_thread_pool_size等指标。这些指标在调度故障排查时帮助很大,值得提前配上。
最后几个操作上的忠告
Quartz整合本身不难,真正难的是使用边界。如果只是简单需求,@Scheduled完全够用,不必引入额外的复杂度。一旦引入Quartz,就要把持久化、动态管理、异常告警当成标配去做,而不是加个依赖就跑。
再分享一个有用的操作习惯:在开发环境把spring.quartz.job-store-type配成jdbc,同时把initialize-schema设为always。这样每次改动Job类或触发器时,你会在本地数据库里直观地看到任务的数据变化,排查问题比纯内存模式清晰得多。
另外不要在Job实现类里直接注入RequestContextHolder或者HttpServletRequest,Quartz的线程池不是Web请求线程,取不到Web层的请求上下文。跨Job之间如果需要传递业务上下文,维护一个线程级别的上下文对象,或者把参数放进JobDataMap。
这套方案我已经在两个业务项目里跑了一年多,动态任务、重启恢复、集群部署都验证过。如果你正准备把项目里的定时任务从注解方式升级成Quartz,照着这条路走,能少踩不少坑。