1. 项目概述:先搞清楚为什么要整合Quartz
先说结论:如果你只是想在Spring Boot里跑个定时任务,@Scheduled注解其实够用,但一旦任务涉及动态调度、持久化、集群部署或者复杂的触发策略,@Scheduled就捉襟见肘了。这时候,Quartz几乎是Java生态里的首选方案。
我这个项目的背景很简单——系统里有大量定时任务,包括报表生成、数据同步、缓存预热、对账批处理等,原来全是@Scheduled硬编码。后来需求变成了“运营人员要在后台页面上随时修改任务执行频率”,甚至要临时暂停某个任务,还要能查看任务的执行历史。@Scheduled显然做不到动态管理,于是调研了一圈,决定把调度层换成Quartz。
为什么是Quartz而不是别的?市面上Java定时任务方案其实不少:@Scheduled、xxl-job、Elastic-Job、Quartz。xxl-job和Elastic-Job都带可视化管理界面,功能很全,但它们的强项是分布式任务调度平台,引入成本偏高,需要额外部署调度中心。Quartz作为老牌调度库,轻量、成熟、和Spring Boot整合方案极其完善,单机场景下够稳,搭配数据库表还能做到任务持久化和简单的高可用。对我这个既要“快速落地”又要“保留扩展空间”的项目来说,Quartz是最稳妥的中间选择。
这篇博文就围绕“Spring Boot整合Quartz”这个主题,从零开始讲清楚两件事:一是整合的基础步骤,二是整合时容易踩的坑和应对方案。不搞花里胡哨的封装,直接给你能落地的代码和配置。
2. 整合前的准备工作:依赖和基础知识
2.1 Maven依赖怎么加
Spring Boot整合Quartz其实非常简单,它提供了官方starter——spring-boot-starter-quartz。这个starter不仅帮你引入了Quartz的核心库,还会自动配置SchedulerFactoryBean和Scheduler,省掉了手工创建工厂的繁琐步骤。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-quartz</artifactId> <version>2.7.18</version> </dependency>如果你的项目用的是Spring Boot 3.x,spring-boot-starter-quartz的版本号跟着Spring Boot父BOM走即可,不需要显式指定版本。注意Quartz本身版本已经从2.x升级到了3.x,API有一些变化,我们下面主要基于Spring Boot 2.x/Quartz 2.3的稳定组合来讲,兼容性更好。
2.2 Quartz的核心概念,用生活类比讲清楚
Quartz里最核心的接口有三个:Job、Trigger、Scheduler。很多人一开始会被这三个概念绕晕,我打个比方:
Job(任务):就是“要做什么”。你写一个类实现Job接口,在execute方法里写业务逻辑。相当于你请了一个跑腿小哥,他负责干什么活。Trigger(触发器):就是“什么时候做”。它定义任务的触发规则,是用Cron表达式、还是固定间隔、还是每天几点。相当于给跑腿小哥安排了一个闹钟。Scheduler(调度器):就是“谁来管这个闹钟”。它负责注册Job和Trigger,让它们按规则跑起来。相当于跑腿公司的调度中心,统筹所有小哥和闹钟。
这三者的协作关系是:Scheduler把JobDetail(Job的详细描述,包含任务名、任务组、具体执行类)和Trigger绑定在一起,注册到调度容器里,到了触发时间就执行Job里的逻辑。
2.3 一个最容易忽略的点:JobDetail和Job的区别
很多新手写Quartz会直接new一个Job去执行,其实Quartz里注册的并不是Job本身,而是JobDetail。JobDetail是Job的描述信息,它持久化了任务的名字、分组、参数等元数据,每次触发时,Quartz会通过反射创建出一个新的Job实例来执行execute方法。
为什么是“每次触发重新创建实例”?因为Quartz的设计目标是任务隔离——不同的触发事件不应该共享同一个Job实例的状态,避免并发执行时出现数据混乱。这个设计也带来了一个常见的坑:Job里无法通过@Autowired直接注入Spring Bean,因为Job实例是由Quartz自己反射创建的,不在Spring容器管理范围内。后面第三章我会专门讲怎么解决这问题。
2.4 配置文件先准备好
依赖加好之后,application.yml里其实什么都不配置也能跑起来。Quartz默认使用RAM存储(内存),重启任务就没了。但既然是整合,我建议至少把线程池和实例名先配好:
spring: quartz: job-store-type: memory scheduler-name: my-scheduler properties: org.quartz.threadPool.threadCount: 8 org.quartz.threadPool.threadPriority: 5 org.quartz.jobStore.misfireThreshold: 60000threadCount是调度线程池大小,默认是4。如果你的任务数量多、执行时间长,建议调大到8或16。misfireThreshold是“任务错过触发时长的阈值”,单位毫秒,默认60000(1分钟),超过这个阈值会被判定为misfire。这里先记住这两个参数,后面排查问题时你会回来找它们的。
3. 核心细节解析:三种Trigger怎么选
3.1 CronTrigger:最常用,必掌握
CronTrigger基于Cron表达式触发,支持秒级到年级的执行规则。这是项目里用得最多的触发器,几乎所有“每天凌晨2点执行”“每小时的15分执行”这类需求都是它搞定。
Cron表达式格式:秒 分 时 日 月 周,共6位(Spring Boot整合的Quartz里是6位,不是常见的Unix Cron 5位)。举个例子:0 0 2 * * ?表示每天凌晨2点整执行。注意周字段用?表示“不指定”,这也是Quartz Cron和Unix Cron的一个明显区别。
使用示例:
CronTrigger trigger = TriggerBuilder.newTrigger() .withIdentity("report-trigger", "default-group") .withSchedule(CronScheduleBuilder.cronSchedule("0 0 2 * * ?")) .build();这里最多人懵的地方就是?和*的区别。*表示“每一刻”都触发,比如日字段写*表示每天都执行;周字段写?表示“不关心星期几”,避免“日和周同时指定导致语义冲突”。Quartz规定,日和周字段必须有一个设置为?,不然会报错。这个规则我项目里第一次写时就踩到过,Cron表达式怎么校验都过不去,后来才发现问题出在“周字段必须为?或者日和周互斥”这个细节上。
3.2 SimpleTrigger:适合固定间隔重复执行
如果你不需要复杂的Cron规则,只是想“每5分钟执行一次”或者“延迟10秒后执行一次”,那SimpleTrigger更合适。它支持指定起始时间、重复次数、重复间隔。
SimpleTrigger trigger = TriggerBuilder.newTrigger() .withIdentity("sync-trigger", "default-group") .startNow() .withSchedule(SimpleScheduleBuilder.simpleSchedule() .withIntervalInSeconds(300) .repeatForever()) .build();repeatForever()表示无限重复,如果你只想固定次数,用.withRepeatCount(10)就行。注意repeatForever和withRepeatCount只能选一个,否则运行时报错。
3.3 CalendarIntervalTrigger:处理“每月”“每周”这类规则
这个触发器用的相对少,但很实用——比如“每三个月执行一次”。Cron表达式表达这种跨月的间隔其实非常别扭,而CalendarIntervalTrigger可以按SECOND、MINUTE、HOUR、DAY、WEEK、MONTH、YEAR等粒度来设置间隔。
CalendarIntervalTrigger trigger = TriggerBuilder.newTrigger() .withIdentity("quarterly-trigger", "default-group") .withSchedule(CalendarIntervalScheduleBuilder.calendarIntervalSchedule() .withIntervalInMonths(3)) .build();这个触发器的好处是它排除了夏令时和时区的影响,对“每个月最后一天执行”这类需求也支持得很好。
3.4 选择建议
我整理了一个简表,方便你按需选择:
| 场景 | 推荐Trigger | 理由 |
|---|---|---|
| 每天/每周/每月固定时间执行 | CronTrigger | 表达式灵活,支持复杂规则 |
| 固定间隔重复,如每5分钟 | SimpleTrigger | 配置简单,延迟+重复次数直观 |
| 每月/每季度/每年间隔执行 | CalendarIntervalTrigger | 跨周期表达清晰,免去Cron计算 |
| 需要忽略某些特定日期 | CronTrigger + HolidayCalendar | 配合Calendar排除节假日 |
4. 实操过程:从零搭一个可运行的完整案例
4.1 编写Job类
先创建一个任务类,继承QuartzJobBean(Spring Boot整合包提供的基类),或者实现Job接口。
@Component public class SyncDataJob implements Job { @Override public void execute(JobExecutionContext context) throws JobExecutionException { JobDataMap dataMap = context.getJobDetail().getJobDataMap(); String taskName = dataMap.getString("taskName"); String tableName = dataMap.getString("tableName"); System.out.println("[" + LocalDateTime.now() + "] 执行任务:" + taskName + ",目标表:" + tableName); // 这里写真正的业务逻辑,比如同步数据 } }这里有个关键点:我在Job里用JobExecutionContext拿JobDataMap中的参数。JobDataMap就是给你放业务参数用的,运行时可动态传入。为什么推荐这种写法?因为一个Job类可能被多个Trigger触发,每次触发的参数可能不同,通过JobDataMap传递既能解耦,又能灵活控制每次执行的行为。
4.2 配置JobDetail和Trigger
接下来创建一个配置类,把JobDetail和Trigger装配成Spring Bean,框架启动时会自动注册定时任务。
@Configuration public class QuartzConfig { @Bean public JobDetail syncDataJobDetail() { JobDataMap jobDataMap = new JobDataMap(); jobDataMap.put("taskName", "sync-order-data"); jobDataMap.put("tableName", "t_order"); return JobBuilder.newJob(SyncDataJob.class) .withIdentity("syncDataJob", "data-group") .usingJobData(jobDataMap) .storeDurably() .build(); } @Bean public Trigger syncDataTrigger() { return TriggerBuilder.newTrigger() .forJob(syncDataJobDetail()) .withIdentity("syncDataTrigger", "data-group") .withSchedule(CronScheduleBuilder.cronSchedule("0 0/30 * * * ?")) .build(); } }这里有几个细节值得强调:
第一,storeDurably()是必须的。如果你只是创建一个JobDetail不设置storeDurably(true),Quartz会认为该任务没有绑定Trigger就无意义,启动时会报“Jobs added with no trigger must be durable”错误。
第二,forJob(syncDataJobDetail())把Trigger和JobDetail绑定起来,但这里传的是JobDetail对象,不是再构建一个JobKey字符串。这样两个Bean通过方法调用产生关联,Spring容器初始化时不会搞错顺序。
第三,JobDataMap的键值都是字符串,运行时在Job里可以通过context.getMergedJobDataMap()获取,注意是“merged”,它会合并JobDetail和Trigger上绑定的所有数据。
4.3 动态调度服务类
只是配置好定时任务还不够,项目里更大的需求是“运行中随时创建新任务”,比如运营在后台设置一个新任务的执行时间。这时候需要注入Scheduler,自己写一个动态调度服务。
@Service public class QuartzDynamicService { @Autowired private Scheduler scheduler; /** * 动态创建定时任务 */ public void addJob(Class<? extends Job> jobClass, String jobName, String jobGroup, String cron, JobDataMap params, String triggerName, String triggerGroup) throws SchedulerException { JobDetail jobDetail = JobBuilder.newJob(jobClass) .withIdentity(jobName, jobGroup) .usingJobData(params) .storeDurably() .build(); Trigger trigger = TriggerBuilder.newTrigger() .withIdentity(triggerName, triggerGroup) .forJob(jobDetail) .withSchedule(CronScheduleBuilder.cronSchedule(cron)) .build(); // 先移除存在的,避免重复注册 if (scheduler.checkExists(jobDetail.getKey())) { scheduler.deleteJob(jobDetail.getKey()); } scheduler.scheduleJob(jobDetail, trigger); } /** * 暂停任务 */ public void pauseJob(String jobName, String jobGroup) throws SchedulerException { scheduler.pauseJob(JobKey.jobKey(jobName, jobGroup)); } /** * 恢复任务 */ public void resumeJob(String jobName, String jobGroup) throws SchedulerException { scheduler.resumeJob(JobKey.jobKey(jobName, jobGroup)); } /** * 删除任务 */ public void deleteJob(String jobName, String jobGroup) throws SchedulerException { scheduler.deleteJob(JobKey.jobKey(jobName, jobGroup)); } }这段代码其实就是整个动态调度的核心了。注意几个方法都是SchedulerException向上抛出,业务层需要捕获并转成友好提示。
4.4 调用示例
在接口或Service里调用动态调度服务:
@RestController @RequestMapping("/task") public class TaskController { @Autowired private QuartzDynamicService quartzDynamicService; @PostMapping("/add") public String addTask(@RequestParam String jobName, @RequestParam String cron) { JobDataMap params = new JobDataMap(); params.put("taskName", jobName); params.put("createBy", "admin"); try { quartzDynamicService.addJob(SyncDataJob.class, jobName, "dynamic-group", cron, params, jobName + "-trigger", "dynamic-group"); return "任务添加成功"; } catch (SchedulerException e) { return "任务添加失败:" + e.getMessage(); } } }这样下来,一个最简化的“Spring Boot整合Quartz + 动态任务管理”骨架就搭好了。启动项目后,控制台会每30分钟输出一次同步日志,同时你也可以通过HTTP接口动态添加新任务。
5. 进阶配置:持久化与集群,生产环境必须考虑的事
5.1 RAM还是JDBC,这是个问题
开发阶段用RAM存储没问题,重启项目任务没了也无所谓。但上到生产环境,如果你的任务因为服务器重启就全部消失,这在业务上不能接受。Quartz提供了JDBC存储模式,把JobDetail、Trigger、Calendar等元数据存到数据库表中,项目重启后任务自动恢复。
配置方式在application.yml里:
spring: quartz: job-store-type: jdbc jdbc: initialize-schema: always properties: org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.PostgreSQLDelegate org.quartz.jobStore.tablePrefix: QRTZ_关键是新增了job-store-type: jdbc和对应的数据库表。Quartz官方提供了建表SQL脚本,在Quartz发行包的docs/dbTables目录下,按数据库类型选对应的sql执行即可。如果你不愿意手工建表,Spring Boot还可以在启动时自动执行建表脚本,通过spring.quartz.jdbc.initialize-schema控制,有always(启动时总是初始化)和never(不初始化)两个选项。
要不要自动初始化建表?我建议是生产环境用never,手工执行官方SQL脚本建表,这样表结构可控、不会误删已有数据。always模式有风险——如果项目里已有QRTZ_表,启动时可能因为表已存在而报错。
5.2 集群部署的坑
Quartz还支持集群部署,多个实例共享同一个数据库,通过数据库行锁保证同一时间只有一个实例执行任务。配置上主要是:
spring: quartz: properties: org.quartz.jobStore.isClustered: true org.quartz.jobStore.clusterCheckinInterval: 15000增加isClustered: true后,所有实例的调度任务会在数据库层做抢占式调度。这里有个大坑:集群模式下,任务执行的并发问题不能靠Quartz自动解决。比如一个任务A,两个实例同时启动,虽然Quartz会保证同一个Trigger只在一个实例上触发,但如果你的任务本身执行时间很长(超过调度间隔),上一次还没跑完,下一次触发也可能被同一实例再次调度,造成重入。
解决办法是在Job类上标注@DisallowConcurrentExecution注解:
@DisallowConcurrentExecution @Component public class SyncDataJob implements Job { // ... }这个注解的含义是:同一JobDetail的多个触发之间不能并发执行,如果上一次还没执行完,下一次触发会被阻塞或跳过。这是Quartz性能优化和安全运行的关键开关,生产环境务必加上。
5.3 misfire策略怎么选
用了JDBC持久化后,触发机制变成了“触发时间点到了才从库里读取并执行”。如果任务错过了触发时间(比如应用停了一段时间、线程池满了),就会进入“misfire”状态。Quartz对misfire有几种处理策略,常见的有:
MISFIRE_INSTRUCTION_SMART_POLICY(默认):根据Trigger类型自动选择合适策略。MISFIRE_INSTRUCTION_FIRE_ONCE_NOW:立即补执行一次。MISFIRE_INSTRUCTION_DO_NOTHING:直接跳过错过的触发,等下一个周期。
对于报表任务、数据同步任务,我一般用FIRE_ONCE_NOW,保证数据不缺失;对于那种“固定时间点必须执行,错过就无意义”的任务,比如定时推送消息,用DO_NOTHING更合理,因为用户已经错过了最佳接收时间,补发反而造成骚扰。
设置方式是:
CronScheduleBuilder cronSchedule = CronScheduleBuilder.cronSchedule("0 0 2 * * ?") .withMisfireHandlingInstructionFireAndProceed();FireAndProceed就是“立即执行,并按新时间表计算下一次触发时间”。withMisfireHandlingInstructionDoNothing则对应“跳过”。
6. 踩坑实录与排查技巧
6.1 Job中无法注入Spring Bean,出现NPE
前面提到过,QuartzJobBean或者Job实现类的实例由Quartz内部反射创建,不受Spring容器管理,所以@Autowired一个Service进去会得到null。
解决办法有三种:
第一,实现ApplicationContextAware,把ApplicationContext静态化,在Job里直接通过它拿Bean:
@Component public class SpringContextUtils implements ApplicationContextAware { private static ApplicationContext applicationContext; @Override public void setApplicationContext(ApplicationContext appContext) { applicationContext = appContext; } public static <T> T getBean(Class<T> clazz) { return applicationContext.getBean(clazz); } }Job里这样用:
public class SyncDataJob implements Job { @Override public void execute(JobExecutionContext context) { UserService userService = SpringContextUtils.getBean(UserService.class); userService.syncUserData(); } }第二,用SpringBeanJobFactory扩展版。Spring Boot的QuartzAutoConfiguration已经内置了AutowireSpringBeanJobFactory,它会在Job实例化后自动调用autowireBean,所以理论上你直接@Autowired就能生效。但某些自定义配置场景可能覆盖了这个工厂,导致注入失败。所以稳妥做法还是第一种。
第三,使用QuartzJobBean作为基类。QuartzJobBean是Spring提供的抽象类,它重写了execute,先调用BeanFactory对Job进行属性注入,然后调用executeInternal方法。你只需要继承它,在executeInternal里使用注入的属性即可:
@Component public class SyncDataJob extends QuartzJobBean { @Autowired private UserService userService; @Override protected void executeInternal(JobExecutionContext context) throws JobExecutionException { userService.syncUserData(); } }这个方法最优雅,推荐优先使用。
6.2 Cron表达式校验报错
常见的错误是“Expression does not conform to Quartz Cron format”,原因基本都是:周字段同时和日字段有值,而没有使用?。比如0 0 2 * * MON就报错,因为“每天2点”和“每周一”冲突了。正确写法是0 0 2 ? * MON,表示“每周一凌晨2点”。
还有一个坑:0 0 2 * * ?里第6位是?,但有人习惯用*,运行时会直接抛RuntimeException。记住:Quartz Cron的周字段如果不用?,就会出现?不能和*共存的矛盾。用在线工具验证时,也要选Quartz格式而不是Linux Cron格式。
6.3 任务明明配了,却不执行
优先级最高的怀疑对象是调度线程池太小。Quartz默认线程数4,如果某个任务执行时间很长,比如数据库查询耗时5分钟,线程全被占满,后面的Trigger虽然触发但排不到线程,看起来就是“没执行”。此时去数据库QRTZ_TRIGGERS表查,状态可能一直处于WAITING或PAUSED之间切换,但不会变COMPLETE。
排查步骤:
- 检查
spring.quartz.properties.org.quartz.threadPool.threadCount配置。 - 在Job执行入口打日志,确认是否被调度。
- 查看日志里的
QuartzScheduler信息,看线程池是否饱和。 - 用
JConsole或Arthas查看线程状态。
如果业务上允许,尽量把耗时操作异步化,或者调大线程数。
6.4 重启项目后任务丢了一半
这几乎是100%的RAM存储问题。确认三点:
spring.quartz.job-store-type是否配置为jdbc。QRTZ_系列表是否存在(至少11张核心表)。- 项目启动时是否成功恢复了Trigger和Job。
排查时可以直接查QRTZ_TRIGGERS表,看里面有没有数据。如果表为空,说明配置没生效,看看initialize-schema是否被误配置成never。
6.5 时区问题:服务器跑起来任务时间不准
Quartz默认使用服务器默认时区。不同机房、不同云厂商的服务器默认时区可能都是UTC,导致Cron表达式表现出的执行时间和你想要的时间差8小时。
解决方式:
CronScheduleBuilder.cronSchedule("0 0 2 * * ?") .inTimeZone(TimeZone.getTimeZone("Asia/Shanghai"));或者在Trigger上整体配置:
TriggerBuilder.newTrigger() .withSchedule(cronSchedule) .startAt(Date.from(ZonedDateTime.now(ZoneId.of("Asia/Shanghai")).toInstant())) .build();生产环境建议统一在配置文件中指定时间区:
spring: quartz: properties: org.quartz.scheduler.timeZone: Asia/Shanghai6.6 动态添加任务时,同名任务被静默覆盖
scheduleJob(jobDetail, trigger)如果jobDetail已存在且不是durable,会抛ObjectAlreadyExistsException。所以我在动态服务类里先checkExists再deleteJob是很有必要的。另外如果重复添加同名Trigger,Quartz也会直接报错。要么先删除再添加,要么用rescheduleJob做更新:
Trigger oldTrigger = scheduler.getTrigger(TriggerKey.triggerKey(triggerName, triggerGroup)); if (oldTrigger != null) { scheduler.rescheduleJob(oldTrigger.getKey(), newTrigger); } else { scheduler.scheduleJob(jobDetail, trigger); }这比“删除再添加”更稳,因为它保留了任务的历史状态和关联JobDetail,不会丢数据。
7. 最后的一些额外心得
聊几个我在项目里经过几轮迭代才想明白的细节。
一个是关于任务表的设计。Quartz自身有QRTZ_系列表存元数据,但这些表不适合直接做业务查询。我在业务库里额外建了一张sys_job_record执行记录表,每次Job执行开始、结束、异常都往里面插一条记录。这样运营能直观看到任务跑没跑、跑了多久、失败原因是什么。这一层“业务记录”是Quartz官方管理界面给不了你的,得自己补。
另一个是异常处理。Job里抛出异常时,Quartz会根据JobExecutionException判断是继续还是停止任务。如果想让任务失败后下次继续执行,就不要抛出异常,而是捕获后记录日志;如果想让任务在这个Trigger周期内彻底不跑了,可以调用context.getScheduler().pauseJob(context.getJobDetail().getKey())。通常是“失败后告警+下个周期重跑”的组合策略更合理。
第三点是关于线程池调优。Quartz单机模式下,线程池不是越大越好。线程太多会导致任务并发增加,数据库连接、外部接口压力随之上升。我现在的经验是:任务总数在30个以内、单任务耗时5秒以下的场景,threadCount=8足够;单任务耗时长、任务数多时,先拆任务或者考虑分布式调度方案,而不是一味加线程。
最后一个比较实用的小技巧:在Job里通过context.getFireTime()拿到的触发时间,可以用来做“幂等”判断。比如一个同步任务,同一分钟内被重复触发多次,你可以用fireTime的分钟粒度作为幂等键,配合数据库唯一索引,保证数据不会被重复写入。不用太复杂的逻辑,一个小字段就搞定。
Spring Boot整合Quartz这件事,难度其实不在“整合”本身,而在于整合完了之后如何稳定运行、如何动态管理、如何排查问题。希望这篇博文里的经验对你的项目有直接帮助。