☰
SSM整合Quartz持久化实战:从建表到动态任务管理
2026/10/19 9:04:45 网站建设 项目流程

简介:这份资源面向Java后端开发者与需要实现定时任务管理的项目团队,提供SSM(Spring、Spring MVC、MyBatis)整合Quartz并将任务配置与状态持久化到数据库的完整示例,解决定时任务无法动态增删改查、重启后状态丢失的问题。压缩包共55个文件,约28.2MB,以40个jar依赖包为主,辅以7个xml配置文件、3个properties配置、2个jsp页面及少量class与清单文件,覆盖调度、持久化与Web展示所需组件。资源中给出了SchedulerFactoryBean配置、自定义Job实现、针对Quartz多张数据表的Mapper操作,以及通过控制器接口动态管理任务的思路,并配有任务管理页面,便于读者理解任务列表、添加、编辑与删除的完整链路。目前已有706人学习下载,适合作为定时任务模块的参考实现,帮助快速搭建可维护、可扩展的调度系统。

1. 从一次任务丢失说起:SSM 整合 Quartz 持久化到底难在哪

有个做后台管理系统的朋友找我救火,说定时任务跑着跑着就没了,重启一次服务,之前配好的任务全丢,日志里连个响都没有。我问他任务存哪了,他说存内存里,Quartz 默认的 RAMJobStore。这就是典型的「装的时候能跑,上线就翻车」——SSM 整合 Quartz 本身不难,难的是把任务持久化到数据库,还要支持动态增删改查。这套环境在中小型后台系统里太常见了:订单超时关闭、报表定时生成、数据同步,哪个都离不开它。装不好会怎样?任务重复执行、触发器丢失、集群环境下互相抢任务,每一个都够你排查半天。这篇笔记就是写给正在配这套环境的开发者,从版本选型到建表、从配置到验证,一步步走通。

2. 环境准备与版本选型:别让依赖冲突毁掉一整天

2.1 版本对照表与依赖关系

SSM 整合 Quartz 最容易翻车的地方不是代码,是版本。Spring 4.x 和 Spring 5.x 对 Quartz 的支持方式不一样,Quartz 1.x 和 2.x 的 API 更是天差地别。我一般会先锁定一组经过验证的版本组合,再动手写代码。

组件推荐版本说明
JDK1.8Quartz 2.3 在 JDK 8 上最稳,JDK 11 需要额外处理
Spring5.3.x对 Quartz 2.3 的SchedulerFactoryBean支持完善
MyBatis3.5.x与 Spring 5.3 整合成熟
Quartz2.3.2持久化方案成熟,社区资料多
数据库MySQL 5.7 / 8.08.0 注意驱动版本和时区配置
连接池Druid 1.2.x监控方便,和 Quartz 配合稳定

为什么不用 Quartz 2.2?2.2 有个已知的JobStoreSupport并发问题,在任务密集时会出现触发器状态更新丢失。2.3.2 修复了这个问题,而且对@DisallowConcurrentExecution注解的支持更完整。Spring 5.3 的SchedulerFactoryBean可以直接注入DataSource,不需要额外写工厂类,这是我最看重的一点。

Maven 依赖里有个坑:spring-context-support必须显式引入,否则SchedulerFactoryBean类找不到。很多人只引了spring-context,编译能过,启动就报ClassNotFoundException。

<!-- Quartz 核心包 --> <dependency> <groupId>org.quartz-scheduler</groupId> <artifactId>quartz</artifactId> <version>2.3.2</version> </dependency> <!-- Quartz 与 Spring 整合支持,必须显式引入 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context-support</artifactId> <version>5.3.31</version> </dependency> <!-- 事务支持,Quartz 持久化操作需要 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-tx</artifactId> <version>5.3.31</version> </dependency>

spring-context-support提供了SchedulerFactoryBean和MethodInvokingJobDetailFactoryBean,这两个类是把 Quartz 塞进 Spring 容器的关键。spring-tx则是保证 Quartz 在更新触发器状态时能参与 Spring 事务,避免任务状态和业务数据不一致。

2.2 数据库建表:11 张表一张都不能少

