ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Spring Boot动态定时任务:从@Scheduled到可增删启停的完整实践

Spring Boot动态定时任务:从@Scheduled到可增删启停的完整实践 运营同事某天找我说这个报表的任务每天凌晨跑一次但大促期间想改成每半小时一次而且不想发版能不能在后台配紧接着又补了一句最好还能随时停掉某个跑挂了的任务。说实话Spring Boot 里写一个定时任务很简单一个Scheduled注解就完事但要做到动态增删启停需要动的东西就完全不一样了。这篇文章我把完整的实现方案拆开讲——底层机制、表设计、核心代码、踩过的坑一次性说清楚。适合正在做中后台系统、任务调度模块或者想给项目增加运维能力的朋友参考。1. 定时任务从写死到动态需求到底卡在哪1.1 一个真实的管理后台场景先还原一下我遇到的需求。运营后台需要一个任务配置页面里面能看到所有已经配置好的定时任务并且能新增一个任务指定调用哪个 Bean 的哪个方法、传什么参数、用什么 Cron 表达式。启停一个任务活动结束了就停掉下次活动来了再启动不需要改动代码。删除一个任务这个任务以后再也不用了直接从系统里移除。修改执行周期Cron 从0 0 2 * * ?改成0 0/30 * * * ?立刻生效。这类需求在电商、资讯、营销类系统里太常见了。任务本身不复杂复杂的是任务配置变成了运行期数据而不是编译期代码。1.2 为什么第一反应写 Scheduled 会卡住很多人的第一反应是继续用Scheduled注解顶多把 Cron 表达式挪到配置文件里。但实际做下去就会撞上三堵墙第一注解在编译期就定死了。Scheduled(cron ${xxx.cron})这种写法只能在配置文件里改改完还得重启应用才能生效。用户要的是页面里改完立刻生效两者体验天差地别。第二一个任务对应一个方法方法的注册发生在 Spring 容器启动阶段运行期你没法往注解里追加一个新的任务方法。也就是说新增任务这个操作注解方案根本做不到。第三没有生命周期句柄。注解任务交给 Spring 的ScheduledAnnotationBeanPostProcessor管理应用代码拿不到对应的ScheduledFuture想 stop 一个任务只能靠改代码或者杀进程。所以结论很明确要做动态增删启停必须绕开注解直接面向调度器编程。1.3 选型判断为什么直接面向 TaskScheduler市面上确实有不少成熟方案最典型的是 xxl-job自带调度中心、动态管理、失败重试功能齐全。但引入 xxl-job 意味着要部署调度中心的额外服务对很多中小项目来说为一个运营要个开关的需求就上整套调度平台运维成本是偏高的。我的选择是继续用 Spring 自带的TaskScheduler自己维护任务注册。这套方案的优点很直接——零额外中间件、代码量可控、和 Spring 生态天然集成。如果你怕自己实现不够稳后面章节我还会专门讲多实例部署时怎么补分布式锁以及什么情况下才需要真的换 xxl-job。2. 先把底层机制捋清楚TaskScheduler 和 CronTrigger2.1 EnableScheduling 背后发生了什么很多教程让你加EnableScheduling就完事但它内部其实做了好几件事。它导入了一个配置类注册了ScheduledAnnotationBeanPostProcessor。这个后置处理器在 Bean 初始化完成后扫描所有带Scheduled的方法把它们包装成ScheduledTask再交给ScheduledTaskRegistrar统一注册。ScheduledTaskRegistrar可以理解成一个任务收纳盒它负责把任务交到真正的调度器TaskScheduler手里。关键在于如果我们不走注解而是自己写代码把 Runnable 直接交给TaskScheduler并且把返回值ScheduledFuture留下来那就拿到了任务的生命周期句柄——这就是动态的地基。2.2 TaskScheduler.schedule 方法才是核心入口TaskScheduler接口里有几个重载的schedule方法对我们最有用的一个签名是ScheduledFuture? schedule(Runnable task, Trigger trigger);第一个参数是任务本身第二个参数是触发器。Trigger是个接口最常见实现就是CronTrigger。每次任务执行完调度器都会调用trigger.nextExecutionTime(TriggerContext)来算下一次执行时间。所以只要给CronTrigger换一个表达式任务的执行节奏就变了。这也是为什么我说修改 Cron 不支持原地改必须重建任务——CronTrigger内部持有表达式字符串但 Spring 并没有提供修改已有调度计划的方法。更干净的做法是取消旧的ScheduledFuture用新表达式重新schedule一次新旧交接就完成了。2.3 默认调度器是一个大坑单线程这里必须重点提醒一个坑如果你没有显式定义TaskScheduler的 BeanScheduledTaskRegistrar在afterPropertiesSet时会用一个默认调度器它本质上是单线程的ScheduledThreadPoolExecutor。单线程意味着什么任何一个任务执行时间超过了调度间隔后续所有任务都会被堵住时间点全部延后。比如一个任务是0 * * * * ?每分钟跑一次但任务逻辑要跑 70 秒下一个任务就会立即进入排队迟到状态。更隐蔽的是这个默认调度器会由Scheduled注解任务和动态任务共用你动态加的任务越多线程竞争越明显。所以做动态调度第一步永远是定义一个业务自己的ThreadPoolTaskScheduler后面我会给出具体配置。3. 落地第一步任务配置表与线程池3.1 任务元数据表怎么设计动态任务的前提是任务定义可以持久化不然应用一重启所有动态配置就丢了。我用 MyBatis 做持久化表结构长这样CREATE TABLE scheduled_task ( id bigint(20) NOT NULL AUTO_INCREMENT, task_name varchar(64) NOT NULL COMMENT 任务名称, bean_name varchar(128) NOT NULL COMMENT Spring Bean 名称或全限定类名, method_name varchar(64) NOT NULL COMMENT 方法名, params varchar(500) DEFAULT NULL COMMENT 方法入参, JSON 字符串, cron_expression varchar(64) NOT NULL COMMENT Cron 表达式, status tinyint(4) NOT NULL DEFAULT 0 COMMENT 0-停用 1-启用, remark varchar(255) DEFAULT NULL COMMENT 备注, create_time datetime DEFAULT NULL, update_time datetime DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT动态定时任务配置表;设计上有个点值得多说一句bean_name我存的是 Spring 容器里的 Bean 名称而不是类全限定名。理由是任务 XML 或配置里经常有 AOP 代理按类名拿 Bean 容易拿到代理对象按 Bean 名走ApplicationContext.getBean()更符合 Spring 的使用习惯。实际实现的时候我留了一个兼容先按 bean_name 拿拿不到再按全限定类名拿。params统一存 JSON 字符串规定任务方法签名统一为void execute(String params)这样反射调用时参数传递最简单也最容易保证新增任务的通用性。3.2 线程池配置先从默认单线程里逃出来我自定义了一个ThreadPoolTaskScheduler作为动态任务的专属线程池Configuration public class SchedulerConfig { Bean(name dynamicTaskScheduler) public ThreadPoolTaskScheduler dynamicTaskScheduler() { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(8); scheduler.setThreadNamePrefix(dynamic-scheduler-); scheduler.setWaitForTasksToCompleteOnShutdown(true); scheduler.setAwaitTerminationSeconds(60); scheduler.setRemoveOnCancelPolicy(true); scheduler.setErrorHandler(t - log.error(动态任务执行异常, t)); scheduler.initialize(); return scheduler; } }几个参数的取舍我说明一下。setPoolSize(8)不是拍脑袋定的核心任务是不能因为某个任务阻塞把调度饿死。8 个线程对于常规的报表、推送、数据同步任务足够如果你的任务特别多或者任务里有大量阻塞 IO就往上加。setWaitForTasksToCompleteOnShutdown(true)配合setAwaitTerminationSeconds(60)保证应用停机时正在执行的任务最多再等 60 秒而不是被直接掐断。数据同步类任务非常需要这个否则容易写一半。setRemoveOnCancelPolicy(true)是我后来才加上的。默认情况下ScheduledThreadPoolExecutor取消一个还没开始执行的任务时任务仍然留在工作队列里可能导致后面cancel了还会空跑一次。这个参数设成true会从取消时从队列中移除清得更干净。3.3 任务执行体包一层 Runnable调度器只认Runnable所以我把任务定义和反射调用逻辑封装成一个内部 RunnableSlf4j public class ScheduledTaskRunnable implements Runnable { private final ScheduledTask task; private final ApplicationContext applicationContext; public ScheduledTaskRunnable(ScheduledTask task, ApplicationContext applicationContext) { this.task task; this.applicationContext applicationContext; } Override public void run() { long start System.currentTimeMillis(); try { Object bean applicationContext.getBean(task.getBeanName()); Method method bean.getClass().getMethod(task.getMethodName(), String.class); method.invoke(bean, task.getParams()); log.info(动态任务执行成功, taskId{}, taskName{}, cost{}ms, task.getId(), task.getTaskName(), System.currentTimeMillis() - start); } catch (Exception e) { log.error(动态任务执行失败, taskId{}, taskName{}, cost{}ms, task.getId(), task.getTaskName(), System.currentTimeMillis() - start, e); } } }这里有几个细节是经验之谈一是run()内部必须自己try-catch。虽然调度器对异常不是完全无感但如果你不接住异常要么被线程池的异常处理器接住要么被 Future 吞掉你很难拿到完整上下文。自己 catch 住并且把taskId、taskName、耗时打出来等于顺手把监控日志也做了。二是方法签名固定成(String)。getMethod如果遇到重载方法会因为参数类型匹配不上直接抛NoSuchMethodException所以我在任务规范里就统一约束避免这类隐性问题。三是记录cost。动态任务往往会越来越多没有一次执行耗时数据后面根本没法判断这个任务是不是该优化了。4. 核心代码DynamicTaskManager 的动态增删启停4.1 管理器的数据结构设计我把动态任务的管理逻辑收敛在一个DynamicTaskManager组件里。内部维护两张 MapComponent public class DynamicTaskManager { Resource(name dynamicTaskScheduler) private TaskScheduler taskScheduler; Resource private ScheduledTaskMapper taskMapper; Resource private ApplicationContext applicationContext; private final MapLong, ScheduledFuture? futureMap new ConcurrentHashMap(); private final MapLong, ScheduledTask runningTaskMap new ConcurrentHashMap(); // 以下方法见 4.2 - 4.4 }futureMap存每个任务对应的调度句柄runningTaskMap存当前正在运行的任务定义。为什么要两张 Map因为ScheduledFuture只能拿到是不是完成了是不是取消了这类信息拿不到任务本身的元数据。而在做修改 Cron停止操作时需要根据任务 ID 快速定位 future同时又要知道这个任务原来的 Bean、方法、参数——两张 Map 配合是这类管理器的标准做法。Map 都选ConcurrentHashMap因为任务启动恢复时可能多线程并发注册虽然我们的操作入口都在 Service 层但谁也不敢保证以后不会从多个地方同时调。4.2 新增任务与启动任务一个注册方法搞定新增和启动本质上都是把这个任务交给调度器执行public synchronized void registerTask(ScheduledTask task) { if (runningTaskMap.containsKey(task.getId())) { throw new BusinessException(任务已注册或正在运行: task.getId()); } ScheduledFuture? future taskScheduler.schedule( new ScheduledTaskRunnable(task, applicationContext), new CronTrigger(task.getCronExpression()) ); futureMap.put(task.getId(), future); runningTaskMap.put(task.getId(), task); }加synchronized是有意的。任务启停、修改会先 cancel 再注册如果两个并发请求同时对同一个任务操作可能出现先停的后停或旧 future 被新 future 覆盖但旧任务还在跑的混乱。既然操作频率低直接让单个 JVM 内串行化是最省心也最不容易出错的方案。新增任务落库的流程大概是这样先校验cronExpression能不能被CronExpression正常解析再校验 Bean 和 Method 是否真实存在然后插入数据库最后调用registerTask。注意顺序先落库再注册。如果先注册后落库失败内存里多出一个任务数据库却查不到重启后这个任务就凭空消失了状态不一致。有些团队会问能不能新增时不启动只保存配置当然可以。给status字段一个初始值0停用落库后不调用registerTask等到启动操作时把状态改为 1 再注册即可。4.3 停止任务future.cancel 的正确打开方式停止任务的核心是ScheduledFuture.cancel()但用法上有讲究public synchronized void stopTask(Long taskId) { ScheduledFuture? future futureMap.get(taskId); if (future ! null) { future.cancel(false); futureMap.remove(taskId); runningTaskMap.remove(taskId); } }我特意说用法上有讲究因为cancel有个参数mayInterruptIfRunning。传true表示如果任务正在执行就中断它的线程。听起来很直接但对业务任务来说这是危险的任务可能正在写数据库、正在调用第三方接口强行中断会导致事务回滚不完整、数据状态中断。除非你确定任务内部对中断做了处理否则不要传true。传false表示如果任务正在执行让它自然跑完以后不要再调度了。这个语义和业务上的停用完全一致我停的是你下次别再跑不是把你正在跑的掐死。取消之后CronTrigger对应的任务不会再触发但会立刻执行的 in-flight 任务仍然会跑完。如果你的停用需求连正在跑的也要干掉都必须满足那说明任务本身应该有幂等兜底甚至应该走 xxl-job 那种带强制终止能力的框架而不是靠普通线程取消。4.4 删除任务先停再删不要反过来删除任务比停止多一步数据库里也要删掉。流程是public synchronized void deleteTask(Long taskId) { stopTask(taskId); taskMapper.deleteById(taskId); }先停内存再删数据库。这个顺序的理由和注册时先落库再注册是对称的如果你先删数据库再停内存中间有一个窗口期任务还在内存里定时跑但后台已经看不到这条配置了出问题都没法查。删除操作还有一个边界情况要处理任务正在执行中删除了怎么办按上述流程future.cancel(false)不会打断正在执行的那次而那次执行结束后的下一个调度点才会真正消失。也就是说删除后当前这一次可能还会把日志打出来。如果你连这次也不希望发生就需要在ScheduledTaskRunnable.run()开头再检查一次runningTaskMap.containsKey(task.getId())不在就直接 return。这是一个很实用的双保险特别适合对执行时机敏感的任务。4.5 修改 Cron重建比修改更安全上面说了CronTrigger无法原地改表达式所以修改的流程就是停掉旧的 用新表达式注册新的public synchronized void updateCron(Long taskId, String newCron) { ScheduledTask task runningTaskMap.get(taskId); if (task null) { throw new BusinessException(任务未运行或不存在: taskId); } stopTask(taskId); ScheduledTask dbTask taskMapper.selectById(taskId); dbTask.setCronExpression(newCron); taskMapper.updateById(dbTask); dbTask.setStatus(1); registerTask(dbTask); }这里要补一个容易被忽略的坑stopTask清掉了runningTaskMap但是数据库里的status如果还保持启用你就得在重新注册前把status继续设为 1。我上面的代码里dbTask从库里查出来之后没有动status也就是说如果它在库里本来就是 1那没问题但如果你之前停用过它更新后再注册就必须手动把status置 1。别小看这一行状态字段错位是我见过最多的低级故障来源。整个修改操作本质上是删 增的组合。如果你未来要支持修改参数或修改 Bean 方法复制这套停旧建新的思路就行不需要新的机制。5. 重启恢复、状态一致性、多实例容灾5.1 应用重启后任务自动恢复动态配置存在数据库里那应用重启后谁负责把启用中的任务重新注册到调度器答案是在应用启动完成后做一次恢复扫描。我用ApplicationRunner实现Component RequiredArgsConstructor public class TaskRecoveryRunner implements ApplicationRunner { private final DynamicTaskManager taskManager; private final ScheduledTaskMapper taskMapper; Override public void run(ApplicationArguments args) { ListScheduledTask enabledTasks taskMapper.selectByStatus(1); for (ScheduledTask task : enabledTasks) { try { taskManager.registerTask(task); } catch (Exception e) { log.error(任务恢复失败, taskId{}, bean{}, method{}, task.getId(), task.getBeanName(), task.getMethodName(), e); } } log.info(动态任务恢复完成, 共恢复 {} 个任务, enabledTasks.size()); } }注意两点一是放在ApplicationRunner而不是PostConstruct因为前者在 Spring 容器完全初始化之后执行Bean 全部可用反射调用前不用再等依赖二是恢复单个任务失败不能影响其他任务所以每个任务都要自己 try-catch。恢复失败的场景很常见比如某个 Bean 被删了、方法名改了如果单个失败直接抛异常后面的任务全都不恢复下一次你重启才能发现。5.2 数据库状态与内存状态的一致性动态任务系统有两份状态数据库表里的status内存 Map 里的 future。这两份状态天然存在时间差处理不好就会出现数据库显示启用实际没在跑或者反过来。我的建议是建立一条铁律所有变更都从唯一的 Service 入口走不许任何地方直接改数据库里的status。新增、启动、停止、删除、修改 Cron全部经过DynamicTaskManager的方法由这个方法先改内存再改数据库或者反过来但每一步都是原子顺序执行、有日志可查。实际操作中我还加过一个更保险的手段定期对账。写一个每 5 分钟跑一次的巡检任务查询数据库中启用中的任务列表和内存runningTaskMap做对比发现库里有、内存没有就自动补注册库里没有、内存有就自动停止。这个巡检本质上是把状态漂移兜底住了特别是那种有人手动改了数据库、或者恢复失败之后留下的脏状态都能被它收敛回来。5.3 多实例部署动态任务的双刃剑上面所有方案默认的是单实例部署。一旦你在两台机器上部署同一个应用问题立刻出现两个节点的动态任务都会注册同一个任务会被执行两次。这可能是动态定时任务最大的坑。解决思路分三个档次第一个档次任务内部做幂等。比如任务本质是生成报表写库时用唯一键去重任务本质是发短信发之前查一下发送记录。这是最朴素的方案适合任务少、冲突概率低、对重复执行容忍度尚可的场景。第二个档次引入分布式锁。轻量方案可以用 ShedLock它基于数据库表或者 Redis 给任务加锁锁的粒度是任务 ID 执行时间。实现思路是任务真正执行前尝试获取锁拿不到锁就跳过。ShedLock 的侵入很小基本就是在run()外面包一层锁逻辑。第三个档次直接换 xxl-job 这类分布式调度框架。它天然解决某个任务在多个节点中只让一个节点执行的问题还自带调度日志、失败重试、动态启停。当你的任务数量上升到几十上百团队也不缺运维精力时换框架往往比自己补丁更省心。我的看法是如果你们项目还是单体部署先把本文这套方案用起来运维体验已经优于注解方案一旦确定要上多实例就尽早评估 ShedLock。不要等到任务重复执行造成线上事故了才想起来补。6. 踩坑实录与运维监控6.1 线程池被阻塞任务耗尽这是我实际踩过的一个生产事故。当时我给运营加了动态添加任务的功能运营很开心一口气配了十几个每分钟同步一次的任务。结果第二天发现所有任务都不准点了一看日志线程池里 8 个线程全部卡在一个第三方接口调用上——那个接口超时设置是 60 秒而任务间隔是每分钟一次接口一堵整个池子就满了。这事的根源不是线程池太小而是任务里用了同步阻塞调用且超时时间过长。我给的任务规范里加了两条铁律一是在线任务逻辑里禁止同步调用外部接口必须走异步或加线程池隔离二是所有外部调用的超时时间必须显式设置不允许默认无限等待。同时我把ThreadPoolTaskScheduler的线程池和业务执行线程池拆开了调度线程只管触发真正干活丢给另外一个独立的业务线程池。这样哪怕业务线程池被打满调度线程依然能准点触发任务不至于整体瘫痪。6.2 修改配置不生效缓存惹的祸有次用户反馈后台改了 Cron 后任务还是老时间跑。我查了半天问题出在页面回显后台列表展示的 Cron 是从runningTaskMap里读的而runningTaskMap里的对象是之前注册时缓存的副本更新时我只更新了数据库里的新记录没有同步更新runningTaskMap里的对象引用。结果就是调度确实用的是新 Cron列表展示的却是旧 Cron用户以为没生效。这类 bug 的教训是任务元数据如果要被展示一定要走统一的查询来源不能内存一份、数据库一份各查各的。我在DynamicTaskManager里加了一个refreshCache(Long taskId)方法所有变更操作完成后强制刷新缓存对象同时列表接口改成优先查数据库。从那以后这类看起来没生效的问题再没出现过。6.3 监控不能只靠日志动态任务数量一旦超过 10 个靠人肉看日志排查就不现实了。我给任务管理器加了一组运行时指标每个任务的上次执行时间、上次执行结果、累计执行次数、累计失败次数、最近一次异常堆栈。这些指标维护在一张内存表中任务每次执行完更新对应字段。对外暴露我选了两条路一条是给管理端页面加一个任务监控页直接查这张指标表能看到每个任务最近一次跑了多久、成没成功。运营看到的是业务化表达不涉及底层细节。另一条是接入 Spring Boot Admin。把指标表的数据定期写入 Actuator 的自定义端点这样在 Spring Boot Admin 的界面里就能直接看到每个动态任务的心跳和健康状态。任务挂了、连续失败 N 次还可以再叠加告警通知。监控这层别省动态任务意味着随时可能被人加出问题来有监控兜底凌晨三点被叫起来的是告警而不是用户投诉。6.4 关于暂停与恢复的一点补充可能有人会问标题里的启停做到了那暂停/恢复呢这两个语义在实际运营中经常被混淆但落到技术上完全不同。停止是未来不再触发对应future.cancel(false)。暂停是保留当前配置和调度计划只是暂时跳过执行。要实现暂停我建议用volatile标志位在ScheduledTaskRunnable里加一个volatile boolean pausedrun()开头判断paused就 return。这样暂停时不会销毁 future恢复时只要把标志位翻转回去任务的调度节奏完全不受影响也不会因为 cancel 再注册而丢失下一次执行时间。我个人在做动态启停时一直是把停止和暂停分开实现的。为什么因为运营经常改了又改如果用 cancel/reschedule 实现暂停对象换来换去容易出现泄漏而标志位暂停实现简单还能保留停了多少次、恢复时本来该几点跑这些信息。建议你把这两套都支持后台交互上给用户清晰地区分停用和暂停而不是一个按钮干所有事。最后再分享一个扩展思路这套动态任务管理器做完之后可以进一步把执行历史落库做成一个任务执行流水表这样以后不管出了什么问题都能回溯这个任务每次实际在哪个节点、几点跑的、花了多久、结果如何。有条件的话配合消息队列把任务执行轨迹发出去就能轻松接到日志平台做全链路分析。定时任务的动态化只是第一步让它可观测、可审计才是真正能长期交付出价值的形态。
返回列表