マージを極める:並列世界における状態の協調

SchemaBridge Team · 2025-12-29 · Concurrency, State Management, Synchronization

並列ブランチを競合状態なしに同期する。永続的な join、ロングテール問題、そしてどの join になるかを決めるデフォルト。

並列性のパラドックス:自由 vs. 同期

パフォーマンスを追い求める中で、私たちは並列性を受け入れてきました。タスクをファンアウトし、非同期ワーカーを起動し、データを多数のノードに分散させます。スループットは向上しますが、その代償として複雑さという高い代価を払います。分散システムで難しいのは、物事を同時に開始することではなく、それらを再び一つにまとめることです。

複雑な注文処理のフローを想像してください。「カード決済」タスク、「在庫確認」タスク、「送料計算」タスクを同時に起動します。次のステップ(請求書の印刷)に進むには、3つすべての結果が必要です。これがマージ vertexであり、分散 join あるいはバリア同期機構とも呼ばれます。

シングルスレッド環境なら簡単です。3つの関数呼び出しが返るのを待つだけです。しかし分散エンジンでは、これらのタスクは別々のマシン上で実行され、完了するタイミングが数秒、場合によっては数時間ずれることもあります。1つが失敗し、他は成功することもあります。これが並列性のパラドックスです。速度を得るために処理を並列化すればするほど、最終結果の協調は難しくなります。

バリア同期の歴史的変遷

状態のマージがなぜこれほど難しいのかを理解するには、高性能計算(HPC)の歴史を振り返る必要があります。1970〜80年代、計算機科学者たちはバリアという概念を生み出しました。バリアとは同期点であり、並列プロセスのすべてのスレッドはそこで停止し、他のすべてのスレッドが到着するまで待たなければなりません。カウントが満たされて初めて、プロセスは先へ進めます。

モノリシックなシステムでは、これは共有メモリ上のスピンロックやミューテックスで実装されていました。マシンの CPU がほぼ無限の速度でバリアの状態を管理していたのです。しかし分散システムへ移行したことで、私たちは「共有メモリ」を失いました。調停役となる単一の CPU はもはや存在しません。

2000年代には Map-Reduce(Google の画期的な論文)が台頭しました。Map-Reduce は大規模な並列処理を可能にしましたが、「バッチワークロード」向けに設計されていました。データを map し、その後すべての結果を集約する「Reduce」フェーズがあります。失敗した map タスクは単に再実行されましたが、遅いタスクは reduce フェーズを足止めしました。Google の設計は、ジョブの終盤にまだ実行中のタスクを投機的に再実行するバックアップタスクで、こうした落伍者に対処していました。ここから、状態のマージにおける最大の運用上の課題、ロングテール問題が見えてきます。

ロングテール問題:最も遅いブランチが勝つ

1,000 件の要素を対象とする分散 join では、全体の実行時間はワーカーの平均速度ではなく、最も遅いワーカーのレイテンシによって決まります。999 件が 10 ms で終わっても、1 件がデータベースのロックやネットワークの一時的な不調で 10 秒かかれば、ワークフロー全体が 10 秒待つことになります。

これがロングテール問題です。素朴な実装では、リソース使用量が大幅に積み上がります。999 本のスレッドが最後の1件を待つ間、メモリを消費し、コネクションを占有し、場合によっては優先度の高い他のワークフローをブロックします。

SchemaBridge では、待機がスレッドを占有することはありません。各ブランチは完了すると、マージの永続的な join 状態(PostgreSQL 上の join 状態の行。ブランチの完了は Valkey Streams 経由で配信されます)に到着を記録し、即座に終了します。Merge vertex は CPU を消費せずに待機します。到着したブランチ数が期待数に達すると、エンジンがマージをディスパッチし、ワークフローは先へ進みます。これが非同期バリア同期であり、複数ブランチを持つビジネスロジックがリソースを占有せずに数分から数時間待機できるのはこの仕組みのおかげです。

遅延シグナルへの対処:迷子シグナル問題

状態のマージにおける特に厄介な障害モードが迷子シグナル(stray signal)です。これは、システムがすでに先へ進んだ後に報告してくるブランチ、またはブランチ完了の重複配信を指します。サービスが停止していたわけではなく、単に遅かったか、メッセージが再配信されただけです。

レガシーなスクリプトでは、これは大惨事です。遅れて届いたシグナルがコードに到達し、すでに先へ進んだ状態を更新しようとします。その結果、二重課金、データベースレコードの破損、あるいはステップの二重実行が起こりえます。「完了済みワークフローのシグナルを無視する」ための複雑なロジックを書かなければなりません。

SchemaBridge は、特別な仕組みではなく通常の状態ガードによってこうしたシグナルを吸収します。join がディスパッチされるのは一度だけです。マージのディスパッチは条件付きの状態遷移なので、2回目の開始試行は拒否されます。すでに終端ステータスに達したステップは再び完了させることができないため、そのステップに対する遅れた完了通知は冗長として記録され、破棄されます。さらに、ステップの各実行試行は claim エポックによって保護されているため、claim を失ったワーカーが、引き継いだワーカーの結果を上書きすることはできません。これらはいずれも実行を exactly-once にするものではなく、配信は依然として at-least-once です。しかし、迷子シグナルが join を二度進めることはできません。

不適切な同期のコスト

管理の甘い join は、開発者の頭痛の種にとどまりません。仮想的な EC サイトを考えてみましょう。このサイトは商品検索のたびに、50 社のサードパーティベンダーに対して「価格アグリゲーター」の join を実行します。

