mcpField
S
sql-optimization-patterns

sql-optimization-patterns

github.com/ovachieverデータ・データベース5644 回インストール48 週間前に更新
リポジトリ

sql-optimization-patterns について

sql-optimization-patternsは、データベース開発者向けのSQL最適化スキルです。クエリプラン分析、インデックス設計、クエリの書き換えに加え、PostgreSQLとMySQL/MariaDBのガイドやスクリプトを含みます。

主な機能

  1. 01EXPLAINクエリプランの分析
  2. 02インデックス戦略の選択
  3. 03非効率なクエリの書き換え
  4. 04N+1クエリとページネーションへの対処
  5. 05書き込み処理のバッチ化
  6. 06スロークエリ分析スクリプト

活用シーン

  1. 01遅いSQLクエリの調査
  2. 02データベーステーブルのインデックス設計
  3. 03アプリケーションのN+1クエリ削減
  4. 04大規模データのページネーション最適化

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/ovachiever/droid-tings --skill sql-optimization-patterns

インストール後、Claude Code で/sql-optimization-patternsを入力して読み込まれたか確認します。

詳細

SQL 最適化パターン

体系的な最適化、適切なインデックス作成、クエリプランの分析を通じて、遅いデータベースクエリを高速な処理に変換します。

このスキルを使用する場面

  • 実行に時間がかかるクエリのデバッグ
  • パフォーマンスの高いデータベーススキーマの設計
  • アプリケーションの応答時間の最適化
  • データベースの負荷とコストの削減
  • 増加するデータセットに対するスケーラビリティの向上
  • EXPLAIN クエリプランの分析
  • 効率的なインデックスの実装
  • N+1 クエリ問題の解決

基本概念

1. クエリ実行プラン(EXPLAIN)

EXPLAIN の出力を理解することは、最適化の基本です。

PostgreSQL EXPLAIN:

-- Basic explain
EXPLAIN SELECT * FROM users WHERE email = '[email protected]';

-- With actual execution stats
EXPLAIN ANALYZE
SELECT * FROM users WHERE email = '[email protected]';

-- Verbose output with more details
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT u.*, o.order_total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.created_at > NOW() - INTERVAL '30 days';

確認すべき主なメトリクス:

  • Seq Scan:テーブル全体のスキャン(大きなテーブルでは通常遅くなります)
  • Index Scan:インデックスを使用(良好)
  • Index Only Scan:テーブルにアクセスせずインデックスを使用(最良)
  • Nested Loop:結合方式(小さなデータセットに適しています)
  • Hash Join:結合方式(大きなデータセットに適しています)
  • Merge Join:結合方式(ソート済みデータに適しています)
  • Cost:クエリの推定コスト(低いほど良好)
  • Rows:返される行数の推定値
  • Actual Time:実際の実行時間
2. インデックス戦略

インデックスは最も強力な最適化ツールです。

インデックスの種類:

  • B-Tree:デフォルト。等価検索と範囲検索に適しています
  • Hash:等価(=)比較専用です
  • GIN:全文検索、配列クエリ、JSONB に使用します
  • GiST:幾何データ、全文検索に使用します
  • BRIN:相関のある非常に大きなテーブル向けのブロック範囲インデックスです
-- Standard B-Tree index
CREATE INDEX idx_users_email ON users(email);

-- Composite index (order matters!)
CREATE INDEX idx_orders_user_status ON orders(user_id, status);

-- Partial index (index subset of rows)
CREATE INDEX idx_active_users ON users(email)
WHERE status = 'active';

-- Expression index
CREATE INDEX idx_users_lower_email ON users(LOWER(email));

-- Covering index (include additional columns)
CREATE INDEX idx_users_email_covering ON users(email)
INCLUDE (name, created_at);

-- Full-text search index
CREATE INDEX idx_posts_search ON posts
USING GIN(to_tsvector('english', title || ' ' || body));

-- JSONB index
CREATE INDEX idx_metadata ON events USING GIN(metadata);
3. クエリ最適化パターン

