Postgres 社区花了二十年说"绝不",然后加了一个功能,和 hint 做的事一样,只是换了个名字。Postgres 19 的 feature freeze 包含两个新的 contrib 模块:pg_plan_advice 和 pg_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_indexscan、OFFSET 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_ADVICE 到 EXPLAIN,优化器直接输出能复现当前计划的 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 提供 Parallel、NoParallel、ParallelLeader 控制并行执行位置和是否启用。
组合使用:
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 不能控制聚合使用排序还是哈希,也不能指定 Gather 或 Gather Merge 的策略。优化器有权基于正确性拒绝 advice。施加 advice 有性能开销——即使不改变最终计划,也会打断优化器循环。文档明确提醒:慎用。
这是手术刀,不是电锯。
上线前的检查清单
- [ ] 确认已穷尽统计信息调优(
CREATE STATISTICS、ALTER TABLE SET、ANALIZE采样率) - [ ] 用
EXPLAIN (GEN_PLAN_ADVICE)获取优化器自述 advice,从它出发微调,不从零手写 - [ ] 每条 advice 用
plan_advice_feedback = 'log'验证状态,确认Applied而非Ignored或Rejected - [ ] 生产环境用
pg_stash_advice而非逐查询SET plan_advice - [ ] stash 限定最小作用域(角色或数据库),避免误伤其他查询
- [ ] 设置定期审查:统计信息或优化器版本更新后,重新评估 stash 中的 advice 是否仍然必要
- [ ] advice 是临时措施,目标是最终移除它
Postgres 社区花了二十年说绝不加 hint,最后加了 advice。名字不同,设计不同,降级机制不同,反馈机制不同,生成方式不同。但做的事是同一件事——给 DBA 一个优化器的逃生舱。这次,逃生舱设计得像样了。