Quartz 持久化到数据库,官方提供了一套标准建表 SQL,在quartz-2.3.2.jar的org/quartz/impl/jdbcjobstore/tables_mysql_innodb.sql路径下。我见过有人只建了QRTZ_JOB_DETAILS和QRTZ_TRIGGERS,结果任务能存进去但调度不起来,因为QRTZ_CRON_TRIGGERS和QRTZ_SIMPLE_TRIGGERS没建,触发器类型信息丢了。

-- 以 MySQL 为例,核心表结构节选 CREATE TABLE QRTZ_JOB_DETAILS ( SCHED_NAME VARCHAR(120) NOT NULL, JOB_NAME VARCHAR(200) NOT NULL, JOB_GROUP VARCHAR(200) NOT NULL, DESCRIPTION VARCHAR(250) NULL, JOB_CLASS_NAME VARCHAR(250) NOT NULL, IS_DURABLE VARCHAR(1) NOT NULL, IS_NONCONCURRENT VARCHAR(1) NOT NULL, IS_UPDATE_DATA VARCHAR(1) NOT NULL, REQUESTS_RECOVERY VARCHAR(1) NOT NULL, JOB_DATA BLOB NULL, PRIMARY KEY (SCHED_NAME, JOB_NAME, JOB_GROUP) ) ENGINE=InnoDB; CREATE TABLE QRTZ_TRIGGERS ( SCHED_NAME VARCHAR(120) NOT NULL, TRIGGER_NAME VARCHAR(200) NOT NULL, TRIGGER_GROUP VARCHAR(200) NOT NULL, JOB_NAME VARCHAR(200) NOT NULL, JOB_GROUP VARCHAR(200) NOT NULL, DESCRIPTION VARCHAR(250) NULL, NEXT_FIRE_TIME BIGINT(13) NULL, PREV_FIRE_TIME BIGINT(13) NULL, PRIORITY INTEGER NULL, TRIGGER_STATE VARCHAR(16) NOT NULL, TRIGGER_TYPE VARCHAR(8) NOT NULL, START_TIME BIGINT(13) NOT NULL, END_TIME BIGINT(13) NULL, CALENDAR_NAME VARCHAR(200) NULL, MISFIRE_INSTR SMALLINT(2) NULL, JOB_DATA BLOB NULL, PRIMARY KEY (SCHED_NAME, TRIGGER_NAME, TRIGGER_GROUP), FOREIGN KEY (SCHED_NAME, JOB_NAME, JOB_GROUP) REFERENCES QRTZ_JOB_DETAILS(SCHED_NAME, JOB_NAME, JOB_GROUP) ) ENGINE=InnoDB;

注意SCHED_NAME这个字段,它是 Quartz 2.x 引入的调度器实例名,默认是quartzScheduler。如果你在同一个库里跑多个调度器实例,必须改这个值,否则表数据会串。我一般会在quartz.properties里显式指定org.quartz.scheduler.instanceName,和建表时保持一致。

建表时还要注意字符集,JOB_DATA是 BLOB 类型,存的是序列化后的 JobDataMap。如果 JobDataMap 里有中文,序列化后是二进制,不受字符集影响,但DESCRIPTION字段是 VARCHAR,建议用utf8mb4。

2.3 配置文件:quartz.properties 的五个关键参数

Quartz 的配置有两种方式:quartz.properties文件,或者通过SchedulerFactoryBean的setQuartzProperties方法注入。我习惯用 properties 文件,改起来直观,不用重新编译。

# 调度器实例名,必须和数据库表里的 SCHED_NAME 一致 org.quartz.scheduler.instanceName = quartzScheduler # 实例 ID 设为 AUTO,集群环境下自动生成 org.quartz.scheduler.instanceId = AUTO # 线程池配置,并发任务多的时候调大 org.quartz.threadPool.class = org.quartz.simpl.SimpleThreadPool org.quartz.threadPool.threadCount = 10 org.quartz.threadPool.threadPriority = 5 # JobStore 用 JDBC 持久化 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.useProperties = false # 数据源,交给 Spring 管理时这里可以不配 # org.quartz.jobStore.dataSource = myDS

driverDelegateClass选StdJDBCDelegate就行,MySQL 不需要特殊代理。tablePrefix默认是QRTZ_,如果你建表时改了前缀,这里要同步改。isClustered单机设为 false,集群设为 true,设为 true 后 Quartz 会通过数据库锁来协调多个实例,但需要额外配置org.quartz.jobStore.clusterCheckinInterval,默认 15000 毫秒。

