PostgreSQL 优化器提示:怎样干预和稳定执行计划
本页目录
同一条 SQL,昨天还在走索引,今天变成了全表扫描;一次大版本升级后,连接顺序发生变化,原来很快的查询突然慢下来。碰到这种计划变化,DBA 通常会先查统计信息、数据分布和参数。在排查过程中,也需要验证一个判断:换一种执行计划,查询究竟会不会更好?
优化器提示让我们可以干预规划器的选择。它可以帮助比较方案,也可以暂时稳定已经确认合适的计划。难点在于限制的范围:控制哪个决策、影响哪些查询,以及数据变化后是否还需要保留。
Michael Banck 在 PGDay Lowlands 2026 的《Optimizer Hints in PostgreSQL》中,比较了 PostgreSQL 的规划参数、pg_hint_plan,以及 PG19 的 pg_plan_advice、pg_stash_advice。本文重点讨论这些方式怎样改变规划行为,以及实际管理规则时容易忽略的问题。
干预计划能帮助判断什么
PostgreSQL 根据估计的行数、访问代价和可用路径选择执行计划。统计信息过期、条件之间存在关联、连接结果估算失准,都可能影响选择。
例如,规划器以为外层只会返回 10 行,选择 Nested Loop,每一行去内层做一次索引查询。实际返回 100 万行,就可能发生 100 万次内层访问。如果换成 Hash Join 明显改善,就有了进一步排查的方向:外层行数为什么估得这么低?哪些条件或统计信息影响了这个估算?
这里改变连接方法的价值,是把“估算可能有问题”变成一个可以比较的判断。确认原因后,可以修正统计信息、索引或 SQL;修复需要时间时,也可以暂时保留这条规则。
三种干预方式的范围不同:
| 方式 | 作用范围 | 常见用途 |
|---|---|---|
enable_*、成本参数 |
当前规划过程中的一类方法 | 比较替代计划,定位问题 |
pg_hint_plan |
查询中指定的表、连接或索引 | 精确控制某个选择,也可外置规则 |
| PG19 plan advice | 指定规划目标,并报告应用情况 | 描述、重现和管理关键计划决策 |
规划开关会影响整个查询
用一张两万行的表看访问方法的变化。关闭并行,让计划中的扫描节点更容易比较:
SET max_parallel_workers_per_gather = 0;
CREATE SCHEMA hint_demo;
CREATE TABLE hint_demo.items (
id integer PRIMARY KEY,
payload text NOT NULL
);
INSERT INTO hint_demo.items
SELECT n, 'row-' || lpad(n::text, 5, '0')
FROM generate_series(1, 20000) AS n;
ANALYZE hint_demo.items;
按主键查找一行,默认使用索引:
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
SELECT payload FROM hint_demo.items AS i WHERE id = 42;
Index Scan using items_pkey on items i (actual rows=1.00 loops=1)
Index Cond: (id = 42)
Index Searches: 1
在事务内禁用索引和位图扫描,再执行同一查询:
BEGIN;
SET LOCAL enable_indexscan = off;
SET LOCAL enable_bitmapscan = off;
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
SELECT payload FROM hint_demo.items AS i WHERE id = 42;
ROLLBACK;
Seq Scan on items i (actual rows=1.00 loops=1)
Filter: (id = 42)
Rows Removed by Filter: 19999
返回的仍是一行,读取方式却变成扫描两万行、过滤掉 19999 行。对照时除了看计划是否改变,还要看读取了多少数据、执行了多少次内层访问。
SET LOCAL 在事务结束时恢复,很适合临时比较替代方案。不过,它作用于这一轮规划中的同类节点。复杂查询里,关闭 Nested Loop 可能同时改变多个连接,很难只针对其中一个。
这些开关控制的是规划器对方法的选择倾向。表没有索引时,即使关闭顺序扫描,仍需用顺序扫描完成查询。PG18 会在这类节点上显示 Disabled: true。
pg_hint_plan 可以针对具体的表和连接
加载与 PostgreSQL 主版本对应的 pg_hint_plan 后,可以在查询注释中指定目标。例如,只让别名 i 使用顺序扫描:
LOAD 'pg_hint_plan';
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
/*+ SeqScan(i) */
SELECT payload FROM hint_demo.items AS i WHERE id = 42;
这次同样过滤了 19999 行,但规则只约束指定的表访问,其他表仍可使用索引。目标按查询中的名字匹配:SQL 写了 AS i,提示就用 i;写成 SeqScan(items) 会匹配失败。
连接提示还需区分方法、顺序与估算:
| 提示示例 | 作用 |
|---|---|
IndexScan(i items_pkey) |
指定表的索引访问 |
HashJoin(a b) |
指定关系集合的连接方法 |
Leading((a b)) |
指定连接顺序及外内侧 |
Rows(a b #1000) |
把该连接的估计行数设为 1000 |
DisableIndex(i items_pkey) |
将指定索引排除出候选,适用于 PG18+ 分支 |
Rows 尤其适合分析估算问题。它改变规划器的行数估算,查询实际返回的行数不变。如果把估算调高后,连接方式和执行表现都改善,就值得继续检查原来的基数估算为何偏低。
排查提示是否生效,可以启用 pg_hint_plan.debug_print 和相应消息级别,查看 used、not used 和 error hints,并对照最终计划中的节点。
提示表把规则移到 SQL 之外
应用 SQL 不方便修改时,pg_hint_plan 可以从 hint_plan.hints 查找规则。每条规则包含 query ID、应用名和提示文本。
query ID 是 PostgreSQL 根据分析后的查询结构计算的标识。启用 compute_query_id 后,可从 EXPLAIN (VERBOSE) 或 pg_stat_statements 获取。在 psql 中,用 \set query_id 实际编号 保存目标查询的标识,随后写入规则:
CREATE EXTENSION pg_hint_plan;
SET compute_query_id = on;
SET application_name = 'howto110';
SET pg_hint_plan.enable_hint_table = on;
INSERT INTO hint_plan.hints(query_id, application_name, hints)
VALUES (:query_id, 'howto110', 'SeqScan(i)');
这里的应用名限定规则的匹配范围,空应用名则会匹配所有应用。
提示表还有一个值得注意的优先级:表中规则优先于查询注释。上面的规则写入后,即使注释要求走索引:
EXPLAIN (COSTS OFF)
/*+ IndexScan(i items_pkey) */
SELECT payload FROM hint_demo.items AS i WHERE id = 42;
得到的仍是顺序扫描:
Seq Scan on items i
Filter: (id = 42)
因此,排查注释与实际计划不一致的问题时,提示表也是需要检查的位置。
query ID 通常会归一化常量。id = 42 与 id = 43 这类结构相同的查询会共用规则;数据分布不均匀时,不同参数却可能需要不同计划。对象变化和大版本升级也可能改变 query ID,规则维护需要同时关注查询结构与参数分布。
PG19 能描述计划决策,也能反馈应用结果
pg_plan_advice 可以从现有计划生成 advice 字符串,描述扫描方式、连接顺序和并行选择等决策。在同样的表上执行:
LOAD 'pg_plan_advice';
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT payload FROM hint_demo.items AS i WHERE id = 42;
输出除了计划,还包含相关决策:
Index Scan using items_pkey on items i
Index Cond: (id = 42)
Generated Plan Advice:
INDEX_SCAN(i hint_demo.items_pkey)
NO_GATHER(i)
INDEX_SCAN 指定表的索引访问,NO_GATHER 限制相关目标使用 Gather 或 Gather Merge。把这些内容交给后续规划过程,就能重现相应选择;保存时也可以只保留需要稳定的部分。
例如,在当前事务中只要求顺序扫描:
BEGIN;
SET LOCAL pg_plan_advice.advice = 'SEQ_SCAN(i)';
EXPLAIN (COSTS OFF)
SELECT payload FROM hint_demo.items AS i WHERE id = 42;
ROLLBACK;
Seq Scan on items i
Filter: (id = 42)
Supplied Plan Advice:
SEQ_SCAN(i) /* matched */
Supplied Plan Advice 把输入规则和应用反馈一起放进 EXPLAIN。排查计划的人可以直接看到,这次扫描受到哪条规则影响。
plan advice 的目标语义也更具体。例如,pg_hint_plan 的 HashJoin(a b) 指定一个关系集合的连接方法;plan advice 的 HASH_JOIN(b) 要求 b 位于 Hash Join 的内侧,连接顺序可配合 JOIN_ORDER(a b) 指定。
matched 表示匹配到了什么
提示可以找到目标,却无法完成要求。给 i 指定一个不存在的索引,就能看到这种区别:
BEGIN;
SET LOCAL pg_plan_advice.advice = 'INDEX_SCAN(i hint_demo.missing_idx)';
EXPLAIN (COSTS OFF)
SELECT payload FROM hint_demo.items AS i WHERE id = 42;
ROLLBACK;
Index Scan using items_pkey on items i
Index Cond: (id = 42)
Supplied Plan Advice:
INDEX_SCAN(i hint_demo.missing_idx) /* matched, inapplicable, failed */
这里匹配到了表 i,但 missing_idx 不存在,最终仍使用可用的主键索引。这几个反馈分别说明了目标匹配、要求能否应用、最终是否遵守要求:
| 反馈 | 含义 |
|---|---|
matched |
找到了全部指定目标 |
partially matched |
只匹配到部分目标,或未以所需组合出现 |
not matched |
没有找到相应目标 |
inapplicable |
找到了目标,但要求无法应用 |
conflicting |
多条要求互相冲突 |
failed |
最终计划没有遵守已匹配的要求 |
这些标记可以组合出现,所以读反馈时要看完整一条。advice 限制的是规划器考虑的候选路径,规划器仍然负责生成合法的执行计划;最终节点与执行表现,才是比较方案的依据。
按查询保存 advice
pg_plan_advice.advice 是会话配置。要让后续连接按查询自动查找规则,可以用 pg_stash_advice:它把 query ID 与 advice 字符串保存在命名的 stash 中,供规划时读取。
实例通过 shared_preload_libraries 预加载 pg_plan_advice,pg_stash_advice 后,创建 stash,并用当前实例中目标查询的 query_id 保存规则:
CREATE EXTENSION pg_stash_advice;
SELECT pg_create_advice_stash('howto110');
SELECT pg_set_stashed_advice('howto110', :query_id, 'SEQ_SCAN(i)');
SET pg_stash_advice.stash_name = 'howto110';
EXPLAIN (COSTS OFF)
SELECT payload FROM hint_demo.items AS i WHERE id = 42;
查询会采用前面的顺序扫描规则。stash 跨连接共享;新会话选择 howto110 后即可使用,也可以把 stash 名配置到指定角色或数据库。开启持久化后,后台 worker 负责保存内容,供实例重启后恢复。
提示表和 stash 都在规划阶段起作用。连接池或预备语句已经缓存了计划时,需要等到重新规划,新增规则才会参与选择。这也是配置已经写入、应用中的计划却暂时没变的一个排查方向。
规则值得保留多久
比较替代计划时,先保存默认计划中的估计与实际行数、循环次数、Buffers 和代表性参数。随后只改变关键决策,观察是哪一部分工作量发生了变化。单独比较运行时间,很容易漏掉缓存、返回比例和参数差异的影响。
如果提示修正了一个错误的连接选择,就继续追查行数估算;如果强制索引后读取了更多页面,就检查返回比例和访问方式。把原因处理掉以后,通常也就有了撤销规则的依据。
确实需要长期保留的规则,应记录适用查询、应用范围、关键计划节点和重新评估的条件。数据分布、索引和版本变化后,重新比较代表性参数。只约束有问题的决策,其他选择仍交给优化器,维护起来更容易。
进一步追查计划变化,可结合 PostgreSQL 性能排查 中的规划、通用计划和分区例子;EXPLAIN ANALYZE 的时间含义见 actual time 与 Execution Time 的差异。
参考资料
- Michael Banck:Optimizer Hints in PostgreSQL,PGDay Lowlands 2026,2026-09-10。
- PostgreSQL 18:查询规划参数、PG18 规划器变化。
- pg_hint_plan:PG18 分支文档、提示的目标与限制、提示表、主版本对应的发行版。
- PostgreSQL 19:pg_plan_advice、pg_stash_advice、pg_stat_statements 中的 query ID 说明。
- PostgreSQL:19 RC1 公告,2026-10-15。
中文解读与技术校订:xiongcc。