微服务场景下的轻量级分布式任务调度:自研ax调度全解析
从“ax调度”这四个字入手我猜大多数人第一反应是又一个定时任务的轮子说实话我第一次在团队内部听到“ax”这个代号时也以为是某个前端请求库的变种。直到看了设计文档才明白这是一个完全自研的轻量级分布式任务调度组件专门解决微服务化之后“定时任务不知道跑到哪台机器上、跑没跑、跑挂了几台”这些破事。如果你也被Quartz集群的数据库锁折磨过或者觉得引入一套重量级调度平台太重那这篇分享应该能给你一些直接的参考。1. ax调度是什么先搞清楚它解决的痛点1.1 单体时代的定时任务为什么到微服务就崩了先聊聊背景。很多团队的定时任务演进路径是这样的早期单体应用一个Spring Boot服务里用Scheduled注解或者接一个Quartz把每天凌晨跑报表、每小时同步数据这些活儿排在本地。单机时代这套东西挺好用代码简单、部署粗暴、出问题重启就行。但服务拆分成多个实例之后第一个坑就来了三台实例部署同一份代码Scheduled的定时任务会在三台机器上同时执行。如果任务是幂等的写操作还好顶多多跑几次浪费资源要是做订单状态流转、发消息、扣库存这一类操作重复执行就是线上事故。业界常规解法无非三条路用Quartz的集群模式靠数据库行级锁抢任务执行权引入XXL-Job、Elastic-Job这类成熟的分布式调度平台自己写一个调度组件ax调度的出现恰好是第三条路的产物。团队当时的诉求非常明确不想为了一个“固定时间跑一下”的需求引入一套需要单独部署控制台、还要维护注册中心的重型框架。ax最终被设计成一个内嵌在业务服务里的轻量调度内核加上一个极简的可视化控制面调度数据放MySQL执行逻辑全部落在业务服务内不依赖额外的中间件。1.2 ax和Quartz、XXL-Job的本质区别很多人一听到“自研调度器”就问Quartz它不香吗香但得分场景。Quartz的核心优势是纯Java、无外部依赖、单机能力极强但它的集群模式存在两个不省心的地方。第一Quartz集群是靠数据库行锁来实现任务抢占的每个调度周期所有节点都要去抢锁节点多了之后数据库压力陡增而且调度延迟会随着实例数量上升而劣化。我们曾经压过一个12节点的Quartz集群每秒钟触发几千个任务时数据库的锁等待和死锁日志直接把运维同学搞崩溃。第二Quartz的任务执行状态只有“运行中”和“结束”它并不关心你业务里那个任务到底是成功还是失败了。补偿、重试、告警这一套都得自己在外面包一层包来包去代码就脏了。xxl-job这类平台则走了另一个极端。它们把调度和执行分离调度中心单独部署执行器回执注册功能确实全但引入的成本也不小多一个必须保证高可用的调度中心、一套权限体系、一个运维要盯的后台。对很多内部系统来说这属于“为了喝牛奶养了头牛”。ax的设计哲学是把调度器做成一个库而不是一个系统。任务定义、触发计算、执行分发三个核心逻辑封装成SDK嵌入业务服务管理端只需要一个能看任务列表、看执行日志、手动触发和暂停的轻量页面。调度数据和执行记录沉淀在MySQL整体架构简单到可以用一张图说清业务服务内嵌SDK负责跑任务MySQL存储任务元信息和执行轨迹控制台负责展示和操作。1.3 ax适合谁用如果你的业务符合下面几条ax这个设计思路值得参考团队已经有稳定的MySQL运维能力但不想再引入Redis、ZK、etcd这类额外组件只为做调度任务量级是“百级到万级”单台机器每秒触发几十上百个任务而不是像互联网大厂那样每秒调度几十万次业务服务本身已经是多实例部署需要防止任务重复执行但不希望引入过于复杂的协调机制团队对Quartz的数据库锁方案不满受够了“任务积压、节点抢锁失衡”这类问题反过来说如果你要支撑的调度规模极大、需要秒级甚至毫秒级的实时触发、要跨机房容灾那还是老老实实上专业调度平台吧。ax解决的是“微服务化之后定时任务不乱套”这个中等级别的问题它不追求极致性能追求的是可控、简洁、不添乱。2. 整体架构与核心设计思路拆解2.1 四个核心角色各管一摊ax调度的整体结构可以拆成四个角色角色职责部署形态调度内核解析触发器、计算下一次执行时间、生成调度指令内嵌在业务服务JVM中执行器真正执行业务逻辑上报执行结果业务服务本身存储层保存任务定义、执行记录、锁状态MySQL需额外建库表控制台查看、管理、手动触发任务独立的Web应用可选部署关键点在于调度内核和执行器跑在同一个进程里。这和xxl-job“调度中心统一调度、执行器节点执行”的模型完全不同。ax选择这种设计基于一个非常现实的考量对于大多数内部业务系统任务的执行逻辑强依赖业务服务里的Spring上下文、数据库连接池、配置中心数据如果把执行器拆出去每次任务请求都要走一次网络调用凭白引入序列化和网络开销还增加了定位问题的复杂度。调度内核和执行器同进程意味着调度器发现自己所在的服务实例负载过高时可以直接获取JVM的CPU、内存指标结合注册到存储层的心跳信息来做任务分配决策这个我们后面细说。2.2 任务注册与心跳机制没有注册中心怎么发现节点ax没有引入独立的注册中心节点发现完全靠MySQL的一张心跳表实现。每个服务实例启动时会向ax_node表插入一条节点记录包含实例IP、端口、应用名、启动时间、所在机房等信息然后每隔5秒更新一次心跳时间戳。调度内核在分发任务前会查询ax_node表过滤掉心跳超时默认15秒的实例拿到当前“存活”的节点列表。这个方案的优缺点都很明显优点架构极简不需要额外维护ZK或etcd集群MySQL本身就是团队绕不开的组件缺点节点数量超过几百个时频繁读写心跳表的性能压力会显现且节点状态的最长感知延迟约等于心跳间隔加超时阈值极端情况下任务会被分发到刚宕机还没来得及超时的节点为了解决后一个问题ax在分发任务前加了一道预检调度内核把任务推给目标节点前先快速检查该节点最近一次心跳时间若距离当前超过8秒就立即换一个节点。这套“心跳超时分发前预检”的双保险把任务发给死节点的概率降到很低。心跳表结构设计上有一个容易被忽略的细节必须给app_name和ip建联合唯一索引防止同一条机器重复注册心跳时间戳的更新要放在独立的事务里不能和业务逻辑混在一起避免长事务阻塞心跳更新导致误判节点宕机。2.3 触发器模型为什么不能只支持Cron表达式很多调度框架的触发器就是cron表达式ax一开始也是这样设计的。但实际用下来发现cron能表达的场景其实很有限或者说用cron表达某些场景非常别扭。举几个真实例子“每个工作日早上10点跑”这个需求cron是0 0 10 ? * MON-FRI勉强能写“每月最后一个周五凌晨2点跑全量对账”这个需求cron写不出来需要额外判断当月最后一个周五是哪一天“每天随机一个时段跑数据预热”这个需求cron完全无能为力所以ax的触发器拆成了三层基础Cron触发器、日历触发器、自定义触发器接口。基础Cron触发器覆盖固定周期场景底层用自已实现的cron解析器支持标准7段式和6段式带秒。日历触发器在cron基础上叠加排除日期比如“工作日执行但元旦、春节假期不执行”日历规则支持按年和按月的节假日导入。自定义触发器接口则暴露一个nextValidTimeAfter(time)方法让业务方实现任意复杂的触发逻辑比如“每月最后一个周五”“每周一且非月末最后一天”这种。这个设计给了调度内核一个统一的抽象调度器不关心任务怎么定义触发时间它只需要拿到一个触发器对象然后反复调用nextValidTimeAfter拿下一个执行时间点。这样扩展触发器类型完全不需要改动调度内核符合开闭原则也方便测试。2.4 时间轮与任务扫描触发引擎的实现思路ax的调度内核没有用Quartz那种“每秒钟扫一次数据库、把到期的任务捞出来”的方式。每秒全表扫描的任务执行表在任务量过千之后会产生大量无效查询而且数据库的IO抖动会直接影响调度准时性。ax实现了一个基于时间轮的触发引擎。简单解释一下时间轮把时间划分为一个个槽位每个槽位代表一个时间单位ax的默认刻度是1秒槽位内挂着“到这个时刻需要触发的任务ID列表”。调度内核启动时会从数据库加载未来5分钟内的所有触发计划放入时间轮对应槽位每个调度周期推进一个刻度把当前槽位的任务全部取出进入分发流程。用时间轮带来的直接好处是调度触发不再依赖数据库的实时查询数据库只在启动时加载近期计划和每次回调后更新下一个触发时间。任务数量在几千级别时时间轮完全不构成内存压力而调度准时性从“依赖数据库查询速度”变成了“依赖JVM内部数据结构遍历速度”天然高一个量级。这里有个细节需要处理好时间轮刻度是1秒但cron表达式有可能定义到秒级精确触发。ax的做法是让时间轮槽位支持“二级轮”第一级轮刻度1秒转一圈第二级轮刻度60秒转一圈类似时钟的秒针和分针。一级轮里挂的是一分钟内到期的任务二级轮挂的是未来一小时到期的任务当二级轮的某个槽位转到期时就把槽位里的任务“降级”到一级轮对应秒位。3. 核心实现环节从触发到执行的全链路3.1 任务分发的三种策略触发引擎取出到期任务后需要决定“派给哪个节点执行”。ax提供了三种分发策略一致性哈希分发。根据任务ID哈希到某个节点上执行保证同一个任务每次都由同一台节点执行。这适合有状态的任务比如任务内部依赖本机的缓存或本地文件。缺点是可能造成节点间负载不均。轮询分发。任务按顺序轮流分配给存活节点适合无状态、轻量、高频的任务。实现简单均衡性尚可但当任务执行耗时长且不均匀时轮询会造成某些节点积压。最少任务数分发。每个节点在心跳上报时会携带自己当前排队中的任务数量调度内核把新任务分给排队数最少的节点。这是ax默认的策略它虽然依赖心跳上报的实时性5秒间隔会有延迟但实测在大多数场景下均衡效果最好。分发策略在任务维度可配置每个任务可以在创建时指定dispatchStrategy字段。对于那种执行时间很长的批处理任务我强烈建议用一致性哈希否则任务在多个节点间跳来跳去每次都要重新加载数据白白浪费IO。3.2 执行器回调与任务状态机任务被分发到某节点后执行器接收调度指令包装成一次“任务执行实例”在工作线程池里执行。ax定义了明确的任务状态机待触发 - 已触发 - 执行中 - 成功 / 失败 / 超时 | - 已分派(未确认) - 重试中 - ...执行器拿到任务后先返回一个RECEIVED确认包调度内核收到确认后把任务状态从“已分派”更新为“执行中”。这一步确认机制很重要如果执行器收到任务后立刻执行但节点在处理过程中宕机调度内核会误以为任务从未送达而重新分发造成重复执行。有了确认机制至少能区分“没送达”和“送达了但没跑完”两种失败前者可以立即重试后者要交给业务层的幂等逻辑去处理。执行结束执行器通过HTTP回调或MQ消息把结果传给调度内核。回调消息包含执行实例ID、任务ID、状态、耗时、错误信息。调度内核更新任务实例状态并写入一条执行日志。关于“有没有必要用MQ传回调”我的经验是内部系统、执行频率不高时直接HTTP回调完全够用还少一套MQ依赖但如果你已经用了MQ且不想让回调失败导致状态丢失走MQ确实更稳。ax把这层抽象成了ResultReporter接口默认实现是HTTP也预留了MQ扩展。3.3 任务防重各种重复执行场景的兜底分布式调度最难搞的从来不是“让任务跑起来”而是“让任务只跑一次”。ax从三个层面做了防重设计。第一层调度前检查。时间轮触发任务前会查询任务实例表里是否存在“执行中”状态的记录用任务ID执行时间做联合条件存在则跳过本次触发。这一层防的是“调度内核认为任务还没跑完又到了下一个触发周期”的情况典型场景是任务执行耗时超过触发间隔。第二层分发的对账。调度内核把任务分发给节点A后如果长时间没收到确认包默认30秒超时会重新分发给节点B。但这个重发有可能造成A和B同时执行同一个任务实例。为此ax在任务实例表里设计了一个owner_node字段和乐观锁执行器执行前先尝试用UPDATE ax_instance SET status执行中, owner_node? WHERE id? AND status已分派抢占归属权影响行数为0说明别人已经抢到自己主动放弃。这个方案比分布式锁轻量得多也规避了锁超时带来的各种边界问题。第三层业务侧幂等兜底。ax在文档中反复强调调度框架能保证“尽量不重复”但业务侧必须有幂等设计。比如对账任务可以通过“业务日期机构号”唯一键去重同步任务可以通过版本号字段做乐观锁。这不是ax的缺陷而是分布式系统的常识——任何调度框架都不能为业务逻辑的幂等性负责。顺便说一句很多人在这个环节会纠结“要不要引入Redis分布式锁”。ax的答案是不需要。原因很简单任务的抢归属更新已经有数据库行级锁兜底加Redis锁属于重复建设还引入了新的故障点。Redis锁在“多系统共享同一个任务的执行权”这种跨系统场景下才真正有价值同一套系统内的多实例竞争数据库乐观锁足够。3.4 失败重试与告警失败重试的策略可以参考下图套路这里用文字描述任务执行失败后重试次数、重试间隔倍率、是否允许失败都做成任务配置项。ax的默认策略是重试2次第一次与失败相隔1分钟第二次与第一次相隔5分钟重试间隔按照指数退避增长。这里有一个容易踩坑的地方重试不能交给调度内核直接触发否则任务会再次进入分发流程可能被分到不同节点。ax的做法是重试指令直接发给原执行节点优先考虑在产生失败的节点上重跑因为业务的本地状态比如数据库连接缓存、文件句柄可能还留着。告警方面ax在任务维度配置了告警阈值和接收人触发失败或连续重试失败后调度内核会发送告警。告警通道抽象成Notifier接口默认实现对接了邮件和钉钉机器人。为了让告警真的有用而不是变成“狼来了”ax刻意做了一条规则同一任务在10分钟内的告警最多发1条聚合之后的信息里带上失败次数和最近一次错误堆栈摘要。4. 实操过程快速接入一个ax调度任务4.1 初始化数据库和后端服务ax的部署成本很低核心就两步。第一步建库表总共四张任务定义表ax_task、任务实例表ax_instance、节点表ax_node、执行日志表ax_log。初期没有专门抄xxl-job那种十几张表的复杂设计这四张表足够覆盖核心场景。建表时有一个为了查询性能必须注意的点ax_instance表要按天做分区因为执行实例数据增长很快一张单表扛不住半年数据每天几万次执行的话半年就是几百万行。另外ax_instance表要建联合索引(task_id, trigger_time)和(status, create_time)前者是防重复查询的主路径后者是控制台任务列表的分页查询路径。控制台服务用的是Spring Boot Thymeleaf包体很小。但需要注意控制台和业务服务里内嵌的调度内核连接的是同一个数据库所以数据库账号要区分权限控制台用读写账号业务服务内嵌SDK建议用单独的账号并限制只有任务表和实例表的DML权限防止调度SDK被业务方误操作牵连。4.2 业务服务接入SDK的完整流程以一个订单同步任务为例走一遍完整接入流程。第一步引入ax SDK依赖在Spring配置里指定数据库连接信息、应用名、执行器线程池大小。第二步编写任务处理器类实现AxJobHandler接口Component public class OrderSyncJob implements AxJobHandler { Override public AxResult execute(JobContext context) { String bizDate context.getParam(bizDate); // 业务逻辑同步某一天的订单数据 int count orderSyncService.syncByDate(bizDate); return AxResult.success(同步完成处理订单数 count); } }第三步在控制台创建任务填任务名、选择OrderSyncJob处理器、配cron表达式、选分发策略和执行超时时间。创建完成后任务就会自动注册到调度内核无需重启服务。我特别提醒一下任务处理器的类名必须全局唯一。因为调度指令通过HTTP回调传给执行器时携带的是处理器类名字符串执行器靠Spring容器的getBean按名字找到那个处理器。不同服务里如果出现同名处理器类任务可能被错误路由。我在接入初期就踩过这个坑排查半天才发现是另一个服务里有个同名的DataCleanJob。参数传递也是一个容易忽略的细节。ax的任务参数不是“创建时固定死”的而是支持在每次触发时动态生成。任务配置里可以写一个参数提供者比如同步任务需要根据“当前日期前推一天”动态计算业务日期就实现ParamProvider接口在任务触发时动态生成参数并绑定到本次执行实例上。这样比静态维护任务参数聪明得多省去每天手工改参数的运维操作。4.3 关键参数与调优建议ax的调度内核有几个核心参数配置得好不好直接决定调度稳定性参数默认值说明与建议心跳间隔5秒节点调度数量多时可适当调大到10秒减轻数据库压力心跳超时阈值15秒必须大于心跳间隔的2倍防止网络抖动导致误判任务确认超时30秒执行器收到任务后必须在此时间内返回确认包执行超时按任务配置单任务设置建议为预估耗时的1.5倍过长会导致调度资源被占用工作线程池大小CPU核数 * 2任务堵塞的标志是线程池满关注这个指标比关注任务数量更直接时间轮预加载窗口5分钟预加载未来5分钟的触发计划频繁修改cron的任务可调大线程池大小的设置有个容易误判的地方线程池大小不代表“最多同时执行多少个任务”而是“最多同时跑多少个任务实例”。如果你的任务都是IO密集型的调外部接口、读写数据库线程池可以开大一些因为线程大部分时间在等待IO如果是CPU密集型的计算任务线程池大小接近CPU核数就够了开再大也只是增加上下文切换开销。5. 常见问题与排查技巧实录5.1 任务到点没执行控制台显示“待触发”这个问题排查时先分两层。第一层看调度内核有没有把任务捞出来。进入时间轮后的任务会在内存日志里打一条trigger_ready记录如果没有这条记录说明cron解析或时间轮加载有问题。最常见的场景是控制台上改了cron表达式但业务服务里的调度内核没有热更新到导致它还在用旧的触发计划。ax的处理方式是调度内核每30秒轮询一次任务定义表的变更但如果你改完cron后想立刻生效需要在控制台手动触发一次“重载任务计划”。第二层看任务是不是被“分发前预检”挡住了。如果任务分发给了一个刚宕机还没被心跳超时剔除的节点分发会失败任务状态退回“待触发”。此时控制台往往没有任何报错只在日志里能看到dispatch_retry阶段的记录。解决方法是调整预检的时间窗口或者加强节点的心跳上报频率。5.2 任务重复执行的真正原因历史经验告诉我90%的“重复执行”不是调度器故障而是业务侧幂等没做好。但调度器侧确实存在一种会引发重复的边界执行器执行完任务后HTTP回调超时了调度内核没收到结果判定任务失败并触发重试于是同一业务逻辑被再次执行。解决这个问题的思路不是让调度器“猜”任务是否执行完而是让回调更可靠。ax的优化方案是执行器在回调失败时不是立即丢弃结果而是把结果写入本地的ax_result_pending表由后台进程定时重推最多保留48小时。这个方案本质上和“本地消息表”的思路一模一样简单直接可靠性和引入MQ相当但少了一套中间件。5.3 执行器频繁掉线节点列表忽多忽少节点心跳频繁失败的根因十有八九是心跳更新和业务长事务竞争同一个数据库连接。有些业务服务的事务会把连接持有几十秒心跳更新排队等待超时阈值一过就被判为掉线。排查方法很简单监控ax_node表的更新时间和业务服务数据库连接池的活跃连接数两条曲线对比着看。如果是这个原因解决办法是把心跳更新放到一个独立的数据库连接池池大小设为2~3并设置maxLifetime和connectionTimeout参数确保心跳更新不受业务连接池阻塞。切换独立连接池之后这类问题基本绝迹。另一个容易被忽视的原因是数据库的max_allowed_packet太小。ax的心跳包虽然只有几百字节但某些服务在自定义心跳扩展字段里塞了大量上下文信息比如机房、版本号、权重配置包体超过限制后MySQL直接断开连接造成心跳写入失败。5.4 任务积压触发速度远大于消费速度任务积压的表现是控制台显示任务状态卡在“已分派”执行器侧线程池满新任务排队等待队列越来越长。压垮线程池的通常是某几个“慢任务”占满了线程。排查时先按任务ID聚合查看平均执行时长找到耗时最长的Top N任务。对慢任务的处理是限流ax提供了任务维度的并行度配置比如concurrency2表示该任务最多同时2个实例在跑超过的触发计划自动顺延到下一个调度周期。给慢任务加上并行度限制后线程池的积压情况几乎立刻缓解。还有一个让人容易忽略的元凶执行器的工作线程池用的是无界队列慢任务不断塞入队列占满内存JVM频繁Full GCGC停顿导致心跳上报超时节点被误判掉线掉线后任务被派到其他节点其他节点也积压……最终雪崩。所以务必将工作线程池的队列设置为有界队列ax SDK默认128拒绝策略选择“阻塞并等待”这样至少能把压力控制在任务排队层而不会拖垮整个节点的JVM。写在最后的一点体会ax调度的整个迭代过程让我对“分布式系统里最不该做的事”有了明确认知不必要的复杂度才是最大的坑。很多人一谈分布式调度就想到ZK、etcd、消息队列、分布式锁一套组合拳但这些组件本身需要运维成本、需要处理组件的故障和一致性对中小规模业务来说往往是负担大于收益。我自己最大的收获是调度系统的核心不在于触发引擎多快、调度算法多炫而在于把“任务到底执行了没有、执行结果是什么、失败了怎么办”这三件事讲清楚。ax用最简单的MySQL存储解决了这三个问题任务元数据落库、执行状态落库、执行日志落库三个表搞定任何问题翻开数据库都能查到轨迹定位和排查的体验远好于那些状态分散在Redis和各节点内存里的复杂方案。如果你也在规划自己的调度组件建议先别急着抄复杂架构把“任务生命周期状态机”吃透把“防重复”的多层兜底想清楚再考虑性能优化。调度框架这种东西稳定可靠永远排在第一位跑得快不如跑得准。