94. Sagaは打ち消しで巻き戻す分散トランザクションである — 補償の連鎖をCatchで宣言する
単一DBならロールバック1命令で消える「なかったこと」を、複数のマイクロサービスを跨ぐと自分で逆再生の物語として書かねばならない——その巻き戻しをStep FunctionsのCatchでどう宣言するかを、図で追っていきます。
① なぜ分散ではACIDのロールバックが効かないのか
- 単一DBのACIDは、コミット前の中間状態をロック/分離で外から見えなくし、失敗時はROLLBACKという1命令で巻き戻す。サービスごとにDBが分かれると、この「1つのコミット境界」が消える。
- DynamoDB編で見た結果整合性の裏返しで、分散では「全員が一致した瞬間」を作ること自体が高くつく——だからその瞬間を強制するロックという発想を捨てる必要がある。
複数のマイクロサービスがそれぞれ自分のDBを持つと、単一DBのACIDのように「全部やるか全部やらないか」をロックで守れない。ここがSagaが解こうとする問題の出発点。
② Sagaの答え — ローカルトランザクションの連鎖と逆順の打ち消し
- 公式定義(AWS Prescriptive Guidance)——「The saga pattern is a failure management pattern that helps establish consistency in distributed applications」。
- 各トランザクションごとにイベントを発行し、「the next transaction is initiated based on the event's outcome. It can take two different paths, depending on the success or failure」——成功と失敗で別の道を辿る。
- 補償トランザクション(compensating transaction)は成功済みのローカルコミットを意味的に取り消す操作。どこで失敗したかによって巻き戻す範囲(=それまでに成功した分)が変わる。もしT2(決済)で失敗なら、走る補償はC1(注文キャンセル)だけ——公式の図でもProcessPaymentの失敗はCancel Order等の巻き戻しステップへ遷移する。
Sagaは処理をローカルトランザクションの連鎖として進め、途中で失敗したら、成功済みの分だけを逆順に補償トランザクションで巻き戻す失敗管理パターン。成功と失敗で別の道(two different paths)を辿るのが核心。
③ 使いどころと正直な代償 — 3条件と公式の警告
- 公式の警告(Important)——「The saga pattern is difficult to debug and its complexity increases with the number of microservices. The pattern requires a complex programming model that develops and designs compensating transactions for rolling back and undoing changes.」ロックを捨てた代わりに、巻き戻しの物語を自分で全部書く責任を負う。
Sagaは「密結合なしの整合性/長寿命処理/失敗時ロールバック」の3条件で効く一方、補償ロジックの設計はサービス数とともに複雑化する——タダの解決策ではないとAWS自身が明記している。
④ Step Functionsでの組み立て — 各TaskにCatchを付けて補償へ分岐させる
- Catchフィールド(catchers)を持てるのはTask・Parallel・Mapの3状態。各catcherはErrorEquals(捕まえるエラー名の配列)とNext(遷移先。ステート名に厳密一致する文字列)を必須で持つ。
- States.ALLはエラー名に広く一致するワイルドカードで、ErrorEquals配列内では単独で置き、かつCatch配列の最後のcatcherに置く必要がある——ただしStates.DataLimitExceededとStates.RuntimeだけはStates.ALLでも捕まえられない。
- RetryとCatchを両方持つ場合、Step Functionsは先にretrierを試し、解決できなければcatcherの遷移を適用する。
- 図中のステート名(CreateOrder等)は例示であり、公式サンプルの逐語ではない。
各Task状態にCatchを付け、失敗したら「そのステップまでの補償状態」へNextで遷移させる。Task/Parallel/Mapだけがcatcherを持てるので、補償が要る操作はこの3種の状態で表現する。これは前のRetry/Catch回で学んだ分岐機構を、そのまま巻き戻しの経路づくりに使っているだけ。
⑤ 補償は「何を打ち消すか」を知る必要がある — エラー出力をResultPathで運ぶ
- エラー出力のオブジェクトは通常Causeフィールド(人間可読の説明)を含む。
- catcherのResultPathは「catcherがNext先へ何を入力として送るか」を決めるJSONPath。指定するとエラー出力を入力に追記できる(公式例: "ResultPath": "$.error-info")。
- JSONPathワークフローで省略するとデフォルトは$となり、入力全体をエラー出力で上書きする。
ResultPathでエラー出力(Cause)を元の入力に追記して運べば、補償トランザクションは「何を打ち消すべきか」を知れる。ResultPathを省略するとエラー出力が入力を全部上書きしてしまい、打ち消し対象のID等が消える——だから補償を跨ぐSagaでは追記する形が定石。
⑥ 補償が事故らないための土台 — exactly-onceと冪等性
- Standard Workflowsは「exactly-once model, where your tasks and states are never run more than once, unless you have specified Retry behavior」(ASLでRetryを指定した場合を除く)。公式はこのexactly-onceモデルゆえに、決済処理のような非冪等(non-idempotent)な操作の編成にStandardが向くと明記している。
- Retryを明示すれば同じ操作が再実行され得るので、補償操作(RefundPayment等)は再試行に備えて冪等に作る——メッセージング編のat-least-once下の冪等性の実戦投入そのもの。
- Express(Async)はat-least-once、Express(Sync)はat-most-once。確実に最後まで走り切る耐久実行の土台(イベントログ回)が、この巻き戻しを途中で取りこぼさない前提になっている。
exactly-once実行(Standard)が「補償が二重に走る」事故を減らし、補償操作自体を冪等に作ることで再試行にも耐える。ロックで守る(悲観)のではなく失敗したら打ち消す(楽観)——DBの中ではロールバックは1命令、分散では自分で書く逆再生の物語である。