注文はあるデータベースに、支払いは別のデータベースに書き込まれます。両方が起きるか、どちらも起きないかのどちらかでなければなりません。各データベースは自分の部分をアトミックにコミットできますが、一方がコミットしている間にもう一方がクラッシュすることを、ローカルな仕組みでは防げません。2 フェーズコミット(2PC)はこの問題に対する古典的なプロトコルで、XA 標準を通じてほとんどのリレーショナルデータベースに組み込まれており、現在は分散 SQL データベースの内部でも使われています。
一方で、有名な弱点もあります。コーディネーターが悪いタイミングで故障すると、参加者はロックを保持したまま、自分では決められない状態で立ち往生することがあります。この記事では、シーケンス図を使ってプロトコルを順に追い、あらゆる障害ケース、実際のシステムでの実装方法、そして saga や outbox のほうが適している場面を説明します。
役割
- コーディネーター(トランザクションマネージャー):プロトコルを進め、結果を決めます。アプリケーションサーバー内のライブラリのこともあれば、独立したサービスや分散データベースのコンポーネントのこともあります。
- 参加者(リソースマネージャー):トランザクションの一部をそれぞれ保持するデータベースやキューです。
すべての当事者は永続的なログを持ちます。ログへの書き込みは実装の細部ではありません。プロトコルがクラッシュを乗り越えられるのは、この書き込みのおかげです。
プロトコル
フェーズ 1:prepare
- アプリケーションは 1 つのグローバルトランザクションの中で各参加者に対する処理を行い、そのあとコーディネーターにコミットを依頼します。
- コーディネーターはすべての参加者に PREPARE を送ります。
- 各参加者は、このあと何が起きてもコミットできる状態にします。トランザクションの変更内容と prepare レコードを永続ログに書き込み、ロックを保持したまま YES を投票します。制約違反などでコミットできない場合は NO を投票してロールバックします。
フェーズ 2:commit または abort
- すべての投票が YES なら、コーディネーターは自分のログに COMMIT の決定を書き込みます。この書き込みがコミットポイントです。ここから先は、1 ミリ秒後にすべてのマシンがクラッシュしても、トランザクションはコミット済みです。
- コーディネーターはすべての参加者に COMMIT を送ります。各参加者はコミットし、ロックを解放して確認応答を返します。
- どれかの参加者が NO を投票するか、時間内に応答しなければ、コーディネーターは ABORT を決定し、代わりに ROLLBACK を送ります。
YES の投票は約束です。YES を投票したあとの参加者は、単独で決める権利を手放しています。コミットを指示されれば、再起動後であってもコミットしなければならず、自分からロールバックすることもできません。コーディネーターがすでにコミットを決めているかもしれないからです。
コスト
N 個の参加者がいる場合、成功するコミットには、コーディネーターからの往復が 2 回と、参加者ごとに 4 つのメッセージ(PREPARE、投票、COMMIT、確認応答)が必要です。各参加者は prepare 時とコミット時の 2 回、ログをディスクに強制的に書き込み、コーディネーターは決定を 1 回強制的に書き込みます。ロックはその間ずっと保持されるので、ほかのすべての参加者が競合する処理をどれだけ長くブロックするかは、最も遅い参加者で決まります。
障害が起きたときに何が起きるか
| 障害 | タイミング | 結果 |
|---|---|---|
| 参加者がクラッシュまたはタイムアウト | 投票前 | コーディネーターがトランザクションを中止する |
| 参加者が NO を投票 | フェーズ 1 | コーディネーターがトランザクションを中止する |
| 参加者がクラッシュ | YES の投票後 | 再起動時に prepare レコードを見つけ、コーディネーターに結果を問い合わせる。その間ロックは保持したまま |
| コーディネーターがクラッシュ | 決定を書き込む前 | prepare 済みの参加者は待つ。復旧時、ログに決定がないのでコーディネーターは中止する |
| コーディネーターがクラッシュ | COMMIT を書き込んだ後 | 復旧時にログを読み、COMMIT を再送する |
| ネットワーク分断 | 参加者が YES を投票した後 | その参加者はコーディネーターに到達できるまでブロックする |
重要なのは、参加者が YES を投票したのに決定を受け取れない行です。別の参加者が NO を投票したかもしれないので、コミットはできません。コーディネーターがコミットしたかもしれないので、中止もできません。コーディネーターに到達できない間ずっと、ロックを保持したまま待ちます。それらの行に触れるほかのトランザクションもすべて待ちます。2PC がブロッキングプロトコルと呼ばれるのは、このためです。
実際に起きる、詰まった prepare 済みトランザクション
PostgreSQL は、参加者側を PREPARE TRANSACTION、COMMIT PREPARED、ROLLBACK PREPARED で実装しています。この機能はデフォルトで無効(max_prepared_transactions は 0)で、ドキュメントは、これがアプリケーション向けではなく外部のトランザクションマネージャー向けだと警告しています。忘れられた prepare 済みトランザクションはロックを保持し続け、VACUUM による領域の回収を妨げます。
これを見つけて解決する手順は次のようになります。
-- Prepared transactions waiting for a decision
SELECT gid, prepared, owner, database
FROM pg_prepared_xacts
ORDER BY prepared;
-- Only after checking the coordinator's log for the real outcome:
COMMIT PREPARED 'order-7f3a';
-- or
ROLLBACK PREPARED 'order-7f3a';
prepare 済みトランザクションを当て推量で解決してはいけません。コーディネーターがコミットしたのに参加者の 1 つをロールバックすれば、2PC が防ぐために存在する不整合そのものを作り出すことになります。MySQL も XA PREPARE、XA COMMIT、XA RECOVER で同じ仕組みを提供しています。
痛みを和らげるバリエーション
- Presumed abort。 コーディネーターにトランザクションの記録がなければ、答えは中止です。中止されたトランザクションのログ書き込みを節約でき、復旧も単純になります。ほとんどの実装がこれを使っています。
- 読み取り専用の参加者。 何も変更しなかった参加者は「read-only」と投票し、フェーズ 2 を省略できます。
- 3 フェーズコミット。 参加者がコーディネーターなしで完了できるように pre-commit のラウンドを追加します。ただし、メッセージの遅延に上限があり、ネットワークが決して分断されない場合に限ります。こうした前提が成り立つことはまれなので、3PC は主に教科書の話題です。
- コーディネーターと参加者を複製する。 ブロッキングの本当の解決策は、各役割をマシン 1 台の喪失に耐える複製グループにすることです。Google の Spanner はまさにこの理由で Paxos グループの上で 2 フェーズコミットを動かしており、ほかの分散 SQL データベースも同じ考え方を Raft で使っています。結果を決めるのは引き続き 2 フェーズコミットで、合意は各当事者の可用性を保ちます。
2PC を使うべきとき、使うべきでないとき
2PC が向いているのは、すべての参加者が prepare のステップをサポートするデータベースやブローカーで、そのすべてを自分で管理しており、トランザクションが短く、参加者をまたいだアトミック性と分離性が必要な場合です。ほかのチームが所有するサービス間、prepare のステップを持たない HTTP API 間(決済プロバイダーは課金を prepare 済みの状態で保持してはくれません)、数分から数日かかる業務プロセスでは、うまく機能しません。
| 2 フェーズコミット | Saga | Transactional outbox | |
|---|---|---|---|
| 一貫性 | アトミック。ロックが分離性を与える | 結果整合。補償ステップを伴う | データベースとブローカーの間で結果整合 |
| ブロッキング | あり。決定が保留中の間 | なし | なし |
| 必要なもの | prepare をサポートする参加者 | 各ステップの補償ロジック | 1 つのデータベースとリレー |
| 典型的な用途 | 自分で運用するデータベースやシャード | サービスをまたぐ業務ワークフロー | 書き込み後のイベントを確実に発行する |
saga パターン図は補償による代替案を示し、transactional outbox パターンは、チームが 2PC に手を伸ばす最もよくあるケース、つまりデータベースへの書き込みとメッセージの発行を一緒に行うケースを扱っています。多くの場合、最善の解決策は構造的なものです。データベースシャーディング図で説明しているように、一緒に変わる必要があるデータは 1 つのデータベース、または 1 つのシャードにまとめましょう。
2 フェーズコミットの図
ダイアグラムの内容
- Application
- Coordinator
- Orders DB
- Payments DB
- phase 1: prepare
- phase 2: all votes YES
- commit()
- PREPARE
- YES (prepare record on disk)
- COMMIT
- ACK, locks released
- ACK
- ROLLBACK
- committed or aborted
図の読み方は次のとおりです。
- 4 本のライフライン。 アプリケーション、コーディネーター、注文データベース、決済データベースです。時間は上から下へ流れます。
- par フラグメントがフェーズ 1 です。 PREPARE は 2 つの参加者に同時に送られ、各参加者は prepare レコードを書き込んだあとで投票を返します。
- alt フラグメントがフェーズ 2 です。 上の分岐は通常のケースで、すべての投票が YES になり、コーディネーターが COMMIT をログに書き、両方の参加者がコミットして確認応答を返します。下の分岐は中止の経路です。
- メモは in-doubt の期間を示しています。 YES の投票から決定までの間で、参加者はロックを保持し、単独では決められません。
- Markdown ブロックは、上の表にある障害ケースを一覧にしています。
分散トランザクションを正直に描く
- ログへの書き込みを描きます。 「prepare レコードを書く」と「決定を書く」がない 2PC の図は、正しさを支える部分を省いています。
- コミットポイントを示します。 結果を決めるのは 1 つのメッセージかログ書き込みです。そこを指し示しましょう。
- ブロックが起きる期間を描きます。 チームが 2PC を避ける理由はそこにあるので、それを隠す図は誤解を招きます。
- 障害の経路は別の図にします。 prepare 後のコーディネーターのクラッシュは、復旧手順を含めた独自のシーケンスにする価値があります。
記法そのものについては、シーケンス図の例でライフライン、フラグメント、命名を解説しています。
Archboard で作る
- Google または GitHub でサインインし、図を作成します。
- シーケンスパックの ライフライン ノードを 4 つ追加します。Application、Coordinator、Orders DB、Payments DB です。
- メッセージを上から下へエッジとして描きます。コミット要求、各データベースへの PREPARE、YES の投票、各データベースへの COMMIT、確認応答の順です。
- prepare のメッセージを囲むように、演算子
parを設定したシーケンスパックの 複合フラグメント を追加し、コミットとロールバックの分岐を囲むように、演算子altを設定した 2 つ目の 複合フラグメント を追加します。 - in-doubt の期間を示す メモ と、障害の表を載せた基本パックの Markdown ブロックを追加します。
この例で使うダイアグラムノードは 7 個で、Free プランの範囲に収まります。
よくある質問
2 フェーズコミットがブロッキングプロトコルと呼ばれるのはなぜですか? YES を投票した参加者は、自分だけではコミットも中止もできません。決定が送られる前にコーディネーターに到達できなくなると、参加者はロックを保持したまま待ち、それらの行を必要とするすべてのトランザクションも待つことになります。
2 フェーズコミットは分散トランザクションと同じものですか? 分散トランザクションをコミットする最も一般的な方法です。分散トランザクションは複数のリソースにまたがり、2 フェーズコミットはそのコミットをアトミックにするプロトコルです。
3 フェーズコミットはブロッキングの問題を解決しますか? まれにしか成り立たない前提、つまりメッセージの遅延に上限があり、ネットワーク分断が起きないという前提の下でだけです。実用的な解決策は、Spanner のように、コーディネーターと参加者を合意によって複製することです。
マイクロサービス間で 2 フェーズコミットを使えますか? うまくいくことはまれです。サービスが prepare のステップを公開し、互いのためにロックを保持する必要があり、可用性が結びついてしまいます。サービス間では、saga と outbox パターンが一般的な選択肢です。
2 フェーズコミットは分離性を保証しますか? 保証するのはアトミック性です。分離性は参加者のロックから生まれ、2PC は決定が届くまでそのロックを保持するので、並行するトランザクションが途中の結果を見ることはありません。