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 取的是 100ms,wheelSize 取的是 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 索引,条件就是 status=0 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) { // 从时间轮和溢出堆中取出当前应该触发的任务 List<Task> 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 status='WAITING' 的 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 秒,也就是 tick=200ms、wheelSize=600,冲突率明显下降。
| 指标 | 调整前 | 调整后 |
|---|---|---|
| corePoolSize | 4 | 16 |
| workQueue | 无界 | 1000 |
| wheelSize | 512 | 600 |
| tickDuration | 100ms | 200ms |
| 任务过期率 | 3.8% | 0.02% |
| 99分位调度延迟 | 1200ms | 210ms |
调整后的测试结果如上面表格。真正上生产前还补了一步:把调度延迟指标打印进日志,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. 参数调优实测参考:不同场景下的推荐配置
最后把这些经验汇总成一套配置参考表,都是我实测过能稳定运行的组合。注意这些参数不是万能的,但它能给你一个合理的起点。
| 场景 | 任务规模 | tickDuration | wheelSize | corePoolSize | 队列长度 | 恢复窗口 |
|---|---|---|---|---|---|---|
| 订单超时/延迟关单 | ≤ 1万 | 200ms | 512 | CPU核数×2 | 1000 | 10分钟 |
| 优惠券/活动到期 | ≤ 5万 | 200ms | 600 | CPU核数×3 | 2000 | 15分钟 |
| 报表/数据同步 | ≤ 2万 | 500ms | 512 | CPU核数×1.5 | 5000 | 20分钟 |
| 混合型任务 | 5万-10万 | 100ms | 1024 | CPU核数×2 | 2000 | 10分钟,需分表 |
最后一个建议,也是我个人实测下来最有价值的一点:监控指标别看平均值,要看 99 分位。平均值漂亮不代表系统稳定,负载均衡器上你永远不知道哪一个用户正在等待那个 99 分位的延迟尖峰。ax 调度在每个任务执行完成后都统计一次调度延迟和执行耗时,按 bizType 分类,99 分位超过阈值就告警。上线那一刻我就把这条规则加了进去,后续几次发布能提前发现问题,靠的都是这条 99 分位告警,而不是平均值。
调度系统这块内容,写出来看着简单,实际落地过程中每一步都有看不见的权衡。那些没有在标题里写出来的部分,比如状态机怎么流转、时间轮溢出怎么办、重启怎么恢复,恰恰才是决定一套调度系统能不能稳定运行的关键。希望这篇记录能帮正在做同类需求的你少走几个弯路。