ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Apollo-11:高可靠任务编排与状态回放系统设计与实现

Apollo-11:高可靠任务编排与状态回放系统设计与实现

1. 从代号到工程:Apollo-11 到底在做什么

第一次看到“Apollo-11”这个代号,很多人脑子里蹦出来的可能是那段著名的登月历史。但在我这里,它指的是一套我反复打磨过的高可靠任务编排与状态回放系统的代号。取这个名字,是因为它要解决的核心问题和当年那次任务惊人地相似:在一个链路长、环节多、任何单点失误都可能让整个流程崩掉的场景里,如何保证每一步都可预期、可观测、可回滚,并且在出问题之后能像看录像一样把现场完整还原出来。

这套东西能做什么?简单说,它把一次复杂的多步骤任务拆成一个个有明确输入输出的“动作单元”,用一个调度内核按依赖关系串起来,每个动作执行前后的状态都被记录下来,形成一条可回放的时间线。它解决的问题是:传统脚本式流程一旦跑到一半挂了,你只能靠日志猜哪里出了问题,重跑还得从头来;而 Apollo-11 让你能精确定位到失败的那一步,从断点续跑,甚至把当时的输入原样喂回去复现。

适合谁来参考?如果你正在做数据管道、自动化部署、批量任务处理、或者任何“一串步骤必须按顺序且不能出错”的事情,这套思路都能直接抄。哪怕你只是写个每天定时抓数据再清洗入库的小脚本,理解它的设计逻辑也能让你的代码从“能跑”变成“敢在半夜无人值守时跑”。我下面会把整套设计思路、关键实现、踩过的坑全部摊开讲,尽量让刚入门的朋友也能跟着复现出一个可用的版本。

2. 整体架构设计与选型背后的取舍

2.1 为什么是“动作单元 + 调度内核”而不是一条大脚本

我最早的做法就是写一个长长的脚本,从头到尾顺序执行。前几十次跑得挺顺,直到有一次在中间某个环节因为网络抖动失败了,整个脚本挂掉,前面已经完成的步骤全部白做,重跑又要等半小时。那次之后我就意识到,流程的原子化和状态持久化是绕不过去的坎。

Apollo-11 的核心设计就两条:第一,把每个独立操作封装成一个“动作单元”,每个单元自己声明它依赖哪些前置单元、需要什么输入、产出什么输出;第二,调度内核只负责根据依赖关系决定执行顺序,并在每个单元执行前后把状态写进持久化存储。这样做的好处是,单元之间解耦,任何一个单元的内部实现改了,只要输入输出契约不变,整个流程不受影响。而且因为状态是持久化的,进程崩了重启之后,内核能读出上次执行到哪,直接跳过已完成的单元。

选这个方案而不是用现成的重型工作流引擎,是因为我的场景对部署轻量化和启动速度有要求。那些引擎功能全,但引入的依赖多、配置复杂,对于中小规模任务来说属于杀鸡用牛刀。自己实现一个精简内核,代码量可控,出问题也好排查。

2.2 状态存储为什么选文件而不是数据库

状态存哪里,这是个关键决策。我试过用关系型数据库存执行状态,好处是查询方便、支持事务。但实际跑下来发现两个问题:一是每次状态更新都要走一次数据库连接,在动作单元执行频率高的时候,这部分开销占比不小;二是部署环境不一定有现成的数据库可用,为了跑一个流程还要先搭一套数据库,太重了。

最后我选了基于文件的追加式日志来存状态。每个动作单元执行前后,往一个按时间顺序追加的日志文件里写一条记录,记录里包含单元标识、时间戳、输入摘要、输出摘要、执行结果。这个方案的好处是写入极快(就是文件追加),不依赖外部服务,而且日志本身就是一条完整的时间线,回放的时候直接顺序读取就行。代价是查询不如数据库灵活,但对于“按顺序回放”这个核心需求来说完全够用。如果后续需要复杂查询,可以再加一个索引层,但那是后话。

