当定时任务涨到 180+,我们为什么从 Elastic Job 迁到了 XXL-JOB

一、背景

我们的系统是个基于 Spring Cloud 的信贷业务中台,业务从用户申请、授信、借款一路覆盖到还款、对账,背后对接了一堆资金方。随着接的资金方越来越多,定时任务也跟着疯长------从最早几十个一路涨到了 180+ 个,散落在好几个微服务里:

模块类型 任务数量级 主要任务类型
运营后台模块 十余个 收入跑批、报表统计、线下结果查询
核心业务模块 四十余个 放款重试、案件同步、额度管理
资方对接模块 百余个 多家资方对账、SFTP 文件拉取、放款/还款重试
支付模块 少量 渠道对账、账户余额查询
额度模块 少量 额度检查、冻结解除、数据脱敏

到了这个量级,原本用 Elastic Job 时不太显眼的问题,开始一个接一个冒出来。


二、Elastic Job 的痛点

先说明一点:Elastic Job 本身是个优秀的框架,下面列的「痛点」都是相对我们这套场景而言的------任务多、类型杂、以轮询补偿为主、且经常需要临时介入。换个业务场景,这些结论未必成立。

2.1 强依赖 ZooKeeper,凭空多养一套中间件

Elastic Job 把分布式协调、分片、选主全部压在 ZooKeeper 上。而我们的注册中心已经是 Nacos,等于为了跑定时任务,单独再养一套 ZooKeeper 集群。两套语义重叠的协调组件并存,本身就是架构上的冗余,对开发和运维来说成本也是实打实的:

  • 多一套高可用组件:ZooKeeper 至少 3 节点起步,监控、备份、扩容、版本升级都要单独搞一套
  • 多一个故障点:ZooKeeper 在网络抖动时可能触发 Leader 重选举,期间调度停摆;一旦 ZK 与执行器会话超时,还可能引发分片重新分配,正在跑的任务被中断重跑
  • 排查链路被拉长:任务出问题要同时看业务日志、执行器状态和 ZK 节点数据,问题边界不清晰

2.2 几乎没有可视化能力,上百个任务等于「盲跑」

Elastic Job Lite 没有官方控制台(早期得自己搭 Console,功能也有限)。结果就是:

  • 任务当前在不在跑、上一次跑成没成、跑了多久,没有一个现成的地方能看,只能翻日志或读 ZK 节点
  • 凌晨批量任务失败,值班的人得先 SSH 上机器 grep 日志,再回头对 ZK 节点状态,定位一个问题动辄几十分钟
  • 「这个任务最近十次执行情况」这种最基本的诉求,框架层面根本不提供

任务只有几个时还能忍,涨到上百个、分散在多个服务里之后,「盲跑」就成了线上隐患------很多任务到底有没有正常在跑,其实没人能第一时间说清楚。

2.3 主打的分片能力,我们几乎用不上

Elastic Job 最大的卖点是作业分片:把一批数据切成 N 片,由 N 个实例并行消费,典型场景是「千万级数据全表批处理」。

但回看我们的任务清单,绝大多数是这几类:

  • 轮询补偿类:每隔几分钟捞一批「处理中」的订单,调外部接口确认最终状态
  • 文件拉取类:凌晨从资方 SFTP 拉对账 / 放款 / 还款文件,解析入库
  • 状态回查类:定期查处理中的授信、放款申请,更新状态并回调业务方

这些任务数据量不大、也不需要多实例并行切分,要的只是「在集群里挑一个实例、稳定地跑一次」。Elastic Job 引以为傲的分片模型在这里完全空转,我们为一个用不上的能力,背了它整套基于 ZK 的复杂度。

2.4 临时补偿任务,每次都要开发亲自下场

这是日常最磨人的一点,而且痛在开发身上。

金融业务天天有临时补偿和重跑的诉求:对账文件没拉到要重拉、卡住的订单要重试、月底要手动核对一遍收入。这些诉求最后基本都汇成一句「找开发处理一下」。根子在于,Elastic Job 没有把『带参数、立刻触发一次』当成一等能力------它的编程模型是围绕 Cron + 分片设计的,天然适合「到点自动跑」,而不适合「现在、用这组参数、跑一次」。

