
1. 为什么做ax调度1.1 业务痛点从crontab到分布式任务墙几年前我们团队维护的定时任务还很简单一台服务器上挂着三十多个crontab凌晨执行数据同步报表生成账单结算每个脚本后面站着一个提心吊胆的开发。后来业务量上来微服务拆了几十个调度需求像滚雪球一样膨胀数据中台要每小时拉一次外部接口交易系统要做延迟任务运营要推消息和优惠券风控要定期跑模型几十个服务各自定义任务有的用crontab有的自己写进while循环有的干脆用spring的Scheduled。系统机器多了以后crontab的问题被无限放大脚本散落在不同机器上没有统一视图任务挂掉没人知道重复执行没有幂等保护一台机器宕机任务就静默消失。最疼的一次是月底结算那天负责任务的老服务器凌晨三点内存溢出结算任务没跑第二天一堆线上账单是错的我们手动补数据补到下午三点。那时候我就在想能不能做一个统一的调度平台把所有任务都收拢进来就像机场塔台一样统一指挥航班起降。这个系统需要解决几个核心问题任务不丢、不重、可查看、可重试还要支持复杂依赖和分布式执行。当时也评估过开源方案调研了市面上常见的几个调度框架有的太重部署成本大有的不满足多租户隔离需求有的社区活跃度太低不敢上生产。最后决定自己写一个轻量调度内核内部代号就叫ax全称是Automator X设计目标很直接把所有需要“到点执行”的事情统一交给它让业务方只关心任务逻辑不关心什么时候跑、在哪台机器跑、跑了有没有成功。这套系统上线以后效果比我预期的要好。原本每天靠人肉盯crontab的运维工程师终于能从告警群里解放出来新业务接入任务只需要调一个API注册不需要申请机器和脚本权限。后来我们把ax内部叫成“ax调度”越用越顺。这篇文章就把我们做ax调度时的架构设计、核心原理、部署经验以及踩过的坑完整分享一下给同样被分布式任务折磨的团队一个参考。1.2 设计目标简单、可靠、可观测ax调度从立项第一天就有三条不能妥协的原则。第一是接入简单业务方注册一个任务只需要知道任务名、执行方式、执行时间和超时时间其他细节全部由平台接管就像一个外卖平台用户只需要下单和取餐不需要关心骑手怎么接单、怎么规划路线。第二是可靠性优先允许系统短暂不“准时”但绝不允许任务丢或者重复执行造成资损宁可让一个任务晚跑五分钟也不能让一条账单被重复计算两次。第三是可观测性任何任务从创建、触发、派发、执行、成功、失败、重试全链路都要有日志和指标出问题能快速定位到具体环节不能做黑盒。这三个目标听起来简单落地的时候其实一个比一个麻烦。接入简单要求我们抽象出一套通用的任务模型把所有类型的任务都归一成“执行器 参数 触发规则”三元组。可靠性要求我们在设计分布式协调、存储模型和重试策略时做大量异常场景推演。可观测性要求我们埋点细致从任务进入系统开始每一步都要落日志。我不会在这一章空谈概念下面把ax调度的整体架构和关键设计一个个拆开每一步都说清楚为什么这样选。2. 核心调度原理拆解2.1 时间轮让千万级定时任务不被拖垮ax调度要支撑的最大规模场景是千万级定时任务比如给几百万用户各生成一条晚间推送再比如每秒钟有大量延迟订单需要到期自动关闭。传统做法是用数据库扫表每隔几秒查一次“到期时间小于当前时间的任务”然后批量执行。这个方案在任务量小于十万的时候没问题一旦到了千万级别频繁扫全表会把数据库拖垮而且存在时间间隔误差到了秒级就基本不可用了。ax调度采用时间轮算法来组织触发时间。时间轮的思路跟火车站检票口有点像把时间切成一个个格子每一格代表一个最小时间精度比如一秒。任务按照到期时间放到对应格子的链表中调度线程每秒钟往前走一格把当前格子里的所有任务全部取出来派发。这样不需要扫描全表只需要处理当前时间点上的任务列表时间复杂度从O(n)降到O(1)。时间轮的层数设计也很关键。如果只有一层轮子表的长度跟最大延时时间成正比一天延时的任务就得几十万个格子内存扛不住。所以ax调度用了多层时间轮类似时钟的秒针、分针、时针低层轮子管最近几分钟的任务高层轮子管更长周期的任务高层轮子转一圈会把任务降级到低层轮子。我实现的时候参考了Netty和Kafka的时间轮设计做了两个改动一是把层级深度从固定4层改成按最大延时动态扩展二是对每个任务记录round剩余轮数减少不必要的链表迁移操作。这里有一组实测数据可以说明效果单机时间轮实例在100万个每秒触发任务的场景下调度线程CPU占用稳定在15%左右内存占用大约200M完全在可接受范围。2.2 任务队列与优先级防止重要任务排队饿死时间轮解决了“什么时候触发”的问题但触发之后任务去哪执行、按什么顺序执行是另一个问题。ax调度内部维护了一个异步任务队列触发的时间轮线程把任务投递到队列后端的执行线程池从队列里消费任务。核心难点在队列设计如果所有任务共享一个先进先出队列一个耗时长的数据同步任务把队列堵住了后面几十个轻量的日志清理任务就得排队等着影响面很大。ax调度把队列设计成多级优先队列共分五个优先级紧急、高、中、低、后台。业务侧根据任务类型打标比如交易退款类任务标紧急报表生成标中数据清理标低。消费者线程池优先从高优先级队列取任务但是有一个权重系数防止低优先级任务被彻底饿死。具体实现是每个队列设置了取任务比例比如高优先级队列每次最多连续取5个紧急队列最多连续取3个取完之后强制从低优先级队列取1个保证所有任务都有机会执行。这个机制跟交通信号灯里的“全红清空”策略类似宁可让紧急车道少过几辆车也不能让对面车道永远等不到绿灯。队列的数据结构上我没有直接用Redis的List而是采用了分段拉取。每批任务批量从存储层拉到本地内存队列本地消费完再拉下一批。这样做的好处是批量操作减少网络往返坏处是如果任务消费失败已经拉到本地的任务要做重新入队处理。我的经验是本地拉取批次不要大于500条否则执行器宕机导致的任务回滚开销会很大。批次大小可以根据任务的平均执行耗时长来调耗时越短批次可以越大。2.3 分布式协调与防重复调度系统一旦分布化最头疼的就是重复执行。两台调度节点同时持有同一个任务到点都触发业务逻辑被跑两遍。对一般业务重复跑一次问题不大对交易、库存、优惠券这类场景就是事故。ax调度实现防重复采用了三层保护机制。第一层是预发锁。调度节点触发任务之前必须去分布式协调组件注册一个短时锁锁的key是任务唯一ID加本次触发周期锁的有效期设置成任务的超时时间乘以1.2。获得锁的节点才真正派发任务。如果任务执行时间超过了锁的有效期业务执行完毕之后还要主动续锁续锁失败要告警。第二层是幂等令牌。每个触发周期的任务请求都带一个全局唯一token业务执行器把这个token落库做唯一索引重复执行时数据库层面直接拒绝。这个设计本质上假设了任何系统都无法100%防重所以要靠业务侧兜底。第三层是状态机校验。任务实例的状态有pending、running、success、failed、timeout五种只有pending状态允许被置为running分布式环境下用条件更新语句保证状态流转的原子性。我在实际使用中遇到过锁过期导致的重复执行现象就是同一笔订单被关闭了两回第二回返回的错误被业务吞掉了最终用户看到的订单状态是正常的但底层流水多了两条。后来我们规则改成锁最多续三次续不动就进入“疑似重复执行”队列由人工介入确认。分布式环境下防重复问题永远无法百分之百依靠技术解决必须保留人工兜底入口。2.4 调度与执行分离ax调度从架构上把调度器scheduler和执行器executor拆成两个独立的模块中间通过消息队列通信。调度器只负责算时间、拉任务、发指令不做任何实际业务逻辑。执行器只负责执行任务并把执行结果回传。这个分离带来的好处很多。第一是故障隔离。调度器挂了不会影响已经派发出去的任务执行器挂了也不会影响调度器继续接收新任务。第二是独立扩缩容。大促期间执行器可以快速加机器调度器不需要跟着动任务量集中在某个时间段时调度器也不需要增加。第三是执行方式灵活。同一个任务可以配置成在线HTTP调用、RPC调用、本地SpringBean调用甚至是调用云函数、跑容器镜像执行器的适配器只要实现统一接口就行。通信协议上我试过直接HTTP回调也试过用成熟消息队列最终选择了持久化的消息队列做任务下发。原因很现实调度器给执行器发任务时如果执行器刚好在发布重启消息不能丢必须等执行器恢复后再消费。用消息队列我们还能拿到消费位点和堆积量这些可观测指标定位问题方便很多。执行器回传结果则直接用HTTP同步回调因为结果数据量小、频率低实时性要求高HTTP更直观。这个设计的取舍是下行指令需要可靠异步所以走消息队列上行结果需要实时确认所以走同步HTTP。3. 实操部署和配置3.1 环境准备和快速部署ax调度运行环境的依赖分三块至少三台应用节点组成集群一台也能跑但不推荐MySQL用于存储任务元数据和执行记录分布式协调服务用于选主和锁服务消息队列用于任务下发。最小部署情况下可以不用独立的消息队列直接把任务下发接口封装成HTTP轮询模式但生产环境我建议老老实实上消息队列因为可靠性差了两个量级。部署之前需要先初始化数据库。ax源码里带了一个init.sql脚本会创建ax_task、ax_instance、ax_lock、ax_log四个核心表以及一系列索引。我建议建表时关注这几个关键字段next_fire_time是任务下一次触发时间老版本用户经常会忘记给这个字段加索引导致任务量上来后查询变得极慢retry_count用于记录连续失败次数超过阈值会自动进入暂停状态owner_group用于多租户隔离查询历史任务时如果没有它运维人员根本分不清是谁的任务。启动集群前调好两个关键配置node.id每个节点必须唯一它是分布式选主的依据我犯过把两个节点配置成相同id的错结果同时触发任务排查了很久server.port默认是8080多节点部署在同一台物理机时需要区分端口。配置文件建议先用yml格式比较易读生产环境再切换到nacos或者consul做动态配置下发。3.2 核心配置项详解ax调度配置项不少但有五个参数直接决定系统稳定性新手必须理解每个参数的含义再调。调度线程数schedule.threads控制时间轮向前拨动的线程数量。这个参数不宜设置过大我实测过4个线程就已经能支撑百万级触发任务线程多了反而因为锁竞争导致吞吐下降。一般2到4就行。执行线程池executor.core.pool和executor.max.pool控制执行器并发处理能力。major坑在于不要把max.pool调得太大看起来并发了很高实际上任务对下游Redis和数据库的压力会指数上升容易把别人打挂。我们线上执行池核心线程16最大线程32队列容量10000正常情况下利用率不到30%。任务下发批量batch.size控制每批从存储拉取多少条任务到本地队列。业内数据库性能一般的情况下建议300到500超过1000时数据库会频繁发生页分裂反而变慢。超时时间task.timeout.ms要特别留意不要全局设死最好支持按任务单独覆盖。有的执行器冷启动需要加载模型首次执行要30秒全局超时设30秒就把正常任务误杀了。我们的做法是全局默认60秒重任务单独标成300秒。3.3 定义一个任务并观察调度用ax调度注册任务非常简单提供HTTP接口或者用Java SDK都行。以Java SDK为例注册一个每分钟执行一次的订单超时关闭任务代码如下AxTask task AxTask.newBuilder() .taskName(order-timeout-close) .cron(0 */1 * * * ?) .handlerType(AxHandlerType.HTTP) .handlerParam({\url\:\http://order-service:8080/timeout/close\}) .priority(AxPriority.HIGH) .timeoutMs(30000) .retryOnFailure(3) .build(); axClient.register(task);这里handlerType用的HTTP意味着ax执行器到点会对order-service发一个HTTP请求。retryOnFailure的意思是任务失败后自动重试3次每次间隔按指数退避第一次失败后等1分钟第二次等2分钟第三次等4分钟。这个退避策略很重要如果每次都立刻重试下游服务还在恢复期重试大概率再次失败白白消耗系统资源。任务注册后可以在ax控制台看到它每一条执行记录。每个执行实例的完整链路是触发时间、等待派发耗时、派发时间、执行器收到时间、执行开始、执行结束、返回状态。我排查问题第一眼就会看“等待派发耗时”如果这个值特别大说明执行线程池满了或者队列堆积定位方向很清楚。3.4 灰度上线和稳定性参数调度系统是公司的“基础设施”它出了问题全业务线都跟着遭殃。所以ax调度自身的变更必须比业务系统更保守。我们上线新版本时一般先在测试集群验证一整天观察所有调度触发记录跟预期时间差是否在100毫秒以内然后逐步在预发集群放开。预发验证通过后生产环境也不是全量切换而是先挑两个低峰任务组切换跑24小时没有异常再全量。另外有两个稳定性参数我强烈推荐开启。一个是“任务失败熔断”同一个任务连续失败5次自动暂停后续触发防止一个坏的执行器无限重试把下游打垮。另一个是“调度节点自动摘除”节点的健康检查连续3次不通过自动从集群中摘除不再参与选主和任务派发。这两个参数默认关闭但生产环境必须打开它们能避免很多连锁故障。4. 常见问题与排查技巧实录4.1 任务“到点没跑”的三层排查这类问题几乎每个新接入ax调度的团队都会遇到。业务方说任务没跑查数据库看next_fire_time确实过了但任务还在pending状态。我总结了一个三层排查法。第一层查调度器有没有触发。在ax控制台输入任务名看该周期的任务实例有没有生成。没生成说明时间轮没有把任务调度出来重点怀疑任务被误暂停了或者cron表达式算出来的触发时间不对。第二层查任务有没有派发出去。任务实例有了但一直pending看调度日志里派发指令是否发送成功。这个环节最容易出错的是消息队列没建对topic执行器消费不到。第三层查执行器有没有收到。如果派发成功但执行器一直没开始执行看执行器的日志和线程池状态大概率是线程池满了或者执行器服务没有正常启动。我把排查思路整理成了一张速查表贴在我们内部wiki上现象排查点常用命令或位置任务实例未生成触发规则是否匹配控制台查看最近触发时间任务实例pending未派发消息队列topic、节点锁看调度日志中send消息是否报错已派发但未执行执行器线程池、网络看执行器日志中有没有消费记录执行报错返回失败业务逻辑、超时参数查看失败堆栈和retryCount4.2 重复执行问题我前面提过ax调度有三层防重复机制但使用方不注意一些细节还是会出现重复执行。最常见的坑是业务执行器把“请求超时”和“执行失败”混为一谈。比如调用下游接口下游其实已经处理成功了但响应超时执行器这边报了失败框架自动重试下游就收到了两遍请求。这种场景业务方必须做幂等不能指望调度平台帮你判断业务有没有成功。另一个常见情况是手动重放在排查时被误操作一个失败任务被人为点了“重新触发”然后又因为时间轮的正常调度又触发了一次。ax控制台后来加了二次确认弹窗要求填写触发原因防止误点算是从交互层面做了防御。给业务团队的血泪建议就一条所有通过ax调度的任务执行器第一行代码先做幂等检查无论是数据库唯一索引还是Redis setnx必须有。4.3 任务堆积与背压控制大促或数据高峰期业务下游变慢执行器处理能力跟不上队列堆积会越来越大。ax调度有两个应对手段。第一个是背压控制执行器端如果线程池和队列都满了消费端会自动暂停拉取新任务让它“消化”一会儿再继续。第二个是任务丢弃策略这个要特别提防一开始我们在消费端配置了丢弃最旧任务策略以为丢几个延迟任务没关系结果把订单取消的任务丢了用户取消订单第二天发现还是待支付状态投诉炸了。后来改成丢弃策略只允许在后台型任务上使用交易类任务严禁开启。监测堆积有一个指标曲线可以看执行器启动后队列长度变化的斜率。如果斜率一直是正的说明消费速度跟不上生产速度。这时不是盲目加执行器线程而是要往下游找原因是下游数据库慢了还是接口大流量打满对症下药。4.4 时间不准与时钟偏移调度系统对时间敏感服务器时钟偏移会直接导致任务不按时执行或者重复执行。ax调度在集群模式下会周期性检查各节点之间的时钟差超过500毫秒就在控制台打红色告警。我的经验是务必在所有调度节点上配置NTP时间同步但即使有了NTP偶尔还是会偏差因为虚拟机时钟漂移有随机性。更稳妥的做法是执行快照校准。ax调度支持从配置中心获取一个基准时间戳每个节点启动时对比本地时间和基准时间的差值统一以基准时间作为调度时钟。不过这个方案前提是配置中心本身时间要准。有一次我们配置中心部署的机器NTP失效结果所有节点都跟着一个偏了十秒的基准时间走调度任务全部晚十秒触发业务方还以为是网络延迟。后来我在基准时间服务前面又加了一层系统时钟校验偏差太大直接拒绝提供服务。5. 压力测试与调优建议5.1 我实测过的调度容量数据ax调度上线前我们做了一轮完整的压力测试把几个关键数据记录在这里给大家一个参考范围。测试环境是3台8核16G的应用节点1台4核8G的数据库实例消息队列用的标准版。模拟任务总量500万个分布在一小时周期内触发其中高峰期每分钟触发峰值20万个任务。调度器这边的数据是每分钟能够稳定触发18万个任务再往上推就会在消息队列侧出现积压积压量可以在30秒内追平。执行器节点的单机消费能力大约是每秒2200个HTTP任务耗时按平均200毫秒算。CPU方面调度节点高峰期CPU占用在60%左右执行节点在70%到80%之间内存占用稳定。所以按这个比例如果业务峰值达到了每分钟40万触发量我建议至少准备6台调度节点、10台执行节点同时把消息队列的topic分区数扩到12个以上避免单分区写入瓶颈。5.2 内存与线程参数调整JVM参数在部署时不要用默认值ax调度在时间轮任务量大的场景下堆内存默认值不够容易频繁Full GC。我用的参数是java -Xms4g -Xmx4g -XX:UseG1GC -XX:MaxGCPauseMillis200时间轮存储的任务量大对象生命周期短G1回收器比CMS稳定得多。线程池参数的调整原则我前面说过不要贪大。有一个容易忽视的点是执行器线程池的拒绝策略默认要设置成CallerRunsPolicy意思是线程池满了新任务不丢弃而是在提交任务的那个线程里执行。虽然这样会拖慢调度线程但至少任务不会丢。还有一个参数是缓存任务元数据的本地内存大小。执行器启动时需要将任务定义加载到本地缓存任务数量上千万后缓存内存占用很大。可以通过配置关掉不常用任务的本地缓存按需从数据库加载代价是首次执行会多一次查询但能省大量内存。我们线上开了这个策略后每台执行器内存占用降低了将近800M。6. 写在最后做过调度系统之后我最大的体会是这类东西难的不是写算法而是处理各种异常场景。时间轮逻辑再精巧分布式锁再严密也挡不住业务方传错参数、下游服务超时、服务器半夜宕机。ax调度上线两年多真正的核心竞争力是我们把可观测性做得足够细任务从注册、触发、派发到执行结果的全链路都有日志和指标每次出问题都能半小时内定位到根因。最后再分享一个小技巧强烈建议在项目里配一个“任务体检”的定时巡检脚本每天凌晨自动统计当天所有调度任务的失败率、重试率、平均执行耗时生成一份报告发到技术群。如果失败率比前一天高出哪怕一个百分点就要拉响警报。这个习惯帮我们在很多潜在事故恶化之前就发现苗头比加了再多的框架保护都有用。