ハッシュ結合の内部:グレース法とハイブリッドハッシュ
Build 側がメモリに載らない大規模結合でも、なぜ Hash Join は破綻せず速いのか。パーティション分割とスピルの原理を押さえれば、作業メモリ不足時の I/O コストと遅さの理由が読めます。
- 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)
問題は、R が work_mem(PostgreSQL)や sort_area_size 相当の作業メモリより大きい場合です。素朴に載せようとすればスワップで壊滅的に遅くなる。そこで RDB は、Build 側がメモリに載らないと判断した時点で Grace ハッシュ結合へ切り替えます。
Grace ハッシュ結合:分割統治で必ずメモリに載せる
Grace 法の発想は単純です。「全体が載らないなら、載るサイズの小片に分割してから結合すればよい」。両表を同じハッシュ関数 h1 で B 個のパーティションに振り分け、いったんディスクに書き出します(これがスピル)。
-- パーティショニング・パス(両表それぞれ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 です。R と S のページ数を |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 は「分割後の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 ノードに Buckets・Batches・Memory Usage が出ます。Batches が 1 なら in-memory、2 以上ならスピル(Grace/Hybrid)が起きた証拠で、値が大きいほど多段分割を意味します。work_mem を増やして Batches が 1 に落ちれば、3(|R|+|S|) の I/O が |R|+|S| に減り、桁で速くなることがあります。ただし work_mem はクエリ・ソート単位に確保されるため、増やしすぎは同時実行時のメモリ枯渇を招く点に注意します。スピルの読み書きはバッファプールを経由せず一時ファイルへ直接行くため、設計や調整の指針も通常のページキャッシュとは別に考える必要があります。詳しい原理面はクエリ最適化もあわせて参照してください。
まとめ
- in-memory ⊃ Hybrid ⊃ Grace は、作業メモリに応じて連続的に縮退する一連の戦略。等値結合の正しさは「等しいキーは同じパーティションに入る」性質で担保される。
- 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、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。