ALGORITHM VISUALIZER

Hash Join(ハッシュ結合)

小さいテーブルからハッシュテーブルを構築し、大きいテーブルで検索する2フェーズの結合アルゴリズム。等価結合に最適でO(N+M)

コントロール
ステップ: 0 / 0
自動再生
スピード1.2秒
フェーズ
待機中
ビルドフェーズ
プローブフェーズ
完了
統計
結合結果
マッチなし
操作ログ
ステップを進めてください
HASH JOIN待機中
解説

📌
Hash Joinとは

Hash Join(ハッシュ結合)は、小さい方のテーブルからインメモリのハッシュテーブルを構築(Build)し、大きい方のテーブルの各行でハッシュテーブルを検索(Probe)する2フェーズのJOINアルゴリズムです。計算量は O(N + M) で、ソートを必要とせず、等価結合において最も高速なアルゴリズムの一つです。

電話帳で名前を探す場面を想像してください。まず50音順の索引ページ(ハッシュテーブル)を作ります。次に、探したい名前のリストを上から順番に、索引を使って素早く該当ページを開きます。一つの名前につき索引を1回引くだけなので、名簿を最初から最後まで探す必要がありません。この「O(1)ルックアップ」がHash Joinの効率の源です。

PostgreSQLではHash Joinとして実装されており、等価結合のデフォルトとして最も頻繁に選ばれるアルゴリズムです。EXPLAINで Hash Join と表示されればこのアルゴリズムが使われています。ハッシュテーブルの構築にはwork_memが使われ、メモリに収まらない場合はディスクにスピルします。

Hash Joinは2つのフェーズで構成されます

Phase 1: ビルドフェーズ
小さい方のテーブル(ビルドテーブル)の全行を読み取り、結合キーをハッシュ関数に通してバケットに格納します。計算量は O(N) です。ハッシュテーブルはwork_mem内に構築されます。
Phase 2: プローブフェーズ
大きい方のテーブル(プローブテーブル)の各行について、結合キーで同じハッシュ関数を適用し、ハッシュテーブルのバケットを検索します。バケット内のマッチする行と結合結果を出力します。計算量は O(M) です。
SQL例: Hash Joinが選ばれやすいケース
-- 等価結合(=)でインデックスなし → Hash Joinが最適
SELECT e.name, d.dept_name
FROM employees e JOIN departments d ON e.dept_id = d.id;

-- PostgreSQLのEXPLAINで確認
EXPLAIN SELECT e.name, d.dept_name
FROM employees e JOIN departments d ON e.dept_id = d.id;
-- → "Hash Join" + "Hash" と表示されればHash Joinが使われている
PostgreSQLのオプティマイザは以下の場合にHash Joinを選びます:
等価結合(=)で、ビルドテーブルがwork_memに収まる場合(最も一般的なケース)
結合キーにインデックスがなく、Nested Loop JoinのIndex Lookupが使えない場合
両テーブルのサイズ差が大きい場合(小さい方をビルドテーブルにすることでメモリ効率が良い)
ORDER BYが不要で、ソート済み結果を求めない場合
💡 上のツールで「自動再生」をオンにすると、ビルドフェーズからプローブフェーズまでの全過程をアニメーションで確認できます。まずはリセットボタンを押して初期状態から試してみましょう。

🔍
このツールの値は何を表している?

左側のdepartments(部署)テーブルをビルドテーブルとしてハッシュテーブルを構築し、右側のemployees(社員)テーブルをプローブテーブルとして検索します。結合キーは departments.id = employees.dept_id です。

departmentsid: 1, 2, 3, 4(Build: 4行)BUILDHash Table[1]→営業部 [2]→開発部[3]→人事部 [4]→経理部employeesdept_id: 1,2,1,3,2,5(Probe: 6行)PROBE結果 (5件)(営業部, 田中)(開発部, 鈴木)... 他3件 (渡辺はMISS)

具体的なデータ例: departments.id = [1, 2, 3, 4] の4行からハッシュテーブルを構築します。次に employees.dept_id = [1, 2, 1, 3, 2, 5] の6行で検索し、dept_id=5の渡辺以外の5行がマッチします。

矢印はBuildフェーズではビルドテーブルからハッシュテーブルへのデータ流入を、Probeフェーズではプローブテーブルからハッシュテーブルへの検索を表しています。緑の実線はマッチ(ヒット)、赤の破線はミス(不一致)を示します。

