Skip to content
Published on

分散トランザクション:2PC vs Sagaパターン

Authors

はじめに — トランザクションがサービスを越える瞬間

一つのデータベース内では、トランザクションは頼もしい友です。BEGINで始めCOMMITで終えれば、その中のすべての変更がまるごと反映されるか、まるごと取り消されます。ところがシステムを複数のサービスに分割すると、この頼もしさが揺らぎます。注文サービス、決済サービス、在庫サービスがそれぞれ自分のデータベースを持つと、「注文を作り、決済を処理し、在庫を減らす」という一つの論理的作業が、三つの物理的に分離したデータベースにまたがります。

さあ、恐ろしい問いが戻ってきます。決済は成功したのに在庫減少が失敗したら? 一つのCOMMITで三つのデータベースを一挙に確定する方法はありません。これが**分散トランザクション(distributed transaction)**の問題であり、マイクロサービスが避けられない根本的な難しさです。本稿は二つの代表的アプローチ、2フェーズコミットとSagaパターンを対比し、その間で私たちが何を失い何を得るのかを整理します。

これらの概念につながるメッセージングを目で見たければ、このサイトのメッセージキュープレイグラウンドで非同期メッセージの流れを可視化できます。

なぜサービスをまたぐACIDが難しいのか

単一データベースのトランザクションが強力なのは、一つのシステムがすべてのデータを統制するからです。一つのトランザクション管理者がロックをかけ、WALに記録し、原子的にコミットを確定します。すべてが一つ屋根の下にあるので「全か無か」を保証しやすいのです。

複数のサービスに分かれると、この前提が崩れます。

  • 共有トランザクション管理者がありません。 各サービスのデータベースは自分のトランザクションしか知りません。サービスAのコミットとサービスBのコミットを一つに束ねる上位の存在は、デフォルトでは無いのです。
  • ネットワークが割り込みます。 サービス間通信はネットワークに乗ります。ネットワークは遅く、メッセージを失い、遅延させ、順序を入れ替え、そして何より切れます。「要求は行ったが応答が来なかった」が成功なのか失敗なのか、送る側には分かりません。
  • 部分障害が日常です。 単一プロセスでは「全体が死ぬか全体が生きるか」に近いですが、分散では「一部は成功し一部は失敗した」という中途半端な状態がよく起こります。

この三つのため、単一DBで当然だった原子性を複数サービスにまたがって再現するのは根本的に難しいのです。二つの筋の解法があります。一つは「それでも原子性を無理にでも実装しよう」という2フェーズコミット、もう一つは「原子性を諦めて代わりに結果整合性へ行こう」というSagaです。

2フェーズコミット(2PC)— 無理にでも原子性を作る

**2フェーズコミット(Two-Phase Commit, 2PC)は、複数の参加者(participant)のコミットを一つに束ねようとする古典的プロトコルです。中心にはコーディネータ(coordinator)**があり、名前の通り二段階で進みます。

フェーズ1 — 準備(prepare / voting)。 コーディネータがすべての参加者に「コミットする準備はできたか?」と尋ねます。各参加者は実際に変更をコミットはしませんが、コミットできるようすべての準備を済ませ(ロック確保、ログ記録)「はい(準備済み)」または「いいえ」で投票します。「はい」と答えたなら、その参加者は以降コーディネータがコミットを命じれば必ずコミットできなければなりません。

フェーズ2 — コミットまたは中断(commit / abort)。 すべての参加者が「はい」と答えれば、コーディネータは全員に「コミットせよ」と命じます。一つでも「いいえ」か応答が無ければ、全員に「中断せよ」と命じます。

  フェーズ1 (準備):
    コーディネータ --"準備できたか?"--> 参加者たち
    参加者たち     --"はい/いいえ"--> コーディネータ

  フェーズ2 (コミット/中断):
    全員が「はい」なら:
      コーディネータ --"コミット!"--> 参加者たち (全員コミット)
    一つでも「いいえ」/無応答なら:
      コーディネータ --"中断!"--> 参加者たち (全員ロールバック)

