资讯动态

Spring Boot整合Quartz动态任务调度实战:配置、持久化与集群部署

发布时间:2026/10/13 13:47:50 来源:尧图企业网站定制
做后端开发这几年定时任务应该是绕不开的话题。很多人最早接触的就是Spring Boot自带的Scheduled一个注解加一个cron表达式确实能跑起来但真到生产环境需求变成“运营在后端点个按钮动态加一个任务”“每晚汇总报表跑挂了要自动补跑”“两个服务实例不能重复执行同一任务”的时候Scheduled那套静态玩法就明显不够用了。这时候Quartz就成了很自然的选择它不光是老牌的Java任务调度框架而且和Spring Boot的整合非常顺社区资料也足够多。这篇就把我在实际项目里从依赖引入、配置类写法、动态调度、数据库持久化到多实例集群部署的完整过程记录下来重点写那些文档里不会提的坑和排查思路。无论你是刚接触任务调度的新手还是正在做任务管理系统的老手这篇应该都能给你一些直接能用的东西。1. 为什么是Quartz先想清楚方案再动手1.1 Spring Boot自带的Scheduled不香吗先聊一个很多人纠结的问题有了Scheduled为什么还要专门引QuartzScheduled确实香它在Spring Boot项目里几乎是零成本接入。可它的限制也恰恰藏在“零成本”这三个字后面。我列几个最典型的场景任务执行计划是写死在注解里的要么编译期写死要么通过配置文件里的cron表达式占位符替换。但业务上想让用户在后台管理页面上随时改执行时间Scheduled就需要重启应用才能让新的cron生效。任务执行状态不可见。Scheduled跑挂了多少次、上一次执行是什么时间、当前是pending还是runningSpring Boot默认不会给你任何管理界面你得自己埋点统计。多实例部署基本别想。同一个定时任务在多个实例上同时启动会导致重复执行而Scheduled没有原生的分布式锁或者说集群协调能力。没有持久化。应用重启之后任务执行记录和计划信息全部丢失无法追踪。这些场景叠加在一起当你需要构建一个“任务可管理、可配置、可追踪”的任务调度中心时Scheduled就顶不住了。Quartz是一个从2009年左右就活跃的Java调度框架它内置了Job、Trigger、Scheduler、JobDataMap、Listener等一整套机制支持丰富的cron表达和SimpleTrigger也支持RAM和JDBC两种任务存储方式还自带集群模式。引入它不是为了炫技而是为了把“定时任务”从硬编码变成一种可运营的基础能力。1.2 Quartz的四个核心对象Quartz的概念第一次看会觉得多其实剥开来看就四个东西Scheduler、JobDetail、Trigger、Job。我习惯用一个生活化的类比帮助理解Scheduler是调度中心它掌握全局负责把任务按时间安排下去执行Job是我们真正要干的活比如发邮件、生成报表JobDetail是这份活的台账里面记录了这个活叫什么名字、属于哪个分组、需要什么参数Trigger是闹钟它决定这个活在什么时候响、多久响一次。理解这四者的关系很重要。一个JobDetail可以被多个Trigger关联这样一个“发报表”的任务可以绑定“每天早上8点”和“每周一早上8点”两个闹钟但Quartz会保证同一个JobDetail在有效时间内只由同一个Scheduler实例中的一个线程执行。反过来说一个Trigger也只能绑定一个JobDetail绑定关系在构建Trigger时通过withIdentity去指定。还有一个容易被忽略但特别实用的对象叫JobDataMap它其实就是个Map用来给Job传参数。你可以在JobDetail和Trigger上各挂一份参数Job执行时通过context获取这些参数。后面讲动态调度时这个机制几乎撑起了整个“后台配置任务”的功能所以务必理解它的使用方式。2. 整合前置准备依赖、版本与目录结构2.1 依赖引入和版本说明Spring Boot整合Quartz最舒服的地方在于官方已经帮我们把自动配置和依赖管理都做好了你只需要引入一个starterdependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-quartz/artifactId /dependency不需要手动指定Quartz的版本因为Spring Boot的依赖管理spring-boot-dependencies已经替你锁定了版本。这里有个需要留意的版本对应关系Spring Boot 2.7.x系列内置的是Quartz 2.3.2而Spring Boot 3.x系列已经切换到Quartz 3.x意味着它默认要求JDK 17以上并且部分包名从javax迁移到了jakarta。如果你在迁移老项目时发现一些Quartz工具类的import报错先检查是不是Spring Boot 3.x导致的包路径问题。引入starter后Spring Boot的QuartzAutoConfiguration会自动创建一个SchedulerFactoryBean同时会把所有定义好的JobDetail和Trigger Bean自动注册进Scheduler。这句话是理解整合方式的关键我们不需要手动new一个Scheduler只需要在容器里声明JobDetail和Trigger的BeanSpring Boot会帮我们装配好。目录结构没有强制要求我一般习惯在项目中建一个quartz包里面按“config / job / service”组织。config放Quartz的配置类和自定义JobFactoryjob放具体的任务类service放动态调度的操作逻辑。这么分的好处是后面任务多了不至于全都挤在一个文件里。2.2 一个最小可运行的Job类最小可运行的Job类其实就是实现一个接口或者继承一个抽象类。两种写法import org.quartz.Job; import org.quartz.JobExecutionContext; import org.quartz.JobExecutionException; public class SimpleJob implements Job { Override public void execute(JobExecutionContext context) throws JobExecutionException { System.out.println(执行任务 context.getJobDetail().getKey()); } }还有一种是继承QuartzJobBean这是Spring框架为Quartz提供的适配类import org.quartz.JobExecutionContext; import org.springframework.scheduling.quartz.QuartzJobBean; public class SimpleJob extends QuartzJobBean { Override protected void executeInternal(JobExecutionContext context) { System.out.println(执行任务 context.getJobDetail().getKey()); } }这里直接给个结论建议优先继承QuartzJobBean。因为这个类会通过Spring的ApplicationContext去实例化JobJob内的字段会自动从Spring容器中注入比如你可以在SimpleJob里写一个Service类型的属性哪怕这个Job类本身不是Spring Bean属性也能被注入进来。而直接实现Job接口时Quartz默认使用反射无参构造创建实例内部依赖的Service只能用ApplicationContext手动getBean或者借助SpringBeanJobFactory来接管创建过程明显麻烦一些。3. 核心实现从配置类到动态调度3.1 配置类其实没有你想的那么复杂定义JobDetail和Trigger不需要写太多代码一个配置类就可以完成import org.quartz.*; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class QuartzConfig { Bean public JobDetail dailyReportJobDetail() { return JobBuilder.newJob(DailyReportJob.class) .withIdentity(dailyReportJob, reportGroup) .storeDurably(true) .build(); } Bean public Trigger dailyReportTrigger() { return TriggerBuilder.newTrigger() .forJob(dailyReportJob, reportGroup) .withIdentity(dailyReportTrigger, reportGroup) .withSchedule(CronScheduleBuilder.cronSchedule(0 0 8 * * ?)) .build(); } }这里有个细节很关键配置类只定义了JobDetail和Trigger的BeanSpring Boot就会自动把它们注册到Scheduler里你不需要额外注入和手动scheduleJob。Trigger通过withIdentity指定了Trigger的name和group并指明了它绑定哪个JobDetail。第四部分我还会专门讲com.quartz.jobStore类型这里先不展开。Cron表达式我在这里就要提前强调一个高频坑Spring自带的Scheduled的cron是6位也就是没有秒前面的年份但Quartz的cron表达式是7位的顺序是“秒 分 时 日 月 周 ”。上面代码里“0 0 8 * * ?”表示每天早上8点整执行前面的“0 0 8”就是8点0分0秒注意比Scheduled多了一位秒。很多人拿着在线网站生成的6位表达式直接贴到Quartz里结果就是任务要么完全不触发要么干脆启动报错后面排查部分我会详细说。3.2 动态任务管理新增、修改、暂停和删除生产环境中任务往往不是写死在配置类里的而是用户在前端页面上配置的。这种需求下JobDetail和Trigger就不能靠Bean静态声明了而需要在业务代码里动态创建并注册到Scheduler。第一步是注入Schedulerimport org.quartz.Scheduler; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; Service public class QuartzJobService { Autowired private Scheduler scheduler; }新增任务时核心API是addJob和scheduleJobpublic void addJob(String jobName, String jobGroup, String cron, Class? extends Job jobClass, String paramValue) throws Exception { JobDetail jobDetail JobBuilder.newJob(jobClass) .withIdentity(jobName, jobGroup) .storeDurably(true) .build(); jobDetail.getJobDataMap().put(param, paramValue); Trigger trigger TriggerBuilder.newTrigger() .withIdentity(jobName Trigger, jobGroup) .withSchedule(CronScheduleBuilder.cronSchedule(cron)) .build(); scheduler.addJob(jobDetail, true); scheduler.scheduleJob(trigger); }修改任务时间最笨的办法是把任务删掉再新增但这样会丢失执行次数等状态。更合理的做法是重新构建一个Trigger然后调用rescheduleJobpublic void updateJobCron(String jobName, String jobGroup, String cron) throws Exception { TriggerKey triggerKey TriggerKey.triggerKey(jobName Trigger, jobGroup); CronTrigger newTrigger TriggerBuilder.newTrigger() .withIdentity(triggerKey) .withSchedule(CronScheduleBuilder.cronSchedule(cron)) .build(); scheduler.rescheduleJob(triggerKey, newTrigger); }暂停和恢复也很直接scheduler.pauseJob(JobKey.jobKey(jobName, jobGroup)); scheduler.resumeJob(JobKey.jobKey(jobName, jobGroup));删除任务用unscheduleJob还是deleteJob要注意区分。unscheduleJob只是撤销Trigger如果JobDetail是durable的JobDetail还会留在库里之后可以重新绑定TriggerdeleteJob则会把JobDetail连同它下面的Trigger一起删除而且会先解除所有关联的Trigger再删。业务上如果确认这个任务不再需要应当用deleteJob否则数据库里会留一堆挂空的JobDetail记录。3.3 JobDataMap给任务传参的正确姿势动态添加任务时通常需要给每个任务传一些业务参数比如报表任务传“报表类型ID”、推送任务传“推送目标列表”。这些参数不能写死在Job类里而应该放在JobDataMap中。上面addJob方法里已经有示例就是jobDetail.getJobDataMap().put(param, paramValue);在Job执行的一方取参数public class DailyReportJob extends QuartzJobBean { Override protected void executeInternal(JobExecutionContext context) { String param context.getJobDetail().getJobDataMap().getString(param); // 使用param做业务处理 } }取参数时有个容易混淆的点参数可以放在JobDetail的JobDataMap里也可以放在Trigger的JobDataMap里两者互不影响。Job内部通过context.getJobDetail().getJobDataMap()只能取到JobDetail上挂的参数而context.getMergedJobDataMap()会把Trigger和JobDetail上的参数合并到一起Trigger参数优先级更高。所以我在实际项目里统一用context.getMergedJobDataMap()来取避免团队里有人把参数挂到Trigger上导致Job里取不到debug半天。这个问题相当隐蔽尤其是你修改Trigger参数后Job里还读着旧值排查起来特别费劲。另外从Quartz 2.x开始持久化状态下JobDataMap中的值必须实现序列化接口因为数据要写进数据库BLOB字段。如果你往里放了一个自定义对象但没实现Serializablejob-store-type设为jdbc后保存任务时就会报NotSerializableException。这种问题的经典处理方法是把对象先转成JSON字符串再放入JobDataMap取的时候再反序列化既安全又便于数据库里排查。3.4 并发执行和错过补偿策略Quartz默认同一个JobDetail如果上一次执行还没结束下一次触发时间到了会再次创建线程执行也就是说同一个任务可能重叠执行。这一点在多数业务场景下是需要避免的比如报表任务跑10分钟但cron设定每5分钟触发一次就会导致两个线程同时生成报表数据可能就乱了。解决办法是在Job类上加一个注解import org.quartz.DisallowConcurrentExecution; DisallowConcurrentExecution public class DailyReportJob extends QuartzJobBean { // 同一个任务上一次执行未结束不会并发启动新线程 }与之配套的还有misfire策略。所谓misfire是指任务到了触发时间却没机会执行比如应用停机、线程池繁忙。Quartz的默认策略因Trigger类型而异CronTrigger的默认策略是withMisfireHandlingInstructionSmartPolicy实际会表现为“立刻补执行一次然后恢复正常节奏”。但这不一定符合业务预期停机两小时期间漏掉的几十次任务恢复后你只想让最新一次执行而不是把漏掉的全部补跑一遍。这种情况下建议在构建Trigger时显式指定.withSchedule(CronScheduleBuilder.cronSchedule(0 0 8 * * ?) .withMisfireHandlingInstructionDoNothing())这个策略的含义是错过了就直接跳过恢复调度后只执行接下来的计划。对“定时汇总”“定时推送”这类场景通常更合理。4. 持久化与集群生产环境躲不开的两件事4.1 RAMStore与JDBCStore怎么选Quartz的任务存储默认是RAMJobStore就是把JobDetail和Trigger信息全部保存在内存里应用一重启所有任务配置都没了。这在开发环境没问题但生产环境显然不能接受。第二种存储方式是JDBCJobStore把任务信息、执行记录放进数据库应用重启后任务配置依然存在配合集群模式还能解决多实例重复执行。Spring Boot里切换存储方式很简单在application.yml中加一行spring: quartz: job-store-type: jdbc配置了jdbc之后Spring Boot会自动使用项目中配置的数据源。这一步需要注意项目里必须要有DataSource否则启动阶段会报找不到DataSource的错误。如果你还没引入spring-boot-starter-jdbc或者mybatis等能创建数据源的依赖Quartz会自动配置是会失败的。4.2 JDBCStore的数据库表和初始化脚本JDBCJobStore需要在数据库里创建11张表包括qrtz_job_details、qrtz_triggers、qrtz_cron_triggers、qrtz_simple_triggers、qrtz_simprop_triggers、qrtz_blob_triggers、qrtz_calendars、qrtz_paused_trigger_grps、qrtz_fired_triggers、qrtz_scheduler_state、qrtz_locks。这些表的建表脚本就藏在Quartz的jar包里路径是org/quartz/impl/jdbcjobstore/目录下面不同数据库有对应的sql文件比如tables_mysql_innodb.sql、tables_postgres.sql、tables_oracle.sql等。实际操作时你可以去依赖包里找到对应脚本也可以去GitHub上Quartz仓库的quartz-core/src/main/resources/org/quartz/impl/jdbcjobstore/目录下载然后导入到自己的数据库中。导入时注意前缀默认是QRTZ_如果你想换表名前缀比如不想和已有表冲突需要同时修改Spring配置、SQL脚本、驱动配置不建议新手折腾默认前缀够用。4.3 多实例部署下的集群配置数据库持久化解决的是“重启不丢任务”但没解决“多实例重复执行”。假设你有两个后端服务实例共用一个数据库两台机器都会执行同一个定时任务这就造成了重复发送、重复处理。Quartz的集群方案就是让所有实例共享同一套数据库表配合行锁机制保证同一时间只有一个实例能获取某个任务的执行权。Spring Boot里启动集群只需要在application.yml里加几行spring: quartz: job-store-type: jdbc properties: org: quartz: jobStore: isClustered: true clusterCheckinInterval: 15000 scheduler: instanceId: AUTO这里有一个重要细节clusterCheckinInterval表示实例向数据库报告心跳的间隔默认是15000毫秒也就是15秒。如果某个实例宕机其他实例最多延迟这个时间才能感知到它失效。既然15秒只是一个下限问题那是不是可以把它调得很小不建议太极端因为每个实例都会定期去qrtz_scheduler_state表里更新状态间隔太小会增加数据库压力。生产上我一般保持15000毫秒如果你对故障转移的实时性要求高可以调到10000毫秒左右。还有一点容易被忽略多个实例的时间必须同步最好挂统一的NTP时间同步服务。Quartz集群判断任务是否被占用靠的是分布式锁但调度器检查时间时的逻辑依赖实例本地时钟时钟偏差太大会导致任务提前触发或者延迟触发排查起来特别像灵异事件。集群模式下任务默认是能够在多个实例间故障转移的假设实例A获得了某个任务执行到一半宕机了实例B会在下一次检查时通过misfire机制把这个任务接管过去。但要注意Quartz并没有事务性执行能力如果任务本身执行了一半才宕机那这部分执行结果要自己保证幂等。5. 常见问题与排查技巧实录5.1 Job始终没触发先查这四样遇到过太多次“任务配置没问题但就是不执行”的求助帖了。按照我自己的排查顺序一般先看四样东西第一Scheduler是不是启动状态。Spring Boot引入starter后默认会启动Scheduler但如果你在代码里自己声明了SchedulerFactoryBean且没有设置AutoStartup或者某处调用了scheduler.shutdown()那任务就永远不会触发。可以写个临时接口打印scheduler.isStarted()确认。第二Trigger的当前状态。给一个Controller加上查看逻辑或者通过数据库qrtz_triggers表查看TRIGGER_STATE字段正常应该显示WAITING或者ACQUIRED。如果状态是ERROR通常说明Trigger在构建时出了问题比如cron表达式非法或者数据库里的JobDetail被误删。第三JobKey和TriggerKey是否重复。动态调度中addJob时报过IllegalStateException“JobDetail with name xxx already exists”的人应该不止我一个。这通常是因为代码里没有先判断任务是否已存在就重复addJob。解决方法是addJob前先调用scheduler.checkExists(jobKey)或者直接addJob(jobDetail, true)让重复时覆盖但要注意覆盖会重置状态。第四线程池是否被耗尽。Quartz的默认线程池大小是10如果你的任务全是阻塞型的长任务10个线程全被占住后面所有任务都会堵着不执行。在日志里表现为任务到点没有任何响应过很久才突然一起跑。这种情况要把线程池调大或在任务内部控制阻塞时间。5.2 Cron表达式是6位还是7位这个坑几乎是每个从Scheduled过渡到Quartz的人都会踩。Spring的Scheduled表达式只支持6位没有年份而Quartz的CronTrigger支持7位秒 分 时 日 月 周注意还有一个“?”符号用于日和周这两个字段二者只能有一个填值。例如Web上很多生成工具默认生成的表达式是“0 0 8 * * *”这种6位的直接拿去Quartz用最后一位会被当成“周”结果就是对应不上任务永远不触发或触发时间完全不对。正确示例每天8点执行一次0 0 8 * * ?每周一10点执行一次0 0 10 ? * MON每天上午9点到下午6点每小时执行一次0 0 9-18 * * ?如果是从项目里迁移代码可以把Quartz的trigger表达式用在线校验工具先跑一遍确认是7位。实在不放心建Trigger之后打印trigger.getCronExpression()验证。5.3 Job类里注入不了Spring BeanQuartz的Job对象是由Quartz框架反射创建实例的不是由Spring容器管理的。所以如果你在Job类里写了形如new DailyReportJob()再setService的方式那service永远是个null。更隐蔽的是有人习惯在Job里写个带参数的构造器Quartz反射初始化需要无参构造器这种情况下会直接抛InstantiationException。解决办法前面已经提过继承QuartzJobBean并保证类里不要写带参构造器。成员变量上直接使用Spring的Autowired或ResourceQuartzJobBean的execute方法内部会通过当前应用的ApplicationContext将Job实例化并注入依赖这个机制在Spring Boot中默认是生效的。如果你用原生Quartz而不经过Spring则需要自行实现JobFactory并在SchedulerFactoryBean里设置jobFactory为SpringBeanJobFactory。5.4 任务改完不生效、删不掉修改cron后不生效十有八九是只改了数据库记录或者只重新赋了值给Trigger对象但没有调用scheduler.rescheduleJob。Quartz的工作方式是“以内存里缓存的那份Trigger为准”改数据库不会自动同步回运行中的Scheduler所以业务代码里更新完任务配置后必须主动执行一次rescheduleJob。删除任务也有类似的坑。直接调用scheduler.deleteJob(jobKey)会返回false尤其是在集群模式下任务可能还被其他实例持有锁。如果业务上允许可以先暂停任务再删除scheduler.pauseTrigger(triggerKey); scheduler.unscheduleJob(triggerKey); scheduler.deleteJob(jobKey);顺序很重要先停Trigger再解绑最后删JobDetail基本不会残留脏数据。5.5 多实例重复执行问题这类问题大多数发生在集群配置没生效或者不完整的情况下。我遇到过的一种情况是两个实例都正确配置了isClusteredtrue但任务仍然重复执行。后来一查两个实例的Scheduler名称org.quartz.scheduler.instanceName不一样Quartz集群默认要求同一集群的实例名必须一致它们必须使用同一个调度器名称才能共享任务锁。Spring Boot里默认的instanceName是QuartzScheduler但不同环境如果显式配置成了不同的名字就会导致看起来集群实际各持一个独立Scheduler的错觉。另外检查是否真的走了数据库集群不要只看配置。启动日志里如果出现了“Using db table-based Scheduler”“ClusterService”等关键字说明集群生效如果日志里还在提示RAMJobStore就得回去检查job-store-type是不是被覆盖成ram了。5.6 任务异常和事务的处理Quartz的Job执行时如果抛出未捕获异常Quartz会认为这次执行失败情况因misfire策略不同而不同。默认CronTrigger如果Job执行抛异常这个任务会被标记为异常后续触发会被暂停这在日志里很容易让人以为Quartz“坏了”其实只是任务网络异常被Quartz吞了。因此Job内部一定要做一层整体try-catch记录错误日志并把失败信息持久化到自己的任务执行表里。不要依赖Quartz的异常传播机制它不会帮你做业务补偿。数据库操作方面如果是Spring管理的Service方法事务照样生效因为Quartz线程内的调用链最终还是会进入Spring代理对象但如果Job里有非数据库的外部调用比如HTTP请求不推荐把事务范围扩大到整个Job执行否则一次长任务会占用数据库连接池很久。最后分享一点个人体会做了这么多任务调度相关的项目我的体会是Quartz真正难的地方从来不是把JobDetail、Trigger、Scheduler三个类写出来而是运行时任务如何和业务数据打通。我现在的做法是把所有动态任务配置放在一张业务表里后台改动cron时同步更新表并调用rescheduleJob这样应用重启不丢配置页面看到的执行时间和数据库里的配置永远一致。后来又做了一个任务执行历史表每次Job跑完后把开始时间、结束时间、状态、异常信息写进去排查问题时能直接查到“上一次为什么没跑”比翻Quartz的日志效率高得多。如果你打算在新项目里引入Quartz建议一开始就把存储层选好本地开发用RAM预发和生产用JDBC并且尽早搭上isClustered配置。这一套流程坑不算少但摸清楚之后动态任务调度这块业务就再也不会成为瓶颈了。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