☰
基于JDK线程池的SpringBoot动态定时任务
2026/10/3 1:08:00 网站建设 项目流程

前阵子一个朋友在群里问:对账任务用的 @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,而是任务的生命周期管理和异常边界。线程池创建、任务包装、状态切换、取消策略,任何一个细节处理不好,线上都会出莫名其妙的问题。但只要你把框架搭对,用起来是真的省心。最后再提醒一句,不管用什么方案,任务日志一定要打完整,定时任务这种无人值守的东西,日志就是唯一的线索来源,我排查过的所有调度诡异问题,最后都是靠日志翻出来的。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询