🐙

第6章 サーガと補償トランザクション — 「やり直し可能な会話」を組み立てる

に公開

サーガと補償トランザクション — 「やり直し可能な会話」を組み立てる

『The Worlds of Distributed Systems』第6章


「2つのサービスをまたぐトランザクションを、どうやって安全にやりますか?」

と聞かれると、だいたい次のどれかになります

  • 「2PC とか…? でも現実にはあまりやらないですよね」
  • 「イベント駆動で、最終的に整合するように…」
  • 「サーガで補償トランザクションを書けば…(そして誰も書かない)」

ここで出てくる「最終的に整合する」は、いわゆる

結果整合性(Eventual Consistency)

のことです

RML の観点で言い換えると、だいたいこんな感じになります

  • RML-2:結果整合性に「向かっている途中の世界」
  • RML-3:結果が整合した後、その履歴ごと「歴史として確定した世界」

この章では、RML-2(Dialog World)の世界で、

  • 結果整合性に向かう 途中の会話 をどう構造化するか
  • その会話を「やり直し可能」にするために サーガ(Saga) をどう設計するか

を整理していきます

前章では、RML-2 の世界で

  • 失敗を「どの世界の出来事か」で分ける
  • world / action を持つ StructuredError でラベル付けする
  • start-compensation / escalate-history / retry-with-backoff といった ActionHint で
    呼び出し側に「次にやるべきこと」を伝える

というところまで進みました

この章は、その上に乗る 「会話(サーガ)」の設計編です


1. トランザクションから「会話」へ

1.1 ローカルトランザクションと何が違うのか

単一サービス・単一DBの世界(RML-1)なら、よくある流れはこうです

  1. DB トランザクション開始
  2. 複数テーブルに対して更新
  3. 問題なければコミット、失敗したらロールバック

ここでの「ロールバック」は、

トランザクション開始前の状態に戻す

という意味で、DB が全部やってくれます

一方、複数サービスにまたがる処理(RML-2)の世界では、

  • Aサービスが Bサービスを呼ぶ
  • Bサービスは自分のDBを更新する
  • さらに Cサービスを叩く
  • その途中で何かが失敗する

といった 長い “会話” になります

このときに必要になるのが、

「会話全体として、どこまで巻き戻せるのか?」
「そのために、誰が何を undo するのか?」

を事前に決めておく仕組みです

これを「サーガ(Saga)」と呼びます

1.2 結果整合性と RML の位置づけ

イベント駆動アーキテクチャでは、

  • 各サービスが自分のローカル状態を更新し
  • イベントを通じて、他サービスも徐々に追従し
  • 最終的に「全体として整合した状態」に落ち着く

という構造をよく取ります

これが 結果整合性(Eventual Consistency) で、
RML の世界観で見ると、次のような分解になります

  • 各サービス内のローカル更新
    → RML-1 の世界(単一DB/プロセス)
  • サービス間でイベントが飛び交い、補償が動くプロセス全体
    → RML-2 の世界(結果整合性に向かっている最中)
  • 一定時間が経ち、すべてのイベントと補償が適用され、
    それが監査可能な履歴として残る状態
    → RML-3 の世界(整合した「歴史」として確定)

サーガは、この RML-2 の過程 をきちんと管理して、

  • 巻き戻せるところは巻き戻し
  • 巻き戻せないところは RML-3 に送る

という線引きを行うための枠組みだと考えられます


2. サーガとは「やり直し可能な会話」である

2.1 ざっくり定義

この本の文脈では、サーガをこう定義します:

サーガ = 「RML-2 の世界」で起こる一連のステップと、
それぞれに対応する補償ステップ(undo)の組で構成された会話

もう少し噛み砕くと

  • T1, T2, T3, ... といった 前向きのステップ
  • C1, C2, C3, ... といった 逆向きの補償ステップ

をペアで設計しておき、

  • T1 が成功したら T2 に進む
  • 途中で失敗したら、成功済みのステップを逆順に C* で戻していく

という構造です

図にするとこんな感じ

T1 → T2 → T3
↑    ↑    ↑
C1   C2   C3   (失敗したら C を逆順に実行)

2.2 RMLの世界観との対応

