1. SELECT * の問題
SELECT * は手軽だが、見えないコストを多数抱えている。カラムが増えるたびに転送量とメモリが増え、カバリングインデックス(インデックスだけで結果を返せる状態)も無効化されてしまう。
-- 100カラムあっても全部取得する
SELECT * FROM users;
-- テキスト・BLOB 列を含む場合、数MB単位のデータが毎回転送される
SELECT * FROM documents WHERE category = 'report';
-- 使うカラムだけ取得する
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 行スキャンしてから捨てる という処理になる。
ページが深くなるほどコストが線形に増大する。
-- 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 → 非常に重い
-- 直前のページ末尾の 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 の設計を見直す。
-- orders に複数の注文がある user_id を取得したいだけなのに
-- JOIN で多重化された結果を DISTINCT で潰している
SELECT DISTINCT u.id
FROM users u
JOIN orders o ON u.id = o.user_id;
-- 「少なくとも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# 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回のクエリ
-- 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-- 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;
-- 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 条件の欠落(デカルト積)
JOIN に ON 句を書き忘れると、2 つのテーブルの全行の組み合わせ(デカルト積)が生成される。
1 万行 × 1 万行で 1 億行 が返るため、DB サーバーが応答しなくなることもある。
-- 旧式の FROM でのカンマ区切り:ON 句なし
SELECT * FROM orders, users;
-- INNER JOIN で ON を書き忘れた場合も同様
-- SELECT * FROM orders JOIN users; -- エラーになるDBもあるが一部はデカルト積になる
-- 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 句を必ず書く |