Skip to main content

loom-sql-optimization

Analyzes and optimizes SQL queries for performance.

ソース情報

リポジトリ
cosmix/loom
ソースの最終更新活動
2026年8月29日 21:19
検出された SKILL.md の言語
英語
スター
61
フォーク
4

インストール方法

デフォルトでは、最初にソースを確認する Prompt が選択されています。直接コマンドに切り替えるか、ローカルコピーをダウンロードすることもできます。

ソースファイルを確認

インストールを決める前に、SKILL.md と SkillsMP に表示されている付属ファイルをお読みください。

SKILL.md を表示中

SKILL.md
ソースの指示 · 読み取り専用プレビュー
name
loom-sql-optimization
description
Analyzes and optimizes SQL queries for performance.
allowed-tools
["Read","Grep","Glob","Bash"]
triggers
["SQL","query optimization","EXPLAIN","EXPLAIN ANALYZE","index","slow query","execution plan","query plan","join optimization","subquery","CTE","common table expression","window function","partition","N+1","query cache","database performance","sequential scan","index scan","bitmap scan","nested loop","hash join","merge join","PostgreSQL","query tuning","table scan","cardinality","statistics","vacuum","analyze"]
# SQL Optimization ## Overview Analyzing and tuning SQL for performance: reading execution plans, index design, query rewriting, and PostgreSQL-specific behavior. Most notes assume PostgreSQL. The mechanism-level material — reading EXPLAIN, composite-index column order, partial-index limits, statistics, concurrency — is in **Expert Practices** below; this section is the workflow and the anti-pattern catalogue. ## Workflow 1. **Find** the slow query (logs, `pg_stat_statements` by total time, not just per-call). 2. **Explain** it: `EXPLAIN (ANALYZE, BUFFERS, SETTINGS)`. Read plans by **estimated-vs-actual row divergence** (bad stats → wrong join/scan choice), scan type, join algorithm, and `Rows Removed by Filter`. See Expert Practices → Reading EXPLAIN. 3. **Fix** in priority order: refresh/extend statistics → add/reshape an index → rewrite the query → denormalize/derive → tune config. Change one thing at a time. 4. **Validate**: re-EXPLAIN on production-like data, confirm the target node changed (Sort gone / Index Scan chosen / Heap Fetches low), verify correctness, monitor post-deploy. ## Best Practices - **`EXPLAIN (ANALYZE, BUFFERS)` is the source of truth** — never optimize by guessing; actual rows and buffers reveal the real cost. - **Select only needed columns** (enables index-only scans; avoids TOAST/wide-row I/O). - **Index for WHERE/JOIN/ORDER BY**, but mind column order and the write tax (Expert Practices → Index Design). - **Avoid N+1**: one JOIN or a batched `WHERE id = ANY($1)`, not a query per row. - **`NOT EXISTS`, never `NOT IN (nullable subquery)`** — one NULL silently returns zero rows; `NOT EXISTS` is NULL-safe and hash-anti-join-able. (Inclusion `IN`/`ANY`/`EXISTS` plan identically in modern PG — pick readability.) - **No functions/implicit casts on indexed columns** (`WHERE lower(x)=` needs an expression index; `WHERE int_col = '1'` may cast and skip the index). - **Keyset pagination**, not deep `OFFSET` (Expert Practices). - **CTEs are not fences (PG12+)**: a non-recursive, side-effect-free CTE used once is inlined; used >1× it materializes. Force with `AS MATERIALIZED` / `AS NOT MATERIALIZED`; the `OFFSET 0` trick is obsolete. - **`ANALYZE` after bulk loads**; monitor query performance over time. ## PostgreSQL-Specific Optimizations ### Execution Plan Operators **Scan Types:** - **Sequential Scan**: Full table scan (slow for large tables) - **Index Scan**: Walks the index then fetches matching heap rows — best when the predicate matches only a SMALL fraction of the table. When many rows match, the planner switches to a Sequential Scan (or Bitmap Heap Scan for a middling fraction) because the random heap I/O of many index lookups costs more than reading the table sequentially. - **Index Only Scan**: Uses covering index (fastest) - **Bitmap Index Scan**: Multiple index scans combined (good for OR conditions) **Join Algorithms:** - **Nested Loop**: Best for small tables or index lookups - **Hash Join**: Best for medium-sized tables with equality joins - **Merge Join**: Best for large pre-sorted tables ### Statistics and Maintenance ```sql -- Update table statistics for better query plans ANALYZE table_name; -- Check statistics freshness SELECT schemaname, tablename, last_analyze, last_autoanalyze FROM pg_stat_user_tables WHERE schemaname = 'public' ORDER BY last_analyze NULLS FIRST; -- Find bloated tables SELECT schemaname, tablename, pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS size, n_dead_tup, n_live_tup, round(n_dead_tup * 100.0 / NULLIF(n_live_tup + n_dead_tup, 0), 2) AS dead_pct FROM pg_stat_user_tables WHERE n_dead_tup > 1000 ORDER BY n_dead_tup DESC; -- Vacuum bloated tables VACUUM ANALYZE table_name; ``` ### Configuration Tuning ```sql -- Key parameters to check SHOW shared_buffers; -- Should be 25% of RAM SHOW effective_cache_size; -- Should be 50-75% of RAM SHOW work_mem; -- Per sort/hash operation AND per parallel worker SHOW random_page_cost; -- Lower for SSDs (1.1-2.0) ``` `work_mem` is the limit for each sort/hash operation and each parallel worker — a single query with several such nodes can use N × `work_mem` simultaneously, and concurrent sessions multiply that further. Since PG14, hash operations use up to `hash_mem_multiplier` × `work_mem` (default 2.0). A large global `work_mem` on a high-concurrency server is an OOM risk: keep the global value modest and raise it per-session with `SET LOCAL work_mem` for known heavy reports. Set `log_temp_files = 0` and watch for `Batches > 1` on Hash nodes in `EXPLAIN ANALYZE` to detect spills. ## Common Anti-Patterns ### 1. SELECT \* in Application Code ```sql -- BAD: Fetches unnecessary columns SELECT * FROM users WHERE id = 1; -- GOOD: Fetch only needed columns SELECT id, email, name FROM users WHERE id = 1; ``` ### 2. Implicit Type Conversion ```sql -- BAD: Can't use index if id is integer SELECT * FROM users WHERE id = '123'; -- GOOD: Match column type SELECT * FROM users WHERE id = 123; ``` ### 3. OR Conditions Without Indexes ```sql -- BAD: May not use indexes efficiently SELECT * FROM orders WHERE status = 'pending' OR status = 'processing'; -- GOOD: Rewrite same-column OR as IN SELECT * FROM orders WHERE status IN ('pending', 'processing'); ``` PostgreSQL generally rewrites OR conditions on the SAME column into `IN`/`ANY` and can use a plain B-tree index on that column directly. For OR conditions across DIFFERENT columns it combines separate index scans via a BitmapOr (or you can rewrite as `UNION ALL`). Verify the chosen path with EXPLAIN rather than assuming a partial index helps — a partial index only restricts which rows are indexed; it does not provide an access path for searching by those status values. ### 4. Correlated Subqueries ```sql -- BAD: Executes subquery for each row SELECT p.name, (SELECT COUNT(*) FROM order_items WHERE product_id = p.id) AS order_count FROM products p; -- GOOD: Use JOIN with aggregation SELECT p.name, COUNT(oi.id) AS order_count FROM products p LEFT JOIN order_items oi ON oi.product_id = p.id GROUP BY p.id, p.name; ``` ### 5. Missing WHERE Clauses ```sql -- BAD: Updates entire table UPDATE products SET updated_at = NOW(); -- GOOD: Update only what changed UPDATE products SET updated_at = NOW() WHERE id IN (SELECT product_id FROM price_changes); ``` ## Advanced Patterns ### CTEs (Common Table Expressions) ```sql -- CTEs for readability and reusability WITH recent_orders AS ( SELECT customer_id, COUNT(*) AS order_count, SUM(total) AS total_spent FROM orders WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY customer_id ), high_value_customers AS ( SELECT customer_id FROM recent_orders WHERE total_spent > 1000 ) SELECT c.name, c.email, ro.order_count, ro.total_spent FROM customers c INNER JOIN high_value_customers hvc ON c.id = hvc.customer_id INNER JOIN recent_orders ro ON c.id = ro.customer_id; -- Recursive CTEs for hierarchical data WITH RECURSIVE category_tree AS ( SELECT id, name, parent_id, 1 AS level FROM categories WHERE parent_id IS NULL UNION ALL SELECT c.id, c.name, c.parent_id, ct.level + 1 FROM categories c INNER JOIN category_tree ct ON c.parent_id = ct.id ) SELECT * FROM category_tree ORDER BY level, name; ``` ### Window Functions ```sql -- Ranking and row numbers SELECT product_id, category_id, price, ROW_NUMBER() OVER (PARTITION BY category_id ORDER BY price DESC) AS price_rank, RANK() OVER (ORDER BY price DESC) AS overall_rank, DENSE_RANK() OVER (PARTITION BY category_id ORDER BY price DESC) AS dense_rank FROM products; -- Running totals and moving averages SELECT date, revenue, SUM(revenue) OVER (ORDER BY date ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_total, AVG(revenue) OVER (ORDER BY date ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS moving_avg_7day FROM daily_sales ORDER BY date; -- Lead/Lag for time-series analysis SELECT customer_id, order_date, total, LAG(order_date) OVER (PARTITION BY customer_id ORDER BY order_date) AS prev_order_date, LEAD(total) OVER (PARTITION BY customer_id ORDER BY order_date) AS next_order_total, total - LAG(total) OVER (PARTITION BY customer_id ORDER BY order_date) AS total_diff FROM orders; ``` ## Examples ### Example 1: Query Optimization with EXPLAIN ```sql -- Original slow query SELECT o.*, c.name, c.email FROM orders o, customers c WHERE o.customer_id = c.id AND o.status = 'pending' AND o.created_at > '2024-01-01' ORDER BY o.created_at DESC; -- Step 1: Analyze with EXPLAIN ANALYZE -- SETTINGS (PG12) surfaces non-default GUCs so the plan reproduces across environments. -- For a prepared statement's plan without executing, use EXPLAIN (GENERIC_PLAN) (PG16+). EXPLAIN (ANALYZE, BUFFERS, SETTINGS, FORMAT TEXT) SELECT o.*, c.name, c.email FROM orders o, customers c WHERE o.customer_id = c.id AND o.status = 'pending' AND o.created_at > '2024-01-01' ORDER BY o.created_at DESC; -- Output analysis: -- Seq Scan on orders (cost=0.00..15420.00 rows=50000) -- Filter: (status = 'pending' AND created_at > '2024-01-01') -- Rows Removed by Filter: 450000 -- Problem: Sequential scan on large table! -- Step 2: Composite index (equality col first, sort col last). -- Use a plain composite, not a partial index, when status is bound by a parameter -- ($1) — see Expert Practices → Index Design for why partial indexes go unused. CREATE INDEX idx_orders_status_created ON orders(status, created_at DESC); -- Step 3: Rewrite with explicit JOIN SELECT o.id, o.total, o.created_at, c.name, c.email FROM orders o INNER JOIN customers c ON o.customer_id = c.id WHERE o.status = 'pending' AND o.created_at > '2024-01-01' ORDER BY o.created_at DESC LIMIT 100; -- After optimization: -- Index Scan using idx_orders_status_created (cost=0.42..125.50 rows=100) -- 99% reduction in query time! ``` ### Example 2: N+1 Query Problem ```sql -- Problem: N+1 queries -- Application code: -- orders = SELECT * FROM orders WHERE user_id = 1 -- for order in orders: -- items = SELECT * FROM order_items WHERE order_id = order.id -- Solution: Single query with JOIN SELECT o.id AS order_id, o.total, o.created_at, oi.product_id, oi.quantity, oi.unit_price, p.name AS product_name FROM orders o LEFT JOIN order_items oi ON o.id = oi.order_id LEFT JOIN products p ON oi.product_id = p.id WHERE o.user_id = 1 ORDER BY o.created_at DESC, oi.id; -- Alternative: Batch query SELECT * FROM orders WHERE user_id = 1; -- Get order IDs: [1, 2, 3, 4, 5] SELECT * FROM order_items WHERE order_id IN (1, 2, 3, 4, 5); ``` ### Example 3: Index Design Strategies ```sql -- Single column index for equality checks CREATE INDEX idx_users_email ON users(email); -- Composite index: equality cols first, single range/sort col last. -- The trailing sort col skips a Sort node only if every preceding col is bound by -- equality AND the ASC/DESC/NULLS direction matches ORDER BY exactly (Expert Practices). CREATE INDEX idx_orders_user_status_date ON orders(user_id, status, created_at DESC); -- Partial index for filtered queries CREATE INDEX idx_orders_pending ON orders(created_at DESC) WHERE status = 'pending'; -- Covering index to avoid table lookups CREATE INDEX idx_orders_summary ON orders(user_id, status) INCLUDE (total, created_at); -- Expression index for computed conditions CREATE INDEX idx_users_email_lower ON users(LOWER(email)); -- Check existing indexes SELECT indexname, indexdef, pg_size_pretty(pg_relation_size(indexname::regclass)) AS size FROM pg_indexes WHERE tablename = 'orders'; -- Find unused indexes 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; ``` ### Example 4: Query Rewriting Patterns ```sql -- Pattern 1: Replace subquery with JOIN -- Before SELECT * FROM orders WHERE customer_id IN (SELECT id FROM customers WHERE region = 'US'); -- After SELECT o.* FROM orders o INNER JOIN customers c ON o.customer_id = c.id WHERE c.region = 'US'; -- Pattern 2: Use EXISTS instead of IN for large subqueries -- Before SELECT * FROM products WHERE id IN (SELECT product_id FROM order_items); -- After SELECT * FROM products p WHERE EXISTS ( SELECT 1 FROM order_items oi WHERE oi.product_id = p.id ); -- Pattern 3: Avoid functions on indexed columns -- Before (can't use index) SELECT * FROM users WHERE YEAR(created_at) = 2024; -- After (uses index) SELECT * FROM users WHERE created_at >= '2024-01-01' AND created_at < '2025-01-01'; -- Pattern 4: Optimize pagination -- Before (slow for large offsets) SELECT * FROM products ORDER BY id LIMIT 20 OFFSET 10000; -- After (keyset pagination) SELECT * FROM products WHERE id > 10000 ORDER BY id LIMIT 20; -- Pattern 5: Batch operations -- Before (row-by-row) UPDATE products SET price = price * 1.1 WHERE id = 1; UPDATE products SET price = price * 1.1 WHERE id = 2; -- ... repeated 1000 times -- After (single batch) UPDATE products SET price = price * 1.1 WHERE id = ANY(ARRAY[1, 2, 3, ..., 1000]); -- Or use CTE for complex batches WITH price_updates AS ( SELECT id, new_price FROM temp_price_updates ) UPDATE products p SET price = pu.new_price FROM price_updates pu WHERE p.id = pu.id; ``` ## Expert Practices: Idioms, Anti-Patterns & Gotchas High-signal guidance for thinking like a query-tuning expert. Defaults and version notes assume PostgreSQL. ### Reading EXPLAIN **`Index Cond` is an access predicate; `Filter` is not.** On an Index Scan, `Index Cond` decides which index leaf entries are traversed; a `Filter` line is applied to every row those entries return, discarding non-matching rows afterward. A `Filter` on an Index Scan plus a high `Rows Removed by Filter` means the index is doing more work than the query needs — usually because a filtering column is not in the access predicate. Fix by reordering/extending the composite index so that column joins the `Index Cond`. (A `Filter` after a Seq Scan instead means no usable index at all.) **Diagnose bad plans by estimated-vs-actual row divergence, not cost-vs-time.** Cost units (arbitrary) and actual time (ms) are not numerically comparable. The signal that matters is whether estimated `rows=N` tracks `actual rows=M`. A divergence of ~10× or more means the planner chose its architecture (nested loop vs hash join, index vs seq scan) on wrong cardinality — the usual root cause of a slow plan. Fixes in order: (1) `ANALYZE` to refresh stats; (2) raise per-column statistics target for skewed columns (`ALTER TABLE t ALTER COLUMN c SET STATISTICS 500`); (3) `CREATE STATISTICS` for correlated column groups. The `enable_*` GUCs (e.g. `SET enable_nestloop = off`) are diagnostic probes only — never a permanent fix, since they affect every query in the session. **Looped-node times/rows are per-loop averages — multiply by `loops`.** For a node inside a loop (e.g. the inner side of a Nested Loop), `EXPLAIN ANALYZE` reports actual time and rows as averages PER execution, with `loops=N` the count. A node showing `actual time=0.003..0.005 rows=1 loops=10000` is ~30 ms total, not 0.003 ms. Related trap: under `LIMIT`, child nodes report only the rows delivered before the limit stopped them, not what they would have produced.
GitHubで見る
この SKILL.md は非常に大きいため、SkillsMP では最初のセクションだけを表示しています。 GitHubで見る