「SpannerはTrueTime APIがあるからロックフリーで動作する」——もしあなたのチームのメンバーがそう発言したら、即座にその勘違いを正さなければならない。
TrueTimeがロックを不要にしたのは「Read-Onlyトランザクション(参照専用)」の世界だけだ。データの不整合を1ビットたりとも許さない「Read-Writeトランザクション」の裏側では、堅牢かつ極限まで洗練されたペシミスティック(悲観的)な二相ロック(2PL: Two-Phase Locking)が泥臭く、そして精密に駆動している。
本稿では、Cloud Spannerの真骨頂である「スプリット単位で独立稼働するロックマネージャー」の内部構造を解剖する。なぜSpannerは分散環境でありながら高速にロックを処理できるのか? 共有ロック(S)と排他ロック(X)がどのように獲得され、どの粒度で競合するのか? これを理解せずして、大規模Spannerシステムの設計・パフォーマンスチューニングは不可能だ。
現場のアーキテクト、そしてテクニカルリードとして知っておくべき「極限の知見」を叩き込む。
—
1. ロックマネージャーはどこに存在するのか?
まず、Spannerの基本構造を思い出してほしい。データはキー範囲に応じて「スプリット(Split)」という単位に自動分割され、スプリットごとにPaxosグループが形成される。
では、ロックマネージャーはどこで動いているのか?
答えは「各スプリットのPaxosリーダー(Leader Node)のメモリ上」だ。
[ Client ]
│
│ Read-Write Transaction
▼
┌─────────────────────────────────────────────────────────┐
│ Split 1 (Key Range: A – M) │
│ ├── Paxos Leader ───► [ Lock Manager (In-Memory) ] │
│ ├── Paxos Follower 1 │
│ └── Paxos Follower 2 │
└─────────────────────────────────────────────────────────┘
アーキテクチャの真実
1. 完全メモリ主義:
ロック情報はPaxosで同期(複製)されない。リーダーのRAM上にのみ保持される。ロック獲得のたびにPaxos合意(ディスクI/Oやネットワーク往復)を発生させていては、スループットが壊滅するからだ。
2. リーダーフェイルオーバー時の挙動:
Paxosリーダーが死んだ場合、新しいリーダーが選出される。新リーダーは前のリーダーのメモリ上のロック状態を知らないため、進行中だったRead-Writeトランザクションはすべて強制アボート(Abort)される。クライアント側は自動リトライを行う。これが「ロックをPaxosでレプリケーションしない」トレードオフの正体だ。
3. スプリットごとの独立性:
ロックマネージャーはスプリットごとに完全に独立している。スプリットAに対するロック獲得処理が、スプリットBのロックマネージャーのCPUを1サイクルたりともブロックすることはない。
—
2. ロックの種別と管理粒度:セル・行・レンジ
Spannerのロックマネージャーが扱うロックの種類と、その「粒度(Granularity)」を正確に把握しよう。
ロックの種類(SロックとXロック)
- 共有ロック(Shared / S-Lock): Read-Writeトランザクション内でデータを「読み込む」際に獲得。
- 排他ロック(Exclusive / X-Lock): Read-Writeトランザクション内でデータを「書き込む(Update/Delete/Insert)」際に獲得。
| 現在の状態 \ 要求 | S-Lock | X-Lock |
| :— | :— | :— |
| なし | 許可 | 許可 |
| S-Lock保持中 | 許可 | ブロック (Wait) |
| X-Lock保持中 | ブロック (Wait) | ブロック (Wait) |
※繰り返すが、Read-OnlyトランザクションはSロックすら獲得しない。TrueTimeに基づく過去のタイムスタンプ(Snapshot Read)でノンブロッキングにデータを取得する。
ロックの粒度(Granularity)
Spannerのロック粒度は極めて柔軟であり、以下の3階層で管理される。
1. セルレベル(Cell-Level / Column-Level)
- Spannerはカラム単位(PrimaryKey + Column)でSロックを獲得できる。例えば、同一行であっても `User` テーブルの `Name` カラムと `Age` カラムを別のトランザクションが更新・参照する場合、ロック競合を起こさずに並列処理が可能だ。
2. 行レベル(Row-Level)
- 行全体を変更するような処理(またはMutationsでの挿入/削除)では、行全体に対してXロックが獲得される。
3. レンジレベル(Range-Lock / Gap-Lock)
- ここが盲点になりやすい。`WHERE Age > 30` のような範囲検索をRead-Writeトランザクション内で行うと、該当するデータのスキップリスト上に「ギャップ(隙間)ロック」が設置される。これにより、トランザクション実行中のファントム・リード(Phantom Read)を防ぐ。
—
3. 分散デッドロックをどう回避しているか?:Wait-Die アルゴリズム
複数のスプリットをまたぐRead-Writeトランザクションを実行する際、古典的な「デッドロック検出(Wait-For Graphの巡回)」を分散環境で行うのはコストが高すぎる。
Spannerはこれを「Wait-Die アルゴリズム」という決定論的な手法で解決している。
Wait-Dieのメカニズム
トランザクション開始時、Spannerはそのトランザクションに「優先度(開始タイムスタンプ)」を付与する。タイムスタンプが古い(過去の)トランザクションほど優先度が高い(Priorityが高い)とみなされる。
今、トランザクション $T_{old}$(古)と $T_{young}$(新)が存在し、同じロックを要求したとする。
- $T_{old}$ が $T_{young}$ の保持するロックを要求した場合:
- Wait: $T_{old}$(先輩)は $T_{young}$(後輩)がロックを解放するのを「待つ」。
- $T_{young}$ が $T_{old}$ の保持するロックを要求した場合:
- Die: $T_{young}$(後輩)は即座に「自決(Abort & Rollback)」する。待つことは許されない。
[ Case 1: 先輩(Old)が後輩(Young)のロックを欲しがる ]
T_Old (Priority High) ───( WAIT )───► [ Locked by T_Young ]
⇒ T_Old はブロックして待機する。
[ Case 2: 後輩(Young)が先輩(Old)のロックを欲しがる ]
T_Young (Priority Low) ───( DIE! )───► [ Locked by T_Old ]
⇒ T_Young は即座にアボートし、バックオフしてリトライする。
このルールにより、待機関係のグラフは常に一方向(古いものから新しいものへ)にしか伸びないため、循環待ち(デッドロック)が物理的に発生しない。
コードレビューで `Aborted due to concurrent transaction` というエラーログを見かけたら、「Wait-Dieアルゴリズムによって後輩トランザクションが殺されたのだな」と即座に理解できなければならない。
—
4. 実務で直面する「ロック競合アンチパターン」と対策
ここからは、実務のコードや設計でよくやらかすアンチパターンと、それを打破する設計パターンを伝授する。
アンチパターン1:Read-Writeトランザクション内での「余計な外部通信」
以下のようなコードをレビューで見つけたら、即座に修正を命じるべきだ。
// ✕【悪しき例】トランザクションブロック内で外部APIをコールしている
_, err := client.ReadWriteTransaction(ctx, func(ctx context.Context, txn spanner.ReadWriteTransaction) error {
// 1. ユーザー情報を取得(ここでSロック獲得)
row, err := txn.ReadRow(ctx, “Users”, spanner.Key{userID}, []string{“Email”, “Status”})
if err != nil {
return err
}
// 2. 外部決済APIを実行(ネットワーク latency 500ms…)
// 💀【致命的】この500msの間、Usersテーブルの該当行にはSロックがかかったまま!
paymentSuccess, err := externalPaymentClient.Charge(ctx, userID)
if err != nil {
return err
}
// 3. 状態を更新(Xロックへ昇格しようとする)
if paymentSuccess {
stmt := spanner.Statement{
SQL: `UPDATE Users SET Status = ‘PAID’ WHERE UserID = @id`,
Params: map[string]interface{}{“id”: userID},
}
return txn.Update(ctx, stmt)
}
return nil
})
何が起きているか?
Sロックを保持したまま外部APIのレスポンス(500ms)を待っている。この間、該当ユーザーに対する他トランザクションの書き込み(Xロック要求)はすべてブロックされ、後輩トランザクションは Wait-Die により殺しまくられる。
正しい設計パターン(フェッチ・イン・アドバンス)
外部通信や重い計算はトランザクションの外に出せ。
// 〇【正しき例】データを事前取得し、API実行後に短時間でRWトランザクションを完了させる
// 1. Read-Onlyトランザクションで必要なデータを取得(ロック非発生!)
var email string
row, err := client.Single().ReadRow(ctx, “Users”, spanner.Key{userID}, []string{“Email”})
// … エラー処理 …
row.ColumnByName(“Email”, &email)
// 2. トランザクションの外で外部APIを実行
paymentSuccess, err := externalPaymentClient.Charge(ctx, userID)
if err != nil {
return err
}
// 3. 超短時間のRead-WriteトランザクションでDBを更新
_, err = client.ReadWriteTransaction(ctx, func(ctx context.Context, txn spanner.ReadWriteTransaction) error {
// 楽観的ロックチェックが必要ならバージョン確認を行う
stmt := spanner.Statement{
SQL: `UPDATE Users SET Status = ‘PAID’ WHERE UserID = @id AND Status = ‘UNPAID’`,
Params: map[string]interface{}{“id”: userID},
}
return txn.Update(ctx, stmt)
})
—
アンチパターン2:広範囲の「インデックス・ギャップロック」によるスループット低下
例えば、タスクキューのテーブルから「未処理のタスクを1件取得してステータスを処理中に変える」処理を愚直にRead-Writeトランザクションで書くとどうなるか。
— Read-Write トランザクション内で実行
SELECT TaskID FROM Tasks WITH FORCE_INDEX = TaskByStatus
WHERE Status = ‘PENDING’ LIMIT 1;
何が起きているか?
Spannerは `Status = ‘PENDING’` の範囲に対してRange-Lock(ギャップロック)をかける。
並列で別のWorkerが同じクエリを実行すると、全く同じレンジに対してSロックを獲得しに行き、更新(Xロック獲得)の段になってお互いがロック競合を起こす。結果として大量のトランザクションがアボートし、ワーカーの数が増えるほどパフォーマンスが落ちるという泥沼に陥る。
対策
1. Partitioned DMLの検討: 厳密な即時一貫性が不要なバッチ処理なら `Partitioned DML` を検討する。
2. タスクのハッシュ分散: タスクを `QueueID` (0〜15) などのバケットに分散させ、ロックの競合範囲(スプリット)を強制的に分離させる。
—
5. テクニカルリードとしての設計チェックリスト
レビュー時、あるいはシステムアーキテクチャ設計時に、以下のチェックリストをチームに徹底させてもらいたい。
1. Read-Onlyで済む処理に Read-Write トランザクションを使っていないか?
- 単なる画面表示のためのSELECTに `ReadWriteTransaction` を使うな。Snapshot Readを使え。
2. Read-Write トランザクションの生存期間(Lifetime)は最小化されているか?
- トランザクション内で分岐判断のための重い計算や、外部RPCを呼んでいないか?
3. ホットスポット(シーケンシャルキー)によって特定スプリットのロックマネージャーに負荷が集中していないか?
- UUID v4 やハッシュ値をプレフィックスに付与し、スプリット(=Paxosリーダー=ロックマネージャー)を分散させているか?
4. 大量データの更新に DML ではなく Mutation を使っているか?
- 読み取りを伴わない単純な `INSERT/UPDATE` であれば、DML(SQL文の解析オーバーヘッドあり)ではなく `Mutation` を使え。ロック保持時間が減り、スループットが向上する。
—
まとめ
Cloud Spannerは、TrueTimeという比類なき科学の結晶によって「読み取りのノンスケーラブルな制約」を解き放った。しかし、書き込みの一貫性を保証しているのは、各スプリットのPaxosリーダー上でひっそりと、かつ苛烈に動作するメモリ上のロックマネージャーとWait-Dieアルゴリズムだ。
- ロックマネージャーはスプリットのメモリ上にあり、リーダー昇格でリセットされる。
- S/Xロックはセル、行、そしてレンジの粒度で制御される。
- デッドロックはWait-Dieにより「若いトランザクションを殺す」ことで防ぐ。
この物理的な挙動を脳裏に描きながらコードを書くこと。それが、Spannerのパフォーマンスを極限まで引き出す真のアーキテクトの姿である。
コメント