当定时任务涨到 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 分片的需求,那会儿再迁也完全来得及。说到底,技术选型就是在当下的约束里挑最合适的那个,而不是一上来就奔着最强的去。

相关推荐
Kir1to1 小时前
分布式锁基础与三种实现方式对比
后端
MariaH1 小时前
Web服务器开发
后端
程序边界1 小时前
凌晨三点批量掉授权,我花了四小时才搞明白LAC心跳链路是怎么算的
后端
叫我:松哥1 小时前
基于Flask的在线考试刷题系统设计与实现,集智能练习、过程追踪、深度分析与个性化引导
数据库·人工智能·后端·python·flask·boostrap
AI人工智能_电脑小能手1 小时前
【大白话说Java面试题 第106题】【并发篇】第6题:synchronized 锁的锁对象可以是什么?
java·后端·面试
Rain5091 小时前
2.3. 安全配置:环境变量与 API 密钥管理
前端·人工智能·后端·安全·ai·node.js·ai编程
yinchnag1 小时前
Go 语言 map 底层实现
后端·源码阅读
MariaH1 小时前
Express框架使用
后端
MacroZheng1 小时前
横空出世!Claude Code画图神器来了,比Visio快10倍!
java·人工智能·后端