提示:追加式日志要注意文件切割。单个日志文件不要无限增长,我一般按天或者按大小切分,回放的时候按需加载对应区间的文件。

2.3 依赖关系的表达方式

依赖关系怎么描述,直接决定了调度内核的复杂度。我见过用图结构描述的,节点是单元、边是依赖,功能强大但实现起来代码量不小。Apollo-11 里我用了一个更朴素的办法:每个动作单元在定义的时候,用一个列表声明它依赖哪些单元的名称。内核启动时,先扫描所有已注册的单元,构建出一张依赖表,然后做拓扑排序得到执行顺序。

这个方案的限制是只能表达“先后依赖”,不能表达“条件依赖”(比如 A 成功才执行 B,A 失败则执行 C)。对于条件分支,我的处理方式是在单元内部自己判断,把分支逻辑收进单元里,而不是让调度内核去管。这样内核保持简单,分支的复杂性由单元自己消化。实测下来,大部分场景用这种方式都能覆盖,真正需要复杂条件编排的情况并不多。

3. 核心细节拆解与实操要点

3.1 动作单元的生命周期与接口设计

一个动作单元从被调度到执行完成,经历这几个阶段:待执行、执行中、成功、失败、跳过。内核在决定执行某个单元之前,先检查它的状态,如果是成功或跳过,直接略过;如果是失败,根据配置决定是重试还是终止整个流程。

单元对外暴露的接口我设计得很简单,就三个方法:prepare、execute、cleanup。prepare负责检查输入是否就绪、准备临时资源;execute是核心逻辑;cleanup负责释放资源,无论成功失败都会调用。这样设计的好处是,资源释放的逻辑集中在一处,不会因为异常路径漏掉。我踩过的坑是早期没有cleanup,某个单元执行到一半失败,打开的文件句柄没关,跑多了之后进程直接报“too many open files”。加上cleanup之后这类问题再没出现过。

每个单元还需要声明自己的元信息:名称(唯一标识)、依赖列表、超时时间、重试次数。超时时间这个参数很关键,我一般根据单元的实际耗时上浮 50% 来设。比如一个网络请求单元正常 2 秒完成,超时设 3 秒。设太短会误杀,设太长则失败发现得晚。重试次数默认给 1 次,也就是失败后再试一次,对于偶发的网络抖动足够;如果是幂等性没保证的单元,重试次数要设 0,避免重复执行产生副作用。

3.2 状态记录的粒度与内容

状态记录记什么,直接关系到回放能不能还原现场。我一开始只记了单元名称和执行结果,后来发现回放的时候根本不知道当时输入是什么,没法复现。现在每条记录包含这些字段:单元名称、阶段(开始/结束)、时间戳、输入参数的哈希值、输出结果的哈希值、执行耗时、错误信息(如果有)。

输入输出我存的是哈希值而不是原文,原因是原文可能很大,全存下来日志文件膨胀太快。哈希值的作用是校验:回放的时候,如果当前输入算出来的哈希和记录里的不一致,说明输入变了,这时候要么报警要么强制重跑。如果确实需要看原始输入,可以在单元内部单独打一份详细日志,和状态日志分开存。

注意:哈希算法选稳定的,我用的是 SHA-256。不要用那些可能随版本变化的哈希实现,否则回放校验会莫名其妙失败。

3.3 断点续跑的判定逻辑

断点续跑是 Apollo-11 最实用的功能,但判定逻辑要小心。内核启动时读取状态日志,对每个单元确定它的最新状态。如果最新状态是“成功”,就标记为已完成,跳过;如果是“执行中”,说明上次是在执行过程中崩的,这个单元的状态是不确定的,需要根据单元自身的幂等性决定是重跑还是报错人工介入。

这里有个容易忽略的点:“执行中”状态的单元,它的副作用可能已经部分发生了。比如一个单元负责往目标位置写文件,写到一半进程挂了,文件是残缺的。这种情况下直接重跑可能会出问题。我的做法是,对于有副作用的单元,在prepare阶段先做一次清理,把可能残留的中间状态清掉,再开始执行。这样即使上次是执行中崩的,重跑也能从干净状态开始。

