Cloud Spannerの2相コミット(2PC):分散トランザクションの深淵とその手懐け方
こんにちは。テクニカルリードの私だ。
今日のコードレビュー、あるいはアーキテクチャ設計レビューで、こんな質問を受けたとしよう。
「複数スプリット(シャード)にまたがる更新って、Spannerなら自動でよしなにやってくれるんですよね? 2PC(2相コミット)が走るから遅くなるって聞いたけど、本当のところどうなんですか?」
この問いに、君はロジカルに、そして背中を預けられるエンジニアとして答えられるだろうか?
「はい、自動でやってくれます。便利ですよ」——もしこう答えたとしたら、今すぐそのキーボードを置いて私の話を聞き給え。
Cloud Spannerは「マジックボックス」ではない。物理法則(光速の限界やネットワーク遅延)と分散合意アルゴリズムの極限の最適化の結晶だ。
今回は、Spannerのコアである「2相コミット(2PC)」の内部挙動を解剖し、実務で痛い目を見ないための設計パターンとパフォーマンスチューニングの極意を伝授する。
—
1. そもそもなぜ、Spannerの2PCは「特別なもの」なのか?
一般的なリレーショナルデータベース(RDBMS)における分散トランザクション(XAトランザクションなど)は、設定が複雑で、遅く、障害耐性が低いという「エンジニアのトラウマ」の代名詞だった。
しかし、Spannerの2PCは、以下の2つの技術的基盤と強烈に結合することで、その常識を覆している。
1. TrueTime API: 不確実性を伴う実時間を原子時計とGPSで極小化(誤差数ミリ秒以内)し、グローバルでの直列化可能性(External Consistency)を保証する。
2. Paxosによる冗長化: 2PCに参加する各「ノード(厳密にはPaxosグループ)」自体が、内部でPaxosによる高可用性レプリケーション構成をとっている。
つまり、Spannerの2PCとは、「単一ノード間のトランザクション」ではなく、「高可用性を持つPaxosグループ間の分散合意プロトコル」なのだ。ここを履き違えると、設計を根本から誤る。
—
2. 内部挙動の完全解剖:準備フェーズとコミットフェーズ
複数スプリットにまたがる書き込み(Write)が発生した瞬間、Spannerの内部では何が起きているのか。そのライフサイクルをステップ・バイ・ステップで見ていこう。
[クライアント]
│
▼ 1. トランザクション開始 & 読み取り/書き込みバッファリング
[リーダー・スプリット (Coordinator)]
│
├───────────────────────┐ (2. 準備フェーズ: Prepare RPC)
▼ ▼
[参加者スプリット A] [参加者スプリット B]
(Paxos合意形成) (Paxos合意形成)
│ │
├─ “Prepared” ├─ “Prepared”
▼ ▼
│ │
└───────────────────────┘ (3. コミットフェーズ: Commit RPC)
▼
[コミット完了 (TrueTimeのタイムスタンプ確定)]
ステップ1: コーディネーターの選出とバッファリング
クライアントが書き込み要求を発行すると、トランザクションに含まれるキー範囲を管理するスプリットの中から、1つがコーディネーター(Coordinator)として自動選出される。残りのスプリットは参加者(Participants)となる。
ステップ2: 準備フェーズ(Prepare Phase)
1. コーディネーターは、すべての参加者スプリットに対して `Prepare` RPCを並行送信する。
2. 各参加者は、トランザクションログ(Paxosログ)に「準備完了(Prepared状態)」を書き込む。この時点で、ロックが獲得され、障害が起きてもコミットまたはアボートできる状態(Durability)が担保される。
3. すべての参加者が「Prepared」を返すと、コーディネーターはコミットの判断を下す権利を得る。
ステップ3: コミットフェーズ(Commit Phase)
1. コーディネーターは、TrueTimeから「このタイムスタンプ以降であれば絶対に安全」というコミットタイムスタンプ $S$ を取得する。
2. コーディネーターおよび全参加者は、タイムスタンプ $S$ でデータを永続化し、ロックを解放する。
3. クライアントに成功(Commit OK)が返される。
—
3. 実務で直面する「2PCの代償」とパフォーマンスの罠
「仕組みは分かった。で、何が問題なのか?」
実務において、2PCは「レイテンシの爆弾」になり得る。これを理解していないと、スケールアウトするはずのSpannerでスループットが頭打ちになる。
罠1: レイテンシの増幅(ネットワークホップの増加)
単一スプリットへの書き込みであれば、そのPaxosグループ内(リーダー+過半数のレプリカ)だけの往復で済む。
しかし、2PCが発動すると、コーディネーターと複数の参加者間でネットワークRPCが直列・並行に発生する。特に参加者が別 リージョン(あるいは別ゾーン)にまたがっている場合、物理的な光速の壁(RTT)がダイレクトにレイテンシに上乗せされる。
罠2: ロックの保持期間の長期化
2PCの準備フェーズからコミットフェーズが完了するまで、行ロック(Row Locks)は保持され続ける。
もしネットワークの一時的な瞬断や、参加者側のPaxosグループでリーダー選出(Failover)が重なると、ロックが解放されずに後続のトランザクションがブロックされ、スレッドプールの枯渇やタイムアウトの嵐を引き起こす。
—
4. 現場でどう設計すべきか? 堅牢な設計パターン
では、テクニカルリードとして私たちはどう設計を導くべきか。具体的なプラクティスを提示しよう。
アンチパターン: 「とりあえず何でも突っ込む巨大トランザクション」
リレーショナルデータベースの感覚で、1つのトランザクションの中で「ユーザー作成」「初期口座作成」「監査ログの挿入」「決済処理」をすべてシリアライズして実行していませんか?
これらは見事に異なるスプリットに配置される可能性が高く、毎回盛大に2PCが発動する。
パターンA: スキーマ設計によるスプリット局所化(Interleaving)
Spannerの真骨頂はインターリーブ(Interleave)テーブルだ。
例えば、`Users` テーブルと `Orders` テーブルを `USER_ID` を親子関係としてインターリーブ配置すると、特定のユーザーに関するデータは物理的に同一のスプリット(または近傍)に集約される。
— 親テーブル
CREATE TABLE Users (
UserId INT64 NOT NULL,
UserName STRING(100),
) PRIMARY KEY(UserId);
— 子テーブル(物理的に親の近傍に配置される)
CREATE TABLE Orders (
UserId INT64 NOT NULL,
OrderId INT64 NOT NULL,
OrderDate TIMESTAMP,
) PRIMARY KEY(UserId, OrderId),
INTERLEAVE IN PARENT Users ON DELETE CASCADE;
効果: `Users` とその `Orders` を同時に更新する際、プライマリキーに `UserId` が含まれているため、Spannerは単一スプリット(または最小限の範囲)でトランザクションを完結させ、2PCの発生を回避(または最小化)できる。
パターンB: 非同期処理(Outboxパターン)の活用
どうしても異なるドメイン(別スプリット、別マイクロサービス)にまたがる処理をアトミックにしたい誘惑に駆られる時がある。しかし、厳密な2PCをデータベース層でやり続けると可用性が下がる。
実務的アプローチ:
1. Spanner内では、メインの更新と同時に、同一トランザクション内で「Outboxテーブル」にイベントを書き込む(同一スプリットに寄せるため、これは高速)。
2. トランザクションはここでコミット(2PCを最小限に抑える)。
3. 別のワーカー(Pub/SubやChange Streams)がOutboxを非同期で読み出し、他領域へ伝搬する(結果整合性)。
金融の厳密な台帳処理など、どうしても単一トランザクションが必須な場合を除き、「書き込みのスコープを絞る」か「結果整合性に逃げる」のトレードオフを常に意識させよ。
—
5. チーフアーキテクトからのメッセージ
Cloud Spannerの2PCは、分散システムの歴史における偉大なエンジニアリングの成果だ。しかし、それは「魔法」ではなく、物理制約の中で整合性を保つための「重いコストを伴う仕組み」である。
コードレビューで開発者から「なんでこのトランザクション、こんなにレイテンシが高いんですか?」と聞かれたとき、こう切り返せるようになってほしい。
> 「君の書いたそのクエリ、裏で何個のPaxosグループを巻き込んで2PC(2相コミット)を踊っているか知っているかね? スキーマ設計を見直して、スプリットの局所性を高めようか」
この一言が言えた瞬間、あなたのチームのSpannerの使いこなし方は、次のステージへと進化するはずだ。
妥協のない設計を。健闘を祈る。
コメント