尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
ax调度:轻量级分布式定时任务引擎的设计与实践
项目代号定为ax的时候我一度觉得这名字随意得像随手敲的。后来有同事问起我解释为 Auto eXecution 的缩写——一个只负责自动触发、自动调度的小引擎。业务方把越来越多的定时任务、延时任务、批处理任务丢过来之后ax调度反而成了团队里出现频次最高的词比它正式的名字好记太多。这篇文章就是把 ax调度 从设计、实现到上线踩坑的过程完整拆一遍适合正在做内部调度系统、或者准备接手类似项目的同学参考。像很多内部系统一样ax 一开始并不是什么大工程。它只花了两周写出来本意是替代散落在各服务里的定时器。结果半年后它开始承担大促场景下上万级任务的编排每天处理几百万次触发。回头看它既不是最全能的分布式调度器也没有炫酷的可视化界面但它解决了一个很具体的问题在一个以业务交付为主的技术团队里用最小的维护成本把到点该做的事统一管起来。写这篇文章目的是把这类轻量调度系统里真正有效的部分讲清楚也希望你能少踩几个我已经踩过的坑。1. 一个叫 ax 的项目为什么值得单独写一篇文章1.1 散落在各处的定时器才是最初要解决的敌人催生 ax 的根本原因不是什么高深的技术选型而是定时器到处长。当时各个业务服务里都有调度逻辑有人用 goroutine 加 select 做超时控制有人用一个跑在常驻进程里的 cron 表达式定时执行还有人干脆在用户的请求路径里判断当前时间戳时间到了顺手执行一段逻辑。任务少的时候这种做法还行得通顶多是在代码仓库里多搜几个关键字。可一旦任务量起来问题就非常现实没人能说清楚当前一共有多少定时任务在跑每个任务上次什么时候执行的、失败了多少次、有没有重试、日志去了哪里。更难受的是某个服务发版重启后内存里的定时器全部归零业务方隔天来问为什么昨天凌晨的推送没发排查要大半天。所以 ax 要解决的第一件事不是调度效率更高而是让所有需要到点执行的东西有一个统一出口并且状态可查、失败可追。1.2 为什么没直接用现成的调度框架动手之前我把市面常见的方案过了一遍也说一下为什么没有直接拿来用。xxl-job调度能力和管理界面确实成熟但需要额外部署调度中心权限、报警体系也更偏向 Java 团队。我们的执行器主要是 Python 服务接入成本不算低。AirflowDAG 编排是一等公民适合数据管道场景但对短延时和秒级触发不太友好调度器本身偏重装一套最小集群也不轻松。APScheduler嵌入应用内很舒服但任务状态不持久化、分布式支持弱。多实例部署同一个服务时每个进程都会独立调度任务就会被重复触发。ax 的设计目标因此定得非常克制调度引擎和业务逻辑分离管理者只需要注册任务和触发器执行器挂到引擎上由引擎决定在什么时间点回调谁。它不做工作流审批不做数据血缘只做到点触发这一件事。明确边界之后自研的成本一下子就降下来了。1.3 先做一个五分钟级别的可行性验证当时我先写了一个最小 Demo只支持两个任务一个每分钟执行一次另一个延时 30 秒后执行。存储用 Redis触发用最简单的时间轮。这么做不是为了炫技而是为了快速验证两个问题一是这种模型能不能覆盖现有业务里的绝大多数场景二是运维起来麻不麻烦。跑通之后才决定正式立项。现在回头看这个先做最小路径的习惯很管用——如果一开始就想去设计一个完美的分布式调度器大概率两周内做不出来。2. AX调度的任务模型把触发和执行解耦是第一步2.1 任务、触发器、执行器三张表撑起整个模型ax 的数据模型没有走复杂的微服务拆分核心就三类对象任务、触发器、执行器。任务Task描述做什么包含任务名、执行器 ID、超时时间、重试策略、当前状态等字段。触发器Trigger描述什么时候做包含类型、类型相关的参数、是否启用等字段。执行器Executor描述由谁来做是暴露给调度器的回调地址或本地函数需要实现一个注册协议。这三层分离之后很多需求就变成了简单的数据操作。比如想每天凌晨跑一次数据统计只需要给同一个任务挂一个 cron 类型触发器想临时改成每小时跑一次改触发器即可任务本身不动。反过来同一个触发器也可以挂在多个任务上只要把任务列表塞进去就行。这种模型在代码里体现为三张表接口层面的成本很低但给后续扩展留下了非常大的空间。2.2 触发器的核心接口next_time 和 is_due为了让调度循环不关心具体触发器类型所有触发器都实现了同一个接口。用 Python 写大概像这样class BaseTrigger: def next_time(self, after: datetime) - datetime: 返回 after 之后的下一次触发时间。 raise NotImplementedError def is_due(self, now: datetime) - bool: 判断当前时刻是否应该触发。 next_run self.next_time(now) return next_run is not None and next_run nowcron 类型就解析 cron 表达式算出下一个时刻interval 类型就基于上次执行时间加固定间隔once 类型则直接比较预设时间点是不是已到。调度循环不再需要判断这个任务到底是日切、周切还是延时任务只需要统一调用next_time把算出的时间点放回调度索引里。这里有个细节值得注意next_time必须基于上次执行后的语义来算而不是每次都用当前时间重新对齐。否则一个周期任务如果执行耗时太长下一次触发会被当前时间带着漂移最终整个周期全部错乱。这也是很多用while True: sleep(interval)写定时任务的人会踩的坑。2.3 依赖编排任务之间不是越多越好而是要有先后很多调度场景不是孤立的任务之间有上下游关系。比如先同步数据再生成报表最后发推送。ax 里我用了一张依赖表和一张事件队列来实现。一个主任务执行完成后会把完成事件写入事件队列。依赖服务消费这个事件判断它下游任务的前置条件是否全部满足满足才把下游任务标记为可调度。如果前置任务失败默认策略是直接标记下游任务为 failed不盲目往下走。依赖图在注册阶段做过拓扑排序校验一旦发现有环直接拒绝创建避免出现两个任务互相等待导致死锁。这个模型很简单但没有引入重量级的 DAG 引擎。实际用下来普通团队 90% 的编排需求就是先 A 后 B或多个 A 完成后做 B这两种一张依赖表完全够用没必要把复杂度引进来。3. 调度器内部的时间轮与状态机决定了稳定性上限3.1 时间轮不是越精细越好tick 与 wheelSize 的取舍调度引擎内部我选用了时间轮HashedWheelTimer。它的原理很简单一个环形数组每个槽位挂一个待触发任务链表指针按照固定 tick 间隔向前移动落到哪个槽就把哪个槽的任务拿出来执行。用生活里的例子类比就像一个钟表秒针每走一格就把这一格抽屉里到期的便签全部拿出来处理。ax 的参数最终定为 tick1 秒、wheelSize3600也就是说时间轮可以覆盖未来一个小时内的任务。内存占用就是 3600 个槽位非常小。为什么不用 100ms 甚至更细的 tick因为 ax 面向的业务是推送、批处理、数据同步秒级延迟已经完全满足需求。tick 越细指针空转频率越高CPU 浪费越大。如果真有毫秒级触发需求那应该考虑消息中间件的延时队列而不是在调度器里死磕精度。对于延时超过一小时的任务时间轮本身存不下我在任务结构里加了一个圈数概念任务实际到期时间除以时间轮覆盖范围得到的商就是还需转几圈。指针每轮回到对应槽位时把圈数减一减到零才真正执行。这种处理方式避免了链表无限膨胀代价是极端场景下大延时任务的精度会随轮转略有偏移但对秒级业务来说可忽略。3.2 任务状态机pending、running、success、failed、retry状态机不需要花哨但状态转换规则必须严格。pending任务注册成功、等待触发这是所有任务的起点。running调度器把任务投递给执行器之后立即进入而不是等执行器反馈后再进入。success执行器回调上报成功。failed执行器返回失败或回调超时。retryfailed 后如果重试次数没超过上限就进入 retry重新计算下次触发时间。这里有一个容易被忽略的规则状态持久化必须在转换发生时同步落库而不是等整个任务流程结束时统一保存。如果只在任务结束后保存崩溃恢复时系统就分不清这个任务到底是还没触发还是已经触发正在运行。这两种情况的恢复策略完全不同。调度器恢复时只要那些还处于 pending 和 retry 状态的任务running 状态的任务交给执行器侧的幂等逻辑去兜底。3.3 崩溃恢复先选至少一次再用幂等兜底分布式调度绕不开语义选择。ax 默认采用 at least once至少一次也就是任务可能被重复触发但绝不能因为崩溃而丢失。配合执行器侧的幂等设计重复触发不会产生重复的副作用。具体恢复机制分两层。内存里时间轮负责快速判断现在该触发谁数据库里任务表负责记录所有任务的持久化状态。系统运行期间除了状态转换写库之外我还会每隔一段时间把时间轮里即将到期的任务快照写一次。崩溃重启后调度器扫描数据库中所有 pending 和 retry 任务重新计算各自的 next_time再放回时间轮。这个过程有一个默认策略很关键已经错过多次触发窗口的任务恢复后只补偿一次不追着补跑。因为一旦系统停机超过几个小时补跑所有错过的批次会让下游系统瞬间被打爆。把补偿一次作为默认值业务方如果有特殊需求再按任务单独放开。3.4 执行超时、线程池与调度漂移执行器回调很容易出现超时。ax 的默认回调超时是 30 秒超时后任务标记为 failed 并走重试策略。但真正要小心的不是单次超时而是线程池被打满。调度器向执行器发起请求时用的是有界线程池队列长度默认 5000。如果等待队列已经满了新任务直接拒绝并进入 retry而不是把队列做成无界队列否则内存会先炸。这个取舍非常重要宁可让任务重试也不能让调度器进程因为堆积而崩溃。另一个经验是调度漂移。比如一个每 5 分钟执行一次的任务某次执行花了 15 分钟等到结束时按固定间隔算法算出的下一个触发时间点已经过去了。ax 的默认行为是无论推迟了多久只补偿一次并立即触发然后回到正常节奏。如果不做这个限制一个慢任务会把后续所有触发时间全部向后挤整个调度序列彻底乱掉。4. 上线前我踩过的四个坑每个都差点导致线上事故4.1 第一个坑分布式锁的自动过期导致任务被重复执行最开始为了保证双节点不重复触发任务我直接用 Redis setnx 抢锁谁的锁过期时间更长谁就拥有触发权。测试环境一切正常直到一次线上压测时发现同一个任务在极短的时间内被触发了两次。排查过程很痛苦。日志显示两件事几乎同时发生节点 A 拿到锁但进程发生了一次接近锁过期时间的 GC 停顿节点 B 在锁自动过期后抢到了同一把锁于是两个节点同时执行触发逻辑。这个问题的本质是分布式锁的过期时间太静态了没法感知持有者的健康状态。修复方案分两层。第一层给锁加续期机制也就是租约续期——持有锁的节点每隔一段时间延长锁的过期时间节点失联后锁才会真正释放。第二层也是最关键的兜底执行请求里带上全局唯一的 requestId执行器侧用 requestId 做幂等。就算两个节点真的同时发起了触发执行器也只会接受第一个 requestId 对应的请求后面的直接丢弃。这比任何聪明的锁方案都可靠。4.2 第二个坑数据库扫表成为了新的瓶颈最初版本里调度器每秒执行一次SELECT * FROM tasks WHERE next_time now AND status IN (pending, retry)把到点任务捞出来。任务量小的时候没问题到几万条时数据库 CPU 开始报警慢查询日志里全是这张表的全表扫描。后来做了两个改动。第一把每秒全表扫描改成时间轮到期槽位批量取任务 ID再用WHERE id IN (...)把任务捞出来查询量立刻下降了几个量级。第二引入 Redis ZSet 作为二级索引score 就是任务的秒级到期时间。调度器只查ZRANGEBYSCORE拿到到期任务 ID数据库只负责最终的状态持久化。经过这一轮改造高频扫描压力彻底转移到了 Redis 上数据库回归到它擅长的持久化角色。4.3 第三个坑手动触发和自动触发抢同一个任务管理后台加了一个立即执行一次的按钮让业务方可以手动触发。当时实现得很偷懒直接调用了和自动触发同一个接口。结果上线第二天就出了事故一个数据同步任务既被手动触发了一次又被自动调度触发了一次两边同时跑把下游表写重了。问题根源在于任务模型里没有区分触发来源。后来的修复方案是任务模型增加trigger_source字段手动触发走单独的通道并带上force标记同时约定同一个任务在同一时刻只能存在一个 running 实例靠 Redis 原子递增和请求 ID 双重校验来实现。如果手动触发时任务已经在 running系统会明确提示任务正在执行中而不是静默地再发一次。4.4 第四个坑时钟回拨让调度直接乱掉这个问题排查时间最长因为开发环境几乎无法复现。现象是某次线上任务出现了重复触发但去查锁、查幂等都没有问题后来发现同一批任务在某些时刻被反复扫描而且时间越往前扫描次数越多。最终定位到是云主机发生了一次 NTP 时间回拨。时间轮指针基于墙上时钟系统时间往回跳了一秒指针也跟着倒退于是已处理过的槽位又被扫了一遍。修复方案很明确调度器内部计时全部改用单调时钟。在 Python 里用time.monotonic它只保证两次调用之间的间隔是递增的不受系统时间调整影响墙上时钟只用于生成任务的实际触发时间点。分布式环境下多节点之间的对时以数据库时间或 leader 节点时间为准不使用本地墙钟。这个改动修复之后时钟回拨类问题再没出现过。5. AX调度实测数据说话顺便说点它的边界5.1 我们环境里的压测结果压测环境是一台 4C8G 的虚拟机Redis 6.0 部署在同机房。任务类型是 50 万条一次性延时任务调度节点为单节点。数据如下任务总量每秒触发量P99 调度延迟错误率5 万835 次/秒45ms0.02%50 万8200 次/秒180ms0.05%这里的调度延迟指任务到点时间到调度器真正发出回调之间的时间差。可以看到触发量上升后延迟并没有线性恶化说明时间轮加 Redis ZSet 的组合在单机容量内表现稳定。CPU 峰值在 75% 左右内存约 500MB。如果任务量再上一个量级单节点会顶不住需要引入分片。5.2 真实业务里的表现每天几百万次触发线上以 cron 和 fixed_delay 类型任务为主。高峰时期每天约 300 万次触发调度成功率约 99.97%失败大头在下游服务超时和依赖接口抖动。回调平均耗时大约 3.2 秒整个调度服务的部署成本只有两个 4C8G 节点其中一个还是备节点。ax 真正有价值的地方不是它每秒能触发多少任务而是它把所有散落在业务代码里的时间判断全部收了口。团队从某个服务里可能有个定时器变成了所有定时任务都在 ax 里能看到状态、历史、日志。这种可观测性才是内部工具最大的收益。5.3 边界哪些任务别交给 AXax 不是万能调度器有几类场景它明确不做。第一亚秒级高频触发比如每秒几百次以上的行情推送。这种场景更适合消息中间件或流处理系统调度器做不了精细时间控制。第二有状态的流式计算任务需要窗口、聚合、状态恢复这些能力应该交给专用计算引擎。第三复杂 DAG 且节点数以万计的工作流时间轮这种模型不是为大规模 DAG 设计的硬塞进来只会让依赖表膨胀到难以维护。如果业务是真的遇到这些边界与其改造 ax不如在 ax 前面加一层分流让 ax 只负责什么时候开始具体怎么做交给专业系统。这样边界清晰两边都不会被拖垮。最后分享一下我个人的体会。如果让我重新写一次 ax我会把调度器和执行器彻底拆成两个独立进程再上线。第一版为了省事直接在调度进程里 import 了业务执行函数导致每次发布调度器都要连带把业务代码一起发一遍踩过好几次头。另外我不会再急着优化时间轮这类内部结构而是先把一套完整的任务血缘日志做好。调度系统最难的地方从来不是把任务触发出去而是事后能让人准确回答这个任务为什么在那个时间跑、到底跑得成不成功。ax 调度能走到今天靠的不是算法有多漂亮而是这些朴素的边界被一次又一次踩实了。
RELATED

