任务编排系统设计:从状态机、分布式锁到幂等与可观测性
1. 立项动机当定时任务散落各处连现在有哪些任务在跑都没人说得清去年年中我们团队做了一次不算愉快的技术盘点。盘点结果一出来所有人都沉默了整个系统里大大小小的定时任务加起来超过300个散落在五个服务里有的是框架注解有的是配置文件里的一行 cron还有几个是早年遗留的脚本挂在某台只有老同事知道的服务器上靠 nohup 跑着。真正让人头疼的不是数量而是没人能说清楚这些任务它们什么时候被谁加的依赖哪些表哪些接口失败了重不重试有没有幂等最现实的场景是每次发版前都要挨个确认这个任务会不会重复执行那个任务的锁还在不在。有一次一个凌晨跑的数据对账任务因为超时被重复触发早上业务方看到两笔冲突的数据直接影响了对账结果最后还是人工手工修的。这就是 Apollo-11 立项的起点。项目代号一开始其实是备选方案我们内部列了好几个鹰码头工人天桥最后选 Apollo-11 的理由特别朴素登月这件事对普通人的意义不是火箭有多快而是把一个看上去不可能完成的复杂目标拆成一段一段点火、分离、变轨、着陆每一段都有明确的结束状态任何一段出问题都能单独停下处理。这不就是我们做任务编排最想要的模型吗1.1 立项前先回答的三个问题立项评审会上我们没急着画架构图先回答了三个问题第一为什么不能直接用现成的调度框架市面上很多成熟的方案比如分布式调度中间件它们把定时触发这点做得很好但我们面对的并不只是定时问题。我们的任务里面有大量跨服务依赖比如先同步订单数据再清洗再写入分析库最后触发报表生成每一步的输入输出都要流转。单纯定时触发根本表达不了这种依赖关系我们需要的是工作流编排能力。用现成的编排平台当然也行但往往要引入一套很重的部署体系还要改造现有服务的接入方式团队学习成本和使用意愿都不太乐观。第二为什么不能继续用 cron 和人工管理因为人工管理本质上是在靠记忆力对抗熵增。今天你记得这个任务必须串行执行明天另一个同事接手可能就改成并行然后线上就会出现数据错乱。我们需要把任务之间的关系显性化让代码和数据库替我们记住。第三这个项目的边界在哪这也是最容易被忽视的。我们反复提醒自己Apollo-11 只做编排不做业务。具体某个任务是去同步订单还是发邮件那是业务服务自己的事Apollo-11 只负责保证这个任务在正确的时间被正确地触发并且这次触发是唯一的、可追踪的。1.2 命名的附加效应选 Apollo-11 这个名字还有个实际好处任务列表页里有一个专门的菜单你问任何一个人他都能直觉地知道这是任务系统不需要额外解释。项目启动后每次周会看板上的标题都是Apollo-11 本周进度新同学入职看到这个代号自己就会去翻一下文档。一个好记的项目代号在新人培养上省了不少事。2. 任务模型设计从单任务到DAG状态机不能少2.1 任务模型的演进史最早我们的模型很天真一张 task 表字段就是 task_id、type、cron、status。够用吗单个任务当然够但两天后我们就发现一个致命问题很多业务场景是多个任务组成的比如拉取外部数据 → 数据清洗 → 写入数仓 → 触发下游报表。这里每一步都可以失败失败后需要单独重试而且每一步之间是有依赖的前一步没成功后一步根本不该开始。于是我们把模型升级成了三层任务链pipeline用户提交的一个完整工作流。任务节点node任务链里的单个步骤对应一次具体的执行操作。执行实例instance每一次运行产生一条实例记录记录运行到哪个节点、状态如何。这就解决了任务之间有没有关系的问题。我们允许任务节点之间配置依赖关系图也就是 DAG一个节点可以依赖多个前置节点全部完成后才能开始也可以配置多个后续节点让数据并行分叉。实际上线的第一个月DAG 最多的一个任务链里面有 17 个节点从前端用户填表到最终数据归档一步没落下。2.2 状态机任务从生到死的完整路径状态机是整个系统的地基。我们花了大量时间定义状态和流转关系因为一旦状态乱掉后面的幂等、重试、告警全都是空谈。每个节点的状态我们定义了这几个PENDING等待执行、RUNNING正在执行、SUCCESS成功、FAILED失败、RETRYING重试中、TIMEOUT超时和CANCELED被取消。这里有几个容易出错的小细节值得展开说。一是RETRYING和FAILED是分开的。很多系统偷懒失败就是失败重试就是重试直接用一个状态字段记录看起来省事实际上会给告警造成巨大麻烦。你要区分短时间抖动马上会自动恢复和真的不行了需要人介入两种情况。RETRYING状态意味着还没到最终失败告警等级应该放低而FAILED状态则要立刻通知到人。二是节点状态和任务链状态要分开。一个任务链只要有一个节点FAILED任务链本身可能是PAUSED挂起而未必是FAILED因为我们要允许某个分支失败后其他分支继续跑或者等待人工介入后从失败节点重跑而不是整个任务链一锅端。这个设计后来在一次多分支并行任务里救了命一个分支的数据源临时不可用另一个分支照样跑完最后只是挂起了失败分支没有影响整体产出。2.3 任务本身的持久化任务配置和运行实例我们都存在 MySQL 里加了一组索引覆盖常用查询。为什么不用内存存储并且只序在内存里因为任务编排系统一旦重启就忘了之前的进度那这个系统就只配跑一个 cron。我们的实际场景要保证即使调度节点全部重启没有执行完的任务也要能从数据库里捞出来继续跑。这一条后面成为稳定性设计的基础。运行实例表会记录每一步执行的开始时间、结束时间、执行日志引用、返回参数快照。这样排查问题时不只能看到失败这个状态还能看到失败那次运行传进去的参数长什么样、下游接口返回了什么东西。用我们团队的话说状态要能回放不能只留一个墓碑。3. 调度与执行分离为什么我们要自己写时间轮而不是继续用现成的3.1 调度器和执行器拆开的直接原因市面上的调度框架通常自带执行器的概念任务在哪个机器上调度就在那个机器上执行。对于小规模系统这没问题但我们很快就撞上一堵墙某个任务在 A 机器上被调度并执行执行中 A 机器负载高了任务没法自动迁移到 B 机器或者 A 机器因为网络分区和数据库失联任务直接被跳过这种跳过的损失在数据对账场景里是不可接受的。所以架构上我们做了彻底的拆分调度器Scheduler只负责算时间、派发指令。它不执行任何业务代码也不关心任务节点具体干什么。执行器Worker负责真正跑任务节点接收指令执行回写结果。调度器可以部署多个组成集群执行器也可以部署多台按任务类型分组。调度器发出该执行这个任务了的消息后由任意一个有空闲的 Worker 抢到并执行。这样任务天然具备了横向扩展能力新增执行节点不用改一行业务代码。3.2 时间轮的选择与实现细节任务编排系统首先要解决到点触发这件事。我们调研过现成的分布式调度中间件但最后还是决定自研一个轻量时间轮。为什么因为我们的触发需求不只是 cron。很多任务链是前置节点跑完以后立刻触发下一个节点这种触发是条件触发不是时间触发用 cron 表达不了。时间轮作为一个基础数据结构同时支持延迟触发和条件触发我们可以把整套触发逻辑收在同一个机制里。实现上我们用的是分层时间轮。为什么分层因为普通单层时间轮的精度和内存占用是矛盾的你要是想支持秒级触发就得搞一个长度很大的轮子如果还有小时级任务内存浪费得让人心疼。分层时间轮的做法是秒级一格一圈60格分钟级一圈60格小时级一圈24格。任务到来时先放进能覆盖它延迟的那一层每一层有指针推进指针正好扫到该任务的槽位时把任务降级到下一层直到最终触发。说一个实际工程细节时间轮里每个格子的任务桶用的是尾插法顺序执行时用ScheduledThreadPoolExecutor的核心线程池来跑避免一个慢任务堵住整个轮的推进。调度器节点是集群部署的但时间轮只在当前节点的内存里跑分布式一致性交给数据库锁来兜底而不是在内存层面做 Gossip。这个取舍后面细说。3.3 分布式锁的边界多调度器节点同时运行必然要解决同一个任务不能同时被两个节点重复派发的问题。我们用 Redis 的SETNX做了一版分布式锁锁的 key 是task_id:instance_id锁的过期时间和任务的触发周期强绑定。但这里有个大坑。一开始我们把锁过期时间设成固定 30 秒结果遇到一个运行要 5 分钟的任务锁到时间自动过期了另一个节点立刻抢到锁把同一个任务又派发了一遍。业务方看到的结果就是重复执行。这个问题的解决方式不是把过期时间调大而是给锁加了一个持有者续约机制持有锁的节点在过期前会不断续期等任务真正结束才释放锁万一节点宕机锁会在几秒内自动过期其他节点可以接管。这一套机制从原理上保证了同一时刻一个任务只有一个节点在管。但分布式锁只是一个入口守门员真正的防线还是下一章要讲的幂等。4. 稳定性的最后一道防线幂等、重试和告警4.1 幂等设计不要让分布式锁变成唯一的防线我见过不少系统把分布式锁当成银弹以为加了锁就不会重复执行。实际上锁只能解决同一时刻只有一个节点在派发这个层面的问题。执行器拿到任务后如果执行超时、网络抖动、响应丢失任务状态回写失败调度器可能判定执行失败并重新派发这时候任务还是会执行两次。锁管不了这个能管的只有幂等。幂等设计的关键是幂等键。我们给每个任务节点的每次执行生成一个全局唯一的execution_id这个 ID 在任务链建立时通过 UUID 生成贯穿所有下游。执行器在执行业务代码之前先调用内部消息服务登记这个execution_id如果发现同一个 ID 已经处理过就直接返回成功不再执行。登记这个动作本身也需要保证原子性我们用数据库唯一索引来兜底。每个任务的执行记录表里建了execution_id的唯一索引重复插入会直接被数据库拒绝这样即使有多条消息同时到达也只有一个能真正写进去。百密一疏的地方在于执行器写入记录成功但业务逻辑本身还没跑这时候掉电重启后重新跑任务会看到记录已存在而认为成功了。这个边界我们最后是加了一个状态字段从已登记到执行中再到已完成在已登记状态重新执行是允许的在已完成状态才直接返回。这个细节是在一次事故之后才补上的后面事故章节细聊。4.2 重试策略与退避任务失败是常态重试策略要围绕快速失败和减少抖动影响两个目标来设计。Apollo-11 的重试策略是三层快速重试失败后 5 秒内重试一次应对网络瞬时抖动。慢速重试5 秒重试失败后进入指数退避2 分钟后重试第二次、4 分钟后重试第三次最多试三次。人工介入三层都失败后节点进入FAILED状态任务链挂起等值班同学决定是修复后重跑、还是跳过该节点继续后续任务。指数退避的随机因子一定要加。不加的话集群里几百个任务同时失败、同时按同样间隔重试就会产生一波一波的流量尖峰。我们在每次重试间隔上加了 0.3 到 0.7 倍区间的随机抖动把重试请求打散后端服务承受的压力平滑多了。4.3 告警不是发个消息那么简单告警模块我们迭代了很多次。最初就是失败后往群里丢一条消息结果没过多久大家就把群免打扰了因为消息太多太吵全是日志堆过来的异常栈。后来我们总结出一套告警分级RETRYING状态不上报或者只上报告警中心不打扰人。因为重试中意味着系统自己在尝试恢复人不需要盯。某节点最终失败并且任务链挂起时告警给任务负责人附带失败节点的上下文信息。如果一个任务链里失败节点数大于某个阈值默认 3 个意味着大概率不是单点问题而是某个共用基础设施出问题了这属于告警风暴会做聚合处理只发一条疑似批量失败的汇总消息。这套分级最大的价值是把无效噪音砍掉了大半。真正需要人处理的问题被暴露得更明显。5. 一次线上事故复盘任务悄悄掉进死信队列5.1 事故现象上线第三个月的一个周五夜里告警群里突然安静得诡异——之前每天都有的任务执行失败但自动恢复的常规消息也没了整个系统像是睡着了一样。起初没人注意直到周六早晨业务方反馈昨晚到今晨的所有定时报表都没出来。查的时候发现任务链的状态普遍停在PENDING有些甚至已经是CANCELED但没有任何人操作过。5.2 排查链路第一步我们看调度器的日志发现一批任务已经被派发出去但 Worker 节点上没有收到任何消息。这说明问题出在调度器和执行器之间。第二步检查消息队列发现一个惊人的情况队列里的积压消息数量高达几十万而消费者线程数只有几个消费速度远跟不上生产速度。任务消息在队列里排着长队后面的任务哪怕已经到点也只能排队等待。第三步追查为什么积压。Worker 节点有日志显示它在批量重跑某个数据同步任务每个消息处理时间从正常的 200 毫秒飙升到了 30 秒以上。大批消息在消费者端超时被重新投递然后又被同一个满负荷的 Worker 拉走死循环。第四步定位根因。那个数据同步任务调用的是外部接口外部接口因为对方系统变更返回的数据结构变了导致解析代码抛出异常。代码异常按理说会重试几次然后失败但问题是解析代码里的一个循环没设置上限异常被捕获后重试机制又把它当成普通任务重新投递反复失败、反复重投终于把队列堵死了。5.3 根因与教训这次事故的根源是两件事一是循环任务没有设置最大重试次数。我们的快速重试和慢速重试都设置了次数上限但那个同步任务的代码内部还得自己写了重试逻辑形成一个外层重试 × 内层重试的乘法效应直接把队列打成死信海。二是幂等记录状态不严谨。刚才提到过如果一个节点登记了execution_id但还未执行完就失败重新执行时应该允许覆盖我们的第一版实现只看记录是否存在导致重复任务被误判为已完成跳过不跑。这两条教训一条靠代码规范约束所有重试逻辑必须在框架层完成业务代码内不允许自行重试一条靠状态机完善用已登记 → 执行中 → 已完成三级状态取代二态。5.4 修复与验证修的问题分为两步。第一步先把积压任务拉闸停掉那个坏节点把队列中大量重复消息剔除恢复正常消费。第二步代码修复上线前我们额外加了一种手段给每个执行器节点设置单节点最大并发数上限超过上限的多余消息直接丢回队列尾部宁可慢一点也不能让一个节点被打垮。修复后我们又做了一次压测模拟 5 倍于平时的任务量确认队列积压在可接受范围内消费耗时稳定在 100 到 300 毫秒业务报表也在预定时间前全部生成完毕。这次事故让我最深刻的体会是监控系统能看到积压但看不到积压的原因所以每个任务的关键路径必须有 trace否则排查全靠猜。6. 上线半年后的复盘哪些设计是过度设计哪些该重构6.1 性能数据与稳定性结论Apollo-11 上线半年后我们整理了一组真实数据支撑了大约 400 个任务链、1200 个节点日均触发约 3 万次任务执行调度器集群 3 节点执行器集群 8 节点数据库 2 个主从。最近三个月的运行数据显示系统整体可用率在 99.97% 左右故障主要集中在依赖的下游服务Apollo-11 自身导致的故障只有两次一次就是前面说的队列事故另一次是某执行器节点磁盘满导致状态回写失败后来我们在执行器上加了磁盘水位检查超过 80% 自动停止拉取新任务。6.2 复盘哪些设计是过度设计做技术方案时我们容易把所有未来可能出现的问题都提前设计好但实际运行半年后有些设计确实证明是过度设计。最明显的一个是为每个任务节点都配置独立的人工审批确认流程。当时我们觉得有些高影响任务需要在执行前让人再确认一下于是设计了一套审批流结果上线半年真正走审批的任务不到 5 个反而让普通任务在配置时多了一层无感知的复杂性。后来我们把审批流程拆出去做成一个独立的可选组件默认不开启只有真正需要审批的任务链才显式启用。另一个过度设计是对 DAG 的过度泛化。任务链里其实 80% 的场景都是简单的前后顺序依赖真正需要并行分叉的不到 20%。我们一开始花了很多精力做 DAG 的环路检测和拓扑排序其实后来发现在任务编排这个场景里完全可以约定任务链内不允许有环直接用有向树表达就够了。做了 DAG 泛化反而让配置界面的心智负担变重。6.3 如果让我重写一次我会改什么如果现在拿到同样一个需求让我重写一次有几点我会改变。第一任务配置和运行实例的存储我不会再放 MySQL。MySQL 在任务量上来之后实例表的写入压力会成为一个瓶颈虽然我们做了分表和归档但依然要花不少精力维护。如果重来运行实例部分会用专门面向时序写入的存储设计方案比如写入时只追加、按时间分区查询时按任务 ID 和时间段走索引。第二调度器的时间轮我不会再自研。不是说自研不好而是我们花了大约两周时间做时间轮和分布式锁的联调期间出了不少边界问题。如果当时选一个成熟的分布式调度基础组件把时间轮的实现和一致性保障交给它项目可以提前至少三个星期上线。对于大多数团队来说自研基础设施的隐性成本就是调试和测试成本这个成本往往比写代码本身高得多。第三我会在第一天就把执行上下文快照做好。现在排查问题时最耗时的往往不是看代码而是把某个失败任务当次运行的入参、配置、中间步骤输出还原出来。我们是在上线一个月后才开始给每次执行保存完整的上下文信息包括节点入参、前置节点输出的引用以及任务链级的状态快照。这个能力如果从第一天就有很多排查会从几小时缩短到几分钟。第四权限和审计能力要前置设计。因为 Apollo-11 控制着所有任务的启停任何一个误操作都可能引发生产事故。我们后来才补上了操作审计日志包括谁在什么时间改了哪个任务链的配置、谁手动重跑了哪个实例。如果一开始就有完整的审计就不会有同事改配置没生效却半天查不到原因的事情发生。最后聊点实在的项目代号 Apollo-11 这套代码如今还在支撑着内部每天大量的数据处理工作但它早就不是我写的那个版本了。组里的同学在上面的基础上加了很多东西更友好的配置界面、更智能的重试建议、更细粒度的权限控制。我有时候翻到项目初期的文档第一行写着像登月一样把复杂任务拆成可独立推进的阶段这句话到今天依然不过时。如果你也在计划做一个类似的任务编排系统我给你一个最直接的建议先把状态机、幂等、可观测性这三件事想清楚再考虑功能边界。任务系统最怕的不是功能少而是出问题的时候你既不知道它出了问题也不知道到底哪里出了问题。还有一点小经验项目代号真的可以提升团队凝聚力选择一个有画面感的名字比叫 Project-001 有意思多了。