我刚开始接手这类需求时,脑子里第一反应还是改配置、重新打包、发布、重启。结果生产环境里一次临时调整定时任务频率,完全可以“一条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,下一步是把它们组织起来。我会维护一个ConcurrentHashMap,key是任务ID,value是当前调度ScheduledFuture。
@Service public class DynamicTaskRegistry { private final ThreadPoolTaskScheduler taskScheduler; private final Map<String, 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(Map<String, Object> param); }比如一个统计任务:
@Component public class ReportTaskHandler implements DynamicTaskHandler { @Override public String handlerName() { return "reportTaskHandler"; } @Override public void execute(Map<String, 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():应用启动时扫描数据库里所有enabled=1的配置,逐个注册。refreshTask(String taskId):当配置被修改后,先禁用旧任务,再读取最新配置重新注册。
@Service public class TaskConfigService { @Autowired private TaskConfigMapper taskConfigMapper; @Autowired private DynamicTaskRegistry dynamicTaskRegistry; @Autowired private ApplicationContext applicationContext; public void initAllTasks() { List<TaskConfig> 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); Map<String, 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. 实战演练:一个完整的动态调度Demo
4.1 项目依赖和工程结构
我用Spring Boot 2.7 + MyBatis-Plus做示例。核心依赖只需要:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter</artifactId> </dependency> <dependency> <groupId>com.baomidou</groupId> <artifactId>mybatis-plus-boot-starter</artifactId> <version>3.5.3.2</version> </dependency> <dependency> <groupId>com.alibaba</groupId> <artifactId>fastjson2</artifactId> <version>2.0.25</version> </dependency>工程结构大致如下:
config:调度器配置类handler:业务任务Handler接口和实现registry:动态任务注册表service:任务配置服务controller:修改任务接口entity:数据库实体mapper:MyBatis-Plus Mapper
4.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); Map<String, Object> params = JSON.parseObject(latest.getParamJson(), new TypeReference<Map<String, 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(Map<String, 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?taskId=dataSync"观察日志,执行频率立刻变成每5分钟一次,应用进程完全没有重启。
如果你想临时停掉这个任务,直接执行:
UPDATE t_task_config SET enabled = 0 WHERE task_id = 'dataSync'; curl -X POST "http://localhost:8080/task-config/refresh?taskId=dataSync"任务立即停止调度。这在出现线上紧急事故时能救命。
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 ConcurrentHashMap<String, 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任务全部迁移。先挑两个重要任务完成动态化,观察性能和稳定性,再逐步迁移。这个过程里你大概率会发现,以前觉得“重启一下无所谓”的任务,其实有很多临时调整需求。把核心任务全部动态化以后,线上运营和研发都会轻松很多。