于是作为开发,我手上只有几种都不太体面的办法:

  1. 临时改 Cron 再改回来:把任务 Cron 改成马上触发,跑完再改回原值。改配置要走发布或配置中心,跑完还得记得还原------忘了还原就是一次线上事故,整个过程也没有任何审计。
  2. 写一次性脚本 / 临时代码:为了重拉某天的文件,单独写段代码或 SQL 手动连库、调接口。一次性、不可复用、容易手抖,跑完即弃。
  3. 自己造一套触发入口 :在管理后台加接口,接收参数 → 校验 → 调起对应逻辑 → 落执行记录 → 关联日志。可这套东西本质上就是把调度框架本该自带的「手动触发 + 参数传递 + 执行记录」重新实现一遍,纯属重复造轮子。

更难受的是后半段:这些临时触发跑完之后,没有统一的执行记录和日志入口。运营问我「刚才那次重拉成功没」,我还得回到 SSH 上 grep 日志才能回答。一次临时补偿,从接需求、改触发、看结果到回话,全程靠人肉串联。

算下来,开发被这类琐事反复打断,还要维护一堆胶水代码,时间都耗在了本该「点一下按钮」就能解决的事情上。

2.5 社区基本停更,等于自带技术债

Elastic Job 早年很活跃,但在我们做选型时已经很久没有实质性更新:Issue 大量堆积、文档老旧、跟进新版本 Spring Boot 的节奏很慢。把一个近乎停更的框架放在核心链路上,任何坑都只能自己填,长期看就是一笔持续累积的技术债。


三、主流分布式任务调度框架横向对比

真正动手迁之前,我把市面上主流的几个方案都挨个摸了一遍。

3.1 Spring Task(@Scheduled)

Spring 自带的,零依赖、配置也简单。但它是单机的------任务就跑在应用进程里,一旦多实例部署,每个实例都会各跑各的,同一个任务被重复执行。

结论:单体应用、或者压根不需要分布式协调的简单场景还行,对我们来说直接出局。

3.2 Quartz

老牌框架了,靠 JDBC JobStore 撑起集群模式------多个实例抢数据库锁,谁抢到谁跑,保证同一时刻只有一个实例在执行。

优点 缺点
成熟稳定,社区大 无可用 UI,管理完全依赖代码或第三方工具
集群模式只需 MySQL 动态修改任务配置需要自己实现
Spring 生态集成好 任务日志、执行历史需要自建
集群模式轮询数据库,性能有上限

Quartz 能解决「多实例重复跑」这个问题,但可运维性这块它没管。不管是研发排查、运维值守,还是想往上包一层手动触发的入口,它对你来说都还是个黑盒。

3.3 Elastic Job

当当网开源的,基于 ZooKeeper,分片这块设计得很漂亮。

优点 缺点
分片能力强,适合大数据量批处理 强依赖 ZooKeeper,运维成本高
故障转移机制完善 UI 基本缺失,运维不友好
弹性扩缩容 社区活跃度持续下降
与 Nacos 注册中心并存,技术栈重叠

3.4 XXL-JOB

大众点评开源的,国内用得最广的调度平台之一。

优点 缺点
完整 Web 控制台,开箱即用 分片能力相对弱(路由策略固定)
只依赖 MySQL,无额外中间件 调度中心是中心化架构,调度中心自身需高可用部署
手动触发 + 参数传递原生支持
执行日志实时在线查看
丰富路由策略(轮询/故障转移/分片广播等)
社区活跃,持续迭代
Spring Boot 集成简洁

3.5 PowerJob

阿里系新一代的调度框架,功能是这几个里最猛的。

优点 缺点
支持工作流(DAG 任务依赖) 相对较新,社区规模尚小
MapReduce 模式支持海量数据分片 引入成本高,学习曲线陡
原生支持容器化 对多数业务场景来说过于重
管控 UI 最完善

3.6 DolphinScheduler / Airflow

