マイクロサービスを採用すると、ほぼ確実にぶつかる壁が「複数の DB や、DB とメッセージブローカーをまたぐ書き込みを、どう一貫性を保って扱うか」という問題である。この記事では、この問題に対する代表的な3つのアプローチ — 2PC・Saga・Outbox — を端的に整理する。
前提: なぜモノリスでは問題にならなかったのか
モノリスの場合、すべてのテーブルが同一 DB に存在するため、BEGIN; ... COMMIT; で囲むだけで原子性・一貫性・分離性・永続性(ACID)が DB エンジンによって保証される。アプリ側は「トランザクション境界を切るだけ」で済む。
ところが、サービスを分割して DB も分けた瞬間、この前提が崩れる。異種リソースをまたぐ COMMIT は、DB エンジンの責務の外になるからである。ここで初めて、分散トランザクションの設計が必要になる。
1. 2PC(Two-Phase Commit / 二相コミット)
「全部 COMMIT」か「全部 ROLLBACK」かを、コーディネーターが2フェーズで決めるプロトコルである。
仕組み
- Phase 1(Prepare): コーディネーターが各参加者に「COMMIT できるか?」と問い合わせる。参加者はロックを取得し、Prepared 状態で Yes/No を返す
- Phase 2(Commit): 全員 Yes なら COMMIT 命令、誰か No なら ROLLBACK 命令を送る
長所
- 強い一貫性(即時整合性)を保証する
- プロトコル自体は単純明快である
短所
- ブロッキング: Prepare 後にコーディネーターが落ちると、参加者はロックを握ったまま固まる
- 可用性が低い: 1台でも遅い・落ちると全体が止まる
- 異種リソースとの相性が悪い: XA 等の 2PC 対応リソースマネージャである必要があり、Pub/Sub・Kafka・HTTP API などは通常その枠外にある(Kafka には独自の transaction API があるが XA とは別物)
- マイクロサービスの設計思想と相性が悪い: 「サービスを分けたのに片方が落ちると全体が止まる」のは本末転倒である
ACID の観点
| ACID | 2PC での担保 |
|---|---|
| Atomicity(原子性) | ◎ 全参加者で同時に COMMIT か ROLLBACK が決まる |
| Consistency(一貫性) | ◎ COMMIT 完了時点で全リソースが揃っている(即時整合性) |
| Isolation(分離性) | ◎ Prepare 中はロックで他トランザクションから隔離される |
| Durability(永続性) | ◎ COMMIT 後は各参加者で永続化される |
…と理想だけ見れば完璧だが、その代償として可用性を犠牲にしている。
適する場面
ほぼ無い。マイクロサービス間では避けるのが定石である。XA 等のトランザクションマネージャ配下で異種リソース(複数の DB、DB + MQ など)を確実に揃えたいレガシー寄りの場面で、限定的に検討する。
2. Saga パターン
長期トランザクションを、各ステップのローカルトランザクションと「補償トランザクション」の連鎖で構成するパターンである。
仕組み
- 各ステップ Ti は独立して COMMIT する
- 途中で失敗したら、それまで成功した Ti の効果を打ち消す補償 Ci を逆順で実行する
- 補償は「物理的なロールバック」ではなく「業務的に無かったことにする」操作である(例: 引き出しの補償は再入金)
実装方式
- Choreography(振付): 中央調整者を持たず、各サービスがイベントを発火・受信して次のステップへ進む。疎結合だがフローが見えにくい
- Orchestration(指揮): 中央のオーケストレーターがフローを持つ。可視化・テストしやすいが中央集権化する
長所
- 可用性が高い(参加者全員を同期で待たない)
- 異種リソースに対応できる
- マイクロサービスとの相性が良い
短所
- ACID の "I"(Isolation)を犠牲にする: 中間状態が他から見えるため、UI 側で「セットアップ中」状態を扱う設計が必要である
- 補償ロジックの実装コストが高い: 各ステップ分の打ち消し操作を設計しなければならない
- 打ち消し不能な副作用(メール送信・外部 API 通知など)の扱いが難しい
ACID の観点
| ACID | Saga での担保 |
|---|---|
| Atomicity(原子性) | ○ 「全ステップ成功」または「補償で業務的に無かったことになる」を最終的に保つ |
| Consistency(一貫性) | ○ 各ステップは独立して COMMIT、全体は結果整合性 |
| Isolation(分離性) | ✕ これを諦めるのが本質。中間状態が他のサービスやユーザーから見える |
| Durability(永続性) | ◎ 各ステップのローカル COMMIT で永続化される |
ACID の "I" を諦め、"A" を補償で取り戻すのが Saga の本質である。
適する場面
複数サービスをまたぐ業務フロー全般。テナント登録・注文処理・決済フローなど。
3. Outbox パターン
「業務データの書き込み」と「外部システムへの通知」を、確実にズレなく揃えるためのパターンである。Saga のような大きなフロー設計ではなく、もっと小さな「書いた事実とイベント発行を一致させる装置」である。
解きたい問題: dual write
そもそも何が困るのかというと、素朴に書くとこうなる:
BEGIN;
INSERT INTO orders ...; -- 業務データ
COMMIT; -- DB はここで確定
queue.publish(event); -- 別システムへの操作
DB のトランザクションは「同じ DB の中の操作」しか束ねられない。queue.publish は DB の外側なので、
- COMMIT 直後にアプリが落ちると publish されないまま終わる(イベントロスト)
- 逆に publish を先にやって COMMIT が失敗すると 業務データはないのに通知だけ飛ぶ(幻のイベント)
COMMIT と publish のどちらを先にやっても、逆向きのズレが残る。これが二重書き込み(dual write)問題である。
仕組み: 同じ DB のテーブルに「送信予約」を書く
Outbox は、publish を直接やる代わりに 同じ DB 内の outbox テーブルに INSERT することでこれを回避する。
BEGIN;
INSERT INTO orders ...; -- 業務データ
INSERT INTO outbox ...; -- 「あとで publish してね」というメモ
COMMIT; -- 同じ DB なので両方原子的に確定
- アプリは業務データと同じトランザクションで
outboxテーブルにメッセージを INSERT する - COMMIT により「業務データ」と「送信予約」が1回の COMMIT で原子的に確定する
- 別プロセス(リレー)が
outboxを polling や CDC で読み、外部送信する - 送信成功したら
outboxのレコードに送信済みフラグを立てるか、行を削除する
ポイントは、Outbox の置き場所は「業務データと一緒に1回の COMMIT で確定できる場所」でなければならないということ。実務上はほぼ「業務 DB と同じ DB のテーブル」になる(Kafka transaction や CDC で同等の境界を作る亜種はあるが、本質は同じ)。
長所
- イベントロストが原理的に発生しない: DB に書けたなら必ず後で飛ぶ
- 実装が単純である(テーブルとリレーを追加するだけ)
- Saga のステップ間通知の基盤として組み込みやすい
短所
- At-least-once 配信: 重複は発生する。受信側に冪等性が必要である
- 順序保証は弱い(並列リレーでは入れ替わりうる)
outboxテーブルの肥大化対策(パーティション・定期削除)が必要である- polling 方式だとレイテンシが polling 間隔に依存する(CDC を使えば数十 ms 以下)
ACID の観点
| ACID | Outbox での担保 |
|---|---|
| Atomicity(原子性) | ◎ これが本丸。業務データと「送信予約」が1回の COMMIT で同時に確定 or 同時になかったことになる |
| Consistency(一貫性) | ○ 「業務データを書いた」⇔「いつか必ずイベントが飛ぶ」の対応を最終的に保つ(= 結果整合性) |
| Isolation(分離性) | △ DB 内の INSERT 自体は分離されるが、外部システムから見ると中間状態(DB は書かれたがイベント未配信)が見える |
| Durability(永続性) | ◎ DB に書けば消えない。リレーが何度死んでも outbox レコードが残るので最終的に publish される |
Outbox が新規に持ち込む保証は実質 Atomicity だけ。それを足場に Consistency と Durability が結果としてついてくる、と捉えるのが正確である。
Outbox はどの「ゆるさ」に位置するか
Outbox は強い一貫性のための仕組みではなく、結果整合性寄りの設計である。整理するとこうなる:
| 要求 | 採るべき手段 |
|---|---|
| ロストしてもいい(ログ・メトリクス等) | COMMIT 後にそのまま publish |
| ロストは困るが、重複・遅延・順序入れ替わりは許容できる | Outbox |
| 強い即時一貫性が欲しい | 2PC(だが採用は慎重に) |
Outbox は強い一貫性を狙う仕組みではなく、dual write 問題を回避することに目的を絞ったシンプルなパターンである、と捉えるのが正確である。
適する場面
業務データを書いた事実を、何らかの外部システム(Pub/Sub・Webhook・別サービスの API)にロストなく通知したい場面のすべて。Saga と組み合わせるのが定番である。
三者の関係
3つは競合する技術ではなく、抽象度の違うレイヤーである。
Saga(業務フロー全体)
└ Outbox(イベント発行の信頼性)
└ DB Transaction(ローカルの原子性)
- Saga はビジネスフロー全体の整合性を扱う
- Outbox は Saga の各ステップでイベントを確実に飛ばす土台になる
- 2PC は、これらと「強い一貫性 vs 可用性」のトレードオフを反対側から眺めるための比較対象である
選定の指針
| 要件 | 推奨パターン |
|---|---|
| サービスをまたぐ業務フロー全体の整合性 | Saga |
| DB COMMIT とイベント発行の原子化 | Outbox |
| 強い一貫性が絶対要件・可用性は妥協できる | 2PC(ただし採用は慎重に) |
| 単一 DB 内で済む | 普通のトランザクションで十分 |
実務的には、Saga と Outbox を中心に据え、2PC は明確な理由がない限り採用しない、というのが現代のマイクロサービス設計で広く採られている方針である。
まとめ
- 2PC: 強一貫だが可用性が犠牲。マイクロサービスでは原則使わない
- Saga: 補償ベースで結果整合性を実現する業務フロー設計。可用性が高く異種リソース対応も可能
- Outbox: DB と外部通知を確実に揃えるための装置。Saga の信頼性の土台になる
「分散システムでは ACID の Isolation を諦め、Atomicity を補償で取り戻す」 — これが Saga の本質であり、Outbox はその各ステップを確実に動かすための土台になる。2PC を選ぶ前に、まずこの組み合わせで設計できないかを検討するところから始めるのが良い。