Skip to content

필사 모드: Postgres 19 的 pg_plan_advice — 一个长期拒绝提示的项目给出的妥协方案

中文
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.

引言 — "我们不会加入提示"

PostgreSQL 社区里长期悬着这样一句话。项目 Wiki 的 Optimizer hints discussion 页面开篇就是它 — 我们不打算像其他数据库里常见的那样去实现提示。

紧接着列出了六条理由,概括如下。

  1. 让应用代码的可维护性变差 — 把提示塞进查询里,日后就需要大规模重构。
  2. 妨碍升级 — 今天有用的提示,升级之后可能会拖累性能。
  3. 助长糟糕的 DBA 习惯 — 不去找真正的原因,而是贴一个提示了事。
  4. 不适应数据规模的变化 — 表还小的时候正确的提示,表变大之后就错了。
  5. 实际上会让性能变差 — 大多数情况下优化器是对的。
  6. 妨碍规划器本身的改进 — 用提示的人不会把问题报告给项目。

这份清单并不只是固执。它更像是把 Oracle 和 MySQL 里提示实际制造出的问题整理了一遍。但同一个页面又补了一句 — 如果有提案能避开其他提示系统里观察到的问题,我们是感兴趣的。

2026 年 6 月 4 日发布的 PostgreSQL 19 Beta 1 里,这个提案落地了。Robert Haas 编写的 pg_plan_advicepg_stash_advice,两个 contrib 模块。发行说明的说法是"让用户能够稳定并控制规划器的决策"。

本文要看的正是这到底是什么,以及它在上述六条里抹掉了哪些、又没能抹掉哪些。顺带一提,19 的正式发布定在 2026 年 9〜10 月左右,眼下还是 beta。

为什么叫"建议"而不是提示

官方文档 F.30. pg_plan_advice 是这样描述这个模块的 — 用一门叫作"plan advice"的专用迷你语言,把规划器的主要决策描述出来、复现出来、改变掉。用意有两个:把用户判断为好的计划固定下来,以及试验一下规划器认为不是最优的那个计划。

叫 advice 而不是 hint,是有设计上的理由的。有三点和既有的提示不一样。

第一,建议活在 SQL 之外。 Oracle 的提示和 pg_hint_plan 会把提示放进查询文本内部(注释里)。pg_plan_advice 则通过一个叫 pg_plan_advice.advice 的 GUC 给出,或者通过 pg_stash_advice,以查询 ID 为键存进单独的仓库。应用的 SQL 字符串不会被碰。这个设计正对着 Wiki 反对理由第 1 条去的。

第二,它不是替代规划器,而是收窄搜索空间。 这是最重要的差别。照搬文档的原话 — 不管给出什么建议,对那条查询而言,出来的只会是核心规划器本来就会考虑的计划。也就是说,建议不是"用这个计划"这种命令,更接近"从这些候选里挑,把那些排除掉"。规划器认为在语义上会给出错误结果的计划,不管建议怎么说都不会出现。

第三,可以只固定一部分。 可以把整段建议字符串整个塞进去,把整个计划钉死,但也可以只指定必要的那一小块,其余交给规划器。Christophe Pettus 在 Hints, Part 3: Advice, Not Orders(2026-04-21) 里指出,这种克制的用法有余地缓解 Wiki 反对理由第 2 条(妨碍升级)和第 4 条(数据规模)。不过他紧接着补了一句 — 这个工具只是允许克制,并不强制。

建议实际长什么样

抽象地讲没有实感,来看实物。加载这个模块之后,EXPLAIN 会多出一个 PLAN_ADVICE 选项,把规划器当下选中的计划,写回成一段建议字符串。

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

文档里给出的输出是这样的。

Generated Plan Advice:
   JOIN_ORDER(f d)
   HASH_JOIN(d)
   SEQ_SCAN(f d)
   NO_GATHER(f d)

读法很直观 — 按这个顺序连接 f 和 d,把 d 放在内侧做哈希连接,两边都用顺序扫描,没有用并行 Gather。把这段字符串原样喂回去,就能复现同一个计划。

SET pg_plan_advice.advice = 'JOIN_ORDER(f d)';

