一、背景
我们的系统是个基于 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 + 分片设计的,天然适合「到点自动跑」,而不适合「现在、用这组参数、跑一次」。
于是作为开发,我手上只有几种都不太体面的办法:
- 临时改 Cron 再改回来:把任务 Cron 改成马上触发,跑完再改回原值。改配置要走发布或配置中心,跑完还得记得还原------忘了还原就是一次线上事故,整个过程也没有任何审计。
- 写一次性脚本 / 临时代码:为了重拉某天的文件,单独写段代码或 SQL 手动连库、调接口。一次性、不可复用、容易手抖,跑完即弃。
- 自己造一套触发入口 :在管理后台加接口,接收参数 → 校验 → 调起对应逻辑 → 落执行记录 → 关联日志。可这套东西本质上就是把调度框架本该自带的「手动触发 + 参数传递 + 执行记录」重新实现一遍,纯属重复造轮子。
更难受的是后半段:这些临时触发跑完之后,没有统一的执行记录和日志入口。运营问我「刚才那次重拉成功没」,我还得回到 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 分片的需求,那会儿再迁也完全来得及。说到底,技术选型就是在当下的约束里挑最合适的那个,而不是一上来就奔着最强的去。