ax分布式任务调度系统:架构设计与工程实践解析
先交代背景。ax 不是我们内部第一个调度器但它是第一个让我觉得“调度这件事终于可以被守住”的版本。在这之前团队里的定时任务散落在各个服务的 cron 配置里谁改了谁清了谁埋了雷全靠口口相传。直到某天核心报表连续两天没跑了排查出来发现是某个节点时钟漂移那件事之后我们下定决心把分布式的定时任务统一收拢到一个平台里来做代号就叫 ax。这个标题看起来就两个字母但背后的东西一点也不小。说白了ax 是一套面向分布式环境的任务调度系统解决的是“什么任务、在什么时间、跑到哪台机器上、执行成没成功、失败怎么处理”这一连串问题。无论你是后端开发、架构师还是负责系统稳定性的运维只要你的系统不止一个实例cron 已经开始让你觉得难受了那这篇内容就值得你花时间看看。我尽量把设计和踩坑都拿出来讲不会只丢一堆名词。1. 项目场景与需求拆解为什么不能继续用 Cron1.1 单机 Cron 的“三步失控”很多团队最开始都是用系统 cron 跑任务。一台机器上写几行 crontab简单直接确实能用。但系统一旦开始多实例部署cron 的隐患会从三个方向同时冒出来。第一个是重复执行。业务服务一扩容代码同一份cron 配置同一份于是每个实例都会到点执行一次。如果任务是订单对账、发优惠券、同步库存这类不具备幂等性的操作一次重复可能就是线上事故。你可能会说“加个分布式锁不就行了”但分布式锁怎么放、锁异常怎么处理、锁过期了任务还在跑怎么办这些问题比 cron 本身复杂得多。第二个是全局不可观测。所有任务散落在不同服务器的 /etc/crontab 里跑没跑、跑了多久、成功还是失败没有统一视图。很多团队排查任务问题的方式就是登上去看日志文件难受不说效率极低。第三个是资源不可控。cron 任务直接在业务进程里执行一个 CPU 密集型的统计任务可能把正在处理用户请求的线程拖垮互相干扰。任务本身也可能因为依赖外部系统超时把进程的连接池占满。ax 一开始就是冲着这三个痛点去的。我们要的是一套独立的、中心化的调度系统把任务的定义、触发、执行、反馈全部收拢让业务服务不再背负 cron 的包袱。1.2 ax 需要覆盖的核心动作把需求拆开看调度系统本质上是在回答四个问题。什么任务任务有唯一标识、类型、所属业务方、超时时间、重试策略。什么时候跑支持 cron 表达式也支持固定延迟、一次性任务后续最好还能支持上下游依赖触发。跑到哪任务通过注册的路由规则分发到合适的 Worker 节点像“节点标签匹配”“指定节点”“分片广播”都是常见路由策略。跑得怎么样执行结果、耗时、日志、异常信息回传给调度中心调度中心输出可查询的视图并触发告警。这四个问题看着简单落到代码里就是一张完整的调度状态机。稍后我会细讲。1.3 谁应该参考这套设计如果你是每天被任务重复执行折腾的 Java/Go 后端工程师或者正在做系统稳定性建设的架构师又或者你负责的 SaaS 产品需要面对“每个租户都有自己定时规则”这种复杂场景ax 这套设计都能拿来当参考。我们不一定要完全复刻它的架构但里面的触发机制、状态流转、幂等设计思路是可以直接抄到你项目里的。2. 整体架构与技术选型中心化调度与去中心化的博弈2.1 两种主流调度模型怎么选做任务调度先要确定调度模型。行业里主流有两类中心化调度和去中心化调度。对比项中心化调度去中心化调度调度决策者独立 Scheduler 节点各 Worker 通过共识自行协调代表性开源项目Quartz、XXL-JOB、Airflow各类基于 Raft/一致性哈希的自研方案优点逻辑集中易于管控、审计、观测无单点水平扩展能力强缺点Scheduler 需要保证高可用实现复杂调试成本高任务可观测性弱适合场景任务数量中等、稳定性要求高、团队维护能力有限超大规模集群、任务量和节点数极高ax 最终选了中心化调度。为什么因为对我们来说可观测性和可控性远比绝对的扩展性重要。中心化调度意味着所有任务状态都经过调度中心谁触发的、下发到哪、有没有成功全都留有痕迹。排查问题时你不用在多台机器之间猜来猜去。当然中心化不能做成单点否则整个调度系统挂掉所有任务都停摆。ax 的调度中心采用主从方式部署主节点负责真正的调度决策从节点实时热备通过协调组件自动完成故障切换。整个切换过程对业务是无感的Worker 一旦发现与当前主节点心跳异常会自动重新上报并领取新的任务指令。2.2 ax 的分层架构长什么样ax 内部拆成四个角色职责边界非常清楚。ax-server调度中心接收任务注册、解析触发规则、生成调度计划、下发执行指令、收集执行结果。所有任务的“大脑”都在这层。ax-worker执行节点部署在业务服务旁边负责实际执行任务并向调度中心上报心跳和执行状态。ax-console控制台提供 Web 管理界面任务的增删改查、手动触发、日志查询、告警配置都在这里完成。ax-storage存储层保存任务定义、调度记录、执行日志、告警规则等数据是整个系统可回溯的底座。这里特别说一下 server 和 worker 的通信。我们没有走重量级的消息队列而是用轻量级 RPC 长连接。worker 主动向 server 发起长连接并注册自身信息server 在连接上直接下发指令。这种设计的优势在于worker 可以实时感知 server 存活状态连接断开就知道要重新找主节点注册省去了独立注册中心的额外依赖。相比通过消息队列转发指令长连接的延迟更低避免“任务已经触发但消息还在队列里排队”的问题。2.3 存储与时间轮选型背后的细节任务定义和执行记录需要持久化我们用的是 MySQL 加 Redis 的组合各司其职。MySQL 存储任务定义、调度历史、操作审计这类强一致要求的数据表结构按照“任务主表 调度记录明细表 节点注册表”来设计。Redis 则承担两块高吞吐的场景一是分布式锁二是待触发的延迟任务队列。说到延迟任务这是调度系统里的核心难点。系统里每秒可能有成千上万个待触发的任务你不能让数据库去扫所有任务来挑到点的那会把数据库拖垮。ax 的做法是server 启动时预加载未来一段时间内要到点的任务放入内存中的时间轮每个任务对应一个到期时间时间轮每秒推进一次把到期的任务取出来投递到执行队列。这样无论任务总量多大真正在“计时”的任务数量只取决于未来一段时间的密度数据库压力被有效化解。选 Redis 做分布式锁也是基于同样的思路。任务需要在多台 server 之间保证“同一时刻只有一个调度器在触发某个任务”这时候用 Redis 的 SETNX 命令抢锁配合过期时间防止锁异常。虽然 Redis 锁有极端情况下的安全隐患但在调度场景里结合 DB 唯一约束兜底已经足够可靠。3. 核心机制拆解一次任务调度的完整生命周期3.1 从任务注册到执行完成的状态流转在 ax 里一个任务从生到死会经历这样一组状态新建、启用、调度中、执行中、成功、失败、过期、停用。我在实际设计时深受状态机的思维影响。一开始的版本里状态字段只是一个简单的枚举记录当前状态但排查问题时发现“这任务到底为什么失败”完全看不出来中间经历了哪几步一无所知。后来重构为显式状态机不再只是存一个终点状态而是把每次流转记录成一条带时间戳和动作的 trace。举个例子任务到点被 Scheduler 触发状态从“调度中”改为“执行中”同时生成一条带 taskId、scheduleTime、targetWorker 的一条投递记录Worker 收到后开始执行执行完成回传结果状态再改为“成功”或“失败”。如果 Worker 一直没有回传结果任务会进入“执行中超时待查”状态由 server 主动发起重试或标记失败。这个状态机的价值在后续排障中体现得淋漓尽致。每当有人问“为什么 02:00 的任务没跑”我只需要拉到调度记录明细就能看到任务是否被触发、下发给了哪个 worker、worker 是否确认、确认后执行结果是什么。整个链路是完整闭环的。3.2 触发方式的三种类型与实现要点ax 的任务触发有三种形态。第一种是 cron 触发最常用但不简单。cron 表达式解析成下一次触发时间后放入时间轮等待。注意服务重启时预加载时间范围必须覆盖到最远的下一次触发点否则任务会丢。第二种是延迟触发常见用法是“订单创建后 30 分钟未支付就自动取消”。这类任务特点是要精确到秒级而且量大。ax 的实现思路是先把未来一段时间的延迟任务预加载进时间轮到点后投递执行。需要特别关注的是延迟任务必须支持持久化恢复否则一旦 server 宕机所有正在计时的任务全部丢失。我们的恢复策略是server 重启后先从 DB 读取“未执行且到期时间在启动前三分钟以内”的任务重新入轮宁可重复一次也不能漏。第三种是依赖触发就是 A 任务执行完满足条件才触发 B 任务。依赖触发最难的是环检测。A 依赖 BB 依赖 CC 又依赖 A这种环形依赖如果不处理调度中心会陷入死循环。ax 的解决方案是在 DAG 里做拓扑校验注册依赖关系时先行检测有环直接拒绝这比执行时再发现要省很多事。3.3 失败重试、超时控制与幂等三件套最早做调度系统时我以为失败重试只是“重跑一次”那么简单后来被真实场景教育了。失败重试要回答三个问题重试几次、间隔多久、重试期间业务方是否需要感知。ax 的重试策略是第一次失败后立即重试一次第二次失败后过 10 秒重试再失败就过 60 秒重试超过 3 次后不再自动重试任务进入“失败待人工”状态通过控制台和告警通道通知负责人。重试间隔采用退避策略避免任务失败的根本原因还没恢复就大量重试把下游系统打挂。超时控制同样重要。任务的超时时间在注册任务时指定粒度到秒。Worker 端执行任务时会有一个守护协程计时超过超时时间会主动中断执行并回传“超时失败”。这里有一个早期的教训如果不做超时中断任务会一直卡在“执行中”状态而 Worker 以为还在跑Server 又没法重新调度任务就悬死了。所以超时中断不是可选项是必须项。再说幂等这是任务调度里最容易被忽视、又最容易出事故的点。ax 会在每个任务实例创建时生成唯一的 executionId并在执行前写入一张“执行记录表”带唯一键。Worker 执行任务前先尝试插入 executionId插入成功说明这是首次执行继续跑插入失败说明这个 executionId 已经被处理过直接返回成功。这个设计保证了无论网络重试、server 重放还是 worker 重启任务的同一个 executionId 只被真正执行一次。业务侧如果本身有幂等机制两者叠加更安全。3.4 高可用与分片如何不把鸡蛋放一个篮子里ax-server 的高可用用了主从模式主节点负责调度从节点负责热备。故障切换后新的主节点做的第一件事是从数据库中恢复所有未完成任务重新加载到时间轮。恢复期间任务触发延迟在所难免但不能丢这是我们的底线。Worker 端的高可用则是通过标签和路由实现。每个 worker 在启动时配置标签比如 regioncn、roleworker、categoryreport。任务可以指定“必须运行在哪些标签的节点上”这样即使某些节点不可用只要集群里还有匹配标签的节点任务就能正常执行。如果任务的数据量非常大比如拉取全量用户数据、批量重算“近 30 天所有门店的报表”单台 worker 执行可能几小时都跑不完这时候就要用到分片。ax 支持把一个大任务按参数拆成多个子任务任务注册时声明分片策略执行时调度中心根据可用 worker 数量和分片规则生成 N 个子任务分别投递到不同 worker。每个子任务独立记录、独立统计、独立重试全部执行完才认为父任务成功。4. 实操落地从零部署 ax 到跑通第一个任务4.1 最小化部署Docker Compose 同时拉起三个组件ax 部署起来并不复杂最小的可用集群只需要三部分一个 MySQL、一个 Redis、至少一台 server。我习惯用 docker-compose 在开发环境快速验证。下面是开发环境用的精简配置。version: 3.8 services: mysql: image: mysql:8.0 environment: MYSQL_ROOT_PASSWORD: ax_password MYSQL_DATABASE: ax ports: - 3306:3306 volumes: - ./sql/init.sql:/docker-entrypoint-initdb.d/init.sql redis: image: redis:7.0 ports: - 6379:6379 ax-server: image: ax/ax-server:2.4.0 environment: AX_DB_HOST: mysql AX_DB_PORT: 3306 AX_DB_USER: root AX_DB_PASSWORD: ax_password AX_REDIS_HOST: redis AX_REDIS_PORT: 6379 AX_SERVER_HTTP_PORT: 8080 AX_NODE_ID: server-1 ports: - 8080:8080 depends_on: - mysql - redis ax-worker: image: ax/ax-worker:2.4.0 environment: AX_SERVER_ADDR: ax-server:8080 AX_WORKER_TAGS: roleworker,regiondev depends_on: - ax-server部署时我踩过一个很实在的坑worker 的启动顺序必须在 server 之后。如果 worker 先启动它寻找 server 注册时会失败如果没有自动重连机制任务就一直跑不起来。生产环境建议用弹性编排的探针等 server 健康检查通过后才拉起 worker。4.2 注册一个真正可跑的任务控制台配置与 API 调用部署完成后打开 ax-console 的管理页面选择“新建任务”你会看到几组关键项任务名称与编码编码是全局唯一的比如report.daily_sales。所属应用标识这个任务归属哪个业务线用于后续权限隔离。调度类型cron / 延迟 / 依赖。cron 表达式比如0 30 2 * * ?表示每天凌晨 2 点 30 分执行。路由策略指定执行节点的标签条件。超时时间建议至少要比任务预估耗时长 20% 到 50%。重试设置最大重试次数和退避间隔。控制台操作是对人的对程序则通常走 OpenAPI。我们的业务侧注册任务大多通过 HTTP API 完成下面是简化版的注册请求示例。curl -X POST http://ax-server:8080/openapi/task/register \ -H Content-Type: application/json \ -d { taskCode: report.daily_sales, taskName: 每日销售汇总, cronExpr: 0 30 2 * * ?, routeTag: roleworker,regiondev, timeoutSeconds: 600, retryTimes: 3, callbackUrl: http://my-service/api/tasks/report-sales/callback, alarmGroups: [oncall-report] }注意看这里的 callbackUrl这是 ax 的关键约定。调度中心不直接运行业务代码它只负责“按点通知”。触达方式就是向业务服务注册好的 callbackUrl 发起 HTTP 请求业务方收到请求后真正执行任务再把执行结果写给 ax。这种“调度与执行解耦”的设计让 ax 可以调度任意语言编写的任务只要对方能提供 HTTP 接口。4.3 核心配置参数速查照着填就不会出大错很多人在配置阶段会犹豫参数怎么给我把 ax 里影响最大的几个参数和推荐经验值整理成一张表方便对号入座。参数名作用推荐配置思路任务超时时间限制单次执行时长预估耗时的 1.2~1.5 倍低于 30 秒的任务直接给 60 秒重试次数自动重试上限建议 2~3 次超过后人工介入避免对下游造成冲击重试退避间隔两次重试之间间隔按 10s、60s、300s 递增退避不建议固定间隔调度提前加载窗口服务启动时预取任务的范围新手建议 1 分钟任务量大再逐步收紧Worker 心跳间隔节点存活探活周期5 秒是合理值太短会造成 server 压力太长会放大故障感知延迟执行日志保留天数日志与执行记录的过期清理报警类任务保留 30 天重报表类保留 180 天这里分享一个实际总结出的经验任务超时时间的设置宁宽勿窄。我们曾经有一个任务配置了 120 秒超时某天依赖的数据库出现慢查询正常 100 秒的任务跑了 3 分钟被超时中断后重试两次全都因为同一个底层原因失败最后人为介入才处理完。如果当时给到 240 秒第一次执行就能成功反而是最省事的。4.4 监控与告警调度系统的“体检报告”一套调度系统如果没有可观测性就等同于没有。ax 在三个层面输出监控数据。第一个层面是任务层面。每次任务执行产生完整记录包括调度时间、开始执行时间、结束时间、执行耗时、返回状态、失败原因。控制台提供按任务编码、按时间范围检索的能力也能看到某个任务最近 30 天的成功率趋势。第二个层面是系统层面。server 的调度队列长度、时间轮当前任务量、worker 在线数量、心跳延迟这些都要通过监控系统采集并画成曲线。我自己的经验是调度队列长度是最需要盯的指标。一旦这个数字持续增长说明生成任务的速度超过了投递速度系统正在走向积压。第三个层面是告警。ax 内置了 Webhook 告警通道任务失败、超时次数过多、worker 失联、server 主从切换都会触发通知。告警要分级不能所有告警都往同一个群里扔。我的实践是失败重试一次发普通通知重试全失败发高优告警主从切换这类系统级事件单独渠道立即通知值班人。5. 经验复盘常见问题与排查技巧实录5.1 任务重复执行80% 出在网络重试和恢复逻辑任务调度系统最让人头疼的问题永远是重复执行。遇到的重复大多可以归到两类。第一类是回调结果丢包。Worker 执行成功以后回传回调结果到 server 时网络超时了server 判定任务执行超时重新下发。这时候任务被重复执行其实是“系统本来就会这样设计”的——因为能否成功无法确认所以至少要再试一次。解药的逻辑是我前面写到的 executionId 幂等机制业务方必须基于 executionId 做去重。第二类是恢复逻辑过于激进。Server 从一个 Worker 失联切换到另一个 Worker旧 Worker 可能只是网络分区任务还在跑。等网络恢复旧 Worker 把执行结果成功回传时新 Worker 也执行完了。这时候你会在执行明细里看到同一个调度计划下有两个执行实例。我的排查习惯很固定先看执行明细找到同一个 taskCode 同一个计划时间下出现了几条执行记录。记录只有一条但业务重复了就是业务侧自身幂等失效记录有两条就是调度侧的分区、重投问题。5.2 调度延迟越来越大从时间轮到数据库两头查现实中经常出现任务到点了但执行时间比预想晚了十几秒甚至几分钟。这类问题排查有两个重点。一是时间轮的推进是否正常。时间轮的 tick 是幂等推进的但如果调节的粒度太粗每秒只检查一次那任务能感知到的最大延迟就是 1 秒这是正常的。如果延迟远大于 1 秒就要看 server 的 CPU 和 GC 情况。我曾经遇到一次任务延迟从 1 秒飙到 30 秒排查下来是 server 节点内存不足Full GC 频繁时间轮线程被阻塞。解决方法是加大启动参数里的堆内存并限制单节点的任务总量。二是数据库连接池耗尽。每个任务执行完都会异步写执行记录如果写库速度跟不上任务执行完的速度线程池就会堆积最终连接池耗尽导致调度线程拿不到数据库连接后续任务全部卡住。这个问题在“任务量大 日志多”时特别典型。解法有两个方向写库操作改成异步批量化攒一批再统一提交或者把执行记录与任务主表拆到不同库隔离影响。5.3 任务堆积盲目调大并发等于自杀式救援某个凌晨大促重跑任务几百个子任务同时被触发结果下游数据库被压垮页面直接超时。当时第一反应是“提高 worker 并发数加快执行速度”结果越调越糟。后来想明白了任务堆积问题的核心从来不是“跑得更快”而是“放得更慢”。正确做法是限流排队。ax 的做法是给每个下游依赖方设置独立的执行速率限制比如“同一时间内该下游最多被 10 个任务同时调用”。超过限制的任务先放回延迟队列等待窗口释放后再次尝试。这等于在调度系统里给下游做了一层流量保护。初期看任务完成时间长了一些但下游系统稳定整体没有出现雪崩最后的完成时间反而提前了。5.4 常见问题速查表现象可能原因排查动作任务到点未触发cron 表达式解析异常或时间轮任务被重启加载遗漏查看调度记录是否生成检查 server 启动日志中的任务加载行任务触发但没执行Worker 失联路由标签匹配不到节点检查 worker 在线列表确认任务路由标签是否有对应节点任务一直卡在执行中Worker 执行超时但中断机制失效查看 worker 端任务日志定位耗时节点检查超时中断配置任务频繁失败重试下游依赖不稳定或回调接口本身异常拿到失败任务的日志 trace先看回调阶段再查执行阶段server 切换后任务延迟明显新主恢复任务需要时间加载窗口设置偏小检查主从切换日志评估任务加载耗时必要时增加加载线程池调度队列持续增长下游处理慢限流策略失效降低 worker 并发检查下游依赖的响应耗时曲线6. 再往前走一步容量规划与工作流扩展6.1 容量估算一个公式搞定调度规模如果要评估一套 ax 集群能扛多大业务量我会用一个简单的公式单节点可支撑的调度量 ≈ 每秒时间轮推进次数 × 每次推进可投递任务数 × 可用服务器的数量。纵使公式看起来简单真正要算的是另两个间接指标每个任务的执行平均耗时和下游能接受的排队时间。比如线上每分钟产生 1000 个延迟任务每个任务平均执行 2 秒意味着至少需要1000 × 2 2000秒·并发的执行容量也就是将近 34 个并发 worker 槽位。如果只有 10 个 worker每个 worker 并行 2 个任务总共 20 个并发任务必然积压到下一分钟。做容量规划时先按这个思路粗算再留出 30% 的富余基本就不会在峰值时出大岔子。6.2 从“单任务定时跑”到“DAG 工作流”定时任务只是调度系统的底座业务一旦复杂起来很快就需要工作流编排。比如每日报表流程“先同步数据再清洗处理然后任务汇总最后推送”四步之间有严格的先后关系。ax 的 DAG 模型把每个任务定义成节点任务之间用上下游依赖连接。调度器先触发无依赖或依赖已满足的任务节点完成后更新下游节点状态直到整条链路全部执行完。复杂工作流里最常见的是中间节点失败这时可以配“失败重试”“跳过继续”或“中断整条链路”在配置阶段就要设计清楚。做 DAG 之后我体会到一句话特别对定时任务系统拼的是稳定工作流拼的是设计。很多团队上手就是复杂 DAG结果上游一个小任务抖动整个链路后面全乱了排查起来极其痛苦。我的建议是先跑通单任务再把最基础的链式依赖做扎实最后再引入分支和并行循序渐进。6.3 几个越早想清楚越好的设计抉择最后补几点当时做 ax 时没有早些想明白、后来才补课的设计。第一条是任务编码的全局唯一性。如果只要一个团队用随便起名无所谓一旦多个业务线接入重复编码会让告警和日志完全乱套。第二条是执行结果协议必须版本化。我们早期回调接口的 JSON 字段随意增删导致新版本 server 调度旧的业务接口时解析失败后来定义成带 version 字段的协议双方各自兼容。第三条是权限隔离。ax 里不同业务线只能看到自己的任务增删改都要走独立的应用权限校验这点接入方一多价值就特别明显。说实话调度系统这种基建项目做出来不难做好却靠大量细节。像时间轮线程被卡的坑、重试退避导致下游雪崩的坑、恢复逻辑放大重复执行的坑都是在真实生产环境里被逼着修炼出来的。如果这篇文章能让你的调度系统少走我当初的弯路那就值了。