写Spring Boot整合Quartz这个方法,我估计你们多半是遇到了这么几种情况:项目里要用到比@Scheduled更靠谱的定时任务,或者任务多了需要持久化、需要动态管理,再或者公司要求甩掉xxl-job这类重框架,让调度逻辑跟业务代码住在一起。Quartz是老牌调度框架,Spring Boot从2.x开始官方就提供了spring-boot-starter-quartz,整合起来其实比你想象中简单,但网上教程七零八落,有的只讲内存模式,有的配置写得让人一脸懵。这篇我一次性讲清楚,从最基础的快速跑通,到JDBC持久化、集群部署、动态任务管理,再到我踩过的坑,全部用实际能跑的代码说话。
1. 动手之前:把Quartz的调度模型先吃透
1.1 为什么选Quartz而不是Spring自带的@Scheduled
很多初学者上来就问:我直接用@Scheduled不行吗?说实话,如果你的需求只是“每天早上8点跑个清理任务”“每隔5分钟拉取一次数据”,那@Scheduled绰绰有余,根本不用上Quartz。但一旦遇到下面这些场景,@Scheduled就明显不够用了:
- 任务需要动态创建、暂停、恢复、删除,运行期间要能调整触发时间。
- 任务跑挂了需要自动恢复,服务重启后任务不能丢,得能从数据库重新加载。
- 多个实例部署(集群)时,同一个任务只能被一个节点执行,不能重复跑。
- 业务上需要精确的调度时间管理,比如错过触发时间后是否要补跑。
这些能力恰恰是Quartz的看家本领。Quartz本身就是一套完整的任务调度中间件,支持持久化、集群、事务、多种Trigger策略,而@Scheduled只是Spring封装的一个轻量注解,既不能持久化也不支持集群。
1.2 核心三件套:Scheduler、JobDetail、Trigger
Quartz的模型不复杂,就三个核心对象来回折腾。
- Job:任务逻辑本身,就是你写的业务代码,比如“生成报表”“发送短信”。在实际代码里,Job是一个接口,要实现execute(JobExecutionContext context)方法。
- JobDetail:Job的“说明书”,描述某个Job的元信息。一个Job类可以创建多个JobDetail,每个JobDetail可以携带不同的JobDataMap参数,相当于同一个Job类、不同配置的多个任务实例。
- Trigger:触发器,决定JobDetail什么时候执行。最常用的是CronTrigger(按Cron表达式调度)和SimpleTrigger(按固定间隔调度)。
这三者的关系,用一个生活化的类比来解释:Job像是“洗衣服”这个动作本身,JobDetail是“把这堆脏衣服放进洗衣机”的具体任务单,Trigger是“每周六晚上8点开始洗”的闹钟。调度器Scheduler就是那个统筹全局的人,它手里拿着任务单和闹钟,到点就把活儿派下去。
1.3 Maven工程怎么搭,目录怎么摆
这里顺便把Maven构建Spring Boot项目也说一下,很多人卡在第一步。用IntelliJ IDEA直接新建Spring Initializr项目是最快的,选Java 8或11、Spring Boot 2.7.x就够用。如果你更习惯手工创建,pom.xml里加这些内容:
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-quartz</artifactId> </dependency> </dependencies>目录规范上,我习惯把Quartz相关代码独立分包,别跟业务Service混在一起:
com.example.demo ├── config // Quartz配置类 ├── job // 所有Job类 ├── service // 业务Service ├── controller // 如果要做动态任务管理,放Controller └── DemoApplication.java这个结构看起来简单,但保持下去能让后面的动态任务、定时任务日志排查省很多事。尤其项目大了之后,Job类散落在各个业务包里,找人调试都是灾难。
2. 跑通第一个Quartz任务:比你想的简单
2.1 加依赖、建Job类,三步走
引入依赖后,一个最简的Quartz任务只需要三步:
第一步,写一个Job类。这里有个重要选择:继承QuartzJobBean还是直接实现Job接口。官方推荐Spring环境下用QuartzJobBean,因为它能拿到Spring容器里的Bean,也就是说你可以在Job里注入Service。看代码:
@Component public class SimpleJob extends QuartzJobBean { private static final Logger log = LoggerFactory.getLogger(SimpleJob.class); @Autowired private HelloService helloService; @Override protected void executeInternal(JobExecutionContext context) throws JobExecutionException { log.info("SimpleJob 开始执行,当前时间:{}", LocalDateTime.now()); helloService.sayHello(); } }第二步,创建配置类,把JobDetail和Trigger注册成Bean。Spring Boot的Quartz自动配置会把这些Bean自动交给Scheduler管理:
@Configuration public class QuartzConfig { @Bean public JobDetail simpleJobDetail() { return JobBuilder.newJob(SimpleJob.class) .withIdentity("simpleJob") .storeDurably() .build(); } @Bean public Trigger simpleJobTrigger() { CronScheduleBuilder cronScheduleBuilder = CronScheduleBuilder.cronSchedule("0/10 * * * * ?"); return TriggerBuilder.newTrigger() .forJob(simpleJobDetail()) .withIdentity("simpleJobTrigger") .withSchedule(cronScheduleBuilder) .build(); } }第三步,直接启动应用。Spring Boot会自动识别JobDetail和Trigger,到点就执行。
这里有个细节值得注意:storeDurably()。它的意思是,即使没有Trigger指向这个JobDetail,也把它保存在调度器中。因为JobDetail和Trigger是分开注册的,如果你后续要动态给这个Job加Trigger,不加storeDurably()会报“job will not be durable”的错误。我建议凡是手动创建的JobDetail都加上。
2.2 两种Job写法的区别:QuartzJobBean和普通Job接口
有人可能看过另一种写法:
public class SimpleJob implements Job { @Override public void execute(JobExecutionContext context) throws JobExecutionException { // ... } }然后配JobBuilder.newJob(SimpleJob.class)。
这种写法能用,但要注意:直接实现Job接口时,Spring容器管理的@Autowired依赖注入默认是拿不到的,因为Quartz实例化Job是通过字节码反射创建的,不是Spring的Bean生命周期。除非你自己写SchedulerFactoryBeanCustomizer或者用AutowireCapableBeanFactory手动注入,比较麻烦。
所以我的建议是:能继承QuartzJobBean就继承它,省事又稳,Job内部照常使用Spring的注入能力。至于网上说的“QuartzJobBean每次执行的Job实例是new出来的,非单例”,这句话本身没错,但这恰恰是Quartz的设计——状态隔离,Job实例本来就不该被多个线程共享。
2.3 Cron表达式最容易写错的几个地方
Quartz的Cron表达式跟Linux的Crontab还有Spring的@Scheduled都不完全一样,最大区别是:Quartz是7位的(秒、分、时、日、月、周、年可选),而且“日”和“周”不能同时指定,必须有一个是?。
常用示例:
| 表达式 | 含义 |
|---|---|
0/10 * * * * ? | 每10秒执行一次 |
0 0 8 * * ? | 每天早上8点执行 |
0 0 2 * * ? | 每天凌晨2点执行 |
0 0 0/2 * * ? | 每隔2小时执行 |
0 0 10,14,16 * * ? | 每天10点、14点、16点执行 |
0 0 12 * * ? * | 每天12点执行(每年,可省略) |
0 15 10 ? * MON-FRI | 周一到周五每天10:15执行 |
0 0 0 1 * ? * | 每月1号0点执行 |
踩坑提醒:如果你的表达式写了“周”字段,比如? * MON-FRI,那“日”字段必须是?,写个具体数字会校验失败。另外Quartz的周是从周日=1开始,跟Java的DayOfWeek不完全一致,别搞混。
3. 任务持久化与集群:正式项目绕不过去的坎
3.1 默认的内存存储到底行不行
Spring Boot的Quartz默认使用RAMJobStore,也就是任务信息全在内存里。好处是零配置、启动快,适合单机、任务少、允许丢任务的场景。
但问题也很明显:
- 服务一重启,所有任务消失,需要重新注册。
- 多个实例部署时,每个实例各调度各的,同一任务会被执行多次。
比如你有个每天凌晨跑报表的Job,服务偶尔重启一次,报表就漏跑了。短时间看问题不大,但审计问起来很麻烦。
所以生产环境,我基本无条件建议上JDBC存储,也就是JDBCJobStore,把JobDetail、Trigger、调度状态都持久化到数据库。
3.2 用Spring Boot配置JDBC存储
Spring Boot对Quartz的自动配置其实很贴心,你只需要在application.yml里加配置:
spring: quartz: job-store-type: jdbc jdbc: initialize-schema: always properties: org.quartz.scheduler.instanceName: MyScheduler org.quartz.scheduler.instanceId: AUTO org.quartz.jobStore.isClustered: true org.quartz.jobStore.clusterCheckinInterval: 20000 org.quartz.jobStore.class: org.springframework.scheduling.quartz.LocalDataSourceJobStore注意几点:
job-store-type: jdbc是关键,Spring Boot会根据这个值自动使用LocalDataSourceJobStore,这个Store会复用你配置的数据源,不用单独写Quartz的数据源。initialize-schema: always表示启动时自动执行建表SQL。Quartz需要的表有11张,包括QRTZ_JOB_DETAILS、QRTZ_TRIGGERS、QRTZ_SIMPLE_TRIGGERS、QRTZ_CRON_TRIGGERS、QRTZ_LOCKS等。首次启动建议用always,表建好之后改成never,避免每次启动都去检查,省点性能。isClustered: true表示集群模式,多个实例共享同一个数据库,Quartz通过数据库锁保证同一个任务同一时间只在一个节点运行。
3.3 集群部署必须搞清楚的原理
集群模式下,Quartz不是靠某个“中央节点”来分发任务,而是靠数据库行锁。每个实例启动时都会在QRTZ_LOCKS表里抢锁,谁抢到谁执行。执行完释放锁,其他实例再抢下一个任务。
这样一来,只要所有实例连接的是同一个数据库,Quartz能保证:
- 同一个JobDetail + Trigger组合,同一时刻只在一个实例上执行。
- 实例挂了之后,其他实例会通过
clusterCheckinInterval周期性地检查(默认15秒),发现某个实例失联后会接管那个实例的任务。
但这里有一个关键配置容易被忽略:org.quartz.scheduler.instanceId。集群模式下必须确保每个实例的instanceId不一样,一般设成AUTO,让Quartz自动生成基于主机名和时间戳的唯一值。如果你手写死了,两个实例的id一样,会出问题。
还有,集群模式的时间精度依赖数据库的锁,任务调度频率太密的话(比如每秒一次),数据库锁竞争会很严重。所以集群方案更适合分钟级以上的任务,秒级任务优先考虑分布式锁配合其他方案。
3.4 用国产数据库要注意哪些
热词里有人搜“quartz 达梦数据库”,这个我必须说一句。Quartz的JDBCJobStore底层依赖标准JDBC,理论上任何支持JDBC的数据库都能跑。但实际项目里,我踩过国产数据库适配的坑,主要集中在这几处:
- 建表SQL:Quartz官方提供的
tables_*.sql主要覆盖MySQL、PostgreSQL、Oracle、SQL Server等。国产数据库通常兼容MySQL或Oracle模式,建表SQL不能直接全套照搬,要根据兼容模式调整字段类型。比如达梦用MySQL兼容模式时,BLOB、CLOB的处理要格外小心。 - 锁机制:
QRTZ_LOCKS表依赖数据库的行锁特性。达梦等数据库在事务隔离级别、悲观锁行为上和MySQL有细微差别,容易出现锁等待超时。这时候把org.quartz.jobStore.lockHandler换成对应的Semaphore实现能缓解。 - 驱动配置:驱动类名、URL前缀都跟MySQL不同,
spring.datasource.driver-class-name要写对。另外国产数据库对FOR UPDATE支持程度不同,遇到死锁异常时不要慌,检查数据库的锁超时参数。
实话实说,除非公司强制要求国产数据库,否则我还是优先选MySQL/PostgreSQL跑Quartz,适配成本低得多。但如果你确实要适配达梦,建议先拿官方SQL脚本加手工改造,在测试环境把调度、集群、恢复这些场景全部过一遍再上线。
4. 动态任务管理:让调度器被业务管起来
4.1 为什么说“配置写死”只是入门玩法
上一节的配置,Job和Trigger写死在代码里,业务想加一个任务就得改代码重新发布。真实项目里,运营或管理员经常需要在前台页面自己维护定时任务——比如“每天凌晨2点同步一次商品数据”“这个活动结束前每小时推送一次提醒”。这些任务数量不定、时间不定,甚至可能需要用户传参。这时候就需要动态任务管理,也就是通过API动态地创建、暂停、恢复、删除、修改调度任务。
Quartz在这块设计得相当成熟,Scheduler接口本身就是一套完整的操作API。你不需要改任何配置,运行期调用几个方法就行。
4.2 动态创建、暂停、恢复、删除的完整实现
先在Service里注入Scheduler:
@Service public class QuartzJobService { @Autowired private Scheduler scheduler; public void createJob(String jobName, String jobGroup, String cron, Map<String, Object> params) throws SchedulerException { JobKey jobKey = JobKey.jobKey(jobName, jobGroup); if (scheduler.checkExists(jobKey)) { throw new RuntimeException("任务已存在"); } JobDetail jobDetail = JobBuilder.newJob(DynamicJob.class) .withIdentity(jobKey) .storeDurably() .build(); jobDetail.getJobDataMap().putAll(params); CronTrigger trigger = TriggerBuilder.newTrigger() .withIdentity(jobName + "Trigger", jobGroup) .withSchedule(CronScheduleBuilder.cronSchedule(cron)) .build(); 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)); } public void updateCron(String jobName, String jobGroup, String cron) throws SchedulerException { TriggerKey triggerKey = TriggerKey.triggerKey(jobName + "Trigger", jobGroup); CronTrigger oldTrigger = (CronTrigger) scheduler.getTrigger(triggerKey); CronTrigger newTrigger = oldTrigger.getTriggerBuilder() .withSchedule(CronScheduleBuilder.cronSchedule(cron)) .build(); scheduler.rescheduleJob(triggerKey, newTrigger); } }这里几个点很关键:
Scheduler是线程安全的,可以注入到Service里随便用。checkExists先判断任务是否存在,避免重复创建。生产上我还会把任务名、任务组作为唯一键做一层数据库校验,防止并发操作。- 动态任务里,Job类通常是同一个
DynamicJob,真正的业务逻辑根据JobDataMap里的参数分发,比如jobType字段区分不同类型。
4.3 JobDataMap传参的细节
JobDataMap本质是一个Map,Quartz序列化后存在数据库里(JDBC模式下)。传参虽然简单,但有几个细节不注意会踩雷:
- 传进去的Param值,在JDBC模式下通常以二进制序列化(或JSON)方式存库。所以参数对象必须是可序列化的,否则抛异常。最好只传简单类型、字符串、DTO,别塞复杂对象。
- 在Job里取参数有两个时机:
executeInternal方法里取的是当前Job实例的JobDataMap,而context.getMergedJobDataMap()会合并JobDetail和Trigger上的数据,后者更常用,因为有些参数在创建Trigger时也会通过usingJobData塞进去。 - 参数变更后,如果不重新trigger,旧参数会保留。比如你调了
rescheduleJob,但没动JobDetail,JobDataMap里的参数还是旧的。需要更新参数时,必须先getJobDetail()改JobDataMap,再addJob重新存储。
public void updateJobParam(String jobName, String jobGroup, String key, Object value) throws SchedulerException { JobKey jobKey = JobKey.jobKey(jobName, jobGroup); JobDetail jobDetail = scheduler.getJobDetail(jobKey); jobDetail.getJobDataMap().put(key, value); scheduler.addJob(jobDetail, true); }注意addJob的第二个参数true表示替换已有的JobDetail。这块是最容易被忽略的,很多人在动态修改参数时发现不生效,就是没重新store JobDetail。
5. 生产环境排查经验:我在实际项目里踩过的坑
5.1 时区问题导致定时任务偏移
这个坑我印象很深。项目上线后业务反馈“凌晨2点的报表任务总是2点过几分才跑”,排查发现不是Quartz跑慢了,而是服务器时间本身有偏差,而且Quartz调度器不感知系统时间变化,它用的是System.currentTimeMillis()。
更隐蔽的是时区问题。如果数据库连接或者JVM默认时区不对,CronTrigger计算触发时间时会按当前时区解析表达式。比如表达式0 0 2 * * ?,在Asia/Shanghai时区是北京时间凌晨2点,但如果JVM跑在UTC时区,实际执行时间是北京时间早上8点。
解决方案也很简单:
- 服务器统一NTP校时,这个不细说。
- JVM启动参数加
-Duser.timezone=Asia/Shanghai。 - 更保险的做法是配置
org.quartz.scheduler.timezone:
spring: quartz: properties: org.quartz.scheduler.timezone: Asia/Shanghai配置之后,所有Cron表达式的解析都按这个时区走。
5.2 任务重复执行的排查思路
集群模式下任务重复执行,我遇到过一次,最后定位是数据库时钟不一致导致的。Quartz的集群锁依赖数据库的当前时间,如果多个实例连接不同的数据库节点,节点时间偏差大,锁的获取和释放就会异常,触发重复执行。
还有单机模式下,如果任务执行时间超过触发间隔,Quartz默认行为是“错过立即执行”。也就是说,一个执行了50秒的任务,如果Trigger是每30秒一次,那么第一次没跑完,第二次也不会排队,而是等第一次结束后马上补跑。这是Quartz的线程池策略:默认线程数10,只要线程没耗尽,错过触发的时间点会补执行。
这种“补跑”行为在有些业务里不能接受。解决方式是加DisallowConcurrentExecution注解,让同一个JobDetail不能并发执行:
@DisallowConcurrentExecution @Component public class SyncDataJob extends QuartzJobBean { // ... }这个注解加到Job类上就行,作用范围是同一个JobDetail,不同JobDetail之间不影响。如果你希望同一个Job类所有实例都不能并发,那就没办法只靠注解了,得用@PersistJobDataAfterExecution配合,或者用分布式锁兜底。
5.3 启动时“Table not found”的排查
第一次切JDBC模式的人,经常在启动时遇到表不存在的异常。原因多半是initialize-schema配置不对,或者数据源权限不够。
我的排查步骤一般是:
- 确认
spring.quartz.job-store-type是不是jdbc。 - 看日志里有没有执行
tables_xxx.sql的信息。Spring Boot脚本里的schema路径是org/quartz/impl/jdbcjobstore/tables_@@platform@@.sql,会根据你的数据源平台自动选。 - 如果日志没动静,直接连数据库看有没有表。没有的话,手动执行官方SQL脚本建表最快。
- 检查数据源账号是否有DDL权限。很多公司数据库规范里业务账号只给DML权限,建表脚本要DBA执行。
- 确认平台识别正确。
spring.quartz.jdbc.platform可以手动指定,比如mysql、postgresql。如果自动识别错了(比如某些中间件代理数据库),手动指定能解决。
5.4 简单聊聊Quartz和Actuator的配合
最后提一嘴热词里有人搜的“spring boot actuator”。Spring Boot Actuator本身不直接暴露Quartz的指标,但它可以监控线程池、数据源这些基础资源。如果你想让Quartz调度信息进入监控指标,一般是在Job里埋点,把执行次数、耗时推给Micrometer的Timer/Counter,然后通过Actuator的/actuator/metrics接口暴露出来。
常用做法是这样:
@Component public class MonitorAspect { @Autowired private MeterRegistry meterRegistry; private Timer getTimer(String jobName) { return Timer.builder("quartz.job.duration") .tag("jobName", jobName) .register(meterRegistry); } @Around("@annotation(JobMonitor)") public Object doAround(ProceedingJoinPoint pjp) throws Throwable { Timer.Sample sample = Timer.start(meterRegistry); try { return pjp.proceed(); } finally { sample.stop(getTimer(...)); } } }这样在Grafana里就能看到每个任务的耗时和调度次数。Quartz自带的Scheduler.getMetaData()也能拿到很多信息,但那是JMX层面的,日常监控我建议还是通过Micrometer走一遍。
写在最后
说到整合Quartz,我真正想强调的是,别把它当做一个只有“注册Job、配置Trigger”的玩具。它在生产环境真正值钱的是持久化、集群、动态管理和可观测性这些能力。你只要把前面第2章的快速跑通和第3章的持久化这两块吃透,就已经能覆盖绝大多数项目的需求了。第4章动态任务管理,基本是“业务要灵活”时的标配。第5章的那些坑,是我一个个趟出来的,能帮你少走不少弯路。
我个人的习惯是,新项目里如果预计定时任务会超过三五个,就一次性把Quartz的JDBC存储和动态任务服务搭好,哪怕前期用不上,后面也不用返工。最后一个小技巧:任务分组命名上,用项目名做前缀,比如report-sync、>
Telegraf 安装指南:4 种方案怎么选,10 分钟跑通指标采集 Agent
Telegraf 安装指南:4 种方案怎么选,10 分钟跑通指标采集 Agent 【免费下载链接】telegraf Agent for collecting, processing, aggregating, and writing metrics, logs, and other arbitrary data. 项目地址: https://gitcode.com/GitHub_Trending/te…
告别无标题:文件命名与信息资产整理的实战指南
“【无标题】”——当我看到这个输入时,第一反应是乐了。做内容这行十几年,我见过太多空空荡荡的文件夹、密密麻麻的“未命名文档”、发出去之前才仓促改名的PPT,以及聊天记录里一堆“新建文本文档(3).txt”。我甚至怀疑,很多人电…
3款降AI率工具实测:不达标退款的E工具效果最佳
3款降AI率工具实测对比:不达标退款的那个效果意外最好最近后台收到好多私信,都在问同一件事:AI写的文章怎么让它别那么“AI味”?说实话,这个需求我太懂了。我自己做内容创作,有时候赶稿子用AI打底ÿ…
手把手:开题报告的技术路线怎么分步画到清晰可执行?
开题报告里被导师圈出来反复改的,技术路线图常排前几位。它不是画得漂亮就过关,评审要看的是"按这张图真能做完研究"。本篇给出分步画法:从研究目标拆出实施环节,给每环节补方法与产出,最后排成一条评审能照…
DeepEval SummaC 一致性检测:不用 LLM API,3 行代码给文本“矛盾“打分
DeepEval SummaC 一致性检测:不用 LLM API,3 行代码给文本"矛盾"打分 【免费下载链接】deepeval The LLM Evaluation Framework 项目地址: https://gitcode.com/GitHub_Trending/de/deepeval AI 摘要把"48 小时发货"写成&quo…
MPU6050 DMP姿态解算实战:从初始化到四元数转换与避坑指南
简介:面向 STM32 与 Linux 开发者的 MPU6050 姿态解算参考工程,围绕陀螺仪内部 DMP 实现欧拉角获取,涵盖 I2C 初始化、DMP 固件加载、中断读取与姿态数据处理等关键环节,帮助快速搭建运动检测与姿态控制原型。压缩包共 99 个文件&…