相关推荐

结构化数据价格预测实战入门 从 Kaggle 回归赛题理解建模与落地

结构化数据价格预测实战入门 从 Kaggle 回归赛题理解建模与落地

这道 Kaggle 入门赛题围绕价格预测展开,任务形态清晰,适合用结构化数据完成一次完整的监督学习回归实践。题目规模不大,却覆盖了业务建模中最常见的关键环节,包括目标定义、特征处理、验证设计、误差分析与结果提交。 真正值得关注的,不是榜单名次本身,而是如何把一份表…

📅 2026/9/26 8:53:15
ax:基于gRPC的Kubernetes设备智能调度底座

ax:基于gRPC的Kubernetes设备智能调度底座

1. 项目概述:从“ax”这个简短代号说起,它到底指什么?刚看到“ax”这两个字母时,我第一反应是——这不像一个完整项目名,倒像某个系统内部的代号、缩写,或是团队里大家心照不宣的简称。翻遍当前主流开源仓库…

📅 2026/9/26 8:53:15
用户评分驱动的电影个性化推荐排序优化

用户评分驱动的电影个性化推荐排序优化

推荐系统作为连接海量内容与个体用户的核心技术,其效能直接决定了数字平台的内容分发效率与用户体验。本竞赛以经典的电影评分数据为背景,设定了一个明确的监督学习任务:基于历史用户评分,预测未来偏好并生成个性化排序列表。这不仅是一个算法练习场,更是理解如何将“为用…

📅 2026/9/26 8:53:15
MORE NEWS

更多资讯

📰

Windows系统级神经渲染:DLSS 5 Swapper技术解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

从本地编译到官方索引:ROS2包发布全流程与避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

FameView V7.6.20.2安装与运行环境深度解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

DevEco Code 在 MacOS 上的安装、配置与卸载全流程指南(TaoToken 配置版)

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📰

pysheeet 实战:在 Slurm 集群上用 Docker 部署 Ray 集群并运行分布式 GPU 训练

文档教程开发工具 【免费下载链接】pysheeet Python Cheat Sheet 项目地址: https://gitcode.com/gh_mirrors/py/pysheeet 点击查看 免费下载 Ray 是用于将 Python 应用扩展到集群的开源分布式计算框架,支持分布式机器学习训练、强化学习、超参数调优与…

📰

Claude Code 上下文压缩工程拆解:Microcompact、Prompt Cache 与 cache_edits 配置实战

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