⚙️
Hash Joinの仕組み

図1: ビルドフェーズ — ハッシュテーブルの構築

小さい方のテーブル(departments)の各行を結合キー(id)でハッシュし、バケットに格納します。ハッシュ関数により、同じキーは常に同じバケットに入ります。

departments テーブルid=1 営業id=2 開発id=3 人事id=4 経理hash(key)Hash Table[1] → (id=1, 営業部)[2] → (id=2, 開発部)[3] → (id=3, 人事部)[4] → (id=4, 経理部)

図2: プローブフェーズ — ハッシュテーブルの検索

大きい方のテーブル(employees)の各行でハッシュテーブルを検索します。O(1)でバケットにアクセスし、マッチ/ミスを判定します。

employees テーブル田中 dept_id=1 → HIT鈴木 dept_id=2 → HIT佐藤 dept_id=1 → HIT高橋 dept_id=3 → HIT伊藤 dept_id=2 → HIT渡辺 dept_id=5 → MISS[1] → 営業部[2] → 開発部[3] → 人事部[4] → 経理部バケット[5]なし!結合結果(営業部, 田中)(開発部, 鈴木)(営業部, 佐藤)(人事部, 高橋)(開発部, 伊藤)渡辺 → マッチなし

図3: ハッシュテーブルの構造

ハッシュテーブルは結合キーをハッシュ関数に通してバケット番号を決定します。同じキーは同じバケットに入り、O(1)でアクセスできます。

Hash Table 内部構造key = 2hash(2) = 2[1] → (id=1, 営業部)[2] → (id=2, 開発部) ← ヒット![3] → (id=3, 人事部)[4] → (id=4, 経理部)

💡
Hash Joinの特徴

ソート不要でO(N+M)
Sort-Merge Joinとは異なり、事前ソートが不要です。ハッシュテーブルの構築がO(N)、プローブがO(M)で合計O(N+M)です。インデックスの有無に関係なく高速に動作するため、インデックスが存在しないカラム同士のJOINでも効率的です。PostgreSQLのオプティマイザが等価結合で最も頻繁に選択するのは、このソート不要という特性のためです。
💾
メモリ使用量はビルドテーブルに依存
ハッシュテーブルのサイズはビルドテーブルのサイズで決まります。そのためオプティマイザは常に小さい方のテーブルをビルド側に選びます。work_mem(PostgreSQLデフォルト4MB)を超えるとバッチ処理に切り替わり、ディスクにスピルします。SET work_mem = '256MB'; でセッション単位で調整できますが、同時接続数分のメモリが必要です。
📊
等価結合(=)専用
ハッシュ関数は等価性のみを判定できるため、Hash Joinは WHERE a.key = b.key の等価結合でしか使えません。不等価結合(<, >, BETWEEN等)ではSort-Merge JoinかNested Loop Joinが選択されます。これはハッシュ関数の性質上、「近い値」が「近いバケット」に入る保証がないためです。
🔄
並列実行に向いている
プローブフェーズでは各行が独立にハッシュテーブルを検索するため、容易に並列化できます。PostgreSQL 11以降ではParallel Hash Joinをサポートしており、ビルドフェーズも複数ワーカーで分担して構築するShared Hash Tableが使われます。CPUコアが多い環境では線形に近いスケーリングが得られます。
📈
データの偏りに注意
結合キーの値が偏っている場合(例: dept_id=1が全体の80%)、特定のバケットに行が集中し、プローブ時のバケット内走査が遅くなります。最悪ケースではO(N×M)に退化する可能性があります。PostgreSQLではハッシュ衝突が多い場合に自動的にSort-Merge Joinに切り替えることがあります。EXPLAINの「Hash Batches」や「Peak Memory Usage」で確認できます。

🎯
ユースケース

