SQLのJOIN選択とパフォーマンス — FULL OUTER JOINを避けるべき理由
FULL OUTER JOINが遅い原因と、LEFT JOINへの置き換えパターンを理解する。JOINの種類と使い分けの判断基準も整理する
TL;DR
- FULL OUTER JOIN は両テーブルの全行を結合するため、結果セットが膨らみやすく遅い
- 多くの場合は LEFT JOIN で代替できる。まず LEFT JOIN で表現できないか考える
- FULL OUTER JOIN が本当に必要なのは「どちら側にも存在しないレコードを両方向で探したい」場面のみ
JOINの種類おさらい
JOINの基本的な使い方は PostgreSQL入門 で扱っている。
ここでは全 JOIN 種別をパフォーマンス観点で比較するために簡潔に整理する。
graph LR
subgraph joins ["JOIN の種類と取得範囲"]
IJ["INNER JOIN<br/>両テーブルで一致した行のみ"]
LJ["LEFT JOIN<br/>左テーブル全行 + 右は一致分"]
RJ["RIGHT JOIN<br/>右テーブル全行 + 左は一致分"]
FJ["FULL OUTER JOIN<br/>両テーブルの全行"]
end
| JOIN種類 | 左テーブル(非マッチ) | 右テーブル(非マッチ) | 結果行数の目安 |
|---|---|---|---|
| INNER JOIN | 含まない | 含まない | 少ない |
| LEFT JOIN | 含む | 含まない | 中程度 |
| RIGHT JOIN | 含まない | 含む | 中程度 |
| FULL OUTER JOIN | 含む | 含む | 最大(両側の全行) |
FULL OUTER JOIN が遅い理由
FULL OUTER JOIN はどちらのテーブルにも一致しない行を 両側から 返す。
これがパフォーマンス上の問題を引き起こす主な理由は2つある。
理由1:結果セットが最大まで膨らむ
LEFT JOIN の結果行数は次の式で決まる。
LEFT JOIN の結果行数 = 一致したペアの数 + 左テーブルの非マッチ行数非マッチの左行は必ず1行として残るため、LEFT JOIN の結果行数が左テーブルの行数を下回ることはない。
ただし「左テーブルの行数に近い」のは 1行あたりの一致がおおよそ1件程度の場合の目安にすぎない。1人のユーザーが複数の注文を持つような1対多の関係があると、一致ペアの数がその分増えるため、結果行数は左テーブルの行数より大きくなる(詳しくはPostgreSQL入門のJOIN結果の例を参照)。
FULL OUTER JOIN はここに 右テーブルの非マッチ行 をさらに加えるだけなので、1対多の有無に関係なく次の関係が常に成り立つ。
FULL OUTER JOIN の結果行数 = LEFT JOIN の結果行数 + 右テーブルの非マッチ行数「FULL OUTER JOIN は必ず LEFT JOIN 以上の行数になる」というのは、この式からいつでも確実に言える。
users: 100万行 orders: 500万行(1ユーザー平均1件の注文と仮定した単純化した例)
LEFT JOIN の目安: 100万行(usersが軸、注文がないユーザーはNULL補完で1行)
FULL OUTER JOIN の目安: 600万行(LEFT JOINの結果 + ordersにのみ存在する非マッチ行)後続の WHERE や集計処理のコストがそのまま増える。
理由2:インデックスを活かしにくい実行計画になる
INNER JOIN や LEFT JOIN はインデックスを使った Nested Loop Join(インデックス走査 × 結合)が使いやすい。
FULL OUTER JOIN は両側の非マッチ行を収集する必要があるため、DBオプティマイザが Hash Join や Merge Join を選びやすく、大量データのときにメモリ・CPU コストが増える。
sequenceDiagram
participant Q as クエリ
participant L as 左テーブル
participant R as 右テーブル
Note over Q,R: LEFT JOIN の実行イメージ
Q->>L: 左テーブル全行を取得
Q->>R: 各行に対してインデックスで右テーブルを検索
R-->>Q: 一致した行(なければ NULL)
Note over Q,R: FULL OUTER JOIN の実行イメージ
Q->>L: 左テーブル全行を取得
Q->>R: 右テーブル全行を取得(ハッシュテーブル構築)
Q->>Q: 一致・非マッチを両側で収集して結合
LEFT JOIN への置き換えパターン
パターン1:単純に左テーブルを軸にする
最も多いケースは、どちらのテーブルを軸にするかを決めれば FULL OUTER JOIN が不要になる。
-- NG: FULL OUTER JOIN(右側の非マッチ行が実際には不要)
SELECT u.id, u.name, o.item_name, o.price
FROM users AS u
FULL OUTER JOIN orders AS o ON u.id = o.user_id;-- OK: usersを軸にした LEFT JOIN
-- 注文がないユーザーも含め、すべてのユーザーと紐づく注文を取得する
SELECT u.id, u.name, o.item_name, o.price
FROM users AS u
LEFT JOIN orders AS o ON u.id = o.user_id;「どちらのテーブルを中心にデータを見たいか」を明確にすれば、大抵は LEFT JOIN で表現できる。
パターン2:非マッチ行を探したい場合
「どちらかに存在しない行を探したい」だけなら、LEFT JOIN + IS NULL で対応できる。
-- ordersに紐づかないusersと、usersに紐づかないordersを両方探したい場合
-- NG: FULL OUTER JOIN で全部取って後から絞る
SELECT u.id, u.name, o.id AS order_id
FROM users AS u
FULL OUTER JOIN orders AS o ON u.id = o.user_id
WHERE u.id IS NULL OR o.id IS NULL;-- OK: 2つの LEFT JOIN を UNION ALL で結合する
-- それぞれの方向でインデックスが効く
-- 注文がないユーザー
SELECT u.id AS user_id, u.name, NULL AS order_id
FROM users AS u
LEFT JOIN orders AS o ON u.id = o.user_id
WHERE o.id IS NULL
UNION ALL
-- ユーザーが存在しない注文(孤立レコード)
SELECT o.user_id, NULL AS name, o.id AS order_id
FROM orders AS o
LEFT JOIN users AS u ON o.user_id = u.id
WHERE u.id IS NULL;FULL OUTER JOIN が本当に必要な場面
以下のケースは FULL OUTER JOIN を使うのが適切。
| 場面 | 理由 |
|---|---|
| 2つのデータソースを突き合わせて差分を確認したい | どちら側に欠けているかを一度に把握したい |
| マスターデータとトランザクションデータの完全照合 | 両テーブルのすべての行が必要 |
| データ移行後の整合性チェック | 旧・新テーブルを全行比較する |
これらは「本当に両側の全行が必要」なケース。日常的な一覧取得・集計クエリに FULL OUTER JOIN が出てきたら、LEFT JOIN で書き直せないか見直すサインと考えてよい。
よくある落とし穴
意図せず行が増殖する
FULL OUTER JOIN は両テーブルの非マッチ行を追加するため、1対多の関係があると 想定より多くの行が返る ことがある。
-- usersとordersのFULL OUTER JOIN
-- 1ユーザーが3件の注文を持っていると、そのユーザーは3行になる
-- + ordersに存在しないusers行 + usersに存在しないorders行 が加わる
SELECT u.id, o.id
FROM users AS u
FULL OUTER JOIN orders AS o ON u.id = o.user_id;集計クエリ(SUM・COUNT)と組み合わせると NULL を含む行が集計に混入し、意図しない結果になることがある。