メインコンテンツへスキップ
  1. Posts/

[RDBMS] JOINのドライビングテーブルとドリブンテーブル整理

NineKoo9
著者
NineKoo9
目次

このドキュメントは疑問に思っていた点をAIエージェントと対話しながらまとめたメモです。Nested Loop Joinを中心にドライビングテーブルとドリブンテーブルがなぜ重要で、実行計画でどのように読むかを整理します。


目次
#

  1. 核心概念の整理
  2. JOINの内部動作原理
  3. なぜドライビングテーブルの選択が重要なのか?
  4. 良いドライビング/ドリブンテーブルの選択基準
  5. インデックスとの関係
  6. JOINアルゴリズム別ドライビング/ドリブンの役割の違い
  7. オプティマイザーがドライビングテーブルを決定する方法
  8. 強制的にドライビングテーブルを指定する方法
  9. 実務チェックリスト
  10. 一行まとめ

1. 核心概念の整理
#

JOINを実行する際、オプティマイザーは2つのテーブルのうちどちらを先に読むかを決定する。

区分別名説明
ドライビングテーブルDriving Table / Outer Table先に読まれるテーブル。JOINの基準点となる
ドリブンテーブルDriven Table / Inner Tableドライビングテーブルの各行ごとに探索されるテーブル

この選択は単純な順序の問題ではなく、クエリ全体の性能を大きく左右する核心要素だ。

ただしこの用語は特にJOIN順序や**Nested Loop Join(NLJ)**を説明する際に最も直感的だ。 Hash JoinやMerge Joinではouter/innerbuild/probeといった表現がより正確な場合もある。


2. JOINの内部動作原理
#

2-1. Nested Loop Join(NLJ)— ドライビング/ドリブン概念を理解するのに最適なモデル
#

ドライビング/ドリブンの概念はNLJで最も明確に現れる。

// ドライビング = A、ドリブン = B と仮定
for each row in A (ドライビング):             // Aを一度巡回
    for each matching row in B (ドリブン):  // BをAのrow数だけ繰り返し探索
        output(A.row + B.row)

この構造を見ると核心の非対称性が明確になる。

  • ドライビングテーブルは一度読まれる
  • ドリブンテーブルはドライビングの結果row数だけ繰り返し探索される

したがってNLJではドリブンテーブルの探索コストが全体のJOIN性能を支配しやすい。

2-2. 探索回数から見るコスト
#

ドライビング結果 = N件
ドリブン探索コスト = 1回当たり C

全体コスト = N × C
  • Nを減らすには → ドライビングテーブルにフィルター効果の大きいWHERE条件
  • Cを減らすには → ドリブンテーブルのJOIN列にインデックス

3. なぜドライビングテーブルの選択が重要なのか?
#

例示シナリオ
#

SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'PENDING';

テーブルの状況(仮定):

