最近在基础架构圈子里,大家开始频繁提起“ax调度”这四个字。如果你还没接触过,我简单交代一下背景:ax是我大半年一直在维护的一个轻量级调度内核的代号,取自Adaptive eXecution的缩写。市面上调度框架并不少,但真把业务放进去跑一遍就会明白,现成的轮子看着省事,落到具体场景里往往不是过重就是不够灵活。这篇文章会把ax从设计动机、内核模型、分布式一致性到落地案例完整拆开来讲,适合正在做任务调度选型、或者想自研一个调度器的后端同学参考。
我自己是从业务研发转过来做基础组件的,所以写的东西一般都比较“土”,不绕弯子。你跟着读下来,至少能知道一个调度系统真正难在哪里,以及遇到问题时该从哪个切入点排查。
1. 为什么放着现成的调度框架不用,非要自己写 ax
先说结论:并不是现成框架不好,而是大部分团队的调度需求并没有复杂到需要一套完整平台的程度。我们最初评估过 Quartz、XXL-Job、Temporal 这几类方案,每个都有各自的隐性成本,最后才决定自己写一个内嵌式的调度内核。
1.1 四类现成方案的隐性成本
Quartz 最大的问题是集群模式靠数据库锁竞争来协调节点,调度量一上来,数据库压力和分布式锁等待就开始变成瓶颈。你在单机上跑 Quartz 确实很舒服,但一旦要做高可用、多节点抢跑,它那一套 RAMJobStore 和 JDBCJobStore 的切换逻辑会把你折腾得够呛。
XXL-Job 的定位是完整的分布式调度平台,调度中心、执行器、控制台一应俱全。功能确实全,但部署和运维成本也高:调度中心要单独维护,执行器要接入它的 SDK 并注册心跳,整个链路被固定成“中心派发到执行器”的模型。对我们几十个微服务来说,为了几个延迟任务和周期任务就要多维护一套中心化服务的生命周期,性价比太低。
Temporal 就更重了。它是完整的工作流引擎,支持补偿、人审、子流程编排,学习曲线非常陡。如果团队只有基础 CRUD 的背景,投入产出比非常不划算。我们当时需要的不是“工作流”,只是“到点了把事情跑起来”而已。
1.2 我们真正想要的:一颗可嵌入的调度内核
帮团队做技术选型时,我会先列一个“不要什么”清单:不要独立的调度中心、不要强制改业务工程的启动方式、不要复杂的 SDK 依赖。于是ax的核心定位就很清晰了——它是一颗能直接塞进业务进程里的调度内核。你可以在任何 Go 服务里ax.New()创建一个调度器实例,然后注册 handler、投递延迟任务,所有状态持久化在你自己指定的数据库里。
这带来两个直接好处:一是部署模型非常简单,没有多余的组件要维护;二是扩展路径灵活,单个实例跑不下了,可以再加节点做选主派发,而不用推翻重来。
2. ax 的内核:时间轮与一张持久化任务表的配合
调度器最核心的能力就一句话:任务到点了能被触发。听起来简单,真实现起来会发现“高效地知道任务到点”这件事并不容易。
2.1 为什么选时间轮而不是轮询 Cron 表达式
最朴素的实现方式是定期扫表,把所有状态为待执行且next_run_at <= now的任务捞出来。任务量小的时候这完全没问题,但任务量上到十万级之后,每次全表扫描的代价就很大。即便加索引,高频扫描对数据库的压力还是让人心疼。
所以内核里我用了分层时间轮。你可以把它理解成钟表的时分针:一个 60 格的秒轮加一个 60 格的分轮,任务往对应的格子放,指针每走一秒就摘掉当前格子里的到期任务,复杂度是 O(1)。拿“每分钟执行一次”来举例,任务会被放入分轮的某个槽位,等到分针走到那个位置时触发,不需要每一秒都去扫描整个任务表。
时间轮只负责高效地发现“哪些任务到点了”,它不自己存业务任务详情。真正的任务主记录在数据库里,时间轮里放的是任务 ID 和内存中的到期时间。这是一个非常关键的权衡——纯内存方案丢了状态,纯数据库方案扛不住高频,两者配合才是工程上比较合理的解法。
2.2 内存时间轮 + 数据库任务表的混合模型
具体启动流程是这样:调度器启动后,先恢复数据库里所有处于待执行状态且还没有跑完的任务,把未来一段窗口内的任务灌入时间轮。平时的时间轮靠 leader 节点的调度循环从数据库批量拉取“未来 5 分钟内到点”的任务补充进去。这样每次数据库查询都是带索引的局部查询,而不是无差别全表扫描。
数据库这张ax_tasks表是状态的权威来源,时间轮只是加速发现的本地缓存。即使进程重启、时间轮全部丢失,重启后也能从数据库恢复。这里要特别强调一下时间精度设置。时间轮 tick 我设置的是 1 秒,这足够覆盖大部分业务场景。如果你的场景需要毫秒级延迟触发,可以把 tick 改成 100ms,但要注意内存开销和数据库拉取频率会同步上涨。
2.3 核心调度循环的简化示意
调度循环用 Go 写大概就是这样一个结构:
func (a *Ax) scheduleLoop(ctx context.Context) { timer := time.NewTicker(a.cfg.Tick) defer timer.Stop() for { select { case <-ctx.Done(): return case <-timer.C: tasks := a.wheel.Expire(time.Now()) for _, t := range tasks { a.Dispatch(ctx, t) } a.refill(ctx) // 从DB补充未来窗口内的任务到时间轮 } } }Expire取出当前刻度上的任务 ID 列表,Dispatch负责走状态机推进和派发。补轮的动作放在同一个循环里,是为了避免多线程对时间轮内部状态产生并发竞争。实际开发中refill需要控制每次拉取的量,避免一次性灌入太多任务导致内存抖动。
3. 任务状态机与派发链路:从“到点”到“回调成功”中间发生了什么
调度器最怕的事情是“模棱两可”——任务到底派发出去没有?执行器到底跑了没有?跑的结果是什么?如果这些问题没有明确答案,后面所有重试和补偿都是无根之木。所以ax从一开始就设计了一套严格的状态机。
3.1 状态机设计与迁移规则
一张表把这些状态说清楚:
| 状态 | 含义 | 谁写入 | 可迁移到 |
|---|---|---|---|
SCHEDULED | 已持久化,等待到时 | 投递方 / 调度器 | DISPATCHING |
DISPATCHING | 已到点,尝试派发中 | 调度器 | RUNNING/SCHEDULED |
RUNNING | 执行器确认执行中 | 执行器 | SUCCEEDED/FAILED |
SUCCEEDED | 成功结束 | 执行器 | 无 |
FAILED | 业务执行失败或重试耗尽 | 调度器 / 执行器 | SCHEDULED(重试) /FAILED(放弃) |
状态迁移最关键的一条铁律:状态的每一次变化都必须通过条件更新 SQL 实现,不能先读再写。比如从SCHEDULED迁移到DISPATCHING,SQL 条件是WHERE id = ? AND status = 'SCHEDULED',更新影响行数为 1 才说明当前节点抢到了这个任务的派发权。这种乐观锁式的做法天然避免了两个节点同时派发同一个任务。
3.2 一次完整派发的链路细节
一个周期任务“到点”后,完整的链路是这样的:时间轮到期摘出任务 ID,调度器先把它从SCHEDULEDCAS 成DISPATCHING,然后根据任务注册时选择的执行器地址发起调用。执行器收到请求后先落一条执行记录表,再把状态更新为RUNNING,业务逻辑跑完后再回调调度器把结果写回SUCCEEDED或FAILED。
这里有个很容易被忽略的点:执行器必须先落执行记录再执行业务逻辑。如果把“执行记录”放在业务逻辑之后,一旦业务逻辑崩溃或进程被杀,这条任务会进入无限重试循环,而且你根本查不到上一次执行到哪一步了。执行记录表里的execution_id是每次派发独立生成的 UUID,后面幂等全靠它。
3.3 失败重试这样设计,不把自己坑死
失败重试不能傻傻地立即重试,否则一个下游故障能把执行器打挂。重试策略沿用了指数退避:第一次失败等 5 秒,第二次等 25 秒,第三次等 125 秒,最多重试 5 次。每次重试都只是把任务状态 CAS 回SCHEDULED,并更新next_run_at,让它重新进入时间轮。
这里有个非常实用的经验:重试次数一定要和服务商给出的“最大重试”语义区分开。max_retry指业务重试次数,不包含触发阶段失败的重试。触发阶段失败(比如执行器地址不通)应该单独用dispatch_retry_count控制,两个计数字段分开维护,不然你会发现任务没过业务重试就已经被系统放弃了。
4. 多实例不会重复调度吗?租约、幂等与孤儿任务回收
ax支持多节点部署,节点之间通过选主决定谁来跑调度循环。但选主只是第一步,真正的复杂度在“选主失败”“锁过期”“执行器宕机”这些边缘场景里。
4.1 选主锁与租约续期
我们用了 Redis 做分布式选主,核心就是SET ax:leader <nodeId> NX PX 10000这把锁。拿到锁的节点成为 leader,负责从数据库拉任务、填时间轮、派发调度;其他节点进入待命状态,每 5 秒尝试抢一次锁。
锁不能设了就不管。Redis 锁最常见的坑是“过期时间到了但任务还没执行完”,另一个节点抢到锁,两个 leader 同时存在,任务被重复派发。所以我专门写了一个续约协程,每隔TTL/3的时间刷新一次锁的过期时间。简单说就是把租约续住,而不是寄希望于业务逻辑能在锁过期前跑完。
4.2 fencing token 才是防脑裂的终极手段
光靠续约仍然不够踏实。极端情况下,leader 节点发生长 GC 或网络分区,锁过期释放了,新 leader 上位,但老 leader 又缓过来继续派发任务,两个节点同时操作任务表。这时候唯一可靠的防线就是 fencing token。
每次抢锁成功,锁值里的 token 会自增。leader 在派发请求时把这个 token 放进 header 里,执行器收到请求后检查 token,如果发现小于自己本地记录过的最大 token,直接拒绝执行。这个机制保证哪怕老 leader 还在“自嗨”,它发出的一切派发请求都会被执行器挡掉,双主造成重复执行的窗口被压到最小。
4.3 幂等和孤儿任务回收,一个都不能少
即便做到上面的防护,网络超时、进程崩溃这些情况还是会带来重复投递。所以每个任务派发时都会带execution_id,执行器端用它对结果表做唯一索引。重复请求进来时,查询到execution_id已经存在就直接返回旧结果,不会重复执行业务逻辑。
执行器宕机的场景更隐蔽。调用方已经发出请求,执行器进程却没了,任务停留在RUNNING状态永远无法终结。为此账户任务表里额外加了last_heartbeat字段,执行器执行期间每 10 秒更新一次。调度器巡检线程会扫描RUNNING超过 5 分钟且心跳停止的任务,把它们强制标记为FAILED并触发重试。代价是心跳的额外写入,但换来的是“死任务”可以被及时发现和处理。
5. 两个落地案例:订单超时关闭和定时数据对账
讲了这么多原理,落到具体业务里才更有感觉。我挑两个已经在线上稳定跑的场景展开。
5.1 订单超时未支付自动关闭
这个场景是典型的延迟任务。用户下单创建的订单,30 分钟内未支付就要自动关闭。传统做法是定时批量扫订单表然后挨个判断,既浪费数据库资源,又没办法做到精确的 30 分钟级触发。ax的延迟投递只需要一行调用:
ax.Delay("order.close.timeout", orderID, 30*time.Minute, orderCloseHandler)订单创建时把任务投递出去,任务记录通过orderID作为业务唯一键。orderCloseHandler在处理前先查一次订单状态,如果已经支付就直接返回nil,不再做任何关闭动作。这个“先查再改”的习惯非常重要,因为延迟任务一定存在迟到触发的情况,业务方必须自己处理“状态已经变化”的场景。
时间轮的精度能保证触发时间非常接近 30 分钟整点,实测 p99 误差在 200ms 以内,远好于过去那种每分钟扫一次表的批量方案。更重要的是,数据库负载明显降下来了,订单表不再需要频繁被全表扫。
5.2 分钟级数据对账任务
另一个场景是跨系统数据对账。每个两分钟跑一次,把订单库和账务系统的数据拉出来比对,不一致的记录要写告警表。对账任务执行时间可能超过 2 分钟,如果任务还在跑,下一次又触发了,就要做并发控制。
这里我用的是ax的互斥执行能力:同一个task_key在同一时间点最多只允许一个实例执行。实现上也是在状态机里做文章:调度前检查是否有其他执行中的同task_key任务,有就直接跳过本次触发。这种“跳过”策略对于周期任务来说比“排队”更合理,因为对账要看的是最新快照,旧的数据等对完了再跑一次就行了。
5.3 手动补偿接口不能少
调度系统哪怕再稳,总有需要人工介入的时刻。ax提供了一个手动触发接口,允许运维人员绕过时间约束,直接把某个任务推到DISPATCHING状态。它的核心用途不是日常操作,而是故障恢复后的补偿——比如数据修复脚本、漏掉的报表重跑。
这个接口的权限控制必须严格,不能让普通业务人员随便调。否则一个误操作就可能让一个任务瞬间执行成千上万次,这种事故在调度系统领域太常见了。
6. 上线三个月,我们踩过的三个坑
自研调度器的好处是可控,坏处是坑全靠自己踩。这三个问题每一个都让我们半夜爬起来过,分享出来帮你避雷。
6.1 任务“凭空消失”?时钟漂移在背后捣鬼
第一阶段测试时批跑任务一直正常,结果上线后出现一个诡异现象:某些任务比设定的执行时间提前了十几秒触发。排查了很久,最后发现是数据库服务器的时间比应用服务器快了十几秒。任务恢复流程读取的是数据库时间,而时间轮触发用的是应用本地时间,两边一旦有偏差,就会出现“未来时间任务被当成到期任务执行”的问题。
修复方案是把时间标准统一:所有调度决策一律以 leader 节点的单调时钟为准,数据库时间只作为持久化恢复的参考。恢复流程里加了一个偏差修正逻辑,节点启动时先从任务表中读取最新一条任务的updated_at,和本地时间做差值修正,后续所有到期判断都基于修正后的时间。从此这个坑再没踩过。
6.2 回调接口被长任务堵死,健康检查先挂了
执行器接口既要处理任务回调,又要响应注册中心的健康检查。任务量一大,长耗时任务占满了 HTTP 连接池,健康检查请求也在排队,执行器节点被注册中心误判为离线,流量被摘除。更麻烦的是摘除后的流量又转移到其他节点,引发连锁效应。
解决思路是给执行器接口分两类线程池:一类专门处理健康检查,永远不排队;另一类处理任务派发,允许一定程度的积压。同时给任务调用设置客户端超时,超过指定时间直接放弃,不要无限期占用连接。这是我在排查告警风暴时总结出来的,调度系统的健康检查和业务处理必须物理隔离,不能共享资源。
6.3 批量重试触发数据库行锁死锁
大促那段时间任务量激增,数据库开始频繁报死锁。看日志发现全部集中在工作批次 CAS 更新任务表状态时。原因是批量更新 SQL 里没有对任务 ID 排序,两个并发事务分别持有对方需要的行锁,形成环形等待。
这个问题的修复很简单:批量更新前先按task_id升序排列,所有节点按同样的顺序获取行锁。同时把单次批量更新的行数限制在 200 行以内,降低单个事务的持锁时间。从那以后,死锁日志基本绝迹。这个经验不是调度系统独有的,任何大规模状态更新的场景都适用。
7. 最后再分享两点实在的
如果你打算自研或者选型调度器,我最后的建议分两层。一是技术上的:把时间当作一等公民去建模,所有跟时间相关的字段必须区分语义,是到期时间、实际执行时间、心跳时间还是补偿时间,混在一起迟早出事。二是工程上的:调度故障往往不是单点问题,而是锁、状态机、心跳、幂等这些能力缺一环导致的,所以要重视可观测性,核心指标至少包括调度延迟、派发失败率、重试分布和任务执行耗时。
有个小技巧也一并说了吧。我们给每个任务都加了一个owner_service字段,记录投递方归属。这个字段平时没什么用,但每次任务异常需要拉群找人时,它直接告诉你该找哪个服务的人,少了很多沟通成本。
ax这套东西没有用什么高深算法,贵在把工程细节抠到位。希望你读完后对调度系统的设计取舍有自己的判断,也能避开那些反复出现的暗坑。