【実務・中級編】 2相コミットプロトコル – Cloud Spanner

Cloud Spannerの2相コミット(2PC):分散トランザクションの深淵と実戦的設計論

こんにちは。テクニカルリードの私だ。
今日のコードレビューで、誰かが「Spannerだからトランザクションは安全に勝手にやってくれる」という甘い認識で、巨大な範囲を巻き込んだバッチ更新の設計を出してきた。

……即座に差し戻しだ。

Cloud Spannerは、その圧倒的なスケーラビリティと外部一貫性(External Consistency)の陰で、物理の法則を無視して動いているわけではない。その魔法の裏側を支えている主役こそ、「2相コミット(2PC: Two-Phase Commit)」 と 「Paxos」 のコンビネーションだ。

今回は、Spannerの分散トランザクションの中核である2相コミットが内部でどう動き、我々エンジニアが実務でどのような地雷を踏みがちで、それをどう回避すべきかを徹底的に叩き込む。

—

1. なぜSpannerに「2相コミット」が必要なのか?

Spannerは単なるキーバリューストアではない。リレーショナルデータベースであり、厳密なACID特性を持つ。
しかし、考えてみてほしい。数千、数万のノードが地球規模で分散配置されている環境において、単一の行(Row)ならいざ知らず、異なるスプリット(Split)や異なるリージョンにまたがるデータ更新を、どうやって「アトミック(不可分)」に実行するのか?

ここで登場するのが2相コミットだ。

  • 単一スプリットのトランザクション:

更新対象がすべて同一のPaxosグループ(リーダーノード)に収まる場合、2PCは発動しない。リーダーが一箇所で完結させるため、非常に高速だ。

  • 分散トランザクション(複数スプリット):

更新対象が複数のPaxosグループにまたがる瞬間、Spannerは自動的に2PCを裏側でオーケストレーションする。

つまり、「何気なく書いた1つのトランザクションが、裏側でネットワークを跨いだ壮大な合意形成プロセスを召喚している」 という事実を、まず頭に叩き込んでおいてほしい。

—

2. 2相コミットの裏側:Prepare と Commit の二重奏

Spannerの2PCは、古典的な理論通りの「Prepareフェーズ」と「Commitフェーズ」を、各Paxosグループのリーダー間で実行する。

[クライアント]
│
▼ 1. Read/Write 処理 & トランザクション開始
[Coordinator (調整役リーダー)]
│
├── (Prepare) ──► [Participant A (参加者リーダー)] ──► Paxosによる合意 & ログ永続化
├── (Prepare) ──► [Participant B (参加者リーダー)] ──► Paxosによる合意 & ログ永続化
│
│ 全参加者から “Prepared” の返答を受領
▼
[Coordinator]
│
├── (Commit) ──► [Participant A]
├── (Commit) ──► [Participant B]
│
▼ コミット完了をクライアントへ通知
[クライアント]

フェーズ1:準備(Prepare Phase)

1. クライアントからのリクエストを受けたトランザクションのコーディネーター(Coordinator)となるリーダーは、関係するすべての参加者(Participant)のリーダーに対して `Prepare` リクエストを送る。
2. 各参加者は、ロックを獲得し、アボート(中止)しないことを保証するためのログをPaxosグループ全体で合意形成してディスクに書き込む。
3. 準備ができたら、コーディネーターに「Prepared(準備完了)」を返す。

フェーズ2:コミット(Commit Phase)

1. すべての参加者から「Prepared」が集まったら、コーディネーターはコミット判断を下す(ここでTrueTime APIを用いたコミットタイムスタンプが確定する)。
2. コーディネーターは全参加者に `Commit` を指示し、各参加者はデータを確定させ、ロックを解放する。

—

3. パフォーマンス上の罠:2PCがもたらす「レイテンシの代償」

ここで、実務上の重大な警鐘を鳴らす。
「2PCが走るトランザクションは、ネットワークホップ数とディスクI/Oのオーバヘッドが劇的に跳ね上がる」。

古典的な2PCの弱点は「ブロッキング」だが、SpannerはPaxosと組み合わせることで可用性を担保している。しかし、物理的なボトルネックは消えない。

1. レイテンシの増大:
単一スプリットなら数ミリ秒で終わる処理が、複数スプリットを巻き込むと、最も遅い参加者やPaxosグループの合意形成(過半数のディスク書き込み)を待たされるため、レイテンシが数倍に膨れ上がる。
2. ロックの保持期間の長期化:
2PCの完了(Commitフェーズの終了)まで行ロックが保持される。高頻度で更新されるホットスポットに対して分散トランザクションを投げると、即座にロック競合(Lock Contention)とスループットの急降下を招く。

—

4. 堅牢な設計パターン:2PCの爆風を避ける実務テクニック

では、我々はこの強力だが重い2PCとどう向き合べきか。設計レビューで私が必ずチェックする3つのパターンを伝授する。

パターンA:インターリーブ(Interleave)による局所化

親子関係にあるテーブルを設計する場合、親と子が同じスプリットに物理的に同居するように `INTERLEAVE IN PARENT` を使ってスキーマを定義しろ。

— 親テーブル
CREATE TABLE Customers (
CustomerId INT64 NOT NULL,
Name STRING(100),
) PRIMARY KEY(CustomerId);

— 子テーブル:親と同一のスプリットに物理配置される
CREATE TABLE Orders (
CustomerId INT64 NOT NULL,
OrderId INT64 NOT NULL,
OrderDate DATE,
) PRIMARY KEY(CustomerId, OrderId),
INTERLEAVE IN PARENT Customers ON DELETE CASCADE;

なぜこれが効くのか?
プライマリキーの先頭に `CustomerId` を含めることで、特定の顧客の `Customers` と `Orders` に対する操作が同一のPaxosグループ内(単一スプリット)で完結するようになる。結果として、2PCの爆風を完全に回避できる。

パターンB:非正規化による分散トランザクションの排除

マイクロサービスのノリで「データを綺麗に正規化して、1回のトランザクションで複数テーブルを更新する」という設計を持ち込むな。Spannerではアンチパターンだ。
必要であれば、多少の冗長性を許容し、アグリゲート単位でデータを非正規化して1つの行、あるいは1つのスプリットに収めろ。

パターンC:大規模バッチでの「分割と並行処理」

数万件のデータを1つの巨大なトランザクションで更新しようとするエンジニアが後を絶たない。
Spannerのトランザクションはサイズ制限(変数の数やバイト数)もあり、巨大な2PCは確実にタイムアウトやアボートを引き起こす。
データ更新は適切にチャンク(分割)し、個別の独立したトランザクション(可能なら単一スプリットに収まる単位)として非同期、あるいは並行実行すべきだ。

—

5. まとめ:Spannerを使いこなす知性

Cloud Spannerの2相コミットは、開発者から分散システムの複雑性を美しく隠蔽してくれる。しかし、その「隠蔽」に甘えて内部の物理法則を無視した設計をすれば、システムは高負荷時に容赦なく崩壊する。

  • 「今、そのクエリは2PCを引き起こしているか?」
  • 「単一スプリットに処理を閉じ込められないか?」
  • 「ロック競合を生むホットスポットを作っていないか?」

コードレビューの場では、常にこの視点を持っていてほしい。
シュッとした美しいコードの裏側で、地球の裏側のデータセンター間を光速でパケットが飛び交っている――そのイメージを持てるエンジニアこそが、真のSpanner使いだ。

次のレビューでは、もっと洗練された設計を見せてくれることを期待している。

コメント

タイトルとURLをコピーしました