精通合并:在并行世界中协调状态
SchemaBridge Team · 2025-12-29 · Concurrency, State Management, Synchronization
无竞态地同步并行分支:持久化 join、长尾问题,以及决定你实际得到哪种 join 的默认设置。
并行悖论:自由与同步
为了追求性能,我们拥抱了并行。我们扇出任务、启动异步 worker,并把数据分散到众多节点上。我们获得了吞吐量,却在复杂性上付出了高昂代价。分布式系统的难点不在于同时启动多件事,而在于把它们重新汇合到一起。
设想一个复杂的订单履约流程:你同时启动"扣款"任务、"检查库存"任务和"计算运费"任务。要进入下一步——打印发票——你需要这三个任务的全部结果。这就是合并 vertex,也称为分布式 join 或屏障同步器。
在单线程环境中,这很简单:只需等待三个函数调用返回。但在分布式引擎中,这些任务运行在不同的机器上,完成时间可能相差数秒甚至数小时。其中一个可能失败,而其他的成功。这就是并行悖论:你越是为了速度而并行化工作,协调最终结果就越困难。
屏障同步的历史演进
要理解为什么合并状态如此困难,我们需要回顾高性能计算(HPC)的历史。在 20 世纪 70 和 80 年代,计算机科学家提出了屏障的概念。屏障是一个同步点,并行进程中的每个线程都必须在此停下,等待所有其他线程到达。只有当计数满足时,进程才能继续前进。
在单体系统中,这是通过共享内存中的自旋锁或互斥锁实现的。机器的 CPU 以近乎无限的速度管理屏障的状态。但当我们转向分布式系统时,就失去了"共享内存",也不再有单一的 CPU 充当仲裁者。
21 世纪初,Map-Reduce(Google 的奠基性论文)兴起。Map-Reduce 为并行处理提供了海量规模,但它是为"批处理工作负载"设计的。你先对数据执行 map,然后由一个"Reduce"阶段聚合所有结果。失败的 map 任务只需重新执行,但缓慢的任务会拖住 reduce 阶段;Google 的设计用备份任务来对付这些掉队者,在作业接近尾声时投机性地重新运行仍在进行中的任务。这就引出了合并状态时最重要的运维挑战:长尾问题。
长尾问题:最慢的分支说了算
在一个涉及 1,000 个条目的分布式 join 中,总执行时间并不由 worker 的平均速度决定,而是由最慢 worker 的延迟决定。如果 999 个条目在 10 毫秒内完成,但有 1 个条目因为数据库锁或网络抖动耗时 10 秒,那么整个工作流都要等待 10 秒。
这就是长尾问题。在朴素的实现中,它会导致资源占用大量堆积。当 999 个线程都在等待最后一个掉队者时,它们消耗内存、占用连接,还可能阻塞其他高优先级的工作流。
在 SchemaBridge 中,等待不会占用任何线程。每个分支完成后,会在合并的持久化 join 状态中记录自己的到达——即 PostgreSQL 中的一行 join 状态记录,分支完成事件通过 Valkey Streams 投递——然后立即退出。Merge vertex 在等待期间不消耗 CPU。当已到达的分支数达到预期数量时,引擎会分派该合并,工作流继续向前推进。这就是异步屏障同步,正是它让多分支业务逻辑能够等待数分钟乃至数小时而不占用资源。
处理迟到信号:游离信号问题
合并状态时一种特别棘手的故障模式是游离信号(stray signal):某个分支,或某个分支完成事件的重复投递,在系统已经向前推进之后才上报。服务并没有宕机,只是慢了,或者消息被重新投递了。
在遗留脚本中,这是一场灾难。迟到的信号到达你的代码,试图更新一个早已向前推进的状态。这可能导致重复扣款、数据库记录损坏,或某个步骤被执行两次。你不得不编写复杂的逻辑来"忽略已完成工作流的信号"。
SchemaBridge 依靠普通的状态守卫而非特殊机制来吸收这些信号。一个 join 只会被分派一次:分派合并是一次条件性的状态转换,因此第二次启动它的尝试会被拒绝。已经到达终态的步骤不能被再次完成,因此针对它的迟到完成事件会被记录为冗余并丢弃。此外,每次执行步骤的尝试都由一个 claim 纪元加以隔离,因此失去 claim 的 worker 无法覆盖接手它的 worker 的结果。这些都不会让执行变成 exactly-once——投递依然是 at-least-once——但游离信号无法让一个 join 推进两次。
糟糕同步的代价
管理不善的 join 不只是开发者的烦恼。设想一个假设的电商网站,它在每次商品搜索时都会对 50 家第三方供应商执行一次"比价聚合" join。
- 朴素做法:一个 Java 服务启动 50 个线程并使用
CountDownLatch。如果某个供应商在 p99 上很慢,用户的浏览器就会一直挂起直到它响应,页面看起来就像坏了。 - 等待的代价:普遍认为交互路径上的延迟会损害转化率。在给出具体数字之前,请先测量你自己的漏斗。
对于工作流引擎在这里的定位,要保持坦诚。SchemaBridge 的 MERGE 没有时间窗口:它在所有预期分支都到达时触发,而不是"500 毫秒后用已返回的结果"触发。如果截止时间很重要,就把它放进每个分支自己的调用里(为供应商请求设置较短的超时),让慢分支无论成功与否都能尽快结束。对于扇出,部分成功是通过容忍有限数量的失败分支来表达的,而不是通过截止时间。对延迟敏感的搜索页面,通常更适合由你应用自身的请求路径来处理,而把缓存刷新等较慢的后台工作交给工作流。
合并策略对比:Map-Reduce 与 Flow-Sync
| 特性 | Map-Reduce(大批量) | Apache Spark(流式) | SchemaBridge Flow-Sync |
| :--- | :--- | :--- | :--- |
| 侧重点 | 离线处理 | 近实时流 | 事务性业务逻辑 |
| 状态持久化 | 中间文件 | 检查点状态 | PostgreSQL 中的持久化 join 状态 |
| 错误处理 | 重新执行失败的任务 | 检查点/重启 | 按分支失败、重试或有限容忍 |
| Join 逻辑 | 基于键的 Shuffle | 时间窗口 Join | 基于图的依赖 |
| 持久性 | 高 | 高(借助检查点) | 高(可承受 worker 重启) |
"有状态 Join"大师课:你实际得到的是哪种 join?
并非所有合并都是"等待全部",而关于 SchemaBridge join 最需要了解的一点,就是在你不做选择时会得到哪一种。
1. 默认值:先到者胜(WAIT_ONE)
当多条边汇聚到一个普通 vertex 时,它的激活策略默认是 WAIT_ONE:该 vertex 只运行一次,由最先到达的分支触发,之后到达的会被丢弃。不会取消任何东西——较慢的分支仍会运行到结束——但它们的载荷永远不会到达 join。对于"使用三个天气服务商中最快的那个"来说,这恰恰是正确的,前提是你接受另外两个调用依然会发生。但如果你画了一个菱形、期待拿到全部三个结果,这恰恰是错误的,而且不会有任何错误提示你。这是整个平台中影响最大的默认值,所以请在你绘制的每一个汇聚点上检查它。
2. 等待全部的合并(屏障)
要收集所有分支,请使用 MERGE vertex,或将激活策略设为 WAIT_ALL。只有当所有预期分支都到达时 join 才会触发,并且你可以选择如何组合载荷:覆盖写入一个扁平对象、按分支作为键的映射,或者数组。
{
"type": "MERGE",
"config": {
"mergeStrategy": "NAMED_MAP"
}
}
如果任一分支失败,join 不会触发,运行会以可见的方式失败;已成功的分支不会自动回滚,因此补偿操作需要你显式建模。对于由 Spawner 创建的扇出,你可以选择改为容忍有限数量的失败子项。
3. 饥饿的 join:一条设计规则
永远不要把 WAIT_ALL 或 MERGE vertex 放在条件分支的下游。如果某个条件绕开了其中一个输入,该输入就永远不会到达,join 会永远等待一个本就不会运行的分支。如果你需要收集来自不一定都会执行的路径的结果,请使用 WAIT_ONE 并在 join 主体中处理缺失的数据,或者把条件完全移到某一个分支内部。
分布式合并专家清单
要构建有韧性的合并策略,请遵循以下经验法则:
1. 显式选择 join:每个汇聚点要么是先到者胜(WAIT_ONE),要么是等待全部(MERGE / WAIT_ALL)。明确你想要哪一种,而不是沿用默认值。
2. 限制每个分支,而不是合并本身:合并本身没有超时,因此请为每个分支内部的调用设置截止时间,并确保每个分支都能到达终态。
3. 让分支具备幂等性:投递是 at-least-once 的。确保分支即使运行两次,其副作用也是安全的。
4. 让条件远离屏障:永远不要从条件路径向等待全部的 join 输入数据。
5. 最小化状态大小:不要让不必要的数据穿过合并。只带上下一步需要的字段,以降低序列化成本。
6. 为部分成功做规划:并非所有业务逻辑都需要 100% 的输入。问问你的产品经理:"继续推进所需的最少可用数据是什么?"
结论:合并是分布式的最后疆域
没有受管同步的并行只是混乱。把 join 移入引擎的持久化状态,意味着长时间等待不会耗费线程,迟到或重复的信号无法让 join 触发两次,失败的分支会清晰可见,而不是悄无声息地丢失。引擎做不到的,是猜出你想要哪种 join——因此上述默认值和设计规则与底层机制同样重要。
你不应该再为互斥锁或闩锁操心;你应该专注于数据成功汇合之后发生的逻辑。
另请参阅:保护管道:事件层中的零信任,介绍如何保护多分支流程及其使用的凭据;以及 精通扇出,介绍如何首先生成这些分支。