SQL 查询优化:索引、EXPLAIN 与 N+1 问题解决
慢查询是 Web 应用中最常见的性能瓶颈之一。本指南涵盖核心技术:选择合适的索引类型、读懂 EXPLAIN ANALYZE 输出、消除 N+1 查询,以及重写低效 SQL 模式。
B-Tree 索引:基础
B-Tree 是默认索引类型,能高效处理等值查询、范围查询和排序。理解复合索引的列顺序和部分索引,可以将查询时间降低数个数量级。
-- Understanding B-Tree indexes (default in PostgreSQL, MySQL, SQLite)
-- An index is a separate data structure that maps column values → row locations
-- Without index: full table scan (reads every row)
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
-- Seq Scan on orders (cost=0.00..1842.00 rows=18 width=96)
-- Create a B-Tree index on the foreign key
CREATE INDEX CONCURRENTLY idx_orders_customer_id ON orders(customer_id);
-- With index: index scan (jumps directly to matching rows)
EXPLAIN SELECT * FROM orders WHERE customer_id = 42;
-- Index Scan using idx_orders_customer_id on orders
-- (cost=0.29..8.55 rows=18 width=96)
-- Composite index: order matters! (customer_id, created_at) covers:
-- WHERE customer_id = ?
-- WHERE customer_id = ? AND created_at > ?
-- But NOT: WHERE created_at > ? (left-prefix rule)
CREATE INDEX idx_orders_customer_date
ON orders(customer_id, created_at DESC);
-- Partial index: only index rows matching a condition
-- Huge win when you frequently query a small subset
CREATE INDEX idx_orders_pending
ON orders(created_at)
WHERE status = 'pending'; -- only ~5% of rows get indexedGIN 索引与表达式索引
GIN(广义倒排索引)针对多值数据进行了优化,如 JSONB 列、数组和全文搜索。表达式索引允许对计算值(如 lower(email))建立索引。
-- GIN (Generalized Inverted Index): for JSONB, arrays, full-text search
-- Much faster than B-Tree for containment queries
-- JSONB column with GIN index
CREATE INDEX idx_products_attrs ON products USING GIN (attributes);
-- Containment query: "find products where attributes includes {color: 'red'}"
SELECT * FROM products
WHERE attributes @> '{"color": "red"}'; -- uses GIN index
-- Full-text search with GIN
ALTER TABLE articles ADD COLUMN tsv tsvector
GENERATED ALWAYS AS (
to_tsvector('english', coalesce(title,'') || ' ' || coalesce(body,''))
) STORED;
CREATE INDEX idx_articles_fts ON articles USING GIN (tsv);
SELECT title, ts_rank(tsv, q) AS rank
FROM articles, to_tsquery('english', 'postgres & performance') q
WHERE tsv @@ q
ORDER BY rank DESC
LIMIT 10;
-- Expression index: index on a computed value
CREATE INDEX idx_users_email_lower ON users (lower(email));
SELECT * FROM users WHERE lower(email) = lower('User@Example.com');读懂 EXPLAIN ANALYZE
EXPLAIN ANALYZE 在执行查询的同时显示实际耗时和行数,以及查询计划。学会读懂计划节点(顺序扫描、索引扫描、哈希连接)是定位性能瓶颈的关键。
-- EXPLAIN ANALYZE: actual execution stats (not just estimates)
-- Always use ANALYZE to see real row counts and timing
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT
c.name,
COUNT(o.id) AS order_count,
SUM(o.total) AS revenue
FROM customers c
JOIN orders o ON o.customer_id = c.id
WHERE o.created_at >= '2025-01-01'
AND o.status = 'completed'
GROUP BY c.id, c.name
ORDER BY revenue DESC
LIMIT 20;
-- Reading the output:
-- "Seq Scan" → no index used, reading all rows (BAD for large tables)
-- "Index Scan" → using B-Tree index (good)
-- "Bitmap Scan" → multiple ranges, batched (good for many rows)
-- "Hash Join" → join via hash table (good for large datasets)
-- "Nested Loop" → row-by-row join (good for small inner set)
-- "actual time=X..Y" → X=first row, Y=last row (ms)
-- "rows=N" → if estimate vs actual differ a lot → stale stats!
-- Fix stale statistics:
ANALYZE orders; -- update stats for one table
ANALYZE; -- update all tables
-- Or set autovacuum_analyze_scale_factor = 0.01 in postgresql.confN+1 问题:检测与修复
N+1 问题发生在代码执行一条查询获取列表后,再对每个元素执行 N 条额外查询。在开发环境中不易察觉,但在生产环境中会造成严重性能问题。解决方案是批量查询或使用 JOIN。
-- N+1 Problem: the silent query killer in ORMs
-- BAD: N+1 in pseudo-ORM code
-- for each user (1 query):
-- fetch user's orders (N queries)
-- Total: 1 + N queries for N users
-- Example: fetching 100 users + their order counts = 101 queries
const users = await db.query('SELECT * FROM users LIMIT 100');
for (const user of users) {
const orders = await db.query(
'SELECT COUNT(*) FROM orders WHERE customer_id = $1',
[user.id]
);
user.orderCount = orders[0].count;
}
-- GOOD: Single query with JOIN or subquery
SELECT
u.id,
u.name,
u.email,
COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.customer_id = u.id
GROUP BY u.id, u.name, u.email
LIMIT 100;
-- GOOD: Use IN clause to batch (avoid when set is large)
-- Fetch users first, then batch-load orders
SELECT * FROM orders
WHERE customer_id = ANY($1::int[]); -- pass array of user IDs
-- GOOD: Prisma/TypeORM eager loading
const users = await prisma.user.findMany({
include: { orders: true }, -- generates single LEFT JOIN query
take: 100,
});查询重写模式
许多慢查询可以通过重写大幅提速:用 JOIN 替换相关子查询、用窗口函数替换自连接、用 EXISTS 替换 COUNT 做存在性检查。
-- Query rewrites that dramatically improve performance
-- 1. Replace correlated subquery with JOIN
-- SLOW: correlated subquery executes for each outer row
SELECT name,
(SELECT COUNT(*) FROM orders o WHERE o.customer_id = c.id) AS cnt
FROM customers c;
-- FAST: single aggregation pass
SELECT c.name, COUNT(o.id) AS cnt
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name;
-- 2. Use window functions instead of self-join
-- SLOW: self-join to find each employee's department average
SELECT e.name, e.salary,
(SELECT AVG(salary) FROM employees e2 WHERE e2.dept = e.dept) AS dept_avg
FROM employees e;
-- FAST: window function scans table once
SELECT name, salary,
AVG(salary) OVER (PARTITION BY dept) AS dept_avg
FROM employees;
-- 3. Avoid SELECT * in subqueries (forces extra columns)
-- SLOW
SELECT * FROM (SELECT * FROM large_table) sub WHERE id > 1000;
-- FAST: only select needed columns
SELECT id, name FROM large_table WHERE id > 1000;
-- 4. Use EXISTS instead of COUNT for existence checks
-- SLOW: scans all matching rows
SELECT * FROM products p WHERE (SELECT COUNT(*) FROM reviews r WHERE r.product_id = p.id) > 0;
-- FAST: stops at first match
SELECT * FROM products p WHERE EXISTS (SELECT 1 FROM reviews r WHERE r.product_id = p.id);连接池与批量操作
数据库连接代价高昂。连接池(PgBouncer、pg Pool)、预处理语句,以及使用 UNNEST 或 COPY 进行批量 INSERT,可以减少往返次数并显著提升吞吐量。
-- Connection pooling & query batching best practices
-- PostgreSQL: use PgBouncer or built-in pooling
-- Connection pool settings (nodejs pg pool)
import { Pool } from 'pg';
const pool = new Pool({
max: 20, // max connections (default: 10)
idleTimeoutMillis: 30000,
connectionTimeoutMillis: 2000,
// keepAlive improves performance for long-lived processes
keepAlive: true,
keepAliveInitialDelayMillis: 10000,
});
// Prepared statements: parse once, execute many times
await pool.query({
name: 'get-user-orders',
text: 'SELECT * FROM orders WHERE customer_id = $1 AND status = $2',
values: [customerId, 'completed'],
});
-- Batch INSERT with single statement (much faster than loop)
INSERT INTO events (user_id, event_type, created_at)
SELECT * FROM UNNEST(
$1::int[],
$2::text[],
$3::timestamptz[]
);
-- Inserts thousands of rows in one round-trip
-- Use COPY for bulk data loading (fastest option)
COPY orders (customer_id, total, status, created_at)
FROM '/tmp/orders.csv'
WITH (FORMAT CSV, HEADER true);索引类型对比
| Index Type | Best For | Operators | Write Overhead | Notes |
|---|---|---|---|---|
| B-Tree | Equality, range, sort | =, <, >, BETWEEN, LIKE prefix | Low | Default; most queries |
| GIN | Multi-valued (JSONB, arrays) | @>, <@, @@, ANY | High write | Full-text, JSONB queries |
| GiST | Geometric, full-text | &&, @>, <@, ~~ | Medium | Nearest-neighbor, overlap |
| BRIN | Sequential data (timestamps) | =, <, > | Very low | Huge tables, append-only |
| Hash | Equality only | = | Low | Faster than B-Tree for =, no range |
| Partial | Subset of rows | Any | Low | WHERE clause filter in index def |
| Expression | Computed values | Any | Medium | lower(email), date_trunc() |
常见问题
如何确定先优化哪些查询?
启用慢查询日志:在 PostgreSQL 中设置 log_min_duration_statement = 1000(毫秒)。使用 pg_stat_statements 查看聚合查询统计。优先关注 total_time(调用次数 × 平均时间)高的查询,而非仅针对单次最慢执行。
为什么添加索引有时会让查询更慢?
索引扫描有开销——如果查询返回超过约 15% 的行,优化器可能更倾向于顺序扫描。此外,过多的索引会拖慢 INSERT/UPDATE/DELETE,因为每个索引都需要维护。用 pg_stat_user_indexes 删除未使用的索引。
EXPLAIN 和 EXPLAIN ANALYZE 有什么区别?
EXPLAIN 仅显示带代价估算的查询计划(非实际)。EXPLAIN ANALYZE 实际执行查询,显示真实耗时和行数。始终使用 ANALYZE 来验证优化器估算是否准确。若差异悬殊,说明统计信息过期,需运行 ANALYZE。
如何修复 Prisma 或 TypeORM 中的 N+1 问题?
在 Prisma 中使用 include 预加载关联。在 TypeORM 中使用 findOptions 中的 relations 或 QueryBuilder 的 .leftJoinAndSelect()。复杂场景下,使用 DataLoader(Facebook 出品)在 resolver 层批量缓存 N+1 查询(常见于 GraphQL API)。