这俩是大数据圈的工作流调度平台,主要拿来编排数据管道的,部署重、跟微服务场景也不搭,直接排除。

3.7 横向对比总结

维度 Spring Task Quartz Elastic Job XXL-JOB PowerJob
分布式支持 ✓(DB锁) ✓(ZK分片) ✓(DB+调度中心)
可视化控制台
手动触发+传参
额外中间件依赖 MySQL ZooKeeper MySQL MySQL
数据分片能力 ✓✓✓ ✓✓✓
社区活跃度 ---
接入复杂度
适合场景 单机简单任务 通用分布式 大数据分片 通用 + 临时补偿 复杂工作流

四、为什么最终落在 XXL-JOB

调研一圈下来,结合我们自己的技术栈和那一堆任务的实际形态,XXL-JOB 是最顺手的那个。下面说说具体打动我的几点。

4.1 依赖最轻,不给技术栈添乱

XXL-JOB 的架构很简单:一个中心化的调度中心(admin)+ 嵌在各业务服务里的执行器(executor),两边靠 HTTP RPC 通信,所有调度元数据就存在一张 MySQL 库里。

这正好戳中我们的痛点------不用再为了跑定时任务单独养一套 ZooKeeper。MySQL 我们本来就有,调度中心连过去就能用;Nacos 还干它注册中心的本职,互不打扰。比起 Elastic Job 那套「Nacos + ZooKeeper」的双中间件,基础设施一下清爽了不少。

4.2 自带控制台,把「盲跑」变成看得见

2.2 里「翻日志 + 读 ZK 节点」那套排查方式,XXL-JOB 用一个自带的 Web 控制台从框架层面直接给抹平了:

  • 任务和执行器状态一眼就能看:哪些执行器在线、每个任务上次/下次什么时候触发、当前在不在跑,界面上直接摆着,不用再去猜某个任务到底有没有正常调度
  • 调度日志和执行日志分两层留痕 :调度中心记每一次「触发」的结果(调没调成功、路由到了哪台机器),代码里 XxlJobHelper.log() 打的执行日志能在控制台在线滚动看,不用再 SSH 上去 grep------一次执行从头到尾在一个页面里就翻完了
  • 历史和耗时都能追:每次执行的历史记录、耗时都留着,「这个任务最近十次跑得怎么样」点开就有,慢慢变卡也能提前发现
  • 失败有兜底:支持失败重试、失败告警,还有单机串行、丢弃后续、覆盖之前这几种阻塞处理策略,长耗时任务堆住了也有明确的处理方式

对我们研发和运维来说,排查链路从「业务日志 + 执行器状态 + ZK 节点」三头来回对,收敛成了控制台一个入口,定位问题从几十分钟缩到几分钟。

4.3 触发和传参是原生能力,正好解开 2.4 那个结

第二章里最磨人的临时补偿问题,到这儿基本就没了。XXL-JOB 把「手动触发 + 带参执行」做成了开箱即用的东西:控制台点一下「执行一次」,在「任务参数」框里填上参数,调度中心就把它下发给执行器,代码里一行 XxlJobHelper.getJobParam() 就拿到了。

我们的任务本来就大量靠 JSON 参数控制行为,配合得很顺:

java 复制代码
@XxlJob("batchDeductRecordTask")
public void batchDeductRecordTask() {
    String param = XxlJobHelper.getJobParam();
    // 空参就按默认逻辑跑(比如处理昨天的数据)
    // 传参 {"fundId":"xxx","date":"2024-01-15","rate":50} 就按指定条件重跑
    ...
}

同一个 Handler,到点了自动跑,要补偿时手动带参跑一次,两种用法一套代码搞定。以前那些「临时改 Cron」「写一次性脚本」的破活儿,基本就用不着了。

要是还想再进一步,XXL-JOB 也开放了 RESTful 的触发 API,能把「触发一次」这个动作接到我们自己的后台里,让运营在后台点按钮、后端转成一次带参触发------这层看各自需不需要,框架已经把底座铺好了。

4.4 路由策略够用,单实例执行稳稳的