🗄️ インデックスなしの大規模JOIN
結合キーにインデックスがないテーブル同士のJOIN。Sort-Merge Joinのようにソートする必要がなく、ハッシュテーブルを構築するだけで高速にJOINできます。SELECT * FROM logs JOIN sessions ON logs.session_id = sessions.id のように、一時テーブルやログテーブルの結合で頻繁に使われます。
📊 マスターテーブルとの結合
小さなマスターテーブル(部署、カテゴリ、ステータス等)と大きなトランザクションテーブルの結合。マスターテーブルが小さいため、ハッシュテーブルがメモリに収まり、理想的なHash Join性能が得られます。ビルドテーブルが数百〜数千行程度なら、work_memの心配は不要です。
🔄 分析クエリ・アドホッククエリ
データ分析で複数テーブルを即座にJOINしたい場合。事前のインデックス設計やソートが不要なため、アドホックなクエリに最適です。BI ツールが生成する複雑なJOINクエリでも、PostgreSQLのオプティマイザがHash Joinを選択して効率的に処理します。
📐 サブクエリ・CTE結合
WITH active_users AS (...) SELECT * FROM orders JOIN active_users ON ... のように、CTEやサブクエリの結果セットとの結合。中間結果にはインデックスがないため、Hash Joinが唯一の高速な選択肢になります。

📖
用語解説

ビルドフェーズ(Build Phase)
= 小さいテーブルからハッシュテーブルを構築する前処理
ビルドテーブルの全行を読み取り、結合キーにハッシュ関数を適用してバケット番号を決定し、対応するバケットに行データを格納します。計算量はO(N)で、各行につき1回のハッシュ計算とバケットへの挿入のみです。
具体例: departments テーブル(4行)
hash(1)→バケット[1]に(id=1,営業部)を格納
hash(2)→バケット[2]に(id=2,開発部)を格納 ...
上のツールでは、ビルドフェーズの各ステップで左のテーブルからハッシュテーブルへ矢印が表示されます。
プローブフェーズ(Probe Phase)
= 大きいテーブルの各行でハッシュテーブルを検索する処理
プローブテーブルの各行について同じハッシュ関数を適用し、対応するバケットをO(1)でルックアップします。バケット内に行があればマッチ、なければミスです。計算量は全体でO(M)です。
SQL例: SELECT e.name, d.dept_name FROM employees e JOIN departments d ON e.dept_id = d.id
→ employees各行のdept_idでハッシュテーブルを検索
上のツールではプローブフェーズの各ステップで右のテーブルからハッシュテーブルへ矢印が表示されます。緑はヒット、赤はミスです。
ハッシュバケット(Hash Bucket)
= ハッシュ関数の出力値ごとに行を格納するスロット
同じハッシュ値を持つキーは同じバケットに入ります。理想的にはバケットあたり1〜数行ですが、ハッシュ衝突が多いと1つのバケットに多数の行が入り、プローブ時にバケット内の線形走査が発生します。
例: hash(dept_id=1) → バケット[1] に 営業部 が格納済み
employees.dept_id=1 の田中がプローブ → バケット[1]を検索 → ヒット!
上のツールではハッシュテーブルの各行がバケットです。ハイライトされたバケットが現在アクセスされているバケットを示します。
work_mem
= PostgreSQLがソートやハッシュテーブル構築に使えるメモリ上限
Hash Joinではビルドフェーズでハッシュテーブルをwork_mem内に構築します。テーブルがwork_mem(デフォルト4MB)に収まらない場合、複数のバッチに分割してディスクにスピルし、バッチごとにBuild+Probeを繰り返します(Grace Hash Join)。
SET work_mem = '256MB'; -- セッション単位で変更可能
EXPLAIN ANALYZE でHash Batches: 1ならメモリ内完結
Hash Batches: 4ならディスクスピルが発生
上のツールではすべてメモリ内で完結するサンプルデータを使用しています。実際のDBではEXPLAIN ANALYZEでHash Batchesを確認しましょう。
ビルドテーブル選択(Build Side Selection)
= どちらのテーブルでハッシュテーブルを作るかの最適化判断
ハッシュテーブルのサイズはビルドテーブルのサイズに比例するため、常に小さい方のテーブルをビルド側に選ぶのが最適です。PostgreSQLのオプティマイザは統計情報(pg_stats)を使って自動的に判断します。
例: departments(4行) vs employees(100万行)
→ departments をビルド側に選択(ハッシュテーブル: 数KB)
→ employees をプローブ側(100万行を順に検索)
上のツールでは departments(4行) がビルドテーブル、employees(6行) がプローブテーブルとして設定されています。

📋
Hash Joinの手順

