【Cloud Spanner】クエリオプティマイザの完全制御:実行計画を支配し、レイテンシのブレを根絶する技術
こんにちは。テクニカルリードの私だ。
コードレビューや設計レビューで、こんなセリフを耳にしたことはないだろうか。
- 「本番環境データ量が増えたら、急にこのクエリが遅くなった」
- 「ステージングでは一瞬だったのに、本番でスキャン数が跳ね上がっている」
- 「オプティマイザの気まぐれで、インデックスが使われなくなった」
Cloud Spannerはフルマネージドの分散RDBであり、そのクエリオプティマイザ(CBO: Cost-Based Optimizer)は非常に優秀だ。しかし、データ分散環境における統計情報の更新や複雑なJOIN、述語のプッシュダウンの兼ね合いで、「人間が意図した最適な実行計画」と「オプティマイザが選択した実行計画」が乖離する瞬間が必ず訪れる。
今回は、Cloud Spannerのクエリオプティマイザを完全に手なずけ、ミリ秒単位のレイテンシを死守するための「バージョン管理」と「ヒント句による実行計画の強制制御」について、実務の現場でそのまま使えるレベルの知見を叩き込む。
—
1. クエリオプティマイザのバージョン管理:なぜ「固定」が必須なのか
Spannerのオプティマイザは常に進化している。Googleは定期的にオプティマイザの挙動を改善し、新しいバージョンをリリースしている。これは素晴らしいことだが、「昨日まで10msで返っていたクエリが、オプティマイザの自動更新によって突然フルスキャン(数万行の読み取り)に変わり、レイテンシが数秒に跳ね上がった」というインシデントは、大規模システムにおいて悪夢でしかない。
オプティマイザバージョンの指定戦略
Spannerでは、クエリ単位、データベース単位、あるいはセッション単位でオプティマイザのバージョンを固定できる。
— クエリ単位でのバージョン指定(構文例)
@{OPTIMIZER_VERSION=620}
SELECT
u.user_id,
o.order_id
FROM Users u
JOIN Orders o ON u.user_id = o.user_id
WHERE u.status = ‘ACTIVE’;
実務における鉄則は以下の通りだ。
1. 本番環境のデフォルトデータベース設定では、必ず特定のバージョンに固定する(例: `LATEST` を直に使わず、検証済みのバージョンを指定する)。
2. 新しいオプティマイザバージョンへの追従は、必ずステージング環境での負荷テストと実行計画(`EXPLAIN`)の比較を経て、手動でバージョンをインクリメントして行う。
3. リリースパイプラインに `EXPLAIN` の自動回帰テストを組み込み、意図しない実行計画の変更を検知できるようにする。
—
2. 実行計画の覗き見:`EXPLAIN` と `PROFILE` の使い分け
制御を語る前に、現状の把握方法を揃えておこう。Spannerには2つの強力な武器がある。
- `EXPLAIN`: 実際にクエリを実行せず、オプティマイザが選択した「実行計画(コスト見積もり)」を返す。
- `PROFILE`: クエリを実際に実行し、各オペレータ(分散フェッチ、スキャン、JOINなど)で消費された実際のCPU時間、行数、メモリ使用量を完全に可視化する。
パフォーマンスチューニングの基本は、「勘」ではなく `PROFILE` の結果(特に `Rows` と `Exact Rows` の乖離、および `CPU time` の偏り)に基づくことだ。
—
3. ヒント句による実行計画の強制制御
オプティマイザが間違った道を選んだ時、我々にはそれを強制的に矯正する手段が用意されている。それがクエリヒント(Query Hints)だ。
代表的な制御用ヒントを実務的な文脈とともに見ていこう。
① `FORCE_JOIN_ORDER`:結合順序の完全支配
複数のテーブルをJOINする場合、結合順序(どのテーブルを起点にし、どれをハッシュ結合やアプライ結合するか)でコストが桁違いに変わる。特に分散データベースであるSpannerにおいて、不適切な順序はネットワーク越しの無駄なデータ転送(Cross-Node communication)を爆発させる。
以下の例を見てほしい。
— 悪い例:オプティマイザが巨大な Orders を先にスキャンし、Users と結合しようとしてメモリ溢れを起こすケース
SELECT @{FORCE_JOIN_ORDER=true}
u.user_name,
item.item_name
FROM Orders o
JOIN Users u ON o.user_id = u.user_id
JOIN OrderItems oi ON o.order_id = oi.order_id
JOIN Items item ON oi.item_id = item.item_id
WHERE o.created_at >= ‘2023-10-01’;
【チーフアーキテクトの知見】
`FORCE_JOIN_ORDER=true` を指定すると、FROM句に記述したテーブルの左から順に結合が行われるようになる。
つまり、開発者は「絶対に絞り込まれるべき高選択性の小さなテーブル(例: マスタや直近の特定日時でフィルタされたトランザクション)」を一番左に置き、そこから順に結合するパイプラインを設計しなければならない。このヒントを使うときは、FROM句の並び順自体がアルゴリズムの定義になることを忘れるな。
② `@{JOIN_METHOD=…}`:結合アルゴリズムの強制
Spannerは主に以下の結合アルゴリズムをサポートしている。
- Hash Join: 大規模なデータ同士の結合に強い。
- Apply Join (Nested Loop): 片方のデータが極めて小さく、インデックスが効く場合に強力。
もしオプティマイザが誤ってHash Joinを選び、大量のメモリを消費している場合や、逆にNested Loopで無限に近いランダムアクセスが発生している場合は、ヒントで明示的に縛る。
SELECT
u.user_id,
a.address_1
FROM Users@{FORCE_INDEX=UsersByEmail} u
JOIN Addresses a@{JOIN_METHOD=HASH_JOIN}
ON u.user_id = a.user_id
WHERE u.email = ‘target@example.com’;
—
4. 実務で遭遇する「最悪のアンチパターン」と回避策
ここで、レビューで私が必ず弾き返す、オプティマイザ関連の典型的なアンチパターンを共有しよう。
アンチパターン:ヒントの乱用
「遅いからとりあえず全部のテーブルに `FORCE_INDEX` や `FORCE_JOIN_ORDER` を貼る」
これは最悪の悪手だ。データ構造が変わった瞬間、そのヒントは「毒」に変わり、システムを沈没させる。
【正しいアプローチ】
1. まずスキーマ設計を見直せ(インターリーブ構造の活用、適切なセカンダリインデックスの設計)。
2. 次に述語(WHERE句)の書き方を見直せ(SARGableな条件になっているか? `LOWER(col)` のような関数ラップでインデックスが無効化されていないか?)。
3. どうしてもオプティマイザが最適解にたどり着けない場合の「最終手段(外科手術)」としてのみヒント句を使用せよ。そして、その旨をコードのコメントに詳細に残すこと。
—
まとめ:スパナを使いこなすプロフェッショナルであれ
Cloud Spannerのクエリオプティマイザは、ブラックボックスの魔法ではない。統計情報に基づき、数学的にコストを計算して動いているロジックだ。
- バージョン管理で予期せぬ挙動の変化を防ぐ。
- `PROFILE`で現実のボトルネックを直視する。
- ヒント句(`FORCE_JOIN_ORDER`等)は、オプティマイザと対話するための外科メスとして慎重に振るう。
この3つを頭に叩き込んだエンジニアであれば、いかにデータ量が増大しようとも、Spannerのパフォーマンスを完全にコントロール下に置くことができるはずだ。
次の設計レビューで、君の書いたクエリと実行計画の解説を楽しみにしている。
コメント