SQLで複数テーブルからデータを効率的に抽出する方法

SQLを使ってデータベースから情報を取得する際、複数のテーブルから効率的にデータを抽出することが重要です。本記事では、JOIN句の基本から始まり、サブクエリやウィンドウ関数の活用、インデックスの最適化まで、複数テーブルからデータを効率的に抽出するための方法とテクニックを詳しく解説します。

効率化の前に、結果を「顧客1人につき1行」「注文1件につき1行」「明細1件につき1行」のどれにするかを決めます。JOINで行が増えることと、同じ行が誤って重複することは別です。件数・合計が正しいことを確認してから、実行計画と実測で速度を比較してください。

前半のtableA・column1・conditionなどは構文説明用の仮名で、実際のテーブル・列・条件に置き換える必要があります。後半はcustomers/orders/order_itemsの一貫した例を使います。SQL方言やバージョンで実行計画・インデックス操作は異なり、以下の最適化説明は主にPostgreSQL公式資料を参照しています。

目次

JOIN句の基礎

SQLのJOIN句は、複数のテーブルから関連するデータを結合して抽出するために使用されます。基本的なJOIN句には、INNER JOIN、LEFT JOIN、RIGHT JOINがあります。それぞれの使い方と違いについて説明します。

INNER JOIN

INNER JOINは、指定された条件に一致する両方のテーブルのレコードを結合します。条件に一致しないレコードは除外されます。

SELECT A.column1, B.column2
FROM tableA A
INNER JOIN tableB B ON A.id = B.id;

LEFT JOIN

LEFT JOINは、左側のテーブルの全てのレコードと、条件に一致する右側のテーブルのレコードを結合します。右側のテーブルに一致するレコードがない場合、NULLが返されます。

SELECT A.column1, B.column2
FROM tableA A
LEFT JOIN tableB B ON A.id = B.id;

RIGHT JOIN

RIGHT JOINは、右側のテーブルの全てのレコードと、条件に一致する左側のテーブルのレコードを結合します。左側のテーブルに一致するレコードがない場合、NULLが返されます。

SELECT A.column1, B.column2
FROM tableA A
RIGHT JOIN tableB B ON A.id = B.id;

右側に一致する行が複数あれば、LEFT JOINでも左側の1行は複数行になります。「左側の全件を残す」は「左側と同じ行数になる」という意味ではありません。また、複合キーで関係を識別する設計なら、ON句にも必要なキーをすべて指定します。

複数テーブルの結合方法

複数のテーブルをJOINしてデータを抽出する際には、いくつかのポイントを押さえることで、クエリの効率を高めることができます。

複数のJOIN句の使用

複数のテーブルを結合するためには、複数のJOIN句を連続して使用することができます。以下は、3つのテーブルを結合する例です。

SELECT A.column1, B.column2, C.column3
FROM tableA A
INNER JOIN tableB B ON A.id = B.id
INNER JOIN tableC C ON B.id = C.id;

条件の優先順位を考慮する

SQLに書いたINNER JOINの順番が、そのまま物理的な実行順になるとは限りません。オプティマイザーは統計情報などを使って結合順や結合方法を選びます。「小さいテーブルを先に書けば速い」と判断せず、実行計画を確認します。外部結合は順序や条件の移動で結果も変わるため、意味を保たずに並べ替えてはいけません。

パフォーマンスの向上

複数のテーブルを結合する際のパフォーマンスを向上させるためには、以下の点に注意します。

インデックスの利用

結合キーや絞り込み条件に合うインデックスは高速化の候補です。ただし全件走査が適する場合もあり、追加すれば必ず速くなるわけではありません。既存の主キー・一意インデックスとの重複、書き込み負荷、保存容量も確認します。

データの正規化と冗長性の排除

正規化はデータの重複や更新時の不整合を抑えるための設計です。読み取り速度が必ず上がるという意味ではありません。主キー・外部キーと1対多の関係を明確にし、必要な列だけを取得します。

サブクエリの使用

サブクエリ(ネストされたクエリ)は、他のクエリの内部に埋め込まれたクエリです。サブクエリを使用することで、複数のテーブルからデータを抽出する複雑なクエリを簡潔に表現できます。

サブクエリの基本

サブクエリは、通常、SELECT文の中やWHERE句、FROM句の中で使用されます。以下は、サブクエリを使って特定の条件に一致するデータを抽出する例です。

