PostgreSQL 完全指南 / 08 - 查询优化
第 08 章 · 查询优化
查询优化是 DBA 和开发者的必备技能。本章介绍 PostgreSQL 的查询计划器、EXPLAIN ANALYZE 的使用方法以及常见优化技巧。
8.1 查询处理流程
SQL 文本
│
▼
┌──────────┐
│ Parser │ ── 语法分析,生成语法树
└────┬─────┘
▼
┌──────────┐
│ Analyzer │ ── 语义分析,解析表名/列名
└────┬─────┘
▼
┌──────────┐
│ Rewriter │ ── 应用规则系统(视图展开等)
└────┬─────┘
▼
┌──────────┐
│ Planner/ │ ── 生成最优执行计划
│Optimizer │ 成本模型:随机页读取 + CPU 处理
└────┬─────┘
▼
┌──────────┐
│ Executor │ ── 按计划执行,返回结果
└──────────┘
8.2 EXPLAIN ANALYZE
基本用法
-- EXPLAIN:只看计划,不实际执行
EXPLAIN SELECT * FROM employees WHERE department = '工程部';
-- EXPLAIN ANALYZE:实际执行并返回真实统计
EXPLAIN ANALYZE SELECT * FROM employees WHERE department = '工程部';
-- 常用选项组合
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) SELECT * FROM employees WHERE department = '工程部';
-- JSON 格式输出(适合工具解析)
EXPLAIN (ANALYZE, BUFFERS, FORMAT JSON) SELECT * FROM employees WHERE department = '工程部';
输出解读
Seq Scan on employees (cost=0.00..1250.00 rows=100 width=56) (actual time=0.015..8.500 rows=95 loops=1)
Filter: (department = '工程部')
Rows Removed by Filter: 905
Buffers: shared hit=450 read=50
Planning Time: 0.150 ms
Execution Time: 8.700 ms
| 字段 | 含义 |
|---|---|
Seq Scan | 扫描方式(顺序扫描) |
cost=0.00..1250.00 | 估算成本(启动成本..总成本) |
rows=100 | 估算返回行数 |
width=56 | 估算每行平均字节数 |
actual time=0.015..8.500 | 实际耗时(首行..最后一行,毫秒) |
rows=95 | 实际返回行数 |
loops=1 | 该节点执行次数 |
Buffers: shared hit=450 read=50 | 缓冲区命中 450 页,磁盘读取 50 页 |
Rows Removed by Filter | 过滤掉的行数 |
成本模型参数
-- 查看成本参数
SHOW seq_page_cost; -- 顺序读一页的成本(默认 1.0)
SHOW random_page_cost; -- 随机读一页的成本(默认 4.0)
SHOW cpu_tuple_cost; -- 处理一行的 CPU 成本(默认 0.01)
SHOW cpu_index_tuple_cost; -- 处理索引行的 CPU 成本(默认 0.005)
SHOW cpu_operator_cost; -- 操作符的 CPU 成本(默认 0.0025)
-- SSD 环境调低随机读成本
SET random_page_cost = 1.1;
8.3 常见扫描方式
| 扫描方式 | 说明 | 适用场景 |
|---|---|---|
| Seq Scan | 顺序扫描全表 | 小表、无合适索引、大部分行 |
| Index Scan | 索引扫描 + 回表 | 有索引、返回少量行 |
| Index Only Scan | 仅索引扫描(不回表) | 覆盖索引、Visibility Map 命中 |
| Bitmap Index Scan | 位图索引扫描 | 中等数量行、多条件组合 |
| Bitmap Heap Scan | 位图堆扫描 | 配合 Bitmap Index Scan |
-- 触发 Seq Scan(无索引)
EXPLAIN ANALYZE SELECT * FROM employees WHERE salary > 0;
-- 触发 Index Scan(有索引)
CREATE INDEX idx_emp_dept ON employees (department);
EXPLAIN ANALYZE SELECT * FROM employees WHERE department = '工程部';
-- 触发 Index Only Scan
EXPLAIN ANALYZE SELECT id FROM employees WHERE id < 100;
-- 触发 Bitmap Index Scan(多条件组合)
EXPLAIN ANALYZE SELECT * FROM employees
WHERE department = '工程部' AND salary > 15000;
8.4 常见 Join 策略
| Join 类型 | 说明 | 适用场景 |
|---|---|---|
| Nested Loop | 嵌套循环 | 小表驱动大表、有索引 |
| Hash Join | 哈希连接 | 等值连接、无索引、中等表 |
| Merge Join | 归并连接 | 已排序的表、大表连接 |
-- 强制使用不同的 Join 策略来观察
SET enable_nestloop = off;
SET enable_hashjoin = on;
SET enable_mergejoin = off;
EXPLAIN ANALYZE SELECT e.name, d.budget
FROM employees e JOIN departments d ON e.department = d.name;
-- 恢复默认
RESET enable_nestloop;
RESET enable_hashjoin;
RESET enable_mergejoin;
8.5 统计信息
查询优化器依赖统计信息来估算成本。
-- 查看列的统计信息
SELECT
attname AS column_name,
n_distinct, -- 不同值数量(-1 表示唯一)
most_common_vals, -- 最常见值
most_common_freqs, -- 最常见值的频率
histogram_bounds, -- 直方图边界
null_frac -- NULL 值比例
FROM pg_stats
WHERE tablename = 'employees';
-- 手动更新统计信息
ANALYZE employees;
-- 更精确的统计(更多采样)
ANALYZE employees (department, salary);
-- 设置统计精度
ALTER TABLE employees ALTER COLUMN department SET STATISTICS 1000;
ANALYZE employees;
8.6 并行查询
PostgreSQL 支持并行查询以利用多核 CPU。
-- 并行查询相关参数
SHOW max_parallel_workers_per_gather; -- 每个 Gather 节点的最大并行工作者(默认 2)
SHOW max_parallel_workers; -- 并行工作者总数上限(默认 8)
SHOW parallel_tuple_cost; -- 并行处理一行的成本
SHOW parallel_setup_cost; -- 启动并行工作者的成本
SHOW min_parallel_table_scan_size; -- 触发并行扫描的最小表大小(默认 8MB)
SHOW min_parallel_index_scan_size; -- 触发并行索引扫描的最小大小(默认 512kB)
-- 查看是否使用了并行
EXPLAIN ANALYZE SELECT COUNT(*) FROM large_table;
-- 禁用并行(调试时)
SET max_parallel_workers_per_gather = 0;
EXPLAIN ANALYZE SELECT COUNT(*) FROM large_table;
-- 增加并行度
SET max_parallel_workers_per_gather = 4;
并行查询计划示例:
Finalize Aggregate (cost=1000.00..1000.01 rows=1 width=8)
-> Gather (cost=1000.00..1000.01 rows=3 width=8)
Workers Planned: 2
-> Partial Aggregate (cost=0.00..1000.00 rows=1 width=8)
-> Parallel Seq Scan on large_table (cost=0.00..800.00 rows=100000 width=0)
8.7 常见查询优化技巧
避免 SELECT *
-- ❌ 不好
SELECT * FROM employees WHERE department = '工程部';
-- ✅ 好:只选需要的列
SELECT id, name, salary FROM employees WHERE department = '工程部';
-- ✅ 更好:使用覆盖索引
CREATE INDEX idx_emp_covering ON employees (department) INCLUDE (name, salary);
SELECT name, salary FROM employees WHERE department = '工程部'; -- Index Only Scan
避免隐式类型转换
-- ❌ 类型不匹配,无法使用索引
SELECT * FROM orders WHERE order_no = 12345; -- order_no 是 TEXT
-- ✅ 类型匹配
SELECT * FROM orders WHERE order_no = '12345';
-- ❌ 函数包裹列,无法使用索引
SELECT * FROM employees WHERE LOWER(email) = 'alice@example.com';
-- ✅ 创建表达式索引
CREATE INDEX idx_emp_lower_email ON employees (LOWER(email));
SELECT * FROM employees WHERE LOWER(email) = 'alice@example.com';
LIMIT 优化
-- ❌ 深分页性能差
SELECT * FROM employees ORDER BY id LIMIT 20 OFFSET 100000;
-- 会扫描前 100020 行
-- ✅ Keyset 分页(Seek Method)
SELECT * FROM employees
WHERE id > 100000 -- 上一页最后一条的 id
ORDER BY id
LIMIT 20;
IN vs EXISTS vs JOIN
-- 当子查询结果集大时,EXISTS 通常更快
-- ❌ IN:需要物化子查询
SELECT * FROM employees e
WHERE e.department IN (SELECT d.name FROM departments d WHERE d.budget > 200000);
-- ✅ EXISTS:短路求值
SELECT * FROM employees e
WHERE EXISTS (SELECT 1 FROM departments d WHERE d.name = e.department AND d.budget > 200000);
-- ✅ JOIN:优化器自动选择
SELECT e.* FROM employees e
JOIN departments d ON e.department = d.name
WHERE d.budget > 200000;
避免 OR 导致的全表扫描
-- ❌ OR 可能导致全表扫描
SELECT * FROM employees WHERE department = '工程部' OR department = '产品部';
-- ✅ 使用 IN
SELECT * FROM employees WHERE department IN ('工程部', '产品部');
-- ✅ 使用 UNION ALL(当 OR 无法使用索引时)
SELECT * FROM employees WHERE department = '工程部'
UNION ALL
SELECT * FROM employees WHERE department = '产品部';
8.8 查询优化器提示(Hints)
PostgreSQL 没有内置的 Hint 语法,但可以通过以下方式影响优化器:
-- 设置会话级参数
SET enable_seqscan = off; -- 禁用顺序扫描
SET enable_indexscan = on; -- 启用索引扫描
SET enable_hashjoin = off; -- 禁用哈希连接
SET enable_mergejoin = on; -- 启用归并连接
-- 调整代价参数
SET random_page_cost = 1.1; -- SSD 环境
SET effective_cache_size = '4GB'; -- 估算可用缓存
-- CTE 物化控制
WITH cte AS MATERIALIZED (...) SELECT ...;
WITH cte AS NOT MATERIALIZED (...) SELECT ...;
-- 使用 pg_hint_plan 扩展(第三方)
-- CREATE EXTENSION pg_hint_plan;
-- /*+ IndexScan(employees idx_emp_dept) */
-- SELECT * FROM employees WHERE department = '工程部';
8.9 慢查询排查流程
1. 找到慢查询
├── pg_stat_statements(最常用)
├── log_min_duration_statement(日志记录)
└── pg_stat_activity(实时查看)
2. EXPLAIN ANALYZE
├── 检查估算行数 vs 实际行数(差距大说明统计不准)
├── 检查是否有 Seq Scan(全表扫描大表)
├── 检查是否有 Nested Loop 且内表无索引
└── 检查 Buffers(hit vs read 比例)
3. 分析原因
├── 统计信息过时 → ANALYZE
├── 缺少索引 → 创建索引
├── 索引未被使用 → 检查查询条件/类型转换
├── 参数不合理 → 调整 work_mem 等
└── 数据分布不均 → 优化查询逻辑
4. 验证优化
└── 再次 EXPLAIN ANALYZE 对比
业务场景
| 场景 | 排查方法 |
|---|---|
| 查询突然变慢 | EXPLAIN ANALYZE → 检查计划变化 |
| CPU 飙高 | pg_stat_statements 找高频查询 |
| 大表查询慢 | 检查索引 + 统计信息 + 并行查询 |
| 深分页慢 | 改用 Keyset 分页 |
| 连接慢 | 检查连接池配置 + pg_stat_activity |
扩展阅读
- EXPLAIN 官方文档
- EXPLAIN Visualizer (pev2)
- pgMustard — EXPLAIN 分析工具
- Use The Index, Luke — Indexing Course
- 《PostgreSQL 查询引擎源码技术探析》— 张树杰