Tech Learning Daily

2026-09-30 (Wed) — 第 75 号
AI が毎朝届ける、ソフトウェア技術の基礎解説
Arch 🌿 基礎 ⏱ 約 7 分

1つのDBならロールバックで済んだはずなのに、複数サービスをまたぐ更新はなぜそう簡単に戻せないのか?

あなたも Rails の ActiveRecord::Base.transaction や Go の BEGIN〜COMMIT で、複数テーブルへの更新を「全部成功か全部失敗か」の1つの塊として守ってきたはずだ。では注文サービス・決済サービス・在庫サービスがそれぞれ別のDBを持つ構成で、この3つをまたいで同じ保証をしたいとき、同じ発想はそのまま通用するだろうか。

🎯 3 行まとめ
  • 単一DBのトランザクションは1つのコミットログで守られるが、サービスが分かれてDBも分かれると、その1つのコミットという仕組みが使えなくなる。
  • 2PC(Two-Phase Commit)は「全員が準備完了」を確認してから確定する方式だが、コーディネータが途中で落ちると参加者がロックを持ったまま止まる「ブロッキング問題」があり、マイクロサービスでは避けられがちだ。
  • Sagaパターンは確定を待たずに一歩ずつ進み、失敗したら補償トランザクションで打ち消すことで前へ進むが、代わりに分離性(isolation)を失う。

単一DBのトランザクションが守っていたもの

1つのDBに対する BEGIN〜COMMIT は、複数の変更を1つのコミットという単一のイベントに束ねている。DBの内部では、コミットは1本のログに「このトランザクションは確定した」という記録を書き込む1回の操作であり、途中でサーバーが落ちても、そのログの記録があるかないかで「全部反映された」か「全く反映されていない」かがきれいに決まる。中間状態が外から見えないのは、決定権を持つ主体が1つしかないからだ。

マイクロサービス化すると、注文・決済・在庫はそれぞれ別のDB、別のプロセス、別のホストを持つようになる。それぞれのDBは自分のトランザクションのコミットログしか知らず、他のDBが何を確定したかを知る手段を持たない。1つの BEGIN〜COMMIT は1本のDB接続の中に閉じているため、3つのDBを1つのトランザクションでまとめて確定・撤回することはできない。この「複数の独立したデータストアをまたいで、全体を all-or-nothing に保ちたい」という要求を分散トランザクションと呼ぶ。

2PC(Two-Phase Commit): 先に「準備完了」を全員から取る

分散トランザクションへの古典的な回答が2PCだ。1台のコーディネータ(調整役)が、参加する各DBに対して2段階でやり取りする。フェーズ1(prepare)では「これから確定してよいか」を全員に問い、各DBは自分の変更を安定した記憶領域に書き込んでから「OK」と返す。ここで注意したいのは、OKと返した時点でもまだロックは保持し続けているという点だ。まだ確定していないので、いつでも巻き戻せる状態を保っておく必要がある。

Coordinator      注文DB  決済DB  在庫DB
   │─①prepare──▶ │      │      │
   │◀──── OK ─────┤──────┤──────┤
   │ (全員OKするまでロック保持)
   │─②commit────▶ │      │      │
   │  1つでもNGなら②はrollback

全員からOKが返ってきたら、フェーズ2(commit)で全員に「確定してよい」と一斉に伝える。1つでも「NG」(prepareに失敗)があれば、全員に rollback を伝える。MySQLのXA START/XA END/XA PREPARE/XA COMMITという一連のSQL文は、この2フェーズをそのまま実装したものだ。

なぜ2PCは「理屈上は安全」なのに実務で避けられるのか

2PCにはブロッキング問題という弱点がある。全参加者がprepareに「OK」と答えた直後、確定の指示を出す前にコーディネータ自身が落ちてしまうと、参加者たちは「commitすべきかrollbackすべきか」を誰からも知らされないまま、確定するその日まで行に張ったロックを保持し続けることになる。ロックを保持し続けるということは、その行に触れる他の処理も連鎖的に止まるということだ。コーディネータが復旧するまで、システム全体の可用性がその一点に握られてしまう。