SELECT column1
FROM tableA
WHERE column2 IN (SELECT column2 FROM tableB WHERE condition);

スカラーサブクエリ

スカラーサブクエリは1列・最大1行の値を使う式です。PostgreSQLでは0行ならNULL、複数行ならエラーになります。次の例はtableB.idが一意であるなど、最大1行になる前提が必要です。複数の注文を持つ顧客には、そのまま当てはめられません。

SELECT column1,
       (SELECT column2 FROM tableB WHERE tableB.id = tableA.id) AS column2_alias
FROM tableA;

関連サブクエリ

相関サブクエリは外側の行を参照するサブクエリです。EXISTSは一致する行が存在するかを判定するため、右側の列が不要で、左側の行を一致件数だけ増やしたくない場合に適します。実際の処理方法は実行計画で確認し、必ず1行ずつ繰り返して遅くなるとは断定しません。

SELECT column1
FROM tableA
WHERE EXISTS (SELECT 1 FROM tableB WHERE tableB.id = tableA.id AND condition);

FROM句のサブクエリ

FROM句のサブクエリは派生テーブルとして扱います。永続テーブルや明示的な一時テーブルを作成する操作ではなく、物理的に一時データを保存するかはDBMSや実行計画によります。

SELECT sub.column1, sub.column2
FROM (SELECT column1, column2 FROM tableA WHERE condition) sub;

ウィンドウ関数の活用

ウィンドウ関数は、クエリ内で特定のデータセットに対して集計や分析を行うための強力なツールです。これを利用することで、複数のテーブルから効率的にデータを抽出し、詳細な分析を行うことができます。

ウィンドウ関数の基本

ウィンドウ関数は、OVER句を使用して、クエリ結果の特定の部分に対して計算を行います。代表的なウィンドウ関数には、ROW_NUMBER、RANK、DENSE_RANK、SUM、AVGなどがあります。次のROW_NUMBERの例はidを一意キーとし、column3が同じ場合の順序を決めています。RANKは同順位の次に順位が飛び、DENSE_RANKは飛びません。

SELECT column1,
       ROW_NUMBER() OVER (PARTITION BY column2 ORDER BY column3, id) AS row_num
FROM tableA;

PARTITION BY句

PARTITION BY句は、ウィンドウを特定のグループに分割します。これにより、各グループ内での集計や分析を行うことができます。

SELECT column1,
       SUM(column2) OVER (PARTITION BY column3) AS sum_by_group
FROM tableA;

ORDER BY句

ORDER BY句は、ウィンドウ内のデータを特定の順序で並べ替えるために使用されます。これにより、順位付けや累積計算を行うことができます。

SELECT column1,
       RANK() OVER (PARTITION BY column2 ORDER BY column3 DESC) AS rank_by_group
FROM tableA;

ウィンドウフレームの指定

ウィンドウフレームは計算対象の範囲です。次のROWSの例は、並び順で前1行・現在行・後1行を対象にします。column3が同値になる場合、安定した行順が必要なら一意キーを追加します。RANGEは値が同じ行の扱いなどが異なり、ROWSと常に同じ結果にはなりません。ウィンドウ内のORDER BYは最終結果の表示順を保証しないため、必要なら外側にもORDER BYを書きます。

SELECT column1,
       SUM(column2) OVER (ORDER BY column3, id ROWS BETWEEN 1 PRECEDING AND 1 FOLLOWING) AS moving_sum
FROM tableA;

インデックスの最適化

インデックスは検索や結合の性能に影響しますが、効果はデータ量、値の分布、条件、既存の索引に依存します。小さなサンプルの結果だけで本番の高速化を保証しないでください。

インデックスの基本

インデックスは、テーブル内のデータを効率的に検索するためのデータ構造です。主キーや外部キー、頻繁に検索条件に使用されるカラムにインデックスを作成することが推奨されます。

CREATE INDEX idx_column1 ON tableA(column1);

複合インデックスの利用

複合インデックスは列の順序と検索条件の組み合わせが重要です。例えば顧客IDの等価条件と注文日時の範囲条件には、(customer_id, order_date)が候補になります。すべての検索条件に同じインデックスが有効とは限りません。

CREATE INDEX idx_column1_column2 ON tableA(column1, column2);

インデックスの管理

