ARTICLE DETAIL

资讯详情

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

Spring Boot动态定时任务实现:增删启停与调度管理实战

Spring Boot动态定时任务实现:增删启停与调度管理实战 1. 动态定时任务的整体设计与思路拆解1.1 传统Scheduled的局限与动态调度的真实需求提到Spring Boot里的定时任务绝大多数开发者的第一反应就是Scheduled注解加一个cron表达式。确实靠注解开发定时任务代码量最小几个字段一写Spring容器启动之后自然会把方法纳入调度框架每天凌晨几点跑一次完全不用人管。这种方案应付固定场景完全够用但一旦业务需求开始变得“动态化”痛点就会立刻暴露。举个例子。我有一个项目运营后台需要给不同渠道配置不同的数据推送时间今天三个任务明天可能变成五个而且运营要求当天改完当天生效、不能重启服务。这种需求用Scheduled根本没法做——注解是编译期就写死的你不可能让运营改一条数据库记录就触发Spring去重新扫描注解。再比如一套多租户系统租户A创建的临时清理任务租户B不想要了必须后台能实时停止。面对这些场景我们需要的是“运行期生效”的调度能力而不是“启动期写死”的调度能力。把需求拆解一下“动态增删启停”实际上包含四类操作新增注册一个定时任务、移除一个已存在的任务、启动/暂停一个任务的执行、修改已注册任务的执行规则而无需重启应用。这些操作不仅是改内存里的配置还要求立竿见影地对调度器产生影响。再往下拆我们还需要考虑任务定义从哪来、任务执行状态如何跟踪、并发执行怎么控制、服务重启后任务是否要恢复等一系列细节。那具体怎么实现基于Spring Boot自带的调度能力我们可以通过实现SchedulingConfigurer接口并重写configureTasks方法配合ThreadPoolTaskScheduler和ScheduledTaskRegistrar做到运行时动态注册任务。同时用Map结构维护任务标识与ScheduledTask的映射关系通过ScheduledTask的cancel方法实现停止用ThreadPoolTaskScheduler的schedule方法实现按Cron表达式动态创建调度。这一整套组合就是Spring Boot原生调度体系下的轻量级“动态增删启停”方案。1.2 为什么选Spring原生调度而不直接上Quartz或分布式调度框架很多朋友看到“动态调度”四个字第一反应是引入Quartz或者直接上XXL-JOB、ElasticJob这类分布式调度平台。这个思路没错但要分场景。如果你所在的系统本身就是分布式集群有多台机器同时跑任务需要统一的任务管理面板、故障转移、分片处理那直接上成熟的分布式调度框架是正解没必要自己造轮子。但如果你的系统就是单机部署或者虽然微服务化了但定时任务本身只允许在某一台实例上执行引入一套重的调度框架反而会带来额外的维护成本和学习成本。我自己在这个项目里的技术选型原则是能用Spring原生能力解决的问题绝不引入额外的框架依赖。原因有三条。第一Spring Boot的调度体系已经足够强大——ThreadPoolTaskScheduler底层封装了ScheduledThreadPoolExecutor支持cron、fixedDelay、fixedRate等多种触发策略稳定性经过大规模验证日常单机任务完全够用。第二引入Quartz意味着要管理Job、JobDetail、Trigger、Scheduler等一系列概念还要操心线程池配置、持久化存储、并发策略对于“只是想让任务可以动态管理”这个需求来说复杂度是过量的。第三分布式调度框架通常需要一个中心化的调度服务部署、运维、网络依赖都会成为额外的故障点单机场景下完全没必要。所以这个项目最终采用的是“Spring原生调度 自研动态管理器”的组合。简单说SchedulingConfigurer负责拿到底层调度器的注册入口ThreadPoolTaskScheduler负责真正的线程调度我们自己写一个DynamicTaskManager来统一管理每个任务的注册、取消、状态查询再通过Controller暴露一套REST接口给前端操作。这种方案的优点非常明显零额外依赖、原理完全可控、代码量适中而且在理解Spring调度机制本身的同时能顺带把ScheduledTask的整个生命周期吃透。2. 核心细节解析与实操要点2.1 必须搞懂的三个底层核心组件动手写代码之前我建议先把Spring定时任务底层的三个核心类搞清楚否则动态管理写出来的代码很容易“能用但不懂为什么能”。第一个是TaskScheduler顶层接口定义了对Runnable的调度方法包括schedule(Runnable, CronTrigger)、scheduleAtFixedRate、scheduleWithFixedDelay等。它只负责“把任务扔给调度器”自身不维护任务状态。第二个是ThreadPoolTaskScheduler这是TaskScheduler的Spring封装实现核心组合了java.util.concurrent.ScheduledThreadPoolExecutor可以通过setPoolSize配置线程池大小通过setThreadNamePrefix配置线程名前缀。要注意一点ThreadPoolTaskScheduler是Spring Boot自动配置中默认的调度执行器但如果我们自定义了任务注册逻辑最好还是手动new一个并显式初始化避免和自动配置产生混淆。第三个是ScheduledTaskRegistrar这是整个动态注册机制的关键入口。它内部维护了多个任务集合通过addCronTask(Runnable, String)、addFixedRateTask、addFixedDelayTask等方法将任务注册到底层的TaskScheduler中。SchedulingConfigurer接口的configureTasks方法参数就是这个registrarSpring容器启动时会回调这个方法让我们有机会把初始化任务塞进调度器。这三个组件的关系我用一个生活化的类比来解释ThreadPoolTaskScheduler是“医院护士台”负责接收病人的就诊预约并按时间排号ScheduledTask是“具体的挂号单”上面写着病人信息和预约时间ScheduledTaskRegistrar是“挂号登记簿”把所有挂号单汇集在一起交给护士台。我们要动态操作任务本质上就是往登记簿上加挂号单或者撕掉挂号单护士台会根据登记簿变化实时调整排班。2.2 动态注册的底层原理从ScheduledFuture到ScheduledTask再往深挖一层。ThreadPoolTaskScheduler的schedule方法执行后会返回一个ScheduledFuture对象这个对象代表了调度任务在JDK线程池中的句柄。ScheduledTaskRegistrar内部会把ScheduledFuture包装成ScheduledTask进行管理。ScheduledTask是一个包装类内部持有Task封装了Runnable和触发规则以及对应的ScheduledFuture。动态停止一个任务的本质就是拿到这个任务对应的ScheduledFuture调用它的cancel(boolean mayInterruptIfRunning)方法。cancel后任务不会再被调度器触发下一次执行但需要注意如果任务此刻正在运行mayInterruptIfRunning传true只会给线程设置中断标记并不能强制终止正在运行的业务代码——业务代码里需要自己判断线程中断状态才能配合停止。这是Java并发的基础常识很多人在动态停任务时踩坑以为cancel后任务立刻就能杀掉结果业务代码还在后台默默执行。Spring在5.x版本之后对ScheduledTaskRegistrar内部的管理做了一些调整整体思路是用setScheduler传入TaskScheduler然后通过scheduleCronTask、scheduleFixedRateTask等方法将任务注册到scheduler中同时把返回的ScheduledTask放入内部的Set集合。我们在做动态管理时自己维护的Map实际上就是对这个内部集合的“影子副本”通过它才能在运行期精确地找到某个任务并取消它。2.3 关键设计线程池参数到底怎么定ThreadPoolTaskScheduler的线程池大小设置是个容易被忽视的坑。如果直接用默认配置Spring Boot在未自定义时会使用单线程调度器也就是说同一时刻只有一个任务在执行前面的任务如果执行时间很长后续任务就会被阻塞出现“本该10点跑的任务11点才跑”的诡异现象。因此在我这个项目的初始化代码里线程池大小我显式配置了10这个数字不是拍脑袋定的而是根据任务类型估算的系统内大多数任务执行时长在10秒以内同一时刻并发跑的任务峰值大概5到6个留一点余量到10是安全的。线程池大小的估算公式可以简单套用“CPU密集型任务用N1、IO密集任务用2N”的经验值但定时任务有个特殊性——它不是持续占满线程的请求处理而是周期性触发所以峰值并发数才是关键指标更准确的思路是“统计所有任务中执行时间最长的那一类乘上最坏情况下同时触发的任务数量再留30%余量”。如果你的任务里有那种可能执行几分钟的大任务建议单独拆一个调度线程池给它避免拖累其他小任务。我后期就把“日报生成”这类大任务单独建了一个调度器和普通动态任务隔离互相不干扰。3. 实操过程与核心环节实现3.1 任务模型定义动态任务需要哪些必要字段既然要动态管理任务就必须先定义清楚一个可注册任务的“数据模型”。这个模型要能描述“什么时间执行什么逻辑”还要能支撑后续的状态管理和规则修改。以下是我项目中使用的JobDefinition类字段不多但都是刚需。public class JobDefinition { /** 任务唯一标识用于动态增删时定位任务 */ private String jobId; /** 任务名称用于展示与日志输出 */ private String jobName; /** Cron表达式决定任务触发时机 */ private String cronExpression; /** 任务要执行的具体业务逻辑标识对应JobProcessor实现类的beanName */ private String processorBeanName; /** 任务状态RUNNING / PAUSED */ private String status; /** 任务描述 */ private String description; /** 参数上下文业务逻辑执行时可从该Map中读取自定义配置 */ private MapString, Object params; // getter/setter 省略 }这里关键有两个字段。processorBeanName是任务执行逻辑的定位符——动态任务和静态Scheduled不同静态方法直接把逻辑写到注解下面而动态任务必须通过一个“处理器接口”来抽象执行逻辑否则你不可能用一个字符串标识去定位一段代码。我定义了JobProcessor接口只有一个execute(MapString, Object params)方法每个可动态调度的业务逻辑都实现这个接口并注册成Spring Bean用beanName作为标识。Cron表达式字段则直接对应调度规则允许后续通过更新接口动态修改。3.2 核心调度管理器DynamicTaskManager的完整实现DynamicTaskManager是整套方案的心脏它负责所有的注册、查询、修改、取消操作。在设计上我让它持有两个核心组件ThreadPoolTaskScheduler和MapString, ScheduledTask任务映射表。Map的key是jobIdvalue是当前在调度器中注册的任务句柄通过这个句柄才能精确地取消或重新注册任务。Component public class DynamicTaskManager { private final MapString, ScheduledTask taskMapping new ConcurrentHashMap(); private ThreadPoolTaskScheduler taskScheduler; PostConstruct public void initScheduler() { // 核心手动创建线程池调度器显式初始化避免Spring Boot默认单线程调度 this.taskScheduler new ThreadPoolTaskScheduler(); // 线程池大小根据任务并发峰值合理设置不能使用默认单线程 taskScheduler.setPoolSize(10); // 线程名前缀方便日志排查和线程Dump分析 taskScheduler.setThreadNamePrefix(dynamic-task-); // 核心知识点设置等待任务完成后再关闭线程池避免强制中断正在执行的任务 taskScheduler.setWaitForTasksToCompleteOnShutdown(true); // 设置优雅关闭的最大等待时间防止任务长时间卡死导致应用退出缓慢 taskScheduler.setAwaitTerminationSeconds(30); taskScheduler.initialize(); } /** * 注册一个新任务。如果jobId已存在先取消旧任务再注册新任务。 */ public boolean registerJob(JobDefinition jobDefinition) { String jobId jobDefinition.getJobId(); String cronExpression jobDefinition.getCronExpression(); String processorBeanName jobDefinition.getProcessorBeanName(); // 核心校验Cron表达式在运行时必须严格校验非法表达式直接拒绝 if (!CronExpression.isValidExpression(cronExpression)) { throw new IllegalArgumentException(非法Cron表达式: cronExpression); } // 根据任务处理器标识从Spring容器中获取真正的业务执行逻辑 JobProcessor processor ApplicationContextHolder.getBean(processorBeanName, JobProcessor.class); if (processor null) { throw new IllegalArgumentException(未找到对应的JobProcessor: processorBeanName); } // 先取消同ID的旧任务保证幂等注册 cancelJob(jobId); Runnable runnable () - { try { processor.execute(jobDefinition.getParams()); } catch (Exception e) { // 任务执行异常必须捕获否则会影响到调度线程池中其他任务 log.error(任务执行异常, jobId{}, jobId, e); } }; // 核心注册动作通过CronTrigger将任务提交给调度器返回ScheduledTask句柄 ScheduledTask scheduledTask taskScheduler.schedule(runnable, new CronTrigger(cronExpression, TimeZone.getDefault())); taskMapping.put(jobId, scheduledTask); jobDefinition.setStatus(RUNNING); return true; } }这段代码里藏了几个值得展开的关键点。CronExpression.isValidExpression是Spring 5.3版本新增的校验API早期版本的Spring只能通过new CronTrigger(expr)尝试解析如果表达式非法会在构造阶段抛异常捕获异常即可完成校验。我明确选择先用isValidExpression校验一遍再进入注册流程目的是把错误前置避免出现“任务注册了但cron一直不触发”的尴尬局面。再看ApplicationContextHolder这个类它是我们自行实现的Spring容器工具类静态持有ApplicationContext方便在管理器内部从容器中按类型和名称获取Bean。之所以不用Autowired注入JobProcessor的Map是因为任务处理器是后期可能动态新增的用容器按需获取更灵活。schedule方法的返回值类型是ScheduledFuture?但taskScheduler.schedule在Spring的TaskScheduler接口中定义返回ScheduledFuture为什么我赋值给ScheduledTask类型这里必须澄清ThreadPoolTaskScheduler的schedule方法返回的是ReschedulingRunnable或者其父类对象但Spring内部完成包装之后会生成ScheduledTask实例作为注册结果我这里为了准确描述实际上使用的是taskScheduler.schedule配合返回值的重新包装。Sping的ConcurrentTaskScheduler在调用schedule时会通过ReschedulingRunnable构建ScheduledTask并注册到内部注册表中。为了不误导读者我建议在DynamicTaskManager内部维护的value类型仍然用ScheduledTask因为只有ScheduledTask才能暴露cancel方法且能配合ScheduledTaskRegistrar的remove操作。实际项目中我会写一个私有包装方法将schedule返回的ScheduledFuture包装成带taskId的ScheduledTask进行管理核心逻辑不受影响。3.3 动态启停与删除控制逻辑的四种核心操作除了注册其他三种操作同样要小心实现。停止任务暂停的处理方式是关键很多人的第一反应是从Map中移除ScheduledTask然后cancel这其实是“删除”而非“暂停”。暂停的语义应该是保留任务的注册信息让调度器不再触发它但任务随时可以被恢复。因此我在设计上引入了状态字段暂停操作实际上是“先取消当前调度句柄再更新任务状态为PAUSED但jobId在Map中仍然占位”。恢复操作则是用相同的jobDefinition重新调用registerJob由于registerJob内部会先cancel再注册幂等性自然得到保证。public void pauseJob(String jobId) { ScheduledTask scheduledTask taskMapping.get(jobId); if (scheduledTask null) { throw new IllegalArgumentException(任务不存在或未注册: jobId); } // 取消调度器中的下一次触发但业务代码当前正在执行的实例不会被终止 scheduledTask.cancel(false); // 更新状态为暂停 jobDefinitionMap.get(jobId).setStatus(PAUSED); log.info(定时任务暂停, jobId{}, jobId); } public void resumeJob(String jobId) { JobDefinition jobDefinition jobDefinitionMap.get(jobId); if (jobDefinition null) { throw new IllegalArgumentException(任务不存在或未注册: jobId); } // 复用registerJob内部的cancelregister逻辑达到恢复调度的效果 jobDefinition.setStatus(RUNNING); registerJob(jobDefinition); } public boolean deleteJob(String jobId) { ScheduledTask scheduledTask taskMapping.remove(jobId); if (scheduledTask null) { return false; } // 真正删除取消任务句柄同时从任务详情映射中移除记录 scheduledTask.cancel(false); jobDefinitionMap.remove(jobId); log.info(定时任务删除, jobId{}, jobId); return true; }这里cancel(false)和cancel(true)的区别值得单独说。cancel(false)表示不中断正在运行的线程只是取消后续调度cancel(true)会设置线程中断标志。对于大多数业务场景我推荐传false理由很实在强制中断一个正在写数据库或调用第三方接口的任务容易留下数据不一致或状态错乱的问题。正确的做法是在JobProcessor实现代码中自行判断线程中断状态配合优雅停机。实测下来如果业务代码里没有处理InterruptedException传true几乎没有实际作用只会让你误以为任务真的停了。3.4 SchedulingConfigurer接入让动态任务和Spring调度体系无缝衔接要让DynamicTaskManager内部创建的任务融入Spring调度体系核心的一步是实现SchedulingConfigurer接口。configureTasks方法会在Spring容器启动阶段被回调参数registrar可以设置默认调度器。但这里有一个取舍如果你直接设置registrar.setScheduler(taskScheduler)之后用Scheduled注解声明的任务就会使用这个调度器。这往往是件好事——线程池比默认的单线程调度器更健壮但也不要忽略它的副作用所有Scheduled任务现在共享同一个线程池若前文所说大任务和小任务会互相影响。我的方案是动态任务使用DynamicTaskManager自己的调度器Scheduled静态任务使用Spring Boot自动配置的调度器两者互不干扰需要做分布式锁保护时再加上。为了实现这个隔离效果我的配置类实现SchedulingConfigurer但实际上什么都不做只在方法里打印一行日志确认容器回调发生。这个做法看起来有些反直觉但效果很好不设置默认调度器Scheduled保持原有行为DynamicTaskManager内部自建线程池动态任务独立运行。如果你希望统一线程池则可改为registrar.setScheduler(taskScheduler)代价是需要统筹评估所有任务的线程占用。Configuration EnableScheduling public class SchedulingConfig implements SchedulingConfigurer { private static final Logger log LoggerFactory.getLogger(SchedulingConfig.class); Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { // 不做任何设置保持Spring Boot默认的Scheduled调度行为 // 动态任务的调度由DynamicTaskManager内部的独立线程池管理 log.info(Spring调度配置初始化完成动态任务管理器独立管理调度线程池); } }3.5 控制接口将动态管理能力暴露成REST API管理能力已经具备接下来还差一个入口供前端或运维调用。我提供了一个TaskManageController暴露四类接口。之所以要单独拆Controller而不是直接调用DynamicTaskManager是因为接口层要负责参数校验、返回结果包装、操作日志记录等横切关注点管理器的职责应该保持纯粹。RestController RequestMapping(/api/tasks) public class TaskManageController { private final DynamicTaskManager dynamicTaskManager; public TaskManageController(DynamicTaskManager dynamicTaskManager) { this.dynamicTaskManager dynamicTaskManager; } PostMapping(/register) public ResultVoid registerTask(RequestBody JobDefinition jobDefinition) { dynamicTaskManager.registerJob(jobDefinition); return Result.success(); } PostMapping(/pause) public ResultVoid pauseTask(RequestParam String jobId) { dynamicTaskManager.pauseJob(jobId); return Result.success(); } PostMapping(/resume) public ResultVoid resumeTask(RequestParam String jobId) { dynamicTaskManager.resumeJob(jobId); return Result.success(); } DeleteMapping(/{jobId}) public ResultVoid deleteTask(PathVariable String jobId) { dynamicTaskManager.deleteJob(jobId); return Result.success(); } GetMapping(/list) public ResultListJobDefinition listTasks() { return Result.success(dynamicTaskManager.listAllJobs()); } }前端调用流程就很完整了运营在页面上填一个表单包含任务名称、cron表达式、处理器beanName、参数JSON提交后调用register接口任务立刻进入调度队列想停某个任务点击暂停按钮调度器停止触发恢复则重新执行register逻辑无需重启服务。至此“动态增删启停”的需求完整落地。4. 常见问题与排查技巧实录4.1 定时任务不触发的六类典型根因动态调度方案写完之后排查问题的难度比静态Scheduled高一些因为任务注册链路更长涉及调度器状态、Map映射、cron校验、处理器是否存在等多个环节。我把自己踩过以及帮同事排查过的典型问题整理成了一份速查表。现象排查方向解决方案任务从未执行未启用EnableScheduling在配置类上添加EnableScheduling任务注册报错非法croncron表达式格式错误或特殊字符问题统一用CronExpression.isValidExpression校验并打日志任务执行时间远晚于预期调度线程池被长任务阻塞增加线程池大小或拆分独立调度器任务被暂停后恢复无反应恢复时未重新注册CronTrigger恢复逻辑复用registerJob保证重新调度任务停止后业务仍在执行cancel(true)无效果业务代码未配合中断检查在处理器中自行检查线程中断状态服务重启后任务全部丢失动态注册任务只存在内存中结合数据库持久化并在启动时重新加载这里我想重点展开最后一个问题。动态注册的任务天然有个软肋——服务重启后内存中的注册信息全部清空如果不做恢复机制运营配置的任务就会悄无声息地消失。解决思路很简单任务定义持久化到数据库应用启动时在SchedulingConfigurer里扫描配置表并重新注册。我在项目中的做法是在configureTasks方法中查询任务配置表把状态为RUNNING的任务全部重新注册。务必注意顺序如果任务执行逻辑依赖某些初始化数据要确保所有相关Bean初始化完毕后再注册Spring容器启动回调configureTasks的时机在单例Bean初始化之后整体是安全的。4.2 并发场景下任务重复执行的防护方案动态任务还有一个隐性问题由于任务执行时间和触发时间可能重叠同一个任务可能在执行还没结束时又被调度器触发新一次执行。Scheduled注解默认是串行执行的但动态任务线程池是并发执行的因此重复执行的概率会被放大。例如你的cron是每隔5秒一次任务执行耗时10秒那么第二次触发时会同时跑两个实例。如果任务操作的数据不具备幂等性就会产生脏数据。我在设计时提供了两层防护第一层在每个JobProcessor内部建议用分布式锁或数据库唯一约束保证并发安全单机场景则直接用synchronized或JUC的Lock即可第二层在DynamicTaskManager的registerJob包装逻辑里为每个jobId维护一个AtomicBoolean状态任务执行前尝试CAS置true执行结束恢复false如果当前任务仍处于运行中则跳过本次触发。private final ConcurrentHashMapString, AtomicBoolean runningFlags new ConcurrentHashMap(); // 在runnable包装中使用 AtomicBoolean runFlag runningFlags.computeIfAbsent(jobId, k - new AtomicBoolean(false)); if (!runFlag.compareAndSet(false, true)) { // 上一次任务仍在执行本次触发直接跳过 log.warn(任务仍在执行中跳过本次触发, jobId{}, jobId); return; } try { processor.execute(jobDefinition.getParams()); } finally { runFlag.set(false); }这层防护看似简单但价值极高。它避免了在业务逻辑中重复编写“防重”代码同时保留了“跳过本次触发”而不是“排队等待”的语义——对于大多数定时任务来说跳过过期触发比堆积执行更合理。4.3 线上排查动态任务故障的实操三步法动态任务的故障排查比静态任务更有难度因为任务信息不在配置文件中而在运行期的内存Map里。我一般按三步走排查。第一步通过list接口查看任务列表确认jobId是否存在、状态是否符合预期。排查时很容易发现任务其实注册了但cron表达式不是你以为的那个遇到这种情况直接用update接口修正表达式秒级生效。第二步查看调度线程的执行日志关键是观察“dynamic-task-”前缀的线程日志确认任务是被调度了但执行报错还是根本没被调度。如果执行报错我在registerJob的runnable内已经包裹了异常捕获错误日志里会完整打印异常栈和jobId快速定位业务问题如果根本没调度继续查线程池是否满负荷。第三步Thread Dump分析线程池状态。使用jstack命令抓取线程快照观察“dynamic-task-”线程处于WAITING还是RUNNABLE状态如果所有线程都被某个长时间运行的任务占满说明线程池资源被耗尽需要看是不是某个任务执行时间异常拉长。我在项目里还额外加了一个监控点每个任务执行结束时记录耗时超过预警告警阈值就输出一条日志。这样在“无告警的正常”状态下也能提前发现任务的性能劣化趋势避免等到彻底堵死调度线程池才处理。5. 个人实操体会与后续扩展方向整套方案落地之后我最大的体会是“轻量”二字在设计中的价值。用Spring原生调度体系解决动态管理需求代码量不算少但没有引入任何额外的重量级依赖整个机制的每个环节都是可解释、可排查的。如果你遇到同样的需求先别急着引入Quartz或分布式调度框架把Spring自身提供的SchedulingConfigurer、ThreadPoolTaskScheduler、ScheduledTaskRegistrar这套链路吃透大概率能覆盖业务需求。在做这个项目的过程中我还有一个比较深的感触动态定时任务的难点其实不在注册和取消这两个动作上而在围绕它们衍生出的一系列工程化问题上——比如任务状态如何持久化、并发执行如何保护、失败之后如何告警、线程池资源如何隔离。这些问题没有现成的注解可以直接解决需要结合具体业务场景去设计这也是这类“小而难”的功能最有价值的地方。最后分享一个小经验不管你的动态任务管理系统做得有多完善一定要给关键操作留下审计日志。谁在什么时候注册了哪个任务、修改了哪个cron、暂停了哪个任务这些记录在业务出问题时会成为最重要的排查线索。我这边在Controller层用AOP切面统一记录了操作日志后来排查线上问题时多次靠它还原了任务变动时间线有几次甚至直接定位到了运营的误操作。具体实现不复杂一个自定义注解加一个Aspect切面就行但这个细节千万别省略。
返回列表