我们绝大多数任务都是「同一时刻只在一台机器上跑一次」,XXL-JOB 的路由策略里现成就有:默认轮询,机器挂了用「故障转移」自动切到下一个在线执行器,想固定某台还能用「第一个」或「一致性 HASH」。再配上 4.2 说的阻塞处理策略,定时触发和手动触发撞一块儿也不至于乱跑。

真要做大数据量并行,它也有「分片广播」能用------只是我们这些任务压根用不上。能力在那儿但不强求,比 Elastic Job 那套强绑 ZK 的分片轻量太多了。

4.5 PowerJob 更强,但对我们是杀鸡用牛刀

说句公道话,论功能上限 PowerJob 确实更强:DAG 工作流、MapReduce 级别的分片,XXL-JOB 都比不了。但我们的任务彼此基本没依赖关系,也没大到要 MapReduce 才扛得住的量。为这些用不上的能力去扛 PowerJob 更高的学习和运维成本,不划算。

选型这事我一直信「够用就好」------当下 XXL-JOB 刚好卡在我们需求的甜区。哪天业务真长出复杂的 DAG 编排需求,再迁 PowerJob 也来得及。


五、迁移过程里踩过、攒下的几个经验

真正落地的时候也不是一帆风顺,下面几个点是我觉得比较值得记下来的。

5.1 配置塞进公共模块,各服务拿来即用

我把 XXL-JOB 的配置统一封装在公共基础模块里,各业务服务只要引个依赖、配上执行器参数就行:

java 复制代码
@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 port;

    @Value("${xxl.job.executor.logpath}")
    private String logPath;

    @Value("${xxl.job.executor.logretentiondays}")
    private int logRetentionDays;

    @Bean
    public XxlJobSpringExecutor xxlJobExecutor() {
        XxlJobSpringExecutor executor = new XxlJobSpringExecutor();
        executor.setAdminAddresses(adminAddresses);
        executor.setAppname(appName);
        executor.setPort(port);
        executor.setLogPath(logPath);
        executor.setLogRetentionDays(logRetentionDays);
        return executor;
    }
}

之后写新任务,方法上加个 @XxlJob("handlerName") 注解就完事了,对业务代码几乎没侵入。

5.2 执行器老是「注册超时」,最后靠反射救场

迁移刚开始那会儿,执行器时不时报「注册超时」。查下来是 XXL-JOB 内置的 AdminBizClient 默认超时给得太短,网络稍微差点就容易触发。

我的解法是用 ApplicationRunner,在服务启动后用反射把这个超时值改大:

java 复制代码
@Component
public class XxlJobAdminClient implements ApplicationRunner {

    // 通过配置中心动态控制,无需重启
    @Value("${xxlJob.client.readTimeout:10}")
    private int readTimeout;

    @Override
    public void run(ApplicationArguments args) throws Exception {
        List<AdminBiz> list = XxlJobExecutor.getAdminBizList();
        for (AdminBiz adminBiz : list) {
            AdminBizClient client = (AdminBizClient) adminBiz;
            Field field = client.getClass().getDeclaredField("timeout");
            field.setAccessible(true);
            field.set(client, readTimeout);
        }
    }
}

为啥不直接配?因为 XXL-JOB 2.x 压根没把这个参数开放出来配置,只能反射硬改------算是框架的一个小坑。

5.3 执行器按模块拆开,互不拖累

我没让所有任务挤在一个执行器里,而是按模块各自注册、各占一个端口:

properties 复制代码
# 各模块各自配置不同的 appname 和 port
xxl.job.executor.appname=xxxCoreExecutor
xxl.job.executor.port=9996

xxl.job.executor.appname=xxxFundExecutor
xxl.job.executor.port=9998

这样核心交易类任务(批量代扣、放款重试)和统计类任务(收入核算、报表生成)就隔开了,一个模块的任务卡住了,不会连累别的模块。

5.4 关键任务再加一把 Redis 分布式锁

XXL-JOB 自带的「防重执行」只能保证同一个任务不并发触发,但拦不住多个模块的任务去抢同一份资源。所以关键任务我还会额外上一把 Redis 分布式锁:

