ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Spring Boot整合Quartz实战:从动态调度到持久化集群全解析

Spring Boot整合Quartz实战:从动态调度到持久化集群全解析 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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-quartz/artifactId version2.7.18/version /dependency如果你的项目用的是Spring Boot 3.xspring-boot-starter-quartz的版本号跟着Spring Boot父BOM走即可不需要显式指定版本。注意Quartz本身版本已经从2.x升级到了3.xAPI有一些变化我们下面主要基于Spring Boot 2.x/Quartz 2.3的稳定组合来讲兼容性更好。2.2 Quartz的核心概念用生活类比讲清楚Quartz里最核心的接口有三个Job、Trigger、Scheduler。很多人一开始会被这三个概念绕晕我打个比方Job任务就是“要做什么”。你写一个类实现Job接口在execute方法里写业务逻辑。相当于你请了一个跑腿小哥他负责干什么活。Trigger触发器就是“什么时候做”。它定义任务的触发规则是用Cron表达式、还是固定间隔、还是每天几点。相当于给跑腿小哥安排了一个闹钟。Scheduler调度器就是“谁来管这个闹钟”。它负责注册Job和Trigger让它们按规则跑起来。相当于跑腿公司的调度中心统筹所有小哥和闹钟。这三者的协作关系是Scheduler把JobDetailJob的详细描述包含任务名、任务组、具体执行类和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是“任务错过触发时长的阈值”单位毫秒默认600001分钟超过这个阈值会被判定为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类先创建一个任务类继承QuartzJobBeanSpring 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里直接通过它拿BeanComponent public class SpringContextUtils implements ApplicationContextAware { private static ApplicationContext applicationContext; Override public void setApplicationContext(ApplicationContext appContext) { applicationContext appContext; } public static T T getBean(ClassT 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是很有必要的。另外如果重复添加同名TriggerQuartz也会直接报错。要么先删除再添加要么用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秒以下的场景threadCount8足够单任务耗时长、任务数多时先拆任务或者考虑分布式调度方案而不是一味加线程。最后一个比较实用的小技巧在Job里通过context.getFireTime()拿到的触发时间可以用来做“幂等”判断。比如一个同步任务同一分钟内被重复触发多次你可以用fireTime的分钟粒度作为幂等键配合数据库唯一索引保证数据不会被重复写入。不用太复杂的逻辑一个小字段就搞定。Spring Boot整合Quartz这件事难度其实不在“整合”本身而在于整合完了之后如何稳定运行、如何动态管理、如何排查问题。希望这篇博文里的经验对你的项目有直接帮助。
返回列表