4. 完整实操流程与关键环节实现

4.1 环境准备与依赖安装

这套东西我用 Python 实现,因为它的标准库足够覆盖需求,不需要额外装太多东西。基础环境就是 Python 3.8 以上,依赖只有两个:一个用于计算哈希(标准库hashlib自带),一个用于处理时间(标准库time和datetime自带)。如果你想要更友好的日志输出,可以装个loguru,但这不是必须的。

目录结构我这样组织:根目录下建units/放所有动作单元的实现,core/放调度内核和状态管理,logs/放状态日志,config/放流程配置文件。配置文件用 YAML 格式,描述这次要执行哪些单元、它们的依赖关系、各自的超时和重试参数。用 YAML 而不是 JSON 是因为它写起来更简洁,支持注释,改起来方便。

# 目录结构示例 apollo11/ ├── units/ │ ├── fetch_data.py │ ├── clean_data.py │ └── save_result.py ├── core/ │ ├── scheduler.py │ └── state_store.py ├── logs/ ├── config/ │ └── pipeline.yaml └── run.py

4.2 调度内核的核心代码实现

调度内核的逻辑不复杂,核心就是一个循环:从待执行队列里取出一个单元,检查依赖是否都满足,满足就执行,不满足就放回队列等下一轮。为了避免死循环,每轮开始前检查一下队列里是否还有可执行的单元,如果一个都没有且队列非空,说明存在循环依赖或者依赖的单元永远无法完成,这时候直接报错退出。

# core/scheduler.py 核心逻辑示意 def run(self): while self.pending: progressed = False for unit in list(self.pending): if self.deps_satisfied(unit): self.execute_unit(unit) self.pending.remove(unit) progressed = True if not progressed: raise RuntimeError("检测到循环依赖或不可满足的依赖,流程终止")

execute_unit方法里做几件事:先写一条“开始”状态记录,然后调用单元的prepare、execute,无论成功失败都调用cleanup,最后写一条“结束”状态记录。如果execute抛异常,捕获之后根据重试配置决定是重试还是标记失败。重试的时候会重新走一遍prepare和cleanup,保证每次尝试都是干净的。

4.3 一个完整流程的配置与执行

假设我要做一个“抓取数据、清洗、保存”的流程,配置文件这样写:

# config/pipeline.yaml units: - name: fetch_data deps: [] timeout: 30 retries: 2 - name: clean_data deps: [fetch_data] timeout: 60 retries: 1 - name: save_result deps: [clean_data] timeout: 30 retries: 0

执行的时候,内核先读配置,实例化对应的单元对象,然后按依赖关系调度。fetch_data没有依赖,第一个执行;它成功后,clean_data的依赖满足,接着执行;最后是save_result。如果fetch_data失败了,重试两次还是失败,那么clean_data和save_result永远不会被执行,内核会标记整个流程失败并退出。

这里有个细节:save_result的重试次数我设成 0,因为它涉及写最终结果,重复执行可能覆盖掉正确数据。对于这类单元,失败就失败,人工介入处理比自动重试更安全。这个取舍要根据单元的具体行为来定,不能一刀切。

4.4 回放功能的实现

回放就是读取状态日志,按时间顺序把每个单元的执行记录展示出来。我实现了一个简单的回放器,输入是日志文件路径,输出是一条格式化的时间线。每条记录显示单元名称、阶段、耗时、结果。如果某个单元失败了,还会把错误信息打出来。

回放器还有一个进阶用法:对比回放。把两次执行的日志加载进来,逐条对比同一个单元的状态和输入输出哈希。如果发现某次执行中某个单元的输入哈希和上次不一样,说明这次运行的输入变了,可能是上游数据源改了,也可能是配置改了。这个功能在排查“为什么上次能跑这次跑不了”这类问题时特别有用。

5. 常见问题与排查技巧实录

5.1 单元卡住不结束怎么办

