Cloud Spannerの2相コミット(2PC):分散データベースの聖杯をどう手なずけるか
こんにちは。テクニカルリードの私だ。
今日のコードレビュー、あるいは昨日のシステム設計レビューで「なぜこのトランザクションはレイテンシが高いんだ?」「本当にこのテーブル分割で整合性は保たれるのか?」という議論になったチームはないだろうか。
Cloud Spannerは、リレーショナルデータベースの強力な一貫性(ACID特性)と、NoSQLが持つ水平スケーリングの夢を同時に叶えた、現代分散システムの最高峰だ。しかし、その魔法の裏側には物理法則の限界が存在する。それが 2相コミット(Two-Phase Commit: 2PC) だ。
今回は、Spannerのコアアーキテクチャの心臓部である2PCについて、綺麗事抜きの実務的な知見を共有しよう。これを理解していなければ、あなたの書いたクエリやスキーマ設計は、知らず知らずのうちにシステム全体のスループットを殺す「静かなる爆弾」になりかねない。
—
1. そもそも、なぜSpannerで2PCが必要なのか?
Spannerのデータは、数GBごとに「スプリット(Split)」と呼ばれる単位に分割され、世界中の様々なリーダーReplica(Paxosグループ)に配置されている。
単一のリーダー(単一スプリット)内で完結するトランザクションであれば、2PCは不要だ。そのPaxosグループ内の合意だけでアトミズムは保証される。
問題は、「複数のスプリット(=複数のPaxosグループ)にまたがる書き込み」が発生したときだ。
例えば、ユーザーAの口座からユーザーBの口座へ送金するトランザクションを考えてみよう。ユーザーAとユーザーBのレコードが、たまたま別々のスプリット(別々のマシン、あるいは別々のデータセンター)に存在していたとする。このとき、以下の要件を満たす必要がある。
- すべて成功するか、すべて失敗するか(原子性)
- 途中の状態が外部から見えないこと(隔離性)
これを分散環境で実現するために、Spannerが強制的に発動させるのが 2相コミットメント・プロトコル である。
—
2. Spanner版 2PCの裏側:フェーズ1とフェーズ2の現実
一般的な教科書に載っている2PCは「コーディネーターがいて、全参加者が返事をするまでロックし続ける、遅くて脆弱なプロトコル」として描かれる。その認識のままSpannerを叩くと痛い目を見る。Spannerはこれを TrueTime API と組み合わせることで、極限まで最適化している。
トランザクションの書き込みフェーズは、大まかに以下のステップで進む。
[クライアント]
│
▼ (1. 読み取り&ローカルバッファリング)
[参加者A (Paxos Group 1)] [参加者B (Paxos Group 2)]
│ │
└──────────────┬───────────────┘
│
▼ (2. 2PC開始:コーディネーター選出)
[コーディネーター (例: A)]
│
├─ Phase 1: Prepare ──> 参加者B
│ <── Prepared ──────┘
│
├─ TrueTimeによるコミットタイムスタンプの確定
│
└─ Phase 2: Commit ──> 参加者B
<── Committed ─────┘
Phase 1: 準備フェーズ (Prepare)
1. トランザクションに含まれる変更を担当する複数のスプリットのうち、1つがコーディネーター(Coordinator)、残りが参加者(Participant)に任命される。
2. コーディネーターは全参加者に対して `Prepare` メッセージを送り、「このトランザクションをコミットする準備はできたか?」と問う。
3. 参加者は、競合がないかを確認し、ロックを獲得した上で、ローカルのトランザクションログに書き込み、「準備完了(Prepared)」の旨を返す。この時点ではまだデータは永続的に確定していないが、コミット権限をコーディネーターに委ねる状態になる。
Phase 2: コミットフェーズ (Commit)
1. コーディネーターは、全参加者から「Prepared」の返事を受け取ると、Googleの原子時計とGPSによる時刻同期基盤 TrueTime を用いて、グローバルで一意な Commit Timestamp(コミット時刻) を決定する。
2. コーディネーターは自身と全参加者に対し、そのタイムスタンプと共に `Commit` を指示する。
3. 各参加者はデータにそのタイムスタンプを刻み込んで変更を確定させ、ロックを解放する。
—
3. 現場で起きる悲劇:なぜ2PCは「悪者」になりやすいのか?
ここで、シニアエンジニアとして皆さんに警鐘を鳴らしたい。
「2PCは、ネットワークホップとロック保持時間を劇的に増大させる」 という厳然たる事実だ。
単一スプリットの書き込みであれば、1つのPaxosグループ内の合意(数ミリ秒)で終わる。しかし、ひとたび2PCが発動すると:
1. 複数の Paxos グループ間でメッセージが飛び交う(ネットワークレイテンシの乗算)。
2. Prepare から Commit に至るまで、行ロック(Row Locks)が保持され続ける。
もし、あなたの設計したスキーマが原因で、あらゆるトランザクションが意図せず2PCを引き起こしていたらどうなるか?
高負荷時にロック競合が連鎖し、スループットは急降下、レイテンシは跳ね上がり、最終的に `DEADLINE_EXCEEDED` や `ABORTED` エラーの嵐に見舞われることになる。
—
4. 堅牢な設計パターン:2PCを回避し、Spannerの性能を極限まで引き出す法
では、どう設計すべきか?
コードレビューで私が後輩たちに口酸っぱく指導している「2PCを最小化する3つの設計原則」を伝授しよう。
原則1: インターリーブ(Interleave)構造による物理的コロケーション
Spannerの真骨頂は、親テーブルと子テーブルを物理的に同じスプリット(同じPaxosグループ)に同居させる Interleave(インターリーブ) 機能だ。
— 親テーブル:顧客
CREATE TABLE Customers (
CustomerID INT64 NOT NULL,
Name STRING(100),
) PRIMARY KEY(CustomerID);
— 子テーブル:注文(CustomerIDごとに物理的に同じスプリットに配置される)
CREATE TABLE Orders (
CustomerID INT64 NOT NULL,
OrderID INT64 NOT NULL,
OrderDate TIMESTAMP,
) PRIMARY KEY(CustomerID, OrderID),
INTERLEAVE IN PARENT Customers ON DELETE CASCADE;
なぜこれが効くのか?
`Customers` とその子である `Orders` に対する操作(顧客情報の更新と注文の追加など)は、同じ `CustomerID` をプレフィックスに持つため、確実に同一のスプリット(同一リーダー)にルーティングされる。
結果として、2PCのオーバーヘッドが完全に消失し、単一パーティションの超高速なトランザクションとして処理されるのだ。
原則2: ホットスポットを生む連番IDの排除
プライマリーキーの先頭に `AUTO_INCREMENT` や単調増加するシーケンス、あるいは現在時刻(`CURRENT_TIMESTAMP()`)を置くのは、分散データベースにおいては「自殺行為」だ。すべての書き込みが最新のスプリットに集中し、かつ2PCの対象が分散せず特定箇所で競合を起こす。
対策:
UUIDv4や、ハッシュ化されたプレフィックス(Sharded ID)をプライマリーキーの先頭に使い、書き込み負荷を意図的に全スプリットへ分散させよ。
原則3: 読み取り専用トランザクションの積極的な活用
もしデータの一貫性が「少しの遅延(Stale Read)を許容できる」性質のものであれば、2PCを伴う読み書きトランザクション(Read-Write Transaction)を使う必要は一切ない。
TrueTimeの機能を利用した Bounded Stale Read や Exact Stale Read を使えば、ロックを取得せず、2PCすら発生させずに、任意の過去時点のスナップショットをノーロックで安全に読み出すことができる。
// Java (Cloud Spanner Client Library) の例
// ロックを取得せず、2PCも発生しない安全なリードオンリークエリ
try (ReadOnlyTransaction tx = client.readOnlyTransaction(TimestampBound.ofExactStale(pastTime))) {
ResultSet resultSet = tx.executeQuery(Statement.of(“SELECT FROM Accounts WHERE AccountID = ‘123’”));
// 処理…
}
—
5. まとめ
Cloud Spannerの2相コミットは、分散環境で「完全なACID」を実現するための美しくも重厚なメカニズムだ。
しかし、道具は使いようである。何も考えずに巨大なテーブルをバラバラに設計し、あらゆる更新を2PCまみれにすれば、Spannerであっても性能の天井に激突する。
- インターリーブを活用し、関連データを物理的に同じスプリットに束ねる。
- 書き込みの粒度を適切に絞り、不必要なクロス・スプリット・トランザクションを避ける。
- リードは極力、Stale Readを活用してロックと2PCをバイパスする。
この3点を意識したアーキテクチャを組むこと。それこそが、Cloud Spannerのポテンシャルを100%引き出し、数百万QPSの世界でも揺るぎないシステムを構築する唯一の道だ。
次の設計レビューでは、君たちのその誇らしいコードとスキーマを見せてほしい。期待している。
コメント