加えて、prepareからcommitまで参加者全員の返事を同期的に待つ必要があるため、1つのサービスが遅ければ全体が待たされる。Kafkaのような多くのメッセージブローカーがXAに標準対応していないことも、実務でマイクロサービス間の2PCが選ばれにくい理由になっている。

🍱 たとえるなら

2PCは、引っ越しでガス・電気・水道の3つの窓口それぞれに「今日中に契約変更してよいか」と先に確認を取り、3つ全部から「はい」と返事が来てから「では確定してください」と一斉に連絡するやり方だ。ところが、連絡役の担当者が3つの「はい」を受け取った直後に急病で倒れると、各窓口の担当者は書類を机に置いたまま(=行ロックを保持したまま)、確定の連絡が来るのをいつまでも待つことになる。担当者が復帰するまで、他の引っ越しの手続きもその窓口では進められない。

Sagaパターン: 先に進めて、失敗したら打ち消す

Sagaパターンは発想を逆転させる。ローカルトランザクションの連鎖として処理を進め、全体の合意を待たずに各サービスがその場で確定してしまう。代わりに、各ステップに対して「これを取り消すにはどうするか」を表す補償トランザクションをあらかじめ用意しておき、途中のステップが失敗したら、それより前に確定済みのステップを補償トランザクションで1つずつ打ち消していく。

正常系: 注文確定→在庫確保→決済実行
失敗時の補償(逆順に打ち消す):
決済取消 ←在庫解放 ←注文キャンセル

誰が「次に進め」「打ち消せ」を指示するかで2つの流派がある。オーケストレーションは1つの中央の管理役が全ステップと補償の実行を明示的に指揮する方式、コレオグラフィは各サービスが自分の処理完了イベントを発行し、次のサービスがそのイベントを購読して自律的に動く方式だ。コレオグラフィはKafkaのようなイベント駆動基盤と馴染みやすい一方、処理全体の流れが1か所にまとまらないため、後から追いにくくなる。

Sagaの代償: 分離性(isolation)を失う

Sagaは確定を待たずに前へ進む代わりに、ACIDのI(isolation、分離性)を持たない。注文サービスがすでに確定した「在庫確保済み」の状態を、決済が失敗して補償が完了する前に、別の注文処理が読んでしまう可能性がある。単一DBのトランザクションなら他から見えなかった中間状態が、Sagaでは各ステップの確定ごとに外部から見える状態になるからだ。この対策は個々の設計判断に委ねられ、Sagaパターン自体が自動で解決してくれるわけではない。

もう1つの実務上の課題は、DBへの更新とイベントの発行を1つの操作として確定できない点だ。「在庫を減らす」更新と「在庫確保イベントを発行する」処理を別々に行うと、更新後にプロセスが落ちてイベントが発行されない、といった不整合が起こりうる。これを避けるため、更新対象と同じDB内に「送信予定イベント」テーブルを持ち、そこへの書き込みを本体の更新と同じローカルトランザクションに含めてから、別プロセスがそのテーブルを読んでイベントを配信するOutboxパターンが使われる。

💼 実務でどう出会うか

注文・在庫・決済がそれぞれ独立したサービスとDBを持つECサイトのような構成でSagaに出会うことが多い。AWS Step FunctionsやTemporalのようなワークフローエンジンはオーケストレーション型Sagaの実装基盤として使われ、Kafkaベースのイベント駆動アーキテクチャではコレオグラフィ型Sagaと組み合わせてOutboxパターンが採用される。逆に2PCそのものを直接書く機会は、社内の複数DB間でXAを使う一部のレガシー統合を除けば、マイクロサービス間ではほとんどない。

⌨️ 手を動かす(5 分)