useProperties这个参数容易被忽略。设为 false 时,JobDataMap 里的值会被序列化成 BLOB 存储;设为 true 时,只支持 String 类型的值,但可读性更好。我一般设为 false,因为业务里经常要传对象。

3. Spring 整合配置:把 Scheduler 交给容器管

3.1 SchedulerFactoryBean 的完整配置

Spring 整合 Quartz 的核心就是SchedulerFactoryBean,它负责创建Scheduler实例、注入数据源、加载配置文件,并在 Spring 容器启动时自动启动调度器。

<!-- Spring 配置文件片段 --> <bean id="schedulerFactoryBean" class="org.springframework.scheduling.quartz.SchedulerFactoryBean"> <!-- 注入数据源,Quartz 持久化必须 --> <property name="dataSource" ref="dataSource"/> <!-- 指定 quartz.properties 位置 --> <property name="configLocation" value="classpath:quartz.properties"/> <!-- 覆盖 properties 中的配置,优先级更高 --> <property name="quartzProperties"> <props> <prop key="org.quartz.scheduler.instanceName">quartzScheduler</prop> <prop key="org.quartz.jobStore.isClustered">false</prop> </props> </property> <!-- 容器启动时延迟 10 秒启动调度器,等应用完全初始化 --> <property name="startupDelay" value="10"/> <!-- 覆盖已存在的任务,避免重复定义报错 --> <property name="overwriteExistingJobs" value="true"/> <!-- 自动启动 --> <property name="autoStartup" value="true"/> <!-- 事务管理器,保证 Quartz 操作和业务操作在同一事务 --> <property name="transactionManager" ref="transactionManager"/> </bean>

dataSource必须注入,否则 Quartz 会用自己的连接池,和 Spring 的事务管理器脱节,导致任务状态更新和业务数据不一致。startupDelay设 10 秒是经验值,给 Spring 容器足够时间完成 Bean 初始化,避免调度器启动时依赖的 Bean 还没创建好。

overwriteExistingJobs设为 true 时,每次启动会覆盖数据库里同名的 JobDetail,适合开发阶段。生产环境建议设为 false,否则手动改过的任务配置会被覆盖。

3.2 动态增删改查的 Service 实现

持久化的意义在于动态管理。Quartz 提供了Scheduler接口,可以直接操作 Job 和 Trigger。我一般会封装一个QuartzService,把常用的增删改查方法暴露出来。