インデックスの削除・再構築は、DBMSごとの仕様、利用状況、ロック、空き容量、他の処理への影響を確認して行います。SQL Server、MySQL、PostgreSQLなどで構文が異なり、定期的な再構築が常に必要とは限りません。主キーや一意制約を支えるものを、未使用と決めつけて削除しないでください。

ここでは削除と再構築の連続実行例は載せません。同じインデックスを削除した直後に再構築する手順にもなりません。まず読み取りクエリの実行計画と利用状況を確認し、必要な変更だけを検証環境で試してください。

クエリの最適化

次はPostgreSQLの推定実行計画を表示する例です。行数の見積もり、走査・結合方法、ソートなどを確認します。EXPLAIN ANALYZEは実際にクエリを実行するため、本番の重い処理やデータ変更文で安易に使わないでください。SQL Serverでは別の実行計画取得方法を使います。

-- 実行計画の表示
EXPLAIN SELECT column1 FROM tableA WHERE column1 = 'value';

実践例:同じ顧客・注文・明細データで確認する

以下は学習用のデータです。本番ではなく、同名のテーブルがない空の検証用データベースで実行してください。顧客と注文、注文と明細はそれぞれ1対多です。日付の境界を明確にするため、時刻付きの注文データも含めます。

この例では日付を比較しやすい固定形式の文字列、金額を整数の円として扱います。実システムでは日時型・小数型・タイムゾーンなどを要件に合わせて設計し、検索値は利用するドライバーのパラメーターとして渡してください。

CREATE TABLE customers (
  customer_id INTEGER PRIMARY KEY,
  name VARCHAR(50) NOT NULL
);
CREATE TABLE orders (
  order_id INTEGER PRIMARY KEY,
  customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
  order_date VARCHAR(23) NOT NULL,
  amount INTEGER NOT NULL
);
CREATE TABLE order_items (
  order_item_id INTEGER PRIMARY KEY,
  order_id INTEGER NOT NULL REFERENCES orders(order_id),
  product_name VARCHAR(50) NOT NULL,
  quantity INTEGER NOT NULL,
  unit_price INTEGER NOT NULL
);
INSERT INTO customers VALUES
 (1,'Alice'),(2,'Bob'),(3,'Carol'),(4,'Dan');
INSERT INTO orders VALUES
 (101,1,'2024-01-01 00:00:00',3000),
 (102,1,'2024-06-15 12:00:00',3000),
 (103,1,'2024-12-31 23:59:59.999',1000),
 (201,2,'2024-02-01 10:00:00',2000),
 (301,3,'2023-11-01 00:00:00',800);
INSERT INTO order_items VALUES
 (1011,101,'Mouse',1,1000),
 (1012,101,'Keyboard',2,1000),
 (1021,102,'Keyboard',3,1000),
 (1031,103,'Cable',1,1000),
 (2011,201,'Keyboard',1,1200),
 (2012,201,'Cable',2,400),
 (3011,301,'Cable',1,800);

期間内の注文を顧客と結合する

2024年の注文を、翌年の開始日時を含めない半開区間で抽出します。BETWEENの終了値を2024-12-31だけにすると、その日の時刻付きデータを取りこぼすことがあります。日時型を使う本番環境では、比較する境界も列に合う型・タイムゾーンで指定します。

SELECT c.customer_id, c.name, o.order_id, o.order_date, o.amount
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
WHERE o.order_date >= '2024-01-01 00:00:00'
  AND o.order_date <  '2025-01-01 00:00:00'
ORDER BY c.customer_id, o.order_id;

結果はAliceの101・102・103とBobの201の4行です。Carolの301は期間外、Danは注文なしなので含まれません。

3テーブルJOIN:明細行が増えても注文金額を重複集計しない

SELECT c.name, o.order_id, i.order_item_id,
       i.product_name, i.quantity * i.unit_price AS line_amount
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
JOIN order_items i ON i.order_id = o.order_id
ORDER BY c.customer_id, o.order_id, i.order_item_id;

この結果は明細単位の7行です。注文101は明細が2行あるため2回現れます。この結合結果でSUM(o.amount)を計算すると、注文金額が明細数だけ重複し、全顧客合計は正しい9,800円ではなく14,800円になります。SUM(DISTINCT o.amount)も解決策ではありません。101と102は別注文なのに同額の3,000円なので、一方を落としてしまいます。

明細金額を使うなら、先に注文単位へ集約してから結合します。次は各注文1行となり、明細合計とorders.amountを並べて比較できます。