建议语言的词汇是刻意收窄的。文档定义的标签大致分四类。

  • 扫描方式: SEQ_SCAN(target), TID_SCAN(target), INDEX_SCAN(target index_name), INDEX_ONLY_SCAN(target index_name), BITMAP_HEAP_SCAN(target), DO_NOT_SCAN(target)
  • 连接顺序: JOIN_ORDER(...) — 用括号括起来可以强制或松散地约束连接顺序
  • 连接方式: HASH_JOIN(...), MERGE_JOIN_PLAIN(...), MERGE_JOIN_MATERIALIZE(...), NESTED_LOOP_PLAIN(...), NESTED_LOOP_MEMOIZE(...), NESTED_LOOP_MATERIALIZE(...)
  • 并行化: GATHER(...), GATHER_MERGE(...), NO_GATHER(target)

再加上分区级连接(PARTITIONWISE)和半连接唯一性(SEMIJOIN_UNIQUE / SEMIJOIN_NON_UNIQUE)的控制。目标是按别名指定的,同一个别名出现多次时会像 f#2 这样带上出现序号,还有能指到分区或子计划的扩展语法。

Daniel Westermann 的实操记录(2026-03-13)很好地展示了这个流程。把 100 万行的 t1 和 200 万行的 t2 连接起来,规划器会吐出这样的建议。

JOIN_ORDER(t2 t1) NESTED_LOOP_MEMOIZE(t1) SEQ_SCAN(t2) INDEX_SCAN(t1 public.t1_pkey) NO_GATHER(t1 t2)

原样喂回去,全部标记为 matched,计划也保持不变。反过来只把连接顺序倒过来,给出 JOIN_ORDER(t1 t2),计划就变成了 Merge Join + Sort + Materialize。这是确认建议确实生效的最短实验。

告诉你建议有没有生效的反馈

这一点是我个人在这套设计里最喜欢的部分。提示系统的老毛病是"用了提示,感觉好像被忽略了,但没法确认"。pg_plan_advice 会在每一行建议后面用注释附上判定结果。

INDEX_SCAN(f no_such_index) /* matched, inapplicable, failed */
HASH_JOIN(f) /* matched */
HASH_JOIN(g) /* not matched */
JOIN_ORDER(f g) /* partially matched */

按文档,判定词汇一共六种 — matched(目标在规划过程中被看到过,且可以应用)、partially matched(只看到了一部分,或没能一起看到)、not matched(目标完全没被看到)、inapplicable(比如索引不存在,标签根本无法应用)、conflicting(两条建议互相矛盾)、failed(某条建议已经 matched,但最终计划没有遵循它)。

打开 pg_plan_advice.feedback_warnings 后,建议没有被强制执行时会弹出警告。默认是关闭的。这为什么重要 — 建议会随时间腐坏(模式在变、索引会消失、数据分布会漂移),腐坏的建议被悄悄忽略,和吵吵嚷嚷地报警,在运维层面完全是两回事。

pg_stash_advice — 不改应用代码也能固定计划

pg_plan_advice 是以会话 GUC 的形式给出建议的。拿来做实验没问题,但生产环境里不可能为了固定一条问题查询,就在每个会话里都执行一遍 SET。所以有了 F.33. pg_stash_advice

它的机制很简单。把查询 ID 映射到建议字符串(文档里叫 advice stash)放进动态共享内存,只要正在规划的查询 ID 出现在这个仓库里,对应的建议就会被自动应用。Shaun Thomas 在 pgEdge 的文章(2026-06-05)里给出的用法是这样的。

SELECT pg_create_advice_stash('production_tuning');
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';

pg_stash_advice.stash_name 也可以通过 ALTER DATABASE ... SETALTER ROLE ... SET 来设置,也就是说,实际上可以做到一行应用代码都不改,就把某条查询的计划钉死。这和 SQL Server 的 plan guide、DB2 的计划固定属于同一类运维手法。

有几个前提条件。得放进 shared_preload_libraries 并重启,compute_query_id 也得打开(设成 auto 的话,模块加载时会自动打开)。默认情况下,仓库会以 30 秒为间隔持久化到数据目录下的 pg_stash_advice.tsv,这个间隔可以通过 pg_stash_advice.persist_interval 调整。