RML-2 の世界では、サーガの各ステップは次のように分類できます

  • T* / C* どちらも 基本は RML-2
    (他サービスやDBとの対話が前提)

  • ただし、ステップの中で

    • RML-1 レベルのローカル作業
    • 場合によっては RML-3 レベルの不可逆操作
      (ここが境界)

になります

サーガ設計で一番大事なのは、

「どのステップまでが RML-2 で巻き戻せて、
どこから先は RML-3(履歴行き)なのか」

を、最初にハッキリさせることです


3. サーガのステートマシンとしての世界観

サーガを「1つの世界」として扱うとき、
そのライフサイクルはだいたいこんな状態遷移になります

それぞれの状態の意味は:

  • Pending: サーガまだ始まっていない(予約だけされた状態)
  • Running: T1 → T2 → ... と前向きステップを実行している最中
  • Completed: 全ステップ成功、前向きに完了
  • Compensating: どこかで start-compensation して、C* を実行中
  • Compensated: すべての補償完了世界は「サーガ前」と同等の状態
  • Escalated: RML-3 レベルにエスカレーションされた
    今後は Effect Ledger や人間オペの世界

前章の StructuredError と合わせると

  • world: "RML2", action: "start-compensation"
    Running → Compensating
  • world: "RML3", action: "escalate-history"
    Running or Compensating → Escalated

という形で、例外がステートマシンのトリガになるイメージです


4. 補償トランザクションをどう設計するか

4.1 「本当に元に戻るか?」ではなく「意味として帳尻が合うか?」

補償トランザクション(Compensating Transaction)は、

「ステップ T が行ったことを、別のステップ C で“だいたい”取り消す」

ものですが、ここでよくある誤解がこれです

「完全に元の状態に戻せなきゃダメ」

現実はそんなにきれいにいきません

  • 「元に戻せないから RML-3 行き」
  • 「元に戻せる範囲だけ RML-2 の補償で頑張る」

のどちらかを選ぶことになります

大事なのは、

「ビジネス的に帳尻が合うか?」

です

  • 商品を出荷してしまった後でキャンセルされた
    → 補償は「返金 + 返品フロー案内」かもしれない
  • ポイントを付与した後に注文取消し
    → 補償は「ポイントを取り消す + ログに残す」

**「元どおり」ではなく、「受け入れ可能な終状態」**を定義するイメージです

4.2 パターン:予約 → 確定(Reserve/Confirm)

サーガでよく使われるのが、この2段階パターンです

  • T1: Reserve(予約)
  • T2: Confirm(確定)
  • C1: CancelReserve(予約の取り消し)
T1: 席の予約           ←→  C1: 予約の取り消し
T2: 決済の確定 (RML3境界)
  • T1 は RML-2 の世界(予約なので巻き戻し可能)
  • T2 は RML-3 の境界(決済など)

「まず RML-2 で reversible な予約を取り、その後 RML-3 に踏み込む」
という構造にしておくと、

  • 決済前に失敗したら → 予約だけキャンセル(RML-2で完結)
  • 決済後に失敗したら → RML-3 にエスカレート(返金等)

という形で、世界の切り替えがやりやすくなります


5. オーケストレーション vs コレオグラフィ

サーガの実装パターンとして、よく出てくる二つのスタイルがあります

5.1 オーケストレーション型

中央に “指揮者” 役(オーケストレーター)がいて、
各サービスを順番に呼び出すスタイル
です

  • サーガの状態(どこまで進んだか、どこまで補償したか)をオーケストレーターが持つ
  • ActionHint や StructuredError を解釈するのもオーケストレーター

メリット

  • 状態が一箇所にまとまる
  • フローの可視化・変更がしやすい
  • 5章で出てきた callWithRmlHandling のような共通ロジックを
    まとめて置きやすい

デメリット

  • オーケストレーター自体が「神クラス」化しやすい
  • 1箇所が落ちると、全サーガが止まる

5.2 コレオグラフィ型

各サービスがイベントを購読しながら、自分の役割を果たしていくスタイルです

  • 「Aがこれをやったら、そのイベントを見てBが動く」
    「Bが終わったら、そのイベントを見てCが動く」
  • サーガの状態は、イベントストリーム上に暗黙的に存在する