SELECT * を避ける:

-- Bad: Fetches unnecessary columns
SELECT * FROM users WHERE id = 123;

-- Good: Fetch only what you need
SELECT id, email, name FROM users WHERE id = 123;

WHERE 句を効率的に使う:

-- Bad: Function prevents index usage
SELECT * FROM users WHERE LOWER(email) = '[email protected]';

-- Good: Create functional index or use exact match
CREATE INDEX idx_users_email_lower ON users(LOWER(email));
-- Then:
SELECT * FROM users WHERE LOWER(email) = '[email protected]';

-- Or store normalized data
SELECT * FROM users WHERE email = '[email protected]';

JOIN を最適化する:

-- Bad: Cartesian product then filter
SELECT u.name, o.total
FROM users u, orders o
WHERE u.id = o.user_id AND u.created_at > '2024-01-01';

-- Good: Filter before join
SELECT u.name, o.total
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.created_at > '2024-01-01';

-- Better: Filter both tables
SELECT u.name, o.total
FROM (SELECT * FROM users WHERE created_at > '2024-01-01') u
JOIN orders o ON u.id = o.user_id;

最適化パターン

パターン 1: N+1 クエリを解消する

問題:N+1 クエリのアンチパターン

# Bad: Executes N+1 queries
users = db.query("SELECT * FROM users LIMIT 10")
for user in users:
    orders = db.query("SELECT * FROM orders WHERE user_id = ?", user.id)
    # Process orders

解決策:JOIN またはバッチ読み込みを使う

-- Solution 1: JOIN
SELECT
    u.id, u.name,
    o.id as order_id, o.total
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
WHERE u.id IN (1, 2, 3, 4, 5);

-- Solution 2: Batch query
SELECT * FROM orders
WHERE user_id IN (1, 2, 3, 4, 5);
# Good: Single query with JOIN or batch load
# Using JOIN
results = db.query("""
    SELECT u.id, u.name, o.id as order_id, o.total
    FROM users u
    LEFT JOIN orders o ON u.id = o.user_id
    WHERE u.id IN (1, 2, 3, 4, 5)
""")

# Or batch load
users = db.query("SELECT * FROM users LIMIT 10")
user_ids = [u.id for u in users]
orders = db.query(
    "SELECT * FROM orders WHERE user_id IN (?)",
    user_ids
)
# Group orders by user_id
orders_by_user = {}
for order in orders:
    orders_by_user.setdefault(order.user_id, []).append(order)
パターン 2: ページネーションを最適化する

非推奨:大きなテーブルでの OFFSET

-- Slow for large offsets
SELECT * FROM users
ORDER BY created_at DESC
LIMIT 20 OFFSET 100000;  -- Very slow!

推奨:カーソルベースのページネーション

-- Much faster: Use cursor (last seen ID)
SELECT * FROM users
WHERE created_at < '2024-01-15 10:30:00'  -- Last cursor
ORDER BY created_at DESC
LIMIT 20;

-- With composite sorting
SELECT * FROM users
WHERE (created_at, id) < ('2024-01-15 10:30:00', 12345)
ORDER BY created_at DESC, id DESC
LIMIT 20;

-- Requires index
CREATE INDEX idx_users_cursor ON users(created_at DESC, id DESC);
パターン 3: 集計を効率化する

COUNT クエリを最適化する:

-- Bad: Counts all rows
SELECT COUNT(*) FROM orders;  -- Slow on large tables

-- Good: Use estimates for approximate counts
SELECT reltuples::bigint AS estimate
FROM pg_class
WHERE relname = 'orders';

-- Good: Filter before counting
SELECT COUNT(*) FROM orders
WHERE created_at > NOW() - INTERVAL '7 days';

-- Better: Use index-only scan
CREATE INDEX idx_orders_created ON orders(created_at);
SELECT COUNT(*) FROM orders
WHERE created_at > NOW() - INTERVAL '7 days';

GROUP BY を最適化する:

