查询执行流程(Parser / Rewrite / Planner / Executor)
一条 SQL 从客户端发出到返回结果,PostgreSQL 需要经过解析(Parser)→ 重写(Rewrite)→ 计划(Planner / Optimizer)→ 执行(Executor)四个阶段。本节把每个阶段的输入、输出与核心工作讲清楚,并结合 EXPLAIN、pg_stat_statements 与日志参数,展示如何观察与诊断每个环节的耗时与产物。
前置知识
阅读本节前,建议先了解:磁盘文件结构(PGDATA / relfilenode)
基础概念
PostgreSQL 采用单条 SQL 单阶段流转的处理模型:每个后端进程独立完成从解析到执行的全过程,没有共享的执行计划缓存(plan cache)供所有会话共用。
| 术语 | 英文名 | 说明 |
|---|---|---|
| 解析 | Parse | 把 SQL 文本转成语法树(parse tree),检查语法 |
| 分析/语义检查 | Analyze | 查系统目录,解析表名、列名、函数,生成查询树(Query) |
| 重写 | Rewrite | 展开视图、规则(RULE)、行级安全策略,得到最终查询树 |
| 计划 | Plan / Optimize | 基于统计信息枚举访问路径,估算成本,选出最优计划树 |
| 执行 | Execute | 按计划树驱动算子(节点)从存储层取数、计算、返回结果 |
| 计划节点 | Plan Node | 计划树上的一个算子,如 Seq Scan、Hash Join |
| 成本估算 | Cost Estimation | 用 seq_page_cost、random_page_cost 等参数估算 I/O 与 CPU 代价 |
| 选择率 | Selectivity | 谓词过滤掉数据的比例,统计信息是估算基础 |
| 通用计划 | Generic Plan | 预处理语句(PREPARE)在第 6 次执行后可能启用的固定计划 |
| 自定义计划 | Custom Plan | 每次执行时按实际参数值重新生成的计划 |
解析 ≠ 计划 ≠ 执行,成本完全不在一个量级
一次简单的主键点查,解析与计划可能只花几十微秒,执行是微秒级;但一条十几个表关联的复杂分析查询,计划阶段可能耗时数百毫秒,远超执行前几个算子的时间。「SQL 变慢」时首先要知道慢在哪一阶段,否则优化方向会完全跑偏(解析慢要查 SQL 文本与语法,计划慢要查统计信息与 join_collapse_limit,执行慢要查索引与数据量)。
四个阶段的数据结构演进
客户端发送 SQL 文本
│
▼
┌──────────────────┐
│ 1. Parser │ 原始 SQL 字符串 → 语法树(RawStmt / raw parse tree)
│ 语法解析 │ 只做语法检查,不查任何系统表
└──────────────────┘
│
▼
┌──────────────────┐
│ 2. Analyzer │ 语法树 → 查询树(Query)
│ 语义分析 │ 查系统目录,把「表名」解析成 OID,检查列/函数是否存在
│ │ 同时做类型推导与隐式类型转换
└──────────────────┘
│
▼
┌──────────────────┐
│ 3. Rewriter │ 查询树 → 展开后的查询树
│ 规则重写 │ 展开视图、RULE、行级安全策略(RLS)
└──────────────────┘
│
▼
┌──────────────────┐
│ 4. Planner │ 查询树 → 计划树(PlannedStmt,含 Plan tree)
│ 优化器 │ 枚举访问路径与连接顺序,用统计信息估算成本,取最低者
└──────────────────┘
│
▼
┌──────────────────┐
│ 5. Executor │ 计划树 → 结果集
│ 执行器 │ 拉取模型(pull-based):上层节点向上要一行,向下取一行
└──────────────────┘
│
▼
返回结果给客户端(Simple Query 一次性返回;Extended Query 走 Portal 分批)核心机制
阶段 1~2:解析与语义分析
解析(Parser) 完全由语法决定,使用 gram.y 生成的语法分析器(Bison)。它把 SELECT * FROM t WHERE id = 1 转成一棵语法树,并识别出关键字、标识符、常量、操作符。这一步不访问任何系统表,所以 SELECT * FROM 不存在的表 能通过解析,却会在下一阶段失败。
-- 语法错误:解析阶段就被拒绝
SELECT * FORM t_user;ERROR: syntax error at or near "FORM"
LINE 1: SELECT * FORM t_user;
^-- 语义错误:语法正确,但语义分析阶段找不到表
SELECT * FROM t_not_exist;ERROR: relation "t_not_exist" does not exist
LINE 1: SELECT * FROM t_not_exist;
^分析(Analyzer / parse_analyze) 负责把名字翻译成 OID:表名 → pg_class.oid,列名 → pg_attribute.attnum,函数名 → pg_proc.oid。这一步的产物是查询树(Query),是纯内存结构。
| 阶段 | 主要工作 | 典型报错信息 |
|---|---|---|
| 解析(Parse) | 语法检查、关键字识别 | syntax error at or near ... |
| 分析(Analyze) | 对象存在性、列存在性、类型匹配、权限检查 | relation ... does not exist、column ... does not exist、permission denied for table |
类型推导也发生在这里,这就是隐式类型转换的由来:
-- 字面量 '1' 是 unknown 类型,会被推导为 int4 以匹配 id 列的类型
SELECT * FROM t_user WHERE id = '1';
-- 但如果两侧都是「明确类型」,就会按操作符规则决定是否转换
SELECT * FROM t_user WHERE id = 1.0; -- numeric,id 会被隐式提升,索引可能失效阶段 3:规则重写
重写(Rewrite)阶段的唯一职责是把查询树「展开」成不含视图、规则与安全策略的等价形式。
三种典型展开:
-- ① 视图展开:视图名会被替换成其定义
CREATE VIEW v_active_user AS
SELECT id, name FROM t_user WHERE status = 'active';
-- 实际上等价于
SELECT id, name FROM t_user WHERE status = 'active';-- ② 行级安全策略(Row-Level Security)注入
-- 开启 RLS 后,每个查询都会被自动加上 USING 谓词
ALTER TABLE t_user ENABLE ROW LEVEL SECURITY;
CREATE POLICY p_self ON t_user
USING (owner_id = current_setting('app.user_id')::int);
-- 业务写的 SELECT * FROM t_user 会被重写为:
-- SELECT * FROM t_user WHERE owner_id = current_setting('app.user_id')::int;-- ③ 规则(RULE)触发:INSERT 到视图上时,会被改写成对基表的操作
-- 这也是 CREATE RULE ... DO INSTEAD 的底层机制重写阶段最常见的性能陷阱:视图套视图
视图本身不存数据,每次查询都要展开。若视图 A 引用视图 B,视图 B 又引用视图 C,嵌套多层后重写产物会急剧膨胀,且每个视图独立展开导致父查询的 WHERE 条件无法有效下推(除非满足简单视图的可上拉条件)。诊断方法是看 EXPLAIN 输出里的子查询层级,或对可疑查询手工改写成扁平 SQL 对比耗时。
阶段 4:计划(Planner / Optimizer)
计划器是整个流程中最复杂、最耗 CPU 的阶段。它的工作可以概括为三步:
(1)枚举候选路径
对每个基表,先枚举可用的访问路径(Access Path):
| 访问路径 | 触发条件 | EXPLAIN 显示 |
|---|---|---|
| 顺序扫描 | 无可用索引或索引选择性差 | Seq Scan |
| 索引扫描 | 有匹配的 B-tree 索引 | Index Scan / Index Only Scan |
| 位图索引扫描 | 条件返回较多行、可批量定位页面 | Bitmap Heap Scan + Bitmap Index Scan |
| 并行顺序扫描 | 表足够大且 max_parallel_workers_per_gather > 0 | Gather + Parallel Seq Scan |
(2)估算成本
成本由三部分构成,单位是「页」的抽象代价:
总成本 = 启动成本(startup_cost) + 运行成本(run_cost)
运行成本 ≈ 页读取成本 + CPU 元组处理成本 + 操作符自身成本关键的成本参数:
| 参数 | 默认值 | 含义 |
|---|---|---|
seq_page_cost | 1.0 | 顺序读一个页的代价(基准) |
random_page_cost | 4.0 | 随机读一个页的代价(SSD 上建议 1.1~1.5) |
cpu_tuple_cost | 0.01 | 处理一个元组的 CPU 代价 |
cpu_index_tuple_cost | 0.005 | 处理一个索引条目的 CPU 代价 |
cpu_operator_cost | 0.0025 | 执行一次操作符/函数的 CPU 代价 |
effective_cache_size | 4GB | 优化器对「OS 缓存 + 共享缓冲」总量的估计 |
(3)选择代价最低的计划
计划器会做连接顺序搜索(join order search)。表的数量决定搜索空间:
- ≤ 12 个表(
geqo_threshold默认值)时,用动态规划穷举; - 超过
geqo_threshold时,退化为遗传算法(GEQO, Genetic Query Optimizer),只求近似最优。
-- 观察成本参数与优化器阈值
SELECT name, setting, unit
FROM pg_settings
WHERE name IN ('seq_page_cost', 'random_page_cost', 'cpu_tuple_cost',
'effective_cache_size', 'geqo_threshold', 'geqo',
'join_collapse_limit', 'from_collapse_limit',
'enable_seqscan', 'enable_nestloop', 'default_statistics_target')
ORDER BY name; name | setting | unit
---------------------------+---------+------
cpu_tuple_cost | 0.01 |
default_statistics_target | 100 |
effective_cache_size | 524288 | 8kB -- 524288 × 8KB = 4GB
enable_nestloop | on |
enable_seqscan | on |
from_collapse_limit | 8 |
geqo | on |
geqo_threshold | 12 |
join_collapse_limit | 8 |
random_page_cost | 4 |
seq_page_cost | 1 |
(11 rows)为什么统计信息决定计划质量
计划器不知道实际数据分布,它只能读 pg_statistic 里的直方图与 MCV(Most Common Values)。统计信息过期时,估算行数与实际行数会相差几个数量级,直接导致选错访问路径(如该走索引却走了顺序扫描)。这就是为什么 ANALYZE 如此重要,也是 autovacuum 必须正常工作的原因。
-- 查看某个列的统计信息(需要 superuser 或表所有者)
-- 先建一张有倾斜分布的示例表
CREATE TABLE t_skew (id int, city text);
INSERT INTO t_skew
SELECT g, CASE WHEN g % 100 = 0 THEN 'Beijing' ELSE 'Other' END
FROM generate_series(1, 100000) AS g;
ANALYZE t_skew;
SELECT attname, n_distinct, most_common_vals, most_common_freqs
FROM pg_stats
WHERE tablename = 't_skew'; attname | n_distinct | most_common_vals | most_common_freqs
---------+------------+-----------------------+------------------------
id | -1 | | -- 全唯一,走直方图
city | 2 | {Other,Beijing} | {0.99,0.01} -- MCV 精确记录
(2 rows)n_distinct = -1 表示「唯一值数量等于行数」,即该列全部唯一(如主键);正数表示实际的唯一值个数。
阶段 5:执行器与拉取模型
执行器按拉取模型(pull-based / Volcano model) 工作:从计划树根节点开始,逐层向上层返回结果。
Limit(根节点)
└─ 向子节点请求 1 行 → 拿到后返回给客户端
└─ Sort
└─ 向子节点请求全部行 → 排序 → 逐个返回
└─ Seq Scan on t_user
└─ 逐页读 buffer → 逐行过滤 → 返回符合条件的行每个计划节点都实现两个关键接口:ExecInit()(初始化)与 ExecProcNode()(取出下一行)。这种设计让 LIMIT 可以短路整个查询:
-- 有 LIMIT 时,Seq Scan 不会扫描全表,取够 10 行就停
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM t_skew LIMIT 10; QUERY PLAN
------------------------------------------------------------
Limit (cost=0.00..0.35 rows=10 width=12) (actual time=0.015..0.024 rows=10 loops=1)
Buffers: shared hit=1
-> Seq Scan on t_skew (cost=0.00..3472.00 rows=100000 width=12) (actual time=0.013..0.017 rows=10 loops=1)
Buffers: shared hit=1
Planning Time: 0.083 ms
Execution Time: 0.043 ms
(5 rows)注意 Seq Scan 的 rows=100000 是估算,而 actual ... rows=10 是实际只取了 10 行——这就是拉取模型的短路效果。同时 Planning Time 与 Execution Time 被分开报告,正好对应我们前面讲的「计划」与「执行」两阶段。
详细说明
用 EXPLAIN 观察每个阶段的产物
EXPLAIN 只显示计划,EXPLAIN ANALYZE 会真正执行并显示实际耗时。要观察重写结果,需要用 debug_print_rewritten;要观察计划树,用 debug_print_plan。
-- 三个调试开关,把中间产物打到服务器日志(仅用于调试,生产慎用)
SET debug_print_parse = on; -- 打印语法树
SET debug_print_rewritten = on; -- 打印重写后的查询树
SET debug_print_plan = on; -- 打印最终计划树
SELECT name FROM t_user WHERE id = 1;
SET debug_print_parse = off;
SET debug_print_rewritten = off;
SET debug_print_plan = off;更轻量的做法是只观察计划树的结构与各节点成本:
-- 生成三种格式的计划:text(默认)、json、yaml
EXPLAIN (FORMAT JSON, COSTS true, VERBOSE)
SELECT u.id, u.name, o.amount
FROM t_user u
JOIN t_order o ON o.user_id = u.id
WHERE u.status = 'active';[
{
"Plan": {
"Node Type": "Hash Join",
"Startup Cost": 21.50,
"Total Cost": 58.30,
"Plan Rows": 480,
"Plan Width": 40,
"Hash Cond": "(o.user_id = u.id)",
"Plans": [
{ "Node Type": "Seq Scan", "Relation Name": "t_order", "Plan Rows": 2000 },
{ "Node Type": "Hash", "Plans": [
{ "Node Type": "Seq Scan", "Relation Name": "t_user",
"Filter": "(status = 'active'::text)", "Plan Rows": 240 }
]}
]
}
}
]关键字段解读:
| 字段 | 含义 | 何时需要警惕 |
|---|---|---|
Node Type | 算子类型 | 大表上出现 Seq Scan 且带过滤条件 |
Startup Cost | 返回第一行前的代价 | Sort 节点的启动成本高说明要全量排序 |
Total Cost | 返回全部行的总代价 | 相邻计划对比时使用 |
Plan Rows | 估算输出行数 | 与 actual rows 差异 > 10 倍即统计信息失真 |
Plan Width | 估算每行平均字节数 | 过大说明 SELECT 列过多,可考虑覆盖索引 |
actual time | 实际耗时(毫秒) | 只在 ANALYZE 下出现 |
loops | 该节点执行次数 | loops 很大说明嵌套循环内层被反复执行 |
Rows Removed by Filter | 被过滤掉的行数 | 远大于输出行数说明索引或条件设计有问题 |
-- 最实用的诊断组合:ANALYZE 看实际值,BUFFERS 看 I/O 来源
EXPLAIN (ANALYZE, BUFFERS, VERBOSE, SETTINGS)
SELECT status, count(*) FROM t_user GROUP BY status; QUERY PLAN
---------------------------------------------------------------
HashAggregate (cost=31.40..33.40 rows=200 width=12) (actual time=1.245..1.247 rows=3 loops=1)
Group Key: status
Buffers: shared hit=42
-> Seq Scan on t_user (cost=0.00..24.00 rows=1200 width=6) (actual time=0.011..0.612 rows=1200 loops=1)
Buffers: shared hit=42
Settings: work_mem = '4MB'
Planning Time: 0.198 ms
Execution Time: 1.402 ms
(7 rows)Buffers: shared hit=42 表示全部从共享缓冲区命中,没有发生物理读;若出现 read= 则表示从磁盘读取。
预处理语句与计划缓存
使用扩展查询协议(Extended Query Protocol)时,PREPARE 会让 PostgreSQL 缓存计划。这是与「每次重新解析+计划」不同的路径:
-- 1. 准备语句:此时完成解析、分析、重写
PREPARE find_user (int) AS
SELECT id, name FROM t_user WHERE id = $1;
-- 2. 前 5 次执行:使用「自定义计划」(按实际参数值重新计划,通常更优)
EXECUTE find_user(1);
-- 3. 第 6 次起:如果通用计划(按平均选择性估算)的总成本不高于自定义计划,
-- 就固定使用通用计划,避免每次重复计划的 CPU 开销
EXECUTE find_user(1);
-- 4. 查看缓存中的预处理语句
SELECT name, statement, parameter_types, generic_plans, custom_plans
FROM pg_prepared_statements; name | statement | parameter_types | generic_plans | custom_plans
-----------+--------------------------------------------+-----------------+---------------+--------------
find_user | PREPARE find_user (int) AS ... | {integer} | 0 | 5
(1 row)预处理语句的「双刃剑」
计划缓存节省了每次计划的 CPU,但通用计划用的是平均选择性。如果参数值分布极不均匀(例如查「近 1 天订单」和「近 3 年订单」用同一条 SQL),通用计划可能对其中一类参数极差。这是「PREPARE 后偶发慢查询」的经典原因。缓解办法:拆分成两条 SQL、加 plan_cache_mode = force_custom_plan,或在应用层改用逐次拼参数(代价是失去计划缓存)。
何时会跳过某些阶段
并非所有 SQL 都走完整的四阶段:
| 场景 | 跳过的阶段 | 说明 |
|---|---|---|
SELECT 1 等无表查询 | 跳过重写与计划 | 直接由简单表达式求值返回 |
事务控制语句(BEGIN/COMMIT) | 跳过计划与执行 | 由事务管理器直接处理 |
SET 参数、SHOW | 跳过计划 | 不产生计划树 |
缓存了通用计划的 EXECUTE | 复用计划 | 解析、分析、重写、计划都跳过(若为通用计划) |
COPY 批量导入 | 部分跳过 | 不走常规执行器,直接调存储层 |
| 逻辑复制的应用进程 | 不经过解析 | 直接按 WAL 记录修改数据 |
-- 对比:有表查询才有完整的计划与执行时间
SELECT 1; ?column?
----------
1
(1 row)EXPLAIN (ANALYZE, TIMING off) SELECT 1; QUERY PLAN
--------------------------------------------------
Result (cost=0.00..0.01 rows=1 width=4) (actual rows=1 loops=1)
Planning Time: 0.034 ms
Execution Time: 0.006 ms
(3 rows)各阶段的耗时诊断手段
| 阶段 | 诊断工具 | 观察点 |
|---|---|---|
| 解析 + 分析 + 重写 | log_min_duration_statement、pg_stat_statements | 无法单独拆分,合并在「总耗时」里 |
| 计划 | EXPLAIN 的 Planning Time、auto_explain | 计划耗时 > 执行耗时时优先怀疑统计信息 |
| 执行 | EXPLAIN (ANALYZE, BUFFERS) | 逐节点看 actual time 与 Buffers |
| 整体 | pg_stat_statements | total_exec_time、mean_exec_time、rows |
| 等待 | pg_stat_activity.wait_event | 定位是在等锁、等 I/O 还是等 WAL |
-- pg_stat_statements:按总耗时排序找出最消耗的 SQL(需预先安装扩展)
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
SELECT queryid,
calls,
round(total_exec_time::numeric, 2) AS total_ms,
round(mean_exec_time::numeric, 2) AS mean_ms,
round(100.0 * shared_blks_hit
/ nullif(shared_blks_hit + shared_blks_read, 0), 2) AS hit_pct,
left(query, 60) AS query_preview
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5; queryid | calls | total_ms | mean_ms | hit_pct | query_preview
-----------------+-------+----------+---------+---------+----------------------------
-8123456789012345 | 15230 | 84213.55 | 5.53 | 99.87 | SELECT u.id, u.name FROM ...
1234567890123456 | 8412 | 20114.02 | 2.39 | 98.42 | UPDATE t_order SET status = ...
(2 rows)-- 诊断执行期的等待:这条视图能直接看到 SQL 卡在哪
SELECT pid,
state,
wait_event_type,
wait_event,
now() - query_start AS query_time,
left(query, 50) AS query_preview
FROM pg_stat_activity
WHERE state <> 'idle' AND backend_type = 'client backend'
ORDER BY query_time DESC; pid | state | wait_event_type | wait_event | query_time | query_preview
-------+---------------------+-----------------+--------------+-------------+------------------
18823 | active | Lock | transactionid| 00:02:31.12 | UPDATE t_order SET ...
9021 | active | IO | DataFileRead | 00:00:04.88 | SELECT * FROM t_skew ...
(2 rows)wait_event_type = Lock 且 wait_event = transactionid 表示在等另一个事务释放行锁;IO / DataFileRead 表示确实在读磁盘,属正常等待。
实战示例
示例 1:同一条查询的三种计划形态
目标:通过改变数据分布与索引,观察计划器如何在 Seq Scan、Index Scan、Bitmap Heap Scan 之间取舍。
-- 1. 准备数据:10 万行,status 只有 3 个取值
CREATE TABLE t_order_demo (
id bigserial PRIMARY KEY,
user_id int,
status text,
amount numeric(10,2)
);
INSERT INTO t_order_demo (user_id, status, amount)
SELECT (random() * 1000)::int,
(ARRAY['paid','pending','cancelled'])[1 + (random() * 2)::int],
(random() * 10000)::numeric(10,2)
FROM generate_series(1, 100000);
ANALYZE t_order_demo;-- 2. 场景 A:无索引 + 高选择性谓词 → 顺序扫描
EXPLAIN (ANALYZE, BUFFERS, TIMING off)
SELECT * FROM t_order_demo WHERE status = 'paid'; QUERY PLAN
--------------------------------------------------------------
Seq Scan on t_order_demo (cost=0.00..2084.00 rows=33600 width=27) (actual rows=33421 loops=1)
Filter: (status = 'paid'::text)
Rows Removed by Filter: 66579
Buffers: shared hit=1042-- 3. 建索引后重试:优化器会改走位图索引扫描(返回行数占比太高,不适合普通索引扫描)
CREATE INDEX idx_order_status ON t_order_demo (status);
ANALYZE t_order_demo;
EXPLAIN (ANALYZE, BUFFERS, TIMING off)
SELECT * FROM t_order_demo WHERE status = 'paid'; QUERY PLAN
---------------------------------------------------------------------
Bitmap Heap Scan on t_order_demo (cost=570.00..1560.00 rows=33600 width=27) (actual rows=33421 loops=1)
Recheck Cond: (status = 'paid'::text)
Heap Blocks: exact=1039
-> Bitmap Index Scan on idx_order_status (cost=0.00..561.60 rows=33600 width=0) (actual rows=33421 loops=1)
Index Cond: (status = 'paid'::text)-- 4. 换成高选择性谓词:这次优化器会选择普通索引扫描
EXPLAIN (ANALYZE, BUFFERS, TIMING off)
SELECT * FROM t_order_demo WHERE user_id = 42; QUERY PLAN
---------------------------------------------------------------------------
Index Scan using idx_order_user on t_order_demo (cost=0.42..36.10 rows=98 width=27) (actual rows=94 loops=1)
Index Cond: (user_id = 42)
Buffers: shared hit=12从 Buffers: shared hit=1042 降到 hit=12,物理读的差异就是计划器选择路径的核心依据。
示例 2:统计信息失真导致选错计划
目标:人为制造「统计信息过期」,观察估算行数与实际行数的巨大偏差。
-- 1. 建表并分析,此时统计信息准确
CREATE TABLE t_stale (id int, flag boolean);
INSERT INTO t_stale SELECT g, false FROM generate_series(1, 1000) AS g;
ANALYZE t_stale;
EXPLAIN (ANALYZE, TIMING off)
SELECT * FROM t_stale WHERE flag = true; QUERY PLAN
-----------------------------------------------------
Seq Scan on t_stale (cost=0.00..15.00 rows=1 width=5) (actual rows=0 loops=1)
Filter: (flag = true)
Rows Removed by Filter: 1000
Planning Time: 0.120 ms-- 2. 大量插入 true,但不重新 ANALYZE
INSERT INTO t_stale SELECT g, true FROM generate_series(1001, 50000) AS g;
-- 3. 再查:估算仍是 rows=1,实际是 49000 行 —— 偏差近 5 万倍
EXPLAIN (ANALYZE, TIMING off)
SELECT * FROM t_stale WHERE flag = true; QUERY PLAN
-----------------------------------------------------
Seq Scan on t_stale (cost=0.00..629.00 rows=1 width=5) (actual rows=49000 loops=1)
Filter: (flag = true)
Rows Removed by Filter: 1000
Planning Time: 0.098 ms-- 4. 重新 ANALYZE,统计信息恢复准确
ANALYZE t_stale;
EXPLAIN (ANALYZE, TIMING off)
SELECT * FROM t_stale WHERE flag = true; QUERY PLAN
-----------------------------------------------------
Seq Scan on t_stale (cost=0.00..629.00 rows=49000 width=5) (actual rows=49000 loops=1)
Filter: (flag = true)
Rows Removed by Filter: 1000关键信号:rows=1 vs actual rows=49000。 只要有 10 倍以上的偏差,就应怀疑统计信息过期,或该列的 default_statistics_target 不够(对倾斜列可单独提高)。
-- 针对特定列提高统计精度(会延长 ANALYZE 时间)
ALTER TABLE t_order_demo ALTER COLUMN user_id SET STATISTICS 500;
ANALYZE t_order_demo;示例 3:用 auto_explain 捕获「线上慢查询的真实计划」
EXPLAIN 需要手工执行;线上慢查询发生时不方便复现。auto_explain 扩展能把超过阈值的查询的真实执行计划自动写入日志。
-- 1. 加载扩展(需重启或写入 shared_preload_libraries 后重启)
CREATE EXTENSION IF NOT EXISTS auto_explain;# postgresql.conf
shared_preload_libraries = 'auto_explain'
# 记录执行超过 1 秒的查询计划
auto_explain.log_min_duration = '1s'
auto_explain.log_analyze = on # 打印实际执行统计(有额外开销)
auto_explain.log_buffers = on # 打印缓冲区使用
auto_explain.log_verbose = on
auto_explain.log_nested_statements = on
auto_explain.sample_rate = 1.0 # 采样率,高负载时可降到 0.1# 日志输出示例(每条慢查询对应一段)
LOG: duration: 2451.883 ms plan:
Query Text: SELECT user_id, sum(amount) FROM t_order_demo GROUP BY user_id
HashAggregate (cost=2084.00..2184.00 rows=1000 width=36) (actual time=1203.4..1204.1 rows=1001 loops=1)
Group Key: user_id
Buffers: shared hit=1042
-> Seq Scan on t_order_demo (cost=0.00..2084.00 rows=100000 width=14) (actual time=0.02..812.5 rows=100000 loops=1)结合 auto_explain.log_analyze,即使无法复现线上场景,也能拿到当时真实的节点耗时分布。
注意事项
EXPLAIN ANALYZE会真正执行 SQL:对INSERT/UPDATE/DELETE使用它会修改数据。要查看写操作的执行计划而不落盘,请把语句包在事务里并ROLLBACK。Planning Time也可能很大:连接表多、join_collapse_limit大、统计目标高的查询,计划耗时可观;此时优化方向是减少连接表数或降低搜索阈值。rows与actual rows差异是首要诊断信号:10 倍以内可接受,100 倍以上必须处理统计信息或提高default_statistics_target。enable_seqscan = off不是优化手段:它只是把顺序扫描的代价人为抬高,用于验证「索引是否可用」,不应写入生产配置。- 视图不是性能瓶颈的免罪牌:视图展开后等价于手写 SQL,性能问题要在底层表与索引上解决。
- RLS 与 RULE 会改变执行计划:开启行级安全策略后,所有访问该表的查询都会被注入谓词,索引设计需同步调整。
- 通用计划可能劣化:
PREPARE/参数化查询跑顺后突然变慢,优先检查是否为通用计划。 work_mem不足会导致计划改变:排序/哈希被迫落盘后成本模型会偏向其他路径,EXPLAIN里出现Sort Method: external merge Disk: xxxkB即为证据。LIMIT会显著改变计划选择:加LIMIT后计划器知道只需取少量行,可能主动从Seq Scan切到Index Scan;因此不带LIMIT的EXPLAIN不能代表带LIMIT的真实情况。- 解析与分析的耗时无法单独测量:它们与计划时间一起被计入
Planning Time的相邻区间,只能通过log_min_duration_statement与pg_stat_statements做整体判断。
-- 不推荐:在生产中使用 EXPLAIN ANALYZE 直接跑写操作
-- EXPLAIN ANALYZE DELETE FROM t_order_demo WHERE status = 'pending'; -- 不推荐,会真删-- 推荐:写操作的计划用事务包裹并回滚
BEGIN;
EXPLAIN ANALYZE DELETE FROM t_order_demo WHERE status = 'pending';
ROLLBACK;最佳实践
- 优化顺序固定为:解析/计划 → 执行 → 等待:先用
pg_stat_statements找到高耗 SQL,再用EXPLAIN (ANALYZE, BUFFERS)定位节点,最后用pg_stat_activity.wait_event排除锁与 I/O 等待。 ANALYZE跟着批量写入走:大批量导入后手工执行一次ANALYZE,不要等 autovacuum 的延迟。- 对倾斜列提高
default_statistics_target:全局保持 100,仅对少数热点列用ALTER COLUMN ... SET STATISTICS 500。 - SSD 上把
random_page_cost降到 1.1~1.5:默认值 4.0 是按机械盘标定的,SSD 上会导致优化器过度偏爱顺序扫描。 effective_cache_size设为物理内存的 50%~75%:它直接影响索引扫描的成本估算,设得贴合实际能显著改善计划质量。- 保持
EXPLAIN (ANALYZE, BUFFERS)成为肌肉记忆:BUFFERS能区分「内存命中」与「真实磁盘读」,是判断是否需要加内存/加索引最直接的证据。 - 生产环境按需开启
auto_explain:阈值设为业务可接受的慢查询线(如 1s),sample_rate在高负载下调低,避免日志淹没。 - 参数化查询用扩展协议而非字符串拼接:既避免 SQL 注入,也能复用计划;但要监控通用计划劣化。
- 定期审查
pg_stat_user_tables的seq_scan与idx_scan:大表上高频seq_scan是缺失索引的强信号。
-- 找出「被大量顺序扫描」的大表:通常是缺索引
SELECT relname,
seq_scan,
idx_scan,
n_live_tup,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size
FROM pg_stat_user_tables
WHERE seq_scan > 1000
AND n_live_tup > 100000
ORDER BY seq_scan DESC
LIMIT 10;下一节
继续学习:Postmaster 主进程