ハッシュ結合の深淵:PostgreSQLの内部で何が起きているのか?
データベースエンジニアとして長く現場にいると、クエリプランナーが吐き出す「Hash Join」の文字に、ある種の安心感と、同時に一抹の不安を覚えるようになります。
「ハッシュ結合は速い」。これは定説ですが、なぜ速いのか、そして、なぜ突然牙を剥くのか。その境界線を知っているかどうかで、パフォーマンスチューニングの質は劇的に変わります。今日は、PostgreSQLの内部アーキテクチャの視点から、この「静かなる加速装置」を解剖してみましょう。
—
1. ビルドとプローブ:2つのフェーズの静かなるダンス
PostgreSQLのハッシュ結合は、大きく分けて2つのフェーズで構成されています。
1. ビルドフェーズ (Build Phase): 結合対象の一方(通常は小さい方)をスキャンし、インメモリのハッシュテーブルを構築する。
2. プローブフェーズ (Probe Phase): もう一方のテーブルをスキャンしながら、ハッシュテーブルをルックアップしてマッチする行を取り出す。
ここで重要なのは、「ビルドされる側がいかにメモリ(`work_mem`)に収まるか」という点です。もし溢れ出せば、PostgreSQLは容赦なく「Disk-based Hash Join」へと移行します。これは、一時ファイル(`pg_stw`)への書き出しと読み込みを伴う、パフォーマンス上の暗黒時代への入り口です。
2. なぜ「メモリ不足」は悪夢なのか
`work_mem` を超えた瞬間、PostgreSQLはデータをバケット単位でディスクに書き出します。このとき発生するI/Oコストもさることながら、怖いのは「バケットの偏り」です。
ハッシュ関数の選択が不運にも特定のバケットにデータを集中させてしまうと、たとえ全体としてはメモリ容量内であっても、そのバケットだけがディスクへ追い出されることになります。これを防ぐためにPostgreSQLは動的な再ハッシュ化を行いますが、これには多大なCPUコストがかかります。
現場で「なぜか特定のクエリだけCPUが跳ね上がる」というケースに遭遇したら、まず `EXPLAIN (ANALYZE, BUFFERS)` を見てください。`Batches` の値が1よりも大きくなっていたら、それが悲鳴の正体です。
3. 実践的トラブルシューティングの勘所
ハッシュ結合を使いこなすための、私なりの「チェックリスト」をいくつか共有します。
- プランナーの甘い期待を裏切るな:
PostgreSQLのプランナーは、統計情報に基づいてどちらを「ビルド側」にするか決めます。もし統計情報が古ければ、プランナーは「小さいはずのテーブル」を誤認し、巨大なテーブルをメモリに乗せようとして破綻します。`ANALYZE` の頻度や統計目標値(`STATISTICS`)の見直しは、ハッシュ結合の安定化に直結します。
- `work_mem` の誘惑に負けない:
「全部メモリに乗れば速いんだから、`work_mem` を大きくすればいい」という考えは危険です。同時実行数が多いシステムでそれをやると、メモリ不足によるOOM Killerの餌食になります。接続ごとに消費されるメモリであることを忘れず、セッション単位での一時的な引き上げ(`SET LOCAL work_mem = ‘…’;`)を検討するのがプロの作法です。
- ハッシュキーのカーディナリティ:
結合キーが極端に偏っている場合、ハッシュテーブルのルックアップ効率が低下します。インデックスが効かない結合であればあるほど、ハッシュ結合は「ハッシュの衝突」という別の戦いに突入します。
結びに:魔法ではない、計算である
ハッシュ結合は万能ではありません。小規模なデータであれば「Nested Loop」の方が速いこともありますし、データがソート済みであれば「Merge Join」が圧倒的な効率を誇ります。
しかし、大規模なデータセットを扱うとき、このハッシュ結合こそが「計算量O(N+M)」という強力な武器になります。内部で何が起きているのか、メモリのどこでデータが踊っているのかを想像できるようになると、SQLの書き方は間違いなく変わります。
皆さんのクエリが、今日も効率的にメモリ上を駆け巡ることを願っています。
—
何か特定の実行計画で詰まっていることがあれば、ぜひコメントで教えてください。一緒にプランを読み解きましょう。
コメント