-- Bad: Group by then filter
SELECT user_id, COUNT(*) as order_count
FROM orders
GROUP BY user_id
HAVING COUNT(*) > 10;

-- Better: Filter first, then group (if possible)
SELECT user_id, COUNT(*) as order_count
FROM orders
WHERE status = 'completed'
GROUP BY user_id
HAVING COUNT(*) > 10;

-- Best: Use covering index
CREATE INDEX idx_orders_user_status ON orders(user_id, status);
パターン 4: サブクエリを最適化する

相関サブクエリを変換する:

-- Bad: Correlated subquery (runs for each row)
SELECT u.name, u.email,
    (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) as order_count
FROM users u;

-- Good: JOIN with aggregation
SELECT u.name, u.email, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.name, u.email;

-- Better: Use window functions
SELECT DISTINCT ON (u.id)
    u.name, u.email,
    COUNT(o.id) OVER (PARTITION BY u.id) as order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id;

明確さを高めるために CTE を使う:

-- Using Common Table Expressions
WITH recent_users AS (
    SELECT id, name, email
    FROM users
    WHERE created_at > NOW() - INTERVAL '30 days'
),
user_order_counts AS (
    SELECT user_id, COUNT(*) as order_count
    FROM orders
    WHERE created_at > NOW() - INTERVAL '30 days'
    GROUP BY user_id
)
SELECT ru.name, ru.email, COALESCE(uoc.order_count, 0) as orders
FROM recent_users ru
LEFT JOIN user_order_counts uoc ON ru.id = uoc.user_id;
パターン 5: バッチ処理

INSERT をバッチ処理する:

-- Bad: Multiple individual inserts
INSERT INTO users (name, email) VALUES ('Alice', '[email protected]');
INSERT INTO users (name, email) VALUES ('Bob', '[email protected]');
INSERT INTO users (name, email) VALUES ('Carol', '[email protected]');

-- Good: Batch insert
INSERT INTO users (name, email) VALUES
    ('Alice', '[email protected]'),
    ('Bob', '[email protected]'),
    ('Carol', '[email protected]');

-- Better: Use COPY for bulk inserts (PostgreSQL)
COPY users (name, email) FROM '/tmp/users.csv' CSV HEADER;

UPDATE をバッチ処理する:

-- Bad: Update in loop
UPDATE users SET status = 'active' WHERE id = 1;
UPDATE users SET status = 'active' WHERE id = 2;
-- ... repeat for many IDs

-- Good: Single UPDATE with IN clause
UPDATE users
SET status = 'active'
WHERE id IN (1, 2, 3, 4, 5, ...);

-- Better: Use temporary table for large batches
CREATE TEMP TABLE temp_user_updates (id INT, new_status VARCHAR);
INSERT INTO temp_user_updates VALUES (1, 'active'), (2, 'active'), ...;

UPDATE users u
SET status = t.new_status
FROM temp_user_updates t
WHERE u.id = t.id;

高度なテクニック

マテリアライズドビュー

コストの高いクエリを事前に計算します。

-- Create materialized view
CREATE MATERIALIZED VIEW user_order_summary AS
SELECT
    u.id,
    u.name,
    COUNT(o.id) as total_orders,
    SUM(o.total) as total_spent,
    MAX(o.created_at) as last_order_date
FROM users u
LEFT JOIN orders o ON u.id = o.user_id
GROUP BY u.id, u.name;

-- Add index to materialized view
CREATE INDEX idx_user_summary_spent ON user_order_summary(total_spent DESC);

-- Refresh materialized view
REFRESH MATERIALIZED VIEW user_order_summary;

-- Concurrent refresh (PostgreSQL)
REFRESH MATERIALIZED VIEW CONCURRENTLY user_order_summary;

-- Query materialized view (very fast)
SELECT * FROM user_order_summary
WHERE total_spent > 1000
ORDER BY total_spent DESC;
パーティショニング

大きなテーブルを分割してパフォーマンスを向上させます。

