【実務・中級編】 クエリ最適化のための統計情報 – Cloud Spanner

【Cloud Spanner】オプティマイザの心を読め:統計情報の鮮度管理とクエリパフォーマンスの極限チューニング

こんにちは。テクニカルリードの私だ。
きみのチームが書いた美しいクエリが、なぜか本番環境で突如としてレイテンシを悪化させ、CPU使用率を天井知らずにハネ上げている。スケーラブルなCloud Spannerを使っているにもかかわらず、だ。

原因を探ると、オプティマイザが「全件スキャン(Full Table Scan)」という名の地獄を選択している。インデックスはちゃんと貼ってある。WHERE句の条件も完璧だ。なのに、なぜオプティマイザはその愚かな選択をしたのか?

答えは一つ。「オプティマイザが持つ世界の地図(=統計情報)が、実際の現実とズレていたから」だ。

今回は、Cloud Spannerのコアアーキテクチャの心臓部である「オプティマイザと統計情報」について、実務で即座に使える知見を叩き込む。綺麗ごとは抜きだ。さっそく中身に入ろう。

—

1. Cloud Spannerオプティマイザと統計情報の基本原理

まず前提として、Cloud SpannerのオプティマイザはCBO(Cost-Based Optimizer:コストベースオプティマイザ)である。静的なルールベースではない。与えられたクエリに対し、数千・数万通りの実行計画(Execution Plan)をシミュレーションし、推定コスト(IOコスト、CPUコスト、通信コスト)が最も低いプランを選択する。

そのコスト計算の唯一の根拠となるのが、テーブルおよびインデックスの統計情報(Statistics)だ。

統計情報に含まれるもの

  • 行数(Row Count): テーブルやインデックスに現在何行が存在するか。
  • サイズ(Byte Size): 消費されているストレージ容量。
  • 列の分布(Column Value Distribution / Histograms): 特定の範囲にデータがどれくらい偏っているかのヒストグラム。

もしこの統計情報が古ければどうなるか?
「このカラムにはデータが10行しかない(古い情報)」とオプティマイザが判断していれば、実際には1,000万行あるにもかかわらず、効率の悪いインデックスシークやネステッドループ結合を選択してしまう。逆に「1,000万行ある」と思い込んでいれば、実際には数行しかないマスタテーブルに対して無駄な並列分散スキャンを走らせる。

オプティマイザは全知全能の神ではない。彼に見えているのは「統計情報という名の過去の記憶」だけなのだ。

—

2. 統計情報の収集メカニズムと鮮度管理の罠

Cloud Spannerは、統計情報をデフォルトでどのように扱っているか。ここを誤解しているエンジニアが非常に多い。

自動統計収集(Automatic Statistics Collection)

Spannerはバックグラウンドで定期的に統計情報を自動収集している。これがあるから「何もしなくてよい」と思ったら大間違いだ。
自動収集は万能ではない。以下のようなケースで「統計情報の真空地帯」が生まれる。

1. 急激なデータ量の変化(バッチ処理の爆発):
夜間バッチで数千万件のレコードを一気にINSERT/DELETEした直後の朝、最初のオンラインクエリが走る瞬間。自動統計収集が追いつく前に、オプティマイザは古い統計情報をベースに最悪の実行計画を組み立てる。
2. データスキュー(Data Skew)の発生:
特定のテナントIDやステータスコードにデータが極端に偏った場合、デフォルトのヒストグラムの粒度ではその偏りを捉えきれないことがある。

手動統計収集のコントロール

実務において、クリティカルなバッチ処理や大規模なデータ移行の前後では、オプティマイザの統計情報を人間の手で強制的に最新化(あるいは特定のバージョンに固定)するアプローチが必要になる。

統計情報を手動で更新するには、以下のシステムテーブルやプロシージャを利用する(またはInformation SchemaやSpannerの統計機能を使う)。

— 現在のオプティマイザ統計情報の確認
— システムテーブルから最新の統計収集時刻を割り出す
SELECT
料理名ではなくテーブル名,
table_name,
model_name,
last_update_time,
allow_automatic_update
FROM
INFORMATION_SCHEMA.TABLE_STATISTICS
WHERE
table_name = ‘Orders’;