MySQLのXAトランザクションを使って、2PCの「prepareした後、コミットするまでロックを保持し続ける」挙動を実際に固まらせて確認する。ポイントは、prepare済みのトランザクションは元の接続を切っても残り、別の接続からcommit/rollbackできることだ。

docker run --rm -d --name xa-demo -e MYSQL_ROOT_PASSWORD=pass -p 3306:3306 mysql:8.0
until docker exec xa-demo mysqladmin ping -uroot -ppass --silent 2>/dev/null; do sleep 1; done

mysql -h127.0.0.1 -uroot -ppass -e \
  "CREATE DATABASE demo; CREATE TABLE demo.stock (id INT PRIMARY KEY, qty INT); INSERT INTO demo.stock VALUES (1, 10);"

# コーディネータ役: prepareまで進めて、commitはまだ送らない
mysql -h127.0.0.1 -uroot -ppass -e \
  "XA START 'order1'; UPDATE demo.stock SET qty = qty - 1 WHERE id = 1; XA END 'order1'; XA PREPARE 'order1';"

# 別の接続から見ても prepare 済みのまま残っている
mysql -h127.0.0.1 -uroot -ppass -e "XA RECOVER;"

# さらに別の接続がその行をロックしようとすると、コーディネータの結論が出るまで固まる
mysql -h127.0.0.1 -uroot -ppass -e \
  "SET SESSION innodb_lock_wait_timeout=3; SELECT qty FROM demo.stock WHERE id=1 FOR UPDATE;"

# 後始末: 確定させてから片付ける
mysql -h127.0.0.1 -uroot -ppass -e "XA COMMIT 'order1';"
docker rm -f xa-demo

XA RECOVER は接続を切っても order1 が「prepared」のまま残っていることを示し、続く FOR UPDATE は3秒後に Lock wait timeout exceeded で失敗する。コーディネータ(あなた自身)が XA COMMIT を送るまで、他の誰もその行に触れられない——これがブロッキング問題の実体だ。

🙅 よくある誤解
  • 複数サービスのメソッドにそれぞれ @Transactional を付ければ、サービスをまたいでも一括でロールバックできる — ローカルなDBトランザクションの境界は自分のDB接続の中に閉じており、他サービスが確定してしまった変更を自動で取り消す仕組みは持たない。取り消すには補償処理を明示的に書く必要がある。
  • 2PCを使えば、分散システムでも単一DBと同じ安全性が保てる — コーディネータが確定通知を送る前に落ちると、参加者は返事を待ち続けて行ロックを保持し続ける(ブロッキング問題)。理論上の一貫性と、実際の可用性はトレードオフの関係にある。
  • Sagaの補償トランザクションは、元の処理を単純に逆再生すれば書ける — 補償が完了する前に別の処理が中間状態を読んでしまう可能性があり(分離性の欠如)、単純な逆操作だけでは意図しない不整合を生むことがある。
📖 用語ミニ辞典
分散トランザクション
複数の独立したデータストアにまたがる処理を、全体としてall-or-nothingに保ちたいという要求。
2PC(Two-Phase Commit)
コーディネータがprepare(準備確認)とcommit(確定指示)の2段階で参加者に合意を取る分散トランザクションの方式。
ブロッキング問題
2PCでコーディネータが途中で落ちると、prepare済みの参加者が確定/撤回の指示を待ち続け、ロックを保持したまま止まってしまう問題。
補償トランザクション
Sagaにおいて、既に確定したローカルトランザクションの効果を打ち消すために用意しておく逆操作。
Sagaパターン
ローカルトランザクションの連鎖として分散処理を進め、失敗時は補償トランザクションで前段を打ち消す設計パターン。
Outboxパターン
DBへの更新と、それに付随するイベントの発行を同じローカルトランザクションに含めて確定させ、後から別プロセスが配信することで両者のズレを防ぐ手法。
🔗 もっと深く