1. 先从整体上把 ax 调度拆开它到底解决什么问题前几天整理线上后台服务的任务体系时发现团队里各种定时任务实现得七零八落订单超时靠每一分钟扫一次表优惠券过期提醒用 Thread.sleep 硬顶报表生成直接丢进 Redis 里等过期回调重试补偿就更是随缘。看着都能跑但一旦任务量上来要么数据库扛不住要么延迟抖动得厉害。我后来把沉淀下来的这套东西整理成了一个轻量级调度内核工程里就叫 ax 调度取的是 asynchronous execution 的缩写。ax 调度的核心价值其实就三件事什么时候执行时间维度、由谁执行资源维度、执行结果怎么保证可靠维度。它的定位不是一个大而全的分布式工作流引擎而是一个能让业务快速接入、把延迟任务、定时任务、周期任务统一管理的调度内核。如果你正在做订单超时关闭、优惠券到期提醒、报表定时生成、失败重试补偿这类需求这篇文章大概率对你有用。为什么不用现成的 Quartz、XXL-Job 这类框架反而要自研一个不是它们不好而是很多内部系统的任务模型其实没那么复杂引入重框架还要搭调度中心、管理控制台、数据库表一堆依赖对于只有几万到几十万任务的业务来说运维成本比业务本身还高。ax 调度走的是一条折中路线核心代码足够精简单机可跑分布式也能靠数据库乐观锁扛住属于够用且可控。从宏观上看我把整个调度链路拆成了四层接入层接收业务方的任务注册请求做参数校验、去重、持久化。调度层负责时间计算决定一个任务到点了没有这是 ax 调度的核心大脑。执行层真正跑业务逻辑的地方通常是一个线程池执行器只关心拿到任务后怎么处理。存储层任务的持久化。内存里跑热数据数据库做冷备份与恢复两者配合而不是互相替代。这个分法的核心思路是调度和执行解耦。调度层只负责把任务在正确的时间点投递出去完全不知道业务逻辑长什么样执行层只负责吃任务、干活、回写结果完全不关心时间怎么算。这样设计的好处是你换存储、换线程池策略、加分布式协调都只动一小块不会牵一发动全身。2. 方案选型的真实考量时间轮、延迟队列和数据库轮询怎么取舍ax 调度最关键的选型发生在到点判定这个环节。我在方案评审时把市面上常见的三种实现都摆到了桌面上逐一演算过最终选的是时间轮 延迟队列的混合结构。这里把当时的分析过程原样写出来你这样看完整套取舍逻辑比我直接丢结论有用得多。先说数据库轮询。最简单每分钟扫一次任务表把 next_fire_time 小于当前时间的任务捞出来执行。缺点是精度太粗一分钟的窗口对大部分业务够用但遇到支付后 30 分钟未回传就自动关单这种场景扫表延迟叠加执行耗时经常超时十几秒。更要命的是轮询间隔越短数据库压力越大一两万任务时还有余量十万级以上就开始出现慢查询。所以我直接把它排除在核心调度路径外只用作兜底恢复。再说最小堆DelayQueue / PriorityQueue。每次插入任务和取任务都是 O(logN) 的复杂度实现简单精度也高。问题在于当任务量巨大且取消频繁时堆的调整开销会放大同时它天然是就近触发结构缺少分层能力。比如五万个延迟任务同时堆积最小堆的插入和取出会被频繁的堆化操作拖慢。这个方案我认为适合任务量在十万以下、对精度要求高、结构简单的场景作为 ax 调度的基础结构之一完全没问题。最后是时间轮Timing Wheel。它借鉴的是操作系统时钟中断的思路一圈固定数量的槽位每个槽位存放该刻度上到期的任务集合。插入是 O(1) 复杂度取出也是 O(1)特别适合大量超时任务的场景Netty 的 HashedWheelTimer 就是这个结构。时间轮的问题也很明显单圈时长有限圈数取多了精度下降槽位冲突时需要遍历链表极端情况下链表退化成 O(N)。所以纯时间轮也扛不住全部场景。ax 调度的最终选择是时间轮为主、最小堆为辅的混合结构。新增任务如果延迟时间在时间轮覆盖范围内直接入桶如果超过一圈就先进最小堆等它进入时间窗范围内再迁入时间轮。这么干的好处是高频短延迟任务走 O(1) 路径低频长延迟任务走 O(logN) 路径两者互补。整个调度层就围绕这个双结构运转实际测试下来五万量级任务的调度吞吐比纯最小堆方案提升了近一倍。方案插入复杂度取出复杂度精度适合场景数据库轮询取决于SQL取决于SQL秒级到分钟级冷数据兜底恢复最小堆O(logN)O(logN)毫秒级短延迟、任务量可控时间轮O(1)O(1)毫秒级大规模短延迟任务时间轮最小堆O(1)O(logN)O(1)O(logN)毫秒级混合任务模型ax 选用选型做完后还有几个设计细节踩过坑下面单独说清楚。2.1 时间轮参数怎么定tickDuration 和 wheelSize 的计算逻辑时间轮有两个参数要定一个是 tick 的时长每个刻度代表多久一个是轮子的槽位数。这两个参数直接决定时间轮的覆盖范围。公式很简单总时长 tickDuration × wheelSize。我当时要支撑的主要是订单超时关单、支付结果延迟查询、优惠券到期提醒这类秒级到分钟级任务所以 tick 取的是 100mswheelSize 取的是 512总覆盖范围就是 100ms × 512 51.2 秒。也就是说延迟时间在 51.2 秒以内的任务全部直接入轮超过这个范围就先进最小堆等到剩余时间小于 51.2 秒后再迁入时间轮。这个溢出暂存的机制很关键不加这个任务量稍微大一点时间轮的槽位链表就会被长延迟任务撑爆。实际测试时我试过把 tick 调成 10ms 去追求更高精度结果发现完全没必要。tick 越短CPU 空转越频繁调度线程白白消耗的算力远大于精度提升带来的收益。对绝大多数业务来说100ms 的精度已经足够甚至 500ms 都能接受关键是别把 tick 调到毫秒级以下。2.2 线程池参数配置为什么 IO 密集型和 CPU 密集型差别巨大调度层只负责投递真正干活的是执行线程池。这部分我踩过的最大坑是把线程池参数写成了一刀切的配置结果报表任务把 CPU 打满导致订单超时任务也跟着延迟。线程池的核心参数大家应该都熟corePoolSize、maximumPoolSize、workQueue、拒绝策略。关键在于怎么定 corePoolSize。如果任务是 IO 密集型调外部接口、查数据库、发消息队列线程数可以适当放多经验公式大约在 CPU 核数 × 2 到 × 3 的范围内如果任务是 CPU 密集型大量计算、加密解密、序列化线程数控制在 CPU 核数 1 到 2 就够。ax 调度的执行器我拆成了两个独立线程池一个给短任务用corePoolSize 设在 CPU 核数的两倍左右队列用有界队列另一个给长任务用线程数少一半队列加长。这样拆完以后报表任务再慢也影响不到订单超时任务隔离效果立竿见影。2.3 幂等和防重复为什么内存状态与数据库状态必须双写调度系统最容易翻车的地方就是重复执行。一个订单被重复关单、一个优惠券被重复核销牵扯出来的都是客诉级事故。ax 调度里防重复做了三件事唯一任务 ID、数据库唯一索引、状态机 CAS 更新。任务注册时业务方必须传一个全局唯一的 taskId存储层在任务表上建唯一索引。调度层触发任务时不是直接丢给线程池而是先执行一条 CAS 语义的更新语句把所有状态为 WAITING 且已经到期的任务一次性原子地改成 RUNNING。这里用了一个关键技巧更新条件是状态必须等于 WAITING一旦任务被其他节点或线程抢走状态变了本次更新影响行数就是 0说明任务已被别人领取直接跳过。内存中的执行状态和数据库中的持久化状态必须保持一致否则进程重启后会出现实际没执行但库里标记了 RUNNING的脏数据。我的做法是调度前先写库再改内存状态执行完再更新库和内存。虽然多了一次 IO但对于可靠性优先的任务这笔开销完全值得。3. 核心细节与实操要点把一个任务从提交到执行的全流程跑通理论知识聊完下面进入实操部分。这一节我会把 ax 调度里任务提交、调度触发、执行回写三个环节的所有关键细节完整拆开并且给出可以直接抄走的代码骨架和表结构。3.1 任务接入 API 怎么设计参数越少越容易出错业务方接入调度第一件事就是调注册接口。这个接口的参数设计极度影响后续的易用性。我最初设计的版本参数有十来个结果接入方总在传参上传错后来砍到只剩必要字段。public class TaskRequest { private String taskId; // 全局唯一ID幂等键 private String bizType; // 业务类型如 ORDER_TIMEOUT、COUPON_EXPIRE private String payload; // 业务自定义数据JSON字符串 private Long delayMillis; // 延迟时间单位为毫秒 private Integer maxRetry; // 最大重试次数默认3 private Long timeoutMillis; // 执行超时时间超时后任务允许被重新领取 }这个 API 只覆盖一次性延迟任务。周期任务可以在执行器内部通过重新注册相同 taskId 实现我故意没有把 Cron 表达式放进核心 API因为 Cron 解析逻辑会让调度内核变重。定时报表这类需求业务方在任务处理完成后调用注册接口再排一个下一次的延迟任务逻辑简单且可控。3.2 任务表设计索引顺序别搞反存储层是可靠性的底座。任务表我设计了如下结构经过实际运行验证索引顺序很重要写反了查询性能直接下滑。CREATE TABLE task ( id BIGINT AUTO_INCREMENT PRIMARY KEY, task_id VARCHAR(64) NOT NULL, biz_type VARCHAR(32) NOT NULL, payload TEXT, status TINYINT NOT NULL DEFAULT 0 COMMENT 0-WAITING 1-RUNNING 2-SUCCESS 3-FAILED 4-DEAD, next_fire_time BIGINT NOT NULL COMMENT 下次触发时间毫秒时间戳, retry_count INT DEFAULT 0, max_retry INT DEFAULT 3, last_execute_time BIGINT, create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP, update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_task_id (task_id), KEY idx_status_fire (status, next_fire_time), KEY idx_biz_type (biz_type) );查询待执行任务时走的是 idx_status_fire 索引条件就是 status0 AND next_fire_time now。很多人在建索引时习惯把 status 放在前面但这张表的核心查询是按时间取到期任务把 status 放前面还是 next_fire_time 放前面取决于哪个字段选择性更高。状态字段只有 0 和 1 两种值选择性极差所以必须让 next_fire_time 参与到最左前缀或者用 status next_fire_time 的组合索引二者顺序换过来扫描行数会差一个数量级。3.3 调度主循环别用 Thread.sleep 来等时间调度线程是整个 ax 调度的心脏它的实现质量直接决定调度的稳定性和精度。我第一次写的时候图省事用了每 100ms sleep 一次然后扫时间轮的方式结果发现调度会持续漂移sleep 的时间加上扫描耗时实际触发间隔越来越不准。后来改成阻塞唤醒机制核心逻辑如下public void run() { while (running) { // 从时间轮和溢出堆中取出当前应该触发的任务 ListTask dueTasks takeDueTasks(); if (dueTasks.isEmpty()) { // 没有到期任务等待下一次tick信号 tickSignal.await(100, TimeUnit.MILLISECONDS); continue; } for (Task task : dueTasks) { // CAS 抢占任务避免重复提交 if (tryAcquire(task.getTaskId())) { executor.submit(() - executeWithRetry(task)); } } } }关键在于 tryAcquire。它不是加锁而是执行那条 update ... where statusWAITING 的 SQL影响行数等于 1 说明拿到了执行权。这一步把并发控制的复杂度交给了数据库而不是分布式锁单机部署时性能完全够用分布式部署时也天然正确。3.4 执行结果回写成功、失败和重试怎么处理任务执行完后回写逻辑直接决定重试的可靠性。我采用的策略是执行成功后把状态置为 SUCCESS执行失败且重试次数没超把状态置回 WAITING同时把 next_fire_time 往后推到当前时间 重试间隔超过最大重试次数则置为 DEAD等待人工介入。这里有一个非常容易踩的坑重试间隔如果写死高峰期失败任务会产生惊群效应——几千个任务同时重试瞬间把线程池打满。ax 调度里我做的优化是递增重试间隔第一次重试等 10 秒第二次等 30 秒第三次等 60 秒整体把一个量级的失败任务分散到不同的时间窗口系统稳定性提升明显。4. 实操过程与核心环节实现从零搭一个最小可运行版本理论设计聊完这一节直接带你走一遍最小可用版本的实现路径。我没有把所有代码贴出来那没有必要重要的是把关键节点和参数的选择过程说透你照着搭不会卡壳。4.1 启动流程先把持久层恢复逻辑跑通系统启动时第一件事不是调度而是恢复。因为内存中的时间轮在进程重启后是空的如果不做恢复重启期间到期的任务就全部丢失。恢复逻辑分三步第一步查出所有状态为 RUNNING 且 last_execute_time 距离当前时间超过超时阈值的任务这些任务大概率是上次进程崩溃时执行到一半的孤儿把状态重置回 WAITING。第二步查出所有状态为 WAITING 且 next_fire_time 小于当前时间加上一个时间窗口的任务重新注册进时间轮和溢出堆。第三步启动调度主循环和执行线程池。这里要注意一个时间窗口怎么定。窗口开太大启动时会一次性加载大量长期未执行的任务直接把线程池冲爆窗口开太小长周期任务得不到恢复。我用的经验值是 10 分钟也就是只恢复原计划在未来 10 分钟内触发的任务更远的任务等它进入窗口后再从数据库加载。4.2 注册链路的完整代码参数计算用当前时间叠加一个延迟任务从注册到真正被触发核心代码就这块建议直接收藏。public void registerTask(TaskRequest request) { long now System.currentTimeMillis(); Task task new Task(); task.setTaskId(request.getTaskId()); task.setBizType(request.getBizType()); task.setPayload(request.getPayload()); task.setNextFireTime(now request.getDelayMillis()); task.setStatus(TaskStatus.WAITING); task.setMaxRetry(request.getMaxRetry()); task.setRetryCount(0); // 先落库 taskDao.insert(task); // 再入内存调度结构 if (request.getDelayMillis() WHEEL_DURATION_MS) { timeWheel.add(task); } else { overflowQueue.add(task); } }先落库再入内存的顺序不能换。如果先入内存后落库恰好进程在中间崩溃任务就会丢失反过来虽然多了一次数据库写延迟但重启后可以从库里恢复内存里丢不丢都无所谓。4.3 单机实测五万任务量下的表现和调优过程搭好最小版本后我做了个接近生产场景的压测一次性灌入五万条延迟任务延迟范围从 1 秒到 5 分钟随机分布由四个 worker 线程消费。第一轮测试跑下来发现两个问题任务过期率高达 3.8%同时线程池队列频繁打满。排查后定位到两个原因。一是线程池 corePoolSize 设太小了四个线程处理五万个任务即使每条任务只耗时几十毫秒积压也在所难免于是把 corePoolSize 提到 16队列缩减为 1000配合 CallerRunsPolicy 拒绝策略让积压压力反向传导给调度线程。二是时间轮槽位冲突太严重五万个任务塞进 512 个槽位平均每个槽位链表长度接近一百遍历耗时暴增。这个只能靠调参缓解我最终把时间轮总时长拉到了 120 秒也就是 tick200ms、wheelSize600冲突率明显下降。指标调整前调整后corePoolSize416workQueue无界1000wheelSize512600tickDuration100ms200ms任务过期率3.8%0.02%99分位调度延迟1200ms210ms调整后的测试结果如上面表格。真正上生产前还补了一步把调度延迟指标打印进日志tail -f 实时观察连续跑了三天99 分位都在 300ms 以内整体达标。4.4 分布式部署的避坑数据库乐观锁够用别再引入强依赖ax 调度完全可以单机部署但如果业务量上涨到单机扛不住或者需要高可用分布式部署也只需要做一件事每个节点都跑同样的调度循环靠数据库乐观锁去重。因为 tryAcquire 本身就是原子的两个节点同时捞到同一个到期任务只有一个能更新成功另一个会拿到影响行数为 0 的结果然后自动跳过。这套方案最大的优点是不用引入 Redis 分布式锁、ZooKeeper 选主这类强依赖部署成本极低。缺点是任务量极大时数据库会成为瓶颈但按经验单表千万级以内这个瓶颈不会出现。如果你真的到了亿级任务量那就不是改造调度器的问题了需要考虑分库分表或者存储选型那是另一个维度的架构话题。5. 常见问题与排查技巧实录这些坑我不希望你再踩一遍ax 调度跑了几个月遇到的问题不少。我把高频问题和排查思路整理成一张速查表每个问题都附了定位方法和解决措施。这一节的经验价值最高因为很多问题不是看文档能看出来的必须实际踩过坑才能写出来。5.1 任务到点不执行或延迟严重这个问题第一反应先查调度延迟指标。ax 调度的日志里会打印每次从任务到期到实际提交线程池的时间差。如果延迟均值正常但偶发尖峰大概率是 GC 停顿导致的时间轮里的 tick 线程被 Full GC 卡住期间所有任务都会延迟。解决办法是把调度线程的堆内存调大、检查是否有大对象分配必要时缩短 Young GC 周期。如果延迟均值本身就高优先怀疑时间轮槽位冲突。槽位链表过长遍历到点上每个任务的时间就会变长解决办法就是调大 wheelSize 或者减小 tick让任务分布更均匀。还有一个容易被忽略的原因调度线程所在 CPU 被其他核心业务抢占尤其容器化部署时 cgroup 限制没配好邻居业务把 CPU 跑满调度线程饿死。5.2 偶发重复执行先查状态更新原子性重复执行是调度系统最严重的问题。排查思路就一条检查 tryAcquire 的更新条件是否真的带上了状态必须为 WAITING这个条件。很多人的 update 语句只写了 where task_id?漏了 status于是两个请求都能更新成功重复执行由此而来。还有一个隐蔽的场景执行超时任务还在跑但调度侧已经判定它超时并重置为 WAITING另一个节点重新领取执行。这时要额外做一层业务幂等用 taskId 作为业务处理的幂等键在真正处理前先查一下是否已经处理过。调度系统的幂等是兜底业务层的幂等是最后防线两层必须都有。5.3 任务凭空消失多半是内存结构和数据库没对齐任务丢失问题的根源几乎都是内存和数据库状态不一致。最常见的场景任务被调度线程取出CAS 更新为 RUNNING 成功但还没来得及提交到线程池进程崩溃了。重启后恢复逻辑会把这些 RUNNING 且超时的任务重置回 WAITING理论上不会丢但如果超时阈值设置过大比如一个小时那么这一个小时内任务就看起来消失了。解决方法是把超时阈值缩短并且每次执行完都要立即回写状态拖得越久丢的风险越大。还有一个细节时间轮里的任务被取出但执行失败、状态回写 WAITING 后必须重新放回时间轮或溢出堆很多人漏掉这一步导致任务只重试了一次就凭空消失。5.4 线程池拒绝策略怎么选不要无脑用 AbortPolicy线程池满了以后默认的 AbortPolicy 会直接抛异常调度线程捕获不到的话任务就丢了。ax 调度里我推荐的是 CallerRunsPolicy它会把多余的任务直接放回调度线程执行相当于把压力传导回去让调度循环变慢反而起到了天然的背压作用。DiscardOldestPolicy 不推荐问题在于它丢弃的是队列头最老的任务而这些任务恰恰是最接近到点的丢弃后业务就永远走不到了。如果你不希望提交线程被阻塞可以自定义拒绝策略把拒绝的任务打回数据库状态为 WAITING等下一轮再调度。5.5 重启后任务状态脏恢复脚本怎么清理运行久了以后任务表里会出现一批 RUNNING 状态但实际已经死亡的任务。这部分任务占着状态导致对应的业务永远不会再被调度。我写了一个恢复任务每 30 分钟执行一次把 RUNNING 且 last_execute_time 超出当前时间 10 分钟以上的任务重置为 WAITING同时把重试次数清零。清零重试次数这个操作要想清楚。对于偶发故障的任务重置后能重新跑起来对于本身就写错的逻辑重置只会让它在数据库里反复失败最终还是进 DEAD 状态。所以恢复脚本只适合处理进程崩溃遗留的孤儿任务不要把这个脚本当成兜底错误重试的工具。5.6 时间戳回拨问题用单调时钟代替系统时间这是一个极其隐蔽的坑。系统时间被 NTP 校准或运维手动调整时可能出现向回拨动如果调度逻辑用 System.currentTimeMillis() 做时间轴回拨瞬间所有计算出的 next_fire_time 都会错乱。ax 调度在内存调度结构里改用 System.nanoTime() 作为单调时钟它不受系统时间调整影响只在进程内有效。数据库里的 next_fire_time 仍用墙上时间存储因为恢复时需要对齐现实时间点。引入了这套双时间体系以后时间回拨问题彻底没再出现过。类似的坑还包括夏令时切换导致的延迟计算错乱用单调时钟同样能规避掉。6. 参数调优实测参考不同场景下的推荐配置最后把这些经验汇总成一套配置参考表都是我实测过能稳定运行的组合。注意这些参数不是万能的但它能给你一个合理的起点。场景任务规模tickDurationwheelSizecorePoolSize队列长度恢复窗口订单超时/延迟关单≤ 1万200ms512CPU核数×2100010分钟优惠券/活动到期≤ 5万200ms600CPU核数×3200015分钟报表/数据同步≤ 2万500ms512CPU核数×1.5500020分钟混合型任务5万-10万100ms1024CPU核数×2200010分钟需分表最后一个建议也是我个人实测下来最有价值的一点监控指标别看平均值要看 99 分位。平均值漂亮不代表系统稳定负载均衡器上你永远不知道哪一个用户正在等待那个 99 分位的延迟尖峰。ax 调度在每个任务执行完成后都统计一次调度延迟和执行耗时按 bizType 分类99 分位超过阈值就告警。上线那一刻我就把这条规则加了进去后续几次发布能提前发现问题靠的都是这条 99 分位告警而不是平均值。调度系统这块内容写出来看着简单实际落地过程中每一步都有看不见的权衡。那些没有在标题里写出来的部分比如状态机怎么流转、时间轮溢出怎么办、重启怎么恢复恰恰才是决定一套调度系统能不能稳定运行的关键。希望这篇记录能帮正在做同类需求的你少走几个弯路。