1. 为什么我需要一套叫 AX 的调度引擎先说个场景。电商订单超时未支付要自动关单积分要到期了得提前发提醒K8s 里某个 Pod 探针失败需要触发自愈还有一堆数据对账任务每天凌晨三点准时跑。这些需求听上去八竿子打不着但背后全指向同一件事调度。我之前在不同项目里见过各种“野路子”解法。有的团队在业务代码里塞Scheduled注解定时任务散落得到处都是有的用 Redis 过期监听做延迟任务结果消息一丢连日志都查不到还有的图省事直接用 MQ 延迟队列但想做周期任务和失败重试又得另起炉灶。业务一多这些方案就开始互相打架。后来我把内部一个调度模块彻底重写了一遍代号就叫 AX。这个名字起得挺随性AX 可以理解成 Asynchronous eXecution——异步执行引擎也可以理解成一把斧头调度这件事在我心里的定位就是“快、准、稳”。最近团队里聊天也把“ax调度”挂在嘴边说的就是这套异步任务调度与分布式执行的能力。AX 到底能干什么一句话概括一套同时支持定时任务、延迟任务、周期任务、失败重试和任务编排的轻量级调度引擎。它不是要把 Quartz 或者 XXL-JOB 完全替代掉而是在“强实时延迟任务”和“分布式定时任务”之间找一个平衡点让业务方用一个 SDK 就能完成所有调度诉求。适合正在做中后台系统、微服务架构或者被各种定时任务折磨得想骂人的后端开发、架构师和 SRE 参考。1.1 没有统一调度时业务代码到底有多乱我把之前遇到的混乱场景列一下你应该会有共鸣。第一每个业务方各写各的定时器。A 组用 SpringScheduledB 组用TimerScheduledExecutorServiceC 组干脆在业务请求里顺带判断一下“是不是该执行了”。后果就是没有一个全局视图线上到底有多少定时任务在跑没人说得清。第二延迟任务的实现五花八门。Redisson 的RBlockingQueue、RocketMQ 延迟消息、RabbitMQ TTL死信队列每种方案都有自己一套语义。有的延迟精度能到秒级有的只能到分钟级有的支持消息回溯有的消息一旦消费失败就石沉大海。第三可观测性基本为零。任务为啥没执行执行到哪一步失败了重试了几次这些信息散落在各个业务日志里排查一个问题经常要翻七八个系统。第四也是我最不能忍的重复执行和丢失同时存在。有的方案因为网络抖动疯狂重试同一个任务被重复执行三四次有的方案因为进程重启到期任务直接消失。这不是代码 Bug是架构层面没有一个可靠的状态机做兜底。所以 AX 的第一个设计原则就是把调度从业务代码里彻底剥离出来。业务方只需要注册一个任务剩下“何时触发、由谁触发、怎么重试、怎么恢复”全部交给调度引擎。1.2 AX 要解决的核心问题和设计目标AX 从设计之初就定了五个目标每个目标都对应一个真实痛点。统一接入所有调度需求走同一个 SDK屏蔽底层实现差异。业务方不需要知道任务是存在数据库里还是内存里也不需要关心触发逻辑是时间轮还是数据库轮询。精确触发延迟任务的触发精度至少到百毫秒级。这个要求直接排除了“每秒扫一次数据库”的笨方案。可靠恢复进程重启、宕机、网络分区都不能丢任务最差也要做到“至少一次”执行再配合幂等去重达到“仅一次”效果。可观测每个任务都有完整生命周期轨迹——创建、就绪、分发、执行、成功/失败、死亡。任何一个环节出问题都能查日志定位。轻量可控不引入太重的外部依赖。AX 核心只有调度器和执行器两个角色存储层可以只用 MySQL Redis不需要额外部署专门的调度集群。1.3 和其他开源方案的选型对比很多朋友会问Quartz 很成熟XXL-JOB 也很火为什么要自己造轮子我把它们摊开对比一下就明白了。对比维度QuartzXXL-JOBMQ 延迟消息AX触发精度秒级秒级分钟级/秒级不等百毫秒级周期性任务支持支持需要自己拼支持延迟任务不原生支持不原生支持支持支持持久化支持支持依赖 MQ支持失败重试有限有限有限可自定义策略任务编排不支持不支持不支持简单 DAG部署成本低中需要 Admin 集群高需要 MQ 集群低可观测性一般好依靠 MQ好Quartz 的最大问题是集群模式下靠数据库锁调度节点一多就容易出现锁竞争而且它压根没有“延迟任务”这个概念。XXL-JOB 做得确实好尤其是管理后台那一套但它天生是为“定时批量任务”设计的对“下单后 15 分钟自动取消”这种强实时场景并不友好。MQ 延迟消息适合传输不适合管理——任务一旦投入队列你很难再去查它的状态、改它的触发时间。AX 的思路是反过来任务先落库成为一条有状态的记录时间轮只负责“叫醒”调度器调度器再决定要不要现在分发。这样既能拿到 Quarts 的持久化能力又能做到接近内存延迟队列的触发精度。2. 整体架构设计与任务存储模型下面说架构这是 AX 的核心骨架。2.1 三大模块接入层、调度核心、执行层AX 分成三部分。接入层是给业务方用的 SDK提供注解和 API 两种注册方式。比如你在方法上标一个AXCron(0 0 2 * * ?)AX 在启动时会自动扫描并注册也可以用AXClient.submit(taskRequest)动态提交。调度核心是 AX 的大脑它由三块组成接收入口、时间轮、协调分配器。接收入口校验任务参数并落库时间轮维护最近几十万上百万个待触发任务的索引协调分配器从时间轮拿到“到点任务”的 ID 列表然后去数据库把完整任务读出来做分发。执行层是跑在业务服务里的一组轻量进程负责真正干活。执行器启动时向注册中心登记自己的地址收到任务后调用业务方注册的 Handler执行完成把结果回写。单机部署的时候这三层可以在同一个进程里分布式部署时调度核心可以做成多节点通过分布式锁避免同一个任务被多个调度节点同时处理。2.2 任务表设计一张表撑起所有状态任务必须能持久化所以数据库表是 AX 的地基。我拆解一下字段设计。CREATE TABLE ax_task_info ( id BIGINT NOT NULL AUTO_INCREMENT COMMENT 主键, task_no VARCHAR(64) NOT NULL COMMENT 业务方传入的任务编号用于幂等, task_type TINYINT NOT NULL COMMENT 任务类型1-延迟任务 2-定时任务 3-周期任务, status TINYINT NOT NULL DEFAULT 0 COMMENT 状态0-创建 1-就绪 2-分发中 3-执行中 4-成功 5-失败 6-死亡, trigger_time BIGINT NOT NULL COMMENT 下一次触发时间戳毫秒, cron_expr VARCHAR(64) DEFAULT NULL COMMENT 周期表达式仅周期任务使用, execute_count INT NOT NULL DEFAULT 0 COMMENT 已执行次数, max_retry_count INT NOT NULL DEFAULT 3 COMMENT 最大重试次数, task_data TEXT NOT NULL COMMENT 业务透传参数, callback_url VARCHAR(256) DEFAULT NULL COMMENT 结果回调地址为空则走 SDK 回调, route_key VARCHAR(64) DEFAULT NULL COMMENT 路由键用于同业务任务串行执行, parent_task_no VARCHAR(64) DEFAULT NULL COMMENT 父任务编号用于编排, deadline_time BIGINT DEFAULT NULL COMMENT 任务绝对超时时间, version INT NOT NULL DEFAULT 0 COMMENT 乐观锁版本号, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_task_no (task_no), KEY idx_trigger_status (trigger_time, status) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENTAX 任务信息表;这里有几个细节要说清楚。uk_task_no是幂等核心。业务方提交任务时必须传task_no重复提交同一个编号就直接返回已有任务不做二次插入。idx_trigger_status联合索引很重要调度器捞取“到点任务”时就是WHERE trigger_time ? AND status 1 ORDER BY trigger_time LIMIT ?这个索引能保证查询不扫全表。version配合乐观锁做状态流转。比如分发时要执行UPDATE ax_task_info SET status 2, version version 1 WHERE id ? AND version ?影响行数为 0 就说明状态已经被别的节点改了当前节点放弃。2.3 任务状态机防丢防重复的核心AX 的所有逻辑都围绕状态机转这是整个系统可靠性的大梁。状态含义谁触发进入CREATED0任务刚落库接入层提交任务READY1等待触发定时校验线程确认任务已录入DISPATCHED2已分发至执行器协调分配器推送成功RUNNING3执行器开始处理执行器收到任务SUCCESS4执行成功执行器回写结果FAILED5执行失败待重试执行器回写异常DEAD6超过最大重试次数调度器判定任何一个任务在执行过程中中断比如调度器分发后没收到执行器确认、或者执行器执行一半进程挂了都会有一个“巡检线程”定时扫描那些长时间停留在中间状态的任务并强制把它们拉回 READY 重新触发。这个机制保证了任务不会无声无息地丢失。配合乐观锁的 CAS 更新多节点同时尝试修改状态时只有一个能成功这就从底层堵死了重复分发的可能。3. 时间轮AX 调度的“心跳引擎”3.1 为什么不用每秒扫数据库的笨办法最直观的实现方案是起一个后台线程每秒执行一次“查询所有 trigger_time 小于当前时间且状态为 READY 的任务”。听起来没毛病但线上跑起来就很酸爽。假设你有 10 万个延迟 15 分钟的订单关单任务它们都集中在未来 15 分钟内触发。每秒查询一次每次都要扫 10 万条带trigger_time索引的记录虽然能用索引快速过滤但索引扫描加回表查询的压力一点都不小。而且大多数时候这一秒内根本没有任务到点查询就是白跑。更重要的是延迟问题。每秒扫一次任务触发精度就卡在秒级如果扫描线程因为 GC 或者锁竞争卡顿延迟会进一步放大。我想做到百毫秒级就必须换思路。3.2 时间轮原理以及一份可用的 Java 实现时间轮原理不难理解可以把它想象成一个钟表盘。表盘被等分成 N 个格子槽位每个格子代表一个时间刻度。指针每走一个刻度就检查当前格子有没有挂着的定时任务有的话就拿出来处理。要添加一个任务就计算它应该落在哪个格子。单层时间轮的格子数是固定的能表达的时间范围有限。打个比方一格代表 100ms总共 512 格那么一圈只能表达 51.2 秒。超过这个范围的任务要么升级成更多层时间轮高层走一圈低层走很多圈要么触发“溢出”机制存到数据库里等快到触发时间再挂进时间轮。我用的办法是数据库存储 时间轮索引。任务落库后调度器只把“即将在一段时间内到期”的任务加载进时间轮。核心数据结构简单写一下public class TimingWheel { private final long tickMs; // 一个格子的毫秒数比如 100 private final int wheelSize; // 格子数量比如 512 private final long intervalMs; // 轮盘总时长tickMs * wheelSize private final AtomicLong currentTick; // 当前指针指向的格子编号 private final MapLong, SetLong slots; // 格子编号 - 任务ID集合 }插入逻辑public void addTask(long taskId, long triggerTime) { long delayMs triggerTime - System.currentTimeMillis(); if (delayMs tickMs) { delayMs tickMs; } long ticks delayMs / tickMs; long targetSlot (currentTick.get() ticks) % wheelSize; synchronized (slots) { String slotKey String.valueOf(targetSlot); slots.computeIfAbsent(targetSlot, k - new HashSet()).add(taskId); } }推进逻辑public ListLong advance() { long tick currentTick.getAndIncrement() % wheelSize; return slots.remove(tick) null ? Collections.emptyList() : new ArrayList(slots.remove(tick)); }实际代码比我写的要复杂得多还要处理溢出任务重新入轮、槽位并发读写等。但核心思想就是这个用 O(1) 的插入和 O(1) 的取出替代每秒一次全量扫描。3.3 时间轮只负责“叫醒”不负责“干活”我踩过一个大坑最初版本让时间轮线程直接去查库、分发任务、调回调结果时间轮线程被耗时的网络 IO 堵住后续所有任务都跟着延迟。这就像一个前台接待边接电话边处理投诉又边跑出去取快递结果电话线一直被占着。AX 的解法是把职责拆开。时间轮线程只做一件事到点后把任务 ID 从槽位里拿出来丢进一个内存中的“到点任务集合”。然后协调分配器从集合中批量取任务 ID去数据库捞完整任务做状态流转和分发。这样时间轮线程永远只做纯内存操作单线程也能支撑很高的触发吞吐。协调分配器则用独立的线程池控制分发速度避免下游执行器被打爆。3.4 进程重启长期任务如何恢复时间轮里只放“快到期”的任务所以重启恢复不需要把所有任务都加载回来。我的做法是调度器启动后扫描未来 5 分钟内所有状态为 READY 的任务把它们重新灌进时间轮。超过 5 分钟以后到期的任务在运行过程中由一个“预热线程”持续不断地加载进时间轮。如果重启发生在任务刚好到点的那几秒也不用担心。任务状态还是 READY重新加载后照常触发。这就是“至少执行一次”的兜底。4. 任务分发与分布式协调时间轮解决了“何时触发”接下来要解决“触发之后怎么办”。4.1 执行器注册与心跳维护执行器启动时会向注册中心上报自己的地址和所能处理的业务类型。AX 的注册中心可以直接复用 Redis也可以换成 ZooKeeper。我选了 Redis因为它轻量团队已经运维得很熟。注册信息写入 Redis 的 Hash 结构字段是执行器地址值是最近心跳时间。执行器每 5 秒上报一次心跳调度器后台线程每 10 秒扫描一次把超过 15 秒没心跳的执行器标记为下线。这个下线判断要保守一点避免网络抖动导致执行器被误杀后任务被重复分发。4.2 触发时需要抢锁避免多节点重复分发分布式部署时多个调度节点的时间轮可能同时推进到同一个任务 ID。如果不加限制这个任务会被多个节点同时分发导致业务逻辑重复执行。AX 的做法是在分发前先抢一把 Redis 分布式锁String lockKey ax:dispatch: taskNo; boolean locked redis.setIfAbsent(lockKey, 1, 30, TimeUnit.SECONDS); if (!locked) { return; // 其他节点正在处理 } try { // 执行状态机更新 分发 } finally { redis.delete(lockKey); }锁的过期时间要设置成业务的兜底不能太长否则持有锁的节点宕机后锁要等到超时才能释放。30 秒对我来说比较合理因为单次分发流程是纯数据库操作加网络请求正常情况下几百毫秒就完成了。如果不想依赖 Redis还有一个“穷人版”方案用数据库悲观行锁SELECT ... FOR UPDATE SKIP LOCKED。这招在 PostgreSQL 上特别好用MySQL 8.0 也支持了SKIP LOCKED语法。它绕过 Redis 实现节点间协作适合中小规模部署。4.3 执行器侧幂等消费与结果回写执行器收到任务后第一件事不是执行而是查幂等表INSERT INTO ax_task_exec (task_no, execute_id, status) VALUES (?, ?, 0) ON DUPLICATE KEY UPDATE status status;只有插入成功且影响行数为 1 的请求才真正执行业务代码其余请求直接忽略。同时生成全局唯一的execute_id用这个 ID 上报日志和结果方便排查。执行完成后执行器回写任务状态。成功就把任务推进到 SUCCESS失败则判断是否还有重试次数有就把状态改回 READY 并重新计算trigger_time没有就置为 DEAD。回调通知也要注意如果调用业务方回调接口超时不能无限等。我给回调设置了一个超时上限和本地重试队列重试超过 3 次就把回调状态标为手动处理。4.4 简单 DAG 编排先执行上游再执行下游任务编排在一开始没打算做后来被业务逼着加的。场景是这样的新用户注册后要先初始化账户、发送欢迎短信、再同步到 CRM。三个任务有先后依赖串行跑太慢并行又怕依赖乱掉。AX 实现的是最小可用 DAG。任务表里用parent_task_no标记依赖调度器在父任务变成 SUCCESS 后把子任务的trigger_time立即更新为当前时间并放入时间轮。父任务失败时子任务自动进入等待状态不会傻乎乎地继续跑。这套机制非常轻量依赖深度超过三层就不建议用了。复杂 DAG 还是交给 Airflow、DolphinScheduler 这类专门的工作流引擎。5. 延迟精度与高可用压测和调优实录5.1 压测条件与结果我压过一轮让心里有底。机器配置是 4 核 8G 的普通云主机MySQL 在另一台机器上网络内网延迟约 0.5ms。压测模型是这样预置 100 万条延迟任务触发时间分布在未来 10 分钟内执行器在本机起 20 个线程模拟空跑。实测数据指标结果任务入库耗时100 万条批量插入约 72 秒触发分发吞吐约 3500 TPS触发延迟 P5027ms触发延迟 P9996ms分发线程池负载65%这个精度和吞吐对于“关单”“提醒”类业务来说完全够用。如果你的业务要求 P99 达到 30ms 以内那得把调度器和执行器的通信改成 UDP 之类的尽力而为协议但可靠性会下降我不太建议。5.2 三个核心调优点第一个调优点是每次从数据库捞取到点任务的数量。我试过 100、500、1000、5000发现 500 是一个甜点值。少于 500调度线程频繁查库数据库交互开销占比太高多于 1000一次性读出的任务需要大量内存暂存分发不及时就会积压。第二个调优点是时间轮刻度。100ms 一格、512 格是我的组合。格子太小如 10ms会导致槽位频繁推进CPU 空转格子太大如 1s又会损失触发精度。100ms 的粒度配合数据库落库已经可以覆盖绝大多数业务场景。第三个调优点是分发线程池的参数。不要让分发线程数和 CPU 核数线性挂钩。我用的corePoolSize50, maxPoolSize200, queueCapacity2000配合有界队列。队列一旦满了分发线程就会阻塞时间轮那边反而会形成天然背压防止任务过去积压。6. 常见问题与排查技巧实录实际运行中最常被问到的就是下面这几个问题。6.1 任务大面积延迟从哪查起先别查代码按顺序查三个地方。第一看调度器进程的 CPU。时间轮线程是纯内存运算正常情况下 CPU 占用很低。如果发现某个调度节点 CPU 飙升到 90% 以上先看是不是synchronized锁竞争——槽位的读取和写入用同一把锁高并发时会有锁抖动。解决方式是把读写锁改成分段锁或者用ConcurrentHashMap的原子操作替代synchronized。第二看数据库的慢查询日志。idx_trigger_status索引失效会出现全表扫描最常见的索引失效原因是状态字段参与计算比如写成WHERE status 0 1。扫描EXPLAIN结果确认Extra列没有出现Using filesort。第三看执行器的心跳是否正常。如果大量执行器掉线分发出去的任务没人接收任务会积压在 DISPATCHED 状态。6.2 同一个任务被重复执行三条防线逐层堵我做流控时最怕的就是重复。三条防线缺一不可。第一条状态机 CAS 更新。分发前UPDATE ... SET status 2 WHERE status 1影响行数是 0 就说明不是你先抢到的。第二条Redis 分布式锁。防止多节点同时 CAS 成功理论上 CAS 是原子的不会出现这种情况但锁还能挡一下数据库主从延迟导致的可见性问题。第三条执行器幂等表。即使前两层因为极端情况都失效了执行器侧的ON DUPLICATE KEY UPDATE仍然能保证同一个task_no不会执行两遍业务代码。6.3 任务消失了去哪找AX 日志里埋了三个关键节点轮询到、分发成功、执行上报。我要求每个节点都打印task_no和execute_id。排查丢失任务时直接按task_no全局搜日志看卡在哪一步。只有“轮询到”没“分发成功”问题在调度器与执行器的网络通信或者执行器注册信息丢失。有“上报”日志但任务状态没变 SUCCESS问题在结果回写的数据库更新失败大概率是乐观锁冲突。三个节点全都没有问题在时间轮加载环节检查预热线程有没有把这个任务加载进去。还有一个高效技巧直接查后台库把任务状态从 CREATED 到 SUCCESS 的耗时拉出来找出那些长时间卡在中间状态的任务然后定向排查。6.4 时间轮内存占用怎么估算有朋友问100 万延迟任务全放时间轮内存会不会爆。实际上 AX 时间轮里只存任务 ID每个 ID 是一个Long加上HashSet的开销粗略算每任务约 48 字节。计算公式很简单内存占用 QPS × 最大延迟时长秒 × 单任务字节数按 1000 QPS、最大延迟 15 分钟算1000 × 900 × 48 ≈ 43MB。100 万任务放大到极端场景也就 48MB 左右完全在可接受范围。如果你要做一个月级别的延迟任务就别全塞内存了让预热线程按需分批加载或者干脆扔掉时间轮改用数据库轮询兜底。7. 实际运行后的一些体会AX 从设计到现在已经跑了半年最深刻的一点体会是调度系统最核心的不是性能而是可靠性。百万任务你触发得快只代表你性能好但真正让你少熬夜的是状态机清晰、幂等可靠、恢复及时。性能再高数据一丢业务方第二天早上来找你的时候可不会夸你吞吐高。第二个实用心得是时间轮只是触发器状态机才是调度器的灵魂。我最初把重心全放到时间轮上结果加了状态机之后重复执行和丢失问题才真正得到控制。至于后续扩展我觉得有两个方向值得做。一个是把调度规则做成可热加载的脚本比如支持 Groovy 表达式这样业务方想调整任务策略时不需要重新发版。另一个是让调度器内嵌到业务 SDK 里让业务进程具备本地调度能力调度中心只看全局指标实现真正的去中心化。如果你正准备给自己的系统做一套调度能力我的建议很直白先想清楚状态机和幂等设计再考虑要不要上分布式、要不要上时间轮。顺序反了后面大概率要重写。