ARTICLE DETAIL

资讯详情

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

Spring TaskScheduler动态定时任务实战:从@Scheduled到灵活调度

Spring TaskScheduler动态定时任务实战:从@Scheduled到灵活调度 之前做运营后台的时候接了一个需求运营要在页面上配置定时推送任务开始时间、重复规则随时改保存就要生效。我第一反应和很多读者一样Spring不是有Scheduled注解吗拿过来用不就行了。真动手才发现注解里的cron是编译期写死在源码里的用户动态配置的时间怎么塞进注解后来翻Spring文档才注意到TaskScheduler这个接口才意识到Spring早把编程式定时任务的能力准备好了只是平时我们几乎不碰它。这篇文章就围绕TaskScheduler聊聊它和Scheduled的本质区别、底层调度机制、以及怎么用它实现一个动态任务注册中心。适合两类人看一类是被静态定时任务逼疯、想实现运行时可增删改定时任务的开发者另一类是准备Spring面试、想搞懂Scheduled背后执行链路的人。读完你至少能直接写出一套可复用的动态任务管理器。1. 为什么Scheduled解决不了的动态调度场景必须交给TaskScheduler1.1 一个来自运营后台的真实需求先还原一下我遇到的需求细节。运营需要在后台配置营销推送任务一个任务包含这些字段任务名称、首次执行时间、重复规则每天、每周、自定义cron、启停状态。运营保存配置后任务就应该立刻按新规则跑起来修改cron后下次触发就要按新时间执行停用后任务不能再触发。这个需求如果用Scheduled来做每一步都是坑。注解里的cron属性是编译期常量不可能从数据库里读。你说那我用SpEL表达式动态生成扫码之后会发现SpEL的值在容器启动时就固定了改库里的配置不影响已经注册的定时任务。你想那我重启应用读取新配置那运营改一个时间就要重启一次服务这需求根本没法接。真正合理的做法是应用启动时从库里读取所有启用的任务定义用代码把它们注册到调度器里运营修改配置后程序拿到新的cron表达式取消旧的调度注册新的调度。这就是编程式注册和声明式注解的本质区别——前者把任务的增删改查变成了普通的Java操作后者把任务写死在容器启动阶段。1.2 声明式注解的三堵墙很多人没意识到Scheduled有三个天然限制。第一堵墙是编译期写死这个上面已经说了。第二堵墙是任务无法被外部取消。即便你写了一个每5秒执行的注解方法应用运行期间没有任何API能让你把它停下来。第三堵墙是任务生命周期无法与业务状态联动。举个例子一个秒杀活动开始了才需要启动库存同步任务活动结束就要自动停掉。用注解实现的话你得在任务方法里每5秒去查一次活动是否还在浪费资源还逻辑别扭。而编程式调度可以做到活动开始时schedule一个任务活动结束时cancel这个任务干净利落。还有一类场景是任务不是全局唯一的而是要按业务维度拆分。比如每个商户都有自己的对账任务商户A配的是每天凌晨2点商户B配的是每小时跑一次。这种任务的定义是海量且动态的场景注解根本没法建模写一万个带不同cron的方法是不现实的。1.3 TaskScheduler在Spring定时任务体系中的真实身份要理解TaskScheduler的定位先看Scheduled背后的执行链路。EnableScheduling开启定时任务后Spring会注册一个ScheduledAnnotationBeanPostProcessor它在Bean初始化阶段扫描每个方法上的Scheduled注解把注解方法包装成ScheduledMethodRunnable注册进ScheduledTaskRegistrar。ScheduledTaskRegistrar在容器启动时会去容器里找TaskScheduler类型的Bean或ScheduledExecutorService找不到就用一个ThreadPoolTaskScheduler的默认实例最后调用taskScheduler.schedule(runnable, trigger)把任务真正丢给调度器。也就是说Scheduled只是最外面那一层壳负责把某个方法应该定时执行这个声明翻译成调度请求。真正干活的、决定任务什么时候跑、怎么循环、怎么并发处理的是TaskScheduler。它是整个Spring定时任务体系的执行引擎。搞懂这一点面试时候别人问你Scheduled是怎么工作的你就知道不能只答注解语法而要从ScheduledAnnotationBeanPostProcessor和ScheduledTaskRegistrar这条链路往下拆。下面简单对比一下两个模式的差异对比维度ScheduledTaskScheduler任务定义时机编译期写死运行期动态注册触发规则修改改代码重启调API即可任务取消不支持ScheduledFuture.cancel()适合场景固定的运维/清理任务用户可配置、按业务数据动态变化的任务灵活度低高2. ThreadPoolTaskScheduler的底层原理Spring只是给JDK调度器包了一层壳2.1 接口、实现类与初始化链路TaskScheduler是Spring 3.0引入的接口定义了一组schedule方法。核心接口方法有四个维度指定时间执行、固定速率执行、固定延迟执行、触发器执行。日常使用中ThreadPoolTaskScheduler是最重要的实现类它继承自ExecutorConfigurationSupport在afterPropertiesSet()回调里会initializeExecutor()创建一个JDK自带的ScheduledThreadPoolExecutor实例。很多人看到这里会问Spring搞这么复杂不就是包了一个线程池吗对确实是这样。但Spring做的事情是很有价值的它把JDK的ScheduledThreadPoolExecutor通过ExecutorConfigurationSupport的生命周期纳入了Spring容器管理可以设置线程名前缀、拒绝策略、优雅停机等待时间、错误处理器还提供了Trigger抽象让调度策略的编写从使用原始API上升为声明式定义下一次执行时间。这些能力如果直接用JDK API来做代码会散落在各个业务类里维护成本很高。另外TaskScheduler接口还有一个ConcurrentTaskScheduler实现它是TaskExecutor的适配器如果你想复用已有的ExecutorService可以走这个实现。但对大多数场景ThreadPoolTaskScheduler都是最好用的选择因为它自带schedule系列方法和生命周期钩子。2.2 Trigger接口调度策略的灵魂TaskScheduler几个schedule重载方法里最值得研究的是scheduler.schedule(Runnable task, Trigger trigger)。这也是我前面说Scheduled只是壳的核心论据——Scheduled的cron参数最终会被转换成一个CronTrigger而CronTrigger正是Trigger接口的默认实现。Trigger接口只有一个方法Instant nextExecutionTime(TriggerContext triggerContext)。TriggerContext封装了三个时间点上一次计划执行时间、上一次实际执行时间、上一次完成时间。调度器每次触发完一个任务就会拿着这些时间信息再问一次Trigger下一次什么时候如果你的回答返回一个未来时间调度器就把任务安排在那一刻如果返回null任务就结束。这个设计妙在把什么时候执行从怎么执行里完全解耦了。你可以自己实现一个Trigger比如工作日9点到17点之间每30分钟一次错过就顺延到下一个工作日这种复杂规则只要在nextExecutionTime方法里写判断逻辑就行。Spring内置的两个TriggerCronTrigger负责cron表达式解析PeriodicTrigger负责固定周期规则。2.3 ReschedulingRunnable任务为何能无限循环这里有个值得展开的细节ThreadPoolTaskScheduler.schedule(task, trigger)返回的是一个ReschedulingRunnable。这个Runnable很聪明它本身实现了Runnable被交给JDK调度器后在run()方法里先执行真正的task.run()执行完成后再调用trigger.nextExecutionTime(context)拿到下一个执行时间然后把自己重新schedule到调度器上。也就是说一个Trigger任务不是一次性丢给线程池就不管了而是执行完 重新注册形成了一条自我再生的执行链。这个机制带来的实际影响是Trigger任务的注册是递归式的每一次执行都发生在上一个任务实际完成之后所以CronTrigger天然不会出现并发重叠——除非你手动在任务里开另外的线程。但是scheduleAtFixedRate不一样它是用JDK底层的scheduleAtFixedRate语义基于绝对时间点触发不会等上一个任务完成。这个差异是很多人踩坑的根源后面避坑章节会细说。2.4 fixedRate与fixedDelay的取舍scheduleAtFixedRate和scheduleWithFixedDelay是最容易被混淆的两个方法。我用一张表给出它们的语义区别调度方式触发逻辑并发风险适合场景scheduleAtFixedRate以固定周期持续触发不管上一个任务是否完成高任务执行超时会堆积数据补偿、状态同步允许任务间有间隙且能容忍并发执行scheduleWithFixedDelay上一个任务完成后才开始计时再延迟固定时间执行下一次低天然不会并发重叠批量处理、文件清理对资源占用敏感的任务实际使用上我绝大多数场景会用scheduleWithFixedDelay因为它保证了同一任务的串行性。用scheduleAtFixedRate时要注意执行时间一个任务跑3秒周期设2秒很快队列里就会积压大量未执行的任务稍后逐一执行时全是过期的这通常不是你想要的。3. 动态任务注册中心实战代码跑起来3.1 需求与设计回到最开始的运营后台需求我用ThreadPoolTaskScheduler实现了一个轻量的动态任务注册中心。核心设计如下任务定义存储在数据库表里字段包括task_id、task_name、cron_expression、handler_bean_name、enabled应用启动时把enabledtrue的任务全部加载注册。一个TaskManager组件负责统一调度注册新任务、取消旧任务、按新的cron重新注册。任务执行逻辑通过一个handlerName映射到Spring容器里的不同处理器Bean避免taskId直接和某个类绑定死。这种设计的价值在于运营改配置只是更新数据库的一行记录程序收到变更事件后调用TaskManager.modifyTaskCron()即可全程不需要动代码。3.2 ThreadPoolTaskScheduler配置先说线程池配置。我强烈建议你不管用不用Scheduled都显式声明一个ThreadPoolTaskScheduler的Bean而不是依赖Spring默认创建的单线程调度器。下面是我项目里的标准配置Configuration public class TaskSchedulerConfig { Bean(name taskScheduler) public ThreadPoolTaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(8); scheduler.setThreadNamePrefix(scheduled-task-); scheduler.setWaitForTasksToCompleteOnShutdown(true); scheduler.setAwaitTerminationSeconds(60); scheduler.setRemoveOnCancelPolicy(true); scheduler.setErrorHandler(t - { // 记录任务执行过程中的未捕获异常避免异常被吞掉 System.err.println(Scheduled task error: t.getMessage()); }); scheduler.initialize(); return scheduler; } }各参数的解释poolSize8是给不同任务准备的工作线程数我记得有个线上服务就是因为池太小一个任务阻塞导致其他任务全部排队。waitForTasksToCompleteOnShutdown和awaitTerminationSeconds配合使用保证容器关闭时正在执行的任务能跑完超过60秒再强制关闭。removeOnCancelPolicy设置为true后取消的任务会从工作队列移除防止取消的任务占用队列空间这个在任务频繁增减的场景下很管用。3.3 TaskManager核心代码首先定义一个简单的任务定义类public class TaskDefinition { private String taskId; private String taskName; private String cronExpression; private String handlerName; private boolean enabled; // getter/setter略 }然后是TaskManager组件Component public class TaskManager { private final ThreadPoolTaskScheduler taskScheduler; private final MapString, ScheduledFuture? futureMap new ConcurrentHashMap(); private final MapString, TaskDefinition taskMap new ConcurrentHashMap(); private final ApplicationContext applicationContext; public TaskManager(ThreadPoolTaskScheduler taskScheduler, ApplicationContext applicationContext) { this.taskScheduler taskScheduler; this.applicationContext applicationContext; } /** * 注册一个任务。如果已存在同taskId会先取消旧的再注册新的。 */ public synchronized void registerTask(TaskDefinition definition) { cancelTask(definition.getTaskId()); if (!definition.isEnabled()) { return; } // 根据handlerName获取真正的任务执行器 Runnable task (Runnable) applicationContext.getBean(definition.getHandlerName()); ScheduledFuture? future taskScheduler.schedule(task, new CronTrigger(definition.getCronExpression())); futureMap.put(definition.getTaskId(), future); taskMap.put(definition.getTaskId(), definition); } /** * 取消指定任务。 */ public synchronized boolean cancelTask(String taskId) { ScheduledFuture? future futureMap.remove(taskId); taskMap.remove(taskId); if (future ! null) { return future.cancel(false); } return false; } /** * 修改cron表达式并重新注册。 */ public void modifyTaskCron(String taskId, String newCron) { TaskDefinition definition taskMap.get(taskId); if (definition null) { throw new IllegalArgumentException(Task not found: taskId); } definition.setCronExpression(newCron); registerTask(definition); } }这里有几个细节要说明。第一registerTask和cancelTask都加上了synchronized因为运营修改配置可能并发触发如果两个线程同时替换同一个taskId不加锁会注册出两个重复任务。第二ScheduledFuture.cancel(false)的语义是停止后续的调度但不中断正在执行的那一次任务。大多数业务场景都希望当前正在跑的这轮任务跑完所以false比true稳妥得多——true会尝试中断线程处理不好的话可能把同线程的其他任务也坑了。第三getBean(handlerName)把任务执行器做成Spring Bean天然可以获得依赖注入能力。3.4 修改与取消任务的关键操作动态任务管理最核心的操作就三个注册、取消、修改。注册对应schedule(task, trigger)取消对应future.cancel(false)修改则是先取消再注册。这里有一个容易被忽略的坑修改任务时如果新cron和旧cron一样就没必要执行取消再注册可以做个短路判断避免无意义地销毁重建ScheduledFuture。另一个容易忽略的是容器销毁时的清理。虽然我配置了waitForTasksToCompleteOnShutdown但动态注册的ScheduledFuture不在ThreadPoolTaskScheduler的静态任务列表里最好在PreDestroy方法里遍历futureMap逐个cancel(false)再清空Map防止应用重启时残留任务引用导致内存泄漏。PreDestroy public void destroy() { futureMap.values().forEach(future - future.cancel(false)); futureMap.clear(); taskMap.clear(); }4. 生产环境避坑指南这些坑我基本都踩过4.1 默认单线程池一个长任务饿死全场这是定时任务最常见的隐形炸弹。前面提过如果你只是加EnableScheduling然后用ScheduledSpring Boot在没有找到TaskSchedulerBean的情况下会默认创建一个单线程的调度器。这意味着所有Scheduled任务共享一条线程。我有个朋友的项目就遇到过一模一样的事故一个数据同步任务每天凌晨跑正常情况跑5秒结束某天第三方接口卡住了任务卡了40分钟结果同线程上的其他整点任务全部堵塞延迟。表面现象就是每个任务都错乱地挤在一起执行。排查了半个小时才想起去数线程池大小。解决方案非常简单要么像我上面那样显式定义一个ThreadPoolTaskSchedulerBean要么在Spring Boot的配置文件中加一行spring: task: scheduling: pool: size: 8 thread-name-prefix: scheduled-task-注意这个配置只对Boot自动配置的调度器生效如果你自己定义了ThreadPoolTaskSchedulerBean它的优先级更高。4.2 Misfire错过的任务不是被跳过而是被补跑这个坑非常隐蔽。CronTrigger的调度是基于时间点的如果应用因为Full GC、系统休眠、服务假死等原因错过了某个触发点恢复之后CronTrigger会基于当前时间立即触发一次把漏跑的那次补上然后再按cron继续。scheduleAtFixedRate也是类似的逻辑。这在业务上可能造成意外。比如你有一个每小时整点跑的数据汇总任务某次执行因为网络问题卡了1小时恢复后它不会等到下一个整点再跑而是立刻再跑一次。如果任务不是幂等的就可能重复写入数据。scheduleWithFixedDelay就没有这个问题因为它本来就不依赖绝对时间点而是上一个任务完成后才开始计时。所以如果业务对执行时间点不敏感只是要求每隔一段固定时间执行一次无脑选fixedDelay即可如果必须对齐绝对时间点比如每天凌晨0点那就必须保证任务幂等。4.3 优雅停机与线程池关闭生产环境的容器升级、滚动发布经常面临这样一个问题重启应用时正在执行的定时任务被硬切导致数据写到一半。ThreadPoolTaskScheduler提供了两个配置setWaitForTasksToCompleteOnShutdown(true)让容器关闭时先停止接收新任务然后等待正在执行的任务跑完setAwaitTerminationSeconds(60)规定最多等60秒超时就强制关闭。需要强调的是这两个配置不是万能的。如果你在任务方法内部自己开了子线程去做异步操作ThreadPoolTaskScheduler是感知不到的它只会等主任务方法的返回。因此写任务逻辑时尽量保证所有异步操作都同步化或者使用独立的TaskExecutor并配合关闭钩子统一管理。4.4 分布式部署前的提醒最后必须泼一盆冷水ThreadPoolTaskScheduler是进程内的调度器它不知道别的节点也在跑同样的任务。如果服务部署了多个节点同一个cron任务会每个节点都执行一次这个在支付对账、库存扣减这类场景下是致命的。如果你只是单机部署或者多节点部署但任务本身是幂等的比如从数据源拉取数据写入本地缓存那本文这套方案完全够用。如果要处理分布式一致性要么在任务执行前用分布式锁抢锁比如Redis的SETNX要么引入ShedLock这种轻量级锁库要么干脆上xxl-job这类专业分布式调度平台。了解了TaskScheduler的边界你就知道什么时候该换船什么场景下小船继续开着就行。个人用下来的体会是TaskScheduler是Spring里最被低估的组件之一。它不依赖Quartz那种重型框架也没有Scheduled那种静态绑定而是把定时任务变成了一组可以用代码随时操作的注册项。如果你正被用户可配置定时任务折磨按上面的方案搭一套动态注册中心后续想扩展成可视化控制台也只是顺手的事。
返回列表