1. 为什么想把 XXL-JOB 换掉
先说结论:XXL-JOB 不是不好,它是太“重”了。坐标一个中等规模的微服务集群,注册发现本来就走 Nacos,配置也统一放 Nacos 管理,结果为了跑定时任务,硬生生要多维护一套调度中心、一个任务调度库,外加一堆客户端依赖。这套东西刚接入时确实爽,调度可视化、失败重试、任务日志全都现成,但等业务跑起来、集群开始扩缩容的时候,痛点就一个个冒出来了。
首先是部署成本和维护成本。调度中心本身要保证高可用,官方推荐双机部署,得配独立的数据库存放调度日志、任务注册信息。每次版本升级都要盯着表结构变更,稍微大意就可能出现调度记录丢失或者任务莫名其妙不执行的情况。而且整个集群里所有执行器要主动向调度中心注册,心跳断了任务就慌,网络抖动一次,控制台上一片红,值班的人半夜被叫起来处理“假宕机”是常有的事。
其次是它的“中心化”模型和现在的弹性架构有冲突。微服务缩容到两个节点,调度中心还挂着上百个任务记录,触发的任务虽然能路由到存活执行器,但中间隔了一层调度分发,链路长了不说,执行的实时性也受限。更麻烦的是,很多任务其实只是调用本服务内部的方法,犯不上走一轮远程 RPC,调度中心却硬生生把它变成了跨节点调用,白白增加一次网络开销。
当然,XXL-JOB 的功能本身没什么毛病,比如分片广播、动态修改 Cron、执行日志可视化管理,这些都是它受欢迎的原因。但如果我们换一个角度思考:任务本来就是业务代码的一部分,执行器节点本来就在同一个注册中心里,那为什么不把“调度”这层能力直接下沉到业务节点里,让每个节点自己判断要不要执行、什么时候执行?这也是我这个项目最核心的出发点——利用已有的 Nacos 基础设施,去掉独立的调度中心,让任务调度回归到业务进程内部。
这类方案尤其适合已经有 Nacos、还没有引入独立任务调度平台的中小型团队,以及被 XXL-JOB 维护成本逼得想砍掉中心节点的老项目。
2. 基于 Nacos 的调度方案整体设计
2.1 核心架构:调度能力内嵌进业务服务
整个方案可以总结成六个字:配置在 Nacos,调度在本地。所有任务的定义、Cron 表达式、执行参数、开关状态,都作为配置项放在 Nacos 配置中心里;业务服务启动后,通过 Nacos 的 ConfigService 拉取任务配置,然后用进程内的调度器按 Cron 触发任务。
这意味着一套系统里再也不会出现“调度中心挂了任务全部停摆”这种局面。每个业务节点天然是一个调度节点,同时也天然是一个执行节点。由于 Nacos 本来就在注册中心里维护着所有存活实例,所以任务分发只需要在业务代码里调用一下 NamingService 拿到在线实例列表,再做按规则选择即可。
模块拆开来看,整个调度系统由三块组成:
- 任务配置层:负责从 Nacos 读取任务定义、订阅配置变更、管理任务启停。
- 调度执行层:基于 Java 自带的 ScheduledExecutorService 或者 Quartz,按 Cron 触发任务,触发后完成“是不是该我执行”的判断。
- 执行器注册层:业务代码里实现统一的任务处理接口,通过一个内部注册表把 Handler 名称和实际执行逻辑关联起来。
这三块全都跑在业务进程内,没有额外部署任何中间件。之前 XXL-JOB 里负责管理和调度的“中心节点”消失了,取而代之的是每个实例上运行的一个轻量调度库。这个库只做两件事:解析 Nacos 配置、驱动本地调度器按规则触发任务。执行结果可以写回业务自己的数据库或日志系统,不需要再维护一份独立的调度日志表。
2.2 任务触发模型:本地触发 + 分布式收敛
这是我这个方案里最关键的思路。所有节点都会同时触发同一个任务,但最终只有一个(或按分片规则选出的少数)节点真正执行。所以真正要解决的问题,从“中心怎么把任务派给谁”,变成了“每个节点如何自主判断自己该不该干活”。
设计上我支持三种模式:
- sharding 模式:利用一致性哈希,把某个执行周期固定路由到某个节点上。所有节点本地触发,但只有被哈希选中的节点真正执行。
- exclusive 模式:所有节点同时尝试抢占分布式锁,谁拿到锁谁执行。适合对执行节点不敏感、只要求单次执行的场景。
- broadcast 模式:所有存活节点全部执行,适合刷新本地缓存、同步路由表这类需要全量广播的任务。
这三种模式基本覆盖了 XXL-JOB 最常用的几种任务类型。分片模式替代它的“分片广播”,独占模式替代它的“单机路由”,广播模式替代它的“广播执行”。而触发源也从 XXL-JOB 的调度中心统一触发,变成了每个节点各自用 Cron 触发,再用同一套收敛规则保证执行结果不重不漏。
可能有人会问:如果每个节点都触发,那是不是每个节点都要计算一次路由?确实是这样,但这个计算非常轻,就是一次哈希取模,性能开销可以忽略。而换来的好处是架构上彻底去掉了中心节点,不会再因为调度中心负载过高或者宕机而影响任务执行。
2.3 为什么说它“优雅”
用一个生活化的例子解释:XXL-JOB 就像公司里的前台,所有人都把需求交给他,再由他分配给具体办事的人。前台效率高还好,一旦前台请假或者忙不过来,整个公司的流程就堵住了。而我这个方案相当于把审批权下放到每个员工手里,大家每天自己看排班表,排到自己就干活,排不到就继续做自己的事。
这种模型天然具备水平扩展能力。业务节点增加,调度能力跟着增加;业务节点减少,剩下的节点自动接管路由结果,不需要额外配置。把一个调度平台退化成“一个配置 + 一段代码”,运维负担自然小很多。
这里必须指出一个前提:方案把 Nacos 当作依赖,实际情况里 Nacos 高可用性几乎都是有保障的,因为它同时承担着服务发现和配置管理。但如果你的环境里连 Nacos 都没有,那这个方案就不成立。它是给已经有 Nacos 的团队准备的,不是万能药。
3. 核心实现细节与实操要点
3.1 任务配置模型与 Nacos 数据结构设计
任务配置我建议放在一个独立的 Group 下,比如SCHEDULER_GROUP,每个任务单独一个 dataId。这样的好处是任务之间配置隔离,修改单个任务时不会影响其他任务,而且 Nacos 天然的配置回滚能力也能用在任务回滚上。
dataId 命名规则:
scheduler/{appName}/{taskName}比如scheduler/order-service/orderTimeoutTask。每个任务的配置内容采用 JSON 格式:
{ "taskName": "orderTimeoutTask", "handler": "orderTimeoutHandler", "mode": "sharding", "cron": "0 0/15 * * * ?", "enable": true, "retryTimes": 1, "desc": "订单超时未支付自动取消" }字段里最核心的是handler,它对应业务代码里注册的执行器 Bean 名称。mode控制上面说的三种执行模式。retryTimes是失败后的重试次数。enable用来灰度关闭某个任务,不需要改代码或者重新发布。
这个配置模型有几个隐藏的好处:首先是任务参数可以和业务配置一起走 Nacos 的权限体系,不需要单独开发一套任务管理后台;其次配置变更可以记录在 Nacos 的变更历史里,误操作后可以一键回滚;最后是任务的 Cron 修改、参数调整都可以动态生效,不用重启服务。
3.2 执行器接口设计与注册
业务侧只需要实现一个统一接口。这里我参考了常见任务框架的做法,定义了一个 TaskHandler 抽象:
public interface TaskHandler { TaskResult execute(TaskContext context); }TaskContext 里带上了任务名、执行周期、路由模式、以及从配置里透传过来的自定义参数。TaskResult 只需要返回成功或者失败标记,失败时调度器会根据 retryTimes 决定是否重试。
具体业务实现就是一个普通的 Spring Bean:
@Component("orderTimeoutHandler") public class OrderTimeoutHandler implements TaskHandler { @Override public TaskResult execute(TaskContext context) { // 处理超时订单 return TaskResult.success(); } }启动时把这些 Bean 收集到一个注册表里,后续拉取到任务配置后,就能根据配置里的 handler 名称直接找到对应的执行逻辑。这套机制非常简单,但足够满足绝大多数场景,再复杂的任务本质上就是把多个这样的 Handler 串成一条流水线。
3.3 调度器启动与动态刷新
调度器启动流程分成三步:
- 从 Nacos 拉取本服务所有任务配置。
- 解析配置,通过 Spring 的 TaskScheduler 注册 CronTrigger。
- 为每个任务注册 Nacos 配置监听器,配置变更时重建调度器。
核心代码示意:
public void init() { List<String> taskIds = configService.getConfigsForGroup(GROUP_ID); for (String dataId : taskIds) { String content = configService.getConfig(dataId, GROUP_ID, 5000); TaskMeta meta = JSON.parseObject(content, TaskMeta.class); registerTask(meta); configService.addListener(dataId, GROUP_ID, new AbstractListener() { @Override public void receiveConfigInfo(String configInfo) { TaskMeta newMeta = JSON.parseObject(configInfo, TaskMeta.class); scheduler.reSchedule(newMeta); } }); } }这里有一个非常容易踩的坑:配置变更监听回调里如果直接做重调度,很可能会发生“任务间隙”。比如旧的调度器取消后,新的调度器还没注册成功,这个空档期里任务就不触发了。所以重调度建议用“预注册 + 确认后再取消旧任务”的方式,先注册新的定时任务,确认注册成功后,再 Cancell 掉旧的 ScheduledFuture。
还有一个细节,任务触发执行的线程最好不要直接用 Nacos 配置监听的回调线程。配置监听回调线程池很小,一旦里面执行了耗时的任务注册逻辑,会影响其他配置项的通知。稳妥的做法是把配置解析和重调度逻辑丢到独立的线程池里去。
3.4 任务触发与路由判断的实现
这是整个调度方案最核心的部分,单独展开讲。
每个任务在到达 Cron 触发点时,所有存活节点都会同时收到触发信号。此时每个节点要执行一段“判断逻辑”,确认自己是不是本轮执行的目标节点。
我的实现思路是这样的:
void onTrigger(TaskMeta meta) { // 1. 生成本轮执行周期标识 long roundAt = context.getScheduledTime().getTime() / 1000; // 2. 根据不同模式判断是否执行 if ("sharding".equals(meta.getMode())) { Instance target = route(meta, roundAt); if (!target.isThisNode()) { return; // 不是本节点的活,直接跳过 } } else if ("exclusive".equals(meta.getMode())) { if (!lockService.tryLock(meta.getTaskName(), roundAt)) { return; } } // 3. 真正执行任务处理器 TaskResult result = registry.get(meta.getHandler()).execute(createContext(meta)); // 4. 记录执行结果与耗时 logService.record(meta, roundAt, result); }sharding 模式的路由算法用的是简单一致性哈希:
Instance route(TaskMeta meta, long roundAt) { List<Instance> instances = namingService.selectInstances(meta.getAppName(), true); List<String> nodes = instances.stream() .map(i -> i.getIp() + ":" + i.getPort()) .sorted() .collect(Collectors.toList()); String key = meta.getTaskName() + "#" + roundAt; int index = Math.floorMod(key.hashCode(), nodes.size()); String target = nodes.get(index); return findLocalInstance(target, instances); }这里有几个细节值得注意。
节点列表做排序非常重要。如果不排序,每次从 Nacos 拉到的实例顺序可能不一样,同一个哈希结果会命中不同的节点,任务就会重复执行。排序后,只要实例列表内容一致,哈希结果就稳定。
roundAt 的引入也很关键。如果直接用当前时间戳做哈希,每个节点触发时刻差 100 毫秒,哈希结果就完全不同。必须用一个稳定的时间窗口标识,比如按照任务的 Cron 周期计算出一个固定的 roundAt,所有节点都拿这个同一个值去哈希。最简单的方式是使用 Cron 上一次计划触发的秒级时间戳,这个值对所有节点来说是一致的。
3.5 失败重试与故障转移
任务执行失败后的重试逻辑,我做了两层设计。
第一层是本地重试,执行失败的 Handler 会在当前节点上按 retryTimes 重新执行。这个逻辑适合处理临时性故障,比如数据库连接抖动、调用远程接口超时。本地重试的间隔建议用指数退避,而不是立刻重试,否则在高频任务场景下容易把下游服务打爆。
第二层是跨节点转移。如果本地重试全部失败,任务需要转移到下一个候选节点。转移的核心是把路由下标往后挪一位,重新计算目标节点。代码里体现为:
int baseIndex = Math.floorMod(key.hashCode(), nodes.size()); int retryIndex = (baseIndex + retryOffset) % nodes.size();重试一次就向后偏移一个节点,保证不会永远命中同一个故障节点。这个机制在节点宕机、网络故障的场景下特别有用。
当然,跨节点转移需要被转移的节点上有对应的 Handler 注册。好在所有业务节点部署的是同一套代码,这个条件天然满足。转移后的执行结果仍然写回统一的日志表,不会因为换了节点就丢失记录。
3.6 动态启停与优雅下线
任务下线有两种情况:一种是在 Nacos 配置里把 enable 改成 false,另一种是服务节点要缩容下线。
对于第一种情况,监听器检测到 enable 为 false 后,直接取消当前任务对应的 ScheduledFuture 即可。注意一定要把已注册的监听器也移除,否则配置反复变更会造成监听器堆积。
对于第二种情况,问题稍微复杂。节点下线前,如果当前节点正在执行长任务,强制重启会导致执行到一半的任务断掉。我的做法是预留一个关闭钩子,在 Spring 容器销毁前等待正在执行的任务完成,设置一个最长等待时间,比如 30 秒。超过时间仍然强制退出,避免拖慢整个滚动发布流程。
另外,节点从 Nacos 摘除后,如果这时正好有任务路由到它,执行会失败。但因为 Nacos 的心跳摘除是秒级生效的,等下一个周期任务触发时,实例列表已经刷新,失败转移机制会自动把任务指到其他节点。这比 XXL-JOB 的“执行器下线后要手动删除”体验好不少。
4. 和 XXL-JOB 的对照实测
光说理论没什么感觉,我拿实际对比数据来说话。以下是我在同一套业务集群上分别跑 XXL-JOB 和这套 Nacos 方案的对照结果。
4.1 部署与资源占用对比
XXL-JOB 这套体系,至少要部署两个调度中心节点来保证高可用,还需要一个独立的调度数据库。实测下来,两个调度中心节点各占 1C2G,调度库占 0.5C1G 外加磁盘存储,合计差不多 2.5C5G 的资源被调度平台白白吃掉了。
换成 Nacos 方案后,这部分资源完全省掉。调度逻辑运行在已有的业务节点内,每个节点额外开销大约 20 到 30MB 内存,CPU 几乎可以忽略,因为绝大多数时间调度器都在睡眠等待 Cron 触发。
| 对比项 | XXL-JOB | Nacos 方案 |
|---|---|---|
| 独立调度中心 | 需要,至少 2 节点 | 不需要 |
| 独立调度数据库 | 需要 | 不需要 |
| 额外内存占用 | 约 2.5C5G | 每节点 30MB 以内 |
| 新增中间件种类 | 调度中心 + DB | 无 |
更重要的是,XXL-JOB 的调度中心本身存在性能上限。任务量涨到几十万个以后,调度分发开始出现延迟,要么给调度中心加机器,要么做调度中心集群分片。而 Nacos 方案的调度能力是跟着业务节点走的,业务扩到 20 个节点,调度能力就是 20 个节点的水平,不存在中心瓶颈。
4.2 任务变更效率对比
XXL-JOB 改一个任务的 Cron 表达式,流程是登录控制台、找到任务、点编辑、改时间、保存。操作本身不难,但这里有个坑:多人协作时,如果谁不小心改了线上任务又没通知别人,排查问题会非常痛苦。而且控制台没有配置回滚能力,一旦改错,只能靠手动改回来。
Nacos 方案里,任务配置就是一段文本。改 Cron 无非是改配置再发布,走 Nacos 的配置发布流程,天然带上变更历史。改错了直接回滚到上一个版本,秒级生效。配合 Nacos 的权限管控,甚至可以限制只有特定人员能修改任务配置。
还有一个体验差异极大地方:XXL-JOB 的任务列表存储在调度数据库里,想看任务下一次执行时间,得去控制台刷新页面。Nacos 方案可以直接通过 API 读取任务配置并计算下一次执行时间,方便做任务巡检和自动化校验。
4.3 故障转移行为对比
XXL-JOB 的执行器挂了,调度中心要等几个心跳周期才能把任务路由到其他执行器。这个期间任务会有一到两轮的失败。如果执行器和调度中心之间的网络出现分区,调度中心可能误判执行器下线,导致同一任务在两台执行器上重复执行。
Nacos 方案里,节点健康状态由 Nacos 的心跳机制维护,摘除动作通常在 5 秒内完成。任务路由是根据最新实例列表实时计算的,理论上只要哈希到了不健康节点,下一轮就会自动避开。再加上前面说的跨节点重试偏移机制,任务的故障转移时间能控制在秒级。
当然说白了,两种方案都不可能做到绝对的零重复。调度系统里“恰好一次”本身就是一个很难达成的目标,更常见的是“至少一次”加业务幂等兜底。这块后面会细说。
5. 踩坑与排查经验
5.1 任务重复执行:实例列表不一致
这套方案上线第一周我就遇到了一次线上重复执行事故。现象是同一个任务,同时有两个节点在跑,而且都执行成功了。查了半天,根因是两台节点从 Nacos 拉到的实例列表不一致。一台节点刚上线,注册信息在另一台节点上还没同步过来,导致哈希取模的集合不同,最终命中了两台不同的执行者。
Nacos 的服务发现是 AP 模型,实例列表的最终一致性需要时间。在节点频繁上下线的时候,这种窗口期是真实存在的。
解决办法有几个层面。首先是首选要接受“至少一次”的语义,在业务侧做幂等,比如订单处理任务里先更新状态,再处理后续逻辑,用数据库唯一索引兜底。其次是任务执行前额外做一次“二次确认”,比如在共享存储里插入一条本次 roundAt 的执行记录,如果插入冲突就主动放弃。最后才是优化节点列表获取逻辑,尽量减少窗口期。
我后来在路由里加了缓存降级策略:节点列表刷新失败时,沿用上一次成功的实例列表,宁可路由偏移也不要在列表不一致的情况下盲目执行。这个改动有效降低了重复执行的频率。
5.2 配置变更导致的调度空窗
项目早期版本里,重调度逻辑是先取消旧任务,再注册新任务。这导致每次改 Cron 或改配置时,任务会有几秒钟到十几秒钟的空窗期。如果恰好这个空窗期覆盖了任务原本的执行时间,那次任务就漏掉了。
后来把重调度流程改成了“先注册新任务、再取消旧任务”的顺序。因为 Spring 的 TaskScheduler 允许同一个任务短期内注册两个调度器,它们使用的是同一个调度线程池,新调度器注册成功后,旧调度器的下一次触发时间会被跳过。所以先注册再取消,几乎没有空窗。
这个调整需要注意线程池大小。如果调度线程池只有默认的一个线程,新任务注册后可能被旧任务阻塞。我建议调度线程池核心线程数至少设置为 4 到 8,避免多个任务同时触发时互相等待。
5.3 长任务阻塞调度线程
还有一个很容易犯的错:直接在任务 Handler 里做耗时的同步操作,比如大批量数据清洗、复杂报表生成。这些操作动辄几分钟,而调度线程池如果被占满,其他任务的触发就会延迟。
解决思路是调度线程只负责触发,真正的业务逻辑提交到独立的业务线程池里去执行。我的做法是给每个任务配置一个独立的线程池,或者共用一个大线程池,通过线程池隔离避免任务之间互相干扰。
这里要特别注意线程池的拒绝策略。如果任务执行频率高于线程池处理能力,队列会积压,积压到一定程度会触发拒绝策略。我采用 CallerRunsPolicy 作为兜底,让调度线程自己执行任务,保证任务不丢,只是延迟执行时间变长。这在业务上通常是可以接受的。
5.4 Nacos 客户端断连后的行为
一次线上演练中,我把 Nacos 服务短暂停掉了几分钟,发现已经注册好的定时任务还在正常触发。原因是本地调度器已经拿到了任务配置,Cron 逻辑完全在本地运行,不依赖 Nacos 的实时通信。
但问题是,这期间如果任务需要重新路由或者从 Nacos 拉取实例列表,就会因为客户端断连而失败。我的处理是给 NamingService 的调用加缓存和降级逻辑,优先使用最近一次成功的实例列表。如果缓存为空,则跳过本轮任务而不是直接报错。
配置中心断连后,配置监听会失效。这意味着断连期间修改的任务配置,在 Nacos 恢复前不会生效。所以任务配置发布后,最好确认一下所有节点都收到了新配置。我的做法是发布配置后,在日志系统里追踪节点拉取配置的动作,正常情况下几秒钟内所有节点都会打印出“任务配置已更新”的日志。
5.5 几个常见问题速查
| 现象 | 可能原因 | 排查路径 |
|---|---|---|
| 任务完全不触发 | enable 为 false 或者 Cron 写错 | 检查 Nacos 配置,确认监听器是否注册成功 |
| 任务触发两次 | 实例列表不一致 | 对比各节点日志中打印的节点列表,检查是否存在注册延迟 |
| 任务延迟执行 | 调度线程池被长任务占满 | 查看线程池活跃数,改为独立业务线程池 |
| 修改配置后没生效 | Nacos 监听失效或客户端断连 | 查看节点日志是否有配置更新记录,检查 Nacos 客户端连接状态 |
| 跨节点重试不生效 | 目标节点的 Handler 未注册 | 确认所有节点部署的是同一版本代码 |
6. 后续可以这样扩展
其实这个方案还能继续往前推一步。目前路由规则是写在代码里的,如果业务团队对任务调度有更细的要求,可以把路由规则做成可配置的,比如指定某些任务只跑在特定标签的节点上,或者按照机房就近执行。实现方式是在任务配置里增加一个 tags 字段,在实例列表里根据 Nacos 的 metadata 做过滤。
可观测性方面也值得完善。现在任务执行日志是统一写库的,后续可以补充一个轻量的执行历史查询接口,或者把执行指标接入现有的监控系统,对执行耗时、失败率、调度延迟做告警。这些和业务代码耦合度很低,都是在执行器外层包一层切面就能完成的事情。
个人更推荐的做法是优先把执行结果统计做起来。任务调度系统最怕的不是任务失败,而是失败了你不知道。有了执行趋势和成功率数据,调度系统的日常巡检就从被动等告警变成了主动看趋势,质量上能上一个台阶。