我刚开始接手这类需求时脑子里第一反应还是改配置、重新打包、发布、重启。结果生产环境里一次临时调整定时任务频率完全可以“一条SQL解决”硬生生变成一次发版。尤其像对账、推送、抓取这类调度任务频率改起来是经常的事。后来我把SpringBoot项目里的核心定时任务全部重构成动态调度配置存库、运行中修改cron、启停任务、切换执行器全部不用重启。这篇文章就围绕“修改定时任务不重启项目”这个需求完整拆解SpringBoot动态定时任务的具体实现方案、踩坑点、以及我沉淀下来的通用玩法。1. 先搞清楚为什么常规做法做不到“不重启”1.1 Scheduled注解的天然限制SpringBoot项目里大家最熟悉的定时任务写法就是Scheduled(cron 0 0 2 * * ?)在方法上打一个注解Spring容器启动时就会把任务挂到调度器上。看上去很省事但它的缺陷也在这里调度关系在容器初始化阶段就固定了后续想改成几点执行、怎么传参数、怎么临时停掉光靠注解是做不到的。你可能会说把cron写到application.yml里不就行了比如Scheduled(cron ${task.pay.cron})。但这只是把“硬编码换了个地方”配置文件改完后还是要重启应用才能重新加载。Spring的Scheduled注解处理器会在启动时读取配置之后不会再去监听配置文件有没有变化。所以这一类玩法本质上还是没有跳出“重启”的圈子。1.2 真正可行的动态调度思路要支持“运行中改定时任务”关键是把“任务调度”这件事从Spring的容器生命周期里解放出来。Spring框架本身提供了TaskScheduler接口最常用的实现是ThreadPoolTaskScheduler。它允许你在代码运行过程中提交一个Runnable并指定一个Trigger比如CronTrigger方法会返回一个ScheduledFuture?。后续想修改就把这个ScheduledFuture取消掉再用新的Trigger重新提交。整个流程就是“先取消旧的再注册新的”应用完全不需要重启。打个比方Scheduled就像你雇了一个固定上早班的员工排班表在入职当天就贴死而ThreadPoolTaskScheduler就像一个灵活排班的调度中心随时可以调换班次、暂停某个工作、安排新的任务。我们要做的就是自己搭建这个“调度中心”。1.3 “不重启”解决的到底是什么很多人以为不重启只是为了省那几分钟发布时间其实价值远不止这些生产环境发布窗口受限不可能每次都为了改一个cron走完整的发布流程临时调整一个任务的执行时间比如大促期间每隔5分钟跑一次数据同步大促结束后恢复正常频率任务代码出问题后需要立即停掉而不是等到下一次执行前多个环境配置不一致希望由运营同学自己调整而不是每次找开发改代码。这些场景都要求调度信息能动态读取、动态变更而不仅仅是启动时加载一次。2. 动态调度的基础组件与核心原理2.1 配置一个可控的ThreadPoolTaskScheduler首先我们要在SpringBoot里初始化一个调度线程池。可以直接注入已有的ThreadPoolTaskScheduler也可以自建一个专用Bean方便统一管理。Configuration public class TaskSchedulerConfig { Bean(dynamicTaskScheduler) public ThreadPoolTaskScheduler dynamicTaskScheduler() { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(10); scheduler.setThreadNamePrefix(dynamic-task-); scheduler.setWaitForTasksToCompleteOnShutdown(true); scheduler.setAwaitTerminationSeconds(30); scheduler.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); scheduler.initialize(); return scheduler; } }几个参数我单独解释一下poolSize调度线程池的核心线程数。不是越大越好因为定时任务通常是短任务线程数太多反而增加上下文切换开销。经验上核心任务数量在10个以内池子大小设成10基本够用。waitForTasksToCompleteOnShutdown应用停机时是否等待正在执行的任务完成。不重启场景下这个参数影响不大但如果你还是需要偶尔重启它能避免任务执行到一半被杀掉。setAwaitTerminationSeconds(30)最多等30秒。如果任务一直卡住不至于让应用永远关不掉。CallerRunsPolicy线程池满了之后由调用方线程执行。这里我们用的是register方法如果放到Controller里调用可能出现由Tomcat线程执行任务的情况所以生产上我会改成DiscardPolicy或者更合适自己的拒绝策略。这个后面讲坑的时候还会提。2.2 理解ScheduledFuture和Trigger的关系ThreadPoolTaskScheduler.schedule方法签名有很多重载我们最关心两个ScheduledFuture? schedule(Runnable task, Trigger trigger); ScheduledFuture? schedule(Runnable task, Date startTime);CronTrigger就是Trigger最常见的实现。只要cron表达式合法调度器就会按照表达式算出的时间点去执行任务。每次执行完毕后调度器会问Trigger“下一次执行时间是什么”所以同一份cron表达式会被反复解析。只要我们替换掉整个Trigger下一次执行时间就会立刻改变。这里有一个小陷阱CronTrigger绑定的是服务器默认时区。如果系统时区是Asia/Shanghai还好如果是UTC那cron表达式的含义会和你预期完全不一样。建议在创建CronTrigger时显式指定时区new CronTrigger(cron, TimeZone.getTimeZone(Asia/Shanghai))2.3 取消任务时百人百踩的cancel参数ScheduledFuture.cancel()有两个参数mayInterruptIfRunning。false只是让“下一次任务”不再被调度。如果任务此刻正在执行会等它自然跑完不会打断线程。true会尝试中断正在执行的任务线程调用Thread.interrupt()。我的建议是大部分动态调度都选false。因为定时任务里很多操作涉及数据库事务、文件写入、外部接口调用强行中断很难保证数据一致。比如一个任务正在把一批订单标记为已对账你中断了线程数据库事务可能已经提交但代码逻辑以为没成功。所以宁可让它跑完也不要为了“快速停掉”给自己埋雷。如果业务确实要求“立刻停掉正在跑的任务”正确的做法是设计一个“停止标志”任务每次循环或者关键步骤前检查一下是否被要求停止。这个之后再展开。3. 设计一套通用的动态定时任务管理器3.1 用Map维护任务注册表有了调度线程池和ScheduledFuture下一步是把它们组织起来。我会维护一个ConcurrentHashMapkey是任务IDvalue是当前调度ScheduledFuture。Service public class DynamicTaskRegistry { private final ThreadPoolTaskScheduler taskScheduler; private final MapString, ScheduledFuture? futureMap new ConcurrentHashMap(); public DynamicTaskRegistry(Qualifier(dynamicTaskScheduler) ThreadPoolTaskScheduler taskScheduler) { this.taskScheduler taskScheduler; } /** * 注册一个新任务 */ public synchronized boolean register(String taskId, Runnable task, String cron) { if (futureMap.containsKey(taskId)) { return false; } ScheduledFuture? future taskScheduler.schedule(task, new CronTrigger(cron)); futureMap.put(taskId, future); return true; } /** * 修改一个已存在任务的cron */ public synchronized boolean reschedule(String taskId, Runnable task, String cron) { ScheduledFuture? oldFuture futureMap.remove(taskId); if (oldFuture ! null) { oldFuture.cancel(false); } return register(taskId, task, cron); } /** * 停用任务 */ public synchronized boolean disable(String taskId) { ScheduledFuture? future futureMap.remove(taskId); if (future ! null) { future.cancel(false); return true; } return false; } /** * 判断任务是否已注册 */ public boolean isRegistered(String taskId) { return futureMap.containsKey(taskId); } }这里用synchronized是为了避免并发操作同一个任务ID时出现“旧任务没取消、新任务又注册成功”的混乱。比如运营同学在页面上连续点了两次“保存”两个请求同时进来没有同步控制就可能出现两个ScheduledFuture同时存在于线程池里任务重复执行造成线上数据错误。3.2 把任务配置下沉到数据库光有注册表还不够任务配置本身要可持久化。我通常建一张简单的表CREATE TABLE t_task_config ( task_id VARCHAR(64) PRIMARY KEY, handler_name VARCHAR(128) NOT NULL COMMENT 任务执行器的Spring Bean名称, cron_expression VARCHAR(64) NOT NULL COMMENT cron表达式, enabled TINYINT NOT NULL DEFAULT 1 COMMENT 是否启用 1启用 0停用, param_json VARCHAR(2048) NULL COMMENT 业务参数JSON格式, version BIGINT NOT NULL DEFAULT 0 COMMENT 版本号用于并发控制, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT动态定时任务配置表;handler_name是关键它对应Spring容器里某个实现了统一接口的Bean。这样当我们从数据库读到一行配置后可以直接用ApplicationContext.getBean(handlerName)拿到具体的业务处理逻辑不需要在代码里写一堆if-else。定义一个统一的任务执行器接口public interface DynamicTaskHandler { /** * 每个Handler的唯一名称 */ String handlerName(); /** * 真正的业务逻辑 */ void execute(MapString, Object param); }比如一个统计任务Component public class ReportTaskHandler implements DynamicTaskHandler { Override public String handlerName() { return reportTaskHandler; } Override public void execute(MapString, Object param) { // 实时统计昨日订单量 // 写入报表表 } }注册任务时我们把Runnable包装成从ApplicationContext获取Handler并传入参数Runnable task () - { DynamicTaskHandler handler applicationContext.getBean(config.getHandlerName(), DynamicTaskHandler.class); handler.execute(convertJsonToMap(config.getParamJson())); };这样扩展新任务只需要三个动作加一张配置记录、写一个Handler实现、调用注册方法。业务代码完全解耦。3.3 实现“修改后实时生效”配置存库之后接下来要解决的是怎么让修改操作实时生效。我的做法是写一个TaskConfigService提供两个核心方法initAllTasks()应用启动时扫描数据库里所有enabled1的配置逐个注册。refreshTask(String taskId)当配置被修改后先禁用旧任务再读取最新配置重新注册。Service public class TaskConfigService { Autowired private TaskConfigMapper taskConfigMapper; Autowired private DynamicTaskRegistry dynamicTaskRegistry; Autowired private ApplicationContext applicationContext; public void initAllTasks() { ListTaskConfig configs taskConfigMapper.selectByEnabled(1); for (TaskConfig config : configs) { doRegister(config); } } public boolean refreshTask(String taskId) { TaskConfig config taskConfigMapper.selectByTaskId(taskId); if (config null) { return false; } // 先停掉旧任务再根据配置决定是否注册 dynamicTaskRegistry.disable(taskId); if (config.getEnabled() 1) { doRegister(config); } return true; } private void doRegister(TaskConfig config) { if (dynamicTaskRegistry.isRegistered(config.getTaskId())) { return; } Runnable runnable buildRunnable(config); dynamicTaskRegistry.register(config.getTaskId(), runnable, config.getCronExpression()); } private Runnable buildRunnable(TaskConfig config) { return () - { DynamicTaskHandler handler applicationContext.getBean(config.getHandlerName(), DynamicTaskHandler.class); MapString, Object params parseParam(config.getParamJson()); handler.execute(params); }; } }对外暴露接口运营后台或管理端直接调用RestController RequestMapping(/task-config) public class TaskConfigController { PostMapping(/refresh) public String refresh(RequestParam String taskId) { boolean result taskConfigService.refreshTask(taskId); return result ? success : task not found; } }这样运营人员只需要改数据库一行cron然后请求一次refresh接口改造就完成了。如果觉得自己手动调接口不优雅还可以再进一步做一个定时器每30秒扫描一次update_time变化的配置自动刷新。但要注意别和动态调度本身混在一起建议用一个独立的、低频的轮询任务来做。3.4 版本号防覆盖数据库表里有version字段这不是摆设。如果有多个管理员同时修改同一条任务配置后保存的人可能覆盖前一个的cron导致任务最终执行时间和预期不一致。最简单的处理方式就是乐观锁UPDATE t_task_config SET cron_expression #{cron}, enabled #{enabled}, version version 1 WHERE task_id #{taskId} AND version #{oldVersion}如果更新影响行数为0说明有人在并发修改立刻抛出提示拒绝这次变更。这个方案实现成本低用来保证配置不被悄悄覆盖很有效。4. 实战演练一个完整的动态调度Demo4.1 项目依赖和工程结构我用Spring Boot 2.7 MyBatis-Plus做示例。核心依赖只需要dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter/artifactId /dependency dependency groupIdcom.baomidou/groupId artifactIdmybatis-plus-boot-starter/artifactId version3.5.3.2/version /dependency dependency groupIdcom.alibaba/groupId artifactIdfastjson2/artifactId version2.0.25/version /dependency工程结构大致如下config调度器配置类handler业务任务Handler接口和实现registry动态任务注册表service任务配置服务controller修改任务接口entity数据库实体mapperMyBatis-Plus Mapper4.2 核心代码串联前面已经给了DynamicTaskRegistry和TaskConfigService这里补全Runnable的细节。很多新手会直接把数据库查询、业务处理写在Runnable里面这样成本最低但不利于统一处理异常。我推荐的写法是Runnable只做“取Handler、执行、捕获异常”这三件事。private Runnable buildRunnable(TaskConfig config) { return () - { // 配置可能在中途被修改每次执行都重新从库里读取配置保证即时生效 TaskConfig latest taskConfigMapper.selectByTaskId(config.getTaskId()); if (latest null || latest.getEnabled() null || latest.getEnabled() ! 1) { return; } DynamicTaskHandler handler applicationContext.getBean(latest.getHandlerName(), DynamicTaskHandler.class); MapString, Object params JSON.parseObject(latest.getParamJson(), new TypeReferenceMapString, Object() {}); try { handler.execute(params); } catch (Exception e) { // 上报错误持久化异常日志 log.error(动态任务执行失败 taskId:{}, latest.getTaskId(), e); } }; }这样每次执行前都会重新读取配置即使忘了调refresh接口任务最多也只会多执行一次旧的定时计划而参数和Handler都是最新的不会造成业务逻辑长期错乱。4.3 写一个业务Handler下面以“数据同步任务”为例模拟从另一个系统拉数据Component public class DataSyncTaskHandler implements DynamicTaskHandler { Override public String handlerName() { return dataSyncTaskHandler; } Override public void execute(MapString, Object param) { String source (String) param.getOrDefault(source, default); Integer batchSize (Integer) param.getOrDefault(batchSize, 100); System.out.println(同步数据来源 source 批量 batchSize 时间 LocalDateTime.now()); // 具体业务 } }启动时在ApplicationRunner里初始化任务Component public class TaskInitializer implements ApplicationRunner { Autowired private TaskConfigService taskConfigService; Override public void run(ApplicationArguments args) { taskConfigService.initAllTasks(); } }启动应用后往数据库插入一条配置INSERT INTO t_task_config(task_id, handler_name, cron_expression, enabled, param_json) VALUES(dataSync, dataSyncTaskHandler, 0 */1 * * * ?, 1, {source:erp,batchSize:200});看到日志中每分钟执行一次说明注册成功。4.4 动态修改cron并验证假设现在要改成每5分钟执行一次我们直接更新数据库UPDATE t_task_config SET cron_expression 0 */5 * * * ? WHERE task_id dataSync;然后调用接口curl -X POST http://localhost:8080/task-config/refresh?taskIddataSync观察日志执行频率立刻变成每5分钟一次应用进程完全没有重启。如果你想临时停掉这个任务直接执行UPDATE t_task_config SET enabled 0 WHERE task_id dataSync; curl -X POST http://localhost:8080/task-config/refresh?taskIddataSync任务立即停止调度。这在出现线上紧急事故时能救命。4.5 参数动态生效的一个注意点执行器在每次执行时都会重新读取数据库配置所以修改param_json后下一次任务执行就会用到新参数不需要refresh也能生效。唯一需要注意的是cron表达式如果也变化了CronTrigger是在注册时生成的所以必须经过refresh或周期性扫描才能重新注册。这里不推荐在每次执行时重新创建CronTrigger因为ThreadPoolTaskScheduler的Trigger是绑定在Future内部的不是Runnable里能随便替换的。5. 常见问题与排查技巧实录5.1 cancel(false)后任务还在跑正常吗正常。false只阻止后续触发已经在运行中的任务会继续执行到结束。如果你发现任务停了之后未来某一时刻再次出现一次执行那多半是调用了cancel(true)任务线程被中断但Spring的ScheduledThreadPoolExecutor内部状态异常又补了一次执行。我遇到过一次后来统一改成cancel(false)并配合“停止标志”后问题消失。5.2 ThreadPoolTaskScheduler和Scheduled共用线程池冲突SpringBoot默认的Scheduled线程池数量是1也就是所有注解定时任务默认串行。而自定义的ThreadPoolTaskScheduler是独立实例两者之间互不干扰。但是如果你的项目里既有Scheduled又有动态调度兜底模型是注解任务用默认调度器动态任务用自定义调度器。不要试图把动态任务也提交到默认TaskScheduler那样线程池太小、线程名前缀混乱排查问题很痛苦。5.3 同一任务被重复注册这是最常见的坑。第一次注册成功第二次带着相同taskId再次调用register时会被我们的Map拦截。但有些同事会忘了这个方法本身会拦截直接用reschedule结果把正在跑的任务取消重新注册造成短暂间隔后任务又从第一次触发时间开始算。我的建议是注册入口只调register修改入口只调reschedule并且所有操作都走TaskConfigService不要绕过它直接在Controller里操作Registry。5.4 cron表达式解析时报错CronTrigger用的是Spring的cron格式和Quartz的6位/7位格式有区别。Spring支持6位字段秒、分、时、日、月、周。很多从Quartz迁移过来的同学会写成0 0 2 * * ?这其实是7位。Spring的cron不支持“年”这一位也不支持?周字段位置直接写*或MON。正确写法示例每分钟执行0 * * * * *每天凌晨2点0 0 2 * * *工作日早上8点半0 30 8 * * 1-5如果配置存的是7位调度器会直接抛异常。我一般会在注册前用CronExpression校验if (!CronExpression.isValidExpression(cron)) { throw new IllegalArgumentException(非法cron: cron); }5.5 执行时间重叠导致任务互相叠加一个任务上一轮还没跑完下一轮触发时间到了如果线程池里还有空余线程就会并发执行同一个任务。对多数数据同步、统计类任务来说这是灾难。我的处理办法是加“执行中”标志private final ConcurrentHashMapString, Boolean runningFlag new ConcurrentHashMap(); if (runningFlag.putIfAbsent(taskId, Boolean.TRUE) ! null) { // 任务已在执行直接跳过本次 return; } try { handler.execute(params); } finally { runningFlag.remove(taskId); }这个标志要放在Runnable里面而不是Handler里避免不同Handler之间意外共享状态。5.6 动态调度遇上分布式部署怎么办如果服务有多个实例直接在每台机器上注册同一个taskId会出现同一份任务被多次执行。两条路引入分布式锁抢到锁的实例才去注册任务引入统一调度框架比如XXL-Job它们本身就是为分布式定时任务设计的天然支持动态修改、动态启停SpringBoot这边只需要写业务Handler。如果项目还在轻量阶段我推荐先使用ShedLock这类轻量锁配合动态调度它可以保证同一个任务在分布式环境下只有一个节点执行而且不用大量改造。5.7 异常兜底千万不要忽略动态任务的Runnable和Scheduled方法一样如果抛出未捕获异常调度线程会被影响虽然Scheduler会继续执行以后的任务但日志会非常难看也容易让运维误判。所以每个任务执行必须包try-catch并且记录足够的上下文信息比如taskId、触发时间、参数快照。6. 收官一个靠经验堆出来的设计习惯如果你问我最核心的经验是什么我会说不要把调度系统做成“注册一次就永久绑定”而是每次执行都反向查询最新配置。这个习惯让我少踩了很多坑也让运行时修改配置变成了一个非常自然的事情。动态调度的本质并不是什么高深的技术只是把静态的绑定关系改成基于Map和Future的动态匹配但正是这一点点改变让系统在运维层面灵活了很多。最后再分享一个小技巧在改造存量项目时不要一次性把所有Scheduled任务全部迁移。先挑两个重要任务完成动态化观察性能和稳定性再逐步迁移。这个过程里你大概率会发现以前觉得“重启一下无所谓”的任务其实有很多临时调整需求。把核心任务全部动态化以后线上运营和研发都会轻松很多。
阅读完成 · 觉得有帮助?