TL

ハッシュ結合の内部:グレース法とハイブリッドハッシュ

Build 側がメモリに載らない大規模結合でも、なぜ Hash Join は破綻せず速いのか。パーティション分割とスピルの原理を押さえれば、作業メモリ不足時の I/O コストと遅さの理由が読めます。

応用RDBHash Joinクエリ最適化オプティマイザ実行計画最終更新: 2026-06-21
TL;DR要点だけ先に
  • 1.Build 側が作業メモリに収まれば in-memory hash join(両表1回読みで O(N+M))。収まらないと両表を結合キーのハッシュで複数パーティションに分け、ディスクへスピルしてからペアごとに処理する Grace ハッシュ結合になる。
  • 2.Hybrid Hash Join は Grace の改良版。第1パーティションだけメモリに常駐させ、Probe 側のうちそのパーティションに当たる行をスピルせず即照合する。メモリが「ぎりぎり足りない」帯域で I/O を大きく削れる。
  • 3.1パーティションがメモリに載らないほど巨大(データ偏り)なら、別のハッシュ関数で再帰的に再分割する。追加 I/O はスピル1段あたり概ね 2×(N+M) で段数に比例して増えるため、スピル段数がコストを支配する。

メモリに載る前提が崩れるとどうなるか

Hash Join は等値結合の大集合に最も強い方式で、計算量は両表を1回ずつ読む O(N + M) です。ただしこの速さは「Build 側のハッシュテーブルが作業メモリに収まる」という前提に立っています。Build 側を R(行数 M)、Probe 側を S(行数 N)とすると、基本形は次の2フェーズです。

-- Build フェーズ: 小さい側 R を全部メモリに載せる
for each row r in R:
    HashTable[ hash(r.key) ].add(r)

-- Probe フェーズ: 大きい側 S を1行ずつ照合
for each row s in S:
    for each r in HashTable[ hash(s.key) ]:
        if r.key == s.key: emit (r, s)

問題は、Rwork_mem(PostgreSQL)や sort_area_size 相当の作業メモリより大きい場合です。素朴に載せようとすればスワップで壊滅的に遅くなる。そこで RDB は、Build 側がメモリに載らないと判断した時点で Grace ハッシュ結合へ切り替えます。

Grace ハッシュ結合:分割統治で必ずメモリに載せる

Grace 法の発想は単純です。「全体が載らないなら、載るサイズの小片に分割してから結合すればよい」。両表を同じハッシュ関数 h1B 個のパーティションに振り分け、いったんディスクに書き出します(これがスピル)。

-- パーティショニング・パス(両表それぞれ1回読む)
for each row r in R:  書き出す: PartR[ h1(r.key) mod B ]
for each row s in S:  書き出す: PartS[ h1(s.key) mod B ]

-- 結合パス(パーティション i ごとに独立に処理)
for i in 0..B-1:
    HashTable ← PartR[i] をメモリに載せる   -- 小片なので載る
    for each s in PartS[i]:
        probe して一致を emit

正しさの核心は、結合キーが等しい行は h1 が同値なので必ず同じ番号のパーティション同士に入る、という点です。だから PartR[i]PartS[i] だけ突き合わせれば十分で、異なる番号のペア(PartR[i]PartS[j]、i が j と異なる)は一致しえないため処理しなくてよい。これが N×M の総当たりを避けられる理由です。

名前の由来

「Grace」は方式を実装した1980年代の日本の並列データベースマシン GRACE に由来します。パーティションごとに独立して処理できるため、並列・分散結合(各ノードがハッシュ値で担当パーティションを受け持つ)とも相性が良く、現代の MPP データウェアハウスの shuffle join の原型でもあります。

I/O コスト:スピル1段でデータを2回多く触る

Grace 法の代償は明確で、両表をいったんディスクへ書き、また読むぶんの I/O です。RS のページ数を |R||S| とすると、

  • 読み(パーティショニング時): |R| + |S|
  • 書き(スピル): |R| + |S|
  • 読み(結合パス): |R| + |S|

合計 3 × (|R| + |S|) ページの I/O になります。in-memory なら |R| + |S|(1回読むだけ)で済むので、スピル1段あたりおおむね追加で 2×(|R|+|S|) を払う計算です。だから Hash Join が遅いと感じたら、まず「Build がメモリに載りきらずスピルしているか」を疑うのが定石になります。

パーティション数 B の決め方

B は「分割後の1パーティションの Build 側が作業メモリに収まる」ように選びます。目安は B ≈ |R| / メモリ容量 を少し上回る値。各パーティションは出力バッファ(最低1ページ)を同時に確保するため、B を増やしすぎるとバッファ自体がメモリを食い、ランダム I/O も増えます。小さすぎれば載らず再分割が要る——このトレードオフの調整が実装の腕の見せ所です。

ハイブリッドハッシュ結合:第1パーティションを常駐させる

Grace 法は「Build がわずかにメモリを超える」ケースでも、律儀に全パーティションをスピルしてしまうのが無駄です。Hybrid Hash Join はこの帯域を最適化します。発想は「余っているメモリで第1パーティション(partition 0)だけは最初からメモリに保持し、ディスクを経由させない」こと。

-- Build: partition 0 はメモリ常駐、残りはスピル
for each row r in R:
    p = h1(r.key) mod B
    if p == 0: HashTable[r] に直接入れる        -- 常駐
    else:      PartR[p] へ書き出す               -- スピル

