DevToolBox免费
博客

SQL 查询优化:15 个加速数据库的技术

14 分钟阅读作者 DevToolBox

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 indexed

GIN 索引与表达式索引

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.conf

N+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 TypeBest ForOperatorsWrite OverheadNotes
B-TreeEquality, range, sort=, <, >, BETWEEN, LIKE prefixLowDefault; most queries
GINMulti-valued (JSONB, arrays)@>, <@, @@, ANYHigh writeFull-text, JSONB queries
GiSTGeometric, full-text&&, @>, <@, ~~MediumNearest-neighbor, overlap
BRINSequential data (timestamps)=, <, >Very lowHuge tables, append-only
HashEquality only=LowFaster than B-Tree for =, no range
PartialSubset of rowsAnyLowWHERE clause filter in index def
ExpressionComputed valuesAnyMediumlower(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)。

相关工具

𝕏 Twitterin LinkedIn
这篇文章有帮助吗?

保持更新

获取每周开发技巧和新工具通知。

无垃圾邮件,随时退订。

试试这些相关工具

{ }JSON FormatterCron Expression Parser

相关文章

PostgreSQL 性能调优:索引策略、查询优化与配置

PostgreSQL 性能调优完全指南 — 索引策略、EXPLAIN ANALYZE、连接池和服务器配置。

SQL vs NoSQL:选择正确数据库的完整指南

理解 SQL 与 NoSQL 数据库的区别,比较 PostgreSQL、MongoDB、Redis 的使用场景。

SQL Joins 图解详解:INNER、LEFT、RIGHT、FULL、CROSS

通过可视化图表和实际示例掌握 SQL Joins。学习 INNER JOIN、LEFT JOIN、RIGHT JOIN、FULL OUTER JOIN、CROSS JOIN 和 SELF JOIN 的性能技巧和常见模式。