ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Spring Boot动态定时任务:基于数据库的Cron配置与实现

Spring Boot动态定时任务:基于数据库的Cron配置与实现 1. 定时任务的常见形态与写死痛点的由来1.1 注解式定时任务方便但僵化SpringBoot 里写定时任务绝大多数人第一次接触的都是Scheduled。这个注解确实方便一个标签加一个 Cron 表达式项目启动的时候 Spring 容器会自动帮我们注册好调度逻辑无需额外引入 Quartz 这种重量级框架也省去了 XML 配置的年代记忆。Component public class SimpleTask { Scheduled(cron 0 0 2 * * ?) public void run() { // 每天凌晨两点执行一次 System.out.println(任务执行了); } }看起来清爽用起来顺手但一旦项目上了生产环境问题很快就来了。比如业务方突然说统计报表任务原本每天凌晨跑现在改成每小时跑一次。如果只是改一个 Cron 字符串代码层面三秒钟就改完可问题是新逻辑要生效必须重新打包、上传服务器、重启应用。一次改动等于是做了一轮发布流程运气好赶上非高峰期还有操作窗口运气差一点赶上白天业务繁忙时段这种为了改一个时间参数而重启整个应用的操作研发和运维都得跟着捏把汗。后来需求越来越多业务方开始提出更多要求某个任务能不能暂时停掉但不删除配置某个任务能不能由运营人员在后台上自己改执行周期某条数据状态变化了关联定时任务能不能自动调整执行频率这些需求指向同一个结论定时任务的触发计划不能被固化在代码里必须要能动态变化。1.2 动态执行的本质把调度决策权从代码交出去所谓动态执行说白了就是把定时任务的触发规则执行时间、执行频率、开关状态从代码里挪出去放到一个可以在运行时被修改的地方。最常用的载体就是数据库。核心思路并不复杂应用启动时从数据库读取任务配置注册一个可以周期性刷新配置的调度器运行过程中数据库里的配置变了调度器在下一个刷新周期就能感知到变化自动调整任务的触发计划整个过程不需要重启应用。这个方案听上去好像很高级其实 Spring 自带的调度能力已经具备支撑它的底层设施只是很多人只用了Scheduled这个表面入口没摸到更深一层的SchedulingConfigurer和TaskScheduler这两把钥匙。把它们组合起来就能实现一套轻量、不依赖额外框架的数据库驱动定时任务。接下来我会把完整思路和代码逐步讲清楚。2. 动态定时任务的底层机制SchedulingConfigurer 与 TaskScheduler 的组合2.1 为什么 Scheduled 做不到动态调整要理解动态方案先得明白Scheduled为什么僵。它的调度行为在应用启动阶段就已经被固定了。Spring 在启动时会扫描所有被Scheduled标注的方法读取注解里的cron/fixedDelay/cronZone等属性然后交给任务调度器注册成一条条一次性绑定的调度任务。这里的核心问题是Cron 表达式在注册时被解析成了具体的调度时间表后续业务代码里不可能再对已注册的调度任务做修改。要想改只能重新注册而重新注册的前提是先把旧的调度任务销毁这套逻辑在注解模式下没有暴露给开发者。换个角度说注解模式把调度器的使用细节完全封装了我们拿到的只是一个注册完就不用管的便捷入口。想要动态能力就必须绕过这个入口直接去操作底层的调度器 API。2.2 SchedulingConfigurer 的运行机制SchedulingConfigurer提供了自定义调度器配置的能力它允许我们手动把一批需要被调度的任务注册到 Spring 的调度器中。关键亮点在于注册时机configureTasks方法会在容器启动早期被调用但它注册任务时传入的 Trigger 是在运行时才被解析求值的。这里有一个很容易忽略但很关键的设计点Trigger接口的nextExecutionTime方法会在每一次任务执行完成后由调度器再次调用用来计算下一次的执行时间。这意味着如果我们在nextExecutionTime里动态读取数据库的 Cron 配置那么每次任务跑完后就有机会拿到最新的调度时间。换句话说数据库里的配置改了会在下一次任务执行完、计算再下一次触发时间时立即反映出来最多延迟一个执行周期完全不需要重启。来看一个最简版本的实现骨架Configuration public class DynamicScheduleConfig implements SchedulingConfigurer { Autowired private TaskConfigMapper taskConfigMapper; Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.addTriggerTask( // 1. 任务体 () - executeTask(), // 2. 触发逻辑 triggerContext - { String cron taskConfigMapper.getCronByTaskName(dataStatisticsTask); if (StringUtils.isEmpty(cron)) { return null; // 返回 null 表示停止后续调度 } CronTrigger trigger new CronTrigger(cron); return trigger.nextExecutionTime(triggerContext); } ); } }这里的addTriggerTask接收两个参数第一个是任务体Runnable第二个是Trigger。任务体只管业务逻辑执行时间完全交给 Trigger 决定而 Trigger 内部实时查询数据库。两者一组合动态调整时间的目标就实现了。2.3 用生活的例子理解这个模型可以把整个机制理解成一个晨间闹钟。Scheduled模式相当于买了一个固定闹钟每天 7 点响改时间就得重新买一个。而SchedulingConfigurer模式相当于装了一个带联网校准的智能闹钟每天晚上响完以后自己连一次服务器拿到最新的设置第二天按照新时间响。服务器就是数据库校准动作就是nextExecutionTime里查库的那行代码。想通了这个模型后面扩展暂停、恢复、多任务支持都会变得顺理成章。3. 从数据库读取任务时段完整实现步骤3.1 数据表设计与初始化数据既然要配合数据库第一步自然是建表。设计上不需要太复杂够用就行。我通常会在sys_task_config表里保留下列字段字段名类型说明idbigint主键task_namevarchar(64)任务名称代码中唯一标识cron_expressionvarchar(32)Cron 表达式task_statustinyint任务状态1 启用0 停用remarkvarchar(255)备注描述create_timedatetime创建时间update_timedatetime更新时间建表 SQL 如下CREATE TABLE sys_task_config ( id bigint NOT NULL AUTO_INCREMENT, task_name varchar(64) NOT NULL COMMENT 任务名称, cron_expression varchar(32) NOT NULL COMMENT cron表达式, task_status tinyint NOT NULL DEFAULT 1 COMMENT 状态1启用 0停用, remark varchar(255) DEFAULT NULL COMMENT 备注, create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_task_name (task_name) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT定时任务配置表;初始化两条数据INSERT INTO sys_task_config (task_name, cron_expression, task_status, remark) VALUES (dataStatisticsTask, 0 0 2 * * ?, 1, 数据统计任务每天凌晨2点执行), (dataCleanTask, 0 0 4 * * ?, 0, 数据清理任务每天凌晨4点执行暂时停用);注意task_status这个字段后面做动态启用/停用就需要靠它调度器每次计算下一次执行时间时都会检查状态状态为 0 时返回null任务就自然不跑了。3.2 核心代码从 Mapper 到调度器先写一个简单的 Mapper 接口用 MyBatis 还是 Spring Data JPA 都可以逻辑都一样。这里以 MyBatis 举例Mapper public interface TaskConfigMapper { ListTaskConfig selectAllEnabledTask(); TaskConfig selectByTaskName(Param(taskName) String taskName); }对应 XML 文件select idselectAllEnabledTask resultTypecom.example.entity.TaskConfig SELECT id, task_name, cron_expression, task_status, remark FROM sys_task_config WHERE task_status 1 /select select idselectByTaskName resultTypecom.example.entity.TaskConfig SELECT id, task_name, cron_expression, task_status, remark FROM sys_task_config WHERE task_name #{taskName} /select然后是核心的调度配置类。这里有一个关键设计我把每个任务都注册成独立的ScheduledTask句柄方便后续手动启停Configuration EnableScheduling public class DynamicScheduleTaskConfig implements SchedulingConfigurer { private final TaskConfigMapper taskConfigMapper; private final DataStatisticsTask dataStatisticsTask; private final DataCleanTask dataCleanTask; private final MapString, ScheduledTask scheduledTaskMap new ConcurrentHashMap(); private ScheduledTaskRegistrar taskRegistrar; public DynamicScheduleTaskConfig(TaskConfigMapper taskConfigMapper, DataStatisticsTask dataStatisticsTask, DataCleanTask dataCleanTask) { this.taskConfigMapper taskConfigMapper; this.dataStatisticsTask dataStatisticsTask; this.dataCleanTask dataCleanTask; } Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { this.taskRegistrar taskRegistrar; registerTask(dataStatisticsTask, dataStatisticsTask::execute); registerTask(dataCleanTask, dataCleanTask::execute); } private void registerTask(String taskName, Runnable taskRunnable) { if (taskRegistrar null) { throw new IllegalStateException(taskRegistrar 未初始化); } ScheduledTask scheduledTask taskRegistrar.scheduleTriggerTask( new TriggerTask( taskRunnable, triggerContext - { TaskConfig config taskConfigMapper.selectByTaskName(taskName); if (config null || config.getTaskStatus() 0) { return null; } CronTrigger cronTrigger new CronTrigger(config.getCronExpression()); return cronTrigger.nextExecutionTime(triggerContext); } ) ); scheduledTaskMap.put(taskName, scheduledTask); } public void refreshTask(String taskName) { ScheduledTask oldTask scheduledTaskMap.get(taskName); if (oldTask ! null) { oldTask.cancel(); } TaskConfig config taskConfigMapper.selectByTaskName(taskName); if (config null) { return; } if (config.getTaskStatus() 0) { return; // 停用状态不重新注册 } registerTask(taskName, getRunnableByTaskName(taskName)); } private Runnable getRunnableByTaskName(String taskName) { switch (taskName) { case dataStatisticsTask: return dataStatisticsTask::execute; case dataCleanTask: return dataCleanTask::execute; default: throw new IllegalArgumentException(未知任务: taskName); } } }这里scheduleTriggerTask方法会返回一个ScheduledTask我们把它缓存到scheduledTaskMap里。目标是让外部可以随时调用refreshTask方法实现手动刷新单个任务配置而不必等下一个调度周期自然生效。3.3 核心代码逐段详解第一段重点看configureTasks它在容器装配阶段执行里面通过registerTask注册了若干个任务。每一个任务的 Trigger 逻辑里都实时查询数据库读取 Cron 表达式和状态。这个每次执行完重新查库的设计就是动态更新的根基。第二段重点看Trigger返回null的含义。在 Spring 的调度机制里Trigger.nextExecutionTime返回null代表没有下一次执行计划调度器收到null后会自动结束这个任务的调度循环。利用这一点当数据库里task_status 0时返回null任务就自然停用了。第三段是refreshTask方法。它实现了主动刷新能力——假设运营后台改了 Cron 表达式后台代码调用refreshTask(dataStatisticsTask)先把旧调度任务cancel()再注册一个带着最新配置的新调度任务。这里需要注意cancel()只是取消后续调度并不会中断正在执行的任务这个特性后面做线程调度优化时会派上用场。3.4 改动数据库后立即生效的验证方式应用启动后修改数据库中dataStatisticsTask的 Cron 表达式比如从0 0 2 * * ?改为0 0/5 * * * ?每 5 分钟执行一次。按前面讲的机制如果不去主动调用refreshTask任务会在当前调度周期结束后从数据库拿到新的 Cron然后按每 5 分钟一次的频率执行。如果想立即生效就调用一次接口RestController RequestMapping(/task) public class TaskController { private final DynamicScheduleTaskConfig scheduleTaskConfig; public TaskController(DynamicScheduleTaskConfig scheduleTaskConfig) { this.scheduleTaskConfig scheduleTaskConfig; } PostMapping(/refresh/{taskName}) public ResultVoid refresh(PathVariable String taskName) { scheduleTaskConfig.refreshTask(taskName); return Result.success(); } }这里我们可以做一个对比用一张表看清楚被动等待和主动刷新的区别场景操作生效时间等待自然生效只改数据库最多延迟一个执行周期主动刷新改库 调 refresh 接口立即生效4. 多任务场景下的表结构设计与调度编排4.1 从一个任务一张表到一张表多个任务上面代码里我注册任务时直接把两个任务写死在configureTasks中存在一个明显问题任务一多配置类的代码会变成一大坨。更合理的做法是把任务注册做成通用逻辑。核心改造点有两个一个是把 Runnable 的映射做成可扩展的注册机制。Spring 容器启动时把所有实现特定接口的 Bean 收集到一个 Map 里任务名为 keyBean 实例为 valueComponent public class TaskRegistry { private final MapString, Runnable taskRunnableMap; public TaskRegistry(ListTaskHandler taskHandlers) { taskRunnableMap taskHandlers.stream() .collect(Collectors.toMap(TaskHandler::getTaskName, handler - handler::execute)); } public Runnable getRunnable(String taskName) { return taskRunnableMap.get(taskName); } public SetString getAllTaskNames() { return taskRunnableMap.keySet(); } }TaskHandler接口很简单public interface TaskHandler { String getTaskName(); void execute(); }每个实际业务任务只需要实现TaskHandler接口Spring 启动时自动收集。后续新增定时任务不再需要改调度配置只需新增一个实现类。这一步把定时调度的注册逻辑和具体业务任务完全解耦了。另一个是把表结构稍微扩展增加task_group字段用来标识任务所属业务模块。这样做的好处是配置后台查询任务时可以按组过滤不至于一口气返回几千条记录。4.2 并发策略同一个任务是否会重复执行动态定时任务有一个常被忽视的问题当任务执行耗时超过 Cron 表达式的间隔时间时会不会出现前一次还没跑完后一次又启动了的重叠执行默认情况下Spring 的TaskScheduler是否允许并发执行取决于线程池配置。如果使用ThreadPoolTaskScheduler且线程池大小大于 1理论上同一任务的两轮执行确实可能并发。对大多数业务来说这不是想要的行为。解决方案是单任务串行化。最轻量的做法是为每个任务使用独立的、单线程的调度器Bean(taskScheduler) public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(10); scheduler.setThreadNamePrefix(dynamic-task-); scheduler.setWaitForTasksToCompleteOnShutdown(true); scheduler.setAwaitTerminationSeconds(60); return scheduler; }注意这里poolSize 10不等于允许同一任务并发因为同一任务的调度仍然是基于同一个ScheduledTask只有当两次执行的间隔小于执行耗时且调度器有空闲线程时才会出现并发。要真正避免同一任务重入更保险的方法是在任务执行前加锁public abstract class AbstractTaskHandler implements TaskHandler { private final AtomicBoolean running new AtomicBoolean(false); Override public void execute() { if (!running.compareAndSet(false, true)) { log.warn(任务 {} 仍在执行中跳过本次调度, getTaskName()); return; } try { doExecute(); } finally { running.set(false); } } protected abstract void doExecute(); }AtomicBoolean的compareAndSet在这里充当轻量级锁保证同一时间只有一轮任务在跑。这个方案比synchronized更合适因为synchronized会阻塞后续调用而compareAndSet是直接跳过对于定时任务的场景跳过往往比阻塞排队更合理——毕竟下一轮马上又要来了。5. 实战中的坑任务丢失、周期失效与线程池隐患5.1 任务丢失服务重启后的注册时机第一次实现动态定时任务时我踩过一个坑sys_task_config表中状态为 0 的任务在configureTasks阶段会被跳过注册。这本身没有错但问题出在——如果运营在后台把任务状态从 0 改成 1然后调用refreshTask接口试图启用会发现接口报错因为scheduledTaskMap里根本没有这个任务oldTask为 null而cancel分支不会执行registerTask又会因为getRunnableByTaskName里面对应任务实现类不存在而报错。排查链路是这样的先看refreshTask中oldTask.cancel()一步发现oldTask为 null 并不影响后续注册那问题就在getRunnableByTaskName。进一步排查才发现dataCleanTask这个 Bean 虽然在容器中存在但configureTasks阶段因为状态为 0 根本没注册所以scheduledTaskMap中缺失。而getRunnableByTaskName逻辑本身没有问题——问题出在我的registerTask方法里对状态为 0 的任务直接 return导致任务对象压根没被缓存。修正方案是无论任务状态是 0 还是 1只要表中存在配置就注册一个 TriggerTrigger 在每次执行时检查状态状态为 0 就返回 null状态为 1 就正常返回执行时间。这样不会丢任务且状态切换灵敏——运营改状态后无需调用刷新接口任务就会在下一个调度周期自动感知。5.2 Cron 表达式合法性校验数据库里的 Cron 是运营人员手动配置的完全有可能填错。填错的情况分两种一种是字符串格式不合法比如多写了一个字段另一种是语法合法但语义不合理比如在?和*混用时出现冲突。CronTrigger的构造方法内部会调用CronExpression.parse一旦解析失败会直接抛IllegalArgumentException。而这个异常发生在nextExecutionTime方法内部会被调度器捕获接着任务静默终止表现为任务不跑了日志里也没有明显的错误。这个问题很阴险因为它不致命却让排查成本极高。我的做法是在配置保存时做前置校验同时把校验结果反馈给调用方public boolean isValidCron(String cron) { try { new CronTrigger(cron); return true; } catch (Exception e) { return false; } }如果框架里用的是 Spring 5.3CronExpression.parse(cron)是更底层的解析器也可以用类似方式校验。这里要特别提醒一点Quartz 的 Cron 和 Spring 的 Cron 在格式上有些细微差异比如秒的位置、L和W字符的支持。不要假定运营填写的格式一定和 Quartz 兼容该校验的必须校验。5.3 线程池耗尽动态任务增多后的调度卡顿动态定时任务的数量一旦增多默认的调度线程池可能会成为瓶颈。Spring Boot 在没有自定义TaskScheduler的情况下会使用ThreadPoolTaskScheduler的默认配置——默认线程数为 1。是的你没看错默认只有 1 个线程。如果一个任务执行耗时 10 秒而系统里注册了 20 个任务每个任务触发间隔都是 5 秒那么这个单线程调度器会在第 1 个任务执行期间把所有到期的其他任务全部排队。当排队任务积压到一定程度执行延迟会越来越大有些任务的实际执行时间会和 Cron 表达式设定的触发时间相差数分钟肉眼看起来任务时灵时不灵。我在实际排查中见过非常典型的案例运营反馈某个报表任务经常比预期晚跑 5~6 分钟看了日志后发现任务确实有执行但执行的开始时间比 Cron 表达式预设的晚了很久。最后定位到根因就是调度线程池线程数为 1其他长任务把唯一空闲的线程占满了。解决方式是配置合理的线程池Bean(taskScheduler) public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(10); scheduler.setThreadNamePrefix(schedule-task-); scheduler.setRemoveOnCancelPolicy(true); scheduler.setErrorHandler(t - { log.error(调度任务异常, t); }); return scheduler; }关于线程数的选择建议先以系统中最耗时的单个任务耗时 × 任务总数作为上限再结合服务器核数和业务优先级做微调。多数业务系统 8~16 个调度线程足够并不是越多越好——线程越多上下文切换开销越大反而可能拖慢任务执行。5.4 应用关闭时任务中断的问题默认设置下Spring 容器关闭时会直接丢弃未执行完的任务资源清理也来不及做可能导致数据库连接未归还、文件句柄未释放、内存数据未落盘。如果任务里有对账、发邮件、写文件等操作这些中断引发的后果会在生产环境放大。合理的做法是在ThreadPoolTaskScheduler里设置优雅关闭scheduler.setWaitForTasksToCompleteOnShutdown(true); scheduler.setAwaitTerminationSeconds(60);这样应用关闭时调度器会等待正在执行的任务完成最多等待 60 秒。注意这里有一个需要权衡的点如果某个任务耗时特别长比如全量数据清理跑了 20 分钟这 60 秒根本不够用。所以更稳妥的方案是给长任务增加关闭信号机制在业务代码里通过一个静态布尔变量标记应用正在关闭任务在下一次循环体判断到这个标记后就提前结束。6. 更进一步的优化状态控制与调度日志体系6.1 暂停、恢复、手动执行三位一体数据库驱动定时任务跑起来之后运营侧最常见的三个诉求是暂停任务、恢复任务、手动执行一次。三者分别对应三项能力状态切换、重新注册、手动触发。前两项已经讲清楚了手动执行相对独立它绕开调度器直接调用任务的业务方法。可以在控制器里加一个接口PostMapping(/execute/{taskName}) public ResultVoid manualExecute(PathVariable String taskName) { Runnable runnable taskRegistry.getRunnable(taskName); if (runnable null) { return Result.error(任务不存在); } // 建议放进线程池异步执行避免阻塞 HTTP 请求 asyncTaskExecutor.execute(runnable); return Result.success(); }手动执行和定时执行的隔离需要特别注意。如果任务方法内部有分布式锁、幂等控制手动执行一般没问题但如果任务方法内部依赖scheduledTask的上下文状态手动执行可能会触发一些你没有预见到的副作用。安全起见手动执行应该走一条独立的路由不经过调度器但必须经过公用的业务保护逻辑幂等、锁、参数校验。6.2 调度日志排查问题的第一手资料动态任务的排查难度比固定任务高因为触发计划不在代码里运维人员无法直接从代码推断某个任务应该在什么时间执行。因此完善的调度日志就是最关键的排查工具。我一般会在任务执行的关键节点记录三类日志一是调度日志记录任务名称、Cron 表达式、本次调度时间、下次调度时间。这个日志可以在 Trigger 和任务方法里各打一条。二是执行日志记录任务开始时间、结束时间、执行耗时、执行结果。三是异常日志记录异常堆栈和上下文参数。可以用一张单独的task_execution_log表记录执行历史字段名类型说明idbigint主键task_namevarchar(64)任务名称schedule_timedatetime计划执行时间actual_start_timedatetime实际开始时间actual_end_timedatetime实际结束时间execute_statustinyint执行状态1 成功 0 失败error_msgvarchar(1000)异常信息create_timedatetime记录创建时间写入日志时注意一个性能问题高频任务比如每 5 秒执行一次的监控任务如果每次执行都写数据库会产生不少 IO 开销。解决办法是异步写入先写内存队列再由单独线程批量落库或者直接接入现成的日志采集系统只在异常和关键业务操作时写数据库。6.3 基于事件机制的通知配置变更实时同步如果项目已经引入了消息中间件比如 RabbitMQ 或 Kafka还可以玩得更高级一点。配置变更时后台服务发一条变更事件所有实例监听到事件后执行refreshTask这样就解决了多实例部署场景下的配置不一致问题。多实例场景其实非常关键。假设系统部署了两个节点运营在后台改了 Cron请求打到 Node1Node1 的调度器立即感知到变化但 Node2 的调度器对数据库的变化毫不知情还要等到下一个调度周期才慢慢同步。如果两个节点的调度节奏不一致同一任务在 Node1 和 Node2 上可能在不同时间执行。对于定时任务只能执行一次的场景这会产生严重的重复执行问题——比如定时发送邮件、定时生成账单重复执行会直接造成事故。解决路径有三条按复杂度从低到高排列第一条是分布式锁。在任务执行前通过 Redis 的 SETNX 获取锁拿不到锁的实例直接跳过本次执行。这个方案最简单适合多实例部署但调度配置保持一致的场景缺点是锁的粒度粗同一时刻只有一个实例上的任务在执行如果两个实例的任务高峰期叠加会出现等待。第二条是事件广播 本地刷新。配置变更时通过消息中间件广播所有实例监听后各自刷新本地配置。这个方案结合了动态更新和多实例一致性实现成本适中推荐大多数项目使用。第三条是引入专业分布式调度框架如 XXL-Job、ElasticJob。这些框架天然支持分布式调度、故障转移、失败重试如果你有多实例、分片、抢占等复杂需求直接引入成熟框架比自己在 Spring 调度器上堆逻辑要稳得多。对于已经跑在 Spring Boot 单体应用里、只是想让配置动态化的项目我的建议是先做本文前面几章的轻量方案等确实出现多实例调度冲突时再考虑上框架不必一开始就引入重道具。7. 从零搭建时的完整验证清单最后分享一份我在项目上线前用来过一遍的验证清单每一行都来自真实踩坑经历冷启动验证应用刚启动时数据库里停用的任务不应该执行启用的任务应该按配置跑起来。这一步主要验证configureTasks注册阶段的状态判断逻辑。Cron 变更验证启动后修改数据库 Cron等一个周期确认任务按照新频率执行。这一步验证 Trigger 的实时查库能力。主动刷新验证修改数据库后调用refreshTask接口确认任务立即按新配置调度且旧的调度计划被正确取消不会出现新旧计划同时执行。状态切换验证把运行中任务的状态从 1 改成 0等待下一个周期确认任务停止触发再从 0 改成 1确认任务恢复。非法 Cron 验证在数据库里写入非法 Cron确认任务不会静默失败日志里能看到明确异常。长时间运行验证把一个耗时较长的任务挂在系统里跑一天观察日志确认任务没有重叠执行、调度线程池没有积压。优雅关闭验证应用关闭时确认正在执行的任务能正常结束没有资源泄漏。多实例验证如有确认同一任务在不同实例只执行一次或者调整为每个实例各干各的且业务上能接受重复。根据我个人的实际操作经验前四项覆盖了动态定时任务的核心路径是最容易出问题的环节。尤其是第三项的主动刷新很多代码在自然生效场景下跑得好好的一调用刷新接口就暴露了缓存不一致的隐患。这个方案目前已经在几个中小型项目中稳定跑过一段不短的时间整体稳健性对得起它的轻量程度。如果后续任务数量继续膨胀、需要支持分片处理流式数据再考虑引入专业调度框架也不迟当前的改造思路和表结构设计依然能平滑迁移过去。
返回列表