ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

Spring Boot整合Quartz实战:动态任务调度与持久化集群全解析 两年前我接了一个实验室预约管理系统的改造项目原来的定时任务全部用Scheduled硬写结果一到选课高峰期任务要动态调整运维想改个执行时间还得重新发版生产环境一重启任务配置全丢。后来换成了 Spring Boot 整合 Quartz才算把这块理顺。再说监控设备那边用的基于大华 SDK 的实时监控系统录像文件清理、设备状态巡检这类周期任务也用 Quartz 统一调度了效果很稳定。Spring Boot 整合 Quartz 这个方法网上一搜一大堆但大部分只讲怎么跑通一个 hello world真正到了持久化、集群、动态任务、监控告警这些生产环节很多坑没人提。这篇我就按一线实战的顺序把从依赖引入到生产落地的完整方法拆开讲顺便把常见的坑和排查思路一并给你。内容不复杂适合刚接触 Quartz 的开发者也适合想把现有Scheduled换成统一调度平台的团队参考。1. 为什么用 Quartz 而不是 Scheduled1.1 Scheduled 的边界在哪里Spring 自带的Scheduled注解确实简单一行注解就能让方法按固定频率执行。我最早也用它做过定时库存检查、定时发送通知适合固定周期、单机部署、任务数量少、不需要动态改参数的场景。但你会发现它的瓶颈很快出现默认只有一个线程池执行所有定时任务任务 A 阻塞任务 B 只能排队等直接导致延迟。任务配置写死在代码里想改执行时间、临时停掉某个任务必须改代码重新部署。任务状态在内存里应用重启后全部丢失。多实例部署时每个实例都会执行一遍造成重复操作。这些问题在项目规模小的时候无所谓一旦任务规则经常调整、系统需要集群部署Scheduled就撑不住了。Quartz 作为一个老牌调度框架把任务持久化、动态管理、集群抢占执行这些机制都内置了正好补上这些短板。1.2 Quartz 的核心模型一次讲明白Quartz 的概念刚开始看会觉得绕但只要抓住四个核心角色就行Scheduler调度器负责把任务和触发器绑定在一起启动、暂停、停止调度。Job任务执行的逻辑也就是你真正要跑的代码。JobDetail任务描述用来绑定 Job 实现类同时可以携带一个JobDataMap给 Job 传参。Trigger触发器决定任务什么时候跑最常用的是CronTrigger和SimpleTrigger。用生活场景类比一下Scheduler是后勤调度中心JobDetail是员工的档案Trigger是闹钟到了闹钟设定的时间调度中心就按档案找到员工把活干一遍。JobDataMap相当于随任务下发的工单备注比如要处理哪个设备的录像文件、清理哪个房间的过期预约。1.3 整合方案用 Spring Boot Starter 就好Spring Boot 从 2.x 开始提供了spring-boot-starter-quartz这个 starter 会帮我们自动配置好SchedulerFactoryBean并且内置了 Spring 的依赖注入支持。你不需要自己new StdSchedulerFactory也不用手工管理Scheduler生命周期配置方式比原生 Quartz 简洁得多。如果你追求更彻底的掌控也可以自己定义SchedulerFactoryBean覆盖线程池大小、JobStore、数据源等参数。我实际项目里就是自己在配置类里声明了一个SchedulerFactoryBean把数据源和持久化参数统一管控起来这样后续加集群、加监控都比较顺手。2. 从零开始搭建一个 Spring Boot Quartz 工程2.1 Maven 依赖和项目目录规范先看最基础的 Maven 依赖只需要加一个 starter然后根据自己的需要补数据库驱动和连接池即可。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-quartz/artifactId /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-jdbc/artifactId /dependency这里不建议手动引入org.quartz-scheduler:quartz再和 Spring Boot 的版本强行兼容直接用 starter 让 Boot 帮你管理版本就够稳。项目目录我习惯按功能分包不是为了炫技是后面任务多了真的能找到文件。建议是这样的结构com.example.lab ├── config // Quartz、数据源、线程池等配置类 ├── quartz │ ├── job // 所有 Job 实现类 │ ├── manage // 动态任务管理工具类 │ ├── listener // JobListener、SchedulerListener │ └── model // 封装任务参数用的 DTO ├── service // 业务逻辑层Quartz Job 里调用的服务 ├── controller // 请求入口管理任务的 API └── repository // 数据访问层任务配置表、业务表等我把任务配置和业务代码分开quartz/manage下的工具类专门负责动态注册、暂停、恢复任务controller里暴露这几个操作接口运维改任务配置就不需要动代码了。2.2 application.yml 核心配置下面是带着持久化和基本调优的配置如果刚开始用可以先注释掉依赖的数据源部分先跑内存模式看看效果。spring: quartz: job-store-type: jdbc # 使用 JDBC 持久化默认是 memory jdbc: initialize-schema: never # 是否自动初始化表结构生产建议手工执行脚本 properties: org.quartz.scheduler.instanceName: MyScheduler org.quartz.scheduler.instanceId: AUTO org.quartz.jobStore.class: org.springframework.scheduling.quartz.LocalDataSourceJobStore org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate org.quartz.jobStore.tablePrefix: QRTZ_ org.quartz.jobStore.isClustered: false org.quartz.threadPool.class: org.quartz.simpl.SimpleThreadPool org.quartz.threadPool.threadCount: 10 org.quartz.threadPool.threadPriority: 5这里解释几个容易懵的参数instanceName调度器实例名集群模式下多个实例如果要互认必须设置相同的名称。isClustered是否开启集群模式开启后 Quartz 会通过数据库锁保证同一时间只有一台实例执行任务我们后面会详细讲。threadCount调度线程池大小不是越大越好任务内部如果有耗时 IO建议单独用业务线程池不要把调度线程占满。spring.quartz.jdbc.initialize-schema生产环境我建议用never手动初始化数据库表避免应用每次启动都去检查表结构也防止权限不足启动失败。2.3 写第一个任务扫描超时未预约的实验室座位定义 Job 最常见的两种方式继承QuartzJobBean或者直接实现Job接口。我推荐继承QuartzJobBean因为可以直接拿到 Spring 容器中的 Bean。import org.quartz.JobExecutionContext; import org.springframework.scheduling.quartz.QuartzJobBean; import org.springframework.stereotype.Component; Component public class ReleaseExpiredSeatJob extends QuartzJobBean { private final SeatReservationService reservationService; public ReleaseExpiredSeatJob(SeatReservationService reservationService) { this.reservationService reservationService; } Override protected void executeInternal(JobExecutionContext context) { // 从 JobDataMap 拿参数 String roomId context.getMergedJobDataMap().getString(roomId); reservationService.releaseExpiredReservations(roomId); } }注意一个关键点QuartzJobBean本身是被 Quartz 实例化的但如果我们在类上有Component注解Spring 容器会先创建一个 BeanQuartz 的SchedulerFactoryBean会自动识别并优先使用容器里的 Bean这样构造函数注入才有效。如果不用Component就需要在executeInternal里通过ApplicationContext获取 Bean我建议直接用注解方式省事。任务写好后需要一个JobDetail和一个Trigger绑定到Scheduler。可以建一个QuartzConfig配置类来做初始化注册。import org.quartz.*; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; Configuration public class QuartzConfig { Bean public JobDetail releaseExpiredSeatJobDetail() { JobDataMap jobDataMap new JobDataMap(); jobDataMap.put(roomId, A-001); return JobBuilder.newJob(ReleaseExpiredSeatJob.class) .withIdentity(releaseExpiredSeatJob, labJobGroup) .usingJobData(jobDataMap) .storeDurably(true) .build(); } Bean public Trigger releaseExpiredSeatTrigger() { // 每天凌晨 1 点执行 return TriggerBuilder.newTrigger() .forJob(releaseExpiredSeatJob, labJobGroup) .withIdentity(releaseExpiredSeatTrigger, labTriggerGroup) .withSchedule(CronScheduleBuilder.cronSchedule(0 0 1 * * ?)) .build(); } }启动应用后Quartz 的SchedulerFactoryBean会自动找到容器里的JobDetail和Trigger并注册到调度器。这个流程适合固定任务的初始化注册但如果任务以后要动态新增建议走到编程式注册那一步后面章节我详细说。3. 持久化与集群部署实战3.1 默认情况为什么会丢任务Quartz 默认使用RAMJobStore任务明细和触发器都放在内存里应用一重启所有任务都清空。这也是很多人把 Quartz 用起来之后刚开始能跑第二天上班发现任务没执行的原因之一。生产环境必须切换为JDBCJobStore把任务、触发器、调度状态、锁等信息落到数据库表里。这样应用重启后Scheduler会从数据库把任务重新加载继续按计划执行。3.2 数据库表初始化那些坑spring-boot-starter-quartz默认带了各数据库的建表脚本位置在依赖 jar 包里的/org/quartz/impl/jdbcjobstore/目录下MySQL 版本就是tables_mysql_innodb.sql。你可以在项目里把它拷出来手工执行。我一般这么处理新建一个sql目录存放所有数据库 DDL。生产环境使用专门的数据库账号执行脚本不是用应用账号自动建表。spring.quartz.jdbc.initialize-schema设成never避免启动时自动建表带来的权限和意外覆盖风险。这里还要专门提一下达梦数据库。Quartz 官方脚本并没有达梦版本如果公司用的国产数据库是达梦需要手动改造官方 SQL。核心注意点有三个字段类型达梦兼容 MySQL 或 Oracle 语法建议参考官方 Oracle 脚本把VARCHAR2改造成达梦支持的VARCHAR长度按实际需要保留。主键与自增达梦对自增列支持方式和 MySQL 不同需要建序列或者用IDENTITY列建议在达梦官方文档里确认。表名大小写达梦默认大小写敏感Quartz 的 SQL 中有QRTZ_前缀建表时注意统一使用大写表名和列名避免运行时报找不到表。3.3 JDBCJobStore 配置详解使用 JDBC 持久化时我直接把application.yml里的job-store-type改为jdbc同时要配置数据源。因为LocalDataSourceJobStore会复用 Spring 的数据源所以连接池配置直接交给spring.datasource即可。spring: datasource: url: jdbc:mysql://localhost:3306/lab_schedule?useUnicodetruecharacterEncodingutf8 username: quartz_user password: change_me driver-class-name: com.mysql.cj.jdbc.Driver quartz: job-store-type: jdbc jdbc: initialize-schema: never properties: org.quartz.jobStore.driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate org.quartz.jobStore.tablePrefix: QRTZ_ org.quartz.jobStore.isClustered: falseQuartz 会用到这几张核心表表名作用说明QRTZ_JOB_DETAILS任务明细即 JobDetail 的持久化内容QRTZ_TRIGGERS触发器信息与任务明细关联QRTZ_CRON_TRIGGERSCron 触发器的具体 cron 表达式QRTZ_SIMPLE_TRIGGERSSimple 触发器的重复次数、间隔QRTZ_FIRED_TRIGGERS正在执行的触发器记录QRTZ_LOCKS集群锁表用于多实例争抢执行这些表不需要你手动维护Quartz 自己会读写。你只要保证表结构正确、账号有增删改查权限就行。3.4 集群模式下的配置与注意点如果系统部署了多个实例任务不能每个实例都执行一遍比如自动释放预约、清理录像文件重复执行会造成数据错乱。Quartz 的集群方案通过数据库锁实现哪个实例拿到锁哪个实例才执行任务。配置起来其实很简单关键参数就这几个spring: quartz: properties: org.quartz.scheduler.instanceName: MyScheduler org.quartz.scheduler.instanceId: AUTO org.quartz.jobStore.isClustered: true org.quartz.jobStore.clusterCheckinInterval: 15000instanceName集群内所有实例保持一致。instanceId每个实例可以设置为AUTOQuartz 会自动生成唯一 ID。isClustered设为true。clusterCheckinInterval是实例心跳检查间隔单位毫秒默认 15 秒就行。集群模式下有几个注意点说多了都是泪第一多实例的服务器时间尽量同步不然基于时间的调度会有偏差。第二线程池大小和集群任务数量要匹配每个实例的threadCount不一定相同但建议保持一致。第三数据库连接池要支持足够多的连接Quartz 集群模式下每个实例都会频繁检查数据库锁连接不够会导致任务延迟。我当时遇到过一个奇怪问题两个节点都配置了集群但任务依然重复执行。排查半天才发现两个节点一个用的instanceName是MyScheduler另一个默认成了DefaultQuartzSchedulerQuartz 判断不是同一集群自然不抢锁。所以集群配置一定检查instanceName必须一致。4. 动态任务管理与运维技巧4.1 动态添加、暂停、恢复、删除任务固定任务写死在配置类里没问题但真实业务中运维要能随时调整任务用户在后台上传一个 Cron 表达式就要生成一个任务。这时候就要通过Scheduler的 API 动态操作任务。我封装了一个QuartzJobManager工具类核心方法简化如下import org.quartz.*; import org.springframework.stereotype.Component; Component public class QuartzJobManager { private final Scheduler scheduler; public QuartzJobManager(Scheduler scheduler) { this.scheduler scheduler; } public void addJob(Class? extends Job jobClass, String jobName, String jobGroup, String cron, JobDataMap jobDataMap) throws SchedulerException { JobDetail jobDetail JobBuilder.newJob(jobClass) .withIdentity(jobName, jobGroup) .usingJobData(jobDataMap) .storeDurably(true) .build(); Trigger 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)); } }调用方式就很简单了比如用户在前台设置了一个每天凌晨 2 点清理临时文件的规则后台接口只需要拿到任务类、任务名称、Cron 表达式调用addJob即可。删除和暂停类似。需要注意JobKey和TriggerKey要保证唯一我习惯用业务 ID 做后缀比如seatRelease-1001。4.2 让 Quartz Job 中能用 Spring Bean很多新人在 Job 里直接写了Autowired结果发现注入进来的是 null。原因很简单Quartz 的任务实例默认由 Quartz 自己创建不归 Spring 容器管Autowired根本没机会生效。我在前面写示例时用了Component因为SpringBeanJobFactory能从容器里获取 Bean所以没问题。但如果你的项目里任务类没有交给 Spring 管理或者你用了比较老的 Quartz 整合方式就需要自定义一个SpringBeanJobFactoryimport org.quartz.spi.TriggerFiredBundle; import org.springframework.context.ApplicationContext; import org.springframework.scheduling.quartz.SpringBeanJobFactory; public class AutowiringSpringBeanJobFactory extends SpringBeanJobFactory { private ApplicationContext applicationContext; public void setApplicationContext(ApplicationContext applicationContext) { this.applicationContext applicationContext; } Override protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception { Object job super.createJobInstance(bundle); applicationContext.getAutowireCapableBeanFactory().autowireBean(job); return job; } }然后在配置类里自定义SchedulerFactoryBean时把这个 JobFactory 设置进去。这样哪怕任务类没有ComponentJob 里的Autowired也能正常注入。4.3 监听器与任务执行痕迹生产环境必须知道任务到底跑没跑、跑了多久、有没有报错。Quartz 提供了三类监听器我建议至少用两个JobListener监听 Job 执行开始、执行结束、执行拒绝。SchedulerListener监听调度器的启动、关闭、任务新增、任务删除等事件。写一个JobListener并不复杂我们可以把执行日志记录到日志文件或者数据库为后续排查提供依据。import org.quartz.JobExecutionContext; import org.quartz.JobExecutionException; import org.quartz.JobListener; public class TraceJobListener implements JobListener { Override public String getName() { return traceJobListener; } Override public void jobToBeExecuted(JobExecutionContext context) { // 执行前记录 } Override public void jobExecutionVetoed(JobExecutionContext context) { // 被否决执行 } Override public void jobWasExecuted(JobExecutionContext context, JobExecutionException jobException) { // 执行后记录这里能拿到异常 } }另外两个注解也要会用DisallowConcurrentExecution禁止同一个 Job 并发执行上一个未跑完下一个即使到时间也会等。PersistJobDataAfterExecutionJob 执行完成后把 JobDataMap 中修改后的数据持久化方便下次任务读取累计值。这两个注解我通常会一起加上因为如果任务内部修改了 JobDataMap不持久化下次执行拿到的还是旧值。典型的例子是统计任务执行次数。4.4 从实际场景看预约到期自动释放和监控回放清理用前面那套动态 API可以实现一个很常见的需求每次用户预约一个座位就创建一条定时任务到了预约结束时间自动释放座位。public void createSeatReleaseTask(String reservationId, String roomId, LocalDateTime endTime) { JobDataMap dataMap new JobDataMap(); dataMap.put(reservationId, reservationId); dataMap.put(roomId, roomId); // 转换为 Date用 SimpleTrigger 精确触发一次 Date triggerTime Date.from(endTime.atZone(ZoneId.systemDefault()).toInstant()); JobDetail jobDetail JobBuilder.newJob(ReleaseSeatJob.class) .withIdentity(releaseSeat- reservationId, seatRelease) .usingJobData(dataMap) .storeDurably(true) .build(); Trigger trigger TriggerBuilder.newTrigger() .withIdentity(releaseSeatTrigger- reservationId, seatRelease) .startAt(triggerTime) .build(); scheduler.scheduleJob(jobDetail, trigger); }这种设计的好处是任务和业务数据一一对应取消预约时直接deleteJob新增预约时动态注册任务不需要写一堆 if 判断。监控录像文件清理的场景类似用SimpleTrigger每隔一段时间执行一次清理任务但这次扫描的是数据库里录像的过期时间所以任务内部逻辑会复杂一些。如果清理任务要支持多个设备并发可以在 JobDataMap 里带上设备 IDJob 里根据设备 ID 查设备信息调用大华 SDK 的接口删除过期录像。Quartz 只负责触发具体业务还是走 Service 层。5. 监控、安全与常见问题排查5.1 用 Actuator 和 Micrometer 监控 Quartz没有监控的定时任务就像没有逃生通道的大楼出了问题只能靠肉眼盯日志。Spring Boot Actuator 搭配 Micrometer 可以统计 Quartz 的指标比如执行次数、失败次数、任务数量、线程池状态等。首先引入依赖dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-actuator/artifactId /dependency dependency groupIdio.micrometer/groupId artifactIdmicrometer-registry-prometheus/artifactId /dependency然后在配置里打开需要的端点management: endpoints: web: exposure: include: health,metrics,prometheus endpoint: health: show-details: always启动后访问/actuator/prometheus能看到quartz_*开头的指标比如quartz_job_execution_count、quartz_job_execution_time。把这些指标接入 Prometheus 和 Grafana就可以做定时任务大盘了。说句实在话Actuator 是调试利器但也是信息泄露的重灾区。前几年关于 Actuator 的安全问题很多大部分都是因为把所有端点无脑暴露到公网把配置信息、Bean 列表、环境变量直接送给了攻击者。生产环境务必遵循最小暴露原则只开health、metrics、prometheus并且一定要加认证或者只在内网访问shutdown端点绝对不要开放。5.2 常见问题速查表我把实际运维里遇到过的坑整理成了表格基本都是大家容易踩的问题现象可能原因解决方案任务重启后丢失使用的是默认 RAMJobStore切换到 JDBCJobStore配置持久化多个实例重复执行任务集群未开启或 instanceName 不一致检查 isClustered 与 instanceName任务在 Job 里注入的 Service 为 nullJob 实例是 Quartz 创建不受 Spring 管理使用 Component 或自定义 SpringBeanJobFactoryCron 表达式执行时间少了 8 小时时区设置不对设置org.quartz.scheduler.timeZone任务不触发但日志正常Trigger 可能 paused 或 misfire 策略不对检查 TriggerState设置合理的 misfire 策略持久化时报表找不到未初始化 Quartz 表结构或前缀不一致手工执行官方 SQL检查 tablePrefix数据库连接耗尽集群检查频繁 连接池太小调整连接池大小优化 clusterCheckinInterval5.3 优化建议与经验心得最后写几条经验心得都是被生产环境教育过才记住的。第一不要在 Job 里做长时间阻塞操作。Quartz 调度线程是共享的一个 Job 长时间卡住后面的触发都会受影响。遇到重活我习惯在 Job 里把任务提交给业务线程池Job 只负责触发不负责干完。Override protected void executeInternal(JobExecutionContext context) { asyncTaskExecutor.execute(() - { // 真正的业务逻辑 }); }第二Cron 表达式务必设置时区。如果服务器部署在不同地区或者迁移服务器后时间对不上就容易出现任务晚执行 8 小时的诡异问题。可以在CronScheduleBuilder上指定inTimeZone(TimeZone.getTimeZone(Asia/Shanghai))。第三任务执行必须做幂等。Quartz 能保证不丢失但分布式环境下网络抖动、重启恢复都可能让任务执行多次尤其是释放预约这类操作宁可重复扫描也不要出现把不该释放的座位释放了。实现上可以用业务状态机控制比如只有状态为已预约的记录才允许被释放。第四任务日志一定要带JobKey和TriggerKey。没有这两个信息排查问题时你连是哪个任务报错都看不出来。我习惯在JobListener里统一记录任务名、任务组、执行时间、耗时和异常信息。6. 写在最后的几点实操体会回到开头那个预约系统用 Quartz 重构之后运维同事终于不用熬夜改代码改配置了。任务配置全部落库后台管理系统改一下 Cron 就能生效多个应用实例也能平稳抢占任务没有再出现重复释放座位的用户投诉。后来我把这套方法复用到监控录像清理上才发现 Quartz 的坑基本都集中在持久化、动态注册和 Bean 注入这几个点提前处理好后面就顺了。如果你正打算把Scheduled换掉或者第一次在 Spring Boot 里集成 Quartz我建议先跑通内存模式理解 Job、Trigger、Scheduler 的关系再逐步加上持久化和集群。千万别一上来就搞一堆分布式配置出了问题很难定位是哪一层的问题。Quartz 确实老老到很多人觉得它不够高级但它的稳定性和可运维性经过大量生产环境验证。Spring Boot 整合 Quartz 这件事掌握到动态任务和持久化这个程度就已经能覆盖绝大多数业务场景了。希望这篇能帮你少走一点弯路把时间花在业务逻辑上而不是在和框架的细节搏斗上。
返回列表