
去年年底我们内部开始流传一个新词——ax调度。起初大家都以为是什么黑话后来才发现这是我们把老任务调度系统推倒重写后的代号。名字很随意Async 的 a未知数的 x。异步执行里的那个“x”恰恰是整套调度最难搞的地方——任务什么时候该跑、跑在哪台机器上、失败了该重试还是放掉、高峰来了是排队还是拒绝。ax调度要解决的就是这些悬而未决的问题。如果你也在跟定时任务较劲被消息堆积、批处理毛刺、接口超时拖垮过那这篇文章值得看完。我会把 ax 调度从设计思路、核心队列、状态机到线程池参数、踩坑实录全部分享出来基本是一份可直接抄作业的异步任务调度参考方案。1. ax调度到底要解决什么问题1.1 传统调度哪里不爽我们以前用的是老一套一个定时触发器每隔几分钟扫一次任务表把到期任务捞出来丢给线程池执行。听起来没什么问题真正跑起来全是坑。最典型的是凌晨跑批场景上游把文件推送到我们这边本来约定凌晨 1 点准备完毕结果对方有一次延迟了 20 分钟。我们的定时任务 1 点整准时启动扫不到数据只能空跑日志里留下一堆“本次任务未找到文件”的记录。更麻烦的是任务和任务之间还有依赖关系——A 任务要等 B 任务跑完才能开始但老系统压根不知道这层关系全靠程序员在代码里手动 sleep(30) 去等非常脆弱。还有高峰期的问题。促销活动瞬间涌入大量异步任务比如需要给一批用户批量推送消息、批量更新库存。线程池的队列一旦满了后面的任务直接拒绝用户端立刻感知到通知延迟。我们当时只能临时手动扩容线程池等高峰期过了再缩回去一天要折腾好几回。传统调度的核心毛病在于把“执行”和“调度”搅在了一起。任务该什么时候跑是定时器判断的任务跑起来之后能不能超时、能不能重试是业务代码里自己写死的任务之间有没有依赖靠业务开发互相约定。整个系统看起来每个模块都在干活但没人真正掌控全局。1.2 ax的核心理念把“执行”和“调度”拆开ax调度重构时的第一性原理就一句话调度器负责状态流转执行器负责业务逻辑两者彻底解耦。打个比方调度器像指挥中心只管告诉快递员“你现在去接哪一单、什么时候去、如果客户不在家要不要再试一次”。快递员自己不用操心路线的全局规划也不用自己去跟其他快递员协调谁先送谁后送。业务代码里不再出现“轮询等待另一个任务完成”这种逻辑所有依赖、重试、超时、优先级全部交给调度层统一做。这个设计带来的直接好处是执行器变得非常轻薄。我们的执行节点只做三件事从调度中心拿任务、干业务活、回传结果。业务团队新增一个任务只需要实现一个接口不需要关心任务是怎么被调起来的也不需要关心底层的并发模型。调度层则慢慢积累出统一的降级策略、限流策略、重试策略再也不用每个业务方各写一套。这么做之后我们终于敢说系统具备“调度能力”而不是“定时功能”了。所谓调度核心不是“到点触发”而是对任务全生命周期的控制什么时候能触发、触发到哪一台机器、最多等多久、失败了几次之后该放弃、放弃之后要不要进人工补偿队列。这些都成了 ax 调度默认提供的盘子级能力。2. 整体设计与核心数据结构2.1 任务状态机先定规矩再写代码所有调度系统最先要确定的不是用什么中间件而是任务的状态怎么流转。状态设计得乱后期各种问题都会顺着缝冒出来。ax调度最初定义了几个状态READY等待执行、RUNNING执行中、SUCCESS成功、FAILED失败、TIMEOUT超时、CANCELLED取消。后来又加了一个 SUSPEND挂起状态给任务依赖用的。A 任务依赖的 B 任务还没有成功A 就会被挂起等 B 成功之后再恢复成 READY。每个状态有一个统一的转移表格比如 READY 可以转 RUNNING也可以转 CANCELLEDRUNNING 可以转 SUCCESS、FAILED 或者 TIMEOUTTIMEOUT 之后还可以转 RETRYING然后再回到 READY。不允许随意从 SUCCESS 转回 RUNNING——已经成功的任务如果要重跑只能通过显式的新建任务实现避免状态机出现循环和歧义。这个状态机给整个系统带来了清晰的审计逻辑每个任务什么时候被创建、谁把它捞起来、跑了多久、为什么失败全部记录在状态变更历史表里。线上出了问题可以直接按任务 ID 拉出它的完整生命周期不用靠瞎猜。代码设计上我们用 Java 写了一个枚举类每个状态都绑定一组允许的转移动作在入口处统一校验。后来所有状态流转都走scheduleService.transfer(taskId, fromState, toState, reason)这一个方法里面先查数据库确认当前状态等于 fromState再更新成 toState。这个乐观锁式的校验避免了并发环境下的状态错乱。2.2 双重存储数据库是底账Redis 是加速器任务元数据肯定要落在数据库里不然 Redis 一重启全没了。我们用 MySQL 存储任务表和状态变更历史表任务表只存当前状态历史表存每一次流转。但是每次调度都去扫数据库状态字段压力太大。所以 ax 调度引入了 Redis 加速层用一个 zset 存放“到期任务索引”score 存任务下一次可执行的时间戳member 存任务 ID用一个 hash 存放任务当前状态和机机器归属信息。调度循环每 500 毫秒从 zset 里拉一批 score 小于当前时间的任务 ID交给执行器去消费。这里有个容易踩的坑如果只把任务 ID 放 Redis突然消费失败可能导致任务丢失。所以 Redis 里只放“该看一下了”的信号真正要不要执行执行时状态是不是还是 READY必须在消费之前回查数据库确认。换句话说Redis 只是提前把待处理任务暴露出来最终裁定权始终在数据库。我们把这个设计叫做“Redis 建议数据库决定”算是 ax 调度最重要的容错底线之一。任务表的字段也不复杂但有几个很关键task_id全局唯一使用雪花算法生成排序时天然按时间有序。task_type区分不同的业务类型每种类型可以单独配置并发上限、超时时间、重试次数。next_run_time下次可执行时间这是延迟队列的核心字段。status当前状态配合乐观锁使用。retry_count已重试次数。max_retry_count最大重试次数。executor_addr当前执行节点的地址。last_heartbeat_time执行节点上报心跳的时间。2.3 分片消费任务多的时候怎么把机器用起来任务量小的时候单节点轮询 Redis 就够用了。但是到了大促场景每秒需要消费几千上万个任务单节点就成了瓶颈。ax 调度采用“分片消费 一致性哈希”的模型。先把任务 ID 通过哈希映射到固定数量的分片比如 64 个分片。每个执行节点启动时把自己注册到注册中心并声明愿意负责哪些分片。调度器把任务分派给节点时根据任务 ID 所在分片找到对应节点。当节点数量变化时只需要重新分配分片不影响整体架构。我们早期用的是简单取模后来发现节点数量一变大量任务要从一台机器迁移到另一台瞬间冲击数据库。换成一致性哈希之后迁移的任务量降到 1/N 左右平稳很多。不过一致性哈希本身也有冷热不均的问题哈希环上某些节点可能分到特别多的分片。我们的做法是引入虚拟节点每个物理节点在哈希环上放 100 个虚拟节点这样分摊下来相对均匀。另外任务类型和分片是互相独立的所以一个执行节点其实可以同时负责多种任务类型的分片只不过每种任务类型都有独立的并发限制。3. 核心调度逻辑与实操实现3.1 任务提交与优先级队列任务提交不是简单往数据库插一条记录它要决定任务什么时候“可见”。比如用户触发了一个秒杀即时通知任务希望 10 分钟后还没付款就提醒一次。提交时直接设置next_run_time now 600s任务在 Redis zset 里的 score 也相应的放到 600 秒之后。这样调度器在 10 分钟内根本看不到这个任务效率很高。另一个头疼的问题是优先级。比如退款回调任务和用户通知任务同时堆积肯定要先处理退款回调因为那里有超时风险会影响资金状态。如果我们只用一张任务表扫数据的时候很难同时按时间排序和按优先级跳级。ax 的做法是提供多个分级的“快速通道”每个优先级一个独立 zset调度循环按优先级顺序去各 zset 取任务。普通任务优先级是 5退款回调任务设为 1那么同样到期的情况下1 级任务会被优先取出。快速通道的数量不要太多两级到三级就够。我们一开始设计了八级后来发现绝大多数业务场景只需要“高、普通、低”三档级别太多反而容易让团队纠结而且调度的轮询逻辑也要反复判断增加复杂度。提交过程还有一个细节同一类型的任务可以做去重合并。比如批量用户通知任务如果同一个用户已经被其他任务覆盖了同样的内容直接跳过避免重复推送。去重字段是业务方提交时传的biz_key任务表加一个唯一索引插入时捕获重复键异常。这个设计后来帮我们挡掉了不少重复支付回调的重复处理问题。3.2 分布式锁与抢占执行任务从 Redis 中捞出来之后还要确保同一时间只有一个节点执行。如果两个节点同时从 zset 里拿到同一个任务 ID就会造成重复执行。最简单的方案是用 Redis 的SET taskId executorAddr NX EX 30做分布式锁。拿到锁的节点再去查数据库状态如果状态已经变成 RUNNING说明有别的节点抢先了直接放弃并返回。锁的过期时间必须大于任务运行时间上限不然任务还没跑完锁就过期了其他节点就会立刻抢走并二次执行。但是任务不一定每次都能在 30 秒内结束怎么办我们搞了个续约机制执行器持有锁期间每 5 秒延长一次过期时间相当于一个 watchdog。如果节点宕机watchdog 停止续约锁自然过期任务就能被其他节点重新接管。这也就是为什么每个执行节点都要定期发心跳调度器通过心跳判断节点是否存活将死节点的任务重新回收。任务被抢占之后执行器会向调度中心汇报心跳心跳里包含当前正在执行的任务 ID。如果调度器超过一定时间没有收到该任务对应的执行器心跳就会把任务状态从 RUNNING 强制转回 READY然后重新投递到 Redis 队列。这个机制保证了极端情况下任务最多被重复执行一次而不是无限堆僵尸任务。3.3 超时控制与重试退避算法任务执行超时是最常见的问题。外部接口响应慢、数据库卡顿、依赖服务雪崩都可能导致任务长时间不返回。ax 调度里每个任务类型都配置了超时时间比如普通任务 5 分钟文件处理类任务 30 分钟。超时判断有两种途径。一种是执行器内部拦截我们提供的基础执行器会把业务代码包在一个带超时控制的线程里调用Future.get(timeout)获取结果超时则中断业务线程。另一种是调度中心兜底执行器长时间心跳没更新调度中心认为该任务已经超时强制状态迁移。重试不能盲目做。第一次失败马上重试往往还是失败因为故障可能还没恢复。我们采用指数退避加抖动的策略第一次失败后延迟 1 秒第二次失败后延迟 2 秒第三次 4 秒以此类推。为了避免所有任务在同一时刻重试造成羊群效应每次延迟都随机加一个 0% 到 50% 的抖动比如 4 秒的延迟实际等待会在 4 到 6 秒之间随机取一个值。重试次数也需要限制。我们把默认最大重试次数设置为 3 次超过之后就进入 FAILED 状态同时把失败消息写入一张人工介入表运维团队可以手工发起重新执行。有些非核心任务甚至可以设置成“失败静默”不强制重试失败后直接忽略避免无意义的重试加重下游负担。3.4 线程池参数调优才是硬功夫调度系统把任务下发到执行器之后真正干活的还是线程池。线程池参数调不好再优秀的调度策略也白搭。我们最初踩的典型坑是线程池大小拍脑袋定一个数比如固定 50 个线程。结果某业务任务每个都要调用外部接口平均耗时 3 秒50 个线程满负荷时每秒最多处理 16 个任务。大促时一秒钟来了几千个任务任务全堆在队列里等前面的任务跑完后面的任务早就超时了。调整后的思路是线程池大小 目标吞吐 / 单个任务最大耗时。比如希望每秒处理 200 个任务平均每个任务耗时 300 毫秒那么核心线程数至少 200 × 0.3 60 个。还要考虑线程切换成本我们通常会留 30% 的余量最终设置在 80 个左右。如果任务类型差异很大就拆成多个线程池每个池单独配置。队列长度的设置也有讲究。太短的话高峰一来直接拒绝太长的话内存压力大而且任务排队时间可能超过业务容忍度。我们更倾向于队列长度设定为“高峰期一秒内最多积压任务数的一半”配合拒绝策略将多余任务回收再投递。拒绝策略不用系统默认的AbortPolicy而是写了一个自定义处理器捕获拒绝异常把任务状态改为等待重试重新放回 zset 稍后再试。这么处理的理由是调度系统本身就应该具备削峰填谷能力拒绝不是终点延迟执行才是正道。4. 实操中的关键细节与踩坑实录4.1 任务幂等性重复执行才是最难防的无论调度器怎么保证分布式环境下任务依然可能重复执行。最典型的是执行器处理完任务之后还没来得及把状态更新成 SUCCESS 就宕机了锁过期后另一个节点发现任务还是 RUNNING强制转回 READY又把业务跑了一遍。所以 ax 调度明确规定所有业务任务必须幂等。具体操作是给每个任务执行前增加一个去重表记录已经处理过的biz_key。比如发送短信如果biz_key已经发送过就直接返回成功。如果因为是不同任务 ID 但同一个业务键去重表也能兜住防止重复发送。我们曾经接了一个跨境清关状态同步的任务外部渠道重复通知了好几次我们每次都去查询一次清关状态本身问题不大。但有一次外部渠道突然重放了历史数据导致同一条状态的写库操作执行了几万次直接把数据库 CPU 打满了。后来加上biz_key唯一索引重放请求直接被去重挡住数据库瞬间就安稳了。幂等这件事必须在业务侧实现调度侧只能尽可能减少重复概率无法根治。写文档的时候一定要把这条红线写清楚不然业务方图省事迟早会给你搞出线上事故。4.2 时钟漂移与 Redis 过期时间不可忽视的隐形杀手调度系统太依赖时间了。zset 的 score 是时间戳任务超时判断用的是时间戳续约也用时间戳。如果某台机器的时钟漂移比如比真实时间快了 5 秒那么它就会提前 5 秒把所有到期任务拉下来如果比真实时间慢了几秒它可能在整个调度窗口里对到期的任务视而不见。为了避免这个问题我们强制要求所有服务器启用 NTP 时间同步并且定期检查时钟偏移量。另外在代码层面尽量只用 Redis 服务器的当前时间作为比较基准因为 Redis 是单点的它的时间更容易统一。你可以在 Redis 里执行TIME命令获取服务器时间然后基于这个时间判断任务是否到期而不是依赖各个执行节点本地时间。还有一个小坑Redis key 的过期时间不能设得太短。有次我们把调度锁的过期时间设置成了 10 秒结果业务任务在高峰期要跑 20 秒锁提前过期另一个节点立刻把任务抢走了。最后导致同一批任务跑了两次给下游发了重复通知。从那以后我们所有分布式锁的过期时间默认设成 60 秒再靠续约机制动态延长。宁可锁多占一会儿也不能让锁提前释放造成混乱。4.3 调度倾斜热 key 与分片不均分布式环境下任务会跟着分片走但如果某个分片上的任务特别多而恰好这个分片落在了同一台机器上就会形成热点。我们遇到过一次大促抢购任务所有任务 ID 经过哈希之后大约 80% 都集中在某 8 个分片上负责这些分片的机器忙得不可开交其他机器却很闲。排查下来是因为任务 ID 的生成存在时间局部性——发号器的同一毫秒内生成的 ID尾部几位非常相似取模之后天然集中。解决思路有两个。一个是对任务 ID 做一次额外散列比如在取模之前先计算md5(taskId)让分布更加均匀另一个是扩大分片数量从 64 个加到 256 个这样热点就会被摊薄到更多机器上。两个方案我们最终都做了效果很明显。另外如果要保证某个业务方独占一批机器可以在任务类型维度再切一层分片。比如把分片配置从“全局”改成“按 taskType 独立分片”这样不同业务之间的任务调度不会被互相拖累。4.4 常见问题速查表为了便于团队快速定位问题我把 ax 调度上线以来遇到的高频问题整理成了一张速查表这里直接分享出来。现象可能原因排查路径任务迟迟不执行Redis zset 中没有对应任务索引或next_run_time设置太晚检查任务表next_run_time和 Redis zset score确认是否被错误投递到低优先级通道任务执行多次分布式锁过期时间太短或任务执行后没有真正等锁释放查看锁过期时间配置检查执行器宕机后回收逻辑是否触发线程池拒绝大量任务线程池核心线程数不够或队列长度太小按吞吐公式重新计算线程数自定义拒绝策略做回收投递任务超时但还在执行业务线程没被中断或超时设置未在基础执行器中生效检查基础执行器是否使用 Future.get(timeout) 包裹业务代码节点缩容后任务丢失任务索引没有重新被分到其他节点确认分片重分配逻辑缩容前先触发一次待处理任务回收数据库压力突增相同任务被大量重试或去重表没有生效查看重试次数和任务类型确认biz_key唯一索引是否建立这些坑不是一次踩完的每一个背后都有一段半夜爬起来看日志的经历。调度系统的排查核心思路永远是先确认状态再确认时间最后确认机器。4.5 监控与告警没有监控的调度等于裸奔ax 调度上线前必须接好三类监控第一类是任务指标包括每个任务类型的提交量、执行量、失败量、平均耗时、超时量第二类是调度指标包括调度循环延迟、Redis zset 中积压任务数量、分片负载分布第三类是执行器指标包括线程池活跃线程数、队列长度、拒绝次数、心跳超时次数。最有用的一条告警规则是失败任务数量连续 3 分钟超过阈值立刻告警。比告警阈值更重要的是把任务失败后的重试状态也计入告警不然你只会收到一堆“重试成功”的噪音而忽略了最核心的问题。监控看板我们分三层业务层、调度层、资源层。业务层让产品和技术对照看调度层让值班同学看资源层主要给基础设施团队看。三层数据并不是越多越好建议只把关键的十几项指标贴到大屏上其余信息留到排查时再去查明细。5. 从 ax 调度延伸出的实践建议5.1 灰度发布先让真实流量的一小部分先上调度系统直接影响所有线上任务不能一把梭全部切过去。ax 调度切换成新架构时我们采用的是灰度策略先让 5% 的任务流量走新链路观察一天再看失败率、超时率、执行耗时是否出现异常。稳定后再逐步调整到 10%、30%、50%、100%。每个阶段都会对比新旧系统的数据如果新系统的 P99 耗时比旧的慢超过 10%就会回滚并且继续排查原因。灰度期间新老两套系统是要并行运行的但任务不能重复执行。我们的处理方式是给任务表增加一个source_flag字段老系统产生和新系统产生的任务互斥处理。灰度切流时同一类的任务要么走老链路要么走新链路不允许混着跑避免两边同时更新状态互相覆盖。5.2 预留的扩展空间工作流编排与任务血缘ax 调度目前还只解决了“单个任务怎么执行”的问题但实际业务里更多是有向无环图DAG式的编排任务 A 和 B 并行跑两个都成功后才执行 CC 失败后把 A 和 B 的结果回滚。这块我们已经在设计工作流引擎了核心是给每个任务增加parent_task_id和depend_condition字段调度器在任务变为 SUCCESS 之后主动去检查它的下游任务是否能被解锁。任务血缘是另一个重要方向。现在如果想定位“这个订单为什么没发短信”“这笔退款为什么 10 分钟才到账”只能通过任务日志一点点拼。等血缘关系建立起来就可以任务维度拿到完整的执行链路和上下文排障效率大幅提升。这些能力都是站在 ax 调度这个稳定底盘上长出来的所以底盘的稳定比什么都重要。我个人在实际操作中的体会是调度系统最怕的不是复杂而是不可观测。你把状态机收紧、把幂等做实、把监控铺好剩下的事情都可以靠迭代慢慢补。如果现在正打算重构任务调度不要一上来就追求大而全的分布式能力先把单机状态机做对再逐步引入 Redis 加速层和分片消费。ax 这个代号可以随时换但它背后的设计思路值得留档保存。最后再分享一个小技巧给所有延迟重试逻辑加上随机抖动。这个不起眼的细节能帮你避开无数次因服务雪崩集体重试导致的二次宕机。我每次做调度方案评审第一眼就要看退避算法里有没有 jitter没有的话当场建议加上。调度系统承受的是整个公司的异步流量稍微大一点的重试风暴就足够让所有人集中出一次故障了。