简介:本资源面向Java后端开发者与需要实现定时任务动态管理的项目团队,提供一套SSM(Spring+Spring MVC+MyBatis)整合Quartz并将任务配置持久化到数据库的完整示例。通过SchedulerFactoryBean、自定义Job类与MyBatis对Quartz多张数据表的操作,实现定时任务的动态增删改查,并配有JSP管理页面与Ajax接口,便于在后台直观维护任务列表。压缩包共55个文件,以40个jar依赖、7个xml配置、3个properties及2个jsp页面为主,另含class与mf文件,整体约28.2MB,目录涵盖quartz.properties、spring-mybatis.xml、spring-mvc.xml、mappers映射及lib依赖库,结构清晰便于按模块查阅。目前已有706人学习下载。读者可从中获得可运行的整合工程、数据库持久化配置思路、任务调度接口实现方式以及权限与线程池等性能优化参考,适合作为定时任务管理模块的开发模板与排错对照。
1. 从一次定时任务翻车说起:SSM 整合 Quartz 并持久化到数据库到底解决什么问题
很多做 Java Web 的朋友都遇到过这种场景:项目里要跑定时任务,一开始用@Scheduled注解,写死在代码里,简单省事。可上线没多久,运营就找过来了——"这个任务能不能改成每天凌晨两点跑?""这个任务先停掉,等通知再开。""能不能手动触发一次?"你只能改代码、重新打包、重启服务。一次两次还行,次数多了,运维看你的眼神都不对了。
这就是把定时任务写死在代码里的典型痛点:调度逻辑和业务代码耦合,改一次配置就得走一遍发版流程。而 Quartz 这个调度框架,配合 SSM(Spring + SpringMVC + MyBatis)整合之后,把任务信息持久化到数据库,就能做到动态增删改查——不改代码、不重启服务,页面上点几下就能控制任务的启停、修改 Cron 表达式、立即执行一次。这套方案适合谁?适合那些后台管理系统里需要灵活调度、又不想引入太重中间件的团队。下面我把这套整合方案从原理到落地拆开讲,包括建表、配置、动态操作和几个我踩过的坑。
2. Quartz 持久化机制与 SSM 整合的选型逻辑
2.1 为什么必须用 JDBCJobStore 而不是 RAMJobStore
Quartz 有两种任务存储方式。RAMJobStore 把调度信息放在内存里,速度快、配置简单,但服务一重启,所有任务和触发器全部丢失,得重新注册。JDBCJobStore 则把 JobDetail、Trigger、Calendar 等信息通过 JDBC 存到数据库表里,重启后自动恢复调度状态。
对于需要动态增删改查的场景,JDBCJobStore 是唯一选择。原因很直接:动态操作意味着任务信息在运行时被修改,如果只存在内存里,集群环境下多节点无法共享调度状态,单节点重启也会丢数据。用数据库持久化之后,多个应用实例可以共享同一套任务数据,配合 Quartz 的集群模式还能避免任务重复执行。
选型上还有一个细节:JDBCJobStore 又分JobStoreTX和JobStoreCMT。前者自己管理事务,后者依赖容器管理的事务。SSM 项目里通常用 Spring 的DataSourceTransactionManager,所以选JobStoreTX更顺,让 Quartz 自己控制事务边界,避免和 Spring 事务管理器打架。
2.2 Quartz 核心表结构与字段含义
Quartz 官方提供了一套标准建表 SQL,不同数据库版本略有差异。MySQL 下核心表有 11 张,实际开发中重点关注这几张:
| 表名 | 作用 | 关键字段 |
|---|---|---|
| QRTZ_JOB_DETAILS | 存储 JobDetail 信息 | JOB_NAME、JOB_GROUP、JOB_CLASS_NAME |
| QRTZ_TRIGGERS | 存储触发器基本信息 | TRIGGER_NAME、TRIGGER_GROUP、JOB_NAME、TRIGGER_STATE |
| QRTZ_CRON_TRIGGERS | 存储 Cron 类型触发器 | CRON_EXPRESSION、TIME_ZONE_ID |
| QRTZ_SIMPLE_TRIGGERS | 存储 Simple 类型触发器 | REPEAT_COUNT、REPEAT_INTERVAL |
| QRTZ_FIRED_TRIGGERS | 存储已触发的触发器状态 | ENTRY_ID、INSTANCE_NAME、FIRED_TIME |
| QRTZ_SCHEDULER_STATE | 集群节点状态 | INSTANCE_NAME、LAST_CHECKIN_TIME |
QRTZ_TRIGGERS里的TRIGGER_STATE字段尤其重要,它决定了触发器当前是等待、运行、暂停还是完成状态。动态启停任务本质上就是修改这个字段的值,理解这一点后面写代码会清晰很多。
2.3 SSM 各层与 Quartz 的职责划分
整合的时候,各层职责要划清楚,否则代码会乱成一团。我一般这样分:
- Mapper 层:直接操作 Quartz 的数据库表,用于查询任务列表、更新触发器状态等自定义查询。
- Service 层:封装
Scheduler的操作,提供增删改查方法,同时处理业务逻辑校验。 - Controller 层:接收前端请求,调用 Service,返回统一格式的结果。
- Job 类:实现
Job接口,写具体业务逻辑,通过 Spring 的ApplicationContext获取需要的 Service。
关键点在于:Job 类不能直接用@Autowired注入 Spring Bean,因为 Quartz 创建 Job 实例的方式和 Spring 容器是分离的。常见做法是写一个JobFactory继承AdaptableJobFactory,在创建实例后手动把对象交给 Spring 容器完成依赖注入。这个细节后面第 4 章会展开。
3. 从建表到跑通第一个动态任务:完整落地步骤
3.1 引入依赖与 Quartz 配置文件
Maven 项目里需要引入 Quartz 和 Spring 整合的依赖。版本选择上,Quartz 2.3.x 配合 Spring 4.x 或 5.x 都比较稳定,注意spring-context-support是必须的,它提供了SchedulerFactoryBean。
<!-- Quartz 核心包 --> <dependency> <groupId>org.quartz-scheduler</groupId> <artifactId>quartz</artifactId> <version>2.3.2</version> </dependency> <!-- Spring 整合支持 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-context-support</artifactId> <version>5.3.20</version> </dependency> <!-- 数据库连接池,按项目实际情况选 --> <dependency> <groupId>com.alibaba</groupId> <artifactId>druid</artifactId> <version>1.2.8</version> </dependency>依赖说明:quartz是调度核心,spring-context-support提供SchedulerFactoryBean和MethodInvokingJobDetailFactoryBean等整合类。数据库连接池用项目已有的即可,Quartz 会复用同一个DataSource。
接下来是quartz.properties配置,放在src/main/resources下:
# 调度器实例名称,集群环境下每个节点要唯一 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.dataSource = myDS org.quartz.jobStore.isClustered = false # 数据源,这里引用 Spring 中配置的 DataSource org.quartz.dataSource.myDS.driver = com.mysql.cj.jdbc.Driver org.quartz.dataSource.myDS.URL = jdbc:mysql://localhost:3306/quartz_demo?useUnicode=true&characterEncoding=utf8 org.quartz.dataSource.myDS.user = root org.quartz.dataSource.myDS.password = 123456 org.quartz.dataSource.myDS.maxConnections = 10参数说明:jobStore.class指定为JobStoreTX表示使用 JDBC 持久化并自行管理事务;tablePrefix要和建表 SQL 里的前缀一致,默认是QRTZ_;isClustered单机设为 false,集群部署时改为 true 并确保各节点instanceId不同。driverDelegateClass用StdJDBCDelegate适配大多数数据库,MySQL 也可以用MySQLDelegate,但标准代理兼容性更好。
3.2 建表 SQL 与 Spring 整合配置
建表 SQL 在 Quartz 发行包的docs/dbTables目录下,MySQL 对应tables_mysql_innodb.sql。直接执行即可,注意表名大小写要和tablePrefix匹配。执行完之后,QRTZ_开头的表会出现在数据库里。
Spring 配置文件里注册SchedulerFactoryBean:
<bean id="schedulerFactoryBean" class="org.springframework.scheduling.quartz.SchedulerFactoryBean"> <!-- 指定 Quartz 配置文件位置 --> <property name="configLocation" value="classpath:quartz.properties"/> <!-- 复用项目数据源 --> <property name="dataSource" ref="dataSource"/> <!-- 覆盖 JobFactory,解决 Spring Bean 注入问题 --> <property name="jobFactory" ref="customJobFactory"/> <!-- 启动时延迟几秒,等 Spring 容器初始化完成 --> <property name="startupDelay" value="5"/> <!-- 覆盖已存在的任务定义 --> <property name="overwriteExistingJobs" value="true"/> </bean> <bean id="customJobFactory" class="com.demo.quartz.CustomJobFactory"/>startupDelay设 5 秒是个经验值,避免应用刚启动、数据库连接池还没就绪时 Quartz 就去读表导致报错。overwriteExistingJobs设为 true 可以在每次启动时用配置文件里的定义覆盖数据库中的同名任务,调试阶段方便,生产环境按需设置。
3.3 自定义 JobFactory 解决 Spring Bean 注入
前面提到 Job 类里注入不了 Spring Bean,解决办法是自定义 JobFactory:
public class CustomJobFactory extends AdaptableJobFactory { @Autowired private AutowireCapableBeanFactory capableBeanFactory; @Override protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception { // 先让 Quartz 创建 Job 实例 Object jobInstance = super.createJobInstance(bundle); // 再交给 Spring 完成依赖注入 capableBeanFactory.autowireBean(jobInstance); return jobInstance; } }逻辑说明:AdaptableJobFactory是 Quartz 默认的 Job 工厂,createJobInstance方法负责实例化 Job。这里重写之后,先调用父类方法拿到实例,再用 Spring 的AutowireCapableBeanFactory对实例进行自动装配。这样 Job 类里就可以正常使用@Autowired注入 Service 了。参数上不需要额外配置,只要把这个 Factory 注册到SchedulerFactoryBean的jobFactory属性即可。
3.4 动态增删改查的 Service 实现
核心操作都围绕Scheduler接口展开。下面是一个典型的 Service 实现:
@Service public class QuartzJobService { @Autowired private Scheduler scheduler; /** * 新增任务 * @param jobName 任务名 * @param jobGroup 任务分组 * @param cron 时间表达式 * @param jobClass 任务实现类 */ public void addJob(String jobName, String jobGroup, String cron, Class<? extends Job> jobClass) throws Exception { // 构建 JobDetail JobDetail jobDetail = JobBuilder.newJob(jobClass) .withIdentity(jobName, jobGroup) .storeDurably() .build(); // 构建 CronTrigger CronTrigger trigger = TriggerBuilder.newTrigger() .withIdentity(jobName + "Trigger", jobGroup) .withSchedule(CronScheduleBuilder.cronSchedule(cron)) .build(); // 调度器中注册 scheduler.scheduleJob(jobDetail, trigger); } /** * 修改任务的 Cron 表达式 */ public void updateJobCron(String jobName, String jobGroup, String newCron) throws Exception { TriggerKey triggerKey = TriggerKey.triggerKey(jobName + "Trigger", jobGroup); CronTrigger newTrigger = TriggerBuilder.newTrigger() .withIdentity(triggerKey) .withSchedule(CronScheduleBuilder.cronSchedule(newCron)) .build(); scheduler.rescheduleJob(triggerKey, newTrigger); } /** * 暂停任务 */ public void pauseJob(String jobName, String jobGroup) throws Exception { scheduler.pauseJob(JobKey.jobKey(jobName, jobGroup)); } /** * 恢复任务 */ public void resumeJob(String jobName, String jobGroup) throws Exception { scheduler.resumeJob(JobKey.jobKey(jobName, jobGroup)); } /** * 删除任务 */ public void deleteJob(String jobName, String jobGroup) throws Exception { scheduler.deleteJob(JobKey.jobKey(jobName, jobGroup)); } /** * 立即执行一次 */ public void triggerJob(String jobName, String jobGroup) throws Exception { scheduler.triggerJob(JobKey.jobKey(jobName, jobGroup)); } }逻辑说明:addJob里storeDurably()表示即使没有触发器关联,JobDetail 也保留在数据库中,方便后续单独添加触发器。updateJobCron用rescheduleJob替换触发器,注意 TriggerKey 必须和创建时一致,否则会新建一个触发器而不是替换。pauseJob和resumeJob操作的是 JobKey,会同时影响该 Job 下所有触发器。deleteJob会级联删除关联的触发器,数据库里对应记录也会被清理。
参数上,jobName和jobGroup组合起来唯一标识一个任务,建议用业务含义明确的命名,比如orderTimeoutJob+order。Cron 表达式建议在前端做格式校验,后端也加一层校验,避免非法表达式导致RuntimeException。
4. 避坑指南:动态任务持久化中容易翻车的五个点
4.1 任务类没有无参构造函数导致实例化失败
现象:新增任务后,到了触发时间报SchedulerException: Couldn't instantiate class。
原因:Quartz 通过反射创建 Job 实例,要求 Job 类必须有一个 public 的无参构造函数。如果只写了带参构造函数,反射就会失败。
解决:给 Job 类显式加上无参构造函数,或者不写任何构造函数(Java 默认提供无参构造)。如果确实需要传参,通过JobDataMap传递,而不是构造函数。
4.2 集群环境下任务重复执行
现象:两个应用节点部署后,同一个任务在同一时刻被触发两次。
原因:isClustered没有设为 true,或者各节点的instanceId配置成了相同的值,导致 Quartz 认为它们是同一个节点。
解决:quartz.properties里isClustered = true,instanceId设为AUTO让 Quartz 自动生成,或者手动配置为不同值。同时确保数据库表QRTZ_SCHEDULER_STATE能正常写入节点心跳。
4.3 Cron 表达式修改后不生效
现象:调用更新接口后,任务还是按旧的时间执行。
原因:rescheduleJob的 TriggerKey 和创建时的 TriggerKey 不一致,导致新建了一个触发器,旧触发器还在运行。
解决:统一 TriggerKey 的命名规则,比如都用jobName + "Trigger"。更新前先查一下QRTZ_TRIGGERS表确认 TriggerKey 存在。另外注意rescheduleJob返回 null 表示替换失败,要加判断。
4.4 数据库连接池耗尽导致调度阻塞
现象:任务执行一段时间后,整个应用响应变慢,日志里出现获取连接超时。
原因:Quartz 的线程池和 Web 请求共用同一个 DataSource,如果任务执行时间长、并发高,连接被长时间占用。
解决:给 Quartz 配置独立的 DataSource,或者限制threadPool.threadCount不要太大。任务内部避免长事务,数据库操作尽快释放连接。监控QRTZ_FIRED_TRIGGERS表,如果记录堆积说明有任务卡住了。
4.5 删除任务后数据库残留记录
现象:调用删除接口后,QRTZ_JOB_DETAILS表里还有记录。
原因:JobDetail 设置了storeDurably(),删除触发器时不会自动删除 JobDetail。
解决:删除任务时用scheduler.deleteJob(JobKey),它会同时删除 JobDetail 和关联的触发器。如果只想删触发器保留 JobDetail,用unscheduleJob(TriggerKey)。根据业务需求选择合适的方法,别混用。
5. 进阶技巧:用数据库查询做任务监控与手动补偿
动态任务跑起来之后,光有增删改查还不够,实际运维中经常需要看任务执行状态、排查为什么没触发、手动补一次执行。这些需求靠Scheduler接口不够直观,直接查 Quartz 的数据库表反而更快。
我一般会写一个 Mapper,关联查询QRTZ_TRIGGERS、QRTZ_CRON_TRIGGERS和QRTZ_JOB_DETAILS,把任务名、分组、Cron 表达式、触发器状态、下次触发时间一次性查出来给前端展示:
SELECT j.JOB_NAME, j.JOB_GROUP, j.JOB_CLASS_NAME, t.TRIGGER_STATE, t.NEXT_FIRE_TIME, t.PREV_FIRE_TIME, c.CRON_EXPRESSION FROM QRTZ_JOB_DETAILS j LEFT JOIN QRTZ_TRIGGERS t ON j.JOB_NAME = t.JOB_NAME AND j.JOB_GROUP = t.JOB_GROUP LEFT JOIN QRTZ_CRON_TRIGGERS c ON t.TRIGGER_NAME = c.TRIGGER_NAME AND t.TRIGGER_GROUP = c.TRIGGER_GROUP WHERE j.JOB_GROUP = #{jobGroup}TRIGGER_STATE的取值需要转成可读文案:WAITING表示等待触发,PAUSED表示已暂停,ACQUIRED表示正在执行,BLOCKED表示被阻塞,COMPLETE表示已完成。NEXT_FIRE_TIME是时间戳,格式化成日期展示。这个查询比调scheduler.getJobGroupNames()再逐个取属性方便得多,而且能直接看到数据库里的真实状态。
还有一个实用技巧是手动补偿。有时候任务因为服务重启错过了触发时间,Quartz 有个misfire机制,但默认策略不一定符合预期。我习惯在 Service 里加一个triggerJob方法,前端点"立即执行"时调用,底层就是scheduler.triggerJob(JobKey)。注意这个方法会立即触发一次,但不会影响原有的触发计划,适合做数据补偿。
验证任务是否真的持久化了,最简单的办法是:新增一个任务,重启应用,看数据库表里记录还在不在,再看调度器日志有没有恢复调度。如果重启后任务丢了,检查jobStore.class是不是配成了RAMJobStore,或者dataSource引用是不是写错了。
从那以后我每次整合 Quartz,都会先把quartz.properties里的jobStore和dataSource两项单独拎出来核对一遍,再写一个最简单的 Job 类跑通增删改查,确认数据库表有数据变化之后,才往上叠业务逻辑。这个习惯帮我省了不少返工时间。希望帮到你。
本文还有配套的精品资源,点击获取