テーブル全件数条件フィルター後の件数
orders1,000,000500件(status = 'PENDING'
customers100,000条件なし

ケース1:ordersがドライビング(良い場合)

1. ordersでstatus = 'PENDING'フィルター → 500件抽出
2. 500件のcustomer_idでcustomersテーブルのインデックス探索 → 500回
総探索回数 = 500回

ケース2:customersがドライビング(悪い場合)

1. customers全体読み込み → 100,000件
2. 100,000件それぞれでordersテーブルを探索 → 100,000回
総探索回数 = 100,000回

同じロジックのクエリでもジョイン順序によってコストが大きく変わる。


4. 良いドライビング/ドリブンテーブルの選択基準
#

4-1. ドライビングテーブルとして適しているもの
#

✅ WHERE条件適用後の結果件数が少ないテーブル
✅ フィルター効果の大きい条件を持つテーブル
✅ JOIN前にrow数を多く減らせるテーブル

フィルター効果の計算例
#

ordersテーブル:1,000,000件
status = 'PENDING'条件後の結果:500件

条件一致率 = 500 / 1,000,000 = 0.0005(0.05%)
→ 残るrow比率が非常に低いのでフィルター効果が大きい

文献によってselectivityという用語を「残る比率」と定義することもあり、実務では逆に「フィルターがよく効く」という意味で緩く使われることもある。 実践では用語よりも条件適用後に何件残るかを見る方が安全だ。

4-2. ドリブンテーブルとして適しているもの
#

✅ JOIN列(ON句の列)にインデックスが張られているテーブル
✅ 単件または少数件のlookupの役割をするテーブル
✅ テーブルが大きくてもJOIN列インデックスで素早く見つけられるテーブル

特にNLJではドリブンテーブルのJOIN列インデックスが事実上性能の核心だ。

黄金則:ドライビングは「小さく」、ドリブンは「インデックスあり」


5. インデックスとの関係 — 最も重要な部分
#

5-1. 理想的なインデックス構成例
#

-- テーブル構造
CREATE TABLE orders (
    id          BIGINT PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    status      VARCHAR(20) NOT NULL,
    created_at  DATETIME NOT NULL
);

CREATE TABLE customers (
    id    BIGINT PRIMARY KEY,
    name  VARCHAR(100),
    email VARCHAR(200)
);

-- インデックス追加
CREATE INDEX idx_orders_status      ON orders(status);        -- WHERE条件用
CREATE INDEX idx_orders_customer_id ON orders(customer_id);   -- JOIN/FK列用

5-2. 理想的な実行フロー
#

SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'PENDING';
Step 1. ordersでidx_orders_statusを使用
        → status = 'PENDING' に該当するrowだけ素早く抽出(ドライビング)

Step 2. 抽出されたN件のcustomer_idそれぞれで
        customersのPKインデックスを探索(ドリブン)
        → 毎回繰り返しのたびにindex seek

結果:全体コスト ≈ N × lookup_cost

5-3. ドリブンテーブルにインデックスがない場合
#

-- 最悪に近いシナリオ
SELECT * FROM small_ref_table s
JOIN huge_table h ON h.non_indexed_col = s.id;
→ small_ref_tableの結果がN件なら
→ huge_tableをN回Full Table Scan
→ 全体コスト = N × M  (M = huge_table全件数)
→ N=1,000、M=5,000,000ならrow比較数が爆増

結論:NLJでドリブンテーブルのJOIN列にインデックスがなければ、全体のJOINコストが急激に悪化する可能性がある。

5-4. FK列インデックスの注意事項
#

FK列インデックスはDBMSによって自動生成の有無が異なる

  • MySQL InnoDB:参照する側(referencing table)に必要なインデックスがなければ自動的に生成される場合がある
  • PostgreSQL:FK宣言だけでreferencing columnsのインデックスを自動生成しない
  • SQL Server:FK宣言だけでcorresponding indexを自動生成しない

したがって実務では以下の原則が安全だ。

FKがあるからインデックスもあるはずだ → 危険な仮定
実際のインデックス定義 + EXPLAINの結果を確認 → 安全なアプローチ
-- 必要な場合は明示的に作成
CREATE INDEX idx_orders_customer_id ON orders(customer_id);

6. JOINアルゴリズム別ドライビング/ドリブンの役割の違い
#

6-1. Nested Loop Join(NLJ)
#

ドライビング/ドリブンの概念が最も直接的に適用されるアルゴリズムだ。

特徴:
- ドライビングテーブルを一度巡回
- ドリブンテーブルをドライビングのrow数だけ繰り返し探索

最適条件:
- ドライビング:少量の結果、フィルター効果が大きい
- ドリブン:JOIN列にインデックスが非常に重要

適した状況:OLTP、比較的小さい結果セット、インデックスが整備された環境
-- NLJの動作疑似コード
for each row r1 in driving_table:
    lookup driven_table where join_col = r1.join_col
    if found:
        emit(r1, matched_row)

6-2. Hash Join(MySQL 8.4、PostgreSQL、SQL Serverなど)
#

インデックスが弱い状況でも大容量JOINを処理できるアルゴリズムだ。

動作方式:
Phase 1(Build):通常はより小さい入力をハッシュテーブルに積み込む
Phase 2(Probe):もう一方の入力をスキャンしてハッシュテーブルとマッチング

特徴:
- ドリブンのインデックスがなくても動作可能
- メモリ使用量が大きい
- メモリを超えるとディスクspillが発生する可能性がある

適した状況:大容量データ、equi-join中心、インデックスが弱い環境

PostgreSQLのドキュメントはHash Joinで一方の入力がHashノードでハッシュテーブルになり、もう一方の入力がこれをprobeすると説明している。 MySQL 8.4のドキュメントもEXPLAIN FORMAT=TREEHashInner hash joinノードが表示されると説明している。

-- MySQL 8.4でHash Joinの動作を確認
EXPLAIN FORMAT=TREE
SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id;
-- 出力に "Hash" または "Inner hash join" が見えればHash Joinを使用中
-- MySQL:hash joinのメモリに関連する代表的な設定
SET join_buffer_size = 256 * 1024 * 1024;  -- 256MB

MySQL 8.4のドキュメント基準でhash joinのメモリ使用量はjoin_buffer_sizeと関連があり、不足するとディスクファイルを使用する場合がある。


6-3. Sort Merge Join(PostgreSQL、Oracle、SQL Server)
#

両方の入力がJOIN列基準でソートされているとき有利なアルゴリズムだ。

動作方式:
1. 両方の入力をJOIN列基準でソート
2. ソートされた2つのリストを順次スキャンしてマージ

特徴:
- 両方の入力がソートされていなければならない
- ソートコストがかかる場合がある
- 範囲条件やソートされた入力の活用に有利な場合がある
- ドライビング/ドリブンの概念がNLJより重要でない

PostgreSQLの公式ドキュメントもmerge joinは入力データがjoin keyを基準にソートされていなければならないと説明している。


6-4. アルゴリズム選択のまとめ
#

アルゴリズムインデックスの必要性メモリ使用適した環境
Nested Loop Joinドリブンのインデックスが非常に重要低いOLTP、少量結果
Hash Joinインデックス依存度が低い高い大量データ、equi-join
Sort Merge Joinソートされた入力が重要中間大量データ、ソート活用

7. オプティマイザーがドライビングテーブルを決定する方法
#

オプティマイザーは統計情報(テーブル件数、インデックス分布、列分布など)を基にコストを推定してジョイン順序を決定する。

7-1. 実行計画(EXPLAIN)の読み方
#

-- MySQL
EXPLAIN SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'PENDING';
+----+-------------+-------+--------+-------------------------+-------------------+---------+-------------------+------+
| id | select_type | table | type   | possible_keys           | key               | key_len | ref               | rows |
+----+-------------+-------+--------+-------------------------+-------------------+---------+-------------------+------+
|  1 | SIMPLE      | o     | ref    | idx_orders_status       | idx_orders_status | 22      | const             |  500 |
|  1 | SIMPLE      | c     | eq_ref | PRIMARY                 | PRIMARY           | 8       | mydb.o.customer_id|    1 |
+----+-------------+-------+--------+-------------------------+-------------------+---------+-------------------+------+

MySQLの公式ドキュメントはEXPLAINテーブルがどの順序でジョインされるかを示すと説明している。 したがってtabular EXPLAINでは通常、上から下の順序がジョイン順序を反映する。

ただし以下の例外は覚えておく必要がある。

  • constまたはsystemテーブルは最適化段階で先に読まれて計画の一番前に現れる場合がある
  • Hash Joinのような場合はFORMAT=TREEで見る方が実際のbuild/probe構造をよく示す

7-2. type列 — MySQLで見る主要なアクセス方式
#

type値意味
systemテーブルに行が1件だけの場合
constPK/Unique = 定数値、1件アクセス
eq_refPK/Uniqueを利用したジョインlookup
ref一般的なインデックスlookup
rangeインデックス範囲探索
indexインデックス全体スキャン
ALLテーブル全体スキャン

概して上から下に行くほどコストが大きくなる傾向がある。

ドリブンテーブルのtypeALLであれば、インデックス追加やジョイン順序の再検討をまず疑う方がよい。

7-3. 追加で確認すべき列
#

意味
rowsオプティマイザーが予測した探索件数
filteredWHERE条件で絞り込まれる割合の推定値
ExtraUsing indexUsing filesortUsing join buffer (hash join)などの補足情報

7-4. 統計情報の更新
#

オプティマイザーの判断がおかしいときは統計が古くなっている可能性も確認する必要がある。

-- MySQL
ANALYZE TABLE orders;
ANALYZE TABLE customers;

-- PostgreSQL
ANALYZE orders;
ANALYZE customers;

-- Oracle
EXEC DBMS_STATS.GATHER_TABLE_STATS('schema_name', 'orders');

8. 強制的にドライビングテーブルを指定する方法
#

オプティマイザーが誤った判断を下すときは、ヒントやplanner設定で影響を与えることができる。 ただしヒントはあくまで例外的な手段であり、まずインデックスと統計を正した方が安全だ。

8-1. MySQL
#

-- STRAIGHT_JOIN:FROM句の順序を固定
SELECT STRAIGHT_JOIN *
FROM orders o
JOIN customers c ON o.customer_id = c.id;

-- JOIN_ORDER:より細かいジョイン順序の指定
SELECT /*+ JOIN_ORDER(o, c) */ *
FROM orders o
JOIN customers c ON o.customer_id = c.id;

MySQL 8.4の公式ドキュメント基準で:

  • STRAIGHT_JOINはFROM句の順序に従わせる
  • JOIN_FIXED_ORDERSTRAIGHT_JOINと同じ効果だ
  • JOIN_ORDERは特定のテーブルのジョイン順序を指定する

Hash Joinの制御にも注意が必要だ。

-- MySQL 8.4ではHASH_JOIN/NO_HASH_JOINの代わりにBNL/NO_BNLを使用
SELECT /*+ NO_BNL(c) */ *
FROM orders o
JOIN customers c ON o.customer_id = c.id;

MySQL 8.4のドキュメントはHASH_JOIN / NO_HASH_JOINヒントが効果がなく、hash joinの制御にはBNL / NO_BNLを使うよう説明している。

8-2. PostgreSQL
#

-- ジョインの再整列を強く制限
SET join_collapse_limit = 1;

-- 特定のアルゴリズムを非選好
SET enable_hashjoin = off;
SET enable_mergejoin = off;

PostgreSQLの公式ドキュメント基準で:

  • join_collapse_limitはplannerがJOIN順序をどの程度再配置するかに影響を与える
  • enable_hashjoinenable_mergejoinenable_nestloopは該当のジョイン方式を無効化というよりも非選好にする
  • 特にnested loopは他の代替がなければ完全に排除されない

8-3. Oracle
#

-- LEADINGヒント:ジョイン順序の起点を指定
SELECT /*+ LEADING(o) */ *
FROM orders o
JOIN customers c ON o.customer_id = c.id;

-- USE_NL:指定したテーブルをinner tableとしてnested loopを使用
SELECT /*+ LEADING(o) USE_NL(c) */ *
FROM orders o
JOIN customers c ON o.customer_id = c.id;

Oracleの公式ドキュメントは:

  • LEADINGをジョイン順序を決めるmultitable hintとして
  • USE_NLを指定したテーブルをinner tableとしてnested loops joinするよう指示するhintとして説明している

8-4. SQL Server
#

SELECT *
FROM orders o
INNER LOOP JOIN customers c ON o.customer_id = c.id
OPTION (FORCE ORDER);

SQL Serverの公式ドキュメントは:

  • LOOPHASHMERGEをjoin hintとして
  • FORCE ORDERをquery hintとして提供する
  • 一つのjoin hintを与えるとクエリ全体のジョイン順序にも影響が出る場合があると説明している

⚠️ ヒント使用時の注意事項 データ分布、統計、インデックスが変わるとヒントがかえって性能を悪化させる可能性がある。 可能な限りインデックス設計と統計情報の更新で先に解決しよう。


9. 実務チェックリスト
#

9-1. クエリ作成時
#

✅ WHERE条件で結果が多く減るテーブルをドライビング候補として見る
✅ ドリブンテーブルのJOIN列(ON句)にインデックスがあるか確認する
✅ FK列は「自動インデックスがあるはず」と仮定せず実際のインデックスを確認する
✅ 3つ以上のJOIN時はEXPLAINで実行順序を確認する
✅ SELECT * を避け、必要な列だけを選択してカバリングインデックスの可能性を高める

9-2. 性能問題が疑われる際の診断手順
#

Step 1. EXPLAINを実行
        → JOIN順序とアクセスタイプを確認
        → ドリブン側がALLかどうかまず確認する

Step 2. rows / filteredを確認
        → ドライビング候補の結果件数が異常に大きくないか点検

Step 3. 統計情報の更新
        → ANALYZEまたはDBMS_STATS後に実行計画を再確認

Step 4. インデックスの追加
        → ドリブンJOIN列のインデックス
        → 必要な場合はドライビングフィルター列のインデックス

Step 5. それでも遅い場合
        → DBMS別ヒントまたはplanner設定で例外的に補正

9-3. よくあるミスと正しい解決策
#

ミス1:ドリブンテーブルのJOIN列にインデックスがない
#

-- 悪い例
SELECT * FROM small_table s
JOIN huge_table h ON h.non_indexed_col = s.id;

-- 解決
CREATE INDEX idx_huge_non_indexed ON huge_table(non_indexed_col);

ミス2:大型テーブルがドライビングとして選択される
#

-- 悪い例
SELECT * FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.status = 'PENDING';

-- 確認後にジョイン順序を補正
SELECT STRAIGHT_JOIN *
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'PENDING';

ただしこのような場合もヒントの前にインデックスと統計から確認することが優先だ。

ミス3:FKだからインデックスが自動的にあると仮定
#

ALTER TABLE orders
  ADD CONSTRAINT fk_orders_customer
  FOREIGN KEY (customer_id) REFERENCES customers(id);

この宣言だけですべてのDBMSが自動インデックスを作るわけではない。

  • PostgreSQL、SQL Serverはreferencing columnsのインデックスを自動生成しない
  • MySQL InnoDBは必要なインデックスを自動生成できるが、実際にどのインデックスがあるかは確認する必要がある
-- 実際のスキーマを確認後、必要な場合は明示的に作成
CREATE INDEX idx_orders_customer_id ON orders(customer_id);

ミス4:3つ以上のテーブルJOIN時に順序を放置
#

SELECT *
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN products p ON o.product_id = p.id
JOIN categories cat ON p.category_id = cat.id
WHERE o.created_at BETWEEN '2024-01-01' AND '2024-03-31';

このようなクエリは必ず実行計画を見て:

  • どのテーブルが先に読まれるか
  • 中間段階でrow数がどれだけ減るか
  • どのポイントでFull ScanやHash/Mergeが付くか

を確認する必要がある。

9-4. インデックス設計ガイド
#

-- ドライビング候補用:WHERE条件列
CREATE INDEX idx_orders_status_created ON orders(status, created_at);

-- ドリブン候補用:JOIN列
CREATE INDEX idx_orders_customer_id ON orders(customer_id);

-- カバリングインデックス例
CREATE INDEX idx_orders_covering ON orders(customer_id, status, created_at);

MySQLの場合EXPLAINExtraUsing indexが見えれば、カバリングインデックスの活用状況を判断するのに役立つ。


10. 一行まとめ
#

特にNLJでは、ドライビングテーブルは「フィルター後の結果が少ないもの」に、ドリブンテーブルは「JOIN列にインデックスがあるもの」に選ばれることでJOINが速くなる。

小さいドライビング × インデックスありのドリブン = 速いJOIN
大きいドライビング × インデックスなしのドリブン = 遅いJOIN

参考 — DB別クイックリファレンス
#

目的MySQLPostgreSQLOracle
実行計画の確認EXPLAINEXPLAIN ANALYZEFORMAT=TREEEXPLAINEXPLAIN ANALYZEEXPLAIN PLAN FOR
統計の更新ANALYZE TABLE tANALYZE tDBMS_STATS.GATHER_TABLE_STATS
ジョイン順序に影響を与えるSTRAIGHT_JOINJOIN_ORDERJOIN_FIXED_ORDERjoin_collapse_limitLEADING
Hash Joinの制御BNLNO_BNLenable_hashjoinUSE_HASH

参考資料
#