1
ビルドテーブル選択
departments(4行) vs employees(6行)。小さい方のdepartments(4行)をBuild側に選びます。オプティマイザは行数だけでなく推定サイズも考慮します。PostgreSQLではEXPLAINでHashと表示される側がBuildです。
departments (4行) → ビルド側(小さい)
employees (6行) → プローブ側(大きい)
2
ハッシュテーブル構築(ビルドフェーズ)
departments.id をハッシュ関数に通してバケットに格納します。work_mem内で構築するため、メモリに収まるサイズであることが前提です。ハッシュ関数はO(1)でバケット番号を決定します。衝突が起きた場合はチェイン法でリンクリストに追加します。
id=1 → h(1) → バケット[1]: (1, 営業部)
id=2 → h(2) → バケット[2]: (2, 開発部)
id=3 → h(3) → バケット[3]: (3, 人事部)
id=4 → h(4) → バケット[4]: (4, 経理部)
3
プローブ開始
employeesテーブルを先頭から1行ずつ読みます。各行の結合キー(dept_id)をハッシュして、対応するバケットを参照します。バケット内のエントリとキーが一致すればマッチ、バケットが空またはキー不一致ならミスです。シーケンシャルリードなのでディスクI/Oも効率的です。
4
ハッシュ検索 — 最初の1行
employees[0]の田中(dept_id=1)をハッシュします。h(1)=バケット[1]を参照すると、departments id=1「営業部」がヒットします。結果行(田中, 営業部)を出力バッファに追加します。ハッシュ検索はO(1)なので、テーブルサイズに関係なく一定時間で完了します。
employees[0] 田中.dept_id=1 → h(1) → バケット[1]
→ departments id=1「営業部」ヒット!
→ 結果に (田中, 営業部) を追加
5
全行をプローブ
残りのemployees行を順にプローブします。各行のdept_idをハッシュしてバケットを参照し、マッチ/ミスを判定します。渡辺(dept_id=5)はバケット[5]が空のためミスとなり、INNER JOINでは結果に含まれません。LEFT JOINならNULLとして出力されます。
employees[1] 鈴木.dept_id=2 → バケット[2] → ヒット!
employees[2] 佐藤.dept_id=1 → バケット[1] → ヒット!
employees[3] 高橋.dept_id=3 → バケット[3] → ヒット!
employees[4] 伊藤.dept_id=2 → バケット[2] → ヒット!
employees[5] 渡辺.dept_id=5 → バケット[5] → ミス
6
完了
5件マッチ、1件ミス。比較回数=6回(プローブ行数と同じ)。Nested Loop Join(NLJ)なら4×6=24回の比較が必要でしたが、Hash Joinはハッシュ計算のみで6回のプローブで完了しました。これは75%の削減です。テーブルが大きくなるほどこの差は劇的に広がります。
💡 ビルドテーブルがwork_memに収まらない場合、PostgreSQLは自動的にGrace Hash Joinに切り替えてディスクにパーティションを書き出します。EXPLAINで「Batches: 2」以上と表示されたらwork_memの増加を検討しましょう。

📊
JOINアルゴリズム比較表

アルゴリズム計算量メモリ得意な場面苦手な場面
Hash JoinO(N+M)O(N)等価結合、インデックス無不等価結合、メモリ不足
Sort-MergeO(NlogN+M)O(N+M)ソート済み、大規模ソートコスト大
Nested LoopO(N×M)O(1)小規模、選択的大規模テーブル
Index NLJO(N×logM)O(1)インデックス有、選択的インデックス無し
各アルゴリズムを選ぶべきとき
Hash Joinを選ぶべきとき
等価結合(=)で、ビルドテーブルがwork_memに収まる場合。ソート不要でO(N+M)で結合できるため、インデックスがなくても高速です。PostgreSQLの等価結合では最も頻繁に選ばれるデフォルトのアルゴリズムです。
Sort-Merge Joinを選ぶべきとき
両テーブルが大きくソート済み(インデックス有り)の場合、またはORDER BY句で結合キー順のソート結果が必要な場合。不等価結合(BETWEEN, <, >)でHash Joinが使えない場合にも選ばれます。
Nested Loop Joinを選ぶべきとき
内側テーブルが小さい(数百行以下)場合、またはWHERE句で外側テーブルの行数が大幅に絞り込まれる場合。OLTP系の少量データ結合で最も選ばれるアルゴリズムです。
Index NLJを選ぶべきとき
内側テーブルにB-Treeインデックスがあり、選択率が低い(結合で返される行が少ない)場合。外側テーブルの各行に対してインデックスルックアップ O(log M) で結合相手を見つけるため、選択的なクエリでは最速です。

関連コンテンツ