如果你维护过一个跑了两年以上的Spring Boot服务,大概率见过这种场景:部署了两三个实例之后,到了每天凌晨零点,日志里同一个业务方法连着执行好几遍,数据库里多出一批重复对账记录,或者优惠券被同一个用户领了好几次。大多数人第一反应是“代码是不是被哪个同事重复提交了”,实际上问题多半出在@Scheduled上——它根本没有分布式协调能力。这篇文章我打算把从@Scheduled迁移到XXL-JOB的完整过程写出来,包括调度中心怎么搭、执行器怎么配、任务怎么开发、生产环境有哪些坑,以及迁移时那些文档里不会明说的细节。
如果你正被多实例重复执行、动态调整定时策略困难、任务失败没有重试机制这些问题折磨,这篇文章可以直接当操作手册来看。已经对XXL-JOB有一定了解的读者,也可以重点跳到我后面写的生产踩坑部分,那几段代码和配置是我真实环境里的沉淀。
1. 单机定时任务的死穴:为什么@Scheduled撑不住分布式场景
1.1 服务多实例部署后,@Scheduled会闹出什么乱子
先看一个很常见的例子。假设你有一个订单超时自动关闭功能,代码大概长这样:
@Component public class OrderTimeoutTask { private static final Logger log = LoggerFactory.getLogger(OrderTimeoutTask.class); @Scheduled(cron = "0 0/5 * * * ?") public void closeExpiredOrders() { log.info("开始扫描超时订单"); // 查询超时订单并执行关闭操作 } }单机部署的时候一切正常,因为整个JVM里只有一个定时器。后来业务量上来,服务扩到了3个节点,前面挂了负载均衡。问题立刻暴露:三个节点到点同时触发closeExpiredOrders(),三个节点都在查超时订单,都在执行关闭操作。就算你的关闭逻辑做了状态校验,也依然可能因为并发更新导致数据不一致、重复发送通知、重复扣减库存等一系列连锁问题。
有些团队会用fixedRate或者fixedDelay,这两个参数在单实例下好理解:前者是固定间隔执行,后者是上一次执行完再间隔指定时间。可它们本质上没有解决分布式问题,只是在单机上做定时触发。有人会想到“给方法加分布式锁”,比如用Redis的setnx,确实能解决一部分重复执行的问题,但分布式锁引入了额外依赖,锁超时、锁续期、锁释放这些逻辑都需要自己维护。更麻烦的是,如果你有十个定时任务,每个任务都去写一套分布式锁的代码,维护成本会越来越高。
我举个例子说明这个问题的“普遍性”:不只是订单超时,凡是涉及“批处理”的场景都会踩到——每日报表汇总、数据清洗、缓存预热、消息重推、积分结算。只要服务实例数大于1,@Scheduled的语义就变了,它不再保证“全局唯一触发”,必须靠外部机制来约束。
1.2 从“固定频率”到“动态编排”:定时任务真正需要的能力
除了重复执行,@Scheduled还有几个硬伤,这几个硬伤在你没经历过生产环境前可能体会不到。
第一是任务参数不能动态修改。@Scheduled的cron表达式在编译时就写死了,想改执行频率,必须改代码、重新打包、重新发布。可是生产环境里,“这次凌晨2点跑,下次改成凌晨3点跑”这种需求太常见了,业务方甚至可能一周改三次。每次都要走发版流程,效率低不说,还容易因为匆忙发版引入其他风险。
第二是没有失败重试和告警机制。@Scheduled方法一旦抛出异常,Spring只会把异常打在日志里,任务就算“静默失败”了。如果是半夜里的数据同步任务,可能到第二天早上数据对不上才发现,这时候补救成本特别高。有人说可以自己在方法里try...catch并调用告警接口,但每个任务都写一遍,太痛苦了。
第三是没有执行历史记录。任务跑了没有、跑了几次、每次耗时多长、有没有失败,@Scheduled完全没有任何可视化信息。出了问题只能查日志,而多实例下的日志又分散在不同节点,排查问题非常费劲。
所以我个人对定时任务框架的期望是:不仅要能“定时触发”,还得能“编排和管理”。触发方式要支持cron、固定频率、延迟执行;触发记录要可以追溯;执行失败了要能重试和告警;多实例下要保证同一个任务只被一个节点执行,或者按照分片规则让多个节点协同处理。这些能力加在一起,才是真正的“分布式定时任务”该有的样子。
2. XXL-JOB到底解决了什么问题:调度中心的角色
2.1 XXL-JOB的核心组件与一次完整调度请求的生命周期
XXL-JOB把整个体系拆成了两部分:调度中心(admin)和执行器(executor)。调度中心负责管理任务信息、触发调度、记录日志;执行器负责接收调度请求、执行具体业务逻辑、上报结果。两者之间通过HTTP接口通信,没有服务间依赖,所以执行器所在的业务服务即使和调度中心不在同一个网络区域也能正常工作。
一次最简单的调度请求,走完的流程是这样的:调度中心的Quartz线程到点触发一个任务,把任务ID和执行参数封装成请求,调用执行器暴露的/run接口;执行器收到请求后,从任务实例里找到对应的JobHandler,放入线程池执行;执行完成后,把结果回传给调度中心,调度中心把执行日志、执行时间、退出码存进数据库。
这个设计的一个好处是:执行器是被动接收任务,不是自己拿着cron表达式在本地触发。也就是说,执行器完全不关心“任务什么时候该跑”,它只负责“接到活了就把活干好”。调度逻辑被完全收拢到了调度中心这一个地方,这样任务状态、执行结果、日志都可以集中管理和查询。如果你把调度中心部署成集群模式,加上注册中心,就解决了单点问题,而执行器侧无感知。
XXL-JOB还内置了执行器自动注册和发现机制。执行器启动后通过配置的admin地址主动上报自己的appname和IP:Port,调度中心会把在线执行器记录下来。你不需要手动维护“哪个机器能跑哪个任务”,新增节点后任务可以自动被调度到新节点上。这一点在多机房扩缩容时特别好用。
2.2 对比Spring自带方案和Quartz,为什么我选XXL-JOB
我最早引入定时任务调度框架前,也认真对比过Spring的TaskScheduler、Quartz和XXL-JOB。先说结论:如果你的项目只有单机、两三个固定任务,用Spring自带的@Scheduled完全没问题,不要盲目上框架。但一旦你面临多实例、任务量持续扩张、运维需要可视化,XXL-JOB这种“中心化调度+分布式执行”的做法明显更合适。
Quartz本身支持持久化、集群、cron表达式,功能不算弱。但它有两个问题:一是它只是一个调度库,不是一套任务管理平台,没有现成的控制台界面,没有任务日志管理,没有告警;二是Quartz的集群模式要依赖数据库行锁,调度频率很高时数据库压力不小,而且配置和运维相对复杂。XXL-JOB虽然也用Quartz做底层调度触发器,但它把任务管理、执行器管理、日志管理、告警全部做成了可视化功能,还支持动态修改cron、手动触发一次、失败重试这些实用功能,开箱即用。
用一张表总结一下我当时对比的结论:
| 对比维度 | @Scheduled | Quartz | XXL-JOB |
|---|---|---|---|
| 多实例调度 | 不支持 | 支持(DB锁) | 支持(自动注册) |
| 任务控制台 | 无 | 无 | 有 |
| 动态修改cron | 不支持 | 需改代码 | 控制台直接改 |
| 失败重试 | 无 | 需自研 | 内置 |
| 执行日志 | 无 | 无 | 有 |
| 学习成本 | 低 | 中 | 中 |
| 运维友好度 | 低 | 中 | 高 |
这套对比做完,我基本就确定了用一个开源调度平台来承接定时任务。后来又看了PowerJob和ElasticJob,PowerJob功能也在迭代,但XXL-JOB部署简单、社区活跃、文档齐全,国内使用人数多,遇到问题很容易搜到解决方案,所以我最终选了XXL-JOB。这里不是说其他框架不好,而是对大多数Spring Boot团队来说,XXL-JOB的上手代价是最低的。
3. 先把调度中心和第一个执行器跑起来
3.1 调度中心admin的源码构建与配置要点
XXL-JOB的调度中心本身也是一个Spring Boot项目,源码在GitHub上,仓库名叫xxl-job。你需要把它clone下来,找到xxl-job-admin模块,用mvn clean package打包成可执行Jar。如果你是第一次接触,我建议直接用release版本里面的源码,不要用master分支上最新的开发版本,因为开发版本可能有一些未验证的改动。生产环境建议选取v2.4.x或更新的稳定版本,同时注意对应JDK的兼容性——网上经常搜到“xxl-job jdk8最新版本”这类问题,就是因为新版XXL-JOB对JDK版本有要求,老项目如果是JDK8,建议使用v2.3.x或v2.4.x的配套版本,避免引入JDK11+的编译要求。
调度中心的配置文件在application.properties里,核心是数据库连接:
# xxl-job-admin 数据库连接 spring.datasource.url=jdbc:mysql://127.0.0.1:3306/xxl_job?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai spring.datasource.username=root spring.datasource.password=123456 spring.datasource.driver-class-name=com.mysql.cj.jdbc.Driver如果你所在的公司PostgreSQL用得多,也可以在XXL-JOB源码里找到对应的PG建表SQL,把数据源换成PostgreSQL。XXL-JOB对PostgreSQL的适配较早就有了,但要注意SQL脚本里的一些类型和主键策略,不同版本的脚本略有差异,尽量使用和release配套的DDL脚本。我第一次从MySQL迁到PG时,就因为int自增主键的写法不同踩了坑,所以这里特别提醒一句:建表时优先使用官方源码里db目录下对应数据库类型的脚本,不要拿MySQL的脚本直接到PG里执行。
admin启动后,默认端口是8080,访问http://localhost:8080/xxl-job-admin进入控制台,默认登录账号admin/123456。进入后第一件事,我建议你去“执行器管理”里确认没有遗留的测试数据,然后修改admin的登录密码。虽然XXL-JOB控制台不会直接暴露业务数据,但调度平台是生产环境的高权限系统,弱口令是绝对禁忌。
3.2 Spring Boot项目中接入执行器的完整配置
执行器的接入方式对Spring Boot项目来说非常友好,官方提供了xxl-job-spring-boot-starter。在你的pom.xml里引入依赖:
<dependency> <groupId>com.xuxueli</groupId> <artifactId>xxl-job-core</artifactId> <version>2.4.0</version> </dependency>然后在配置类里创建一个XxlJobSpringExecutor的Bean。这个Bean负责在应用启动时把当前服务注册到调度中心,并扫描Spring容器里带有@XxlJob注解的方法。配置如下:
@Configuration public class XxlJobConfig { @Value("${xxl.job.admin.addresses}") private String adminAddresses; @Value("${xxl.job.executor.appname}") private String appname; @Value("${xxl.job.executor.port}") private int executorPort; @Bean public XxlJobSpringExecutor xxlJobExecutor() { XxlJobSpringExecutor executor = new XxlJobSpringExecutor(); executor.setAdminAddresses(adminAddresses); executor.setAppname(appname); executor.setIp(""); // 为空表示自动获取本机IP executor.setPort(executorPort); executor.setAccessToken("your-token"); return executor; } }对应的application.yml:
xxl: job: admin: addresses: http://localhost:8080/xxl-job-admin executor: appname: xxl-job-executor-sample port: 9999这里有几个容易出错的地方。第一,executor.port是执行器自己暴露给调度中心调用的HTTP端口,要在防火墙里放行,不要随便选一个被占用的端口。第二,如果你有多个执行器跑在同一台机器上,每个执行器的port必须不同,否则启动时会提示端口冲突。第三,appname必须和调度中心控制台里配置的执行器AppName保持一致,否则调度中心无法把任务路由到你当前这个服务上。
我见过不少人在第一步就卡住:控制台能登录,但执行器列表一直显示“调度中心自动注册失败”。排查思路很简单——先看执行器日志里有没有“注册成功”的提示,再看调度中心的机器列表有没有记录,再看两边网络通不通,用telnet测试一下executor.port是否可达。大多数情况下都是网络策略导致的,少部分是accessToken不一致。
4. 第一个分布式任务:从创建任务到第一次触发
4.1 任务开发:JobHandler的三种写法
XXL-JOB在Spring Boot环境下的任务开发,官方推荐的是“方法模式”,就是在一个Bean的方法上标注@XxlJob注解,方法名就是任务的JobHandler名称。写法和@Scheduled很接近,但有本质区别:@Scheduled是本地调度,@XxlJob是被动接收远程调度。
来看一个最简单的任务:
@Component public class SampleJob { private static final Logger log = LoggerFactory.getLogger(SampleJob.class); @XxlJob("demoJobHandler") public void demoJobHandler() throws Exception { XxlJobHelper.log("demoJobHandler 开始执行"); // 你的业务逻辑 XxlJobHelper.log("demoJobHandler 执行结束"); } }任务方法里有两个细节:一是通过XxlJobHelper.log写日志,这样日志会回传到调度中心,非常方便在控制台直接查看;二是通过XxlJobHelper.getJobParam()获取任务参数,这样你可以在控制台动态传入参数,同一个JobHandler可以应对不同配置。
XXL-JOB还支持“Bean模式”和“Glue模式”。Bean模式是早期版本的方式,需要自己实现IJobHandler接口,现在一般不用。Glue模式是把一段Groovy脚本直接维护在调度中心,任务触发时动态加载执行,支持热更新,适合那种经常需要改逻辑又不想重新发布的任务。我一直建议团队尽量用方法模式,逻辑和Spring Bean体系融合得最好,测试也方便;Glue模式虽然灵活,但脚本一旦多了会变得很难维护,适合应急场景。
4.2 admin控制台上的任务配置与调度策略
任务开发完,到调度中心控制台去配置一个任务,才能让调度中心知道“这个任务什么时候调、调到哪个执行器、用哪个JobHandler”。
创建任务的页面里,核心配置项有这么几个:
| 配置项 | 含义 | 常见取值 |
|---|---|---|
| 执行器 | 任务的执行者 | 你配置的AppName |
| JobHandler | 执行器中的方法名 | 例如demoJobHandler |
| Cron | 触发规则 | 支持标准cron表达式 |
| 运行模式 | 任务类型 | BEAN或GLUE |
| 路由策略 | 多执行器时选哪个 | 轮询、分片广播、一致性HASH等 |
| 阻塞处理策略 | 任务超时后怎么处理 | 单机串行、丢弃后续调度、覆盖之前调度 |
| 失败重试次数 | 失败后自动重试 | 生产环境建议1-2次 |
创建之后,如果你想先验证功能,可以在操作列表里点击“执行一次”,控制台会让你填一个可选参数,然后立刻触发一次,不需要等cron。这个“手动触发”功能在联调和排查问题时特别有用——你不用为了跑一次任务去改cron等时间。
路由策略是我要重点说的。很多人刚开始用XXL-JOB,只把执行器配置成一个节点,这种用法其实和@Scheduled差别不大。当执行器有多个节点时,路由策略决定了调度中心把任务交给谁:
- 轮询:任务依次分给不同节点,适合单次处理量不大、多个节点都能独立完成任务的场景。
- 一致性HASH:根据任务ID计算Hash值,保证同一个任务总是被同一个节点执行,适合需要基于任务参数分片的场景,比如每个任务处理某一批用户。
- 分片广播:这个最常用也最关键,调度中心会同时把所有在线执行器都调一遍,并且给每个节点传入当前分片索引和总分片数,各节点只处理自己负责的那部分数据,适合海量数据批量处理,比如给100万用户打标签,每个节点只处理四分之一的用户。
我强烈建议所有团队在任务设计阶段就把“数据能不能分片”想清楚。哪怕现在数据量不大,只要业务是成长型的,后续一定会遇到单节点跑批太慢的问题,分片广播是扩展性最好的方式,而且实现成本其实很低。
任务配置完成后,调度中心会在指定时间触发调度。你在“调度日志”里可以看到每一条执行记录,包括调度时间、执行耗时、执行结果、执行机IP。如果任务内部通过XxlJobHelper.log打了日志,还可以在日志详情里看到完整的执行过程,比你去服务器上翻业务日志效率高得多。
5. 从@Scheduled到XXL-JOB:一次真实迁移的步骤与细节
5.1 迁移前的任务盘点与模型转换
迁移的第一步不是写代码,而是把现有的@Scheduled任务全部盘出来。我一般会做一个清单,包含:任务方法名、cron表达式、是否传递参数、依赖的外部资源(数据库、Redis、消息队列)、预计执行耗时、是否允许并发执行、失败后的影响范围。
盘完之后,按复杂度把任务分成三类:
- 无状态任务:不依赖上次执行结果,每次独立跑完即可,这类迁移最简单。
- 有状态任务:任务之间有先后顺序,比如先拉数据再计算再推送,需要设计成多个JobHandler,并在控制台配置触发顺序,或者在一个JobHandler内串联处理。
- 分片任务:单节点处理全部数据已经比较吃力,直接设计成分片广播模式。
举个我实际迁移过的例子:原来的订单清理任务是这样写的:
@Component public class OrderCleanTask { @Scheduled(cron = "0 0 2 * * ?") public void clean() { // 逻辑 } }迁移到XXL-JOB后,我新建了一个OrderCleanJob:
@Component public class OrderCleanJob { @XxlJob("orderCleanJobHandler") public void orderCleanJobHandler() throws Exception { int totalShards = XxlJobHelper.getShardTotal(); // 总分片数 int shardIndex = XxlJobHelper.getShardIndex(); // 当前分片索引 List<Long> shopIds = orderService.getShardShopIds(shardIndex, totalShards); for (Long shopId : shopIds) { orderService.cleanByShopId(shopId); } } }由于订单表是按店铺维度拆分的,所以我把“店铺ID”作为分片键,每个节点只处理自己负责的那几个店铺。这样既避免了多节点重复处理同一批数据,又能利用多台机器的资源并行加快速度。
这里有个很重要的点要提醒:分片广播不是说“每个节点都把所有任务执行一遍”,而是“每个节点只执行自己分到的那一份”,你在写代码时一定要明确获取shardIndex和shardTotal,并基于这两个参数去过滤数据。如果拿不到这两个参数就把业务逻辑跑一遍,等于又回到了重复执行的坑里。
5.2 动态参数、路由策略与幂等设计的落地
迁移过程中,最让人感觉“值回票价”的是任务参数动态化。之前用@Scheduled时,任务参数都是硬编码在代码里的,比如“清理多少天前的订单”,这个“多少天”以前要靠配置文件或者常量。接入XXL-JOB后,我直接在控制台的任务参数框里传一个JSON字符串,然后在代码里解析:
@XxlJob("orderCleanJobHandler") public void orderCleanJobHandler() throws Exception { String param = XxlJobHelper.getJobParam(); CleanParam cleanParam = JSON.parseObject(param, CleanParam.class); int days = cleanParam.getDays() == 0 ? 30 : cleanParam.getDays(); // 执行清理 }这样,不同环境、不同时段只需要在控制台改参数,不需要动代码。例如灰度环境和生产环境的清理周期不一样,只需要分别配置任务参数即可。这个能力在应对业务方“临时要跑一次特殊范围的数据修复”时尤其有用,我在控制台手动执行一次,传入指定的范围参数,几秒钟就解决问题。
幂等性设计在分布式定时任务里是必须的。前面说的分布式锁可以解决一部分问题,但在XXL-JOB里,即使调度中心保证了一个任务在某一时刻只会被一个节点调度,也无法保证业务逻辑自身不会出现重复数据。比如任务执行过程中,另一个定时任务也在改同一张表,或者任务因为网络超时被调度中心判为失败并重试,这些都会导致同一逻辑被执行两次。所以,我的建议还是那句老话:在所有写操作里做好状态判断,比如“只处理状态为待处理的订单”,或者在关键表上建立唯一索引。分布式调度框架只能管“谁在什么时间触发”,管不了“业务代码自己是否幂等”。
另外,配置路由策略的时候,很多人会疑惑“一致性HASH”和“分片广播”到底哪个好。我的理解是:如果任务是单机全量处理类型,用一致性HASH或轮询都行,但一致性HASH能保证某个任务始终落在同一节点,有利于复用节点上的本地缓存;如果任务是海量数据处理类型,直接上分片广播。不要迷信某个策略,要根据任务特性去选。
6. 生产环境才遇得到的坑:失败重试、日志与性能
6.1 失败重试与告警,别让任务“静默死亡”
XXL-JOB相比@Scheduled最大的优势之一就是失败可感知。任务执行失败时,调度中心会根据你的“失败重试次数”再次触发。我建议生产环境的任务至少配置一次重试,因为很多失败是临时性的,比如数据库连接池抖动、外部接口超时,一次重试往往就恢复了。
重试次数也不是越多越好。我曾经遇到过重试次数配置过高,结果一个下游服务持续不可用,任务每5分钟重试一次,连续重试了十几次,把下游接口彻底打挂。这个教训告诉我:失败重试次数要根据依赖的可用性来定,同时要在任务代码里做好“最多执行多少次”的保护,必要时直接把任务状态标记为失败,避免盲目重试放大故障。
告警方面,XXL-JOB支持配置邮箱告警,在“任务配置”里填入接收告警的邮箱。任务失败或超时后,调度中心会发送告警邮件。如果你的团队已经接入了钉钉或企微机器人,可以基于调度中心的失败回调接口做二次开发,把告警消息转发到群聊。官方的基本用法是先在邮件配置里设置SMTP信息,然后在任务上配置告警邮件组。这里有个经验:告警要区分“业务失败”和“调度失败”,前者是业务代码主动抛出异常,后者是执行器没有响应或超时,两者处理方式不同,排查路径也不同。
6.2 阻塞处理策略与线程池调优
在线任务执行还没结束时,下一次调度时间又到了,这个情况在长耗时任务里非常常见。XXL-JOB的“阻塞处理策略”就是解决这个问题的:
- 单机串行:如果上一次还没跑完,下一次调度会排队等待,当前任务执行完后继续执行下一个。适合大多数任务,能保证不丢任务。
- 丢弃后续调度:如果上一次没跑完,后续调度直接丢弃。适合“只关心最新一次数据是否执行完”的任务,比如持续刷缓存。
- 覆盖之前调度:用新的调度直接终止上一个执行的线程。这个要慎重,强制终止可能会中断数据库事务或资源清理,导致数据不一致。
我个人用得最多的是“单机串行”,因为它最安全,不会丢任务也不会乱终止。只有当任务本身就是“不断处理最新数据”的类型时,才考虑“丢弃后续调度”。
再聊聊线程池。执行器接收调度请求后,任务是在线程池里执行的,并不是直接调用主线程。默认的线程池配置在一些QPS高的场景下可能不够用,比如同时跑几十个分片任务。你可以通过自定义XxlJobExecutor的线程池参数来调整,主要包括核心线程数、最大线程数、队列容量。
@Bean public XxlJobSpringExecutor xxlJobExecutor() { XxlJobSpringExecutor executor = new XxlJobSpringExecutor(); // 其他配置 executor.setThreadPoolSize(200); executor.setMaxThreadPoolSize(300); // 最大线程数 executor.setThreadPoolQueueSize(5000); return executor; }具体数值要根据任务的并发量和单个任务的耗时来定。如果任务里包含大量IO操作,比如调用外部接口,线程数可以适当调大;如果任务主要是CPU计算,线程数不要超过CPU核心数的两倍太多,否则线程切换开销反而拖慢速度。我的经验是:先用默认参数跑一段时间,观察调度日志里的“线程池队列积压”情况,再决定是否调整。
6.3 日志回传、PostgreSQL适配和版本兼容性
前面提到XxlJobHelper.log可以把执行日志回传到调度中心。生产环境我建议每个任务都打上足够的日志,不仅仅是记录开始和结束,还要在关键节点把处理进度、失败数据量打出来。因为调度日志是按任务ID归档的,一个任务的多次调度会分开记录,你可以非常方便地对比不同批次的执行情况。如果哪一批数据异常,直接在调度日志里就能定位,不用再去分布式日志系统里慢慢筛。
再单独说下PostgreSQL适配问题。XXL-JOB官方虽然提供了PG的建表脚本,但要在生产环境跑稳妥,有几个细节:一是PG的字段类型和索引策略和MySQL不同,比如timestamp类型和varchar长度的处理,尽量按官方SQL执行,不要自己“优化”;二是如果调度中心使用PG作为存储,注意调整连接池的配置,PG和MySQL的最大连接数行为不一样;三是最新版本的XXL-JOB对PG的适配更友好,但如果你还在用JDK8,建议锁死版本,避免因为升级带来编译问题。网上搜“xxl-job适配postgresql”有不少案例,但版本匹配一定要自己核对源码里的脚本和依赖。
版本兼容性这块再啰嗦一句:XXL-JOB的admin和执行器最好保持同一大版本。比如调度中心升到2.4.0,执行器也使用2.4.0的xxl-job-core,避免旧执行器调用新调度中心接口时出现字段不匹配。我就是因为没注意这一点,执行器升级滞后,导致调度中心下发任务参数时执行器解析失败,排查了很久才发现是两端版本不一致。
换掉@Scheduled之后,我把哪些习惯留了下来
刚才写了很多具体操作,最后分享几个我在生产环境沉淀下来的习惯,不一定都写在官方文档里。
第一个习惯是给所有任务统一加一个“任务标识”日志字段。不管是用XxlJobHelper.log还是业务日志,我都会在日志里带上jobId和schema信息,方便出问题时把调度中心日志和业务日志串起来看。
第二个习惯是优先用分片广播而不是轮询。很多团队一开始任务量小,用轮询把所有任务平均分到几个节点,看起来没问题,但数据量上来之后就会遇到“单节点跑批太久”的瓶颈。分片广播虽然一开始写代码麻烦一点,但它天然支持横向扩展,我迁移过的项目里收益最明显的就是这个改造。
第三个习惯是每季度检查一次任务清单。XXL-JOB控制台里会积累很多老任务,有些任务对应的业务可能已经下线了,但cron还在跑,白白消耗资源。定时清理无用任务和调整任务参数,比频繁优化代码收益更高。
@Scheduled不是不能用,它也足够简单,但只要你开始部署多个实例,或者有动态调度、可观测、失败重试这类需求,把它换成XXL-JOB是性价比很高的选择。迁移过程并不复杂,真正花时间的往往是想清楚任务模型和分片策略。把前面的步骤走完,你会发现定时任务突然变得可控了,半夜被电话叫起来处理数据问题的次数也会明显少很多。