来源:https://www.pgedge.com/blog/looking-forward-to-postgres-19-query-hints

展望 Postgres 19:查询提示

作者: Shaun Thomas
日期: 2026年6月5日

好吧,世界末日真的来了。《捉鬼敢死队》里的彼得·文克曼一直是对的,我们很快将经历“活人献祭,猫狗同窝,群体性癔症!”大家收拾收拾吧;我们曾经辉煌过。Postgres 19 的功能冻结包含了许多人声称永远不会见光的功能:查询提示。我想“永远别说永远”确实是至理名言。

好吧,严格来说,它们不叫提示。Postgres 社区绝不会如此平庸。相反,Postgres 19 引入了两个新的 contrib 模块:pg_plan_advicepg_stash_advice。你看,这是“计划建议”。完全是另一回事。

如此里程碑式的事件,仅仅描述其特性是不够的。所以,让我们先回顾一下 Postgres 历史上最持久的争论之一。

“永不”简史

Postgres 社区对查询提示的立场,可以说是非常坚定的。关于这个主题的官方维基页面明确表示:

“我们没有兴趣以其他数据库常见的具体方式实现提示。基于‘因为他们有’的提案将不受欢迎。”

说得有道理。维基页面接着列出了提示存在问题的六个坚实理由:

  1. 它们会造成维护噩梦。
  2. 它们会在升级时出错。
  3. 它们阻碍根本原因分析。
  4. 它们扩展性差。
  5. 优化器通常比你想象的更聪明。
  6. 它们实际上会妨碍规划器的改进,因为用户会停止报告错误。

是的,这听起来没错。多年来,这就是讨论的终点。Postgres 不支持提示。去修复你的统计信息吧。下一个话题。

但在幕后,争论从未如此尘埃落定。早在 2010 年底,pgsql-performance 邮件列表上爆发了一场传奇性的讨论,持续了数月,几乎涵盖了所有能想到的观点。它开始得很平淡,只是抱怨 COUNT(*) 查询慢,然后转向与 Oracle 的比较,不知怎么就螺旋式演变成了一场关于 Postgres 是否需要提示的全面生存危机。

罗伯特·哈斯率先发出了真正有力的声音:

“我认为说我们不需要提示是愚蠢的。我们想要提示,或者至少我们中的许多人想要。我们只是希望它们能真正工作,并且不糟糕。”

他进一步指出,Postgres 需要为 DBA 提供一个应对边缘情况的逃生舱口:

“对于那些遇到无法用现有方法修复的 0.1% 查询的人来说,我们应该愿意提供一种让他们不会被解雇的方法。”

“请不要让人们被解雇”这个理由是难以反驳的。

传奇人物汤姆·莱恩也表达了类似的看法:

“我还没见过哪个提示方案不糟糕……我也不是说一定没有。”

这样一来,他实际上为未来留下了一线可能性。

凯文·格里特纳后来指出了反提示论点中一个相当明显的悖论:

“即使是那些表面上最反对提示的人,也被发现曾表示,他们宁愿优化器不把两个逻辑等价的构造视为等同并以相同方式优化,因为他们发现当前的差异‘有助于强制优化器’选择某个计划。这实际上就是实现了提示,只是拒绝记录它们。”

他说错了吗?我们中有多少人为了强制使用索引扫描而关闭过 enable_seqscan?或者在子查询中塞入一个 OFFSET 0 来防止规划器展平它?或者将某个东西包裹在一个物化 CTE 中仅仅是为了创建一个优化屏障?这些都是有实无名的提示,只有经验丰富的专家才能使用粗糙的旋钮。我们只是在假装回避提示。

乔什·伯库斯,一位直言不讳的反提示倡导者,划出了一条清晰的界线:

“任何被编码到实际查询中的提示,此后都会成为巨大的维护和升级难题。”

