【テクニカル・上級編】 ハッシュ結合 – PostgreSQL

ハッシュ結合の深淵:PostgreSQLのメモリ戦術を解き明かす

PostgreSQLを長年触っていると、「なぜこのクエリはNested Loopを選ばず、ハッシュ結合を選んだのか?」という疑問にぶつかる瞬間があるはずだ。単に「データ量が多いから」という理解で止まっているなら、もう少しだけ踏み込んでみよう。

今日は、クエリ最適化の要である「ハッシュ結合(Hash Join)」の内部アーキテクチャと、現場で遭遇する“あのアレ”な挙動について、少し技術的な深掘りをしてみる。

—

ハッシュ結合の「静かなる戦術」

ハッシュ結合の基本原理はシンプルだ。結合の小さい方のテーブル(ビルド入力)をメモリ上にハッシュテーブルとして展開し、大きい方のテーブル(プローブ入力)をスキャンしながら、ハッシュテーブルをルックアップしていく。

だが、PostgreSQLのエンジン内部では、もっと緻密な計算が行われている。特に重要なのが、`work_mem` との距離感だ。

1. ビルドフェーズ: ハッシュ関数を用いてキーをバケットに振り分ける。この時、メモリが足りないと、データはディスク上の「一時ファイル(Batch Files)」へと溢れ出す。これがパフォーマンス低下の最初のトリガーだ。
2. プローブフェーズ: 巨大なテーブルをスキャンし、ハッシュテーブルを叩く。この時、メモリに収まっているか否かで、I/Oのコストが劇的に変わる。

「ハッシュテーブルの肥大化」という罠

現場でよくある失敗が、「ハッシュテーブルのサイズを見誤ること」だ。

見積もり(カーディナリティ)が甘いと、オプティマイザは「これならメモリに収まる」と判断してハッシュ結合を選択するが、実行時に実際はメモリを溢れてディスクI/Oが発生する。いわゆる「Spill to Disk」というやつだ。

これを特定するのは難しくない。`EXPLAIN ANALYZE` を叩いてみればいい。
`Batches` という項目を見たことがあるだろうか? もし `Batches` が 1 より大きければ、それはメモリから溢れ、ディスクへ書き込みが発生しているサインだ。

-> Hash Join (actual time=123.456..789.012 rows=100000 loops=1)
Hash Cond: (a.id = b.a_id)
Batches: 32 Memory Usage: 1024kB Disk: 51200kB

この `Disk` の数字を見た瞬間、そのクエリは「本来のポテンシャルを殺されている」と考えていい。

—

トラブルシューティング:どう立ち向かうか

もしハッシュ結合で頭を抱える状況になったら、以下の3つのポイントを順にチェックしてほしい。

  • 統計情報の鮮度を確認せよ:

PostgreSQLのオプティマイザは、統計情報が古いと平気で嘘をつく。`ANALYZE` を実行して、テーブルの分布情報が最新か確認するのは基本中の基本だ。

  • work_mem は「魔法の杖」ではない:

「遅いから `work_mem` を増やそう」というのは安易だが、時には有効だ。しかし、注意が必要だ。`work_mem` は「セッションごと」かつ「ノードごと」に消費される。安易に増やすと、並列実行クエリが重なった瞬間にOSがメモリ不足(OOM Killer)でPostgreSQLを殺しに来る。

  • ハッシュキーの選択性:

結合キーのデータ分布が偏っている(Skewed)場合、ハッシュバケットの不均衡が生じ、特定のメモリ領域だけが極端に負荷を受けることがある。こうなると、いくらチューニングしても限界が来る。必要であれば、キーの型を見直したり、物理的なテーブル設計に立ち返る勇気も必要だ。

最後に:エンジニアとしての矜持

ハッシュ結合は、PostgreSQLが持つ強力な武器だ。しかし、それは「メモリという限られた資源をどう効率的に使い切るか」という高度なゲームでもある。

クエリチューニングとは、ただプランを変えることではない。データベースエンジンが何を考え、どこで息切れしているのか、その「鼓動」を読み解く作業だ。

`EXPLAIN ANALYZE` の出力に現れる数字たちには、すべて理由がある。次にハッシュ結合に出会ったときは、ぜひ「Batches」の数値に注目してみてほしい。そこには、まだあなたが知らない最適化のヒントが隠されているはずだ。

さて、今日はここまで。あなたのデータベースが、明日も軽快に動くことを願っている。

コメント

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