Postgres 19 终于有了"查询提示"——但它们不叫 Hint

2026-06-05 18 预计阅读时间:1 分钟
来源:postgr.es AI 摘要 原文链接

免责声明:本文为 AI 摘要整理,建议结合原文阅读。摘要可能省略上下文、版本差异或边界条件,不作为官方说明。

预计阅读时间:11 分钟

Postgres 社区花了二十年说"绝不",然后加了一个功能,和 hint 做的事一样,只是换了个名字。Postgres 19 的 feature freeze 包含两个新的 contrib 模块:pg_plan_advicepg_stash_advice。官方说法是——这叫"plan advice",不是 hint。技术上正确,是最好的正确。

这件事值得好好聊聊,不只是因为功能本身,更因为它背后的漫长争论和最终的设计选择。

二十年"绝不"简史

Postgres 官方 wiki 曾经写得非常直白:

We are not interested in implementing hints in the exact ways they are commonly implemented on other databases. Proposals based on "because they've got them" will not be welcomed.

wiki 还列了六条反对理由:hint 制造维护噩梦、升级后失效、阻碍根因分析、扩展性差、优化器通常比你聪明、hint 反而拖慢优化器改进(因为用户不再报 bug)。多年来,这就是终局讨论。Postgres 不做 hint,去修你的统计信息,下一个话题。

但争论从未真正平息。2010 年底,pgsql-performance 邮件列表爆发了一场传奇级长帖,持续数月,从一条慢查询抱怨开始,拐进 Oracle 对比,最终演成一场关于 Postgres 是否需要 hint 的存在主义危机。Robert Haas 当时掷出关键论点:DBA 需要逃生舱,别让人因为优化器选了烂计划而被开除。Tom Lane 也留了门缝。Kevin Grittner 指出一个悖论——反对 hint 的人自己却在用 enable_indexscanOFFSET 0、物化 CTE 这些东西当 hint 用,只是名字不叫 hint。

Josh Berkus 作为反 hint 阵营的旗帜人物,划了一条线:他反对的是 Oracle 那种把 hint 嵌在 SQL 注释里的做法。他理想的优先级是 GUC 成本参数 → 数据库对象上的成本参数 → 新统计元数据 → 查询 hint 作为绝望的最后手段。合理,但 Postgres 一直缺最后那一级。

第三方扩展 pg_hint_plan 填了这个空,借用了 Oracle 的注释语法,凑合能用,紧迫感随之消散。

为什么这次不同

十五年后,当年在长帖里掷出第一拳的 Robert Haas,成了这两个模块的作者。他从头参与了争议,知道每一条反对理由的痛点,所以设计上逐条回应:

hint 不该嵌在 SQL 里。 pg_stash_advice 把 advice 完全放在 SQL 之外——通过 GUC 设置,或存储在以 query ID 为键的独立 stash 里。查询文本本身不受污染。

hint 不该替代优化器判断。 advice 只约束优化器的搜索空间,不替代它。文档明确写:advice "can only produce plans the core planner considers viable"。它只把优化器推向一个它已经考虑过的方案。

hint 过时了怎么办。 优化器不会静默产出垃圾计划或报错,而是把受影响的节点标记为 Ignored,回退到剩余约束下的最优方案。坏 advice 优雅降级。

优化器能自己生成 advice。 不需要手写语法,加 GEN_PLAN_ADVICEEXPLAIN,优化器直接输出能复现当前计划的 advice 字符串:

EXPLAIN (GEN_PLAN_ADVICE) 
SELECT * FROM fact f 
JOIN dimension d ON f.dim_id = d.id 
WHERE d.category = 'electronics';

输出类似:

Plan Advice:
  $advice$Scan(f index_dim_id) JoinMethod(f d HashJoin)$advice$

拿到这个字符串,按需调整即可。这是自文档化的——优化器告诉你它在干什么,你微调,而不是从零手写。

Advice 语法速览

advice 语言比想象中丰富,分几个类别:

Scan advice 控制单表访问方式:

Scan(f index_dim_id)

告诉优化器对别名 f 使用指定索引。文档还列了其他 scan 类型,甚至有 Exclude 把表从最终计划中排除。

JoinOrder advice 是亮点,支持嵌套语法——圆括号表示严格顺序,花括号表示灵活分组:

JoinOrder({d f})

花括号里顺序灵活,圆括号里顺序固定。可以锁定关键部分,留空间让优化器优化其余部分。

JoinMethod advice 控制每个 join 的算法,指定方法隐含目标应出现在该 join 类型的内侧:

JoinMethod(f d HashJoin)

意思是"用 d 建 hash 表"。多个关系在内侧?用圆括号包裹:

JoinMethod(f (d a) HashJoin)

Parallel advice 提供 ParallelNoParallelParallelLeader 控制并行执行位置和是否启用。

组合使用:

Scan(f index_dim_id) JoinOrder({d f}) JoinMethod(f d HashJoin)

一条字符串同时控制 join 顺序、join 方法和扫描策略。任何部分无法执行时,优化器优雅降级而非崩溃。

实操:从诊断到 stash