Wiki 上的六条反对理由,逐条打分

接下来是有意思的部分。看看上面这套设计,到底在多大程度上抹掉了项目自己写下的这些反对理由。

1. 应用可维护性 — 抹掉了。 建议活在 SQL 之外,查询文本不会被污染。这是明确的改进。

5. 大多数情况下优化器是对的 — 结构性地抹掉了。 建议只能在核心规划器会考虑的那个计划集合里挑选。规划器判断为"会给出错误结果"的计划,就算用建议也拉不出来。也就是说,最坏的情况是"计划慢",而不是"计划错"。这正是它和 Oracle 式提示的根本差别。

6. 妨碍规划器改进 — 部分抹掉了。 得益于反馈注释和 EXPLAIN (PLAN_ADVICE),"规划器选了什么、我强制了什么"会以机器可读的形式留下来。这是附到 bug 报告里的好格式。不过它依然拦不住那些贴上建议就悄悄了事的人。

2. 妨碍升级 / 4. 数据规模 — 只抹掉了一部分。 把建议控制到最低限度,剩下的部分规划器会继续自适应。但一旦把整个计划整段钉死,这两个问题原样又回来了。Pettus 的说法正是这个意思 — 完全固定计划,会把妨碍升级和扩展性的问题重新招回来,因为这个工具只是允许克制,并不强制克制。文档也说了同样的话 — 数据分布变化时规划器能够改变计划,通常是一个特性而不是 bug。

3. 糟糕的 DBA 习惯 — 完全没抹掉。 Pettus 那句话最诚实 — 与其修统计信息的问题,DBA 要是转而抓起 pg_plan_advice,得到的照样是同样糟糕的结果。而他把这归为"不是技术问题,而是文化问题"。这是工具解决不了的那类反对理由。

归纳一下,六条里有两条确凿无疑地被抹掉了,一条抹掉了大半,两条只有在用户克制使用时才会被抹掉,还有一条完全没抹掉。成绩不算差,但也谈不上全胜。

诚实的局限

文档自己承认的东西不少。这部分不能跳过。

控制不到的领域很大。 聚合到底走排序还是走哈希,决定不了。eager aggregation、partitionwise aggregation 这类策略也碰不了。UNIONINTERSECT 这类集合运算的行为同样不在控制范围内。按 depesz 整理的提交说明,这个功能瞄准的是连接方式、扫描类型这类结果,不会去动代价估算或选择度(selectivity)本身。

就算计划不变,代价也会跟着来。 pg_stash_advice 文档的措辞很明确 — 应用计划建议,即便没有改变计划,也会带来明显的(noticeable)性能代价。文档直接说了,只在需要时才用。pgEdge 的文章也解释说,因为建议会在中途打断规划器的循环,一点性能损耗是不可避免的。具体数字两边都没给,所以这里我不会凭空编一个。

安全模型还不成熟。 文档用的词是"unsophisticated"。创建、修改仓库只有超级用户或被授权的角色能做,但任何用户都可以在自己的会话里设置 pg_stash_advice.stash_name,这就意味着仓库内容可能暴露给没有权限的用户。建议字符串里带着索引名和表别名,因此成了一条模式信息泄露的路径。

会占用内存。 仓库放在动态共享内存里。文档给出的建议是,决定要塞多少条建议进去时,要把这一点考虑进去。

而且这是 1.0。 Haas 本人在自己的博客上(在它还没被提交的时候)这样写道 — 这非常明显是 1.0 版本的技术,局限不少,还有相当多的地方仍然相当粗糙。提交发生在 2026 年 3 月 12 日,并进了 beta,但没有理由认为那句自我评价说错了。

最后,depesz 的一句话最能概括这个工具的性格 — 强大,同时也是一把脚枪。意思是,需求一变,留在生产环境里的建议就可能造成伤害。

设计背后的故事 — mechanism, not policy

还有一点值得知道。Haas 刻意把这项工作做得很克制。用他自己的话说 — 我是刻意想把机制和策略分开的。pg_plan_advice 这个核心技术只提供最简单的生成建议的方式(也就是 EXPLAIN)和最简单的给出建议的方式(也就是 pg_plan_advice.advice 设置),但它是可插拔的。