java 复制代码
@XxlJob("incomeRetryJob")
public void incomeRetryJob() {
    String lockKey = "lock:income:retry";
    boolean locked = redisService.setNxAndEx(lockKey, "1", 30 * 60);
    if (!locked) {
        XxlJobHelper.log("任务正在执行中,跳过本次调度");
        return;
    }
    try {
        // 执行收入重算逻辑
    } finally {
        redisService.del(lockKey);
    }
}

XXL-JOB 的「单机串行」策略虽然能挡住同一个执行器内的并发,但手动触发要是和定时触发撞上了,照样可能并发跑,分布式锁这道保险更稳妥。

5.5 任务参数定个规范

我统一约定用 JSON 传参,而且空参就走默认逻辑,这样同一个任务能反复复用:

java 复制代码
@XxlJob("fileDownloadTask")
public void fileDownloadTask() {
    String paramStr = XxlJobHelper.getJobParam();
    
    // 空参:按默认规则执行(处理昨天的数据)
    // 传参:{"date": "2024-01-15", "fundId": "xxx"} 支持指定重跑
    TaskParam param = StringUtils.isBlank(paramStr)
        ? TaskParam.defaultParam()
        : JSON.parseObject(paramStr, TaskParam.class);
    
    doFileDownload(param);
}

就这么一个小约定,让同一个 Handler 既能当定时任务自动跑,需要的时候手动带参补一次也毫无压力。


六、迁完前后,差别有多大

维度 迁移前(Elastic Job) 迁移后(XXL-JOB)
额外中间件 得养一套 ZooKeeper 不用,直接复用现成的 MySQL
看任务状态 翻日志、抠 ZK 节点 控制台里实时就能看
临时补偿触发 不支持,每次找开发 控制台点一下、带参就跑
执行日志 散在各台机器的文件里 控制台一处集中看
排查效率 慢,链路绕 快,界面直接定位
加新任务 麻烦(配 ZK + 写代码) 轻松(一个注解 + 控制台登记)
社区保障 基本停更,出事自己扛 活跃,一直在迭代

七、写在最后

选型这事没有银弹,合不合适自己才是关键。

Elastic Job 的分片设计是真的好,要是你的场景是「定期扫几千万行数据做并行批处理」,它照样是好选择。但落到我们这种以轮询重试、文件拉取、状态同步为主、还动不动要临时补偿的活儿上,它那套复杂度对我们就是负担,换不来对等的价值。

XXL-JOB 帮我们做到了三件事:拿 MySQL 顶掉 ZooKeeper,基础设施清爽了;靠 Web 控制台,研发运维终于能「看得见」任务了;靠原生的参数传递,临时补偿从「找开发」变成了「点按钮」。任务一多,这三点的价值是实打实能感受到的。

至于 PowerJob,它代表了调度框架更高的天花板。哪天业务真长出了 DAG 编排、或者大到要 MapReduce 分片的需求,那会儿再迁也完全来得及。说到底,技术选型就是在当下的约束里挑最合适的那个,而不是一上来就奔着最强的去。

相关推荐
SamDeepThinking3 小时前
面向对象真正管理的,不是对象,而是依赖
java·后端·程序员
9i编程3 小时前
5.Nacos 配置中心化改造与短信 Debug 模式动态切换实践
后端·spring cloud
PinkSun3 小时前
上线第一天,Redis和Docker联手给我上了一课
后端
清秋冷雨3 小时前
Harness Engineer系列(三):Harness工程化——AI编程的最后一里地
后端
Csvn3 小时前
itertools —— Python 最强迭代器工具箱实战
后端
冰暮流星4 小时前
flask之定义URL
后端·python·flask
程序员爱钓鱼4 小时前
为什么学习 Rust?Rust 能做什么?
后端·rust
二宝哥5 小时前
创建Gradle多模块SpringBoot项目
java·spring boot·后端
掘金者阿豪5 小时前
Docker命令太多记不住?用Portainer把容器管理变成可视化操作
后端
用户69371750013846 小时前
AI Agent 里的 Loop 到底是什么?
android·前端·后端