PostgreSQL查询执行阶段 --- 总结与执行计划选择指南
一、查询执行全流程
SQL 文本
│
▼
[解析] 词法/语法分析 → 抽象语法树 (AST)
│
▼
[转换] 视图展开 / 行级安全 / 规则重写 → 查询树
│
▼
[规划] 基于成本的优化器 → 计划树
│
▼
[执行] 执行器流水线拉取数据 → 结果集
两种协议对比:
| 阶段 | 简单查询协议 | 扩展查询协议 |
|---|---|---|
| 解析 | 每次执行都解析 | 预备阶段解析一次,缓存语法树 |
| 规划 | 每次执行都规划 | 前 5 次 custom plan,之后可切换 generic plan |
| 参数安全 | 需手动转义 | 参数绑定,天然防 SQL 注入 |
| 结果获取 | 一次性全量返回 | 支持批次获取(类似游标) |
二、规划器核心机制
2.1 基于成本的优化
规划器枚举候选计划,为每个计划计算成本(cost),选择成本最低的计划执行。
成本由两部分组成:
cost=<启动成本>..<总成本>
- 启动成本:产出第一行之前必须付出的代价(如排序需先读完所有数据)
- 总成本:获取全部结果的总代价
成本是无量纲的相对值,仅用于同一查询不同计划之间的比较,跨查询比较无意义。
2.2 计划选择策略
规划器根据查询使用方式选择优化目标:
| 场景 | 优化目标 | 选择依据 |
|---|---|---|
| 普通查询(无游标) | 最小化总成本 | 选 total cost 最低的计划 |
| 游标查询 | 最小化前 N 行成本 | 选 startup + cursor_tuple_fraction × (total - startup) 最小的计划 |
cursor_tuple_fraction 默认值为 0.1,表示假设客户端只取前 10% 的行。
2.3 基数估算
基数(cardinality)= 节点输出的预估行数,是成本估算的基础。
估算流程(递归):
- 估算叶节点基数(依赖表统计信息:行数、列分布等)
- 估算过滤条件选择率(selectivity)
- 节点基数 = 输入行数 × 选择率
逻辑运算的选择率公式(假设谓词独立):
sel(x AND y) = sel(x) × sel(y)
sel(x OR y) = sel(x) + sel(y) - sel(x) × sel(y)
注意:谓词相关性会导致估算偏差,这是计划选择错误的主要根源之一。
2.4 连接顺序搜索
| 参数 | 作用 | 默认值 |
|---|---|---|
join_collapse_limit |
超过此值时,显式 JOIN 顺序不再展平 | 8 |
from_collapse_limit |
超过此值时,子查询不再展平到主查询 | 8 |
geqo |
是否启用遗传算法优化 | on |
geqo_threshold |
超过此表数量时启用遗传算法 | 12 |
连接数量与搜索策略:
连接表数 ≤ geqo_threshold → 动态规划(精确,但指数级复杂度)
连接表数 > geqo_threshold → 遗传算法(快速,但不保证最优)
三、Custom Plan vs Generic Plan
切换逻辑
执行次数 1~5 → 始终使用 custom plan(基于实际参数值规划)
执行次数 ≥ 6 → 比较 generic plan 与 custom plan 平均成本
generic plan 更优 → 切换并固定使用 generic plan
custom plan 更优 → 继续使用 custom plan
识别方式
sql
-- custom plan:计划中显示具体参数值
Filter: ((aircraft_code)::text = '319'::text)
-- generic plan:计划中显示参数占位符
Filter: ((aircraft_code)::text = $1)
查看统计
sql
SELECT name, generic_plans, custom_plans
FROM pg_prepared_statements;
手动控制
sql
-- 强制使用 custom plan(参数选择性差异大时推荐)
SET plan_cache_mode = 'force_custom_plan';
-- 强制使用 generic plan(规划开销大、参数分布均匀时)
SET plan_cache_mode = 'force_generic_plan';
-- 恢复自动选择(默认)
SET plan_cache_mode = 'auto';
四、EXPLAIN 解读
基本用法
sql
-- 查看估算计划
EXPLAIN SELECT ...;
-- 查看实际执行信息(真实行数、时间)
EXPLAIN (ANALYZE, BUFFERS) SELECT ...;
输出字段含义
Sort (cost=21.03..21.04 rows=1 width=128)
↑启动成本 ↑总成本 ↑预估行数 ↑每行宽度(bytes)
EXPLAIN ANALYZE 额外输出:
Sort (cost=21.03..21.04 rows=1 width=128)
(actual time=0.123..0.124 rows=3 loops=1)
↑实际启动时间 ↑实际总时间 ↑实际行数 ↑循环次数
常见节点类型
| 节点 | 说明 | 适用场景 |
|---|---|---|
Seq Scan |
全表顺序扫描 | 大比例数据读取、无合适索引 |
Index Scan |
索引扫描 + 回表 | 高选择性条件,少量行 |
Index Only Scan |
仅索引扫描(覆盖索引) | 查询列全在索引中 |
Bitmap Index Scan + Bitmap Heap Scan |
位图索引扫描 | 中等选择性,批量回表 |
Nested Loop |
嵌套循环连接 | 外表小、内表有索引 |
Hash Join |
哈希连接 | 大表等值连接 |
Merge Join |
归并连接 | 两侧已排序或可利用索引顺序 |
Sort |
排序 | ORDER BY、Merge Join 前置 |
Hash |
构建哈希表 | Hash Join 内侧 |
Aggregate |
聚合 | GROUP BY、COUNT 等 |
Limit |
限制行数 | LIMIT 子句 |
五、执行计划选择问题排查
5.1 统计信息过时
现象 :rows=1 但实际返回数千行,或反之。
原因:统计信息未及时更新。
解决:
sql
-- 手动更新统计信息
ANALYZE table_name;
-- 查看统计信息最后更新时间
SELECT relname, last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE relname = 'table_name';
5.2 相关谓词导致估算偏差
现象:多列组合过滤时行数估算严重偏低。
原因:规划器默认谓词独立,相关列会导致乘积低估。
解决:创建多列统计信息(PG 10+):
sql
CREATE STATISTICS stat_name (dependencies) ON col1, col2 FROM table_name;
ANALYZE table_name;
5.3 选错扫描方式
现象:应走索引却走全表扫描,或反之。
排查:
sql
-- 临时禁用某种扫描方式验证
SET enable_seqscan = off;
SET enable_indexscan = off;
SET enable_bitmapscan = off;
EXPLAIN SELECT ...;
根本原因:通常是统计信息不准或成本参数不匹配实际硬件。
5.4 连接顺序不优
现象:大表驱动小表,或连接顺序导致中间结果集过大。
解决方案:
sql
-- 方案1:调整 join_collapse_limit 保留显式 JOIN 顺序
SET join_collapse_limit = 1;
-- 方案2:用 CTE 强制分段优化(PG 12 前默认 fence,PG 12+ 需加 MATERIALIZED)
WITH sub AS MATERIALIZED (
SELECT ... FROM large_table WHERE ...
)
SELECT ... FROM sub JOIN small_table ON ...;
5.5 遗传算法导致计划不稳定
现象:多表连接查询计划随机变化,性能不稳定。
解决:
sql
-- 降低 geqo_threshold 或关闭 geqo
SET geqo = off;
-- 或减少参与连接的表数量(拆分查询、使用 CTE)
六、执行阶段内存管理
| 参数 | 说明 | 默认值 |
|---|---|---|
work_mem |
单个排序/哈希操作可用内存 | 4MB |
temp_file_limit |
临时文件总大小上限 | -1(无限) |
- 一个查询可能有多个需要内存的节点(Sort、Hash 等),每个节点独立分配最多
work_mem。 - 超出
work_mem时数据溢出到磁盘临时文件,性能大幅下降。 EXPLAIN (ANALYZE, BUFFERS)中出现Batches: N(N>1)说明 Hash Join 发生了溢出。
sql
-- 针对当前会话调大(大查询临时使用)
SET work_mem = '256MB';
七、关键参数速查
| 参数 | 默认值 | 说明 |
|---|---|---|
enable_seqscan |
on | 是否允许全表扫描 |
enable_indexscan |
on | 是否允许索引扫描 |
enable_bitmapscan |
on | 是否允许位图扫描 |
enable_hashjoin |
on | 是否允许哈希连接 |
enable_mergejoin |
on | 是否允许归并连接 |
enable_nestloop |
on | 是否允许嵌套循环 |
join_collapse_limit |
8 | JOIN 展平阈值 |
from_collapse_limit |
8 | 子查询展平阈值 |
geqo |
on | 是否启用遗传算法 |
geqo_threshold |
12 | 启用遗传算法的表数阈值 |
cursor_tuple_fraction |
0.1 | 游标预期获取比例 |
plan_cache_mode |
auto | 预备语句计划缓存策略 |
work_mem |
4MB | 排序/哈希操作内存 |
default_statistics_target |
100 | 统计信息采样精度 |
八、优化建议总结
-
保持统计信息新鲜 :确保 autovacuum 正常运行;对频繁变更的表手动
ANALYZE;对高选择性列提高statistics target:sqlALTER TABLE t ALTER COLUMN c SET STATISTICS 500; -
善用
EXPLAIN ANALYZE:对比rows=估算与rows=实际,偏差超过 10 倍时需关注统计信息或查询结构。 -
避免阻止规划器使用索引:
- 不要对索引列做函数运算(
WHERE date(created_at) = ...→ 改为范围条件) - 不要隐式类型转换(
WHERE int_col = '123')
- 不要对索引列做函数运算(
-
合理使用预备语句 :参数选择性差异大(如有时走索引有时走全表)时,设置
plan_cache_mode = force_custom_plan。 -
大表连接控制顺序 :超过 8 张表的复杂查询,考虑用 CTE 分段或手动设置
join_collapse_limit = 1固定连接顺序。 -
work_mem按需调整:排序/哈希密集型查询在会话级临时调大,避免全局调大导致内存压力。 -
分区表注意分区裁剪:确保查询条件包含分区键,否则规划器无法裁剪分区,退化为全分区扫描。