> 【実務の鉄則】
> 大規模なDML(INSERT/UPDATE/DELETEの大量実行)を伴うバッチジョブの設計では、バッチの最終ステップに統計情報の強制更新を組み込むことを標準パターンとせよ。これを怠ると、バッチ直後のオンラインリクエストが軒並みタイムアウトする事故を引き起こす。

—

3. 実行計画のインスペクションと `EXPLAIN` の読み方

問題が発生したとき、感や経験でインデックスを追加するのは三流のすることだ。必ず `EXPLAIN` を使ってオプティマイザと対話しろ。

具体的な調査クエリの例

— クエリの実行計画をコスト見積もり付きで取得する
EXPLAIN
SELECT
o.order_id,
c.customer_name,
o.total_amount
FROM
Orders o
JOIN
Customers c ON o.customer_id = c.customer_id
WHERE
o.status = ‘PENDING’
AND o.created_at >= ‘2023-10-01T00:00:00Z’;

この結果出力(グラフィカルまたはテキスト)で見るべきポイントは以下の3点だ。

1. Distributed Union / Distributed Cross Apply の有無:
スプリットを跨いだ並列処理が意図通りに行われているか。
2. Index Scan vs Full Table Scan:
期待したインデックス(例: `Index_Orders_Status_Created`)が使われているか。ここでフルスキャンになっていれば、統計情報の古さか、インデックスの定義不備を疑う。
3. Estimated Rows と実際の行数の乖離:
オプティマイザの「Estimated Rows: 5」に対し、実際には「500,000行」を処理しているようなケース(Cardinality Estimationの失敗)。これが起きていたら確実に統計情報が実態を捉え損ねている。

—

4. 堅牢な設計パターンとパフォーマンス最適化のプラクティス

では、オプティマイザを常に味方につけ、安定したパフォーマンスを維持するための「堅牢な設計パターン」を伝授する。

パターン1: バッチ処理連携パイプラインでの統計管理

大規模なデータロードを行うシステムでは、以下のライフサイクルをパイプラインに組み込め。

[前処理] インデックスの無効化(※必要な場合のみ) / データのバルクインサート
↓
[データロード完了]
↓
[統計情報の強制更新] ───► ALTER TABLE … または Spanner推奨の統計更新APIの呼び出し
↓
[本番トラフィック再開] ──► 常に最新の統計に基づく最適なクエリ実行

パターン2: オプティマイザバージョンの固定(ピン留め)

Cloud Spannerは継続的にオプティマイザの賢さをアップデートしている(例: `optimizer_version = 1`, `optimizer_version = 2` …)。大半のケースでは最新が最善だが、ミッションクリティカルなシステムで予期せぬプラン変更(プランの退行 / Plan Regression)を恐れる場合、クエリヒントやデータベース設定でオプティマイザのバージョンを固定することができる。

— クエリ単位でオプティマイザバージョンを指定するヒントの例
SELECT / Spanner-OptimizerVersion: 5 /
order_id, total_amount
FROM Orders
WHERE customer_id = ‘CUST-001’;

これにより、Google側でオプティマイザのエンジンがアップデートされたとしても、既存のクリティカルなクエリの実行計画が勝手に変わり、性能が劣化するリスクを防ぐことができる。

—

5. チーフアーキテクトからの最後のアドバイス

Cloud Spannerは「魔法のデータベース」ではない。どれほど分散アーキテクチャが洗練されていようとも、物理的なデータの偏りや、オプティマイザへの情報の伝達不足は、そのままパフォーマンスの劣化となって跳ね返ってくる。

コードレビューの際、きみは後輩にこう問いかけるべきだ。
「この複雑なJOINとWHERE句を書いたが、オプティマイザはこのテーブルの行数や偏りを正しく見積もれる状態になっているか? バッチの後に統計情報の更新を入れたか?」

この視点を持てるか否かが、ジュニアなエンジニアと、システム全体を破綻させずにスケールさせられるシニア/テックリードの分水嶺だ。

オプティマイザと対話し、その思考をコントロールしろ。健闘を祈る。

コメント

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