【実務・中級編】 ロックマネージャーのアーキテクチャ – Cloud Spanner

「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のパフォーマンスを極限まで引き出す真のアーキテクトの姿である。

コメント

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