@Service public class QuartzService { @Autowired private Scheduler scheduler; /** * 添加任务 * @param jobName 任务名 * @param jobGroup 任务组 * @param cronExpression cron 表达式 * @param jobClass 任务实现类 */ public void addJob(String jobName, String jobGroup, String cronExpression, Class<? extends Job> jobClass) throws SchedulerException { // 构建 JobDetail,指定任务类和名称 JobDetail jobDetail = JobBuilder.newJob(jobClass) .withIdentity(jobName, jobGroup) .storeDurably() // 没有触发器时也保留任务 .build(); // 构建 CronTrigger,绑定 cron 表达式 CronTrigger trigger = TriggerBuilder.newTrigger() .withIdentity(jobName + "Trigger", jobGroup) .forJob(jobDetail) .withSchedule(CronScheduleBuilder.cronSchedule(cronExpression)) .build(); // 调度任务,持久化到数据库 scheduler.scheduleJob(jobDetail, trigger); } /** * 删除任务,同时删除关联的触发器 */ public void deleteJob(String jobName, String jobGroup) throws SchedulerException { JobKey jobKey = JobKey.jobKey(jobName, jobGroup); scheduler.deleteJob(jobKey); } /** * 更新任务的 cron 表达式 */ public void updateJobCron(String jobName, String jobGroup, String cronExpression) throws SchedulerException { TriggerKey triggerKey = TriggerKey.triggerKey( jobName + "Trigger", jobGroup); CronTrigger newTrigger = TriggerBuilder.newTrigger() .withIdentity(triggerKey) .withSchedule(CronScheduleBuilder.cronSchedule(cronExpression)) .build(); scheduler.rescheduleJob(triggerKey, newTrigger); } /** * 暂停任务 */ 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)); } }

storeDurably()这个方法很关键。默认情况下,JobDetail 如果没有关联的 Trigger,会被自动删除。加上storeDurably()后,即使触发器暂时不存在,任务也会保留在数据库里,方便后续重新绑定触发器。

rescheduleJob更新触发器时,Quartz 会先删除旧触发器再创建新的,这个过程在数据库里是事务性的。如果更新失败,旧触发器不会被删除,这是 Quartz 内部保证的。

3.3 Job 实现类的两个注解

Job 实现类本身很简单,继承QuartzJobBean或者实现Job接口都行。但有两个注解必须了解。

// 禁止并发执行,同一个 JobDetail 在上一次执行完成前不会再次触发 @DisallowConcurrentExecution public class OrderTimeoutJob extends QuartzJobBean { @Autowired private OrderService orderService; @Override protected void executeInternal(JobExecutionContext context) throws JobExecutionException { // 从 JobDataMap 获取参数 JobDataMap dataMap = context.getJobDetail().getJobDataMap(); String orderType = dataMap.getString("orderType"); try { orderService.closeTimeoutOrders(orderType); } catch (Exception e) { // 抛出 JobExecutionException 让 Quartz 知道任务失败 throw new JobExecutionException(e, false); } } }

@DisallowConcurrentExecution是加在 Job 类上的,不是方法上。它的作用是防止同一个 JobDetail 并发执行。比如订单超时关闭任务,如果上一次还没跑完,下一次触发时间到了,Quartz 会等上一次完成后再执行,而不是同时跑两个。

@PersistJobDataAfterExecution是另一个常用注解,它会让 JobDataMap 的修改在任务执行后持久化到数据库。比如你有个计数器存在 JobDataMap 里,每次执行加一,加上这个注解后,下次执行能拿到上次的值。不加的话,每次执行拿到的都是初始值。

注意:@PersistJobDataAfterExecution和@DisallowConcurrentExecution经常一起用,因为并发修改 JobDataMap 会导致数据覆盖。

4. 避坑与排查:五个让我加班到凌晨的坑

4.1 任务重复执行:集群环境下的经典问题

现象:单机测试正常,部署到两台机器的集群后,同一个任务在相近时间被执行了两次。

原因:org.quartz.jobStore.isClustered设为了 false,或者虽然设为了 true,但instanceId配成了固定值。Quartz 集群靠数据库行锁来协调,每个实例启动时会往QRTZ_SCHEDULER_STATE表写一条记录,通过LAST_CHECKIN_TIME判断实例是否存活。如果instanceId重复,两个实例会互相认为对方是自己的备份,导致任务被重复触发。

解决:instanceId设为AUTO,Quartz 会用主机名和时间戳生成唯一 ID。同时确保isClustered=true,并且clusterCheckinInterval不要设得太大,默认 15000 毫秒够用。另外,数据库时钟要同步,Quartz 依赖数据库时间来判断实例存活。

4.2 触发器状态卡在 BLOCKED 或 ERROR

现象:任务不再触发,查QRTZ_TRIGGERS表发现TRIGGER_STATE是BLOCKED或ERROR。

原因:任务执行时抛了未捕获的异常,或者 JVM 崩溃导致触发器状态没来得及更新。Quartz 在触发任务前会把状态改为BLOCKED,执行完改回WAITING。如果中间进程挂了,状态就卡住了。

解决:写一个恢复脚本,把TRIGGER_STATE为BLOCKED且NEXT_FIRE_TIME小于当前时间的触发器状态改回WAITING。更根本的办法是在 Job 的executeInternal里用 try-catch 包住所有逻辑,确保异常不会泄漏到 Quartz 框架层。

-- 恢复卡住的触发器,谨慎操作,建议先备份 UPDATE QRTZ_TRIGGERS SET TRIGGER_STATE = 'WAITING' WHERE TRIGGER_STATE = 'BLOCKED' AND NEXT_FIRE_TIME < UNIX_TIMESTAMP() * 1000;

4.3 JobDataMap 序列化失败

现象:添加任务时报NotSerializableException,或者任务存进去了但执行时取不到参数。

原因:JobDataMap 里放了不支持序列化的对象。Quartz 默认用 Java 原生序列化,要求对象实现Serializable接口。如果放了 Spring 的 Bean 或者数据库连接,序列化必然失败。

解决:JobDataMap 里只放基本类型和 String,复杂对象存 ID,在 Job 执行时通过 ID 从数据库或 Spring 容器里查。我一般会定义一个JobParam类,实现Serializable,只包含必要的字段。

4.4 MySQL 8.0 时区问题导致任务不触发

现象:任务添加成功,数据库里也有记录,但NEXT_FIRE_TIME一直不变,任务永远不触发。

原因:MySQL 8.0 的 JDBC 驱动默认时区是 UTC,而 Quartz 用System.currentTimeMillis()计算触发时间,两者差 8 小时。NEXT_FIRE_TIME存的是毫秒时间戳,本身不受时区影响,但驱动在读写时做了转换,导致 Quartz 读到的时间比实际时间小 8 小时。

解决:JDBC URL 加上serverTimezone=Asia/Shanghai,并且确保数据库服务器的时区也是东八区。如果用的是 MySQL 8.0 驱动,还要注意useSSL=false和allowPublicKeyRetrieval=true,否则连接会报错。

4.5 应用重启后任务不执行

现象:服务重启后,数据库里的任务还在,但调度器没有加载它们。

原因:SchedulerFactoryBean的autoStartup设为了 false,或者startupDelay设得太短,调度器启动时数据源还没初始化完。还有一种可能是overwriteExistingJobs设为了 true,启动时把数据库里的任务覆盖成了初始状态,但初始状态没有触发器。

解决:autoStartup保持 true,startupDelay至少 5 秒。如果任务是通过代码动态添加的,确保添加逻辑在应用启动完成后执行,可以用ApplicationRunner或@PostConstruct配合Scheduler.isStarted()判断。

5. 验证跑通:从数据库到日志的完整检查链

装完不是终点,能验证跑通才算。我一般会按下面这个顺序走一遍,每一步都有明确的检查点。

第一步,检查数据库表。启动应用后,查QRTZ_JOB_DETAILS和QRTZ_TRIGGERS,确认任务和触发器都写进去了。重点看TRIGGER_STATE是不是WAITING,NEXT_FIRE_TIME是不是一个未来的时间戳。

SELECT JOB_NAME, JOB_GROUP, JOB_CLASS_NAME FROM QRTZ_JOB_DETAILS; SELECT TRIGGER_NAME, TRIGGER_STATE, FROM_UNIXTIME(NEXT_FIRE_TIME/1000) AS NEXT_FIRE FROM QRTZ_TRIGGERS;

第二步,检查调度器状态。通过Scheduler的 API 或者日志确认调度器已启动。Spring 启动日志里会有Scheduler quartzScheduler_$_NON_CLUSTERED started这样的输出。如果没看到,检查autoStartup配置。

第三步,手动触发一次。写一个测试接口,调用scheduler.triggerJob(jobKey),看任务是否立即执行。这一步能排除 cron 表达式写错的问题。

第四步,观察日志。Job 的executeInternal方法里加日志,确认任务按预期时间执行。如果日志没输出,检查@DisallowConcurrentExecution是否导致任务被跳过。

第五步,动态增删改查验证。通过接口添加一个新任务,等一个触发周期,确认执行;然后更新 cron 表达式,确认下次触发时间变了;最后删除任务,确认数据库记录被清理。

检查项预期结果异常处理
QRTZ_JOB_DETAILS 有记录任务名和类名正确检查 JobBuilder 的 withIdentity
TRIGGER_STATE = WAITING触发器等待触发检查 cron 表达式和时区
NEXT_FIRE_TIME 未来时间时间戳大于当前检查系统时间和数据库时间
日志有任务执行输出按 cron 时间打印检查 Job 类是否被 Spring 管理
删除后记录消失数据库无残留检查 deleteJob 是否调用

最后说个习惯。从那以后我每次配 Quartz 持久化,都会先在测试库跑一遍完整的增删改查,再对比QRTZ_TRIGGERS表的TRIGGER_STATE变化,确认状态流转正常才上生产。这套流程帮我省了至少三次半夜爬起来处理任务丢失的麻烦。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询