最常见的问题是某个单元执行时间远超预期,把整个流程拖死。原因通常是单元内部的某个操作没有设超时,比如一个网络请求默认不超时,对端不响应就一直等。解决办法是在单元内部对每个可能阻塞的操作都设超时,同时内核层面的单元超时作为兜底。内核超时触发后,会强制中断单元执行并标记失败,然后走重试或终止逻辑。

提示:内核强制中断单元执行,在 Python 里可以用信号或者子进程的方式实现。我用的是子进程方式,每个单元在独立子进程里跑,超时直接杀子进程,干净利落,不会留下僵尸线程。

5.2 状态日志损坏或丢失

状态日志是追加写的,正常情况不会损坏。但如果磁盘满了或者进程被强杀,最后一条记录可能只写了一半。我的处理方式是,读取日志时对每条记录做格式校验,遇到不完整的记录就丢弃并告警。因为日志是追加的,丢弃最后一条不完整的记录不影响前面的记录,流程可以从上一个完整状态继续。

如果日志文件整个丢了,那就没法断点续跑了,只能从头开始。为了避免这种情况,我会定期把日志文件备份到另一个位置。备份策略很简单,每天凌晨把当天的日志复制一份到备份目录,保留最近 7 天。

5.3 依赖关系配错了怎么发现

依赖配错有两种:一种是漏配,导致单元执行顺序不对;另一种是循环依赖,导致流程永远卡住。漏配的问题比较隐蔽,可能跑了很多次才偶然发现。我的做法是在流程启动前做一次静态检查,把配置里的依赖关系画成图,检查是否有环,同时检查每个依赖的单元名称是否真实存在。这个检查很快,但能拦住大部分低级错误。

循环依赖的检查用拓扑排序就行,如果排序结果的长度小于单元总数,说明有环。发现环之后,把环上的单元名称打出来,人工去改配置。

5.4 常见问题速查表

问题现象可能原因排查方向解决方式
流程卡住不动单元内部阻塞无超时查看当前执行单元给内部操作加超时,内核超时兜底
断点续跑后结果不对执行中状态的单元有残留副作用检查该单元的 prepare 清理逻辑在 prepare 里清理中间状态
回放时输入哈希不匹配上游数据或配置变了对比两次执行的输入确认变更是否符合预期
日志文件异常增大输入输出原文被记录检查状态记录内容只记哈希,原文另存
重试导致重复副作用单元非幂等但配了重试检查单元幂等性非幂等单元重试次数设 0

6. 几个我踩过的坑和对应的经验

第一个坑是关于超时设置的。我早期给所有单元设了统一的 30 秒超时,结果有个单元正常执行需要 45 秒,每次都被误杀。后来改成每个单元单独配置超时,根据实测耗时上浮 50% 来定。这个上浮比例不是随便定的,太小容易误杀,太大则失败发现得晚。50% 是我试下来比较平衡的值。

第二个坑是关于状态记录的写入时机。我一开始只在单元执行结束后写一条记录,结果进程在执行过程中崩了,这条记录根本没写,重启后内核不知道这个单元执行过,又跑了一遍。后来改成执行前写“开始”、执行后写“结束”,这样即使中途崩了,也能从“开始”记录知道这个单元曾经在跑。

第三个坑是关于依赖检查的性能。单元数量少的时候,每轮遍历所有待执行单元检查依赖没什么问题。但当单元数量到几百个的时候,每轮全量遍历就有点慢了。优化方式是维护一个“就绪队列”,只把依赖已满足的单元放进去,每轮只从就绪队列取,不用全量扫描。这个优化在单元数量多的时候效果很明显。

这套 Apollo-11 从最初的一个想法到现在稳定运行,中间改了好几版。核心思路一直没变:原子化、持久化、可回放。如果你也在做类似的事情,建议先把状态记录和断点续跑做扎实,这两个功能带来的收益最大。至于条件分支、并行执行这些高级特性,等基础跑稳了再往上加也不迟。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进