メリット

  • 各サービスの独立性が高い
  • スケーラブルにしやすい

デメリット

  • 全体フローが見えづらい
  • どこまで進んでいて、どこから補償が必要かを追うのが難しい

RML視点でいうと、

  • オーケストレーション型では
    → オーケストレーターが RML-2 の世界の中心
  • コレオグラフィ型では
    → Effect Log / Event Stream が RML-2 の世界の中心

になります

どちらを選ぶにしても、

「サーガ全体としての状態はどこで観測できるのか?」

だけは最初に決めておくのが大事です


6. 実装パターン:ID・Idempotency Key・ログ・タイムアウト

サーガを現実的に運用するには、いくつかの小さな約束事が必要です

6.1 サーガIDとステップID、Idempotency Key

  • 各サーガには sagaId
  • 各ステップには stepId
  • 各外部呼び出しには idempotency key(冪等キー)

を振っておくと、だいぶ扱いやすくなります

type SagaStepLog = {
  sagaId: string;
  stepId: string;
  status: "pending" | "done" | "compensating" | "compensated" | "failed";
  world: "RML1" | "RML2" | "RML3";
  action?: ActionHint;
  errorCode?: string;
  idempotencyKey?: string;
  timestamp: string;
};

ここで特に重要なのが idempotency key です

冪等キーは、RML-2 の世界における「リトライ時の命綱」です

前章で出てきた ActionHint のうち、

  • retry-local
  • retry-with-backoff

を使うと決めた瞬間に、同じ idempotency key で再実行できることを前提にしています

つまりルールとしては

ActionHint で retry-* を返すなら、必ず Idempotency Key をセットで渡すこと

これを徹底しておかないと、

  • リトライのたびに同じ請求が二重三重に走る
  • 下流サービスの状態が「一見成功しているが実はダブっている」

といった事故が起きます

簡単な具体例を出すと

async function chargePayment(sagaId: string, payload: ChargeRequest) {
  const idempotencyKey = `saga:${sagaId}:charge`;

  try {
    return await paymentGateway.charge(payload, { idempotencyKey });
  } catch (e) {
    throw new StructuredError({
      world: "RML2",
      severity: "error",
      action: "retry-with-backoff",
      code: "PAYMENT_TEMPORARY_ERROR",
      message: "決済ゲートウェイで一時的なエラーが発生しました",
      cause: e,
    });
  }
}

呼び出し側は、5章で出てきた callWithRmlHandling のような共通ルーチンを通して、

  • action === "retry-with-backoff" のときに
  • 同じ idempotencyKey で再実行する

という形で統一しておくと、安全にリトライできます

6.2 タイムアウトとハートビート

サーガは長生きすることが多いので、

  • 「何時間も Running のまま固まっているサーガ」
  • 「補償中にどこかが落ちて止まったサーガ」

が必ず出てきます

ここで効いてくるのが、

  • ハートビート(定期的な進捗記録)
  • ステータス監視
  • 「一定時間動いていなければ RML-3 にエスカレート」ルール

です

5章の world / severity / action をタグとして付けておくと、

  • world = "RML2", status = "running", lastUpdate > 1h
    → 「サーガが詰まっている」アラート
  • world = "RML2", status = "compensating", lastUpdate > 30m
    → 「補償中に止まっている」アラート

などを Observability 側で簡単に引けます


7. サーガの境界:ここから先は RML-3

サーガの設計で一番危ないのは、

「サーガを頑張れば、全部 RML-2 でなんとかなるはず」

と思い込むことです

実際には、

  • RML-2 で巻き戻せる範囲(予約・DB更新・通知など)
  • RML-3 に送るべき範囲(決済・法的に重要な記録など)

境界線をはっきり引く ことの方がずっと重要です

7.1 実務的なルール例

例えば、こんなふうにルール化できます

  • 原則、金銭の移動は RML-3
    → サーガの中では「決済依頼イベントを発行する」ところまで
    決済結果は Effect Ledger と reconciler の世界
  • ユーザーへの外部通知(メール・SMSなど)は RML-2.5〜3
    → 一度外に出た情報は完全には戻せない
    補償は「訂正通知」や「管理画面からのアナウンス」
  • 内部の状態遷移・在庫管理などはできる限り RML-2 に留める
    → 予約/確保/解放で回せるように設計する

