说实话,这个项目一开始没人看好。内部代号就叫“ax”,听起来像个随手拍的文件夹,但最后我们把它变成了一个支撑几十条业务线的分布式调度引擎。你问什么是“ax调度”?简单说,就是系统里所有“定时跑的活”、“依赖上游的批处理”、“需要重试和路由的异步任务”,都由这一套引擎统一接管。干这行的都知道,调度这种东西看似是外围支撑,一旦出问题,整个数据链路都跟着抖。这篇就围绕 ax 的选型、架构、落地和踩坑,把能说的实操细节全盘托出来。
我过去断断续续用过 Quartz、XXL-JOB,也接触过一点 Airflow。说实话,各有各的好,但落到我们这种“既要快速接入,又要强管控,还不想被中间件绑架”的团队,总觉得差一口气。ax 的出现并不是要再造一个全套调度平台,而是想做一个足够朴素的“调度内核”:对外暴露稳定 API,对内收敛任务编排和执行策略,把最脏最累的控制逻辑藏在底层。下面的内容主要按五块走:为什么用 ax、核心模型怎么设计、关键参数怎么配、实操怎么落地、线上常见故障怎么排查。如果你是维护过任务系统的人,应该能从这里翻出不少有用的东西。
1. 内容整体设计与思路拆解
1.1 这个项目到底解决什么问题
在没有统一调度之前,各业务线怎么跑定时任务?无非是 Linux crontab 一份,代码里 Thread.sleep 一份,某个运维同学手打的 Jenkins 定时任务再来一份。表面上都能跑,实际上谁也说不清今天哪些任务成功、哪些失败、哪个先跑、哪个被跳过。
ax 要解决的核心问题就是三件事:依赖调度、分布式执行、失败自动衔接。依赖调度,是指任务之间可以声明“上游成功之后我才能开始”;分布式执行,是指任务可以在多个 worker 节点上并行跑,不至于单点一挂全部瘫痪;失败自动衔接,则要求在任务失败时按策略重试、告警,或者直接触发下游的补偿逻辑。
这一层想清楚之后,后面的技术选型就好做了。再也没有“为了用框架而用框架”的折腾,所有组件都围着这三个核心需求转。
1.2 为什么不直接选现成的调度中间件
接触过 XXL-JOB 和 Quartz 的朋友应该会有同感:它们把“调度”这件事本身做得很好,但“编排”和“结合公司内部基础设施”常常得靠大量二次开发。比如我们需要按业务线隔离权限,需要把若干任务组合成有向无环图一键执行,需要把执行结果回写到公司内部的数据平台,这些在原生功能里并不直接支持。
所以我没选“全家桶”,而是选了一个足够薄的“调度内核”路线。假设调度体系分三层:顶层是业务侧的可视化流程编排,中间是调度的核心服务,底层是执行任务的 worker 集群。ax 主要承担中间这一层,把任务模型、触发条件、锁机制、失败转移这些底层能力做扎实,上层允许各自团队接入。这样既保留平台灵活度,又不至于让调度逻辑散落到业务项目里,怎么改都改不动。
1.3 总体架构设计的选择依据
ax 的总体架构参考的是“中心调度 + 无状态执行”的模型。调度端只有一个逻辑上的调度中心,它负责解析任务、计算触发时间、派发指令;实际干活的是多个 worker。worker 本身不存任务状态,全部状态回写存储层,这样即使某个 worker 宕机,调度中心也能快速把未完成任务重新派发给其他节点。
当时也有另一个方案:使用分布式队列把任务全部灌入消息中间件再消费。但后来否了,原因是很多任务并不适合做成纯消息。部分任务需要固定在某台机器上执行,毕竟要读取本机文件或者访问内网 IP;部分任务有严格时序,不能单纯靠消费速度来保证。ax 的做法是支持“分片执行”和“节点路由”,把该并行的并行,该钉死的钉死,最终才符合业务实际情况。
2. 核心细节解析与实操要点
2.1 任务的四种基本模型
ax 里的任务模型没有搞得很玄,归根结底就四种:
- 一次性任务:只跑一次,适合迁移数据、手工触发补偿。
- 定时任务:按 cron 表达式周期触发,适合常规数据同步。
- 依赖任务:等待上游事件或上游任务完成后再执行。
- 长驻任务:持续运行的流式处理,更像常驻进程。
这四种模型覆盖了线上绝大多数场景。但要注意,模型不是越复杂越好,反而应该让业务方明确自己到底属于哪一类。如果一个任务又定时又依赖又长驻,建议拆成多个任务再编排,否则排查问题的时候没人能定位是它没触发还是触发后没结果。
2.2 调度时间计算背后的坑
定时任务的触发时间计算,看起来就是一个 Cron 表达式解析,实际上有点隐蔽问题。最典型的是“上一个任务执行太久,影响了下一个触发点”。有点像你定好了每天早上八点跑步,但昨天熬夜今天起晚了,那今天的跑步是取消、顺延、还是立刻补跑?ax 默认的策略是“跳过”,也就是当前触发时间到达后,如果上一个执行还没结束,本轮不再触发,而是等下一轮。
这里有一个我强烈建议调整的配置:misfire 策略。有些系统默认会立刻补跑错过的任务,这就容易造成雪崩。比如一个任务凌晨三点因为数据库缓慢没跑完,三点零五分还没释放,结果系统认为它错过了三点这轮,立刻补跑,两个实例同时操作同一张表,锁冲突就来了。正确做法是先把 misfire 固定为“不补跑”,然后再根据业务是否允许延后执行来判断是否单独补录。
2.3 调度器时钟同步的细节
调度中心负责算时间,worker 负责干活,这中间有个假设是时钟是可信的。可现实中虚拟机经常发生时间漂移,我曾经见过一个节点时钟慢了 3 分钟,导致它执行的任务全部比预期晚 3 分钟。数据同步晚三分钟对某些场景来说完全不可接受。
排查方式很简单:在调度中心心跳包中附带时间戳,worker 每次上报心跳时和本地时间做差值。如果差值超过阈值,就直接标记节点异常,不再派发新任务。这个机制听起来轻微,但实际省了很多“任务为什么延迟”的排查时间。你不需要人工逐个检查机器时间,调度平台自己在后台就挡掉一批问题。
3. 实操过程与核心环节实现
3.1 环境搭建与目录规划
ax 落地不需要很重的外部依赖,最少只需要一台调度中心节点和一台 worker 节点。但建议无论多小的集群,都预留独立的配置目录,目录里分三块:conf存放调度中心与 worker 的配置,store放本地磁盘缓存与临时文件,logs滚动执行日志。分开存放不是洁癖,而是方便之后排查磁盘占用和日志路径问题。
调度中心配置里最核心的一项是数据库连接串。ax 把任务定义、触发记录、运行日志都持久化到 MySQL,所以这条连接串的质量直接决定系统稳定性。我的建议是不要用默认超时时间,显式配置连接超时和 socket 超时,否则数据库做一次主从切换,调度中心半天没感知,整个任务派发就卡住了。
3.2 接入第一个定时任务的完整步骤
下面我以“每小时同步一次订单表”为例,说明在 ax 里注册一个任务需要做什么。假设你已经部署好调度中心和 worker,并且配置了任务对应的执行器。
第一步,在调度中心后台新增执行器。一个执行器可以理解为一组任务的归属分组,名称建议与业务模块保持一致,比如order-sync-worker。这里有个细节:注册方式选择“自动注册”还是“手动录入”。如果你在容器环境里经常扩缩容,建议选择自动注册,让 worker 启动时自动上报地址。如果 worker 在固定的内网物理机上,手动录入反而更稳妥,避免临时 IP 被安排在漂移之后注册到错误节点。
第二步,新增任务并填写处理参数。关键参数包括:cron 表达式、运行模式(单节点还是分片)、阻塞处理策略、失败重试次数、超时时间。对订单同步这个场景,运行模式选分片,利用多台 worker 各自同步一部分数据;超时时间我设置为 120 秒,超过后调度中心直接强制中断。
第三步,编写执行器代码。ax 暴露的执行器接口很简单,核心方法是接收一个包含任务 ID、分片序号、业务参数的 ExecuteContext,然后返回执行状态。有一点必须提醒:执行器里不做复杂的本地线程管理。以前有同事喜欢在任务里自己创建线程池,结果 worker 节点一重启,线程池里的任务全部变成孤儿。所有异步逻辑要么交给 ax 的分布式执行,要么等任务结束后外部系统统一收口,不要图一时爽快埋雷。
3.3 分片参数的计算方式
“分片参数”听起来高大上,本质上就是把一个大批量任务切碎。假设订单同步任务要处理 100 万条数据,现在有 4 台 worker 节点,那么理想的分片策略是按主键 ID 区间分段。ax 支持给每个分片传入shardIndex和shardSize,业务侧拿到这两个参数后自行整除即可。
例如本次分片大小为 4,第 2 片处理的数据范围是2 * (100万 / 4)到3 * (100万 / 4)。但要注意,如果数据量不是均匀分布的,这种简单平均并不合理。有个很快的优化技巧:业务侧先通过数据库查询出一个最小 ID 和最大 ID,再根据实际数据条数做动态区间切分,让每个分片条数接近均匀。通过在分片逻辑里增加一次COUNT(*)查询,哪怕多花几十毫秒,也能避免某个分片执行 5 秒、另一个分片执行 5 分钟的极端情况。
3.4 阻塞策略的取舍
阻塞处理策略是 ax 里一个容易被忽略但必须提前决定的参数。什么叫阻塞?就是当前任务还没跑完,新的触发时间又到了,系统应该怎么办。ax 提供三种基本策略:丢弃新触发、立即执行、单机串行。
我的习惯是:普通任务统一用“丢新触发”。从业务上看,上一个周期还在处理中,说明数据还没消化完,再来新周期意义不大。对于必须周期连续、不能漏跑的指标统计任务,则单独设为“单机串行”,宁可时间拉长,也不能中间断档。最不建议的是“立即执行”,这等于把并发压力直接放大到数据库或下游接口上,多数线上事故都是从这里开始的。
4. 常见问题与排查技巧实录
4.1 任务堆积怎么排查
线上最恐慌的场景之一就是任务堆积。比如同步任务每小时跑一次,结果某次数据源卡了 40 分钟,所有 worker 都在等结果,后续任务全堆积。
第一件事不是补跑,而是查“是不是同一把锁卡住了所有并发”。ax 默认用数据库乐观锁确保同一个任务同一个时间窗口只有一个调度线程。如果锁记录没有释放,后面的触发全部失败。这时先查调度记录里任务状态是不是一直是“运行中”,如果是,而对应 worker 已经没了心跳,那就需要手动把执行状态置为失败,让锁释放。
第二件事是看堆积任务的“消费速率”。有时根本原因是某条 SQL 查询特别慢,我遇到过上游接口单条耗时 3 秒、一次性请求 5000 条的情况。排查出接口超时后,最直接的办法是把任务改成批量拉取,而不是让调度平台去调高并发。
4.2 重复执行和分布式锁失效
都说调度平台要保证任务不重复执行,但实际很难。一是在网络不稳定时,调度中心发送执行指令后没收到 worker 的回执,便重发指令,worker 实际执行了两遍。二是数据库锁在极端情况下因连接超时提前释放。
ax 的解法是两层保障。第一层是任务执行前检查执行 ID 是否已存在,这里可以用 Redis 幂等标记。第二层是业务侧去重,尤其是写操作,尽量使用数据库唯一索引而不是先查后插。很多团队指望调度平台做到“绝不重复”,这是把责任放错了地方。平台只能尽量降低重复概率,真正的防线还是业务系统自己处理好幂等。
4.3 调度记录与日志不同步的毛病
有段时间我们发现任务重试了好几次,但 Java 日志里一点内容都没有。后来一查,是 worker 端日志滚动配置把 info 级别日志写到了不同目录,而调度记录里只保留了 “ERROR 时打印的错误堆栈”的字段,造成平台显示失败,日志里却找不到报错的假象。
建议从一开始就把结构化 log 接入任务上下文,把 taskId、shardIndex、attempt 序号都打进日志。这样后续无论查询 ELK 还是翻本地文件,都能按任务 ID 串联起来。另一个技巧是采取“双写策略”:调度记录只存摘要信息,详情日志以文件或日志流方式保留,避免把大量运行内容塞进数据库。
4.4 线上真实案例复盘
这里分享一个印象非常深的问题。某个业务方在 ax 上配置了一个每小时执行的数据修复任务,正常情况一秒跑完。后来业务量涨了,修复时间变成五秒。他们自己把任务 cron 从每小时改成每五分钟,但没考虑阻塞策略,结果前一轮还没释放,新一轮触发直接失败,失败又触发重试,重试再次产生并发,最终数据库连接被打满。
复盘下来的根子在于:业务方只考虑了任务执行频率,没有理解调度系统的资源隔离机制。最后我们专门做了一个限制逻辑,单个执行器组的并发线程数不能超过配置上限,一旦达到上限,后续任务进入等待队列,而不是无限重试。这在 ax 里实现起来并不复杂,但价值很大。后来我再给别人设计任务调度方案,都会问一句:你允许这个任务在同一时间的最大并行数是多少?如果答不上来,那调度策略一定有问题。
5. 一些操作习惯与心得
根据我碰过这么多调度系统的经验,有几个根深蒂固的准则可以说一下。
第一个准则是“调度平台只负责触发,不负责业务重试逻辑的完整补偿”。失败重试可以做,但一定要限制次数和总耗时。比如某个接口调对方服务,对方已经处理成功但是响应超时,重试又调一次,就会产生重复数据。这种补偿诉求不能全部交给调度平台,应该在业务代码里判断查询结果是否已经有数据。
第二个准则是“任何任务定义都要像代码一样走评审”。任务的 cron、超时、重试次数、执行队列,这些看起来是配置,其实是生产逻辑。我见过有人把最终生产环境的 cron 表达式错误复制成测试环境的,因为账号权限控制不到位,导致生产凌晨执行了全量数据清理。现在团队里调度配置改动必须提交工单,还要关联变更说明。
第三个建议是定期做一次调度演练。比如手动把某个 worker 节点停掉,观察任务是否自动转移;把数据库连接池调小,看看任务失败是否会在预期时间内触发告警。演练的目的不是证明平台多稳定,而是让值班同学熟悉最快恢复的路径。
最后分享一个小技巧:对于执行频率较高的短任务,不要每次都走数据库记录完整日志,先缓存到本地文件,异步批量上报。否则调度中心在高频任务下磁盘 IO 和数据库压力都很大,原本用来提升效率的调度器反而成了新的性能瓶颈。
ax 这套体系从最开始无人关注,到后来成为业务侧默认的调度入口,靠的不是炫技,而是一点点把底层控制力做扎实。希望对正在摸索任务调度的你有参考价值。