news 2026/10/28 19:05:16

Spring Boot + Quartz 实现后台动态定时任务:从数据库配置到 Agent 执行

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot + Quartz 实现后台动态定时任务:从数据库配置到 Agent 执行

一、项目为什么需要动态定时任务

普通的 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
cronQuartz 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-FRI

5. 删除任务

GET /job/delete?id=任务ID

6. 重启应用

重启后检查:

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 集群配置或其他分布式调度方案。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/28 19:02:27

C++11设计模式实战:从单例到CRTP的完整实现

一、引言 设计模式是面向对象设计中反复出现的问题的通用解决方案。C++ 作为一门支持多范式、强调零开销抽象的语言,设计模式在其中的落地方式与 Java、C# 有明显差异:C++ 有模板、RAII、值语义、移动语义、编译期多态等特性,很多模式可以用更轻量的方式实现,甚至可以在编…

作者头像 李华
网站建设 2026/10/28 19:01:02

智能从来不是工具,是结构:Copilot 救不了组织

WGA 在 2026 年 10 月发布的一份组织设计报告里引过一项覆盖 5179 名客服的研究&#xff1a;AI 辅助让人均处理量涨了 14%&#xff0c;新手和低技能员工涨了 34%。WGA 据此判断&#xff0c;工作流、排队、升级路径与汇报线&#xff0c;四样一样没碰。 人更快了&#xff0c;组织…

作者头像 李华
网站建设 2026/10/28 19:00:42

OpenClaw 一键包|45.7MB 本地 AI 智能体全流程实操

&#x1f4d6; 前言 本文面向 Windows 系统用户&#xff0c;系统梳理 OpenClaw 的标准化部署流程。全程无需输入任何命令&#xff0c;所有操作均可通过可视化向导完成&#xff0c;即使是零基础用户&#xff0c;也能独立走完整套部署流程。文中同时汇总了高频报错对应的解决方案…

作者头像 李华
网站建设 2026/10/28 19:00:24

Linux 核心命令与符号 >> 权限管理体系专题

Linux 权限是安全管控的核心&#xff0c;文件读写执行权限、属主属组修改、文件特殊锁定属性、系统默认权限掩码&#xff0c;是运维排查权限问题高频知识点。一、chmodchange mode&#xff0c;修改文件 / 目录权限数字权限写法字符权限写法&#xff08; - &#xff09;-R&#…

作者头像 李华