分散トランザクションとSaga

分散トランザクションとSagaとは

「注文サービスで注文を作り、決済サービスで課金する」— この2つをどちらも成功か、どちらも起きなかったことにするにはどうすればいいでしょうか。

1つのDBの中なら [[トランザクションとACID]] の BEGIN / COMMIT / ROLLBACK で包むだけです。しかし [[マイクロサービス]] では各サービスが自分専用のDBを持ちます。ROLLBACK が効く範囲は1つのDBの中だけなので、注文DBのロールバックは決済DBに何の影響も与えません。決済が失敗しても、コミット済みの注文だけが残ってしまいます。

2フェーズコミット(2PC)と、それが避けられる理由

古典的な答えが2フェーズコミット(Two-Phase Commit、2PC)です。コーディネーター役が全参加者に「コミットできる?」と聞き(prepare)、全員YESなら「コミットせよ」を送ります(commit)。理屈のうえでは原子性が保てますが、実務では敬遠されます。

  • ロックが長く残る — YESと答えた参加者は指示が届くまで行をロックしたまま待ち、同じ行を触る他の処理も待たされます。コーディネーターが落ちればコミットも中止もできない宙ぶらりん(in-doubt)のまま止まります
  • 可用性が掛け算で落ちる — 各参加者が99.9%でも、5つ全員揃わないとコミットできないなら 0.999⁵ ≒ 99.5%。月の停止許容が約43分から約3.6時間へ悪化します
  • 外部サービスは参加できない — 決済代行のような外部APIに「prepare して指示まで待って」とは言えません。これが決定打になることが多いです

Saga — 補償トランザクションでつなぐ

Saga(サーガ)は、長い業務処理をローカルトランザクションの連鎖に分解する考え方です。各ステップは自分のDBだけで完結してすぐコミットするので、ロックを抱えません。途中で失敗したら、成功済みのステップを打ち消す処理を逆順に実行します。これが補償トランザクション(compensating transaction)です。

順方向: 注文作成 → 在庫引当 → 決済 → 配送手配
                          ↓ 決済失敗
補償  : 注文キャンセル ← 在庫引当の取消

ここが最大の落とし穴です。補償は ROLLBACK ではなく打ち消すための業務操作です。ステップはコミット済みで外から見えているため、「無かったことにする」のではなく「取り消した記録を新たに残す」しかありません。

  • 課金の補償は返金。明細には課金と返金の2行が残り、「課金しなかった」状態にはならない
  • 在庫引当の補償は在庫を戻す操作。その間に他の客が買っていれば元には戻らない
  • メール送信・出荷指示は補償できない。取り消せない操作は Saga のいちばん最後に置くのが鉄則です

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

Saga の進行を誰が管理するかで2つの型に分かれます。

オーケストレーション型コレオグラフィ型
進行役中央のオーケストレーターが順番に指示各サービスがイベントを発行し他が反応
見通し1箇所を読めば全体が分かる流れがどこにも書かれていない
向く場面ステップが多い・分岐や監査ログが要るステップが2〜3個で単純

コレオグラフィ型は書き始めは気持ちよいのですが、サービスが4つ5つと増えると「なぜこのイベントが発行されたのか」を追えなくなります。迷ったらオーケストレーション型が安全です。

Outbox パターン — DB更新とイベント送信をずらさない

各ステップはDBを更新したうえで次へ知らせる必要がありますが、素直に書くと壊れます。

await db.commit();          // ここまでは成功
await queue.publish(event); // この直前に落ちるとイベントは永久に消える

逆順にすると今度は「イベントは流れたのにDBは更新されていない」幽霊イベントが起きます。DBと [[メッセージキュー]] は別システムで、まとめてコミットする手段がないのです。

定番の解が Outbox パターンです。イベントを外部へ送る代わりに、同じDBトランザクションの中で outbox テーブルへ書きます

BEGIN;
INSERT INTO orders (id, status) VALUES ('o-1', 'created');
INSERT INTO outbox (event_type, payload) VALUES ('OrderCreated', '{"id":"o-1"}');
COMMIT;  -- 業務データとイベントが同時に確定する

あとは別プロセス(リレー)が outbox を読んでキューへ送り、送信済みの印を付けます。リレーが直後に落ちれば再送されますが、消えることはありません。「少なくとも1回」の配信になるので受信側の [[冪等性]] が必須です。リトライ設計は [[error-handling]] へ。

結果整合性を業務としてどう受け入れるか

Saga を採用した時点で「注文は確定したが決済はまだ」という中間状態が必ず存在します。これは技術で消せず、業務ルールとして定義するものです。航空券の「15分間の仮押さえ」も、カード明細の「翌営業日反映」も、結果整合性を業務が受け入れている例です。

決めるべきは3つ。中間状態に名前を付ける(PENDING_PAYMENT など)、利用者への見せ方(「処理中です」と出すか)、期限(30分経っても完了しない Saga は自動で補償する)。

初学者向けポイント

  • 分けなければこの問題は起きません。1つのDBで済むうちはモノリスのトランザクションが最善で、Saga は分けた代償です
  • Saga は ACID の I(独立性)を失います。中間状態が他から見えるので、在庫の二重引当は「引当済み」状態を明示的に持って自分で防ぎます
  • 補償トランザクション自体も失敗します。補償のリトライも必要で、ここでも [[冪等性]] が効いてきます
  • [[CAP定理と分散データベース]] は1つの分散DB内部で C と A のどちらを取るかという話、Saga は複数サービスにまたがるアプリケーション層の話です

関連技術とのつながり

  • [[マイクロサービス]] — DBを分けたことで生まれる整合性の課題に答える
  • [[トランザクションとACID]] — 1つのDBで完結する場合の答え。Saga はその範囲を超えたときの代替
  • [[CAP定理と分散データベース]] — 分散DB内部の整合性。層の違う議論として対比する
  • [[メッセージキュー]] — ステップ間をつなぐ非同期の土台であり Outbox の送り先
  • [[冪等性]] — 「少なくとも1回」配信とリトライを安全にする前提
Q: 2フェーズコミット(2PC)が実務で敬遠される理由として本文が挙げたのはどれ?
- [ ] SQLの構文が標準化されていないから
- [x] prepare から指示が届くまでロックを保持し続け、コーディネーター障害時に参加者が宙ぶらりんになるから
- [ ] 参加者が2つまでしか使えないから
解説: ロックを抱えたまま待つこと、コーディネーター障害でin-doubt状態になること、外部APIが参加できないことなどが理由です。

Q: Saga における補償トランザクションの説明として正しいのはどれ?
- [ ] コミット済みの処理をDBのROLLBACKで完全に無かったことにする
- [x] すでにコミットされた処理を打ち消すための業務操作を、新たに実行する
- [ ] 失敗したステップだけを何度も再実行し続ける
解説: 各ステップはすでにコミット済みなのでROLLBACKは効きません。返金や在庫戻しといった「打ち消す業務操作」を実行し、その記録が残ります。

Q: Outbox パターンが解決する問題はどれ?
- [x] DBのコミットとイベント送信がずれて、イベントが消えたり幽霊イベントが起きたりすること
- [ ] SQLの実行速度が遅いこと
- [ ] メッセージキューの保存容量が不足すること
解説: 業務データと同じトランザクションでoutboxテーブルへ書き、別プロセスが後からキューへ送ることで、DB更新とイベント送信の食い違いを防ぎます。