なぜ君のSQLはSpannerで遅いのか?〜クエリプランナーの内部アーキテクチャを解剖する〜
「Cloud Spannerを使っているのに、期待したパフォーマンスが出ない」
「RDBMSと同じ感覚でSQLを書いたのに、分散クエリのオーバーヘッドでレイテンシが跳ね上がる」
設計レビューやコードレビューの場で、私は何度もこの悲鳴を聞いてきた。
Cloud Spannerは、TrueTime APIと Paxos Consensus によってグローバルな強整合性と高い可用性を実現する夢のような分散データベースだ。しかし、どれほど下層のストレージエンジンが強力であっても、上位層の「クエリプランナー」がどのようにSQLを解釈し、論理実行計画(Logical Query Plan)へ変換しているかを理解していなければ、その潜在能力を引き出すことは不可能である。
単一ノードのPostgreSQLやMySQLと同じ感覚でSQLを発行すると、Spannerのクエリプランナーは巨大な分散データをかき集める最悪の実行計画を選択せざるを得なくなる。
本稿では、SpannerにおけるSQLの構文解析・意味解析・クエリ正規化のメカニズムから、分散環境特有の論理実行計画が組み上がるプロセスまでを徹底解剖する。ブラックボックスを排除し、プランナーの「思考回路」を完全に叩き込んでもらう。
—
1. Spannerクエリパイプラインの全体像
SpannerがSQL文を受け取ってから、分散ストレージ階層(Colossus)へとリクエストを投げるまでの全体パイプラインは以下の4つのフェーズに大別される。
[ SQL Client ]
│
▼
┌─────────────────────────────────────────┐
│ 1. 構文解析 (Parsing) │ ──> AST (抽象構文木)
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 2. 意味解析 (Semantic Analysis) │ ──> Resolved AST (カタログ結合済AST)
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 3. クエリ正規化 (Query Rewrite/Norm) │ ──> Normalized Relational Algebra
└─────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────┐
│ 4. 分散論理計画生成 (Logical Planning) │ ──> Distributed Logical Plan
└─────────────────────────────────────────┘
│
▼
[ コストベース最適化 (CBO) & 物理実行計画 ]
Spannerのクエリフロントエンドは ZetaSQL と呼ばれるGoogle内部の標準SQLアナライザをベースに構成されている。このパイプラインを順を追って深掘りしよう。
—
2. Parsing・Analysis・Normalization:SQLが論理代数に化けるまで
Phase 1: 構文解析 (Parsing)
入力されたSQLテキストは、トークナイザによってトークン列に分解され、AST(Abstract Syntax Tree: 抽象構文木)へ変換される。この段階では、テーブルが存在するか、カラム名が正しいかといった意味的妥当性は一切検証されない。純粋に文法(Grammar)に従っているかのみがチェックされる。
Phase 2: 意味解析 (Semantic Analysis)
生成されたASTに対し、Spannerのメタデータ・カタログ(Schema Catalog)を参照しながら型の整合性と参照の解決を行う。
- `Users` というテーブルが存在するか?
- `user_id` は `INT64` 型であり、比較対象のパラメータと型キャスト可能か?
- 暗黙の型変換(Implicit Casting)が必要か?
意味解析を通過すると、ASTは型情報とスキーマ参照が完全にバインドされた Resolved AST へと昇華する。
Phase 3: クエリの正規化と書き換え (Query Rewrite & Normalization)
ここはクエリプランナーのIQが最も問われる領域だ。宣言的なSQL文を、効率的な関係代数木(Relational Algebra Tree)に変換するために、無数の代数法則が適用される。
代表的な正規化プロセスをいくつか挙げよう。
A. Constant Folding (定数畳み込み)
実行前に計算可能な式を評価し、定数へ置き換える。
— 変換前
SELECT FROM Orders WHERE created_at < TIMESTAMP_ADD(TIMESTAMP '2023-10-01 00:00:00UTC', INTERVAL 1 DAY);
-- 正規化後
SELECT FROM Orders WHERE created_at < TIMESTAMP '2023-10-02 00:00:00UTC';
B. Predicate Pushdown (述語の押し下げ)
フィルタ条件(`WHERE`句)を関係代数ツリーの下層(可能な限りScan演算子の直上、あるいはScan内部)へ押し下げる。これにより、メモリ上に読み出すデータ量を最下流で削り取る。
C. Subquery Unnesting (サブクエリの非相関化)
相関サブクエリ(Correlated Subquery)を `JOIN` や `SEMI-JOIN` 構造に書き換える。これは単一DBでも重要だが、分散DBであるSpannerにおいてはノード間のラウンドトリップ回数を爆発させないための絶対条件となる。
—
3. 分散論理計画の生成:単一ノードRDBMSとの決定的な違い
正規化された関係代数演算子は、次に「分散環境で実行可能な論理実行計画」へとマッピングされる。ここがSpannerをSpannerたらしめるコアアーキテクチャだ。
Spannerのデータは、主キーの範囲(Key Range)ごとに分割された Split(スプリット) という単位で複数ノードに分散管理されている。したがって、クエリプランナーは「どのSplitにデータを問い合わせるべきか」を計算する特異な演算子を挿入する。
`Distributed Union` 演算子
Spannerの論理実行計画のトップレベルには、ほぼ必ず `Distributed Union` (あるいはその変種)が存在する。
Logical Plan:
└─ Distributed Union (Splitの並列スキャンと結果の統合)
└─ Local Scan (各Splitを担当するノード上でのスキャン)
クエリプランナーは、`WHERE` 句の述語から主キーのプレフィックスを抽出し、Pruning(プルーニング:枝払い)を行ってアクセスすべきSplitを限定する。
- Point Read (点参照): ターゲットのSplitが1つに特定されるため、`Distributed Union` は単一ノードへのRPCへと軽量化される。
- Range Scan (範囲スキャン): ターゲットとなるキー範囲が複数のSplitに跨る場合、プランナーはそれらのSplitへクエリを並列に分散送信する計画を立てる。
分散Join:`Distributed Cross Apply` vs `Hash Join`
Spannerが2つのテーブルを結合する際、プランナーは主に以下のいずれかの論理戦略を選択する。
1. Distributed Cross Apply (Nested Loop Joinの分散拡張):
親テーブルの出力行ごとに、子テーブルの該当するキーを持つSplitへピンポイントでルックアップをかける。インターリーブ構造(`INTERLEAVE IN PARENT`)を採用している設計では、親と子のデータが物理的に同一のSplit内に共存(Co-locate)しているため、ネットワーク越しのデータ転送ゼロで超高速に実行される。
2. Distributed Hash Join / Distributed Merge Join:
結合キーでInterleaveされていない大容量テーブル同士を結合する場合、プランナーは両方のテーブルからデータを全量分散スキャンし、メモリ上でハッシュテーブルを構築して結合する。これは極めて重いネットワークオーバーヘッドを伴う。
—
4. 実証:`EXPLAIN` からクエリプランナーの思考を読み解く
理論を理解したところで、実際のクエリと実行計画を見てみよう。
以下のような、親テーブル `Customers` と子テーブル `Orders`(Interleave設定あり)のスキーマを定義したとする。
— テーブル定義
CREATE TABLE Customers (
CustomerId INT64 NOT NULL,
Name STRING(MAX),
) PRIMARY KEY (CustomerId);
CREATE TABLE Orders (
CustomerId INT64 NOT NULL,
OrderId INT64 NOT NULL,
OrderDate DATE,
Amount NUMERIC,
) PRIMARY KEY (CustomerId, OrderId),
INTERLEAVE IN PARENT Customers ON DELETE CASCADE;
この状態に対し、以下のクエリを発行し、その `EXPLAIN`(実行計画)を検証する。
— 特定顧客の注文履歴を取得するクエリ
SELECT c.Name, o.OrderId, o.Amount
FROM Customers c
JOIN Orders o ON c.CustomerId = o.CustomerId
WHERE c.CustomerId = @customerId;
生成された実行計画(概念表現)
Distributed Union [Split Pruning: CustomerId = @customerId]
└─ Distributed Cross Apply
├─ [Local] Scan (Table: Customers, Key: CustomerId = @customerId)
└─ [Local] Scan (Table: Orders, Key: CustomerId = @customerId)
プランナーの意思決定ログ(解説)
1. Parse & Analyze: `c.CustomerId = o.CustomerId` および `c.CustomerId = @customerId` から、両テーブルの `CustomerId` が一意に定まることを解析。
2. Normalize: 述語移転により、`Orders` テーブルに対しても `Orders.CustomerId = @customerId` というフィルタ条件を自動付与。
3. Logical Planning:
- `CustomerId` が主キーであるため、ルーティングテーブル(Location Service)を参照して1つのSplitに絞り込む(Split Pruning)。
- `Orders` は `Customers` にインターリーブされているため、データは同じ物理ノードに存在する。したがって、プランナーはリモートネットワーク呼び出しを排除し、ノード内でローカル結合を実行する `Distributed Cross Apply` を採択。
これが、Spannerにおいてミリ秒未満〜数ミリ秒で応答が返る「理想的な分散実行計画」である。
—
5. 現場のテクニカルリードが叩き込むべき3つの罠と設計パターン
クエリプランナーの仕組みを理解すると、開発でありがちな「アンチパターン」がいかにプランナーの足を引っ張るかが明確になる。
① リテラル直埋め vs パラメータ化クエリ(クエリキャッシュの破滅)
コードレビューで最も厳しく弾くべきは、SQL文へのリテラルの直接埋め込みである。
— ❌ アンチパターン: リテラル埋め込み
SELECT FROM Users WHERE UserId = 10023;
SELECT FROM Users WHERE UserId = 10024;
なぜダメなのか?
Spannerのクエリプランナーは、構文解析と論理計画の生成結果を Query Plan Cache に保持する。リテラルが埋め込まれたSQLは、テキストが異なるためキャッシュミスを引き起こす。
結果として、クエリが届くたびに Parsing -> Analysis -> Normalization -> Planning の全パイプラインがCPUを消費して再実行される。Spannerにおいて、このコンパイルオーバーヘッドはレイテンシの数百ミリ秒化やCPUスパイクの主原因となる。
— ⭕ 堅牢なパターン: パラメータ化クエリ
SELECT FROM Users WHERE UserId = @userId;
必ずパラメータ構文(`@userId`)を使用すること。プランナーは1度生成した計画をキャッシュし、2回目以降はコンパイルコストゼロで実行に移る。
—
② 不必要な Subquery / OR 述語による Unnesting の失敗
Spannerのクエリプランナーは高度だが、万能ではない。複雑すぎる `OR` 条件や相関サブクエリは、プランナーの非相関化(Unnesting)ロジックを限界に追い込み、最悪の実行計画を選択させる。
— ❌ プランナーが最適化に失敗しやすいクエリ
SELECT FROM Products p
WHERE p.CategoryId = @catId
OR p.ProductId IN (SELECT ProductId FROM FlashSales WHERE Active = true);
この `OR` 述語が存在することで、プランナーは `CategoryId` によるインデックススキャンと `FlashSales` からのルックアップを最適に統合できず、`Products` テーブル全体に対する Full Table Scan(分散フルスキャン) へフォールバックしてしまうことがある。
解決策: `UNION ALL` による計画の明確な分離
クエリプランナーに推測させるのをやめ、明示的に2つの独立したシンプルな論理計画に分割して結合させる。
— ⭕ プランナーに最適なインデックススキャンを強制するパターン
SELECT FROM Products WHERE CategoryId = @catId
UNION ALL
SELECT p. FROM Products p
JOIN FlashSales f ON p.ProductId = f.ProductId
WHERE f.Active = true AND p.CategoryId != @catId; — 重複排除のための排他条件
—
③ 統計情報(Optimizer Statistics)と Plan Pinning(バージョン固定)
Spannerのクエリプランナーは、バックグラウンドで収集されるテーブルのデータ分布統計情報(Optimizer Statistics)を基にコスト計算(CBO)を行っている。
データ量が増大すると、ある日突然、プランナーが「Hash Joinのほうが効率的だ」と判断を変え、パフォーマンスが崖から落ちるように劣化するケースが存在する。
これを防ぎ、本番環境の予測可能性(Determinism)を保証するために、テクニカルリードはクエリプランナーのバージョン管理を運用プロセスに組み込まなければならない。
— データベース全体でOptimizerのバージョンを特定バージョンに固定する
ALTER DATABASE my_database SET OPTIONS (optimizer_version = 6);
— もしくは特定クエリにヒントを与えて固定する
@{optimizer_version = 6}
SELECT c.Name, o.Amount
FROM Customers c
JOIN Orders o ON c.CustomerId = o.CustomerId;
新しいオプティマイザバージョン(例えば `Latest` やバージョン `7`)がリリースされた際は、ステージング環境で `READ_ONLY` の本番トラフィックをリプレイ検証(A/Bテスト)し、回帰(Regression)がないことを確認した上で本番のバージョンを引き上げる。これがSpanner運用におけるプロの鉄則だ。
—
6. まとめ:アーキテクチャを理解した者が、Spannerを制する
Spannerのクエリプランナーは単なるSQLの翻訳機ではない。分散された巨大なストレージトポロジの上で、最小のネットワークレイテンシと最高の並列度を計算する分散計算エンジンそのものである。
1. 構文解析・意味解析・正規化を経て、宣言的SQLは代数ツリーへ磨き上げられる。
2. 分散論理計画において、主キー設計とインターリーブ構造が `Distributed Union` の効率(Split Pruning)を決定づける。
3. パラメータ化クエリの徹底、オプティマイザ統計とバージョンの管理なしに、プロダクションの安定はあり得ない。
「SQLが動いたから良し」とする実装レベルのエンジニアリングは今日で終わりにしよう。
クエリプランナーが裏でどのような論理実行計画を描いているか、その「思考」を読み解き、プランナーが最も効率的な経路を通れるように誘導する物理設計・クエリ設計を行うこと。それこそが、Spannerの真の性能を引き出すテクニカルリードの責務である。
コメント