第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)なら、よくある流れはこうです
- DB トランザクション開始
- 複数テーブルに対して更新
- 問題なければコミット、失敗したらロールバック
ここでの「ロールバック」は、
「トランザクション開始前の状態に戻す」
という意味で、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-localretry-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 すればだいたいわかるはず」と言ってしまう
結果:
- インシデント時に、「今どこまで進んで、どこから補償したらいいか」が誰にも分からない
対策
- せめて
sagaIdとstepIdだけは全ログ・イベントに入れる - 状態遷移表(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はログやトレースに載っているか? -
RunningやCompensatingが異常に長く続いたときのアラートはあるか? -
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