「Nested Loopは悪」という誤解を解く――PostgreSQLの実行計画と向き合う夜
データベースのチューニングをしていると、必ずといっていいほど「Nested Loop Join(NLJ)=低速」というレッテルを貼るエンジニアに出会います。確かに、数百万件のテーブル同士をキーなしでNLJしようものなら、PostgreSQLは阿鼻叫喚の地獄絵図と化すでしょう。
しかし、真のDB屋にとって、Nested Loopは「適材適所」を見極めるための最も繊細で、かつ強力な武器の一つです。今日は、なぜPostgreSQLがNested Loopを選択するのか、そしてその「コスト見積もり」の深淵に少しだけ踏み込んでみましょう。
Nested Loopが「選ばれる」必然性
PostgreSQLのオプティマイザ(Planner)がNested Loopを選択するのは、多くの場合、コスト見積もりの結果として「これが最も安価である」と判断したからです。
特に、以下のケースではNested Loopは最強の候補になります。
- 外側(Outer)のデータセットが十分に小さい場合: 駆動表から取得する行数が少なければ、内側(Inner)のインデックスを引くコストだけで完結します。
- 内側の結合キーに効率的なインデックスが存在する場合: `Index Scan` または `Index Only Scan` が効く状況であれば、O(log N)の探索コストで済むため、ハッシュ結合のオーバーヘッド(ハッシュテーブル構築のメモリ消費やCPUコスト)を大幅に下回ることができます。
コスト見積もりの裏側:オプティマイザは何を見ているのか
PostgreSQLのコストモデルにおいて、Nested Loopのコスト計算は非常にシンプルです。
Cost = (Outer_Cost) + (Outer_Rows Inner_Scan_Cost)
この式が示す通り、PostgreSQLは「外側の行数」と「内側をスキャンするコスト」の積を重視します。ここで勘違いしてはいけないのが、「行数見積もりの精度」がすべてを決めるということです。
統計情報(`pg_statistic`)が古く、行数の見積もりに誤差が生じると、オプティマイザは「これならNested Loopの方が速いはずだ」と誤認し、悲劇的な実行計画を生成します。もし現場で「なぜここでNLJ?」と感じたら、まずは`EXPLAIN ANALYZE`で「見積もり行数(rows)」と「実際の行数(actual rows)」の乖離を確認してください。そこが、チューニングの最前線です。
パフォーマンストラブルの解像度を上げる
Nested Loopでトラブルが起きる典型的なパターンは、実は「インデックスがないこと」ではありません。「インデックスは存在するが、フィルタリングが不十分で、ランダムアクセスが爆発している」ケースです。
1. ランダムアクセスのコスト: HDDなら致命的、SSDでもインデックスのツリー深さやキャッシュヒット率によっては、シーケンシャルスキャンよりもコストが嵩むことがあります。
2. パラメータクエリの罠: プリペアードステートメントを使用している場合、汎用的なプランが生成され、特定の実行時にNested Loopが最適でないパラメータが渡されることがあります。これを防ぐには、`plan_cache_mode`を`force_custom_plan`にして、値に応じた最適なプランを引かせるのも一つの手です。
3. 相関サブクエリの呪縛: 昔のコードに見られる、SELECT句での相関サブクエリ。これは暗黙的にNested Loopとして実行されます。もしデータ量が増大しているなら、`LATERAL JOIN`への書き換えを検討してください。明示的にプランを制御できるようになります。
最後に:道具を使いこなすということ
Nested Loopを「遅いから避ける」のではなく、「どうすればNested Loopが輝くインデックス設計にできるか」を考える。これが、熟練エンジニアの視点です。
例えば、複合インデックスを貼る際に、結合条件を左側に寄せるだけでなく、フィルタリング条件を含めることで、内側のスキャンコスト(`Inner_Scan_Cost`)を極限まで削り込む。そうして最適化されたNLJは、ハッシュ結合よりも遥かに少ないメモリ消費で、驚くほど高速に応答を返します。
PostgreSQLは、非常に正直なデータベースです。統計情報を正しく育て、オプティマイザの計算式を理解し、彼らがNested Loopを選ぶ理由を納得できるようになったとき、あなたはまた一つ、PostgreSQLとの距離を縮められるはずです。
今夜も、`EXPLAIN`の海を深く潜っていきましょう。それでは。
コメント