論理的にはきれいです。全員が準備できたのを確認してからコミットするので、原子性が守られるように見えます。しかし2PCには深刻な弱点があります。

ブロッキング(blocking)問題。 2PC最大の欠陥です。参加者が「はい(準備済み)」と投票した後、コーディネータがフェーズ2の命令を出す直前にコーディネータが死ぬと、参加者は身動きの取れない状態に陥ります。コミットせよとも中断せよとも聞いていないのに、自分勝手に決めることもできません(「はい」と約束したのですから)。この参加者はコーディネータが復活するまでロックを握ったまま無期限に待ちます。 そのロックに引っかかった他のトランザクションも一緒に止まります。

その他の障害モード。 準備フェーズは通ったのにコミット命令が一部の参加者にしか届かない場合、ネットワーク分断でコーディネータと一部の参加者が分かれる場合など、部分障害の組み合わせが多くあります。これに対処する変種(3PCなど)はありますが完全な解ではなく、複雑さが増すだけです。

ですから2PCは原子性を与えますが、その代償に可用性と性能を犠牲にします。コーディネータが単一障害点になり、準備-コミットの間にロックを長く握ってスループットが落ちます。サービスが多く遅延の大きいマイクロサービス環境で2PCがあまり使われない理由です。(逆に、よく統制された単一データセンター内部やXAトランザクションといった特定の状況では今も使われます。)

Sagaパターン — 原子性を諦め補償で置き換える

2PCが「原子性を守ろうとしてブロッキングを甘受する」なら、Sagaパターンは発想を裏返します。原子性を諦める代わりに、各ステップを個別のローカルトランザクションとしてコミットし、問題が起きたら既に行ったことを取り消す補償トランザクションで片付ける。

核心概念は**補償トランザクション(compensating transaction)**です。あるステップをコミットした後で後のステップが失敗したら、先にコミットしたものを「意味的に取り消す」別のトランザクションを実行します。ロールバックが「まだコミットしていないものを無かったことに」するなら、補償は「既にコミットして世界に反映されたものを取り消す」ことです。

注文の例で見ましょう。

  正常フロー (各ステップは独立にコミットされる):
    1. 注文作成    (コミット)
    2. 決済請求    (コミット)
    3. 在庫減少     (コミット)
    4. 配送予約     (コミット)

  ステップ3で在庫不足により失敗したら -> 補償を逆順に:
    2の補償: 決済返金
    1の補償: 注文取消

ここで重要な性質をいくつか押さえるべきです。

  • 補償は元の演算の完璧な逆とは限りません。「決済請求」の補償は「返金」ですが、返金は請求が無かった状態と完全に同じではありません(取引履歴が残り、手数料がつくこともあります)。補償は物理的な取り消しではなく意味的(semantic)な取り消しです。
  • 中間状態が外部に露出します。 Sagaが進行中の間、他の観察者は「決済は済んだが配送はまだ」という中間状態を見えます。2PCの分離と違い、Sagaは分離を保証しません。ですからアプリケーションはこうした中間状態に耐えるよう設計されねばなりません。
  • 補償自体も失敗しえます。 ですから補償は再試行可能で冪等(idempotent)でなければならず、最悪の場合は人が介入する経路も必要です。

Sagaはこうして原子性と分離を差し出す代わりに、ブロッキングの無い高い可用性とサービス間の疎結合を得ます。これがマイクロサービスでSagaが事実上の標準になった理由です。

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

Sagaを実際に実装する方式は大きく二つあります。誰が「次のステップへ進め」を決めるかが分かれ道です。

コレオグラフィ(choreography)— 中央指揮者のいない踊り。 各サービスが自分の仕事を終えるとイベントを発行し、他のサービスがそのイベントを購読して自分のステップを続けます。中央の調整者がいません。ちょうど踊り手たちが指揮者なしに互いの動きに反応して踊りを続けるようなものです。

  注文サービス: 「注文作成」イベント発行
       |
       v (購読)
  決済サービス: 決済処理 -> 「決済完了」イベント発行
       |
       v (購読)
  在庫サービス: 在庫減少 -> 「在庫確定」イベント発行
       |
       v (購読)
  配送サービス: 配送予約

