1. 長大トランザクション
トランザクションが長くなるほど、ロックの保持時間が延びて他のクエリをブロックする。 特に トランザクション内に外部 API 呼び出しやファイル I/O を含める のは最悪のパターンで、通信遅延がそのままロック時間になる。
❌ NGwith db.begin():
db.execute("UPDATE accounts SET balance = balance - 1000 WHERE id = 1")
# ← ここで外部APIを呼ぶ(数百ms〜数秒かかる)
result = payment_api.charge(amount=1000)
db.execute("UPDATE accounts SET balance = balance + 1000 WHERE id = 2")
# APIが遅い間ずっとロックが保持される
# 1. 外部処理を先に行う
result = payment_api.charge(amount=1000)
# 2. DBへの書き込みだけをトランザクションで囲む(短時間で完了)
if result.success:
with db.begin():
db.execute("UPDATE accounts SET balance = balance - 1000 WHERE id = 1")
db.execute("UPDATE accounts SET balance = balance + 1000 WHERE id = 2")
2. ロック順序の不統一(デッドロック)
複数のテーブルや行をロックする順序がトランザクション間でバラバラだと、互いに相手のロック解放を待つ「デッドロック」が発生する。 デッドロックは DB がロールバックで自動解決するが、アプリケーションにエラーが返り、リトライ処理が必要になる。
❌ NG-- トランザクション A: accounts → orders の順でロック
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
SELECT * FROM orders WHERE id = 101 FOR UPDATE;
COMMIT;
-- トランザクション B: orders → accounts の逆順でロック
BEGIN;
SELECT * FROM orders WHERE id = 101 FOR UPDATE;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE; -- A がロック中で待機 → デッドロック
COMMIT;
-- A・B ともに同じ順序(テーブル名アルファベット順など)でロックする
-- 両方: accounts → orders の順
BEGIN;
SELECT * FROM accounts WHERE id = 1 FOR UPDATE;
SELECT * FROM orders WHERE id = 101 FOR UPDATE;
COMMIT;
💡 ロック順序のルール例
チームで「テーブル名アルファベット順に取得する」「主キーの昇順でロックする」などのルールを決めてドキュメント化する。ORM を使う場合もソート順を意識する。
3. SELECT FOR UPDATE の過剰使用
SELECT FOR UPDATE は行ロックを取得する。読み取り結果をそのまま更新に使う場合は必要だが、更新しないのに FOR UPDATE を使うと不必要な行ロックで並列処理が大幅に低下する。
| 操作の意図 | 適切な選択 |
|---|---|
| 値を確認して直後に UPDATE する | SELECT ... FOR UPDATE |
| 参照のみ(更新しない) | 通常の SELECT |
| 他者の更新を読み飛ばしたくない(スナップショット読み取り) | 分離レベルの調整(SERIALIZABLE など) |
4. GROUP BY と HAVING の誤用
HAVING は集計後のフィルタリングに使う句だ。集計前に絞り込める条件まで HAVING に書くと、全行を集計してから捨てることになり非効率になる。
-- user_id > 1000 は集計「前」に絞れるのに HAVING で書いている
-- 全 user_id を集計してから捨てる
SELECT user_id, COUNT(*)
FROM orders
GROUP BY user_id
HAVING user_id > 1000 AND COUNT(*) >= 3;
-- user_id > 1000 は WHERE に移す(集計対象を最初から絞る)
-- COUNT(*) >= 3 は集計後の条件なので HAVING に残す
SELECT user_id, COUNT(*)
FROM orders
WHERE user_id > 1000
GROUP BY user_id
HAVING COUNT(*) >= 3;
5. COUNT(*) と COUNT(col) の違い
使い分けを間違えると、結果が変わりバグになる。
| 記述 | 意味 | NULL の扱い |
|---|---|---|
COUNT(*) | 全行数(NULL を含む) | カウントに含める |
COUNT(col) | col が NULL でない行数 | カウントから除外 |
COUNT(DISTINCT col) | col の一意な値の数 | 除外したうえで重複も除く |
-- orders テーブル: 10行あるが coupon_code が NULL の行が3行
SELECT
COUNT(*) AS total_orders, -- 10
COUNT(coupon_code) AS orders_with_coupon, -- 7
COUNT(DISTINCT coupon_code) AS unique_coupons -- クーポンの種類数
FROM orders;
6. IN リストへの大量値の詰め込み
IN (v1, v2, ..., v5000) のように大量のリテラルを渡すと、プランナーが最適な実行計画を選べなくなる場合がある。また SQL 文字列の長さ上限(max_allowed_packet など)に引っかかることもある。
-- 一時テーブルに入れて JOIN する(プランナーが最適化しやすい)
CREATE TEMPORARY TABLE target_ids (id INT PRIMARY KEY);
INSERT INTO target_ids VALUES (1),(2),...; -- バルクインサート
SELECT p.* FROM products p
JOIN target_ids t ON t.id = p.id;
-- またはアプリケーション側でチャンク分割する (Python 例)
chunk_size = 500
for i in range(0, len(ids), chunk_size):
chunk = ids[i:i+chunk_size]
results += db.execute("SELECT * FROM products WHERE id IN (%s)"
% ",".join("?" * len(chunk)), chunk).fetchall()
7. 実行計画を確認しない
パフォーマンス問題の多くは「実行計画を読まずに書いたクエリ」から始まる。EXPLAIN ANALYZE は新しいクエリを書くたびに確認する習慣をつける。
-- PostgreSQL
EXPLAIN ANALYZE
SELECT * FROM orders WHERE created_at > '2024-01-01';
-- 見るべきキーワード
-- Seq Scan → フルスキャン(インデックスが使われていない)
-- Index Scan → インデックスが使われている ✅
-- Index Only Scan → カバリングインデックス(最速)✅
-- Sort (filesort) → ORDER BY がインデックスを使えていない
-- Hash Join / Nested Loop → 結合の種類
-- MySQL
EXPLAIN FORMAT=JSON
SELECT * FROM orders WHERE created_at > '2024-01-01';
SQL アンチパターン 総まとめ
| カテゴリ | 主なアンチパターン |
|---|---|
| インデックス無効化 | 関数適用・型変換・前方ワイルドカード・NOT IN・OR |
| 不要な読み込み | SELECT * ・大 OFFSET・DISTINCT 乱用 |
| 結合・サブクエリ | N+1・相関サブクエリ・デカルト積 |
| ロック競合 | 長大トランザクション・デッドロック・過剰 FOR UPDATE |
| 集計・ソート | HAVING 誤用・COUNT(col) と COUNT(*) の混同 |
| その他 | 大 IN リスト・EXPLAIN 未確認 |
✅ 次のページでは…
PART 04 からはコーディングアンチパターン編へ。ゴッドクラス・散弾銃手術・マジックナンバー・深い継承など、設計と命名に関するアンチパターンを解説する。