我扔掉了笨重的XXL-JOB,换成基于Nacos的优雅调度方案
先交代背景:我之前在一家中小型团队做微服务架构,线上跑着大概二十多个服务,定时任务数量不算夸张,但每个业务线都要用。早期图省事,直接上了XXL-JOB,功能确实全,调度中心、执行器、失败重试、任务分片都有,可随着服务越拆越细,部署链路越来越长,XXL-JOB这套东西给我的感觉从“好用”慢慢变成了“沉重”。
真正压垮我的是一次日常发布:调度中心所在的机器需要迁移,结果发现它依赖的MySQL表结构、执行器路由策略、权限账号、日志表清理策略全都耦合在一起,迁移一次差点把整个测试环境搞挂,那会我就知道必须换方案了。反复调研之后,我决定把XXL-JOB彻底拿掉,换成基于Nacos自建的一套轻量分布式调度方案。这篇文章就是记录这次替换过程中我的设计思路、核心代码、参数选型,以及踩过的那些坑。
整套方案的核心只有一句话:让Nacos既当注册中心,又当配置中心,再借助它的一致性能力做分布式协调,把原本依赖外部调度平台的活儿收敛到业务服务内部。如果你也有类似的痛点,或者正纠结要不要上XXL-JOB,这篇应该能给你一个完全不同的视角。
1. 为什么我决定放弃XXL-JOB
不能上来就说XXL-JOB不好,它确实解决过问题,否则当年我也不会选它。但放到现在的团队规模和业务形态下,它的很多设计反而成了运维负担。
1.1 XXL-JOB的“重”体现在哪
第一是部署结构重。XXL-JOB分成调度中心(admin)和执行器(executor)两端,调度中心本身是一个独立Spring Boot应用,需要单独部署、单独配置数据库。这意味着你的交付清单里永远多一套东西:admin的机器、admin的DB、admin的配置、admin的监控。曾经我为了给调度中心做一个轻量的高可用,又引了Nginx和额外的DB主从,一套调度系统搭下来,工作量不比搭一个业务核心服务少。
第二是使用方式重。任务代码里要引入xxl-job的SDK,配置执行器的AppName、IP、端口,然后要到admin后台手动录入任务,绑定执行器,设置cron,还要为每个任务考虑路由策略、阻塞策略、失败重试次数。业务上只是想“每天凌晨跑个数据对账”,结果你得理解一堆概念才能把任务跑起来,这本身就是一种认知负担。
第三是运维成本重。XXL-JOB的调度记录、执行日志都落在自己的表里,任务量一大,日志表膨胀得飞快。我见过线上调度日志表三个月就攒了几千万行,查询一次慢到几十秒,最后还得写定时清理任务去清日志——用调度平台还得给调度平台自己写任务,这个循环怎么看都不太对劲。
1.2 轻量调度的核心诉求
换方案之前,我把自己的需求列了一个清单,核心就四条。
第一,不想要独立的调度中心。任务应该作为业务服务的一部分存在,部署跟随服务走,不需要单独的机器和数据库。
第二,支持分布式协调。多实例部署时,同一个任务在同一时刻只能被一个节点执行,不能出现重复消费。
第三,配置要能动态调整。比如临时把某个任务的开关关掉、把执行周期从每小时改成每两小时,最好能实时生效,不需要重新发布。
第四,学习成本要低。团队里的大部分工程师只要懂Spring Boot和基本微服务概念就能上手。
我当时第一个想到的替代品是Quartz集群版,可它自带的JDBC JobStore也要单独建表,分布式的粒度也比较粗糙。后来我仔细看了一下项目里已经有的Nacos,突然意识到:Nacos本身既带注册中心又带配置中心,而且它内部的服务发现和配置管理都是基于一致性协议实现的,如果拿它来做任务节点协调和任务开关管理,不是刚好能覆盖我那四条诉求吗?
于是方案就这样定了:不引入任何额外的调度中间件,基于Nacos + Spring Boot自研一个极简调度组件。
2. 基于Nacos的调度方案整体设计
整个方案在设计上分两块:Nacos负责协调和配置,业务服务内部负责执行。听起来很简单,但落地的时候有不少细节要抠,我先讲清楚整体思路,再贴关键实现。
2.1 Nacos在方案中扮演两个角色
第一个角色是注册中心。服务启动时把自己的实例信息注册到Nacos,所有任务执行节点组成一个临时集群。Nacos天然支持心跳检测,实例挂了会自动摘除,这正好用来感知执行节点的存活状态。
第二个角色是配置中心。所有任务的开关、cron表达式、参数都放在Nacos的配置里,通过dataId和group来区分不同服务、不同环境。配置一旦变更,Nacos会主动推给客户端,业务服务监听变更事件后刷新内存中的任务定义,就能实现不停机调整。
有人可能会问:那任务到底怎么触发?这里我用了Nacos作为协调者,但触发的核心是每个节点上的调度线程。每个服务实例启动一个调度器,根据本地缓存的任务定义计算下一个执行时间,时间到了就触发执行。为了保证多实例不会重复执行,所有实例在触发之前先通过Nacos去抢一个分布式锁,抢到锁的实例才真正执行,没抢到的直接跳过。
这个设计很像“推选班长”:大家在同一间教室里上课(多实例部署),到了上课时间,班长喊“起立”(抢锁成功)才执行,其他同学虽然醒了,但不用站起来。Nacos在这里扮演的就是那个“喊口令”的中介。
2.2 整体架构与流程
服务启动后,从Nacos拉取任务配置,注册到Nacos,然后启动本地调度线程。调度线程按cron触发任务,触发时先尝试获取分布式锁。为了提高可靠性,锁在Nacos配置中心里实现,锁的key对应一条临时配置,谁能发布成功谁就拿到锁。执行结束后释放锁,并记录执行日志到本地或外部日志系统。
这里有一个容易被忽视的关键点:分布式锁必须和任务执行时长匹配。如果一个任务要跑十分钟,而锁的持有时间只有三十秒,那十分钟后锁早过期了,另一个节点又可以抢到锁,导致重复执行。所以我在设计里把锁的有效期设置成一个可配置的参数,并留了一个续期机制,任务执行时间较长时,执行线程会定时续期,直到任务结束。
整个链路里最爽的部分是任务的新增和下线:以前要登admin后台点半天,现在只需要在Nacos配置里加一个任务条目,配置推送生效,任务自动注册;把配置删掉,任务自动从调度器中移除。对一个还在快速迭代的团队来说,这种敏捷度太重要了。
3. 核心细节解析与实操要点
设计归设计,真正写代码的时候有不少坑。我把几个最关键的细节单独拎出来讲,这些是最容易出错的地方,也是我实测后最值得沉淀的经验。
3.1 任务定义与动态配置
任务定义我用了一个轻量的TaskDefinition模型,包含任务ID、任务名称、cron表达式、执行的Bean名称、开关状态、超时时间、锁有效期等字段。这个模型直接和Nacos配置里的JSON对应,业务侧新增任务时只需要在配置里加一段JSON。
配置的存放我强烈建议按环境隔离。不要把所有环境的任务配在同一个dataId下,一旦写错,测试环境的任务可能跑到生产集群上。我的习惯是:
dataId: task-config-${spring.profiles.active}.json group: TASK_GROUP比如测试环境就是task-config-test.json,生产就是task-config-prod.json,这样至少环境之间是隔离的。如果不同业务线之间也要隔离,可以在group上再拆分,比如ORDER_TASK_GROUP和PAY_TASK_GROUP。这个规范一定要在一开始就定好,否则后面几十个任务混在一个配置里,改配置的时候光看diff都能看花眼。
动态刷新的实现思路是监听Nacos的Listener接口,配置变更后重新解析JSON,比对版本号,然后更新内存里的任务Map。这里有个细节:更新Map的时候必须保证线程安全,否则调度线程正在读取,配置线程突然替换Map,容易出现ConcurrentModificationException。我直接用了ConcurrentHashMap加原子引用替换,先把新配置解析成新的Map,再用一个AtomicReference指向新对象,调度线程每次读取都是拿整个引用,读写互不干扰。
3.2 分布式锁与任务分发
分布式锁是这套方案里最核心的部分。我调研过几种方案:用Redis的setnx做锁、用ZooKeeper做临时节点锁、用Nacos做配置发布锁。Redis需要额外引入Redis依赖,ZooKeeper又太重了,最后我选了Nacos自己的配置发布机制来做。
原理其实不复杂:每个任务在Nacos里对应一个锁配置,dataId固定,group固定,配置内容是一个包含持有者信息和过期时间戳的JSON。多个实例同时去发布同一个配置,Nacos只会允许一个版本写入成功,其他实例发布时会因为版本冲突失败。发布成功的那个实例就认为自己拿到了锁。
你可能会觉得这和Redis的setnx没什么区别,确实本质类似,但好处是这套锁的能力已经包含在Nacos里了,不需要再单独部署一个Redis集群。对于中小团队来说,减少一个中间件就是减少一类故障。
锁的细节我重点做了三件事:
一是锁有效期。不能太长也不能太短。太长了,某个节点挂了之后锁要很久才能释放,任务长时间无法触发;太短了,任务还没执行完锁就过期了,其他节点趁机抢锁造成重复执行。我的做法是锁有效期默认取任务超时时间的两倍,并且提供续期机制。
二是续期机制。拿到锁的线程启动一个守护线程,每隔锁有效期/3的时间检查一下任务是否还在执行,如果还在执行,就重新发布一次锁配置,把过期时间往后顺延。任务执行完毕后,显式删除锁配置,释放锁。
三是锁的公平性。多个节点同时抢锁时,谁能抢到全看Nacos发布时序,没有绝对公平。实际上对定时任务来说,我根本不关心谁执行,只关心有且只有一个节点执行,所以公平性不是问题,但“唯一性”必须被严格保证。
3.3 执行器侧线程池与失败补偿
任务执行不能直接写在调度线程里,否则一个任务卡住会把整个调度器堵死。我在方案里设计了一个独立的线程池来执行任务,调度线程只负责判断时间、抢锁、提交任务,真正跑业务逻辑的是线程池里的worker。
线程池参数我最初的设置是核心线程数4、最大线程数8、队列容量1000。后来遇到一个问题:某个数据同步任务高峰期需要跑二十分钟,而队列里其他任务可能已经等了十几轮,导致任务延迟。随后我把线程池策略改成了CallerRunsPolicy,队列放不下时,让调度线程自己去跑任务,算是用调度线程池的富余能力去做补偿,延迟问题明显缓解。
失败补偿的路径我分了三层:
第一层是任务内的try-catch,捕获业务异常记录日志,不影响调度器本身。 第二层是线程池的UncaughtExceptionHandler,捕获异常后把任务状态标记为失败。 第三层是任务级别的失败重试。我在TaskDefinition里加了retryCount和retryInterval两个字段,执行失败的调度任务会进入一个延迟重试队列,由重试线程在间隔时间后重新提交。
这三层下来,实际生产中的偶发失败基本都能兜住。我没有做复杂的“失败转移”“故障转移”,因为大部分定时任务失败之后,只要在下一个周期能正常运行,业务上是可以接受的。真正不允许失败的任务,应该走消息队列,而不是靠调度框架来解决。
4. 实操过程与核心实现
这部分是动手环节。我要完整记录一次从零搭建的实操过程,从Nacos安装到Spring Boot集成,再到验证分布式调度效果。你在本地完全可以把这套流程走通。
4.1 环境准备:Nacos安装与启动
如果你是第一次用Nacos,可以先在本地跑一个单机版。下载Nacos安装包后,默认是集群模式启动的,单机开发要加-m standalone参数。Linux和Mac下直接执行:
sh startup.sh -m standaloneWindows下执行:
startup.cmd -m standaloneNacos默认使用内置的Derby数据库,单机模式够用。但如果你要模拟多实例部署,我建议给Nacos配上MySQL,否则内置数据库的配置存储一多就容易出问题。配置方式是在conf/application.properties里修改数据源,我一般会把这些内容单独抽出来:
spring.datasource.platform=mysql db.num=1 db.url.0=jdbc:mysql://127.0.0.1:3306/nacos?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=Asia/Shanghai db.user.0=root db.password.0=yourpasswordNacos 2.x版本之后默认会开启gRPC端口(偏移量+1000),所以如果服务器上有防火墙,记得同时放行8848和9848端口,否则客户端连接会一直超时。这个坑我踩过,当时服务能注册成功,但配置动态刷新完全没反应,查了半天发现是gRPC端口被防火墙挡了。
启动成功后,浏览器访问http://localhost:8848/nacos,默认账号密码都是nacos。
4.2 Spring Boot集成依赖
项目里我用的是Spring Boot 2.6.x,Nacos客户端版本用的2.2.x。引入依赖时有两点要特别注意:一是spring-cloud-starter-alibaba-nacos-discovery和nacos-config-spring-boot-starter要区分使用,我这边用的是Nacos Config的Spring Cloud方式,通过bootstrap.yml加载配置;二是Nacos客户端和Spring Boot的版本要匹配,否则会出现NoSuchMethodError一类的坑。
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> <version>2021.0.5.0</version> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> <version>2021.0.5.0</version> </dependency> <dependency> <groupId>com.alibaba.nacos</groupId> <artifactId>nacos-client</artifactId> <version>2.2.3</version> </dependency>bootstrap.yml里就得把Nacos的服务地址、namespace、group配好:
spring: application: name: demo-task-service cloud: nacos: discovery: server-addr: 127.0.0.1:8848 namespace: dev config: server-addr: 127.0.0.1:8848 namespace: dev group: TASK_GROUP file-extension: json shared-configs: - dataId: task-config-dev.json group: TASK_GROUP refresh: true配置文件里的namespace要和Nacos控制台创建的命名空间ID一致,不是命名空间名称,这点容易搞混。
4.3 核心代码骨架
下面是我抽出来的几个核心类,基于这套代码你可以直接改造。
首先是任务定义模型,我用了一个通用类:
public class TaskDefinition { private String taskId; private String taskName; private String cron; private String beanName; private boolean enabled; private int lockSeconds; private int timeoutSeconds; private int retryCount; private int retryInterval; }然后是动态配置监听器:
@Component public class TaskConfigListener implements ApplicationRunner, Listener { private static final Logger log = LoggerFactory.getLogger(TaskConfigListener.class); private static final String TASK_DATA_ID = "task-config-dev.json"; private static final String TASK_GROUP = "TASK_GROUP"; private final NacosConfigManager nacosConfigManager; private final AtomicReference<Map<String, TaskDefinition>> taskCacheRef; private final TaskExecutor taskExecutor; public TaskConfigListener(NacosConfigManager nacosConfigManager, AtomicReference<Map<String, TaskDefinition>> taskCacheRef, TaskExecutor taskExecutor) { this.nacosConfigManager = nacosConfigManager; this.taskCacheRef = taskCacheRef; this.taskExecutor = taskExecutor; } @Override public void run(ApplicationArguments args) throws Exception { String config = nacosConfigManager.getConfigService() .getConfig(TASK_DATA_ID, TASK_GROUP, 5000); refreshTaskCache(config); nacosConfigManager.getConfigService() .addListener(TASK_DATA_ID, TASK_GROUP, this); } @Override public void receiveConfigInfo(String configInfo) { refreshTaskCache(configInfo); } private void refreshTaskCache(String configInfo) { if (configInfo == null || configInfo.isBlank()) { return; } List<TaskDefinition> tasks = JSON.parseArray(configInfo, TaskDefinition.class); Map<String, TaskDefinition> newCache = new ConcurrentHashMap<>(); for (TaskDefinition task : tasks) { if (task.isEnabled()) { newCache.put(task.getTaskId(), task); } } taskCacheRef.set(newCache); taskExecutor.reloadTasks(newCache); } }接着是分布式锁的实现,基于Nacos配置发布:
@Component public class NacosDistributedLock { private final NacosConfigManager nacosConfigManager; public NacosDistributedLock(NacosConfigManager nacosConfigManager) { this.nacosConfigManager = nacosConfigManager; } public boolean tryLock(String lockKey, String owner, long leaseSeconds) { try { String dataId = "lock-" + lockKey; String group = "LOCK_GROUP"; String content = owner + "|" + (System.currentTimeMillis() + leaseSeconds * 1000); boolean success = nacosConfigManager.getConfigService() .publishConfig(dataId, group, content); if (success) { return isOwner(dataId, group, owner); } } catch (NacosException e) { // 这里要记录日志,不要吞掉异常 } return false; } public void unlock(String lockKey, String owner) { try { String dataId = "lock-" + lockKey; String group = "LOCK_GROUP"; String content = nacosConfigManager.getConfigService() .getConfig(dataId, group, 3000); if (content != null && content.startsWith(owner)) { nacosConfigManager.getConfigService() .removeConfig(dataId, group); } } catch (NacosException ignored) { } } private boolean isOwner(String dataId, String group, String owner) throws NacosException { String content = nacosConfigManager.getConfigService() .getConfig(dataId, group, 3000); return content != null && content.startsWith(owner); } }这里有个细节:publishConfig发布成功后,我还会再getConfig确认一次自己确实是持有者。因为在极端并发下,可能我发布成功后,另一个节点也发布会覆盖掉我的值,所以必须二次确认。
然后是调度器线程,这是整个方案的心脏:
@Component public class TaskScheduler { private final Map<String, ScheduledFuture<?>> scheduledTaskMap = new ConcurrentHashMap<>(); private final NacosDistributedLock lock; private final TaskExecutor taskExecutor; private final AtomicReference<Map<String, TaskDefinition>> taskCacheRef; public void reloadTasks(Map<String, TaskDefinition> newCache) { // 移除已经不存在的任务 for (String taskId : scheduledTaskMap.keySet()) { if (!newCache.containsKey(taskId)) { ScheduledFuture<?> future = scheduledTaskMap.remove(taskId); if (future != null) { future.cancel(false); } } } // 新增或更新任务 for (Map.Entry<String, TaskDefinition> entry : newCache.entrySet()) { String taskId = entry.getKey(); TaskDefinition task = entry.getValue(); ScheduledFuture<?> oldFuture = scheduledTaskMap.get(taskId); ScheduledFuture<?> newFuture = buildTaskFuture(task); if (oldFuture == null || !oldFuture.equals(newFuture)) { if (oldFuture != null) { oldFuture.cancel(false); } scheduledTaskMap.put(taskId, newFuture); } } } private ScheduledFuture<?> buildTaskFuture(TaskDefinition task) { return scheduledExecutor.scheduleWithFixedDelay(() -> executeTask(task), 0, 1, TimeUnit.SECONDS); } private void executeTask(TaskDefinition task) { // 1. 检查是否到时间 CronExpression cron = new CronExpression(task.getCron()); // 2. 抢锁 boolean locked = lock.tryLock(task.getTaskId(), instanceId, task.getLockSeconds()); if (!locked) { return; } // 3. 执行 try { taskExecutor.submit(task); } finally { lock.unlock(task.getTaskId(), instanceId); } } }这段代码我是用伪码写的,实际生产环境里你不能每秒都去判断一次cron,比较消耗CPU。我是在每次执行完后,计算下一次执行时间的Delay,然后用schedule来精确触发,而不是用scheduleWithFixedDelay轮询。
4.4 关键参数选择与计算逻辑
这里单独说一下cron触发器的实现思路。Spring自带CronTrigger和CronSequenceGenerator,可以直接根据cron表达式计算下一次执行时间。我在调度器里维护了一个nextFireTime字段,每次任务执行完就调用CronSequenceGenerator.next(currentTime)算出下一次触发时间,然后以这个时间差来设置调度延时。
举个例子,任务是0 0 2 * * ?,即每天凌晨两点执行。当前时间是23:00,那么第一次触发延时是3小时。执行完之后,再算下一次触发时间,延时又是24小时。这样就避免了每秒轮询。如果采用轮询方式,一个服务有50个任务,每秒就要判断50次,虽然不多,但完全没有必要。
锁有效期的计算逻辑是:
锁有效期 = max(任务超时时间 * 2, 任务默认执行时长 + 30秒)如果任务TimeoutSeconds是300,锁的有效期就是600秒。如果任务执行超过600秒,续期线程会在350秒和600秒之间自动续期,保证锁不会提前失效。
这些参数不要拍脑袋定,最好根据任务的实际耗时做一次压测再调优。我遇到过一些团队把锁有效期设成30秒,结果数据同步任务跑了5分钟,两个节点轮流执行同一份数据,最终数据重复统计,排查的时候非常隐蔽。
4.5 验证效果:模拟多实例
本地验证时,我把同一个服务用两个端口启动(如8080和8081),两个实例都注册到同一个Nacos。然后在Nacos控制台创建任务配置,随便写一个每5秒执行一次的任务,观察日志。
配置JSON大致长这样:
[ { "taskId": "demo-task-001", "taskName": "示例任务", "cron": "0/5 * * * * ?", "beanName": "demoTaskHandler", "enabled": true, "lockSeconds": 10, "timeoutSeconds": 5, "retryCount": 1, "retryInterval": 10 } ]日志里应该看到:某一时刻只有8080端口执行,下一个5秒可能还是8080,也可能切换成8081,但同一时刻绝对不会两个端口都打印执行日志。修改Nacos配置里cron为0/10 * * * * ?后,不需要重启服务,日志里执行间隔马上变成10秒,到这里方案就验证通过了。
5. 常见问题与排查技巧实录
整个方案跑起来不难,但真正用到生产环境,我踩了不少坑。下面按问题出现的频率整理成一张速查表,都是实测经验。
| 现象 | 可能原因 | 排查与解法 |
|---|---|---|
| 服务启动时报连接Nacos超时 | 防火墙未放行8848/9848端口,或Nacos地址配错 | 先用telnet测试端口连通性,再检查Nacos日志 |
| 配置修改后任务没动态刷新 | 未配置refresh: true,或监听器没注册成功 | 检查bootstrap.yml的shared-configs是否开启refresh,再确认addListener是否执行 |
| 同一时刻两个节点都在执行任务 | 锁有效期太短,或续期机制没生效 | 调大lockSeconds,检查续期线程是否启动 |
| 任务执行完但锁没释放 | unlock时owner不匹配,或者removeConfig失败 | 保证锁内容的owner字段唯一,打印getConfig内容对比 |
| 服务启动后任务执行了两次 | 启动阶段一遍加载配置、一遍执行Runner的reload,旧任务未取消 | 在reloadTasks中先cancel旧future再提交新future |
| Nacos控制台能看到配置,但客户端获取为空 | namespace或group不一致 | 确认客户端用的namespace是控制台里的“命名空间ID”而不是名称 |
| 任务偶发延迟,特别是高峰期 | 线程池队列饱和,任务排不上 | 使用CallerRunsPolicy,或者动态调大最大线程数 |
| 连续发布锁配置时报“数据被修改” | 多个节点并发发布,Nacos版本冲突 | 这是预期行为,tryLock返回false即可 |
排查这类问题有一个通用技巧:先看Nacos服务端日志,再看客户端日志,最后看业务日志。很多问题从Nacos这边的变化事件就能定位,不要上来就查业务代码,那样效率很低。
另外提一个关于Nacos未授权访问的问题。Nacos控制台在低版本默认没有强制鉴权,如果你部署在公网环境,一定要在application.properties里开启鉴权,设置好nacos.core.auth.enabled=true,并把默认密码换掉。我见过有人把Nacos裸奔在公网上,别人直接通过OpenAPI把配置全部拉走,这属于很低级的失误。
5.1 坑一:Nacos配置中心持久化换成MySQL后原有配置消失
这个坑不少人遇到过。Nacos默认用内嵌Derby时,你在控制台创建的配置都存在Derby里。后来把数据源切换到MySQL,启动后发现控制台里之前的配置全没了,实际是因为Nacos只会在第一次连接数据库时执行初始化脚本,如果数据库是空的,所有老配置自然不见了。
正确做法是:先把旧配置用curl或控制台导出备份,再切换到MySQL,最后重新导入配置。更推荐的方式是,从第一天就给Nacos配上独立的MySQL,否则后面迁移数据真的会头疼。
5.2 坑二:任务执行节点发布后,配置监听回调丢失
Spring Boot应用重启过程中,Nacos的addListener可能在你自己的监听器注册之前就已经收到了一次配置变更推送,导致新配置被跳过。我的解法是在ApplicationRunner里先主动getConfig一次,拿到当前最新配置并初始化缓存,这样无论推送事件是否漏掉,启动后都能加载到最新数据。
这个坑表面看不出来,因为大多数时候启动后配置能拉下来,但如果在启动瞬间有人改了配置,就有概率出现“本次发布配置没生效,重启才生效”的诡异现象。加了主动拉取的兜底逻辑之后,这个问题就再没出现过。
5.3 坑三:动态刷新导致任务重复执行
配置刷新时,监听器拿到的可能是中间态数据,比如任务A的cron更新到一半,旧任务还没取消,新任务已经注册,会出现短暂的双重触发。我在refreshTaskCache里先解析完整JSON,再统一替换缓存,并且在reloadTasks中先取消所有旧future,再提交新的。这里顺序不能反,如果先提交新的再取消旧的,两个future同时存在,就会重复触发。
如果你也遇到类似问题,建议在刷新代码里加一个版本号字段,配置数据里带version,客户端每次处理前对比本地version,只处理更大的版本。虽然增加了复杂度,但对敏感性任务来说更稳妥。
6. 这套方案的边界与后续扩展
最后说点大实话。这套基于Nacos的调度方案不是万能的,它有非常明确的边界。如果你的任务量大到上万级别,或者需要精细的调度编排、复杂的DAG依赖、完善的任务血缘追踪,那还是老老实实选专业的分布式调度平台,XXL-JOB也好,别的也罢,它们存在的意义就是解决这类复杂场景。
它最适用的场景是:中小团队,任务量几十到几百,业务任务多为“周期触发+单机执行+失败重试”,并且团队已经深度用了Nacos。这种情况下,用它替换XXL-JOB能省下不少运维精力,还能把调度能力收拢到业务服务内部,架构更内聚。
后续我计划在这个方案上做两件事。一是把任务执行记录和耗时指标以事件形式发送到监控系统,方便统计每个任务的SLA和失败率。二是把任务分片能力补上,比如大数据量的任务可以拆成多个分片,由多个节点并行处理。这两件事基本能让这套轻量方案覆盖到更多的业务场景。
最后分享一个我自己用得很舒服的小功能:我在Nacos配置里加了一个debug字段,调试任务时临时打开,改成开启后任务可以立即手动触发一次。调试完再关掉。这个功能在排查生产问题时特别管用,你可以直接加一个“手动触发”入口,价值绝对不亚于重写一套调度中心。