搞调度系统这事其实一开始我是拒绝的。团队里跑着几十个定时脚本分散在不同服务器上有的用 crontab有的写在业务代码里还有的靠人工点按钮触发。谁改过 cron 谁心里清楚那东西一旦多起来你根本不知道哪个任务被谁覆盖了哪次漏跑需要回溯。后来我把这套自研的调度引擎代号定为 ax中文叫 ax 调度核心就做一件事把所有零散的定时任务、依赖关系、执行结果统一收口到一个可观测、可编排、可动态调整的管线上。这篇文章不整虚的就讲讲 ax 调度从设计到落地的完整过程包括架构选型、任务定义、DAG 编排、实操验证和那些踩过的坑。适合正在搭调度平台、或者被 cron 和 Jenkins 搞到头疼的团队参考。1. ax 调度到底解决了什么问题整体设计思路拆解1.1 从单体定时任务到分布式调度的真实痛点先说一个特别现实的场景。假设公司有张订单表每天晚上要同步到数仓凌晨跑报表早上九点推给业务群。这个链路只要中间任何一环失败后面全部白跑。原来的做法是在一台服务器上写一串 crontab任务之间靠 sleep 硬凑时间间隔。比如同步凌晨 12 点执行报表 2 点执行推送 6 点执行。一旦同步跑了 3 个小时报表那边就完蛋。你可能会说那把时间往后挪不就行了可业务是变的数据量在涨接口偶尔超时你不可能天天去调 cron 时间。这就是 ax 调度要解决的第一个问题任务之间不能靠时间偏移来建立依赖必须靠真实完成状态。也就是说一个任务只有在它依赖的上游任务标记为成功之后才会被触发。时间只是触发条件之一不是协调手段。第二个痛点是监控和回溯。cron 任务失败了你靠什么知道运气好有日志可以翻运气不好用户投诉了才知道。ax 调度的思路是把每一次触发、每个节点的执行状态、每条调度日志都记录下来并且暴露成一个查询接口。这样任何一个任务跑失败你能立刻看到失败发生在哪一步、重试了几次、参数是什么。第三个痛点是并发和资源隔离。原来的脚本放在同一台机器上互相抢资源一个任务把 CPU 打满其他任务全部超时。这其实是分布式调度最容易被忽视的问题。ax 调度在设计时就考虑了执行器分组把不同业务线的任务分到不同的执行资源池里彼此物理隔离避免一损俱损。1.2 为什么选 ax 这个代号设计哲学是“砍掉重复轮子”起名的时候想了很多什么 TaskFlow、CronX、SchedulerPro都太虚。后来定的 ax灵感来自两个词一个是 axis轴线所有任务像绕一根轴一样有序转动另一个是 axe斧头寓意砍掉那些临时拼凑的脚本和重复的调度轮子。叫起来也顺口两个字母在命令行里敲起来非常快。ax 调度的设计原则归结成一句话让调度回归状态转换。即每分每秒都在判断“当前任务是否可以被执行”而不是“当前时间到了没”。时间只是用来生成调度事件的真正决定一个任务跑不跑要看三件事——时间是否满足、依赖的上游是否全部成功、资源锁是否拿得到。这三件事组合起来才能让调度系统在大规模任务下保持正确的语义。我参考过一些开源框架的做法比如 XXL-Job、Airflow 的 DAG 模式各有各的好处。ax 调度更像是一个轻量混合体既保留了分布式调度器那种基于秒级触发的能力又引入了工作流级别的依赖管理。但比 Airflow 轻得多不需要装整套 Python 环境也不需要写一堆 DAG 文件比 XXL-Job 又多了真正意义上的依赖编排而不是傻傻地按时间把任务串起来。所以更准确地说ax 调度是一个带依赖能力的通用分布式任务调度中间件。2. ax 调度的核心架构与关键模块2.1 触发器、调度器、执行器三层模型ax 调度的整体架构可以拆成三层触发器trigger、调度器scheduler、执行器worker。这个三等分模式在调度领域很经典但 ax 在细节上做了不少优化。触发器负责生成调度事件它只管一件事情检查某个任务的定义里时间规则是否满足当前时间。比如任务配置了 cron 表达式0 0 2 * * ?那么每天凌晨两点会生成一条“该触发任务 A 了”的事件发给调度器。触发器本身不关心任务能不能跑不关心有没有依赖它只负责按时报信。调度器是核心大脑。收到触发事件后它先去数据库里查任务 A 的所有上游任务状态如果全部是 success就尝试获取一个分布式锁。拿到锁之后把任务的状态从 pending 改成 dispatching然后从执行器注册表里挑选一个健康的 worker下发执行指令。整个过程中状态变化是串行化的这就是避免同一个任务被重复执行的关键。执行器就是真正干活的人。worker 启动时会向调度器注册自己的地址和可执行的任务类型之后不断轮询或者通过长连接接收指令。执行器干完活之后把执行结果回调给调度器。回调内容包括执行是否成功、结束时间、退出码、日志路径。调度器根据回调更新任务状态然后触发后续依赖任务。三层模型的好处是每一层都可以独立扩展。触发器跑不动了就多挂几个调度器要避免单点就做集群选主执行器更加不用说了完全可以按业务量动态增减。ax 调度的部署形态就是这三种角色可以混布也可以分开部署。2.2 任务依赖与 DAG 编排的实现思路依赖是 ax 调度最核心的卖点。在 ax 里任务不再是一个个孤立的脚本而是节点。节点之间可以有一条或多条有向边比如 A - B - C表示 B 依赖 AC 依赖 B。所有节点和边组合起来就是一个有向无环图也就是 DAG。为什么必须是无环图因为只要有环就会出现死循环依赖A 等 B、B 等 A两个任务永远都跑不了。ax 调度在保存任务依赖时会对图做合法性校验一旦发现有环直接拒绝提交并且返回是哪几个节点构成的环。这个校验非常简单用 DFS 就能实现但很多小团队自研调度时根本不校验结果上线跑两天发现有个任务卡死排查半天才发现是循环依赖。在真正执行时ax 调度用的是拓扑排序的思想。但和离线静态拓扑排序不同ax 调度是动态触发的每个任务节点在创建时就会算好一个 indegree入度也就是依赖了几个上游任务。当一个上游节点成功结束调度器会把下游节点的 indegree 减一。一旦 indegree 变成零就说明所有上游都完成了这个节点立刻进入可触发状态。这里有一个细节值得说如果一个上游节点失败了下游节点怎么办ax 调度默认是直接置为 cancelled并且不会继续向下游传播。但同时也提供了一个开关叫 failover 依赖。开启后只要上游有一个分支成功下游就可以执行。这个功能在数据同步的多源场景特别有用比如你有三个数据源只要有一个同步成功就可以继续做清洗那就把另外两个源配置为非必需依赖。还有一种场景是条件触发。ax 调度的依赖不只是“成功/失败”二元状态它还允许每个节点往外输出一个自定义参数下游节点可以拿到这个参数做一些判断。比如上游任务计算出今天的数据量是 100 万下游任务判断如果数据量超过 80 万就继续执行全量打包否则执行增量打包。这种带参数的条件分支能让调度器的编排能力提升一个台阶。2.3 数据一致性调度状态机的核心设计调度系统最怕什么最怕消息丢失导致任务漏跑或者网络超时导致任务重复跑。ax 调度在状态机上花了很大功夫。每个任务实例都会经历如下状态pending - running - success/failed/cancelled。如果因为 worker 宕机导致任务没有回调调度器会在一个超时周期后把状态改成 lost然后根据重试策略决定是重新下发到别的 worker还是标记为失败。关键在于状态变更必须可追溯。ax 调度给每次状态变更记录一条日志包含旧状态、新状态、操作者、时间戳、原因。这个设计一开始看上去很冗余但真的排障时就太香了。比如用户怀疑某个任务被重试了两回直接拉出状态流转记录一看是网络抖动触发了超时重试还是手动点了重跑一目了然。分布式锁这一块ax 调度没有用外部的 Redis 锁而是依赖数据库的唯一索引。每个任务实例在触发前会往task_attempt表插入一条记录任务 ID 和计划触发时间构成唯一键。如果插入成功说明这个任务实例在当前调度周期内没有被其他节点处理过如果插入异常唯一键冲突就直接丢弃事件。这种方式比 Redis 锁更可靠因为你不需要担心 Redis 的过期时间设置不合理导致锁提前失效数据库的唯一索引天然保证了强一致。另外执行器在执行命令时会先向调度器报告“开始执行”并把进程 PID 记录在案。调度器如果发现 worker 心跳超时就可以通过 SSH 或者 Agent 接口直接杀掉那个还在跑的老进程防止僵尸任务占用资源。这个手段虽然有点暴力但在数据仓库场景下非常管用因为那些跑了几小时的 SQL 查询如果卡死了你不杀掉它资源永远释放不了。3. 从零上手ax 的安装与一个实际调度任务的配置3.1 环境准备与配置项说明ax 调度本身不依赖什么重型组件服务端用 Go 写的启动就是一个二进制文件会创建一个内置的 SQLite 存储也可以切换成 MySQL。执行器可以是独立的进程也可以嵌入到你的应用里。这里我建议第一次试用直接用 SQLite 加单进程模式最快五分钟跑起来。你需要准备的只有三样东西一个可以运行二进制的 Linux 服务器Mac 也能跑Windows 用 WSLMySQL 或者 SQLite选 SQLite 就不用额外装你的业务脚本Python、Shell、Java什么都可以安装过程很简单从发布页把压缩包下载下来解压目录里会有一个ax-server和ax-worker两个文件。先启动 server./ax-server -config server.yamlserver.yaml 里如果什么都不配置默认监听 8641 端口使用本地 SQLite。不过我还是建议看一下完整配置项。下面是我常用的一个最小配置server: port: 8641 storage: type: sqlite dsn: ./data/ax.db scheduler: heartbeatInterval: 10s lossDetectTimeout: 60s maxRetryNum: 3heartbeatInterval表示调度器向 worker 发送心跳包检查活性的频率lossDetectTimeout表示一个 worker 超过多久没上报状态就被判定为失联maxRetryNum是任务失败后的最大自动重试次数设成 3 表示一个任务最多自动重跑三次然后启动 worker./ax-worker -name demo-worker -server http://127.0.0.1:8641worker 启动后会自己往 server 注册服务端可以通过 API 看到有哪些 worker 在线。3.2 用 YAML 定义任务与调度规则ax 调度的任务定义没有用数据库界面去配置而是直接写 YAML 文件。这样做的好处是可以通过 git 做版本管理回滚方便也可以批量迁移。下面是一个最简单的定时任务定义name: sync_order_to_dw type: shell cron: 0 0 2 * * ? command: /opt/scripts/sync_order.py workerTag: dw-group timeout: 3600 retry: 2逐行解释一下name是任务唯一标识type是执行器类型shell 表示让 worker 用 shell 执行命令cron是调度周期这里表示每天凌晨两点触发command是实际要执行的命令workerTag表示这个任务应该被分配到带dw-group标签的 workertimeout是脚本超时时间超时会被强制杀掉retry表示失败后的自动重试次数。这里最容易踩坑的地方是 cron 表达式。ax 调度使用的是标准的 6 字段 Quartz cron也就是秒 分 时 日 月 周。你一开始可能不习惯会写成 5 字段的公版 cron比如0 2 * * *结果 ax 会把开头的 0 当成“秒”后面的 2 当成“分钟”导致任务每秒触发一次。所以用 ax 调度时一定要记住如果你希望每天凌晨两点跑应该写0 0 2 * * ?而不是0 2 * * *。3.3 动态参数与失败重试设置真实场景里任务基本不会只需要一条固定命令往往要传日期参数。ax 调度内置了几个常用变量你在 command 里直接引用就行${bizDate}业务日期默认是昨天格式 yyyy-MM-dd ${triggerTime}实际触发时间 ${instanceId}本次调度实例的唯一 ID举个例子一个从 MySQL 同步到 Hive 的脚本需要传入前一天的业务日期。那任务定义可以这样写name: full_sync type: shell cron: 0 30 1 * * ? command: /opt/scripts/sync_to_hive.sh --date ${bizDate}这样每次触发时${bizDate}都会被替换成实际的业务日期非常灵活。重试这块我想多说两句重试绝对不能盲目。ax 调度的重试策略是支持“隔次衰减”的不是简单的立即重试。比如配置retry: 3默认第一次失败后等 1 分钟重试第二次间隔 5 分钟第三次间隔 15 分钟。你可以通过retryBackoff配置这些间隔值。因为有些失败是瞬时抖动等一会儿就好有些失败是代码 Bug你重试几百次也没用。把重试间隔设置成递进式既能兜底瞬时故障又不会让系统在一个死 Bug 上疯狂空转。还有一点执行器需要保证任务在同一时间戳下只跑一个实例。ax 调度在 worker 端做了单实例互斥如果上一个实例还在运行新触发的实例会自动跳过而不是并发执行。这对于那些必须串行的数据迁移任务非常关键否则会导致同一张表被两个线程同时写。4. 实操过程跑通一个“数据抽取→加工→推送”的完整链路4.1 定义三个有依赖关系的任务光说不练没用下面我直接带你走一遍完整流程。假设业务场景是凌晨 1 点抽取订单数据extract凌晨 2 点对抽取结果做清洗加工transform早上 8 点把清洗后的报表推送到钉钉群push。这三个任务天然有依赖关系。先创建三个 YAML 文件分别对应这三个任务extract.yamlname: extract_order type: shell cron: 0 0 1 * * ? command: /opt/scripts/extract_order.py --output /data/order_raw.parquettransform.yamlname: transform_order type: shell cron: 0 0 2 * * ? command: /opt/scripts/transform_order.py --input /data/order_raw.parquet --output /data/order_report.parquet upstream: - extract_orderpush.yamlname: push_report type: shell cron: 0 0 8 * * ? command: /opt/scripts/push_to_feishu.py --file /data/order_report.parquet upstream: - transform_order这里的关键就是upstream字段。注意尽管 extract 的 cron 是凌晨 1 点transform 的 cron 是凌晨 2 点但真正的依赖不是时间而是 upstream 里的extract_order任务是否成功。即使 extract 跑到 1 点 50 分才结束transform 直到 2 点才会被触发吗不是的。ax 调度的语义是当 transform 的 cron 触发事件产生时调度器会检测它的上游 extract_order 是否为成功状态。如果凌晨 2 点到了extract 还没跑完那么 transform 的这次触发事件会被标记为 skipped 并等待下一个周期还是等上游完成后再立即执行这取决于一个配置项waitForUpstream。默认情况下如果 cron 到了但上游还未成功该次调度会进入 waiting 状态最多等waitTimeout的时间如果等待超时任务会被标记为 failed。你可以通过等待窗口让 transform 在 extract 完成的那一刻立即启动而不是死等第二个周期。对于跨天任务这种机制很有用。4.2 验证 DAG 执行顺序与并发控制把这三个 YAML 文件提交到 ax 调度后可以通过服务端 API 或者命令行工具查看整个 DAG 的图谱。比如用命令ax-cli graph --task transform_order会输出类似下面的内容extract_order - transform_order - push_report提交完配置后我强烈建议做一次手动触发来验证链路。用ax-cli trigger --task extract_order --now强制触发 extract_order。这时候调度器会生成一个实例按照依赖关系把 transform_order 也置为等待状态等 extract_order 执行成功后transform_order 会被自动触发不需要你手动再点。你会发现push_report 的 cron 尽管配置的是早上 8 点但如果 transform_order 在 3 点就成功完成了push_report 要等到 8 点才执行因为 cron 还没到。等到 8 点 scheduler 检查上游状态为 success才会触发 push_report。另一个细节是并发控制。ax 调度的 DAG 模式允许不同分支并行执行。比如你有两个上游任务extract_a和extract_b下游是load那么这两个 extract 任务在资源充足的情况下会同时跑提高了整体效率。但在同一个 worker 上默认情况下一个 worker 同时只跑一个任务实例避免资源争抢。如果你希望让一个 worker 支持并发可以给它配置maxConcurrent: 2或更多。这个参数需要根据机器的 CPU 核数和内存实际情况去调不是越大越好。我见过有人把 worker 的并发数设成 64然后一台 8 核 16G 的机器直接卡死。合理的做法是先用 2观察资源使用率再逐步往上加。4.3 监控与日志排查ax 调度的监控界面是一个极简的 Web UI能显示任务列表、运行状态、耗时分布。不过我更习惯直接看 API 返回的结果。每次任务执行完执行器会把日志路径回传给调度器。你可以在任务实例详情里看到类似worker-01:/data/ax/logs/extract_order_202501123_bizDate_20250121.log这样的字段然后直接去 worker 上查看日志文件。如果任务失败最简单的方式是调接口查失败原因curl http://127.0.0.1:8641/api/task_instance/12345/error返回的error字段里会有退出码、stderr 前几个字、以及执行时的完整命令。大多数情况下失败原因都是脚本自身的路径不对、变量没生效、权限不足。这反而说明调度器没背锅问题出在脚本逻辑。日志这一块ax 调度默认只会记录调度层的日志不会自动抓取业务脚本的全部输出。执行器的日志中会把 stdout/stderr 重定向到一个文件但文件可能会很大。建议在业务脚本里只用 logger 记录关键信息不要 print 一堆调试内容。否则当你排查一个跑了 2 小时的任务时会发现日志文件已经十几个 GB连打开都卡顿。我还遇到过一种情况任务明明显示 success但实际数据没生成。这种问题往往出在脚本的退出码上。有些 Python 脚本就算逻辑失败只要没抛异常退出码依然是 0。ax 调度没法辨别脚本的语义成功还是失败它只能识别进程退出码。所以你在脚本里一定要在关键步骤后显式退出非零码。例如生成完数据文件后校验一下文件非空如果为空就sys.exit(51)。这个习惯能避免大量“假成功”的脏数据。5. 常见问题与排查技巧实录5.1 任务“假死”不触发怎么办现象任务在 ax 调度界面显示 pending一直不触发等了好几个小时也没反应。排查思路分三步。第一步确认调度器的时钟是否正常。调度器内部的调度循环依赖本机时间如果服务器时钟被 NTP 调慢或者快了几分钟都会导致触发事件不产生。可以对比任务配置的时间和服务端系统时间误差超过 10 秒就会出现这种问题。第二步查看是否有同类任务卡住了调度线程。ax 调度里每个任务都分配了一个小的调度 tick如果全局调度线程被一个耗时的数据库查询阻塞会影响所有任务。我建议直接把日志级别调整为 debug观察 scheduler tick 多久执行一次。第三步检查数据库里是否堆积了重复的触发事件。在某些高频率任务场景下比如每 5 秒一次如果某次执行耗时超过了 5 秒就会产生大量堆积事件。ax 调度默认会丢弃重叠触发但如果堆积太多后面的事件可能都被丢弃导致任务一直不跑。这种情况说明你该考虑换异步队列作为事件缓冲了而不是把一堆事件硬塞给调度器轮询。5.2 重复执行与幂等控制重复执行是调度系统最严重的问题之一。ax 调度虽然依赖数据库唯一索引避免同一个实例重复触发但有一种情况仍然会发生worker 执行脚本到一半网络闪断调度器没收到回调只收到心跳超时于是把任务标记为 lost然后重新调度到另一个 worker。可是原来的 worker 上的进程可能并没有被杀掉它还在继续跑于是同一个任务数据被写了两次。怎么解决ax 调度的建议是严格使用幂等写入。比如数据同步任务在 SQL 里加分区字段用INSERT OVERWRITE推送任务在推送前先查一下公告接口是否已有这一批内容文件生成任务重跑时用实例 ID 作为文件名的一部分。总之调度的正确性要靠业务侧幂等来兜底调度器只能保证触发次数无法保证业务操作本身的可重复性。另外自动重试也要注意有些任务只有在失败后重试才有意义比如网络超时重跑一次可能就好了。但有些任务根本不能重试比如“发送短信”这种一旦短信发出了再重试就会给用户发两条。ax 调度提供了一个强制开关叫disableRetryIfSucceeded意味着如果上次失败时业务可能已经完成一部分那么自动重试会被禁止只能人工介入确认。5.3 时钟漂移与调度偏移问题调度系统依赖时间而分布式环境下机器的时间不一定是绝对一致的。就算你配了 NTP也可能有几秒到几十毫秒的误差。ax 调度的跨 worker 时间同步是建立在工作节点上报时间戳的基础上的如果 worker 的本地时间比调度器快了很多就会导致日志时间看起来出现了“未来时间”给排查造成困扰。解决办法是在所有部署了 ax 调度的服务器上统一配置 NTP 服务并且在 worker 启动时与调度器做一次时间源同步校准。ax 调度的 server 端可以开启一个timeSync接口worker 启动时自动拉一次服务端时间计算本地偏移量并写入日志。这样你在查看任务开始结束时间时看到的都是基于调度器时间轴归一化后的时间而不是设备本地时间。5.4 避坑速查表常见坑表现排查/解决cron 写成 5 字段任务触发频率疯狂使用 6 字段 Quartz cron秒放首位worker 并发数设太高机器负载高任务超时从 2 开始慢慢调高脚本退出码永远是 0假成功数据不完整校验产物并显式exit(1)上游失败了下游还在跑数据链路中断检查 upstream 配置和 failover 开关服务端重启后任务丢失重启前未完成的实例丢失开启runtimeRecovery机制时间戳不一致日志顺序混乱配置 NTP 并启用 timeSync任务实例堆积任务一直等待检查触发频率是否超过执行耗时这里最后一条要详细说一下。ax 调度有一个特性叫漏跑补偿默认只补最近一次未执行的调度周期不会把所有堆积的 missed 周期都补上。所以如果任务因为机房断电停了一个小时而且 cron 是每分钟一次重启后 ax 调度也只会补一次不会把 60 次全补回来。这个设计是合理的因为大多数业务脚本都希望只处理最近一次数据而不是把之前的旧数据重复处理一遍。如果你确实需要追跑过去 60 分钟的数据可以写一个循环任务来处理。说白了ax 调度不是一个框它更像一套思维方式。真正让它发挥价值的是你对任务的拆解、对依赖的梳理、对资源的规划。我们用 ax 调度半年多最明显的感受是再也没有人半夜爬起来去检查 cron 了。它的核心能力是把不确定性比较大的脚本调度变成了一条有条理、可追踪、能回放的状态机这对任何规模稍微大一点的团队来说都是省心省力的重要一步。我在实际使用中还有一个体会就是调度的核心不是代码写得多漂亮而是任务边界的划分是否清晰。一个任务最好只负责一个动作拆得越细重试和编排的灵活性就越高。如果你现在还在用最原始的 cron 硬维护几十个定时脚本我真心建议花点时间试试 ax 调度这套思路。