但即使是他,也不反对所有形式的规划器覆盖。他的立场是反对 Oracle 那种将其嵌入 SQL 注释中的模型。他偏爱的优先级是:首先调整 GUC 成本参数,然后是数据库对象的成本参数,接着是新的统计元数据,而查询提示仅作为绝望的最后手段。这实际上是一个合理的行动计划,但 Postgres 一直缺乏这最后的选项。

第三方的 pg_hint_plan 扩展最终填补了许多用户的空白,它借用了 Oracle 基于注释的提示语法。它或多或少能工作,而将提示添加到核心代码的紧迫性也就悄然消散了。至少我们是这么认为的。

换个名字

那么,是什么改变了?首先,构建这些模块的人不是别人,正是罗伯特·哈斯。在那场史诗般的讨论过去十五年后,他成了 pg_plan_advicepg_stash_advice 的作者。随便你怎么说,但这人玩的是长线游戏。

哈斯没有简单地将 Oracle 风格的提示强加给 Postgres 就完事了。他从一开始就深陷争议之中,了解所有的痛点。因此,他一直在考虑如何真正解决社区过去二十年来提出的每一个反对意见。

伯库斯坚持认为提示永远不应存在于查询文本中。因此 pg_plan_advice 将“建议”完全置于 SQL 之外。建议通过 GUC(pg_plan_advice.advice)设置,或存储在一个由查询 ID 索引的独立存储区中。查询本身不受任何额外杂乱内容的干扰。

社区担心提示会完全取代规划器的判断,产生看似“正确”但实际上可能带来灾难性后果的计划。因此 pg_plan_advice 的工作原理是约束规划器的搜索空间,而不是取代它。文档明确指出:建议“只能产生核心规划器认为可行的计划”。建议只是将规划器推向它已经考虑过的某个计划。

那么,当建议错误或过时时会发生什么?规划器不会无声无息地产生垃圾或抛出错误,而是将受影响的节点标记为“已禁用”,并在剩余约束条件下回退到它认为的最佳计划。错误的建议会优雅地降级。

也许最好的一点是,系统会生成自己的建议。无需死记硬背语法参考并从头手工制作建议字符串。可以询问规划器它正在做什么,它会以一种同样可用作语法的格式回应:

CREATE TABLE my_fact (
  id      BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
  dim_id  BIGINT NOT NULL
);
CREATE TABLE my_dim (
  id     BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
  stuff  TEXT
);
CREATE INDEX idx_fact_dim_id ON my_fact (dim_id);

EXPLAIN (COSTS OFF, PLAN_ADVICE)
SELECT * FROM my_fact f
  JOIN my_dim d ON f.dim_id = d.id;

            QUERY PLAN            
----------------------------------
 Hash Join
   Hash Cond: (f.dim_id = d.id)
   ->  Seq Scan on my_fact f
   ->  Hash
         ->  Seq Scan on my_dim d
 Generated Plan Advice:
   JOIN_ORDER(f d)
   HASH_JOIN(d)
   SEQ_SCAN(f d)
   NO_GATHER(f d)

规划器直接交出了能复现其当前计划的建议字符串。从这里开始,只需根据需要进行调整即可。而这一切,只需将 pg_plan_advice 添加到 shared_preload_libraries 就能获得。

低语轻言

对于社区抵制了几十年的东西,这种“建议语言”出奇地富有表现力。有几个类别,每个都针对查询规划的不同方面。

扫描方法控制如何访问单个表:

SET pg_plan_advice.advice = 'INDEX_SCAN(f idx_fact_dim_id)';

这告诉规划器为别名 f 使用特定的索引。文档列出了其他几种方便的扫描类型。想要完全从最终计划中排除一个表?甚至有 DO_NOT_SCAN 可用。

连接顺序是事情变得有趣的地方。JOIN_ORDER 标签支持嵌套语法,使用圆括号表示严格顺序,使用花括号表示灵活分组:

-- 严格顺序:先将 b 与 c 连接,然后将 a 与该结果连接,最后连接 d
SET pg_plan_advice.advice = 'JOIN_ORDER(a (b c) d)';