利点はサービス間の結合が疎で、新しいサービスをイベント購読だけで差し込みやすいことです。欠点は全体のフローが複数のサービスに散らばり一目で把握しにくいことです。「今この注文はどのステップにいる?」を知るには複数のサービスを漁らねばならず、イベントが循環したり意図せず絡まったりするとデバッグが厄介です。

オーケストレーション(orchestration)— 指揮者のいる演奏。 中央の**オーケストレータ(orchestrator)**が全体のSagaを指揮します。オーケストレータが「では請求して」、応答を受け「では在庫を減らして」のように各ステップを順に呼び出し、失敗したら補償ステップを指示します。

  ┌───────────────── オーケストレータ ─────────────────┐
  │  1. 決済サービスに「請求」要求 -> 成功              │
  │  2. 在庫サービスに「減少」要求 -> 失敗!             │
  │  3. 補償: 決済サービスに「返金」要求               │
  │  4. 補償: 注文サービスに「取消」要求               │
  └───────────────────────────────────────────────────┘

利点は全体のフローが一箇所に集まり理解と追跡がしやすいことです。Sagaの状態をオーケストレータが持つので「今どのステップか」が明確です。欠点はオーケストレータがロジックの中心になって複雑化し、下手をすると再び単一のボトルネックや単一障害点になりうることです。

二つの選択は規模と複雑さ次第です。ステップが少なく単純ならコレオグラフィが軽く、ステップが多く流れが複雑で可視性が重要ならオーケストレーションが管理しやすいです。

二重書き込み問題 — なぜアウトボックスが必要か

Sagaであれイベントドリブンアーキテクチャであれ、実務で必ずぶつかる落とし穴が一つあります。二重書き込み(dual write)問題です。

状況はこうです。サービスがある作業をすると、二つのことをしなければならない場合が多くあります。(1) 自分のデータベースを更新し、(2) その事実を知らせるイベント/メッセージを発行します。問題は、この二つが別々のシステム(ローカルDBとメッセージブローカー)なので、一つのトランザクションに束ねられないことです。

  危険な順序:
    1. DBに注文を保存  (コミット成功)
    2. Kafkaに「注文作成」を発行  <- ここでプロセスが死んだら?

  結果: DBには注文があるのに、イベントは発行されない。
        下流サービスはこの注文を永遠に知らない。(不整合!)

順序を変えても同じです。イベントを先に発行してDB保存が失敗すれば、世界には「作成された注文」イベントが漂うのに実際のDBには注文がありません。どちらを先にしても、その間に死ねばDB状態と発行されたイベントが食い違います。

この問題の標準的な解法が**アウトボックスパターン(outbox pattern)**です。

アウトボックスパターン — 一つのローカルトランザクションに束ねる

アウトボックスパターンの核心はこうです。発行したいイベントを、ビジネスデータと同じデータベースの「アウトボックス」テーブルに、同じトランザクションで一緒に保存する。 すると「データ変更」と「イベント記録」が一つのローカルトランザクションに入るので、二つは原子的に一緒にコミットされるか一緒に失敗します。二重書き込みが単一書き込みに変わるのです。

  一つのローカルトランザクション:
    BEGIN
      INSERT INTO orders (...)         -- ビジネスデータ
      INSERT INTO outbox (event, ...)  -- 発行するイベント
    COMMIT   -- 両方コミットか両方ロールバック

  その後、別のプロセスが:
    アウトボックステーブルをポーリング(またはDBログを追跡)
      -> 新しいイベントをメッセージブローカーへ発行
      -> 発行成功したらアウトボックスで処理済み表示/削除

イベントを実際のブローカーへ送り出す方法は二つが一般的です。一つは**ポーリング発行者(polling publisher)で、別プロセスがアウトボックステーブルを定期的に読み、まだ送っていないイベントを発行します。もう一つはトランザクションログ追跡(transaction log tailing / CDC)**で、データベースの変更ログ(例: PostgreSQLのWAL)を読んでアウトボックスに挿入された行を検知し発行します(Debeziumのようなツールがこの方式です)。