这种分离之所以重要,是因为它下面铺的那层基础设施,可能比 pg_plan_advice 本身活得更久。据 pganalyze 整理,Haas 给 RelOptInfo 加了一个叫 pgs_mask 的位掩码,让扩展能在规划器最早的阶段就施加"这张表不要用顺序扫描"这类约束。既有的 pg_hint_plan 没有这个东西,所以不得不复制超过 2,500 行核心源码,还要操纵全局配置;pganalyze 观察到的是,站在这层基础设施上,那份拷贝的大部分都可以删掉。也就是说,就算你不喜欢 pg_plan_advice 本身,第三方的提示扩展也因此多了一条风险小得多的重新实现路径。

Haas 最初提议的三个模块里,pg_collect_advice 没有出现在 19 文档的 contrib 列表里。真正落地的是两个 — pg_plan_advice(F.30)和 pg_stash_advice(F.33)。

那么,什么时候该用、什么时候不该用

先照搬文档自己的警告 — 因为规划器通常会做出好的决策,推翻它的判断很容易适得其反。只有当约束规划器选择所带来的风险小于收益时,用计划建议才是合适的。

动手之前,先做这些事。

  • 确认 ANALYZE 有在正常跑,统计目标是否足够
  • 如果列之间相关,先试试扩展统计信息(CREATE STATISTICS)
  • 检查 random_page_costeffective_cache_size 这类代价参数是否和实际硬件相符
  • 问自己一句,是不是想用强制连接方式,来掩盖缺索引带来的问题

把这四件事都做过一遍之后,规划器依然反复出错、而这个错误真的会让业务疼的那条特定查询 — 这才是 pg_plan_advice 该出场的地方。用 Pettus 的话说,它不是统计调优的替代品,而是针对那些躲不开的规划器误判的最后手段。

不该用的时候

  • 19 现在还不是 GA。眼下是 beta,正式发布定在 2026 年 9〜10 月左右。现在还不到规划生产上线的时候。
  • 问题出在统计信息而不是规划器本身的时候。大多数情况都属于这一类。
  • 想固定的不是几条查询,而是几十条的时候。到了那个地步,真正出问题的是模式(schema)、统计信息或者硬件,不是这个工具。
  • 数据分布持续变化的表。今天正确的建议,下个月就错了 — Wiki 反对理由第 4 条,这套设计同样没能抹掉。
  • 团队里没有记录"谁在什么时候、为什么设了这条建议"的文化。没人认领的建议,几个月后就会变成没人敢碰的地雷。

要用的话,就这样用

建议尽量降到最低限度。这正是文档自己推荐的方式 — 把建议字符串精简到只控制必要的部分,只用在规划器误判风险高的地方。一旦把整个计划整段复制粘贴进去,Wiki 反对理由第 2 条和第 4 条就原样回来了。而且要打开 feedback_warnings,别让腐坏的建议悄无声息地死掉。

结语

pg_plan_advice 总结成"Postgres 终于接受提示了",有两处会说错。第一,这是约束,不是命令 — 因为它只会从规划器本来就会考虑的计划里挑,就算用错了,最坏的结果也是计划慢,而不是计划错。第二,这是机制,不是策略 — Haas 只打了一个最小的地基,其余交给了扩展。

对项目长期坚持的这份反对清单,这套设计给出的答案大概是过半。SQL 污染和"优化器通常是对的"被结构性地解决了;升级和扩展性问题只有在用户克制使用时才会缓解;糟糕的 DBA 习惯不是工具能解决的问题。这一半是真的改进,另一半仍然是你的责任 — 两句话都是真的。

等 19 正式 GA 之后,把统计信息和代价参数都调过、依然搞不定的那一条查询,用刚好够用的量去用它。这就是这个工具自己要求的用法。

参考资料

현재 단락 (1/96)

PostgreSQL 社区里长期悬着这样一句话。项目 Wiki 的 [Optimizer hints discussion](https://wiki.postgresql.org/wiki/Opti...

작성 글자: 0원문 글자: 9,192작성 단락: 0/96