
项目里做过定时推送的同事应该都经历过这样的流程运营提一个需求说“推送时间从每天 10 点改到 11 点”你就要去代码里翻Scheduled(cron 0 0 10 * * ?)改完一行注解然后打包、发布、重启。如果赶上业务高峰期还得申请变更窗口折腾一两个小时就为了把一个数字从 10 改成 11。Spring Boot 的SchedulingConfigurer接口恰恰就是为了打破这种“改一个时间就要发一次版本”的局面的。它允许你在运行时向调度器注册任务由自定义 Trigger 决定每一次执行的时刻由此实现修改定时任务不重启项目。这篇文章我会给出一个可以直接落地的完整方案包括接口原理、配置表、核心代码、线程池、并发与集群注意点以及我实测踩过的时区、启动顺序、重复注册三个典型坑。适合已经会写Scheduled、但想进一步把调度配置化甚至做成小型调度配置中心的开发者。1. 为什么说写死的 cron 天生不适合业务调度1.1 最常见的 Scheduled 用法以及改配置必须重启的尴尬大多数 Spring Boot 项目的定时任务起步都是这样的Component public class ReportPushTask { Scheduled(cron 0 0 10 * * ?) public void pushReport() { // 推送报表逻辑 } }这段代码很直观但它的坑不在于技术而在于运维模型。cron 一旦写成注解参数它就变成了代码的一部分。要改时间就得重新编译、构建、发布、重启即使只是把10改成11。这带来的不只是效率问题还有一个隐蔽风险在真实业务环境里“改时间”往往不是开发自己能拍板的运营、产品、客户都参与讨论讨论完最终还是要找开发改代码所有时间变更需求全部堆到研发侧。有人会说那我把 cron 放到配置文件里用Scheduled(cron ${report.push.cron})读占位符不就行了这确实比硬编码进代码好一些但它仍然需要重启应用才能生效而且不同环境的管理粒度也比较粗。如果团队里已经有配置中心配合动态刷新也许能解决一部分问题但对很多中小团队来说这套基础设施未必齐全。更好的做法是绕过注解直接借助 Spring 的调度注册机制把 cron 当数据来管理。1.2 动态调度的本质把“执行什么”与“何时执行”拆开Spring 的定时任务体系里真正管事的不是Scheduled注解而是ScheduledTaskRegistrar。注解只是把一个任务翻译注册到调度器里的入口之一。除了注解Spring 还留了另一个口子实现SchedulingConfigurer接口重写configureTasks(ScheduledTaskRegistrar taskRegistrar)方法在这个方法里手动注册任务。ScheduledTaskRegistrar提供了好几类注册方法比如addCronTask(Runnable, String)、addFixedRateTask、addTriggerTask(Runnable, Trigger)。其中Trigger是动态化的关键每次任务执行完成后调度器都会向 Trigger 询问“下一次执行时间是多少”。如果我们让这个 Trigger 在每次回调时都去数据库读最新 cron然后基于最新值计算下一次执行时间那修改 cron 就不再需要碰代码了。你可以把普通Scheduled想象成一个一次性闹钟响了就固定了SchedulingConfigurer加自定义 Trigger 则像是一个每天临睡前会重新查一遍日程表再设置起床闹钟的人。日程哪天变了闹钟下次就按新日程来。1.3 两条实现路线的取舍Trigger 动态计算 vs 取消任务再重注册网上讲动态定时任务的文章主要有两种写法。路线 A 是addTriggerTask加自定义 Trigger读者读到的 cron 每次都来自数据库代码干净也不需要手动管理任务生命周期。缺点在于如果 Trigger 返回null任务会被调度器遗忘后续恢复会比较麻烦另外已经排定的下一次执行无法被“取消”只能等它执行完再按新 cron 排下一次。路线 B 是先用addCronTask注册同时保存住注册回来的ScheduledTask引用需要改 cron 时先cancel()旧任务再重新addCronTask。它的优点是改动立即生效也能随时停用、恢复缺点是自己要管理任务注册状态代码容易写乱操作不当还会造成注册表里残留已取消的任务。两条路线我都实测过结论比较明确如果你主要需求是“改时间不改代码、不重启”用路线 A它就是为这个场景设计的。如果你还需要“动态停用”“立即取消已排定的执行”才需要上路线 B或者做我后面第三章讲的“状态判断”方案。对比维度路线 ATrigger 动态计算路线 B取消后重注册核心 APIaddTriggerTask(Runnable, Trigger)addCronTask 手动 cancel修改 cron 生效时机下次排程时生效立即生效能否取消已排定的执行不能能停用/恢复能力不推荐用空 cron需额外设计可以直接 cancel/重新注册代码复杂度低中高推荐场景改时间、改频率的常规业务需要严格动态启停的调度管理2. SchedulingConfigurer Trigger 实现动态 cron 的核心链路2.1 配置表设计一个业务任务一行数据既然要把 cron 从代码里拿出来第一个问题就是放哪。放配置中心当然可以但数据库是最通用、最容易快速落地的方案改完还能直接接入管理后台。我习惯给每个业务任务建一行配置任务用task_key唯一标识。表结构大致这样CREATE TABLE schedule_config ( id BIGINT AUTO_INCREMENT PRIMARY KEY, task_key VARCHAR(64) NOT NULL COMMENT 任务标识如 report_push, cron_expression VARCHAR(64) NOT NULL COMMENT cron 表达式, task_status TINYINT NOT NULL DEFAULT 1 COMMENT 1启用 0停用, remark VARCHAR(256) COMMENT 任务说明, updated_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_task_key (task_key) ) COMMENT定时任务配置表;task_key这个字段别省。现在可能只有一个任务但系统慢慢会多出报表推送、数据清理、缓存刷新好几个定时任务一张表管理多个任务时task_key就是最自然的维度。而且它也是后面做策略分发、日志落库的关联键。2.2 核心实现addTriggerTask 每次调度前读库先写一个 Mapper负责查 cronMapper public interface ScheduleConfigMapper { Select(SELECT cron_expression FROM schedule_config WHERE task_key #{taskKey} AND task_status 1) String selectCronByTaskKey(Param(taskKey) String taskKey); }然后是核心配置类Configuration EnableScheduling public class DynamicScheduleConfig implements SchedulingConfigurer { private static final TimeZone CRON_TIME_ZONE TimeZone.getTimeZone(Asia/Shanghai); private static final String TASK_KEY_REPORT_PUSH report_push; Autowired private ScheduleConfigMapper scheduleConfigMapper; Autowired private ReportPushService reportPushService; Autowired private TaskScheduler taskScheduler; Bean public TaskScheduler taskScheduler() { ThreadPoolTaskScheduler scheduler new ThreadPoolTaskScheduler(); scheduler.setPoolSize(4); scheduler.setThreadNamePrefix(schedule-task-); scheduler.setWaitForTasksToCompleteOnShutdown(true); scheduler.setAwaitTerminationSeconds(60); return scheduler; } Override public void configureTasks(ScheduledTaskRegistrar taskRegistrar) { taskRegistrar.setScheduler(taskScheduler); taskRegistrar.addTriggerTask( () - reportPushService.pushReport(), this::nextExecutionTime ); } private Date nextExecutionTime(TriggerContext triggerContext) { String cron scheduleConfigMapper.selectCronByTaskKey(TASK_KEY_REPORT_PUSH); if (!StringUtils.hasText(cron)) { return null; } CronTrigger trigger new CronTrigger(cron, CRON_TIME_ZONE); return trigger.nextExecutionTime(triggerContext); } }这段代码里最重要的一行是addTriggerTask的第二个参数。它不是传一个写死的CronTrigger而是传一个每次执行完都会被回调的方法。哪怕配置表里的 cron 在运行中被改了下一次计算执行时间时读到的已经是新值。整个“不重启改定时任务”的核心秘密就在这里。2.3 验证过程改库后不重启等一个调度周期代码写完之后验证方案比你想的简单。我的实测步骤是这样启动应用确认控制台出现schedule-task-1之类的调度线程名。往schedule_config插入一行task_key report_pushcron_expression 0 0/2 * * * ?让它先每 2 分钟执行一次。观察日志确认两次执行之间的间隔确实是 2 分钟。执行一条 UPDATE把 cron 改成0 0 12 * * ?比如改成每天 12 点执行。不重启应用观察日志。这里有个非常容易误解的行为修改 cron 后已经排定的下一次执行不会被撤销。假设当前是 10:05任务刚执行完已经按旧 cron 把下一次排在了 10:10。你 10:06 改了配置10:10 还是会照常执行一次。真正按新 cron 运行是从 10:10 这次执行完成之后才开始的。这是 Trigger 方案的一个特性不算 bug。对绝大多数业务来说晚一个调度周期无伤大雅。但如果你的业务要求“改完立刻取消接下来那一次”那就要考虑路线 B 那种显式 cancel 的方式。2.4 线程池配置别让所有定时任务挤在一条线程上SchedulingConfigurer本身不强制你配置线程池。但如果不做任何设置任务最终会落在默认的单线程调度执行器上。单线程意味着如果某个任务执行时间很长其他任务的调度都会被阻塞住。在上面的代码里我显式定义了一个ThreadPoolTaskScheduler池大小设成 4线程名前缀是schedule-task-。设置线程名前缀是个很划算的好习惯排查线上问题时看到日志里线程名是schedule-task-1一眼就能判断这是调度器在执行任务而不是某个业务线程在摸鱼。这里提醒一个重要细节ScheduledTaskRegistrar的setScheduler方法只能被调用一次调用第二次会直接抛异常。如果你的项目里有多个类实现SchedulingConfigurer不要在每个类里都setScheduler选一个地方统一设置即可。否则启动时会看到关于“scheduler already set”的报错。3. 动态任务比静态任务多出来的三件事并发防护、多实例、停用恢复3.1 并发防护长耗时任务如何避免重叠执行把 cron 动态化之后一个很容易被忽视的问题是任务本身不能重入。举个例子。任务配置成每 2 分钟执行一次但某一次推送的数据量特别大单次要跑 5 分钟。如果线程池里有多个线程第 2 分钟的调度可能会再次触发同一个业务方法造成两个线程在同时跑同一段逻辑。这在报表推送、数据同步、批量消息发送里轻则数据重复重则资源耗尽。我的处理方式是在任务执行入口加一个轻量级本地锁Component public class ReportPushService { private final AtomicBoolean running new AtomicBoolean(false); public void pushReport() { if (!running.compareAndSet(false, true)) { log.warn(上一次报表推送仍在执行本次触发直接跳过); return; } try { // 真正的推送逻辑 } finally { running.set(false); } } }AtomicBoolean.compareAndSet的好处是并发环境下只有一个线程能抢到执行权其余直接跳过。注意这个方案只对单机有效集群部署时需要在任务内部加分布式锁这个放到下一节讲。3.2 多实例部署同一 cron 两台机器都会执行如果你的应用是单机部署上面的内容已经够用。但一旦上了多实例动态 cron 方案会遇到一个更麻烦的问题同一个配置每个实例都会独立注册调度器同一时间所有实例都会执行同一段业务方法。解决思路通常有三条最粗暴也最常见的在配置里加一个开关比如scheduling.enabled只让指定实例开启定时任务注册其他实例不注册。优点是实现简单缺点是该实例挂了任务就没人执行。在任务执行入口加分布式锁抢到锁的实例才执行。依赖 Redis 或者数据库锁稳定性相对好。把调度整体交给统一调度平台应用只提供接口由平台触发调用。这是最彻底的方案但引入成本也最高。我的经验是不要一上来就上分布式调度平台。常见的二三十个内部任务场景用“配置开关控制节点 分布式锁防重入”基本就能解决。明确一点动态 cron 解决的是“改时间不重启”它本身不是集群调度方案这两件事千万别混在一起。3.3 停用和恢复为什么不要用空 cron 当开关这是我在实际使用中踩过最深的坑单独拿出来说。在 2.2 的示例代码里当 cron 为空时我返回了null。Trigger 返回null的后果是调度器不会再安排下一次执行任务会从调度队列里消失。听起来正好可以用来“停用任务”问题在于之后你再把 cron 填回来这个任务也不会自动恢复——因为已经没有“下一次执行”这个钩子了Trigger 不会再被询问。所以如果你想用一个后台开关来“停用/启用”任务千万不要用清空 cron 的方式。正确的做法是保留一个低频 cron 作为兜底让调度永远活着然后在任务内部通过状态判断是否需要真正执行。具体改造是这样。复用配置表里的task_status给 Mapper 加一个方法Select(SELECT COUNT(1) FROM schedule_config WHERE task_key #{taskKey} AND task_status 1) int countEnabledTask(Param(taskKey) String taskKey);然后在注册的 Runnable 里先判断状态taskRegistrar.addTriggerTask( () - { if (scheduleConfigMapper.countEnabledTask(TASK_KEY_REPORT_PUSH) 0) { return; } reportPushService.pushReport(); }, this::nextExecutionTime );cron 保持一个兜底值比如0 0/10 * * * ?每隔 10 分钟“空转”一次执行时发现状态是停用就直接返回。等状态改回启用下一个 10 分钟节点就恢复了。这个方案不是最优的调度架构但很实用既避免了任务下线后无法重建又不需要维护复杂的任务注册状态。代价就是每 10 分钟会有一个几乎零成本的空查对大多数业务系统完全可以接受。4. 实测中踩过的三个坑和完整排查链路4.1 坑一时区偏差导致任务执行时间和预期差 8 个小时第一次把任务从Scheduled改成动态配置时我遇到过这样一个问题数据库里明明配置的是每天 10 点执行日志显示实际执行时间是 18 点。排查链路是这样的先确认应用读到的 cron 是否正确日志里打印出来的配置值是0 0 10 * * ?没问题。再确认服务器系统时间执行date命令发现系统时区是 UTC。确认 Java 进程的默认时区打印ZoneId.systemDefault()依然是 UTC。最终定位CronTrigger默认使用 JVM 默认时区来理解 cron 表达式。0 0 10 * * ?中的 10 点被 UTC 时区解析为 UTC 10:00也就是北京时间 18:00。修复方式很简单构造CronTrigger时显式指定业务时区new CronTrigger(cron, TimeZone.getTimeZone(Asia/Shanghai))这个坑非常容易踩尤其是服务器部署在新加坡、美国这些时区节点时。别依赖服务器默认时区云上环境很多时候系统时区不是你想要的。另外提醒一句排查时要小心日志时区、数据库时区的干扰日志系统经常自动转换时区最后全是 8 小时差容易让人误判。4.2 坑二configureTasks 阶段依赖未就绪导致启动失败第二个坑发生在多数据源场景。某个系统使用了基于租户的动态数据源数据源本身在启动时只是个代理真正初始化和切换发生在第一个请求进来之后。结果我在configureTasks里直接查了 cron启动时数据源还没就绪抛了异常整个应用直接起不来。这个问题的本质是configureTasks的执行时机在 Spring 容器单例初始化阶段比业务请求早得多。如果你在这个阶段做了远程调用、数据源切换、读取尚未初始化的组件很容易触发初始化顺序问题。解决思路是把数据库访问尽可能延后到 Trigger 回调里让它发生在第一次调度时而不是配置类注册时。同时回调里要做好兜底万一数据库查询失败不能让任务静默消失也不应该让异常把调度线程打崩。我的兜底写法是这样private Date nextExecutionTime(TriggerContext triggerContext) { try { String cron scheduleConfigMapper.selectCronByTaskKey(TASK_KEY_REPORT_PUSH); if (!StringUtils.hasText(cron)) { return null; } return new CronTrigger(cron, CRON_TIME_ZONE).nextExecutionTime(triggerContext); } catch (Exception e) { log.error(读取定时任务配置失败使用兜底 cron 继续调度, e); return new CronTrigger(0 0/5 * * * ?, CRON_TIME_ZONE).nextExecutionTime(triggerContext); } }这样即使数据库短暂不可用任务也会按兜底 cron 继续“活着”配置恢复后又自动回到正常节奏。4.3 坑三同一个任务被注册了两次一个周期执行两遍第三个坑是重复注册。我曾在排查中收到运营反馈某个推送任务明明配置的是 5 分钟一次实际 5 分钟内收到了两遍推送。排查过程比较经典先看日志发现同一业务方法在两个不同线程里同时执行线程名都是schedule-task-前缀。说明不是业务代码的问题是调度层注册了两份任务。检查配置类发现configureTasks里用addTriggerTask注册了一次。继续翻代码发现业务类上还遗留了一个Scheduled(cron 0 0/5 * * * ?)注解两个注册入口同时存在。修复就是去掉其中一个入口统一走动态注册链路。如果你在线上遇到过“任务执行两遍”的问题第一反应应该是查重复注册而不是怀疑业务逻辑。排查手段也很直接打开 DEBUG 日志看调度器线程名或者查任务记录里同一时间戳有没有重复批次。这里还有一个进阶经验一旦上了动态定时任务建议在业务日志里带上task_key和触发时间。后面查问题会省非常多力气。4.4 排查动态调度问题的一般路径动态调度出问题一半以上是“配置和数据”问题不是代码问题。我的排查顺序一般是这样现象优先检查常用手段任务完全不执行配置表有没有对应行、cron 是否合法在 Trigger 回调查看读到的 cron 值执行时间与预期不符服务器时区、应用时区、数据库时区打印ZoneId.systemDefault()比对 UTC任务执行多次重复注册、多实例部署、手动触发入口看调度线程名查 task_key 去重任务执行一半后不动线程池被长任务占满、异常吞掉查看线程栈、检查异常日志尤其是“任务不执行”这种问题第一反应永远不要是改代码而是先打印或查询当前配置值到底是什么。动态化的核心是配置驱动配置出错才是最大的故障来源。5. 从单个动态任务到轻量级调度配置中心5.1 任务元数据表状态、执行时间、执行结果一张表管起来单任务动态化跑通后自然想把它推广到整个系统。这时候只靠schedule_config一张表就不够了至少需要把“上次执行时间”“执行结果”这样的元数据也纳管起来。否则运营来问“推送到底跑了没有”你只能翻日志。可以在schedule_config上扩展字段ALTER TABLE schedule_config ADD COLUMN last_exec_time DATETIME COMMENT 最近一次实际执行时间, ADD COLUMN last_exec_result VARCHAR(128) COMMENT 最近一次执行结果如 SUCCESS/ERROR, ADD COLUMN exec_count BIGINT DEFAULT 0 COMMENT 累计执行次数;执行完任务后更新这几个字段。配上管理后台线上定时任务的运行状态就直接可视化了。5.2 策略模式分发一个调度注册器驱动多个业务任务任务多了之后如果在configureTasks里一个个写addTriggerTask代码会越来越臃肿。更好的做法是引入策略分发定义一个统一的任务接口按task_key分发。public interface ScheduleCommand { void execute(); } Component(report_push) public class ReportPushCommand implements ScheduleCommand { Override public void execute() { // 报表推送逻辑 } } Component(report_clean) public class ReportCleanCommand implements ScheduleCommand { Override public void execute() { // 历史数据清理逻辑 } }调度器里通过MapString, ScheduleCommand自动注入拿到所有实现类按task_key分发Component public class ScheduleCommandDispatcher { Autowired private MapString, ScheduleCommand commandMap; public void dispatch(String taskKey) { ScheduleCommand command commandMap.get(taskKey); if (command null) { log.warn(未找到 task_key 对应的任务实现: {}, taskKey); return; } command.execute(); } }这时可以循环注册多个任务for (String taskKey : configuredTaskKeys) { taskRegistrar.addTriggerTask( () - commandDispatcher.dispatch(taskKey), triggerContext - nextExecutionTime(taskKey, triggerContext) ); }注意一个边界configureTasks在启动阶段执行一次所以“配置表新增一行任务自动注册”这件事靠这套方案是做不到的。新任务仍然需要重新发布应用才能完成注册。要做到运行期动态增加任务需要额外的任务注册管理机制那就接近一个独立调度中心了一般团队不一定需要。够用、可靠、可观测永远是优先原则。5.3 执行记录与异常告警动态调度最怕“悄悄不执行”动态定时任务最大的风险不是执行报错而是“悄悄不执行”。代码没问题、配置没动但任务就是没跑这种问题排查成本最高。所以执行日志和告警一定要补齐。我通常会加一张执行日志表CREATE TABLE task_exec_log ( id BIGINT AUTO_INCREMENT PRIMARY KEY, task_key VARCHAR(64) NOT NULL, trigger_time DATETIME NOT NULL, cost_ms BIGINT NOT NULL, result_code VARCHAR(32) NOT NULL, error_msg VARCHAR(512), INDEX idx_task_key_time (task_key, trigger_time) ) COMMENT定时任务执行日志表;核心代码里包一层记录逻辑long start System.currentTimeMillis(); try { commandDispatcher.dispatch(taskKey); saveExecLog(taskKey, start, SUCCESS, null); } catch (Exception e) { saveExecLog(taskKey, start, ERROR, e.getMessage()); throw e; }不需要一开始就上复杂的监控系统把执行记录落库然后对result_code ERROR或者“超过预期时间未执行”的情况做好日志告警就已经能覆盖绝大多数调度故障场景。回到最开始的问题改一个 cron 到底要不要重启我的答案很明确大多数业务系统里不应该重启。我之前维护过一个面向运营的报表推送模块最初也是Scheduled硬编码后来接进数据库配置后改时间就是改一行记录发布流程成本直接归零。唯一需要记住的是动态化带来效率代价是“出问题更隐蔽了”所以执行日志、任务状态、结果记录一定要暴露出来。如果你只需要动态改时间Trigger 方案足够如果还要动态开关任务就按状态判断的模式扩展。别一上来就追求做成调度平台先把“改配置不重启”这件事做扎实比什么都强。