-- 灵活顺序:b 和 c 可以按任意顺序连接,但它们必须在 a 和 d 之间
SET pg_plan_advice.advice = 'JOIN_ORDER(a {b c} d)';

这里的语法很棒,提供了所需顺序和可选顺序之间的便捷区分。这使得可以锁定连接顺序的关键部分,同时留给规划器优化其余部分的空间。在某些情况下,DBA 可能比规划器更了解情况的论点,在连接顺序方面绝对是可能的,而现在它是可调的。

连接方法控制规划器为每个连接使用的算法,有一系列选项。这里有一个微妙但重要的细节:指定一个方法意味着目标应该出现在该连接类型的内侧。因此 HASH_JOIN(d) 意味着“从 d 构建哈希表”。文档有助于消除歧义,而且这仍然非常新,有待进一步澄清。

想要指定多个关系应该位于单个连接的内侧?将它们括在括号中:

-- d1 和 d2 分别位于单独的哈希连接的内侧
SET pg_plan_advice.advice = 'HASH_JOIN(d1 d2)';

-- d1 和 d2 一起位于一个哈希连接的内侧
SET pg_plan_advice.advice = 'HASH_JOIN((d1 d2))';

并行查询控制通过 GATHERGATHER_MERGENO_GATHER 来完成,用于控制是否以及在哪里进行并行执行。

我们能把所有这些组合起来吗?当然:

SET pg_plan_advice.advice =
    'JOIN_ORDER(f d1 d2) HASH_JOIN(d1 d2) SEQ_SCAN(f) INDEX_SCAN(d1 idx_d1_pk)';

这是一个单一的字符串,同时控制连接顺序、连接方法和扫描策略。请记住,如果这份建议的任何部分无法被采纳,规划器会优雅地降级,而不是着火、烧毁然后掉进沼泽。

存储以备后用

直接使用 pg_plan_advice.advice 非常适合临时调优和实验,但生产环境呢?在每个查询前设置一个 GUC 相当不方便。这就是 pg_stash_advice 登场的地方。

一旦我们将其添加到 shared_preload_libraries,还需要安装它:

CREATE EXTENSION pg_stash_advice;

该系统的工作原理是建立一个“建议存储区”,作为存储在共享内存中的、由查询 ID 索引的查询到建议映射的命名集合。创建一个存储区,用建议字符串填充它,并告诉会话在规划期间查阅它:

-- 创建一个存储区
SELECT pg_create_advice_stash('production_tuning');

-- 从 EXPLAIN VERBOSE 或 pg_stat_statements 获取查询 ID
EXPLAIN (VERBOSE, PLAN_ADVICE)
SELECT * FROM my_fact f
  JOIN my_dim d ON f.dim_id = d.id;

-- 为那个查询存储建议
SELECT pg_set_stashed_advice(
    'production_tuning',
    5424487836266966148,
    'INDEX_SCAN(f idx_fact_dim_id) NESTED_LOOP_PLAIN(f)'
);

-- 为此会话激活存储区
SET pg_stash_advice.stash_name = 'production_tuning';

从此刻起,每次规划器遇到这个特定的查询模式时,它会自动应用存储的建议。而且由于它没有嵌入查询本身,我们可以随时更改它。不用担心查询 ID 会改变,它本质上是查询(减去任何动态参数)的哈希值;建议应该普遍适用。

存储区可以按会话、按角色或按数据库限定作用域:

-- 此数据库中的每个会话都使用该存储区
ALTER DATABASE mydb SET pg_stash_advice.stash_name = 'production_tuning';

-- 只有此角色使用该存储区
ALTER ROLE reporting_user SET pg_stash_advice.stash_name = 'reporting_tuning';

默认情况下,存储区通过 pg_stash_advice.persist = on 持久化到磁盘,并带有可配置的写入间隔。这意味着它们在重启后仍然存在,允许 DBA 检查其内容、更新单个条目,或在不再需要时删除整个存储区。