ここでワークフローエンジンがどこに適しているかについては、正直であるべきです。SchemaBridge の MERGE には時間ウィンドウがありません。期待されるすべてのブランチが到着した時点で発火するのであって、「500 ms 経過後に返ってきたものだけで」発火するわけではありません。期限が重要なら、各ブランチ自身の呼び出しの中に入れてください(ベンダーへのリクエストに短いタイムアウトを設定する)。そうすれば、遅いブランチも成功・失敗にかかわらず素早く終了します。ファンアウトでは、部分的な成功は期限ではなく、失敗したブランチを一定数まで許容することで表現します。レイテンシが重要な検索ページは、通常はアプリケーション自身のリクエスト経路で処理し、キャッシュ更新のような時間のかかるバックグラウンド処理をワークフローに任せるほうが適しています。

マージ戦略の比較:Map-Reduce vs. Flow-Sync

| 特徴 | Map-Reduce(大規模バッチ) | Apache Spark(ストリーミング) | SchemaBridge Flow-Sync |

| :--- | :--- | :--- | :--- |

| 主眼 | オフライン処理 | ほぼリアルタイムのストリーム | トランザクショナルなビジネスロジック |

| 状態の永続化 | 中間ファイル | チェックポイントされた状態 | PostgreSQL 上の永続的な join 状態 |

| エラー処理 | 失敗したタスクを再実行 | チェックポイント/再起動 | ブランチ単位の失敗、リトライまたは上限付きの許容 |

| join ロジック | キーベースのシャッフル | 時間ウィンドウ join | グラフベースの依存関係 |

| 耐久性 | 高 | 高(チェックポイント使用時) | 高(ワーカーの再起動に耐える) |

「ステートフル join」マスタークラス:実際にはどの join になっているか?

すべてのマージが「全員を待つ」わけではありません。SchemaBridge の join について知っておくべき最も重要なことは、何も選ばなかったときにどの join になるかです。

1. デフォルト:最初の到着が勝つ(WAIT_ONE)

複数のエッジが通常の vertex に合流する場合、そのアクティベーション戦略のデフォルトは WAIT_ONE です。vertex は最初に到着したブランチをトリガーとして一度だけ実行され、後から到着したものは破棄されます。何もキャンセルされるわけではなく、遅いブランチも最後まで実行されますが、そのペイロードが join に届くことはありません。「3 つの天気プロバイダーのうち最速のものを使う」場合には、残り 2 つの呼び出しも実行されることを受け入れる限り、まさに正しい動作です。しかし、3 つすべての結果を期待してダイヤモンド型を描いた場合にはまさに誤った動作であり、それを知らせるエラーも出ません。これはプラットフォームで最も影響の大きいデフォルトなので、合流を描くたびに必ず確認してください。

2. 全員を待つマージ(バリア)

すべてのブランチを収集するには、MERGE vertex を使うか、アクティベーション戦略を WAIT_ALL に設定します。join は期待されるすべてのブランチが到着したときにのみ発火し、ペイロードの結合方法を選べます。1 つのフラットなオブジェクトへの上書き、ブランチをキーとするマップ、または配列です。

{
  "type": "MERGE",
  "config": {
    "mergeStrategy": "NAMED_MAP"
  }
}

いずれかのブランチが失敗すると、join は発火せず、実行は目に見える形で失敗します。成功したブランチの自動ロールバックはないため、補償アクションは明示的にモデル化する必要があります。Spawner が作成したファンアウトでは、代わりに失敗した子を一定数まで許容するようオプトインできます。

3. 飢餓状態の join:設計ルール

WAIT_ALL や MERGE vertex を条件付きブランチの下流に置いてはいけません。条件によって入力の1つが迂回されると、その入力は決して到着せず、join は実行されるはずのなかったブランチを永遠に待ち続けます。すべてが実行されるとは限らない経路から結果を集める必要がある場合は、WAIT_ONE を使って join 本体で欠損データを処理するか、条件を完全に1つのブランチの内部に移してください。

分散マージのためのエキスパート・チェックリスト

回復力のあるマージ戦略を構築するために、次の指針に従ってください。

1. join を明示的に選ぶ:すべての合流は、最初の到着(WAIT_ONE)か全員待ち(MERGE / WAIT_ALL)のどちらかです。デフォルトを引き継ぐのではなく、どちらを意図しているかを決めてください。

2. マージではなく各ブランチを制限する:マージ自体にはタイムアウトがないため、各ブランチ内の呼び出しに期限を設定し、すべてのブランチが終端状態に達するようにしてください。

3. ブランチを冪等にする:配信は at-least-once です。ブランチが二度実行されても副作用が安全であるようにしてください。

4. 条件分岐をバリアから遠ざける:条件付きの経路から全員待ちの join に入力してはいけません。

5. 状態のサイズを最小化する:不要なデータをマージに通さないでください。シリアライズのコストを抑えるため、次のステップが必要とするフィールドだけを持ち込みましょう。

6. 部分的な成功を想定する:すべてのビジネスロジックが入力の 100% を必要とするわけではありません。プロダクトマネージャーに「先へ進むために必要な最小限のデータは何か?」と尋ねてください。

結論:マージは分散の最後のフロンティア

管理された同期のない並列性は、単なる混沌です。join をエンジンの永続的な状態に移すことで、長い待機がスレッドを消費することはなくなり、遅延や重複したシグナルが join を二度発火させることもなくなり、失敗したブランチは黙って失われるのではなく可視化されます。エンジンにできないのは、あなたがどの join を意図したかを推測することです。だからこそ、上記のデフォルトと設計ルールは仕組みそのものと同じくらい重要なのです。

ミューテックスやラッチに悩まされるべきではありません。データが無事に一つにまとまった後に実行されるロジックに集中すべきです。

関連記事:パイプラインの保護:イベント層におけるゼロトラスト(複数ブランチのフローとそれが使う認証情報の保護について)、および ファンアウトを極める(そもそもブランチをどう生み出すかについて)。

詳しく見る