精通扇出:构建持久且幂等的工作流
SchemaBridge Team · 2025-12-15 · Scalability, Idempotency, Orchestration
处理大规模扇出而不丢失对工作的追踪。深入解析 Spawner vertex、逐项幂等键与 at-least-once 投递。
大规模扇出的挑战:循环的葬身之地
每个开发者都写过循环。无论是 Java 中的 for 循环、JavaScript 中的 .map(),还是 Python 中的 list comprehension,逻辑都是一样的:拿到一个条目列表,对每一项做点什么。这是最简单的数据处理形式。只有几项时,这微不足道;几百项时,尚可应付。但一旦越过成千上万的门槛,这个朴素的循环就会成为应用可靠性和可扩展性的严重隐患。
在分布式系统的世界里,本地循环就是一个故障点。当你从几项增长到成千上万项时,复杂度并不只是线性增长,而是撞上一堵复杂度之墙。这堵墙由冰冷而残酷的现实构成:内存管理、网络延迟,以及运行你代码的机器终将发生的故障。你不再思考"逻辑",而是开始与"物理"搏斗。
本地循环的局限:为什么 Promise.all 是规模化之前的模式
在一个朴素的实现中,你可能会收到一个巨大的 JSON 负载——比如一份大型的每日订单 CSV,或一次 CRM 的批量导出——然后用一个循环包裹对下游服务的 API 调用。如果你是一名现代 JavaScript 开发者,也许会用 Promise.all() 把它们全部并行发出。这正是尚未考虑规模的开发者所犯的第一个错误。
在规模化场景下,这会因三个关键原因而酿成灾难:
1. 内存耗尽:无声的崩溃
将大量复杂对象加载到内存中很容易让你的 worker 崩溃。即使每个对象的体积不大,累积起来也很快,而在内存占用较高的运行时中,原始数据在内存中还可能进一步膨胀。结果就是立即出现"Out of Memory"(OOM)错误,在第一个条目被处理之前就杀死了进程。
2. 执行超时:时间在流逝
大多数平台都有严格的限制。如果你的循环要执行成千上万次 API 调用,而每次调用只需 100ms,你的脚本也可能运行好几分钟。即便你将其并行化,仍然受制于那个单一容器的资源限制和开销。在最后一个条目被处理之前,平台就会终止你的进程,使系统处于不确定的状态。
3. 没有进度记录
当进程死掉时,关于已完成工作的唯一记录只存在于目标系统中。如果不做取证式的对账,没有人能说清哪些条目已发送、哪些成功了、哪些从未被尝试过。
Spawner 登场:以持久化子执行实现扇出
在 SchemaBridge 中,扇出是一个专门的 Spawner Vertex。Spawner 不只是一个循环;它把每个条目变成一个一等的工作单元,由引擎单独追踪和报告,你也可以单独重试它。
Spawner 的实际工作方式:并行拆分
Spawner 将列表与条目的执行解耦。每个条目不再是某个进程内部的一次迭代,而是成为一个独立的执行:
1. 每个条目一个子执行:对于每个条目,Spawner 都会启动目标工作流的一个子执行。每个子执行都以自己的 ID、自己的状态和自己的日志持久化到 PostgreSQL 中,并保留回溯到父运行的血缘关系。
2. 确定性的逐项键:每个子执行请求都携带一个幂等键,该键由父执行、Spawner vertex 和条目的位置派生而来。如果 Spawner 步骤本身被重新投递——例如其 worker Pod 在扇出中途重启,另一个 worker 重新认领了该步骤——它会从头重新提交列表,而已经启动的子执行会通过其键被识别出来,而不会被重复创建。
3. 生命周期相互独立:如果第 501 个条目失败,并不会阻止第 502 个条目被派发或成功。你获得的是众多独立执行的细粒度,而不是一个庞大而脆弱的批次,并且失败的子执行可以单独重试。
请清醒地看待这项保证:基于该键的去重是在创建子执行之前进行的一次查找,而不是数据库的唯一性约束,因此它缩小了产生重复的时间窗口,而不是将其完全关闭。引擎是 at-least-once 的,子执行对外部世界所做的任何操作都应在目标端保持幂等。
并行的经济学
让一个重量级的单线程 worker 运行 15 分钟代价高昂。你不仅要为大内存实例付费,还要为代码等待 API 响应时的"空闲时间"付费。你在网络 I/O 等待上浪费了计费的 CPU 周期。
有了逐项子执行,工作的形态发生了变化:
- 并行执行:子执行由任何有空闲容量的 worker 领取,而不是全部在一个长时间运行的进程中执行。
- 缩小爆炸半径:一个子执行的失败不会影响其他子执行。在传统循环中,某一个条目上的一次内存泄漏或未处理异常就可能拖垮整个运行。
我们预期这比一个大部分时间都在等待 I/O 的长时间运行批处理脚本更有效地利用计算资源,但更大的收益在于可靠性和可观测性,而不是账单。
这个模型是有边界的,你应该了解它们。由于每个条目都是一次执行,扇出在启动任何子执行之前都会先对照你所在套餐的执行配额进行检查。Spawner 将条目列表保存在内存中,并在单个步骤中逐个提交子执行,因此它并不是一条无限宽的管道。而当平台的负载存储承受压力时,Spawner 会推迟整个扇出——将该步骤挂起,稍后由一个持久化定时器重新驱动——而不是向已经吃力的存储继续推送更多数据。
分布式扇出中的幂等性:有防护的 at-least-once
扇出模式中最大的障碍是幂等性。如果 Spawner 步骤被中断并重新运行,我们如何避免把同一个子执行启动两次?
SchemaBridge 叠加了多层防护:
- 逐项键:上文所述的确定性键让重新运行的 Spawner 能够找到已经存在的子执行,而不是创建新的子执行。这一查找无论子执行处于什么状态都会返回它,因此已经失败的子执行只会被重新关联,而不会被重新运行。
- 步骤级防护:在引擎内部,已经完成的步骤不会被重新执行,而认领一个步骤以执行它是一个原子的状态转换,因此针对同一步骤的重复消息会被丢弃,而不会被执行两次。
这不能给你的是 exactly-once 执行。worker 可能在副作用已经发生、但其完成尚未被记录时崩溃,此时该步骤会再次运行。这就是为什么我们将平台描述为带有强防护的 at-least-once,也是为什么任何非幂等调用的目标端都需要自己的保护。
示例场景:一次大型迁移的失败
设想一个团队正在对一大批账本条目进行关键迁移。他们选择使用传统的 Python 脚本。在一个长时间运行的任务进行到一半时,VPN 断开了。脚本崩溃了。
恢复(代价高昂的方式)
恢复过程十分痛苦:工程师们花了好几天扫描目标数据库,手工核对记录,才弄清楚究竟哪些写入了、哪些没有写入。
恢复(SchemaBridge 的方式)
现在设想同样的迁移被建模为一个 Spawner,为每个账本条目扇出一个子执行,并且发生了同样的连接中断。
1. 失败停留在单个条目:已经在运行的子执行继续进行;无法到达目标端的子执行各自失败,每个都带有自己的错误,而扇出的其余部分不受影响。如果 Spawner 本身无法提交某个子执行,Spawner 步骤会显式地失败,而不是假装在等待。
2. 可见的进度:运行视图会显示有多少子执行成功、多少失败,因此团队无需再从目标数据库中反推进度。
3. 有针对性的恢复:连接恢复后,团队逐个重试失败的子执行,而不是重新运行整个迁移。重试失败的 Spawner 步骤并不会重新运行它们:逐项键会重新关联所有已经存在的子执行(包括失败的那些),因此 Spawner 重试只会创建从未启动过的子执行。恢复将成为一个以点击次数衡量的运维操作,而不是一个取证项目——不过账本写入本身仍然需要是幂等的,因为被重试的子执行可能会重复一次已经落地的写入。
详细对比:真实环境中的扩展模型
| 特性 | 朴素循环(forEach) | SQS/Lambda(自建) | SchemaBridge Spawner |
| :--- | :--- | :--- | :--- |
| 状态管理 | 本地(易失) | 手动(数据库/队列) | 原生(持久化,按子执行) |
| 错误处理 | 单个 try/catch | 手动重试/DLQ | 逐项故障隔离 + 运维人员重试 |
| 可见性 | 零散的日志文件 | 不透明的队列深度 | 运行视图中的逐子执行状态 |
| 汇合逻辑 | 困难(单线程) | 非常困难(计数器) | 原生 Merge Vertex |
| 幂等 ID | 无 | 手动生成 | 确定性的逐项键 |
高吞吐编排的专家检查清单
如果你正在设计一个高吞吐的扇出,请遵循以下经验法则:
1. 在目标端限制并发:用并发限制来保护下游系统。在 SchemaBridge 中,这是一个按目标设置的并发限制(在 Rate Limits 页面上配置),它适用于调用该目标的每一个步骤,而不是 Spawner 本身的某个旋钮。从保守的值开始,在观察下游健康状况的同时逐步放宽。
2. 把条目失败视为常态:确保每个条目都可以独立重试,并预先决定一个失败的子执行应当导致整个扇出失败,还是应被容忍。
3. 监控"长尾"延迟:留意那一小部分耗时远超其他条目的条目。它们通常是你最复杂的边界情况,或是数据库锁的争用目标。
4. 利用确定性 ID:使用引擎的逐项键来防范重启带来的问题,同时也为你的目标端提供一个稳定的业务键。在高并发扇出中,绝不要依赖时间戳来保证唯一性。
{
"type": "MERGE",
"config": {
"tolerateChildFailures": ["FAILED"],
"failureTolerance": { "maxFailureRatio": 0.05 },
"failFast": true
}
}
部分失败容忍是可选开启(opt-in)的,在扇出的回调 merge 上配置:默认情况下,任何一个子执行失败都会导致汇合失败,这在每个条目都很重要时是稳妥的选择。在上面的示例中,失败的子执行最多可容忍到扇出的 5%,而 failFast 会在该额度确定被超出时立即结束汇合,而不是继续等待落后的子执行。
结论:扩展是一个基础设施问题
精通扇出并不在于写出更好的循环,而在于为分布式而设计架构。通过将迭代、逐项状态和重复抑制交给引擎,部分失败就变成了一份具体失败条目的清单,而不是一个未知状态——这正是一次重试与一场对账项目之间的区别。
扩展仍然有其限制——配额、背压,以及分布式投递的 at-least-once 本质——但这些限制是可见且有界的,而不是在凌晨 3 点于生产环境中才被发现。
另请参阅:幂等引擎:确保事务完整性,了解键与目标端幂等性如何配合;以及 精通 Merge,了解如何收集扇出的结果。