これらは、結果整合性の文脈でよく出てくる

  • 「どのリソースを最終的なソース・オブ・トゥルースにするか」
  • 「どの変更イベントを RML-3 の台帳に記録するか」

という話とも自然につながります

こうしたドメインごとのルールを決めておくと、

「このステップをサーガの中でやるのは危険なので、
別の RML-3 ワークフローに分離しよう」

といったアーキテクチャ判断がしやすくなります


8. ありがちなアンチパターン

サーガ設計まわりでよく見る地雷も、いくつか挙げておきます

8.1 サーガの途中で副作用を隠している

  • ステップ T2 の中で、こっそり外部APIに永久的な変更を出している
  • なのに C2 ではその副作用を扱っていない

結果:

  • 「補償したはずなのに、どこかの世界に痕跡が残る」
  • しかもログからは見えない

対策

  • ステップ内の副作用を棚卸しして、RML-1/2/3 ごとに分類する
  • RML-2 の副作用は必ず C* のどこかで扱う

8.2 「補償」でごまかして RML-3 を認めない

  • 意味的には RML-3(決済確定後、商品も発送済み)なのに、
  • 「一応サーガの C* を書いたから RML-2 です」と言い張る

結果:

  • エンジニアは「巻き戻せる」と思い込む
  • 法務・ビジネスから見ると「いやそれ全然戻ってない」

対策

  • 6章前半でやったように、「受け入れ可能な終状態」を RML-3 として定義する
  • 補償で届かない部分は、最初から History World として扱う

8.3 状態遷移がどこにも可視化されていない

  • サーガIDもステップログもなく、
  • 「ログをgrep すればだいたいわかるはず」と言ってしまう

結果:

  • インシデント時に、「今どこまで進んで、どこから補償したらいいか」が誰にも分からない

対策

  • せめて sagaIdstepId だけは全ログ・イベントに入れる
  • 状態遷移表(Pending / Running / Compensating / …)を一枚絵にする

9. サーガ設計のチェックリスト

最後に、RML-2 の世界でサーガを設計するときのチェックリストを置いておきます

9.1 ビジネス観点

  • このサーガは、**どんな一続きの「会話」**を表しているか?

  • その会話の中で、

    • RML-2 で巻き戻せるステップ
    • RML-3 行きにすべきステップ
      が明確になっているか?
  • 各ステップに対して、「受け入れ可能な終状態」が定義されているか?

9.2 技術設計観点

  • 各ステップ T* に対して、対応する補償 C* は定義されているか?

  • sagaId / stepId / idempotency key は設計されているか?

  • ActionHint で retry-* を返すとき、常に同じ idempotency key で再実行できるか?

  • サーガの状態は、どこで一元的に観測できるか?

    • オーケストレーター?
    • イベントストリーム?
  • タイムアウトやハートビートの仕組みはあるか?

9.3 運用・Observability観点

  • 5章の world / severity / action はログやトレースに載っているか?
  • RunningCompensating が異常に長く続いたときのアラートはあるか?
  • world = "RML3" にエスカレートしたサーガは、インシデント管理フローに確実に乗るか?

10. おわりに — サーガは「RML-2の世界を正直に受け入れる」ための道具

サーガと補償トランザクションは、魔法の道具ではありません

  • すべてを RML-1 に戻してくれるわけでも
  • すべてを RML-3 の責任から解放してくれるわけでもない

むしろ、

RML-2 の世界には「完全なロールバック」という幻想はない
という事実を受け入れるためのフレームワーク

だと思った方が安全です

  • 巻き戻せるところは、ちゃんと会話として巻き戻す(サーガ)
  • 巻き戻せないところは、最初から History World として設計する(RML-3)
  • その境界を、例外・ログ・API・組織で共有する

結果整合性の文脈でよく語られてきた「最終的に整合する世界」を、RML の言葉で言い換えると、

  • RML-2:整合性に向かって揺れ動き続ける対話の世界
  • RML-3:その揺れが一段落し、記録と責任が確定した歴史の世界

になります

次の章では、このサーガの世界観を
API / クライアント設計(BFF や SDK) にどう埋め込んでいくかを掘り下げていきます

Discussion