SELECT c.name, o.order_id, o.amount, t.item_total
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
JOIN (
  SELECT order_id, SUM(quantity * unit_price) AS item_total
  FROM order_items
  GROUP BY order_id
) t ON t.order_id = o.order_id
ORDER BY c.customer_id, o.order_id;

このサンプルでは5行となり、全注文で両金額が一致します。実務では送料・税・値引きなどが注文金額に含まれることがあるので、単純な明細合計と一致すべきかも定義してください。明細なしの注文まで残すなら、この最後のJOINをLEFT JOINにし、NULLの扱いを決めます。

存在確認だけならEXISTS

SELECT c.customer_id, c.name
FROM customers c
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.customer_id = c.customer_id
    AND o.order_date >= '2024-01-01 00:00:00'
    AND o.order_date <  '2025-01-01 00:00:00'
)
ORDER BY c.customer_id;

結果はAlice・Bobの2行です。Aliceに3件の注文があっても顧客行は増えません。関連データの値を取得するJOINと、存在だけを判定するEXISTSを目的で使い分けます。

注文なしも残す:期間条件はLEFT JOINのONへ

SELECT c.customer_id, c.name,
       COUNT(o.order_id) AS order_count,
       COALESCE(SUM(o.amount), 0) AS total_amount
FROM customers c
LEFT JOIN orders o
  ON o.customer_id = c.customer_id
 AND o.order_date >= '2024-01-01 00:00:00'
 AND o.order_date <  '2025-01-01 00:00:00'
GROUP BY c.customer_id, c.name
ORDER BY c.customer_id;

結果はAliceが3件・7,000円、Bobが1件・2,000円、CarolとDanが0件・0円です。期間条件をWHEREに移すと、右側がNULLになった行も条件を満たせず、0件の顧客を残せません。COUNT(*)ではなく、NULLにならない注文主キーをCOUNT(o.order_id)に渡す点も重要です。

ウィンドウ関数で顧客別の注文金額順位を付ける

SELECT c.customer_id, c.name, o.order_id, o.amount,
       RANK() OVER (
         PARTITION BY c.customer_id ORDER BY o.amount DESC
       ) AS order_rank,
       ROW_NUMBER() OVER (
         PARTITION BY c.customer_id ORDER BY o.amount DESC, o.order_id
       ) AS row_num
FROM customers c
JOIN orders o ON o.customer_id = c.customer_id
ORDER BY c.customer_id, o.amount DESC, o.order_id;

Aliceの101・102・103に対するRANKは1・1・3、ROW_NUMBERは1・2・3です。同順位を残すRANKのORDER BYへ一意キーを追加すると同順位でなくなるため、用途を分けます。両テーブルに存在するcustomer_idは、c.customer_idのように修飾して曖昧さをなくしています。

実際の列に合う複合インデックスの候補

CREATE INDEX idx_orders_customer_date
ON orders(customer_id, order_date);

SELECT order_id, order_date, amount
FROM orders
WHERE customer_id = 1
  AND order_date >= '2024-01-01 00:00:00'
  AND order_date <  '2025-01-01 00:00:00'
ORDER BY order_date, order_id;

顧客名はcustomers.nameにあり、orders.customer_nameという列はこの設計にはありません。顧客IDによる検索に合わせて索引を検討します。名前は同姓同名があり得るので、名前を一意なIDの代わりにしないでください。この5件だけのサンプルは結果の確認用で、高速化を測るベンチマークではありません。

検証では後半のサンプルデータとSELECTをSQLiteで確認しています。PostgreSQL・MySQL・SQL Serverなどでの実行結果や本番性能を検証したものではありません。前半の仮名を使った説明例とは区別してください。

まとめ

複数テーブルの抽出では、結合キーと結果の粒度を最初に決めます。存在確認だけならEXISTS、未注文の顧客も残すならLEFT JOIN、明細から集計するなら注文単位へ先にまとめるなど、目的に合う形を選びます。インデックスやSQLの書き換えは、結果が同じことを確認したうえで、実行計画と代表的なデータによる測定で判断してください。

公式リファレンス

参照:PostgreSQLのJOINとテーブル式、EXISTSなどのサブクエリ、スカラーサブクエリと式、ウィンドウ関数、結合順とプランナー、複合インデックス、EXPLAIN。他のDBMSに適用するときは、その製品の対応構文と実行計画を確認してください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次