一、项目为什么需要动态定时任务
普通的 Spring 定时任务通常直接写在代码中:
@Scheduled(cron = "0 0 0 * * ?") public void execute() { // 执行固定任务 }这种方式适合固定业务,但 Agent 平台的定时任务具有不同特点:
- 不同 Agent 的执行内容不同;
- 管理员需要在后台修改执行时间;
- 每个 Agent 都可能绑定一个独立任务;
- 任务参数不能写死在 Java 代码中;
- 任务需要支持启动、停止和解绑;
- 任务执行结果需要记录。
例如,管理员希望每天早上 9 点让某个 Agent 自动执行:
请生成今天的技术资讯摘要,并整理成 Markdown 格式。如果使用@Scheduled,每增加一个 Agent 都需要修改代码并重新部署。因此项目使用数据库保存任务配置,再通过 Quartz 动态创建调度任务。
项目中定时任务模块位于:
agentOri/job核心技术包括:
Spring Boot Quartz MyBatis-Plus MySQL Spring AOP AgentScope二、整体架构
项目的定时任务执行链路如下:
后台填写 Agent、执行内容和 Cron | v 调用 /job/add 接口 | v 保存 quartz_job_info | v 创建 QuartzConfig | v 注册 Quartz Job/Trigger | v 到达执行时间 | v AgentScheduler | v 获取 Agent 并执行输入内容 | v QuartzJob 统一记录日志应用重启时,流程如下:
Spring Boot 启动 | v JobInit.afterSingletonsInstantiated() | v 查询 enabled = 1 的任务 | v 重新构造 QuartzConfig | v 重新注册到 Quartz这里要特别说明:
数据库保存的是项目自己的任务配置和执行日志,Quartz 的运行时调度对象仍然由 Quartz 调度器管理。
三、数据库表设计
项目使用两张表。
3.1 定时任务配置表
CREATE TABLE `quartz_job_info` ( `id` varchar(64) NOT NULL COMMENT '任务身份唯一标识', `type` varchar(64) DEFAULT NULL COMMENT '任务类型', `biz_id` varchar(64) DEFAULT NULL COMMENT '关联业务ID', `cron` varchar(64) DEFAULT NULL COMMENT 'cron表达式', `job_class` varchar(100) DEFAULT NULL COMMENT 'Job类路径', `data_map` text COMMENT '执行参数', `enabled` tinyint(1) DEFAULT NULL COMMENT '状态(0停止 1启动)', PRIMARY KEY (`id`) ) COMMENT='Quartz定时任务状态';对应实体类:
@Data @TableName("quartz_job_info") public class JobInfo implements SerializableEnable { private String id; private String type; private String bizId; private String cron; private String jobClass; private String dataMap; private boolean enabled; }字段含义:
| 字段 | 作用 |
|---|---|
id | 任务唯一标识 |
type | 业务类型,Agent 任务使用AGENT |
biz_id | 关联的 Agent ID |
cron | Quartz Cron 表达式 |
job_class | 实际执行类的全限定名 |
data_map | 传给任务的 JSON 参数 |
enabled | 是否启用 |
3.2 任务执行日志表
CREATE TABLE `quartz_job_log` ( `id` varchar(64) NOT NULL COMMENT '日志主键', `identity` varchar(255) DEFAULT NULL COMMENT '任务身份标识', `start_time` datetime DEFAULT NULL COMMENT '开始时间', `end_time` datetime DEFAULT NULL COMMENT '结束时间', `content` text COMMENT '执行内容', `status` varchar(64) DEFAULT NULL COMMENT '执行状态', `duration` decimal(11,0) DEFAULT NULL COMMENT '持续时间(秒)', PRIMARY KEY (`id`) ) COMMENT='Quartz定时任务日志';日志实体:
@Data @TableName("quartz_job_log") public class JobLog implements SerializableEnable { @TableId(type = IdType.ASSIGN_ID) private String id; private String identity; private Date startTime; private Date endTime; private String content; private String status; private Long duration; }项目使用:
1:执行成功 0:执行失败保存执行结果。
四、后台如何创建 Agent 定时任务
后台页面使用AgentJobForm.vue配置任务。
前端提交的核心字段如下:
const jobInfo: JobInfo = { id: formData.value.id, type: 'AGENT', bizId: props.agentId, cron: formData.value.cron, jobClass: 'com.hxh.apboa.job.scheduler.AgentScheduler', dataMap: dataMap, enabled: formData.value.enabled }其中dataMap是这样生成的:
const dataMap = JSON.stringify({ agentId: props.agentId, input: formData.value.input })因此,一个实际请求大致如下:
POST /job/add{ "type": "AGENT", "bizId": "1001", "cron": "0 0 9 ? * MON-FRI", "jobClass": "com.hxh.apboa.job.scheduler.AgentScheduler", "dataMap": "{\"agentId\":\"1001\",\"input\":\"请生成今天的技术资讯摘要\"}", "enabled": true }这个表达式表示:
每周一到周五上午 9 点执行接口定义:
@PostMapping("/add") @RoleNeed({Role.ADMIN, Role.EDIT}) public R<Boolean> add(@RequestBody JobInfo jobInfo) throws ClassNotFoundException { quartzInfoService.addJob(jobInfo); return R.data(true); }新增任务只有管理员或编辑角色可以调用。
五、保存数据库并注册 Quartz
新增任务的核心逻辑位于:
job/service/QuartzInfoServiceImpl.java代码如下:
@Override public void addJob(JobInfo jobInfo) { checkAgent(jobInfo.getBizId()); jobInfo.setId(CryptoUtils.uuid()); try { save(jobInfo); if (jobInfo.isEnabled()) { quartzClient.create( JobInit.buildConfig(jobInfo) ); } } catch (Exception e) { removeById(jobInfo.getId()); throw new RuntimeException(e); } }执行过程:
1. 检查 Agent 是否存在或已启用 2. 生成任务 ID 3. 保存 quartz_job_info 4. 如果 enabled = true,创建 Quartz 任务 5. Quartz 创建失败时删除数据库记录检查 Agent 的代码:
private void checkAgent(String agentId) { if (agentId == null) { return; } AgentDefinition agentDefinition = agentDefinitionService.getById(agentId); if (agentDefinition != null && !agentDefinition.getEnabled()) { throw new RuntimeException( "智能体无效,不可设置定时" ); } }这一步避免了给已经停用的 Agent 绑定定时任务。
六、JobInit 如何把数据库配置转换成 Quartz 任务
项目启动时,JobInit会查询所有启用的任务:
@Component public class JobInit implements SmartInitializingSingleton { private final QuartzClient quartzClient; private final QuartzInfoService quartzInfoService; @Override public void afterSingletonsInstantiated() { doJobInit(); } public void doJobInit() { LambdaQueryWrapper<JobInfo> qw = new LambdaQueryWrapper<JobInfo>() .eq(JobInfo::isEnabled, true); List<JobInfo> list = quartzInfoService.list(qw); for (JobInfo jobInfo : list) { try { quartzClient.create( buildConfig(jobInfo) ); } catch (ClassNotFoundException e) { System.err.println( jobInfo.getJobClass() + "不存在" ); } } } }构造QuartzConfig:
public static QuartzConfig buildConfig(JobInfo jobInfo) throws ClassNotFoundException { Class<?> aClass = Class.forName(jobInfo.getJobClass()); if (QuartzJob.class.isAssignableFrom(aClass)) { Class<? extends QuartzJob> jobClass = aClass.asSubclass(QuartzJob.class); return new QuartzConfigFactory() .identity(jobInfo.getId()) .setJobClass(jobClass) .putDataMap( JobConst.DATA_MAP_KEY, JsonUtils.parse( jobInfo.getDataMap(), AgentJobWrapper.class ) ) .cron(jobInfo.getCron()) .build(); } throw new RuntimeException( "类型不兼容,无法转换:" + jobInfo.getJobClass() + "未继承自QuartzJob" ); }这里有三个关键设计。
6.1 通过类名动态加载 Job
Class.forName(jobInfo.getJobClass())数据库保存的是:
com.hxh.apboa.job.scheduler.AgentScheduler应用启动后通过反射加载这个类。
6.2 限制 Job 必须继承 QuartzJob
QuartzJob.class.isAssignableFrom(aClass)这可以保证所有动态任务都具备统一的执行模板和日志逻辑。
6.3 把 JSON 参数转换为 AgentJobWrapper
JsonUtils.parse( jobInfo.getDataMap(), AgentJobWrapper.class )参数对象:
@Data @Builder @NoArgsConstructor @AllArgsConstructor public class AgentJobWrapper implements SerializableEnable { private String agentId; private String input; }最终传入 Quartz 的参数结构是:
DATA_MAP_KEY | +-- AgentJobWrapper | +-- agentId +-- input七、QuartzConfig 如何生成任务身份
项目没有直接在业务代码中拼接 Quartz 的JobKey和TriggerKey,而是统一通过QuartzConfig生成。
public String getJobName() { checkIdentity(); checkJobClass(); return "QUARTZ_JOB@" + getIdentity(); } public String getJobGroupName() { checkIdentity(); checkJobClass(); return "QUARTZ_JOB_GROUP@" + getIdentity(); } public String getTriggerName() { checkIdentity(); checkJobClass(); return "QUARTZ_TRIGGER@" + getIdentity(); } public String getTriggerGroupName() { checkIdentity(); checkJobClass(); return "QUARTZ_TRIGGER_GROUP@" + getIdentity(); }假设数据库中的任务 ID 是:
8a82f3a9-xxxx-xxxx生成的 Quartz 标识类似:
JobName: QUARTZ_JOB@8a82f3a9-xxxx-xxxx TriggerName: QUARTZ_TRIGGER@8a82f3a9-xxxx-xxxx任务 ID 同时作为数据库任务身份和 Quartz 任务身份,可以让新增、修改和删除操作使用同一套定位规则。
八、QuartzClient 负责创建和删除任务
项目对 Quartz API 做了一层封装:
@Component public class QuartzClient { @QuartzCreate public void create(QuartzConfig config) { if (FuncUtils.isEmpty(config.getCron())) { QuartzScript.createOnceJob(config, false); } else { QuartzScript.createCronJob(config); } } @QuartzRemove public void remove(QuartzConfig config) { String triggerState = QuartzScript.getTriggerState(config); if (Trigger.TriggerState.NONE.name() .equals(triggerState)) { return; } QuartzScript.removeJob(config); } }如果cron不为空,创建 Cron 任务:
QuartzScript.createCronJob(config);如果cron为空,则创建一次性任务:
QuartzScript.createOnceJob(config, false);当前 Agent 后台表单默认使用 Cron 任务。
九、Cron 任务如何创建
核心代码:
protected static void createCronJob( QuartzConfig config) { if (!isQuartzEnabled()) { throw new RuntimeException( "quartz 不可用,quartzd.enabled 必须为 true" ); } String triggerState = getTriggerState(config); if (Trigger.TriggerState.NONE.name() .equals(triggerState)) { JobDetail jobDetail = getJobDetail( config.getJobName(), config.getJobGroupName(), config.getDataMap(), config.getJobClass() ); CronTrigger cronTrigger = getCronTrigger( config.getTriggerName(), config.getTriggerGroupName(), config.getCron() ); startScheduler( jobDetail, cronTrigger ); } else { log.warn("job 已存在"); } }Quartz 触发器创建:
protected static CronTrigger getCronTrigger( String triggerName, String triggerGroupName, String cron) { TriggerBuilder<Trigger> triggerBuilder = TriggerBuilder.newTrigger(); triggerBuilder.withIdentity( triggerName, triggerGroupName ); triggerBuilder.withSchedule( CronScheduleBuilder.cronSchedule(cron) ); triggerBuilder.startNow(); return (CronTrigger) triggerBuilder.build(); }项目使用 Quartz 自带的表达式校验:
public static boolean isValidCron(String cron) { return CronExpression.isValidExpression(cron); }QuartzConfig设置 Cron 时会进行校验:
public void setCron(String cron) { if (QuartzScript.isValidCron(cron)) { this.cron = cron; } else { throw new RuntimeException("cron 不合法"); } }十、前端如何避免错误的 Cron 表达式
项目的前端表单将 Cron 拆分为六个部分:
秒 分 时 日 月 周默认结构:
const cronParts = ref({ second: '0', minute: '0', hour: '*', day: '*', month: '*', week: '?' })前端还处理了 Quartz 的特殊规则:
// Quartz 规则:周和日不能同时指定 if (week !== '?' && day !== '?') { actualDay = '?' }生成表达式:
formData.value.cron = `${second} ${minute} ${hour} ` + `${actualDay} ${month} ${week}`常见表达式实例:
0 0 0 * * ? 每天 00:00 执行 0 */5 * * * ? 每 5 分钟执行 0 0 9 ? * MON-FRI 工作日每天 09:00 执行 0 30 18 ? * FRI 每周五 18:30 执行Quartz 的日和周不能同时指定,例如:
错误: 0 0 9 * * MON-FRI 正确: 0 0 9 ? * MON-FRI十一、AgentScheduler 如何执行 Agent
Quartz 触发任务后,最终执行类是:
com.hxh.apboa.job.scheduler.AgentScheduler代码如下:
@Slf4j public class AgentScheduler extends QuartzJob { @Override public Object doJob( JobExecutionContext context) { AgentJobWrapper wrapper = getDataMap( JobConst.DATA_MAP_KEY, AgentJobWrapper.class ); if (wrapper == null) { return false; } String agentId = wrapper.getAgentId(); if (agentId == null || agentId.trim().isEmpty()) { return false; } try { IAgentFactory agentFactory = getBean(IAgentFactory.class); Agent agent = agentFactory.getAgent( Long.valueOf(agentId.trim()) ); agent.call( Msg.builder() .textContent(wrapper.getInput()) .build() ).block(); } catch (Exception e) { log.error(e.getMessage(), e); return false; } return true; } }执行过程:
1. 从 Quartz JobDataMap 读取 AgentJobWrapper 2. 获取 agentId 3. 通过 IAgentFactory 获取 Agent 4. 将 input 封装成 Msg 5. 调用 agent.call() 6. 使用 block() 等待 Agent 执行结束例如数据库中保存:
{ "agentId": "1001", "input": "请生成今天的技术资讯摘要" }Quartz 执行时会转换成:
Agent agent = agentFactory.getAgent(1001L); agent.call( Msg.builder() .textContent("请生成今天的技术资讯摘要") .build() ).block();十二、QuartzJob 统一封装执行流程
所有自定义 Job 都继承:
public abstract class QuartzJob implements Job并且使用:
@DisallowConcurrentExecution核心执行方法:
@Override public void execute(JobExecutionContext context) { this.context = context; QuartzLogService quartzLogService = getBean(QuartzLogService.class); String identity = getDataMap( QuartzEnum.IDENTITY_KEY.value(), String.class ); long startTimeMillis = System.currentTimeMillis(); Date startTime = new Date(); JobLog jobLog = null; try { beforeDoJob(context); Object result = doJob(context); afterDoJob(context, result); Object runMsg = context.getMergedJobDataMap() .get( QuartzEnum.RUN_MSG.value() ); long endTimeMillis = System.currentTimeMillis(); Date endTime = new Date(); long duration = (endTimeMillis - startTimeMillis) / 1000; jobLog = new JobLog( identity, startTime, endTime, duration, "任务执行成功!\r\n" + (runMsg != null ? runMsg.toString() : ""), QuartzResult.STATUS_SUCCESS.value() ); } catch (Exception ex) { long endTimeMillis = System.currentTimeMillis(); Date endTime = new Date(); long duration = (endTimeMillis - startTimeMillis) / 1000; jobLog = new JobLog( identity, startTime, endTime, duration, "任务执行失败!\r\n" + getExceptionInfo(ex), QuartzResult.STATUS_FAIL.value() ); log.error("执行任务出错:", ex); } finally { if (jobLog != null) { quartzLogService.save(jobLog); } } }这个抽象类提供了统一模板:
beforeDoJob() | v doJob() | v afterDoJob() | v 保存成功或失败日志自定义任务只需要实现:
public abstract Object doJob( JobExecutionContext context );十三、@DisallowConcurrentExecution的作用
项目在QuartzJob上添加:
@DisallowConcurrentExecution它的作用是:
同一个 Quartz JobKey 的任务不能并发执行。
例如某个 Agent 的任务每分钟执行一次,但上一次调用模型耗时 90 秒。下一次触发时,Quartz 不会同时启动同一个 JobKey 的第二个实例。
不过它有两个边界:
13.1 只针对同一个 JobKey
如果同一个 Agent 被创建了两条不同的任务记录,它们的 JobKey 不同,仍然可能并发执行。
13.2 不能解决多实例重复执行
项目当前每个应用实例都会创建自己的 Quartz 调度器。@DisallowConcurrentExecution不能代替跨节点协调。
十四、任务执行日志
任务成功后,日志记录:
identity start_time end_time duration content status = 1任务异常时,记录:
identity start_time end_time duration content = 异常堆栈 status = 0可以通过 SQL 查询最近失败任务:
SELECT identity, start_time, end_time, duration, status, content FROM quartz_job_log WHERE status = '0' ORDER BY start_time DESC;查询某个任务的执行历史:
SELECT identity, start_time, end_time, duration, status FROM quartz_job_log WHERE identity = '任务ID' ORDER BY start_time DESC;日志中的duration单位是秒:
long duration = (endTimeMillis - startTimeMillis) / 1000;因此执行时间小于 1 秒的任务,记录出来可能是0秒。
十五、修改任务执行时间
接口:
GET /job/updateCron?id=任务ID&cron=0 0 9 ? * MON-FRI服务代码:
@Override public void updateJobCron( String id, String cron ) throws ClassNotFoundException { JobInfo job = getById(id); if (job == null) { throw new RuntimeException( "任务实例不存在" ); } checkAgent(job.getBizId()); if (cron.equals(job.getCron())) { return; } job.setCron(cron); if (job.isEnabled()) { quartzClient.remove( JobInit.buildConfig(job) ); quartzClient.create( JobInit.buildConfig(job) ); } updateById(job); }当前项目的实际策略是:
读取原任务 | v 如果 Cron 没变化,直接返回 | v 如果任务启用,先删除旧任务 | v 重新创建新 Cron 任务 | v 更新数据库配置项目的QuartzClient中还提供了updateCron方法,内部使用:
scheduler.rescheduleJob( triggerKey, cronTrigger );但当前QuartzInfoServiceImpl.updateJobCron()实际使用的是“删除后重新创建”。
十六、完整修改任务
完整修改接口:
POST /job/update服务逻辑:
@Override public void updateJob(JobInfo jobInfo) throws ClassNotFoundException { JobInfo job = getById(jobInfo.getId()); if (job == null) { throw new RuntimeException( "任务实例不存在" ); } checkAgent(job.getBizId()); if (jobInfo.isEnabled()) { quartzClient.remove( JobInit.buildConfig(jobInfo) ); quartzClient.create( JobInit.buildConfig(jobInfo) ); } else { quartzClient.remove( JobInit.buildConfig(jobInfo) ); } updateById(jobInfo); }根据enabled的值:
enabled = true 删除旧调度 创建新调度 更新数据库 enabled = false 删除调度 更新数据库为停止十七、启动、停止和删除任务
17.1 启动任务
@Override public void startJob(String id) throws ClassNotFoundException { JobInfo jobInfo = getById(id); if (jobInfo == null) { throw new RuntimeException( "任务实例不存在" ); } checkAgent(jobInfo.getBizId()); quartzClient.remove( JobInit.buildConfig(jobInfo) ); quartzClient.create( JobInit.buildConfig(jobInfo) ); }启动时先删除旧任务,再创建任务,可以避免重复注册。
17.2 删除任务
@Override public boolean deleteJob(String id) throws ClassNotFoundException { JobInfo jobInfo = getById(id); if (jobInfo == null) { return true; } if (jobInfo.isEnabled()) { quartzClient.remove( JobInit.buildConfig(jobInfo) ); } return removeById(id); }删除流程:
任务启用 | v 删除 Quartz Trigger | v 中断 Job | v 删除 Quartz Job | v 删除 quartz_job_info 记录底层删除逻辑:
scheduler.pauseTrigger(triggerKey); scheduler.unscheduleJob(triggerKey); scheduler.interrupt(jobKey); scheduler.deleteJob(jobKey);17.3 停止接口的实际问题
当前代码为:
@Override public void stopJob(String id) throws ClassNotFoundException { JobInfo jobInfo = getById(id); if (jobInfo == null) { throw new RuntimeException( "任务实例不存在" ); } if (jobInfo.isEnabled()) { return; } quartzClient.remove( JobInit.buildConfig(jobInfo) ); }按照当前逻辑:
enabled = true -> 直接 return enabled = false -> 删除 Quartz 任务这和“停止启用中的任务”的业务语义相反。
因此,当前项目的停止接口存在逻辑问题。生产代码中应重点核对这一段条件判断,不能直接把当前实现描述成“停止功能已经完全正确”。
十八、AOP 如何同步调度状态
Quartz 创建和删除成功后,项目通过 AOP 更新数据库状态。
创建切面:
@Aspect @Order(-1) @Component public class QuartzCreateAspect extends QuartzAspect { @AfterReturning( pointcut = "quartzCreatePointcut()", returning = "result" ) public void afterReturning( JoinPoint joinPoint, Object result ) { if (result == null) { return; } QuartzConfig config = (QuartzConfig) result; JobInfo jobInfo = new JobInfo(); jobInfo.setId(config.getIdentity()); jobInfo.setCron(config.getCron()); jobInfo.setEnabled(true); saveStatus(jobInfo); } }删除切面:
@AfterReturning( pointcut = "quartzRemovePointcut()", returning = "result" ) public void afterReturning( JoinPoint joinPoint, Object result ) { if (result == null) { return; } QuartzConfig config = (QuartzConfig) result; JobInfo jobInfo = new JobInfo(); jobInfo.setId(config.getIdentity()); jobInfo.setCron(config.getCron()); jobInfo.setEnabled(false); saveStatus(jobInfo); }这种设计的好处是:
QuartzClient.create() 成功 -> 数据库 enabled = 1 QuartzClient.remove() 成功 -> 数据库 enabled = 0调度器操作和状态同步集中在切面中,避免每个业务方法都重复写状态更新代码。
但它也有边界:
Quartz 调度器和 MySQL 不属于同一个事务,不能真正实现跨系统原子提交。
例如:
Quartz 创建成功 AOP 更新数据库失败这时可能出现:
Quartz 中任务已经存在 数据库状态仍然是旧状态因此生产环境还需要增加启动校验或定期对账机制。
十九、项目启动时的恢复机制
项目没有把 Quartz 任务完全依赖在 JVM 内存中,而是把任务配置保存到数据库。
应用启动后:
List<JobInfo> list = quartzInfoService.list( new LambdaQueryWrapper<JobInfo>() .eq(JobInfo::isEnabled, true) );只加载:
enabled = true的任务。
因此,应用重启后,启用任务会重新注册。
但是当前代码使用:
private static final SchedulerFactory SCHEDULER_FACTORY = new StdSchedulerFactory();项目中没有配置 Quartz 标准 JDBC JobStore,也没有使用 Quartz 的QRTZ_*标准表。
所以当前设计是:
MySQL 保存业务配置 Quartz 保存当前运行时调度对象 应用启动时从 MySQL 重建 Quartz 任务这和 Quartz JDBC 持久化不是一回事。
二十、包名修改导致历史任务失效
项目中曾经存在旧包名:
com.hxh.agentOri后来代码包名改成:
com.hxh.apboa但数据库里的job_class仍然可能保存旧类路径。启动时执行:
Class.forName(jobInfo.getJobClass());就会找不到类。
项目为此提供了迁移脚本:
UPDATE `quartz_job_info` SET `job_class` = REPLACE( `job_class`, 'com.hxh.agentOri', 'com.hxh.apboa' ) WHERE `job_class` LIKE '%com.hxh.agentOri%';校验语句:
SELECT COUNT(*) FROM `quartz_job_info` WHERE `job_class` LIKE '%com.hxh.agentOri%';这说明动态任务有一个非常实际的维护成本:
Java 类路径一旦保存进数据库,重构包名时必须同步迁移历史配置。
否则代码本身编译成功,数据库任务仍然可能在启动时失败。
二十一、当前实现不支持集群部署
前端表单中明确提示:
定时任务目前不支持集群部署原因可以从代码中看出来。
每个应用实例启动时都会执行:
JobInit.afterSingletonsInstantiated()每个实例都会查询:
SELECT * FROM quartz_job_info WHERE enabled = 1;然后在自己的 JVM 中创建 Quartz 调度器。
如果部署两个实例:
实例 A:加载任务 1001 实例 B:加载任务 1001到了执行时间:
实例 A 执行一次 实例 B 也执行一次最终同一个 Agent 任务可能被调用两次。
@DisallowConcurrentExecution也不能解决这个问题,因为它主要约束同一个 Quartz 调度器中的 JobKey,不是跨 JVM 的分布式锁。
当前项目也没有看到:
QRTZ_* 标准表 spring.quartz.job-store-type=jdbc 集群 instanceId 配置 Quartz 集群配置 分布式调度锁所以文章必须准确描述:
当前实现是单实例 Quartz 动态调度,数据库负责保存配置和重启恢复,不是 Quartz 集群方案。
如果以后需要集群部署,需要改造成 Quartz JDBC JobStore 或引入独立的分布式调度组件。这个能力不属于当前项目已经实现的功能。
二十二、Agent 任务执行中的两个真实边界
22.1doJob()返回false不会被记录为失败
AgentScheduler在参数为空或 Agent 执行异常时返回:
return false;但QuartzJob.execute()只判断是否抛出异常:
result = doJob(context); afterDoJob(context, result); jobLog = new JobLog( identity, startTime, endTime, duration, "任务执行成功!", QuartzResult.STATUS_SUCCESS.value() );它没有判断:
result == false因此以下情况可能被记录为成功:
AgentJobWrapper 为空 agentId 为空 Agent 调用发生异常但被捕获 AgentScheduler 返回 false这属于当前代码的真实问题。更合理的做法是让doJob()抛出异常,或者让QuartzJob对布尔结果进行判断。
22.2block()会占用 Quartz 执行线程
Agent 执行代码:
agent.call(...).block();意味着 Quartz 工作线程会一直等待 Agent 执行结束。
如果 Agent 调用很慢,可能出现:
Quartz 工作线程被长时间占用 其他定时任务等待执行 多个任务同时执行时线程池耗尽因此需要根据实际任务耗时合理配置 Quartz 线程池,并监控任务执行时间。
二十三、动态类加载带来的安全问题
数据库中的job_class是可配置的:
Class.forName(jobInfo.getJobClass());虽然当前前端固定提交:
com.hxh.apboa.job.scheduler.AgentScheduler但后端接口本身接收的是普通字符串。
如果允许普通用户修改jobClass,就可能加载不应该执行的类。
当前代码已经限制:
QuartzJob.class.isAssignableFrom(aClass)但这只能保证类继承了QuartzJob,不能保证它一定是安全类。
更稳妥的做法是:
只允许白名单中的 Job 类 限制 jobClass 可配置范围 对 dataMap 做字段校验 限制普通用户只能绑定自己的 Agent 后台接口继续保留角色权限项目当前通过:
@RoleNeed({Role.ADMIN, Role.EDIT})保护新增、修改、删除、启动和停止接口,这是必要的权限边界。
二十四、如何验证这套动态任务
可以按照下面流程验证。
1. 创建 Agent 定时任务
POST /job/add提交:
{ "type": "AGENT", "bizId": "1001", "cron": "0 */5 * * * ?", "jobClass": "com.hxh.apboa.job.scheduler.AgentScheduler", "dataMap": "{\"agentId\":\"1001\",\"input\":\"执行一次测试任务\"}", "enabled": true }2. 查询任务配置
GET /job/list或者直接查询:
SELECT * FROM quartz_job_info WHERE biz_id = '1001';3. 查看执行日志
SELECT * FROM quartz_job_log ORDER BY start_time DESC;4. 修改执行时间
GET /job/updateCron?id=任务ID&cron=0 0 9 ? * MON-FRI5. 删除任务
GET /job/delete?id=任务ID6. 重启应用
重启后检查:
enabled = 1 的任务是否重新进入 Quartz如果任务没有恢复,重点检查:
job_class 是否存在 Cron 是否合法 data_map 是否能够解析 Agent 是否仍然存在 Agent 是否处于启用状态二十五、总结
这个项目的动态定时任务实现可以概括为:
后台配置任务 ↓ 保存 quartz_job_info ↓ 构造 QuartzConfig ↓ 注册 JobDetail 和 Trigger ↓ 到达 Cron 时间 ↓ AgentScheduler 获取 Agent ↓ 调用 Agent 执行输入内容 ↓ QuartzJob 保存执行日志项目真正实现的能力包括:
数据库保存任务配置 后台动态新增任务 修改 Cron 表达式 启动和删除调度任务 应用启动时恢复启用任务 Agent 参数动态传递 统一执行日志 任务类继承校验 Cron 表达式校验同时也必须明确当前实现的边界:
当前不支持集群部署 没有使用 Quartz JDBC JobStore 数据库配置和 Quartz 调度不是同一个事务 停止接口存在条件判断问题 AgentScheduler 返回 false 可能仍被记录为成功 任务执行期间会占用 Quartz 工作线程 数据库中的类路径需要随 Java 包名迁移因此,这套方案适合:
单实例部署 后台任务数量可控 需要动态配置 Agent 定时任务 需要保存任务执行日志如果需要多实例部署,则不能只依赖当前实现,需要引入 Quartz JDBC 集群配置或其他分布式调度方案。