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 指定规划目标,并报告应用情况 描述、重现和管理关键计划决策

规划开关会影响整个查询

用一张两万行的表看访问方法的变化。关闭并行,让计划中的扫描节点更容易比较:

SQL
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;

按主键查找一行,默认使用索引:

SQL
EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF, BUFFERS OFF)
SELECT payload FROM hint_demo.items AS i WHERE id = 42;
TEXT
Index Scan using items_pkey on items i (actual rows=1.00 loops=1)
  Index Cond: (id = 42)
  Index Searches: 1

在事务内禁用索引和位图扫描,再执行同一查询:

SQL
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;
TEXT
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 使用顺序扫描:

SQL
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 实际编号 保存目标查询的标识,随后写入规则:

SQL
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)');

这里的应用名限定规则的匹配范围,空应用名则会匹配所有应用。

提示表还有一个值得注意的优先级:表中规则优先于查询注释。上面的规则写入后,即使注释要求走索引:

SQL
EXPLAIN (COSTS OFF)
/*+ IndexScan(i items_pkey) */
SELECT payload FROM hint_demo.items AS i WHERE id = 42;

得到的仍是顺序扫描:

TEXT
Seq Scan on items i
  Filter: (id = 42)

因此,排查注释与实际计划不一致的问题时,提示表也是需要检查的位置。

query ID 通常会归一化常量。id = 42 与 id = 43 这类结构相同的查询会共用规则;数据分布不均匀时,不同参数却可能需要不同计划。对象变化和大版本升级也可能改变 query ID,规则维护需要同时关注查询结构与参数分布。

PG19 能描述计划决策,也能反馈应用结果

pg_plan_advice 可以从现有计划生成 advice 字符串,描述扫描方式、连接顺序和并行选择等决策。在同样的表上执行:

SQL
LOAD 'pg_plan_advice';
EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT payload FROM hint_demo.items AS i WHERE id = 42;

输出除了计划,还包含相关决策:

TEXT
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。把这些内容交给后续规划过程,就能重现相应选择;保存时也可以只保留需要稳定的部分。

例如,在当前事务中只要求顺序扫描:

SQL
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;
TEXT
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 指定一个不存在的索引,就能看到这种区别:

SQL
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;
TEXT
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 保存规则:

SQL
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 的差异。

参考资料

中文解读与技术校订:xiongcc。

讨论与补充

在 GitHub 查看讨论

支持作者

感谢支持 PostgreSQL Howto 的长期维护。

微信赞赏码

读者交流群

二维码若已过期,可通过公众号 PostgreSQL 学徒或邮件联系。

PostgreSQL 学徒读者交流群二维码