ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

SSM整合Quartz持久化到数据库:动态定时任务完整实践指南

SSM整合Quartz持久化到数据库:动态定时任务完整实践指南 简介面向需要实现定时任务动态管理的Java Web开发者这是一套将SSM框架与Quartz调度器深度融合的项目源码。资源完整演示了如何把Quartz的任务、触发器及运行状态持久化到MySQL数据库并在Spring MVC层提供动态新增、修改、删除、查询定时任务的接口前端配套JSP管理页面便于直观理解从前端到数据库的完整调用链。压缩包共55个文件包含40个Jar依赖、7个XML配置、3个properties文件、2个JSP页面及少量class文件。Jar包覆盖mybatis、spring、quartz、shiro、c3p0、mysql驱动等核心组件XML与properties文件分别对spring-mybatis、spring-mvc、quartz.properties等关键配置做了完整示例可直接对照搭建工程。资源包体约28.2MB已有705人学习下载。对于想理清quartz表结构、动态任务管理实现或项目配置细节的读者这份资料提供了包括Mapper SQL、Controller接口、Ajax请求在内的完整闭环是一份不错的工程实战参考。 做Java后端的朋友项目里基本都绕不开定时任务。我最近用SSM搭了一套任务调度平台业务方需求倒是不复杂页面上能配置任务、填cron表达式、随时上线下线改时间但最重要的是——服务重启之后任务不能丢。一开始图省事直接用Quartz默认配置跑结果上线一周后一次重启所有任务全部从调度器里消失了后台日志干干净净页面上的任务列表也空空如也。这个问题不解决所谓“动态管理”就是个笑话。这次我把SSM整合Quartz并持久化到数据库、再实现动态增删改查的完整链路梳理一遍包括表结构初始化、Spring配置、动态API实现和几个典型生产问题。内容偏实操适合正在用SSM做后台管理系统、想把定时任务做成动态可配置的朋友参考。1. 为什么要把Quartz任务持久化到数据库1.1 RAMJobStore的痛点和任务丢失场景Quartz默认使用的是RAMJobStore从名字就能看出来任务明细、触发器、调度状态全部存在JVM内存里。这个方案优点是启动快、无外部依赖但缺点非常致命调度器实例一旦销毁任务数据全部跟着消失。有人会说我的业务表里存了任务配置重启后重新注册不就行了理论上可以但实操中你会发现很多问题哪些任务正在执行哪些被暂停了触发器下次触发时间是什么时候错过的时间要不要补执行这些状态信息业务表里压根不会保存。而且注册任务的代码散落在各个模块里重启后能不能按正确顺序全部恢复本身就是很大的隐患。我在第一次做动态任务管理时就踩了这个坑页面新增的任务写进了业务表但Quartz内存里没有重启之后只能人工手动一个个重新配置业务方直接炸了。所以结论很明确只要你的任务需要跨重启存活或者多个节点需要共享任务状态就别用RAMJobStore。1.2 JobStoreTX和JobStoreCMT我推荐哪个持久化存储的JobStore有两类JobStoreTX和JobStoreCMT。它们的区别简单来说就是事务由谁来管。JobStoreTX是自己管理事务每次调度行为任务注册、触发更新、任务执行状态写入都会开启数据库事务操作完成立即提交适合部署在普通Tomcat、独立应用中的场景。JobStoreCMT则把事务交给外部容器比如J2EE应用服务器管理一般用在EJB这类容器环境里。SSM项目通常就是Spring MyBatis Tomcat没有JTA容器老老实实用JobStoreTX就行。如果以后要做集群部署还可以在JobStoreTX基础上打开isClustered配置Quartz会利用数据库锁来协调多个调度器实例避免同一个任务被多台机器重复执行。表结构里的QRTZ_LOCKS表就是干这个的后面专门说。2. 数据库表结构Quartz依赖的11张表2.1 核心表职责速查Quartz持久化不是简单的把任务存成一条记录而是围绕JobDetail、Trigger、Calendar、SchedulerState等概念建了一张完整的关系网。2.x版本一共需要11张表表名作用QRTZ_JOB_DETAILS任务明细保存JobClass、JobDataMap等QRTZ_TRIGGERS触发器主表保存Trigger基本信息QRTZ_CRON_TRIGGERSCron触发器扩展信息存cron表达式QRTZ_SIMPLE_TRIGGERS简单触发器扩展信息存重复次数、间隔QRTZ_SIMPROP_TRIGGERS存储具有属性参数的TriggerQRTZ_BLOB_TRIGGERS以Blob形式存储自定义触发器QRTZ_CALENDARS日历信息用于排除特定时间段QRTZ_PAUSED_TRIGGER_GRPS被暂停的Trigger组QRTZ_FIRED_TRIGGERS正在执行中的触发器QRTZ_SCHEDULER_STATE调度器实例状态集群模式下使用QRTZ_LOCKS悲观锁表保证集群一致性实际开发中用到最多的就是前四张但其他表不能少缺一张Quartz启动就可能报错。特别是QRTZ_LOCKS很多并发问题都和它有关排查时重点看。2.2 建表脚本从哪来Quartz官方把建表脚本打在了jar包里面路径是org/quartz/impl/jdbcjobstore/tables_mysql_innodb.sql。可以通过maven仓库下载quartz.jar后用解压工具打开或者直接用IDEA依赖面板展开jar包找到这个SQL文件复制出来执行。这里有一个很多人忽略的细节Jar包里还有另一个MySQL脚本tables_mysql.sql这是MyISAM引擎版本千万别用。Quartz的锁表和并发控制依赖InnoDB的行级锁如果用MyISAM高并发下很容易出现锁表异常。很多帖子说Quartz锁表、报错十有八九是建表选的引擎不对。如果你用的不是MySQL而是其他数据库需要去对应SQL目录里找适配脚本。比如国产的达梦、人大金仓这类数据库通用的不会完全兼容得根据自己的库改类型或者找对应的方言包。这个不展开讲先用MySQL把链路跑通最重要。3. SSM整合Quartz的配置全流程3.1 依赖引入与数据源配置Maven依赖方面SSM项目里除了Spring、SpringMVC、MyBatis之外需要额外引Quartz和spring-context-support。之所以要加spring-context-support是因为SchedulerFactoryBean这个整合核心类从Spring 4开始被移到了这里不加的话ClassNotFound跑不掉。dependency groupIdorg.quartz-scheduler/groupId artifactIdquartz/artifactId version2.3.0/version /dependency dependency groupIdorg.springframework/groupId artifactIdspring-context-support/artifactId version4.3.20.RELEASE/version /dependency数据源我建议直接复用Spring里现有的Druid连接池不要给Quartz单独再建一套减少连接资源浪费。操作上在Spring的applicationContext.xml里有一个dataSource Bean就够后面SchedulerFactoryBean直接注入。3.2 Spring配置SchedulerFactoryBean的三个关键属性在Spring里配置Quartz核心就是配置SchedulerFactoryBean。它会读取quartz.properties创建Scheduler对象并把这个Scheduler注册到Spring容器里。bean idschedulerFactoryBean classorg.springframework.scheduling.quartz.SchedulerFactoryBean property nameconfigLocation valueclasspath:quartz.properties/ property namedataSource refdataSource/ property namejobFactory refspringBeanJobFactory/ property nameoverwriteExistingJobs valuetrue/ /beanquartz.properties核心配置如下org.quartz.scheduler.instanceName MyScheduler org.quartz.scheduler.instanceId AUTO org.quartz.threadPool.class org.quartz.simpl.SimpleThreadPool org.quartz.threadPool.threadCount 10 org.quartz.threadPool.threadPriority 5 org.quartz.jobStore.class org.quartz.impl.jdbcjobstore.JobStoreTX org.quartz.jobStore.driverDelegateClass org.quartz.impl.jdbcjobstore.StdJDBCDelegate org.quartz.jobStore.tablePrefix QRTZ_ org.quartz.jobStore.isClustered false org.quartz.jobStore.dataSource springDataSource注意最后一行org.quartz.jobStore.dataSource这个配置名只是占位真正生效的数据源是Spring中SchedulerFactoryBean的dataSource属性注入的。如果你不配置SchedulerFactoryBean的dataSource属性Quartz只会用properties里org.quartz.dataSource.springDataSource.*那一组配置那样的话连接池就脱离了Spring管理。我在项目中选前者统一走Spring容器。还有几个容易踩的点instanceName建议保持唯一多个应用共用一个库时不要重名instanceId配AUTO集群环境下会自动分配实例标识tablePrefix默认QRTZ_如果建表脚本里改了前缀这里必须一致。3.3 Job里注入Service的解决方案Quartz的Job实例是Quartz自己new出来的不是Spring管理的Bean所以你在Job类里写Autowired根本注入不进去编译不报错但运行就是null。这是一个非常经典的坑。解决办法是自定义一个JobFactory让Quartz创建Job实例时再通过Spring的AutowireCapableBeanFactory把Spring容器里的Bean注入进去public class SpringBeanJobFactory extends SpringBeanJobFactory implements ApplicationContextAware { private AutowireCapableBeanFactory beanFactory; Override public void setApplicationContext(ApplicationContext context) { beanFactory context.getAutowireCapableBeanFactory(); } Override protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception { Object job super.createJobInstance(bundle); beanFactory.autowireBean(job); return job; } }把这个类声明成Spring Bean然后注入到SchedulerFactoryBean的jobFactory属性里。这样Job类里写Autowired就正常了。偷懒的写法是在Job里通过ApplicationContext静态工具类手动拿Bean也能用但代码不够优雅我建议还是把JobFactory配好。4. 动态增删改查的实现与避坑4.1 新增任务的完整实现动态新增任务核心就是构造JobDetail和Trigger然后调用scheduler.scheduleJob(jobDetail, trigger)注册进调度器。这个方法会同时处理任务和触发器一步到位。我提供一个Service层方法页面传进来任务名称、任务分组、cron表达式、要执行的Job类以及自定义参数public boolean addJob(String jobName, String jobGroup, String triggerName, String triggerGroup, Class? extends Job jobClass, String cron, MapString, Object params) throws SchedulerException { // 1. 构建JobDetail注意storeDurably(true) JobDetail jobDetail JobBuilder.newJob(jobClass) .withIdentity(jobName, jobGroup) .storeDurably(true) .build(); // 2. 设置任务参数 if (params ! null !params.isEmpty()) { jobDetail.getJobDataMap().putAll(params); } // 3. 构建Cron触发器 CronScheduleBuilder cronScheduleBuilder CronScheduleBuilder.cronSchedule(cron); CronTrigger cronTrigger TriggerBuilder.newTrigger() .withIdentity(triggerName, triggerGroup) .startNow() .withSchedule(cronScheduleBuilder) .build(); // 4. 注册 scheduler.scheduleJob(jobDetail, cronTrigger); return true; }这里几个点要解释一下。storeDurably(true)的意思是即使这个JobDetail没有关联触发器也要保留在调度器中因为后续修改cron或者挂新的Trigger都需要依赖它。JobDataMap是传参的关键它本质上是一个MapQuartz持久化时会序列化存到QRTZ_JOB_DETAILS表里。Job类这边务必提供一个无参构造器Quartz实例化Job时走的是无参构造。有参构造会导致InstantiationException这种问题排查起来不算难但很浪费时间。4.2 修改cron的几种方式动态修改执行时间最常用的API是rescheduleJob(triggerKey, newTrigger)先拿到原来的TriggerKey用新cron重建Trigger替换掉。public boolean updateCron(String triggerName, String triggerGroup, String cron) throws SchedulerException { TriggerKey triggerKey TriggerKey.triggerKey(triggerName, triggerGroup); CronTrigger oldTrigger (CronTrigger) scheduler.getTrigger(triggerKey); if (oldTrigger null) { return false; } CronScheduleBuilder scheduleBuilder CronScheduleBuilder.cronSchedule(cron); CronTrigger newTrigger TriggerBuilder.newTrigger() .withIdentity(triggerKey) .withSchedule(scheduleBuilder) .build(); scheduler.rescheduleJob(triggerKey, newTrigger); return true; }这里我要特别提醒一个坑如果任务正在执行中直接rescheduleJob虽然不会报错但可能在下一次触发时间上出现短暂错乱。稳妥做法是先pauseJobrescheduleJob完成后再resumeJob。实际项目里我都是这么干的宁可多两次操作别给线上留隐患。还有一种需求是只更新JobDetail的参数、不改变调度时间。这种情况直接用scheduler.addJob(jobDetail, true)就能覆盖旧任务。注意第二个参数为true是允许覆盖如果不确认就传false同名的任务会抛ObjectAlreadyExistsException这个也常见。4.3 暂停恢复删除查询一个都不能少暂停和恢复是动态管理系统里一定会用到的一组代码搞定// 暂停任务 scheduler.pauseJob(JobKey.jobKey(jobName, jobGroup)); // 恢复任务 scheduler.resumeJob(JobKey.jobKey(jobName, jobGroup)); // 暂停触发器 scheduler.pauseTrigger(TriggerKey.triggerKey(triggerName, triggerGroup)); // 恢复触发器 scheduler.resumeTrigger(TriggerKey.triggerKey(triggerName, triggerGroup));暂停任务和暂停触发器是两个层面的操作。暂停任务后关联的所有触发器都不会再触发暂停触发器只会影响这个触发器不影响任务。业务上要按需选择。删除任务时要谨慎一点Quartz的deleteJob会把JobDetail以及所有关联触发器一起删除不用手动一个个删触发器。实践中我习惯先pauseJob再deleteJob防止删除过程中任务刚好被触发造成一些不可预期的并发问题public boolean removeJob(String jobName, String jobGroup) throws SchedulerException { JobKey jobKey JobKey.jobKey(jobName, jobGroup); if (!scheduler.checkExists(jobKey)) { return false; } scheduler.pauseJob(jobKey); return scheduler.deleteJob(jobKey); }查询这块我提供一个完整的列表查询思路先拿到所有JobGroup再遍历每个Group下的JobKey最后通过JobKey找关联Trigger组装成VO返回。很多同学只查了任务列表没查触发器结果页面上看不到cron时间所以组装VO时要把Trigger信息带全public ListJobInfoVO listJobs() throws SchedulerException { ListJobInfoVO list new ArrayList(); for (String group : scheduler.getJobGroupNames()) { SetJobKey jobKeys scheduler.getJobKeys(GroupMatcher.jobGroupEquals(group)); for (JobKey jobKey : jobKeys) { JobDetail jobDetail scheduler.getJobDetail(jobKey); List? extends Trigger triggers scheduler.getTriggersOfJob(jobKey); for (Trigger trigger : triggers) { JobInfoVO vo new JobInfoVO(); vo.setJobName(jobKey.getName()); vo.setJobGroup(jobKey.getGroup()); vo.setJobClass(jobDetail.getJobClass().getName()); vo.setTriggerName(trigger.getKey().getName()); vo.setTriggerGroup(trigger.getKey().getGroup()); if (trigger instanceof CronTrigger) { vo.setCron(((CronTrigger) trigger).getCronExpression()); } vo.setTriggerState(scheduler.getTriggerState(trigger.getKey()).name()); list.add(vo); } } } return list; }4.4 JobDataMap传参的序列化坑使用JDBCJobStore时JobDataMap存储到QRTZ_JOB_DETAILS表里走的是Java对象序列化。也就是说放进JobDataMap的Value对象必须实现java.io.Serializable接口否则注册任务时直接抛ObjectSerializationException如果你没处理这个异常页面会直接报内部错误。实际项目里我一般只往JobDataMap里放任务ID、通知地址这类基础配置任务真正需要的复杂业务数据通过任务ID去数据库重新查。这样既避免大对象序列化性能损耗也减少因为对象结构变更导致的历史数据反序列化失败问题。还有一点值得提醒Job内部取参数用jobExecutionContext.getMergedJobDataMap()它合并了JobDetail上的数据和Trigger上的数据。如果两处key重复Trigger上的优先级高理解了这个规则定位问题会快很多。5. 常见问题与排查实录5.1 任务不执行先查这三处很多人在本地跑通Quartz部署到服务器上任务就毫无反应。按优先级排查第一线程池是不是被占满了Quartz默认线程数量很小如果任务运行时间过长后续任务都会排队日志里看着像“不执行”。第二cron表达式是否符合预期用在线cron工具验证一下注意服务端时区。第三数据库里的Trigger状态是否为WAITING如果状态变成了PAUSED说明任务被暂停过需要恢复。处理任务耗时长的场景建议给任务的Job类加上DisallowConcurrentExecution注解防止同一个任务上次没跑完下次又触发导致数据重复处理。这个注解在动态任务场景下尤其重要。5.2 数据库锁表或死锁Quartz用QRTZ_LOCKS表做悲观锁每次调度操作都会对特定行加锁就是为了集群环境下多个调度器不打架。出现锁表或死锁一般是事务没及时提交或者在MyISAM引擎的表上跑调度。排查思路先看QRTZ_LOCKS表里锁的记录是否有长事务。再看QRTZ_TRIGGERS的TRIGGER_STATE字段正常应该是WAITING。另外确认建表用的是InnoDB版本脚本。我把典型问题整理成了表方便对照现象原因解决方案重启后任务全部消失用了RAMJobStore切换JobStoreTX持久化Job里Autowired为nullJob由Quartz实例化不在Spring配置SpringBeanJobFactory注册任务抛ObjectAlreadyExistsException同名Job已经存在判断checkExists或覆盖注册任务被重复执行集群配置缺失配置isClusteredtrue表存在但启动报错建表脚本用了MyISAM版本用tables_mysql_innodb.sql修改cron后不生效TriggerKey对不上确认triggerName和group一致5.3 页面偶尔控制的接口有延迟这个问题是我在线上真实遇到的。页面点暂停QRTZ_TRIGGERS表里状态也变成PAUSED了但任务还在跑。原因是Quartz的调度器扫描是基于时间窗口的暂停操作返回后当前这次Fire已经开始了需要等任务执行完毕才能真正停掉。所以设计接口时页面提示不能写成“已停止”而应该是“停止指令已下发正在停止任务”。尤其对长任务要明确这个延迟机制否则业务方会一直提工单说系统有bug。写到最后分享一点我的实际体会这套SSM整合Quartz持久化方案我经历了好几个版本的迭代最大的感受是Quartz的核心并不复杂难的是动态管理时的各种边界情况。任务存在、任务删除后触发器是否存在、正在执行中的任务改cron会怎样、集群下多个实例会不会打架这些问题不亲自踩一遍很难有深刻印象。建议你在本地先把新建、修改、暂停、删除、重启恢复这几个操作完整测试一遍再用Jmeter跑一下并发任务确认没有锁和丢任务的问题再上生产。这个验证流程比什么方案设计都值钱。本文还有配套的精品资源点击获取
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进