下面是一个完整的工作流,从发现问题到生产环境修复,不改一行应用 SQL。

第一步:诊断慢查询,获取 advice

-- 加载模块
LOAD 'pg_plan_advice';

-- 先看优化器自己选了什么计划,同时生成 advice
EXPLAIN (GEN_PLAN_ADVICE, ANALYZE, BUFFERS)
SELECT o.order_id, c.name, p.product_name
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN products p ON o.product_id = p.id
WHERE c.region = 'APAC'
  AND o.status = 'shipped';

假设输出显示优化器对 orders 做了顺序扫描,而你知道 orders(status) 索引更合适。EXPLAIN 输出的 advice 字符串可能是:

$advice$Scan(o seq) JoinOrder({c o p}) JoinMethod(o c HashJoin)$advice$

第二步:手动调整 advice

Scan(o seq) 改成 Scan(o index_orders_status),其余保持:

SET plan_advice = $advice$Scan(o index_orders_status) JoinOrder({c o p}) JoinMethod(o c HashJoin)$advice$
;

-- 验证 advice 效果
EXPLAIN (ANALYZE, BUFFERS, VERBOSE)
SELECT o.order_id, c.name, p.product_name
FROM orders o
JOIN customers c ON o.customer_id = c.id
JOIN products p ON o.product_id = p.id
WHERE c.region = 'APAC'
  AND o.status = 'shipped';

第三步:检查 advice 反馈

pg_plan_advice 提供详细反馈,每条 advice 标注结果状态:

-- 开启反馈日志,运行时在日志中看到每条 advice 的状态
SET plan_advice_feedback = 'log';

-- 状态含义:
-- Applied   → 目标找到,advice 生效
-- Ignored   → 目标未在查询中出现,或 advice 无法执行(如引用不存在的索引)
-- Rejected  → 最终计划不符合 advice
-- Conflicts → 两条 advice 互相矛盾

第四步:生产部署——用 stash 替代逐查询设 GUC

逐查询设 GUC 只适合调试。生产环境需要 stash:

-- 加载 stash 模块
LOAD 'pg_stash_advice';

-- 创建一个 advice stash
SELECT pg_create_advice_stash('prod_tuning');

-- 获取目标查询的 query ID(去掉动态参数后的哈希,advice 通用适用)
SELECT queryid, query 
FROM pg_stat_statements 
WHERE query LIKE '%APAC%shipped%';

-- 假设 queryid = 1234567890,把 advice 存入 stash
SELECT pg_add_advice('prod_tuning', 1234567890, 
  $advice$Scan(o index_orders_status) JoinOrder({c o p}) JoinMethod(o c HashJoin)$advice$
);

-- 让当前会话使用这个 stash
SET plan_advice_stash = 'prod_tuning';

从现在起,每次优化器遇到这个查询模式,自动应用 stash 中的 advice。不改 SQL,不改应用代码。

第五步:作用域和持久化

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

-- 限定到特定角色
SELECT pg_set_advice_stash_scope('prod_tuning', 'role', 'api_service');

-- 限定到特定数据库
SELECT pg_set_advice_stash_scope('prod_tuning', 'database', 'app_db');

stash 默认持久化到磁盘(通过 pg_stash_advice.auto_dump),可配置写入间隔,重启后不丢失:

-- 配置自动持久化间隔(毫秒)
ALTER SYSTEM SET pg_stash_advice.auto_dump_interval = 30000;
SELECT pg_reload_conf();

第六步:问题解决后,清理 stash

-- 删除单条 advice
SELECT pg_remove_advice('prod_tuning', 1234567890);

-- 或整个 stash
SELECT pg_drop_advice_stash('prod_tuning');

统计信息改善、优化器升级后计划变好,直接删 advice,应用完全无感。

边界与风险

当前 advice 不能控制聚合使用排序还是哈希,也不能指定 GatherGather Merge 的策略。优化器有权基于正确性拒绝 advice。施加 advice 有性能开销——即使不改变最终计划,也会打断优化器循环。文档明确提醒:慎用。

这是手术刀,不是电锯。

上线前的检查清单

  • [ ] 确认已穷尽统计信息调优(CREATE STATISTICSALTER TABLE SETANALIZE 采样率)
  • [ ] 用 EXPLAIN (GEN_PLAN_ADVICE) 获取优化器自述 advice,从它出发微调,不从零手写
  • [ ] 每条 advice 用 plan_advice_feedback = 'log' 验证状态,确认 Applied 而非 IgnoredRejected
  • [ ] 生产环境用 pg_stash_advice 而非逐查询 SET plan_advice
  • [ ] stash 限定最小作用域(角色或数据库),避免误伤其他查询
  • [ ] 设置定期审查:统计信息或优化器版本更新后,重新评估 stash 中的 advice 是否仍然必要
  • [ ] advice 是临时措施,目标是最终移除它

Postgres 社区花了二十年说绝不加 hint,最后加了 advice。名字不同,设计不同,降级机制不同,反馈机制不同,生成方式不同。但做的事是同一件事——给 DBA 一个优化器的逃生舱。这次,逃生舱设计得像样了。


相关推荐