SSM整合Quartz持久化到数据库:动态定时任务管理实战
简介这份资源面向Java后端开发者与需要实现定时任务动态管理的项目团队提供SSM框架整合Quartz并将任务配置与状态持久化到数据库的完整示例。包内共55个文件以40个jar依赖包、7个xml配置、3个properties属性文件、2个jsp页面及少量class与mf元数据文件为主压缩包约28.2MB涵盖Spring、Spring MVC、MyBatis、Quartz、Shiro、C3P0等核心组件目录结构清晰便于按模块查阅。资源重点演示了通过SchedulerFactoryBean管理调度器、自定义Job作业类、MyBatis操作Quartz多张数据表以及控制器接口实现任务的动态增删改查并配有前端页面进行任务列表展示与表单管理。已有706人学习下载适合希望掌握定时任务持久化方案、理解SSM与Quartz协作流程的开发者参考借鉴。1. 从一次定时任务翻车说起SSM 整合 Quartz 持久化到数据库到底解决什么问题很多做 Java Web 的朋友都遇到过这种场景项目里用Scheduled注解写了个定时任务本地跑得好好的一上线就出幺蛾子——集群部署时两台机器同时触发数据被重复处理想临时停掉某个任务得改代码重新打包发布任务执行失败了连个记录都查不到只能翻日志大海捞针。我之前接手一个后台管理系统里面七八个定时任务全是硬编码在 Spring 配置里的每次调整执行时间都要走一遍发版流程运维同事看到我就头疼。这个 SSM 整合 Quartz 并持久化到数据库的项目核心就是解决上面这些痛点。它把定时任务的定义、触发器、执行状态全部存进 MySQL通过 SSMSpring SpringMVC MyBatis提供一套增删改查接口让你在页面上就能动态管理任务——新增、暂停、恢复、立即执行、修改 Cron 表达式统统不用重启服务。适合正在做后台管理系统、需要任务调度能力的开发者尤其是那些被集群重复执行和任务状态不可见折磨过的同学。下面我从项目结构开始一步步拆给你看。2. 项目骨架与核心依赖SSM 和 Quartz 是怎么拼在一起的2.1 目录结构一览先看整体目录心里有个谱。这是一个标准的 Maven Web 项目结构在 SSM 基础上增加了 Quartz 相关配置和任务管理模块src/main/java/com/example/schedule/ ├── controller/ │ └── JobController.java # 任务管理接口 ├── service/ │ ├── JobService.java │ └── impl/JobServiceImpl.java # 核心调度逻辑 ├── mapper/ │ └── JobMapper.java # MyBatis 映射接口 ├── entity/ │ └── ScheduleJob.java # 任务实体 ├── quartz/ │ └── MyJobFactory.java # 解决 Spring Bean 注入问题 └── util/ └── ScheduleUtils.java # 任务工具类 src/main/resources/ ├── spring/ │ ├── applicationContext.xml # Spring 核心配置 │ ├── spring-mvc.xml # MVC 配置 │ └── spring-quartz.xml # Quartz 整合配置 ├── mapper/ │ └── JobMapper.xml ├── quartz.properties # Quartz 参数 └── jdbc.properties这个结构里最容易被忽略的是quartz包下的MyJobFactory后面会专门讲它是解决 Job 中无法注入 Service 的关键。2.2 Maven 依赖配置依赖版本要匹配Quartz 2.3.x 和 Spring 4.x/5.x 配合比较稳。核心依赖如下!-- Quartz 调度核心 -- dependency groupIdorg.quartz-scheduler/groupId artifactIdquartz/artifactId version2.3.2/version /dependency !-- Spring 整合 Quartz 的桥接包 -- dependency groupIdorg.springframework/groupId artifactIdspring-context-support/artifactId version5.3.20/version /dependency !-- 数据库连接池 -- dependency groupIdcom.alibaba/groupId artifactIddruid/artifactId version1.2.11/version /dependency !-- MyBatis -- dependency groupIdorg.mybatis/groupId artifactIdmybatis-spring/artifactId version2.0.7/version /dependencyspring-context-support这个包很多人会漏掉它提供了SchedulerFactoryBean是 Spring 和 Quartz 之间的粘合剂。没有它你就得手动管理 Scheduler 生命周期麻烦得很。2.3 数据库表设计Quartz 持久化需要一套自己的表来存 JobDetail、Trigger、Calendar 等信息。官方提供了建表 SQL在 Quartz 发行包的docs/dbTables目录下能找到。以 MySQL 为例核心表有 11 张常用的几张表名作用QRTZ_JOB_DETAILS存储 JobDetail 信息QRTZ_TRIGGERS存储 Trigger 基本信息QRTZ_CRON_TRIGGERS存储 Cron 类型的 TriggerQRTZ_SIMPLE_TRIGGERS存储 Simple 类型的 TriggerQRTZ_FIRED_TRIGGERS存储正在执行的 Trigger 状态QRTZ_SCHEDULER_STATE存储 Scheduler 实例状态除了这些还需要一张业务表来存任务描述信息方便前端展示CREATE TABLE sys_schedule_job ( job_id BIGINT PRIMARY KEY AUTO_INCREMENT, job_name VARCHAR(100) NOT NULL COMMENT 任务名称, job_group VARCHAR(50) DEFAULT DEFAULT COMMENT 任务分组, cron_expression VARCHAR(50) NOT NULL COMMENT cron表达式, bean_class VARCHAR(200) NOT NULL COMMENT 任务类全限定名, method_name VARCHAR(100) DEFAULT execute COMMENT 执行方法名, status TINYINT DEFAULT 0 COMMENT 0正常 1暂停, remark VARCHAR(255) COMMENT 备注, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;bean_class和method_name这两个字段是动态调度的关键——通过反射调用指定类的指定方法这样新增任务时只需要在页面上填类名和方法名不用改代码。2.4 Quartz 配置文件quartz.properties里几个参数必须改否则持久化不生效# 使用 JDBC 持久化 org.quartz.jobStore.classorg.quartz.impl.jdbcjobstore.JobStoreTX # 数据库代理类MySQL 用这个 org.quartz.jobStore.driverDelegateClassorg.quartz.impl.jdbcjobstore.StdJDBCDelegate # 表前缀和建表 SQL 保持一致 org.quartz.jobStore.tablePrefixQRTZ_ # 集群模式开启 org.quartz.jobStore.isClusteredtrue # 集群实例 ID 自动生成 org.quartz.scheduler.instanceIdAUTO # 线程池大小 org.quartz.threadPool.threadCount10isClusteredtrue是集群部署的关键它让多个节点通过数据库锁协调避免同一个任务被重复触发。instanceIdAUTO让每个节点自动生成唯一 ID不用手动配。2.5 Spring 整合配置spring-quartz.xml是整合的核心把 SchedulerFactoryBean 交给 Spring 管理bean idschedulerFactoryBean classorg.springframework.scheduling.quartz.SchedulerFactoryBean !-- 数据源Quartz 用它读写任务表 -- property namedataSource refdataSource/ !-- Quartz 配置文件位置 -- property nameconfigLocation valueclasspath:quartz.properties/ !-- 覆盖已存在的任务 -- property nameoverwriteExistingJobs valuetrue/ !-- 延迟启动等 Spring 容器初始化完成 -- property namestartupDelay value10/ !-- 自定义 JobFactory解决 Bean 注入 -- property namejobFactory refmyJobFactory/ !-- 自动启动 -- property nameautoStartup valuetrue/ /beanstartupDelay设 10 秒是为了等 Spring 容器完全初始化否则任务启动时可能拿不到 Bean。jobFactory指向自定义工厂这是下一个要讲的重点。3. 核心模块拆解从新增任务到触发执行的完整链路3.1 任务实体与 MapperScheduleJob实体对应sys_schedule_job表字段和表结构一一对应public class ScheduleJob implements Serializable { private Long jobId; private String jobName; private String jobGroup; private String cronExpression; private String beanClass; // 全限定类名 private String methodName; // 执行方法 private Integer status; // 0正常 1暂停 private String remark; // getter/setter 省略 }Mapper 接口提供基本的增删改查public interface JobMapper { ListScheduleJob selectAll(); ScheduleJob selectById(Long jobId); int insert(ScheduleJob job); int update(ScheduleJob job); int deleteById(Long jobId); int updateStatus(Param(jobId) Long jobId, Param(status) Integer status); }对应的 XML 就是标准 CRUD不展开。重点看 Service 层怎么把数据库操作和 Quartz 调度串起来。3.2 自定义 JobFactory解决 Bean 注入的玄学问题这是整个项目最容易翻车的地方。Quartz 默认的JobFactory每次执行任务时直接newInstance()创建 Job 实例不走 Spring 容器导致 Job 里用Autowired注入的 Service 全是 null。我当初在这卡了大半天日志里只报空指针根本看不出原因。解决办法是自定义 JobFactory让 Job 实例从 Spring 容器获取Component public class MyJobFactory extends AdaptableJobFactory { Autowired private AutowireCapableBeanFactory capableBeanFactory; Override protected Object createJobInstance(TriggerFiredBundle bundle) throws Exception { // 先调用父类创建实例 Object jobInstance super.createJobInstance(bundle); // 再通过 Spring 完成依赖注入 capableBeanFactory.autowireBean(jobInstance); return jobInstance; } }AdaptableJobFactory是 Quartz 提供的适配器createJobInstance是创建 Job 实例的入口。关键在autowireBean这行它让 Spring 对实例进行属性注入这样 Job 里的Autowired才能生效。配置里jobFactory指向这个 Bean整个链路就通了。3.3 动态任务的执行入口所有动态任务统一走一个 Job 类通过反射调用目标方法public class DynamicJob implements Job { Override public void execute(JobExecutionContext context) throws JobExecutionException { // 从 JobDataMap 取出任务信息 JobDataMap dataMap context.getJobDetail().getJobDataMap(); String beanClass dataMap.getString(beanClass); String methodName dataMap.getString(methodName); try { // 反射获取目标 Bean Object targetBean SpringContextHolder.getBean( Class.forName(beanClass)); // 反射调用方法 Method method targetBean.getClass() .getMethod(methodName); method.invoke(targetBean); } catch (Exception e) { throw new JobExecutionException(e); } } }JobDataMap是 Quartz 提供的传参机制任务创建时把beanClass和methodName塞进去执行时取出来。SpringContextHolder是一个工具类实现ApplicationContextAware接口持有 Spring 上下文方便在非 Spring 管理的类里获取 Bean。3.4 新增任务的完整流程Service 层新增任务时要同时操作数据库和 Quartz 调度器Override Transactional public void addJob(ScheduleJob job) throws Exception { // 1. 先入库 jobMapper.insert(job); // 2. 构建 JobDetail JobDetail jobDetail JobBuilder.newJob(DynamicJob.class) .withIdentity(job.getJobName(), job.getJobGroup()) .usingJobData(beanClass, job.getBeanClass()) .usingJobData(methodName, job.getMethodName()) .build(); // 3. 构建 Trigger CronTrigger trigger TriggerBuilder.newTrigger() .withIdentity(job.getJobName(), job.getJobGroup()) .withSchedule(CronScheduleBuilder .cronSchedule(job.getCronExpression())) .build(); // 4. 交给调度器 scheduler.scheduleJob(jobDetail, trigger); // 5. 如果状态是暂停立即暂停 if (job.getStatus() 1) { scheduler.pauseJob(JobKey.jobKey( job.getJobName(), job.getJobGroup())); } }withIdentity用任务名和分组作为唯一标识重复添加会报错。usingJobData把反射需要的信息存进 JobDataMap。scheduleJob是真正把任务注册到调度器的方法执行后任务就开始按 Cron 表达式触发。3.5 暂停、恢复与删除暂停和恢复操作比较简单直接调调度器 API// 暂停 public void pauseJob(Long jobId) throws SchedulerException { ScheduleJob job jobMapper.selectById(jobId); scheduler.pauseJob(JobKey.jobKey( job.getJobName(), job.getJobGroup())); jobMapper.updateStatus(jobId, 1); } // 恢复 public void resumeJob(Long jobId) throws SchedulerException { ScheduleJob job jobMapper.selectById(jobId); scheduler.resumeJob(JobKey.jobKey( job.getJobName(), job.getJobGroup())); jobMapper.updateStatus(jobId, 0); } // 删除 public void deleteJob(Long jobId) throws SchedulerException { ScheduleJob job jobMapper.selectById(jobId); scheduler.deleteJob(JobKey.jobKey( job.getJobName(), job.getJobGroup())); jobMapper.deleteById(jobId); }注意删除时要先删调度器里的任务再删数据库记录。反过来如果先删数据库调度器里的任务还在跑就变成孤儿任务了。3.6 修改 Cron 表达式修改执行时间需要先删旧 Trigger 再建新 Triggerpublic void updateCron(Long jobId, String cron) throws Exception { ScheduleJob job jobMapper.selectById(jobId); // 构建新的 Trigger CronTrigger newTrigger TriggerBuilder.newTrigger() .withIdentity(job.getJobName(), job.getJobGroup()) .withSchedule(CronScheduleBuilder.cronSchedule(cron)) .build(); // 替换旧 Trigger scheduler.rescheduleJob( TriggerKey.triggerKey(job.getJobName(), job.getJobGroup()), newTrigger); // 更新数据库 job.setCronExpression(cron); jobMapper.update(job); }rescheduleJob是 Quartz 提供的原子替换方法比先删后建安全中间不会出现任务丢失的窗口期。4. 避坑指南动态任务调度中容易踩的五个坑4.1 任务重复执行现象集群部署后同一个任务在两台机器上同时触发数据被处理两次。原因quartz.properties里isClustered没设为 true或者数据库表没建全导致节点间无法协调。解决确认org.quartz.jobStore.isClusteredtrue并且QRTZ_SCHEDULER_STATE表存在。集群模式下 Quartz 通过数据库行锁保证同一时刻只有一个节点触发任务。4.2 Job 中注入 Service 为 null现象Job 的execute方法里调用 Service 报空指针但 Service 本身在 Controller 里用得好好的。原因Quartz 默认 JobFactory 不走 Spring 容器直接 new 实例。解决配置自定义MyJobFactory在SchedulerFactoryBean里指定jobFactory属性。这个坑前面专门讲过不再赘述。4.3 Cron 表达式写错导致任务不触发现象任务添加成功但从来不执行日志里也没有报错。原因Cron 表达式格式错误比如把0 0/5 * * * ?写成0 0/5 * * * *最后一位应该是?而不是*。解决用在线 Cron 生成器验证表达式或者在代码里加校验try { new CronExpression(cron); } catch (ParseException e) { throw new IllegalArgumentException(Cron表达式不合法); }4.4 任务执行时间过长导致阻塞现象某个任务执行时间超过触发间隔后续触发被阻塞日志里出现Misfire相关警告。原因Quartz 默认线程池大小是 10如果任务耗时长且并发高线程不够用。解决调大org.quartz.threadPool.threadCount同时给 Trigger 设置withMisfireHandlingInstructionDoNothing()让错过的触发直接跳过不补执行。4.5 数据库连接池耗尽现象运行一段时间后任务不再触发日志报Couldnt get a connection。原因Quartz 自己管理一套数据库连接如果和业务共用数据源且连接池配置过小容易被占满。解决给 Quartz 单独配一个数据源或者把 Druid 的maxActive调大。我一般会在SchedulerFactoryBean里单独指定一个dataSource和业务数据源隔离。5. 二次开发与扩展从能跑到好用的几个改造方向5.1 任务执行日志记录项目默认只存任务定义不存执行记录。实际用起来你肯定想知道每个任务什么时候执行了、成功还是失败、耗时多久。改造思路是新建一张sys_job_log表在DynamicJob.execute里记录public class DynamicJob implements Job { Override public void execute(JobExecutionContext context) { long start System.currentTimeMillis(); JobLog log new JobLog(); log.setJobName(context.getJobDetail().getKey().getName()); log.setStartTime(new Date(start)); try { // 反射调用... log.setStatus(0); } catch (Exception e) { log.setStatus(1); log.setErrorMsg(e.getMessage()); } finally { log.setCostTime(System.currentTimeMillis() - start); jobLogMapper.insert(log); } } }这样每次执行都有记录排查问题不用再翻服务器日志。注意日志表要定期清理不然数据量涨得很快。5.2 支持手动触发一次有时候任务没到执行时间但你想立即跑一次验证逻辑。Quartz 提供了triggerJob方法public void runOnce(Long jobId) throws SchedulerException { ScheduleJob job jobMapper.selectById(jobId); scheduler.triggerJob(JobKey.jobKey( job.getJobName(), job.getJobGroup())); }这个操作不会影响原有的触发计划只是额外触发一次。前端加个「立即执行」按钮调试时特别方便。5.3 任务分组的界面化展示job_group字段目前只是存着没在界面上体现。可以在 Controller 里按分组聚合返回GetMapping(/list) public Result list() { ListScheduleJob jobs jobService.selectAll(); MapString, ListScheduleJob grouped jobs.stream() .collect(Collectors.groupingBy(ScheduleJob::getJobGroup)); return Result.success(grouped); }前端按分组渲染成折叠面板任务多了也不会乱。5.4 验证改造是否生效改完代码后按这个顺序验证一遍先启动项目看控制台有没有Scheduler started的输出然后调新增接口创建一个每分钟执行的任务观察日志里是否每分钟打印一次再调暂停接口确认日志停止输出最后调恢复接口确认日志重新出现。这套流程走下来基本能覆盖核心链路。我一般会在DynamicJob里加一行log.info(任务执行: {}, jobName)方便肉眼确认。等确认没问题了再删掉换成正式的日志记录。从那以后我每次做定时任务都强制走一遍「集群配置检查 → JobFactory 验证 → Cron 表达式校验」这三步再也没出现过重复执行和注入失败的问题。希望帮到你。本文还有配套的精品资源点击获取