-- Probe: partition 0 に当たる行は即照合(スピルしない)
for each row s in S:
    p = h1(s.key) mod B
    if p == 0: HashTable を probe して emit        -- ディスク不要
    else:      PartS[p] へ書き出す                 -- 後で処理

-- 残りパーティション 1..B-1 を Grace と同様に処理

partition 0 に入る行は書き出しも読み戻しもゼロになります。Build 側のうち partition 0 に当たる割合を f とすると、その f 分の I/O が丸ごと消える。メモリが「全部は載らないが、半分は載る」ような状況で効果が大きく、メモリが潤沢なら f → 1(実質 in-memory)、メモリが枯渇すれば f → 0(純粋な Grace)へと連続的に縮退するのが Hybrid の美点です。多くの商用 RDB・PostgreSQL の Hash Join はこの Hybrid 系を採用しています。

状況採用される形ディスク I/O の目安
Build がメモリに載るin-memory hash join|R|+|S|(1回読むだけ)
Build がやや溢れるHybrid(partition 0 常駐)3(|R|+|S|) より小さい
Build が大きく溢れるGrace(全スピル)約 3(|R|+|S|)
1パーティションが偏って巨大再帰分割(多段スピル)段数に比例して増加

再帰分割:パーティションがなお載らないとき

h1 で分けても、データの偏り(同一キーの大量重複、ハッシュの片寄り)で特定パーティションだけ巨大化し、依然メモリに載らないことがあります。このとき RDB は、そのパーティションに対して別のハッシュ関数 h2 を使い、さらに細かく再分割します。これが再帰分割(recursive partitioning)です。

重要なのは h1 と異なる関数を使う点です。同じ h1 で割り直しても、すでに h1 値が同じ行ばかりなので分かれません。独立な h2 を用いて初めて分散します。再帰の各段で当該パーティションを読み・書き・読みするため、スピルが k 段に達すると I/O はおおむね (2k+1) × 該当データ量 と段数に線形で増えていきます。つまりスピル段数こそがコストの支配項です。

ハッシュの偏りは再帰でも救えないことがある

1つの結合キー値が極端に多い(例: NULL 相当の既定値が数百万行)と、その値はどんなハッシュ関数でも単一パーティションに固まり、再帰分割しても分かれません。最終的にそのパーティションだけ巨大なまま処理され、メモリ超過やディスク膨張を招きます。対策は結合前にそのキーを除外・正規化することや、パーティショニングで母集合を絞ること。実行計画で特定 Hash ノードの Batches が異常に多い・Disk Usage が大きいときはこの偏りを疑います。

実務での読み方

PostgreSQL の EXPLAIN ANALYZE では、Hash ノードに BucketsBatchesMemory Usage が出ます。Batches が 1 なら in-memory、2 以上ならスピル(Grace/Hybrid)が起きた証拠で、値が大きいほど多段分割を意味します。work_mem を増やして Batches が 1 に落ちれば、3(|R|+|S|) の I/O が |R|+|S| に減り、桁で速くなることがあります。ただし work_mem はクエリ・ソート単位に確保されるため、増やしすぎは同時実行時のメモリ枯渇を招く点に注意します。スピルの読み書きはバッファプールを経由せず一時ファイルへ直接行くため、設計や調整の指針も通常のページキャッシュとは別に考える必要があります。詳しい原理面はクエリ最適化もあわせて参照してください。

まとめ

  • in-memoryHybridGrace は、作業メモリに応じて連続的に縮退する一連の戦略。等値結合の正しさは「等しいキーは同じパーティションに入る」性質で担保される。
  • Grace は両表をハッシュで分割してスピルし、PartR[i]PartS[i] だけを突き合わせる。スピル1段で I/O は |R|+|S| から約 3(|R|+|S|) に増える。
  • Hybrid は partition 0 をメモリ常駐させ、その分の書き戻し I/O を丸ごと省く。メモリが「ぎりぎり足りない」帯域で最も効く。
  • 1パーティションが偏って載らなければ、独立な別ハッシュで再帰分割する。コストはスピル段数に線形で増える。
  • 実行計画では Batches > 1 がスピルの合図。work_mem 調整と結合前の行数削減が効く一方、極端なキー偏りはハッシュでは救えない。

データベース Article

ハッシュ結合の内部:グレース法とハイブリッドハッシュを実務で読む

TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。

解決すること

RDB

比較で見る軸

難易度: advanced / カテゴリ: データベース / タグ数: 5

導入後に効く点

Hybrid Hash Join は Grace の改良版。第1パーティションだけメモリに常駐させ、Probe 側のうちそのパーティションに当たる行をスピルせず即照合する。メモリが「ぎりぎり足りない」帯域で I/O を大きく削れる。

先に潰すリスク

用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。

数字・仕様の読み方
難易度
advanced
カテゴリ
データベース
タグ数
5

判断チェックリスト

  • 自社の用途が「RDB / Hash Join」に近いか確認する。
  • 強みである「Build 側が作業メモリに収まれば in-memory hash join(両表1回読みで O(N+M))。収まらないと両表を結合キーのハッシュで複数パーティションに分け、ディスクへスピルしてからペアごとに処理する Grace ハッシュ結合になる。」が本当に評価軸になるか確認する。
  • 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
  • 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
  • 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
  • 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。

次に確認する観点

RDBHash Joinクエリ最適化オプティマイザ実行計画RDBHash Joinクエリ最適化
参考: 公式情報