ここで重要な点: アウトボックスはイベントが**「少なくとも一度(at-least-once)」発行されることを保証しますが、「正確に一度」を保証はしません。発行後「処理済み表示」の前に発行者が死ねば、同じイベントが再び発行されえます。ですからイベントを受ける側(消費者)は必ず冪等**でなければなりません。同じイベントを二度受け取っても結果が同じになるように、です。この冪等性と重複排除が分散システムの信頼性の核心であり、これは次の記事のテーマでもあります。

結果整合性 — 実務で意味すること

2PCを諦めSagaとアウトボックスへ行くと、私たちは強整合性の代わりに**結果整合性(eventual consistency)**を受け入れることになります。この言葉の意味を誤解なく整理しましょう。

結果整合性は「いつかはすべてのレプリカとサービスの状態が一致する」という保証です。ただしその間には不整合が観察されえます。 決済は処理されたのに注文の状態がまだ「決済完了」に変わっていない短い窓が存在しえます。Sagaがまだ進行中か、イベントがまだ伝播中だからです。

これが実務で意味することは具体的です。

  • UIとAPIが中間状態に耐えねばなりません。「処理中です」という状態をユーザーに見せるのが正直で安全です。即座に最終状態を約束しないでください。
  • 読んだ直後に自分の書き込みが見えないことがあります。 たった今作ったものをすぐ照会したのにまだ無いことがあります(「read-your-writes」がデフォルトで保証されない)。必要ならその部分だけ強整合性を別途確保します。
  • ビジネスが中間状態を定義せねばなりません。「決済は済んだが在庫確保は失敗」のような状況で何をユーザーに見せどう補償するかは、技術ではなくビジネスの決定です。

核心は、結果整合性が「整合性が無い」のではなく**「遅延した整合性」**だということです。この遅延を認め、その窓を設計に反映することが分散トランザクションを扱う実践的な感覚です。

実践的な指針

ここまでの内容を圧縮します。

できるなら分散トランザクションそのものを避けましょう。 一つのトランザクションに束ねねばならないデータがあるなら、それらを同じサービス・同じデータベースに置く境界設計が最善です。サービスを分ける線はトランザクション境界を尊重すべきです。

本当にサービスをまたがねばならないなら2PCよりSagaを優先しましょう。 2PCのブロッキングと単一障害点はマイクロサービスの可用性目標と衝突します。Sagaの結果整合性と補償のほうがたいてい現実的です。

流れが単純ならコレオグラフィ、複雑で可視性が重要ならオーケストレーション。 ステップが増え失敗処理が複雑になるほど、中央オーケストレータの追跡可能性が値打ちを持ちます。

イベントを発行するならアウトボックスパターンを基本にしましょう。 二重書き込みの不整合は、静かにデータを食い違わせる代表的なバグです。アウトボックスで「データ変更とイベント記録」を一つのローカルトランザクションに束ねましょう。

消費者を冪等にしましょう。 アウトボックスであれどのブローカーであれ「少なくとも一度」が現実です。重複受信を前提に設計しないと、必ず重複処理のバグが出ます。

おわりに

単一データベースのトランザクションは、一つのシステムがすべてを統制するから強力でした。システムを複数のサービスに分けた瞬間、その統制が散らばり、ネットワークと部分障害が割り込み、「全か無か」をそのまま再現するのが根本的に難しくなります。

これに対する二つの筋の解法が2PCとSagaです。2PCは原子性を守ろうとしてブロッキングと単一障害点を甘受し、Sagaは原子性を諦める代わりに補償トランザクションと結果整合性で高い可用性を得ます。そしてイベントドリブンな世界の二重書き込みの罠はアウトボックスパターンで手なずけます。その土台には常に冪等性という前提が敷かれています。

結局、分散トランザクションは「完璧な原子性という理想」と「可用なシステムという現実」の間の選択です。多くの場合、私たちは少しの遅延した整合性を受け入れ、その窓を設計で包む側を選びます。そのトレードオフを意識的に扱うとき、分散システムのトランザクションは恐怖ではなく、扱える工学になります。

参考資料