1. SELECT * の問題

SELECT * は手軽だが、見えないコストを多数抱えている。カラムが増えるたびに転送量とメモリが増え、カバリングインデックス(インデックスだけで結果を返せる状態)も無効化されてしまう。

❌ NG
SQL — SELECT *
-- 100カラムあっても全部取得する
SELECT * FROM users;

-- テキスト・BLOB 列を含む場合、数MB単位のデータが毎回転送される
SELECT * FROM documents WHERE category = 'report';
✅ OK
SQL — 必要カラムのみ指定
-- 使うカラムだけ取得する
SELECT id, name, email FROM users;

-- カバリングインデックス(id, category をインデックスに含めれば
-- テーブルアクセスなしで結果が返る)
SELECT id, title FROM documents WHERE category = 'report';

⚠️ SELECT * が特に問題になるシーン

① テーブルに TEXT / BLOB / JSONB カラムが含まれる場合(数MB単位の転送)② ORM 経由で SELECT * が自動発行される場合③ カバリングインデックスが設計されているのに SELECT * で無効化してしまう場合。

2. OFFSET によるページネーション(大量データ時)

OFFSET N はページ N まで読み飛ばすのではなく、先頭から N 行スキャンしてから捨てる という処理になる。 ページが深くなるほどコストが線形に増大する。

❌ NG
SQL — OFFSET ページネーション
-- OFFSET 100000 は 100000 行スキャンしてから 20 行返す
SELECT * FROM posts ORDER BY id LIMIT 20 OFFSET 100000;

-- ページ数が増えるほど指数的に重くなる
-- page=1:  OFFSET 0      → OK
-- page=100: OFFSET 1980  → 少し重い
-- page=5000: OFFSET 99980 → 非常に重い
✅ OK
SQL — カーソルベースのページネーション
-- 直前のページ末尾の id を :last_seen_id として渡す
SELECT * FROM posts
WHERE id > :last_seen_id
ORDER BY id
LIMIT 20;

-- 例: 初回は id=0、次回は返ってきた最後の id を渡す
-- → 常に LIMIT 分だけスキャンするため一定速度
方式ページ深度増加時のコストランダムアクセス推奨
OFFSET / LIMIT線形に増大(O(n))可能小規模・管理画面
カーソルベース一定(O(1))不可(前後のみ)大規模・無限スクロール

3. DISTINCT の乱用

DISTINCT は重複排除のためにソートまたはハッシュ処理を走らせる。 正しく JOIN が設計されていれば本来不要なはず。DISTINCT は JOIN の設計ミスを隠すために使われがちなので、まず JOIN の設計を見直す。

❌ NG
SQL — DISTINCT で多重化を隠す
-- orders に複数の注文がある user_id を取得したいだけなのに
-- JOIN で多重化された結果を DISTINCT で潰している
SELECT DISTINCT u.id
FROM users u
JOIN orders o ON u.id = o.user_id;
✅ OK
SQL — EXISTS に書き直す
-- 「少なくとも1件注文がある」という意図が明確で、多重化しない
SELECT u.id
FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o WHERE o.user_id = u.id
);

-- IN パターンも可
SELECT id FROM users WHERE id IN (SELECT DISTINCT user_id FROM orders);

4. N+1 問題(アプリ側ループ + 単発クエリ)

アプリケーションコードでリストを取得した後、ループの中でそれぞれに関連レコードを取得するクエリを発行するパターン。 N 件のリストに対して N 回のクエリが走り、合計 N+1 回のデータベースアクセスが発生する。 これは最も広範に見られるパフォーマンスアンチパターンのひとつだ。

❌ NG
Python + SQL — N+1
# 1回目: orders を全件取得
orders = db.execute("SELECT * FROM orders").fetchall()

# N回: orders の件数だけ SELECT が走る
for order in orders:
    user = db.execute(
        "SELECT * FROM users WHERE id = ?", (order.user_id,)
    ).fetchone()
    print(order.id, user.name)
# → orders が1000件あれば合計1001回のクエリ
✅ OK
SQL — JOIN で1回にまとめる
-- JOIN で1回のクエリにまとめる
rows = db.execute("""
  SELECT o.id, o.amount, u.name
  FROM orders o
  JOIN users u ON u.id = o.user_id
""").fetchall()

for row in rows:
    print(row.id, row.name)
# → 1回のクエリで完結

💡 ORM でも N+1 は発生する

Django ORM の場合 select_related()(JOIN)や prefetch_related()(IN 一括取得)を使う。SQLAlchemy では joinedload() / selectinload()。ORM を使っていても発行されるSQLを確認する習慣が重要だ。

5. 相関サブクエリを SELECT 句に書く

SELECT 句に相関サブクエリを書くと、外側クエリの 1 行ごとに内側クエリが実行される。 外側が N 行あれば内側は N 回実行される(O(N) 計算量)。

❌ NG
SQL — 相関サブクエリ(行ごとに実行される)
-- users が10万行あれば内側のSELECT COUNTも10万回実行される
SELECT
  u.id,
  u.name,
  (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) AS order_count,
  (SELECT MAX(o.created_at) FROM orders o WHERE o.user_id = u.id) AS last_order
FROM users u;
✅ OK
SQL — GROUP BY + LEFT JOIN にまとめる
-- orders を一度集計してから JOIN する → orders を1回しかスキャンしない
SELECT
  u.id,
  u.name,
  COALESCE(s.order_count, 0) AS order_count,
  s.last_order
FROM users u
LEFT JOIN (
  SELECT
    user_id,
    COUNT(*)    AS order_count,
    MAX(created_at) AS last_order
  FROM orders
  GROUP BY user_id
) s ON s.user_id = u.id;

6. JOIN 条件の欠落(デカルト積)

JOINON 句を書き忘れると、2 つのテーブルの全行の組み合わせ(デカルト積)が生成される。 1 万行 × 1 万行で 1 億行 が返るため、DB サーバーが応答しなくなることもある。

❌ NG
SQL — ON 句の欠落
-- 旧式の FROM でのカンマ区切り:ON 句なし
SELECT * FROM orders, users;

-- INNER JOIN で ON を書き忘れた場合も同様
-- SELECT * FROM orders JOIN users;  -- エラーになるDBもあるが一部はデカルト積になる
✅ OK
SQL — ON 句を必ず記述
-- JOIN には必ず ON 句を書く
SELECT o.id, o.amount, u.name
FROM orders o
JOIN users u ON u.id = o.user_id;

-- 3テーブル以上でも同様
SELECT o.id, u.name, p.name AS product_name
FROM orders o
JOIN users    u ON u.id = o.user_id
JOIN products p ON p.id = o.product_id;

⚠️ 意図的なデカルト積(CROSS JOIN)との違い

カレンダー生成など意図的にデカルト積を使う場合は CROSS JOIN と明示する。うっかりミスと意図的なものを区別できるようにする。

まとめ

アンチパターン問題対策
SELECT *転送量増・カバリングインデックス無効化必要カラムを明示
大 OFFSET ページネーション行数に比例してスキャンが増大カーソルベース(keyset)に変更
DISTINCT 乱用ソート/ハッシュコストが増えるEXISTS / IN に書き替える
N+1 問題N+1 回のクエリが発行されるJOIN または一括取得に統合
相関サブクエリ(SELECT 句)行ごとにサブクエリが O(N) 回実行GROUP BY + LEFT JOIN に変換
デカルト積組み合わせ爆発で億行が生成されるON 句を必ず書く