前阵子一个朋友在群里问:对账任务用的 @Scheduled(cron = "0 0 2 * * ?"),运营临时想把执行节奏改成每十分钟跑一次,但又不想重启服务。我说这事很简单,JDK 自带的 ScheduledExecutorService 就能干,SpringBoot 项目里一行额外依赖都不用加,连“三方框架”都算不上,我只是把任务的生命周期从注解里解放出来,改成自己管理而已。今天就把这个完整方案从原理讲到能落地的代码,再梳理一遍实际运行中容易踩的坑。
这个方案适合谁看?就是那些定时任务数量不多、又要求运行期能动态调整执行周期和启停状态的团队。你要是只有两三个任务,为了动态调度直接引入 Quartz 或者分布式调度平台,不管是从学习成本还是从部署复杂度看,都明显偏重。用 ScheduledExecutorService 自己写一个轻量调度中心,代码量控制在两百行以内,就能覆盖绝大多数动态调度的诉求。
1. 为什么需要自己写动态定时任务
1.1 @Scheduled 的静态局限
SpringBoot 里的 @Scheduled 确实好用,一个注解加一个 cron 表达式,任务就能定时跑。但这个“定时”是在什么地方确定的?是在项目启动阶段,ScheduledAnnotationBeanPostProcessor 扫描到注解以后,把 cron 表达式解析成一个 CronTrigger,然后交给调度器注册。整个过程只有一次,注册完之后,触发周期就固化在你提交到 ScheduledTaskRegistrar 的那一刻了。
Spring 没有给 @Scheduled 提供任何“运行期修改触发条件”的入口。你想把 0 0 2 * * ? 改成每十分钟一次,要么改配置重启,要么自己在代码里维护一个 volatile 变量,在任务内部每次执行时去判断当前是否满足新的触发条件。第二种做法看起来规避了重启,但本质上只是“任务内部跳过”,调度器本身仍然按照旧频率唤醒,如果你把间隔拉长,任务就会被无意义地频繁唤醒,哪怕内部直接 return 了,线程资源和日志噪音都还在。
更麻烦的是动态启停。@Scheduled 注册的任务没有一个标准的管理接口,你想暂停某个任务,Spring 没有提供按 taskId 暂停的 API,只能通过 ScheduledFuture 的 cancel 去打断,但那个 Future 藏在容器内部的 registrar 里,业务代码很难拿得到。所以一旦遇到“运行期改调度策略”这种需求,@Scheduled 基本就哑火了。
1.2 引入三方框架的成本真的合算吗
很多人第一反应是上 Quartz。Quartz 确实专业,JobDetail、Trigger、Scheduler、JobStore 一套概念下来,什么都能配。但问题是,如果项目里就三五个月度报表任务,引入 Quartz 意味着你要理解它的调度模型、处理好线程池配置、还要考虑 Job 和 Spring Bean 的绑定关系。学习成本和接入成本并不低,而且实际占用资源也比 JDK 自带的方案大不少。
再往上走就是 xxl-job 这类分布式调度平台。功能确实全面,动态配置、任务列表、失败告警、路由策略样样都有,但它要求额外部署调度中心、配置执行器注册、打通网络。如果当前项目就是单机部署,连集群都没有,把这些东西搬进来,纯粹是给运维添负担。我当时选择自研的核心逻辑很简单:任务量少、单机运行、要求快速调整——JDK 提供的定时线程池就已经满足需求了,没必要为了一个远程开关引一整套基础设施。
1.3 这个轻量方案能做什么、不能做什么
先讲能做的:动态修改任务执行间隔、动态暂停和恢复、手动触发一次、查看任务执行次数和上次耗时。这些是日常运营和排查问题最常用到的能力。不能做的也很明确:不支持跨节点互斥调度,不提供任务持久化,不原生支持 cron 表达式。如果你要的是“一个管理界面 + 数据库配置任务”的产品级功能,这个轻量方案确实不合适,这个边界心里要有数。
2. ScheduledExecutorService 的调度原理与核心方法
2.1 延迟队列加工作线程的调度模型
ScheduledExecutorService 的第一个具体实现类就是 ScheduledThreadPoolExecutor,它继承自 ThreadPoolExecutor,所以你看到的线程池模型它全都有,核心区别在任务队列和任务包装上。
普通线程池用的是 BlockingQueue,任务提交后放进队列,工作线程通过 take() 拿任务,谁拿到谁执行。ScheduledThreadPoolExecutor 用的是 DelayedWorkQueue,这是一个按“下次执行时间”排序的延迟队列。工作线程执行 take() 时,如果队头任务的延迟时间还没到,线程会阻塞等待;时间一到,队头任务出队执行。周期任务执行完之后,会根据周期参数重新计算下一次执行时间,然后再把自己丢回队列。这套机制决定了任务的“准时”是有条件的:如果某个任务长时间占用工作线程,其他到期任务就只能排队等着,表现出来就是调度不准、延迟明显。
另外有个很隐蔽的点:ScheduledThreadPoolExecutor 的 DelayedWorkQueue 是无界队列。这意味着线程池里的 maximumPoolSize 其实不会生效,工作线程数到达 corePoolSize 之后,新任务只会排队,永远不会触发创建额外非核心线程。所以决定并发能力的就是 corePoolSize,把这个设置成合理值即可。
2.2 schedule 几个关键方法的区别怎么选
ScheduledExecutorService 对外主要提供三个调度方法,它们的行为差异很大,用错了很容易出线上问题。
| 方法 | 触发逻辑 | 执行特性 | 适用场景 |
|---|---|---|---|
| schedule(Runnable, delay, unit) | 延迟一段时间后执行一次 | 一次性任务,后续不再触发 | 延迟初始化、超时提醒、临时任务 |
| scheduleAtFixedRate(task, initialDelay, period, unit) | 从任务开始时间往后推 period | 任务执行耗时超过 period 时会重叠排队 | 单次执行耗时短、需要固定节奏的采集任务 |
| scheduleWithFixedDelay(task, initialDelay, delay, unit) | 从任务结束时间往后推 delay | 任务天然不会重叠 | 执行时间不稳定、对固定节奏要求不高的同步和统计任务 |
scheduleAtFixedRate 的方法名容易让人误以为是“每 period 时间执行一次”,但它的语义是“任务开始时刻 + period 作为下一个开始时刻”。比如你设置 period 为 5 秒,任务执行耗了 10 秒,那么到第 5 秒时,线程池里就会挤进来第二个任务实例,如果线程够多就会并行跑,而不是等第一个结束。scheduleWithFixedDelay 就老实得多,它永远等上一个任务执行完,再休息 delay 时间,所以不会产生重叠执行。
还有一点必须强调:这两个周期方法在执行时,如果任务本身抛出了未捕获异常,这个周期任务会被静默取消,后续不再调度,而且默认没有日志输出。这一点放到后面“避坑”章节细说,但请记住这句话。
2.3 线程池参数:别再用 Executors 了
很多初学者写 Executors.newSingleThreadScheduledExecutor() 或者 newScheduledThreadPool(4) 就算完事,但这两个工厂方法返回的线程池有两个问题:线程名是 pool-N-thread-M 这种默认名字,日志定位极其痛苦;线程工厂不可控,没法统一设置守护线程属性。更关键的是,默认情况下被取消的任务不会立刻从延迟队列移除,这个坑后面讲。
我在生产环境一般这样创建:
ScheduledThreadPoolExecutor scheduler = new ScheduledThreadPoolExecutor( 4, r -> { Thread t = new Thread(r); t.setName("dynamic-scheduler-" + t.getId()); t.setDaemon(false); return t; }); scheduler.setRemoveOnCancelPolicy(true); scheduler.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);线程池核心线程数怎么定?我的经验是看“可能同时执行的任务数峰值”,而不是看任务总量。如果高峰期需要五个任务同时执行完,那 corePoolSize 至少是 5,否则就会出现排队。任务里如果主要做的是 HTTP 调用、数据库查询这类耗时 IO 操作,可以适当放宽到 CPU 核数乘 2;如果就是内存计算,保持几个核心线程就够了,因为计算型任务线程开多了反而增加上下文切换开销。
把 setRemoveOnCancelPolicy(true) 设置上去是防止内存泄漏的关键;把 setExecuteExistingDelayedTasksAfterShutdownPolicy(false) 设置上去是为了 Spring 容器关闭时不至于被未执行的任务拖住停机时间。这两行配置属于“看不懂没关系、先写上”的级别,因为踩坑的人太多了。
3. SpringBoot 动态调度中心:从设计到代码
3.1 核心思路:重排 Future
ScheduledExecutorService 的 ScheduledFuture 一旦创建,周期参数就固定了,它没有提供“修改周期”的方法。所以动态调度的核心思路就是绕开这个限制:取消当前的 Future,然后用新的周期重新提交一次任务。这听起来很粗暴,但实际执行就是毫秒级窗口期,业务上完全可以接受。
为了管理方便,我维护一个 ConcurrentHashMap,key 是任务 ID,value 是 TaskEntry 对象。TaskEntry 保存了任务本体、调度参数、当前 ScheduledFuture、运行状态统计信息。你要“修改周期”时,流程就是拿到 entry,cancel 掉旧 future,再重新 schedule 一个新 future 替换回去。“暂停”就是 cancel 但不删 entry;“恢复”就是把 entry 重新提交到线程池。
除重排 Future 之外,我在任务包装这一层加了一个互斥开关:同一时间同一个任务只允许一轮执行,如果上一轮还没跑完,下一轮直接跳过。这样即使某些任务被配置成 fixedRate,也不会因为执行时间超周期而出现重叠实例。
3.2 调度线程池的构建
我用一个 Spring 配置类把线程池注册成 Bean,方便其他地方注入使用:
@Configuration public class ScheduleConfig { @Bean(destroyMethod = "shutdownNow") public ScheduledThreadPoolExecutor dynamicScheduler() { ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor( 4, r -> { Thread t = new Thread(r); t.setName("dynamic-scheduler-" + t.getId()); t.setDaemon(false); return t; }); executor.setRemoveOnCancelPolicy(true); executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false); return executor; } }destroyMethod 直接指定成 shutdownNow,Spring 容器关闭时会自动调用,不需要再写一遍销毁逻辑。实际使用中如果希望正在执行的任务能优雅结束,可以在 shutdownNow 前先调 shutdown 再等待,但对于定时任务这种场景,shutdownNow 配合任务的 try/finally 清理已经够用。
3.3 任务注册中心 DynamicScheduledTaskManager 实现
这是整个动态调度方案的核心类,我把它命名为 DynamicScheduledTaskManager,对外提供注册、修改周期、暂停、恢复、手动触发、查询等方法。
@Component public class DynamicScheduledTaskManager { private static final Logger log = LoggerFactory.getLogger(DynamicScheduledTaskManager.class); private final ScheduledThreadPoolExecutor scheduler; private final Map<String, TaskEntry> taskMap = new ConcurrentHashMap<>(); public DynamicScheduledTaskManager(ScheduledThreadPoolExecutor scheduler) { this.scheduler = scheduler; } public void register(String taskId, Runnable task, DynamicTaskOption option) { TaskEntry entry = new TaskEntry(taskId, task, option); TaskEntry old = taskMap.put(taskId, entry); if (old != null) { old.cancel(); } entry.reschedule(); log.info("dynamic task registered or replaced, taskId={}", taskId); } public void updateInterval(String taskId, long intervalMs) { TaskEntry entry = taskMap.get(taskId); if (entry == null) { throw new IllegalArgumentException("task not found: " + taskId); } entry.option.setIntervalMs(intervalMs); entry.reschedule(); log.info("dynamic task interval updated, taskId={}, intervalMs={}", taskId, intervalMs); } public void pause(String taskId) { TaskEntry entry = taskMap.get(taskId); if (entry == null) { throw new IllegalArgumentException("task not found: " + taskId); } entry.pause(); } public void resume(String taskId) { TaskEntry entry = taskMap.get(taskId); if (entry == null) { throw new IllegalArgumentException("task not found: " + taskId); } entry.resume(); } public void triggerOnce(String taskId) { TaskEntry entry = taskMap.get(taskId); if (entry == null) { throw new IllegalArgumentException("task not found: " + taskId); } scheduler.execute(entry.wrapTask()); } public List<Map<String, Object>> listTasks() { List<Map<String, Object>> result = new ArrayList<>(); taskMap.forEach((taskId, entry) -> result.add(entry.toMap())); result.sort(Comparator.comparing(m -> (String) m.get("taskId"))); return result; } private class TaskEntry { private final String taskId; private final Runnable task; private final DynamicTaskOption option; private final AtomicLong execCount = new AtomicLong(0); private volatile ScheduledFuture<?> future; private final AtomicBoolean running = new AtomicBoolean(false); private volatile boolean paused = false; private volatile long lastExecuteTime; private volatile long lastCostMs; TaskEntry(String taskId, Runnable task, DynamicTaskOption option) { this.taskId = taskId; this.task = task; this.option = option; } Runnable wrapTask() { return () -> { if (paused) { return; } if (!running.compareAndSet(false, true)) { log.warn("task {} still running, skip this round", taskId); return; } long start = System.currentTimeMillis(); try { task.run(); } catch (Throwable t) { log.error("task {} execute error", taskId, t); } finally { running.set(false); execCount.incrementAndGet(); lastExecuteTime = System.currentTimeMillis(); lastCostMs = lastExecuteTime - start; } }; } void reschedule() { Runnable wrapped = wrapTask(); long intervalMs = option.getIntervalMs(); long initialDelay = option.getInitialDelayMs(); if (option.getMode() == DynamicTaskOption.ExecType.FIXED_RATE) { this.future = scheduler.scheduleAtFixedRate(wrapped, initialDelay, intervalMs, TimeUnit.MILLISECONDS); } else { this.future = scheduler.scheduleWithFixedDelay(wrapped, initialDelay, intervalMs, TimeUnit.MILLISECONDS); } } void pause() { this.paused = true; cancel(); } void resume() { if (!paused) { return; } this.paused = false; reschedule(); } void cancel() { ScheduledFuture<?> f = this.future; if (f != null) { f.cancel(false); } } Map<String, Object> toMap() { Map<String, Object> map = new HashMap<>(); map.put("taskId", taskId); map.put("intervalMs", option.getIntervalMs()); map.put("mode", option.getMode().name()); map.put("paused", paused); map.put("execCount", execCount.get()); map.put("lastExecuteTime", lastExecuteTime == 0 ? null : new Date(lastExecuteTime)); map.put("lastCostMs", lastCostMs); ScheduledFuture<?> f = future; if (f == null || f.isCancelled()) { map.put("nextRunDelayMs", null); } else { map.put("nextRunDelayMs", f.getDelay(TimeUnit.MILLISECONDS)); } return map; } } }对应的参数类:
public class DynamicTaskOption { public enum ExecType { FIXED_DELAY, FIXED_RATE } private long intervalMs; private long initialDelayMs = 0; private ExecType mode = ExecType.FIXED_DELAY; public DynamicTaskOption(long intervalMs) { this.intervalMs = intervalMs; } public DynamicTaskOption(long intervalMs, ExecType mode) { this.intervalMs = intervalMs; this.mode = mode; } public long getIntervalMs() { return intervalMs; } public void setIntervalMs(long intervalMs) { this.intervalMs = intervalMs; } public long getInitialDelayMs() { return initialDelayMs; } public DynamicTaskOption setInitialDelayMs(long initialDelayMs) { this.initialDelayMs = initialDelayMs; return this; } public ExecType getMode() { return mode; } public DynamicTaskOption setMode(ExecType mode) { this.mode = mode; return this; } }这里有几个设计细节值得说一下。第一,register 方法遇到重复任务 ID 时,不会抛异常,而是直接替换旧配置。不管是发布新代码后重新扫描注册,还是运营在控制台误操作重复提交,这个行为都更友好。第二,任务包装器里 try/catch 了 Throwable,保证任何任务异常都不会把调度器弄挂。第三,running 这个原子开关不仅防重叠执行,还能在手动触发的时候判断当前任务是否在跑,如果上一轮没结束,手动触发也会被忽略,不会造成混乱。
3.4 运营管理接口:启停与调整周期
有了任务注册中心,管理接口就很简单了。我把接口设计成四个操作:查看任务列表、暂停、恢复、修改间隔。手动触发在排查问题时也特别有用,所以我额外加了一个 trigger 接口。
@RestController @RequestMapping("/api/schedule") public class ScheduleAdminController { private final DynamicScheduledTaskManager taskManager; public ScheduleAdminController(DynamicScheduledTaskManager taskManager) { this.taskManager = taskManager; } @GetMapping("/tasks") public List<Map<String, Object>> list() { return taskManager.listTasks(); } @PostMapping("/tasks/{taskId}/pause") public void pause(@PathVariable String taskId) { taskManager.pause(taskId); } @PostMapping("/tasks/{taskId}/resume") public void resume(@PathVariable String taskId) { taskManager.resume(taskId); } @PostMapping("/tasks/{taskId}/interval") public void updateInterval(@PathVariable String taskId, @RequestBody Map<String, Long> body) { Long intervalMs = body.get("intervalMs"); if (intervalMs == null || intervalMs <= 0) { throw new IllegalArgumentException("intervalMs must be positive"); } taskManager.updateInterval(taskId, intervalMs); } @PostMapping("/tasks/{taskId}/trigger") public void trigger(@PathVariable String taskId) { taskManager.triggerOnce(taskId); } }实际生产环境里,这类接口必须做权限控制,至少加个认证,不能裸奔到公网。操作审计日志也建议打上,谁改了哪个任务的周期,什么时间改的,排查问题的时候能省一半时间。
3.5 更 Spring 的接入方式:注解扫描注册
手动在业务代码里调用 taskManager.register 已经很简单了,但如果你希望业务方只写一个方法,就能被自动注册成动态任务,可以再定义一个注解。扫描时机放在 Spring 容器启动完成之后,也就是 ApplicationRunner 阶段,这样 Bean 都已经创建完成,字段注入也都完成了。
先定义注解:
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface DynamicScheduled { String taskId(); long intervalMs() default 60_000; }再写一个自动注册器:
@Component public class DynamicScheduledAnnotationRegistrar implements ApplicationRunner { private static final Logger log = LoggerFactory.getLogger(DynamicScheduledAnnotationRegistrar.class); @Autowired private ApplicationContext applicationContext; @Autowired private DynamicScheduledTaskManager taskManager; @Override public void run(ApplicationArguments args) { Map<String, Object> beans = applicationContext.getBeansWithAnnotation(Component.class); beans.forEach((beanName, bean) -> { Method[] methods = bean.getClass().getDeclaredMethods(); for (Method method : methods) { DynamicScheduled ds = method.getAnnotation(DynamicScheduled.class); if (ds == null) { continue; } if (method.getParameterCount() != 0) { throw new IllegalStateException("@DynamicScheduled method must have no params: " + method); } method.setAccessible(true); Runnable task = () -> { try { method.invoke(bean); } catch (InvocationTargetException e) { throw new RuntimeException(e.getCause()); } catch (IllegalAccessException e) { throw new RuntimeException(e); } }; taskManager.register(ds.taskId(), task, new DynamicTaskOption(ds.intervalMs())); log.info("dynamic task auto registered, taskId={}, method={}", ds.taskId(), method.getName()); } }); } }这样业务代码只需要写一个无参方法,打上注解:
@Component public class OrderStatService { @DynamicScheduled(taskId = "order-stat", intervalMs = 60_000) public void stat() { // 统计订单数据 } }需要注意,反射调用建议用 public 方法,避免 module 访问限制或者私有方法被代理类改写后取不到注解。如果方法所在类经过 Spring AOP 代理,getDeclaredMethods 拿到的可能是代理子类的方法,所以方法声明成 public 最稳妥。
4. 实际运行中的坑与排查经验
4.1 任务被“静默取消”的元凶:未捕获异常
这个是 ScheduledExecutorService 周期任务最容易踩、也最隐蔽的坑。当周期任务执行时抛出未捕获异常,ScheduledThreadPoolExecutor 内部的 runAndReset 会捕获异常并标记 future 失败,然后这个周期任务就从调度队列里消失了。表面上工作线程还在,线程池也没有崩,日志里往往什么都没有,任务就是不再执行了。
Spring 的 @Scheduled 也有同样的问题。ScheduledMethodRunnable 执行方法时如果抛出异常,异常会继续抛到 ScheduledFutureTask 里面,最后同样是任务被取消、静默停止。我在生产环境遇到过三次这种“任务某天突然不跑了”的情况,排查下来无一例外都是业务代码抛了异常。
解决办法就是任务包装层统一 try/catch,也就是我在 TaskEntry.wrapTask 里做的事情。业务代码自己可以不管异常,但调度框架一定不能让异常越过 run 方法。如果你想在任务失败时告警,可以把异常信息也记录到日志里,再决定是否回调监控系统。
4.2 fixedRate 叠加互斥锁,防止任务重叠
任务重叠是 fixedRate 模式下最典型的故障。比如任务每 5 秒执行一次,但一次执行就要 10 秒,那么到第 5 秒时,下一个任务实例就会被投递到队列。如果线程池里有多个空闲线程,两个实例会同时执行,如果你的任务操作的不是幂等资源,就会出现重复插入、重复扣减之类的问题。
解决思路有两个层面。第一个层面是调度模式换成 fixedDelay,从根上避免重叠。第二个层面是在任务执行入口加互斥,我代码里的 running.compareAndSet(false, true) 就是干这个的,即使调度器在 fixedRate 模式下把下一个实例投递进来了,互斥开关检测到上一轮还没结束,就直接跳过这一轮。这个开关相当于给任务加了一把进程级锁,在多线程环境中保证了“同一时间同一个任务只有一个实例在跑”。
4.3 removeOnCancelPolicy 和内存泄漏
如果你取消了很多任务,但堆内存不降,反而缓慢上涨,大概率是这个配置没设置。DelayedWorkQueue 对取消任务的处理策略默认是延迟移除:任务虽然被 cancel 了,但它对象仍然留在队列里,直到它的原始延迟时间到期才被清理。如果某个任务的周期是一天,你把它 cancel 掉了,这个对象还得在堆里存活最多一天。
动态调度场景里这种情况特别常见,运营频繁调周期、停任务,每次都产生残留对象,线程池又是长生命周期对象,日积月累就是内存泄漏。设置 setRemoveOnCancelPolicy(true) 之后,cancel 的任务会立刻从队列中移除,这个问题就彻底没了。这是我在排查一次上亿级请求量服务的内存问题时发现的,当时堆栈里密密麻麻全是 ScheduledFutureTask 对象,一度怀疑是框架泄漏。
4.4 SpringBoot 默认单线程调度器的隐性问题
再提醒一个和 SpringBoot 默认配置相关的坑。如果你用的是 @Scheduled,没有显式配置 TaskScheduler,SpringBoot 自动装配会创建一个单线程的 ThreadPoolTaskScheduler,核心线程数默认是 1。多个 @Scheduled 任务如果在同一时刻被触发,它们会在这个单线程调度器上排队执行。某个任务执行 30 秒,后面的任务就得等 30 秒,表现出来就是“执行时间对不上”。
自研 DynamicScheduledTaskManager 反而没有这个问题,因为它用的是你自己创建的 ScheduledThreadPoolExecutor,核心线程数由你控制。这也是为什么我前面一再强调 corePoolSize 要根据“可能同时执行的任务数峰值”来配置。如果任务之间有强依赖,需要控制并发顺序,那要单独处理,不能让线程池背锅。
4.5 停机阶段任务还在跑的处理
Spring 容器关闭时,ScheduledThreadPoolExecutor 默认的策略是:延迟任务和周期任务继续等待,直到它们各自的延迟时间到了或者被 shutdownNow 打断。如果不做处理,一个周期一天的定时任务可能在停机后还要赖在队列里,把应用关闭流程拖得很慢。我在配置里把 executeExistingDelayedTasksAfterShutdownPolicy 设置成 false,就是为了关闭时直接把排队中未执行的延迟任务清空,让应用快速结束。
shutdownNow 会对正在执行的任务发送中断信号。如果你的任务内部没有吃掉 InterruptedException,也没有对 interrupt 标志做特殊处理,那么正在跑的那一轮会结束得快一些;但如果你任务里用的是 try/catch 把所有异常吞掉,shutdownNow 也拿它没办法。所以任务内部还是建议对中断做一次检查,尤其是在循环里放 Thread.currentThread().isInterrupted() 判断。
4.6 多实例部署时,定时任务重复执行怎么办
这个方案毕竟是进程内的,两台机器部署同一个服务,每台机器都会跑定时任务,处理同一批数据就会重复。最简单有效的兜底办法是在任务执行前加一把分布式锁。比如基于 Redis 的 setnx 操作,value 存当前实例的唯一标识和日期,拿到锁的节点才执行任务逻辑,执行完再释放锁。
用代码表达大致是这样:
String lockKey = "schedule:lock:" + taskId; String lockValue = UUID.randomUUID().toString(); Boolean locked = redisTemplate.opsForValue() .setIfAbsent(lockKey, lockValue, 30, TimeUnit.SECONDS); if (Boolean.TRUE.equals(locked)) { try { task.run(); } finally { if (lockValue.equals(redisTemplate.opsForValue().get(lockKey))) { redisTemplate.delete(lockKey); } } }锁的过期时间要远比任务最大执行时间长,否则任务没跑完锁就过期了,别的节点会趁机进来重复执行。但这套方案每次都会和 Redis 通信一次,如果只是为了两三个定时任务,其实也可以接受。等到业务量真的大到需要任务分片、需要从中心化平台配置任务时,就该认真考虑分布式调度框架了。
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 周期任务跑几次后消失,无任何报错 | 任务抛出未捕获异常,调度器静默取消 | 任务包装层统一 try/catch(Throwable) |
| 任务执行时间和周期重叠,数据重复 | fixedRate 模式下任务未结束就触发下一轮 | 切换 fixedDelay 或在入口加互斥开关 |
| 取消任务后内存缓慢上涨 | DelayedWorkQueue 延迟移除取消任务 | 设置 setRemoveOnCancelPolicy(true) |
| @Scheduled 多任务互相排队延迟执行 | SpringBoot 默认单线程调度器 | 配置 pool.size 或使用自研多线程调度 |
| 应用停机很慢 | 延迟任务在 shutdown 后仍等待 | 设置 executeExistingDelayedTasksAfterShutdownPolicy(false) |
| 多实例部署定时任务重复执行 | 进程内调度器不跨节点协调 | Redis 分布式锁或更换分布式调度框架 |
5. 什么时候别再用这个方案
5.1 轻量方案的适用边界
没有银弹,自研方案必须清醒认识自己的边界。我建议这样判断:如果项目是单机部署,任务数量在几十个以内,调度策略主要是固定间隔,并且不需要完整的执行历史记录,那 ScheduledExecutorService 这套方案是最划算的,代码量小、不引入额外依赖、排查链路短。
如果项目已经有多台机器,你通过分布式锁强行控制不重复执行,同时任务数量开始超过几十个,运营又天天在后台折腾各种调度配置,那自研方案就会变成负担。因为你要开始考虑任务配置的持久化、任务的动态新增、任务执行记录的存储与查询、失败重试策略,这些写起来的工作量远超过最初引入一个调度平台的成本。
5.2 哪些信号出现时,该考虑上分布式调度框架
出现这三个信号,我基本会认真考虑迁移到成熟方案:第一个是任务需要路由到指定机器执行,比如某个任务必须跑在拥有特定数据的节点上;第二个是任务需要完善的可视化管理和告警;第三个是任务配置和数据需要集中管理、多环境复用。这种情况下,继续在自定义方案上加功能,往往越加越复杂,最后变成一个四不像的“半成品框架”,维护成本很难受。
我并不是说 JDK 自带的调度能力不行,相反,绝大多数项目的定时任务需求用 ScheduledExecutorService 已经完全够用。关键在于你要知道自己的项目处于哪个阶段,再决定要不要引入更重的东西。架构选型最怕的不是“选错了”,而是“用错了场景”。
用下来我的一个体会是:ScheduledExecutorService 这套方案真正考验人的不是 API,而是任务的生命周期管理和异常边界。线程池创建、任务包装、状态切换、取消策略,任何一个细节处理不好,线上都会出莫名其妙的问题。但只要你把框架搭对,用起来是真的省心。最后再提醒一句,不管用什么方案,任务日志一定要打完整,定时任务这种无人值守的东西,日志就是唯一的线索来源,我排查过的所有调度诡异问题,最后都是靠日志翻出来的。