Spannerクエリ最適化の深淵:分散結合のコストモデルを支配し、ネットワークの魔物をねじ伏せる
テックリードの私だ。今日のコードレビューで、また「なんとなく書いたJOIN文」がSpannerの分散ネットワークを盛大に踏み荒らしているクエリを見た。
「なぜこのクエリは遅いのか?」
「なぜスキャン順序を変えただけでCPU使用率が跳ね上がるのか?」
リファレンスをただ眺めていても、この問いには答えられない。Cloud Spannerのオプティマイザは、単なるRDBの延長線上にあるコストベースオプティマイザ(CBO)ではない。数千のノードにまたがる分散空間において、「データがどこにあり、どう移動させれば最もネットワークの帯域とCPUを節約できるか」をミリ秒単位で計算する、冷徹な物理エンジンだ。
今回は、Spannerのクエリ最適化エンジン、特に分散結合(Distributed Join)とスキャン順序決定の裏側にあるメカニズムを丸裸にし、実務で爆速のシステムを組み上げるための設計哲学を伝授しよう。
—
1. 基礎解剖:Spannerオプティマイザは何を見ているのか
前提として、Spannerのストレージは階層型モデル(Interleave)と、バイト列順にソートされたスプリット(Split)群で構成されている。データは物理的に分散している。つまり、JOINを仕掛けた瞬間から、「ネットワークを跨いだデータのシャッフル」という名の魔物との戦いが始まる。
オプティマイザは、クエリを受け取ると以下の要素からコストを見積もる。
1. 統計情報(Statistics): テーブルおよびインデックスの行数、データの分布、NULLの割合。これらはバックグラウンドで自動収集されるが、極端なデータ偏skew(スキュー)がある場合は手動での統計情報管理が勝負の分かれ目になる。
2. 物理ロケーション(Locality): インターリーブされているか? 結合キーが親テーブルのプライマリキー(プレフィックス)と一致しているか?
3. オペレーションコスト: CPUコスト(式評価、ローカルスキャン)+ 通信コスト(ネットワーク転送量・ホップ数)
特に3番目の「通信コスト」の評価アルゴリズムを理解しているかどうかが、プロのエンジニアと素人の分かれ道だ。
—
2. 分散結合(Distributed Join)の3つの戦術と内部挙動
Spannerが選択する分散結合のアルゴリズムは、主に以下の3つに分類される。オプティマイザはこれらを動的に選択する。
① 集中結合(Local / Colocated Join)— 神の最適化
インターリーブ(Interleave)設計が完璧になされている場合、親テーブルのレコードと子テーブルのレコードは、物理的に同じスプリット(同じ物理マシン、あるいは同じホスト上)に格納される。
この場合、ネットワーク転送はゼロになる。オプティマイザはこのパスを最優先で選択する(Colocated Join)。
② 分散ハッシュ結合(Distributed Hash Join)
結合キーにインターリーブが効いていない場合、あるいは大量の非階層データ同士を結合する場合に発動する。
- 挙動: 左右のテーブルからデータをスキャンし、結合キーのハッシュ値に基づいてデータをネットワーク経由でシャッフル(再分散)し、一致するパーティション同士で結合を行う。
- 代償: 膨大なネットワークI/Oが発生する。スプリット間をデータが飛び交うため、クロスリージョン環境であればレイテンシが一気に悪化する。
③ 分散ループ結合(Distributed Apply / Lookup Join)
外側(Outer)のテーブルから取得した少量の行のキーを使い、内側(Inner)のテーブルに対してリモートからピンポイントでルックアップを行う方式。
- 挙動: 外側の結果が数件〜数十件と非常に少ない場合に極めて有効。逆に、外側が何万件もある状態でこれを選ばれると(Nested Loop Joinの悪夢)、リモートRPCの嵐(N+1問題の分散版)となり、CPUが枯渇する。
—
3. 実践:スキャン順序とインデックス戦略のコードレビュー
実際のクエリを例に、オプティマイザを意図通りに誘導する技術を見ていこう。
悪い例:オプティマイザを迷わせるクエリ
以下のスキーマを想定する。
- `Users` (UserId, Region, Profile)
- `Orders` (UserId, OrderId, Amount, CreatedAt) —— `Users`とはインターリーブされていない別テーブル。
— 【アンチパターン】分散ハッシュ結合の罠を踏むクエリ
SELECT
u.UserId,
o.OrderId,
o.Amount
FROM
Orders o
JOIN
Users u ON o.UserId = u.UserId
WHERE
u.Region = ‘JP’
AND o.CreatedAt >= ‘2023-1-1’;
何が起きているか?
オプティマイザの統計情報において、「`Orders`の期間条件に合致するデータ」と「`Users`の地域条件に合致するデータ」のどちらを先にスキャンすべきか、あるいはどちらをハッシュテーブルのビルド側(左側)にすべきか、見積もりが揺らぐことがある。
結果として、不要な全件スキャンや、巨大なデータセットのネットワークシャッフルが発生する。
良い例:スキャン順序とコストを支配する設計
ここでテックリードとして下すべき指示は2つだ。
1. 結合キーに対する適切なセカンダリインデックスの付与。
2. オプティマイザに「どちらを駆動表(Driver)にするか」を明確に示唆するヒント(あるいはクエリ構造の整理)の適用。
— 【推奨パターン】インデックスとスキャン順序を最適化したクエリ
— Orders側から絞り込むべきか、Users側から絞り込むべきかを明確にする
SELECT
u.UserId,
o.OrderId,
o.Amount
FROM
Users u@{FORCE_INDEX=Users_By_Region}
JOIN
Orders o@{FORCE_INDEX=Orders_By_User_Date}
ON u.UserId = o.UserId
WHERE
u.Region = ‘JP’
AND o.CreatedAt >= ‘2023-01-01’;
> 💡 テックリードの解説:
> `FORCE_INDEX` ディレクティブは乱用すべきではないが、「オプティマイザの統計情報が追いついていないリリース初期のバースト」や「データスキューが激しいマスターデータ」に対しては、エンジニアの意図を強制的に伝える強力な武器になる。
> カバリングインデックス(`STORING`句の活用)を組み合わせることで、テーブル本体へのランダムアクセス(Lookup)を排除し、すべてをインデックス上のスキャンだけで完結させることが、Spannerチューニングの極意だ。
—
4. パフォーマンス上の致命的な罠と回避策
実務でシステムをスケールさせるとき、以下の罠に必ず直面する。
罠1: 「巨大IN句」による分散Applyの暴発
アプリケーション側で数千件のIDを生成し、`WHERE id IN (…)` で問い合わせるアンチパターン。
- 結末: Spannerはこれを小さなクエリの束ではなく、巨大な分散Applyとして処理しようとし、内部でプッシュダウン処理が破綻してクエリタイムアウト(Deadline Exceeded)を引き起こす。
- 対策: 検索条件のIDsを一時テーブル(Temporary Table / Staging Table)にバルクインサートし、それをJOINする形にリファクタリングせよ。Spannerはテーブル同士のJOINであれば、高度な分散ハッシュやマージ結合を選択できる。
罠2: 統計情報のスタale(古さ)によるプラン劣化
データが急激に増減した直後、オプティマイザの統計情報が追いつかず、最適なスキャン順序を見誤る。
- 対策: バッチ処理や大規模データインポートの直後には、必要に応じて統計情報の強制更新を検討する。基本は自動収集に任せるが、大規模なスキーマ変更やデータ移行のフローにはこの観点を組み込んでおくこと。
—
5. まとめ:Spannerアーキテクチャと対話せよ
Cloud Spannerのクエリ最適化エンジンはブラックボックスではない。それは「分散環境における物理的制約(ネットワークコストとストレージ配置)」を極限まで効率化するために数学的に設計されたシミュレータだ。
コードレビューの際、単に「動くSQLか」を見るな。
- 「このJOINは、ネットワークのどこでデータをシャッフルしているか?」
- 「このWHERE句は、インデックスのプレフィックスを完全に活用できているか?」
- 「スプリットを無駄に跨ぐスキャンになっていないか?」
この視点を持った瞬間から、あなたの書くクエリは単なる命令文から、Spannerの分散OSを自在に操るための「洗練された指揮棒」へと変わる。
妥協のない設計を続けよう。それが世界最高峰のエンジニアの仕事だ。
コメント