这部分解决了生产 DBA 的实际需求。在遇到有问题的查询且无法更改应用程序代码的情况下,有时能够引导规划器至关重要。所以创建一个存储区,放入正确的建议,问题就消失了,而无需触及一行 SQL。当统计信息、应用程序或规划器本身改进后,只需在数据库端删除计划建议即可。应用程序或最终用户无需知道。

建议存储区是最后的手段,并且它被设计为临时性的。

信任但要验证

更巧妙的设计决策之一是反馈机制。EXPLAIN 清晰地说明了每条建议的结果。考虑使用之前的维表和事实表进行此设置:

SET pg_plan_advice.advice = $$
  INDEX_SCAN(f idx_fact_dim_id)
  NESTED_LOOP_PLAIN(f)
  INDEX_SCAN(d no_such_index)
  SEQ_SCAN(z)
  SEQ_SCAN(f)
$$;

EXPLAIN (COSTS OFF, VERBOSE, PLAN_ADVICE)
SELECT * FROM my_fact f
  JOIN my_dim d ON f.dim_id = d.id;

EXPLAIN 输出提供了关于我们建议的无懈可击的反馈:

Supplied Plan Advice:
  INDEX_SCAN(f idx_fact_dim_id) /* matched */
  INDEX_SCAN(d no_such_index) /* matched, inapplicable, failed */
  SEQ_SCAN(z) /* not matched */
  SEQ_SCAN(f) /* matched, conflicting, failed */
  NESTED_LOOP_PLAIN(f) /* matched */

我们可以从中看出几点:

  • matched 表示找到了目标并且建议已被应用。
  • not matched 表示查询中根本未找到目标。
  • inapplicable 表示建议无法被采纳(例如引用了不存在的索引)。
  • failed 表示最终计划不符合建议。
  • conflicting 表示两条建议相互矛盾。

建议支持美元符 $$ 引用语法,使其更方便且更具可读性。

也可以设置 pg_plan_advice.feedback_warnings = true,以便在查询执行期间将这些信息作为 WARNING 消息获取。这有助于在生产日志中发现过时的建议,防患于未然。

那么,它不能做什么?目前,无法控制聚合是使用排序还是哈希。也无法为 UNIONINTERSECT 操作提供策略。规划器也可以基于正确性理由自由拒绝建议。应用建议也会带来很小的性能损失,因为它必然会中断规划器循环,即使它不会改变最终的计划。文档明确警告要谨慎使用,原因就在于此。

它是一把手术刀,请像使用手术刀一样使用它。

长线游戏

这个故事有种深深的满足感。不知怎么的,提示从一个我们可能永远不会有的被接受的功能,变成了看起来最好的实现之一。开发者们还以一种合理解决了所有关于提示的主要抱怨的方式做到了这一点:

  • 建议存在于 SQL 外部,以避免永久嵌入。
  • 建议引导规划器,而不是取代它。
  • 建议会优雅地降级,并提供详细反馈,以避免最坏情况的计划。
  • 规划器生成自己的建议字符串,用于自我记录,便于更简单的调整。

社区表示,如果有人能避免其他系统中观察到的问题,他们会考虑提示。用了十五年,但最终有人做到了。无论是固执还是极高的标准,这都符合 Postgres 之道™。

这会结束争论吗?也许不会。总会有人坚持认为规划器应该自主处理一切,而且他们在大多数时候并没有错。正确的做法仍然是修复统计信息、调整成本参数,并利用 CREATE STATISTICS 等功能为规划器提供更好的信息。计划建议是用于在完成所有这些之后,规划器仍然坚持采用糟糕计划的情况。

对于这些情况,我们终于有了一个一流的、核心支持的、设计良好的逃生舱口。不再需要像野蛮人一样切换 enable_seqscan。不再需要将 OFFSET 0 塞入子查询并祈祷。不再需要依赖第三方的扩展,粗暴地从不完全不同的引擎中引入可疑的语法。

Postgres 社区花了二十年时间说他们永远不会添加查询提示,最终却添加了“建议”。众所周知,技术上正确是最好的正确。现在去享受你的提示吧,你肯定等得够久了!

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