【Cloud Spanner】ホットスポット完全壊滅:実務で踏み抜かないための「分散キー設計」極意
こんにちは。テクニカルリードの私だ。
今日のコードレビュー、あるいは基本設計レビューで、こんなプライマリキー(主キー)の定義を見かけて冷や汗をかいたことはないか?
— よくある「やってしまった」テーブル定義
CREATE TABLE Orders (
OrderId STRING(36) NOT NULL, — アプリケーション側で生成したランダムUUID
CustomerId STRING(64) NOT NULL,
OrderDate TIMESTAMP NOT NULL,
TotalAmount NUMERIC(38, 9),
) PRIMARY KEY(OrderId);
「おっ、UUIDv4を使っているから一意性は完璧ですね!」なんてドヤ顔で答えたジュニアエンジニアがいたら、すかさずレッドカードを出してほしい。
Cloud Spannerの本番運用において、この設計は「特定のノードを物理的に焼き切るための招待状」に等しい。Spannerの真のパワーを引き出し、ペタバイト級のトラフィックを涼しい顔して捌き切るためには、ストレージと分散の物理法則に基づいた「正しいキー設計」が不可欠だ。
今日は、Cloud Spannerの心臓部であるスプリットとキー範囲のメカニズムを解き明かし、ホットスポットを完全にハックするための実践的アプローチを伝授する。
—
1. なぜSpannerで「ホットスポット」が致命傷になるのか?
まず、Spannerのアーキテクチャの根幹を思い出してほしい。
Spannerは、データをLexicographical Order(辞書順)でソートし、連続するキー範囲ごとに「スプリット(Split)」という物理的なストレージ単位に分割して、世界中のマルチスプリット(あるいはマルチノード)に分散配置する。
ここで重要なのは、「Spannerはリニアにスケールするが、それはトラフィックとデータが適切に分散されている場合に限る」という点だ。
単調増加キー(オートインクリメント等)の悲劇
もし主キーに `TIMESTAMP` や `AUTO_INCREMENT`(Spannerではアンチパターンだが)のような単調増加する値を使うと、どうなるか?
新しく挿入されるデータは、常に辞書順の「一番ケツ(最後尾)」に集中する。
[ スプリット A ] [ スプリット B ] [ スプリット C (現在の書き込み先) ]
(古いデータ) (中くらいのデータ) (最新のデータ 爆発的な負荷集中!)
A 〜 M N 〜 T U 〜 Z <-- 全トラフィックがここへ!
結果として、全ノードが存在するにもかかわらず、最新のキー範囲を保持しているたった1つのスプリット(=たった1つの物理ノード、あるいはリーダーレプリカ)にすべての書き込みトラフィックが集中する。これが、CPU使用率100%・レイテンシの急上昇・スループット頭打ちを引き起こすホットスポットの正体だ。
—
2. 現場で使える「ホットスポット回避」3つの極意
では、この物理的制約をどう突破するか。実務で採用すべき設計パターンは大きく分けて3つある。
① ビット反転(Bit Reversal): シーケンシャルな数値を綺麗に散らす
もしビジネス要件上、どうしてもIDの大小関係を維持したい、あるいは数値ベースのID(例: 64ビット整数)を使いたい場合、「ビット反転(Bit Reversal)」が極めて有効だ。
例えば、`BIGINT`(64ビット整数)のインクリメントIDがあるとする。これをそのままキーにするのではなく、ビットを左右反転させる。
- `1` (`000…001`) ➡️ 反転すると `9223372036854775808` (`100…000`)
- `2` (`000…010`) ➡️ 反転すると `4611686018427387904` (`010…000`)
これにより、隣接していた連続するIDは、全ビット空間全体に美しくランダムに散らばる。
【実装例: Goでのビット反転ロジック】
package main
import “math/bits”
// ReverseBits64 は64ビット整数のビットを完全に反転させ、
// 単調増加するIDをSpannerのキー空間全体に均等に分散させる。
func ReverseBits64(n uint64) uint64 {
return bits.Reverse64(n)
}
func main() {
// 例: 連続するIDを生成して分散させる
var id uint64
for id = 1; id <= 5; id++ {
dispersedKey := ReverseBits64(id)
// この dispersedKey を Cloud Spanner の PRIMARY KEY として使用する
_ = dispersedKey
}
}
※注意: ビット反転を行うと、範囲クエリ(`WHERE id BETWEEN 1 AND 10`など)の効率は失われる。ポイントルックアップ専用のキーと割り切るべし。
---
② ハッシュ化 / プレフィックス付与: マルチテナントの救世主
マルチテナントシステムや、ユーザーごとのアクティビティログを格納するテーブルで最もやりがちなミスが、`PRIMARY KEY(TenantId, CreatedAt)` という定義だ。
特定の巨大テナント(メガ顧客)が参加した瞬間、そのテナントの `TenantId` 配下のデータに書き込みが集中し、そのテナントだけがスローダウン、あるいはDB全体を巻き添えにする。
これを防ぐのが、ハッシュプレフィックス付与だ。
— 改善されたテーブル定義
CREATE TABLE TenantActivityLogs (
— テナントIDのハッシュの上位数ビットをプレフィックスとして付与
ShardId INT64 NOT NULL,
TenantId STRING(64) NOT NULL,
ActivityId STRING(64) NOT NULL,
Timestamp TIMESTAMP NOT NULL,
Payload STRING(MAX),
) PRIMARY KEY(ShardId, TenantId, ActivityId);
アプリケーション側で `TenantId` のハッシュ値を計算し、例えば `0` から `7` までのシャードIDを算出する。これを第一キーに置くことで、たとえ同一テナントへの書き込みであっても、物理的なスプリットに強制的に分散させることができる。
—
③ UUID v4 / v7 の正しい選択と落とし穴
冒頭でUUIDv4を全否定したが、UUIDがすべて悪というわけではない。
- UUIDv4(完全ランダム):
- メリット: 書き込みはキー空間全体に完璧に分散する(ホットスポットは起きない)。
- デメリット: データが物理ストレージ上でバラバラに配置されるため、ローカリティ(局所性)が失われ、範囲スキャンやキャッシュ効率が最悪になる。インデックスの断片化(Page Fragmentation)が起きやすい。
- UUIDv7(タイムスタンプベース + ランダム):
- プレフィックスにタイムスタンプを持ちつつ、サフィックスにランダム性を持つ。現代のSpannerアプリケーションにおける「スイートスポット」と言える。
もしUUIDを使うなら、単なるランダム(v4)ではなく、時系列の秩序を保ちつつ適度にバラすUUIDv7の採用を強く推奨する。Spannerは `BYTES(16)` 型をサポートしているため、UUIDをそのままネイティブのバイト列として格納・インデックス化するのが最も効率が良い。
—
3. チーフアーキテクトからの実践的チェックリスト
設計レビューで私が必ず確認する「Spanner分散キーの鉄則」を共有しよう。
1. 初期スプリットの予熱(Pre-splitting)を行っているか?
数億件の既存データをバルクインサートする際、最初から巨大なテーブルを作ると最初の1つのスプリットに負荷が集中する。事前にスプリット境界を定義(`CREATE TABLE … SPLIT AT`)しているか?
2. インターリーブ(Interleave)構造の親キーに単調増加値を選んでいないか?
親子関係(Parent-Child)を持つテーブルで、親の主キーにオートインクリメントやタイムスタンプを使っている場合、子テーブルの書き込みもすべて親のキー範囲に引きずられてホットスポット化する。親のキーこそ、ハッシュ化や分散が必要だ。
3. モニタリングの設定は万全か?
Google Cloud ConsoleのCloud Spanner指標から、「CPUUtilization(CPU使用率)」のハイパーリージョン別・ノード別の偏りや、「Transaction Latency」を常に監視しているか? 特定のノードだけが飛び抜けて高いCPUを叩いていたら、即座にキー設計の敗北を認めよ。
—
結びにかえて
Cloud Spannerは魔法のデータベースではない。物理法則(分散システムの制約)を無視してコードを書けば、いかにSpannerといえども膝を屈する。
しかし、ストレージの分散メカニズムを理解し、「データをいかに美しく、均等に空間へ散らすか」をコントロールできれば、これほど頼もしい武器はない。数百万QPSの世界へようこそ。君たちの手で、真にスケーラブルで美しいシステムを構築してくれ。コードレビューで合格点を出す日を楽しみにしている。
コメント