-- Range partitioning by date (PostgreSQL)
CREATE TABLE orders (
    id SERIAL,
    user_id INT,
    total DECIMAL,
    created_at TIMESTAMP
) PARTITION BY RANGE (created_at);

-- Create partitions
CREATE TABLE orders_2024_q1 PARTITION OF orders
    FOR VALUES FROM ('2024-01-01') TO ('2024-04-01');

CREATE TABLE orders_2024_q2 PARTITION OF orders
    FOR VALUES FROM ('2024-04-01') TO ('2024-07-01');

-- Queries automatically use appropriate partition
SELECT * FROM orders
WHERE created_at BETWEEN '2024-02-01' AND '2024-02-28';
-- Only scans orders_2024_q1 partition
クエリヒントと最適化
-- Force index usage (MySQL)
SELECT * FROM users
USE INDEX (idx_users_email)
WHERE email = '[email protected]';

-- Parallel query (PostgreSQL)
SET max_parallel_workers_per_gather = 4;
SELECT * FROM large_table WHERE condition;

-- Join hints (PostgreSQL)
SET enable_nestloop = OFF;  -- Force hash or merge join

ベストプラクティス

  1. インデックスは選択的に作成する:インデックスが多すぎると書き込みが遅くなります
  2. クエリのパフォーマンスを監視する:スロークエリログを使います
  3. 統計情報を最新の状態に保つ:ANALYZE を定期的に実行します
  4. 適切なデータ型を使う:小さい型ほどパフォーマンスが向上します
  5. 慎重に正規化する:正規化とパフォーマンスのバランスを取ります
  6. 頻繁にアクセスされるデータをキャッシュする:アプリケーションレベルのキャッシュを使います
  7. コネクションプーリング:データベース接続を再利用します
  8. 定期的なメンテナンス:VACUUM、ANALYZE、インデックスの再構築を行います
-- Update statistics
ANALYZE users;
ANALYZE VERBOSE orders;

-- Vacuum (PostgreSQL)
VACUUM ANALYZE users;
VACUUM FULL users;  -- Reclaim space (locks table)

-- Reindex
REINDEX INDEX idx_users_email;
REINDEX TABLE users;

よくある落とし穴

  • 過剰なインデックス作成:インデックスごとに INSERT/UPDATE/DELETE が遅くなります
  • 未使用のインデックス:容量を浪費し、書き込みを遅くします
  • インデックスの不足:クエリが遅くなり、テーブル全体のスキャンが発生します
  • 暗黙的な型変換:インデックスの使用を妨げます
  • OR 条件:インデックスを効率的に使用できません
  • 先頭にワイルドカードがある LIKE:LIKE '%abc' はインデックスを使用できません
  • WHERE 句内の関数:関数インデックスがない限り、インデックスの使用を妨げます

クエリの監視

-- Find slow queries (PostgreSQL)
SELECT query, calls, total_time, mean_time
FROM pg_stat_statements
ORDER BY mean_time DESC
LIMIT 10;

-- Find missing indexes (PostgreSQL)
SELECT
    schemaname,
    tablename,
    seq_scan,
    seq_tup_read,
    idx_scan,
    seq_tup_read / seq_scan AS avg_seq_tup_read
FROM pg_stat_user_tables
WHERE seq_scan > 0
ORDER BY seq_tup_read DESC
LIMIT 10;

-- Find unused indexes (PostgreSQL)
SELECT
    schemaname,
    tablename,
    indexname,
    idx_scan,
    idx_tup_read,
    idx_tup_fetch
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;

リソース

  • references/postgres-optimization-guide.md:PostgreSQL 固有の最適化
  • references/mysql-optimization-guide.md:MySQL/MariaDB の最適化
  • references/query-plan-analysis.md:EXPLAIN プランの詳細な分析
  • assets/index-strategy-checklist.md:インデックスを作成するタイミングと方法
  • assets/query-optimization-checklist.md:最適化の手順ガイド
  • scripts/analyze-slow-queries.sql:データベース内の遅いクエリを特定します
  • scripts/index-recommendations.sql:インデックスの推奨案を生成します

コメント