首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >PG 19 : 终于可以给查询计划戴上"紧箍咒"了

PG 19 : 终于可以给查询计划戴上"紧箍咒"了

作者头像
用户4035096
发布2026-07-10 09:27:27
发布2026-07-10 09:27:27
70
举报

pg_stash_advice:为 PostgreSQL 查询计划戴上"紧箍咒"

本文解读 PostgreSQL 社区在 2026 年 4 月 6 日合并的全新 contrib 扩展——pg_stash_advice(commit e8ec19a)。该模块由 PostgreSQL 核心贡献者 Robert Haas 主导开发,允许 DBA 将 pg_plan_advice 生成的执行计划建议(plan advice)字符串存储在动态共享内存(DSA)中,并在后续每次规划(plan)该查询时自动注入这些建议,从而精准控制特定查询的执行计划走向。

背景痛点:在生产环境中,PostgreSQL 查询规划器(planner)有时会对特定查询生成不理想的执行计划,尤其在数据分布不均匀、统计信息滞后或复杂多表关联场景下。DBA 往往需要反复通过 pg_plan_advice 手工分析并手动添加 hint(通过 pg_hint_plan 等方式)来干预计划,但这些方案缺乏统一管理、无法自动化、且重启即失效。

价值与意义pg_stash_advice 的出现填补了这一空白——它以查询标识符(query identifier)为键,将 plan advice 存入共享内存,使得建议可以在所有会话自动生效,且通过 advisor hook 机制与 pg_plan_advice 无缝集成。这是一个"一次存储、持续生效"的执行计划治理方案,为 DBA 提供了一种系统化、可持续的查询计划控制手段。


1. 原理介绍

1.1 整体架构

pg_stash_advice 本质上是一个基于动态共享内存(DSA + dshash)的键值存储,对外暴露 SQL 接口,内部通过 pg_plan_advice 提供的 advisor hook 机制在查询规划时自动注入建议。

其核心数据流如下:

代码语言:javascript
复制
用户空间(SQL)
    |
    v
pg_create_advice_stash() / pg_set_stashed_advice()
    |  (调用 pgsa_attach() 绑定到 DSA)
    v
Dynamic Shared Memory (DSA)
    |-- pgsa_shared_state        : 控制结构(含 LWLock + 三个 tranche ID)
    |-- pgsa_stash_dshash        : stash_name(string) -> stash_id(uint64)  哈希表
    |-- pgsa_entry_dshash        : (stash_id, queryId) -> advice_string(DSA pointer) 哈希表
    |
    v
查询规划阶段(每条 SQL)
    |
    v
pg_plan_advice 的 pgsa_advisor() hook 被调用
    |  根据当前 queryId 查 pgsa_entry_dshash
    v
若命中 -> 返回 advice_string -> pg_plan_advice 解析并应用该建议

1.2 核心数据结构(代码对照)

pg_stash_advice.h 第 28-65行定义了三层数据结构:

代码语言:javascript
复制
/* 层一:查询级别的 entry key —— (stash_id, queryId) 复合键 */
typedefstruct pgsa_entry_key {
    uint64  pgsa_stash_id;   /* stash 的整数 ID(非名字) */
    int64   queryId;         /* PostgreSQL 计算的查询标识符 */
} pgsa_entry_key;

/* 层二:单个存储条目 */
typedefstruct pgsa_entry {
    pgsa_entry_key key;
    dsa_pointer     advice_string; /* 指向 DSA 内存中的实际字符串 */
} pgsa_entry;

/* 层三:stash 顶层结构 —— name -> id 映射 */
typedefstruct pgsa_stash {
    char    name[NAMEDATALEN];   /* 人类可读的 ASCII stash 名 */
    uint64  pgsa_stash_id;      /* 递增值,从 1 开始 */
} pgsa_stash;

/* 顶层共享状态 */
typedefstruct pgsa_shared_state {
    LWLock   lock;               /* 保护所有共享数据的主锁 */
    int      dsa_tranche;        /* DSA 内存区域的 tranche */
    int      stash_tranche;      /* stash_name->id 哈希表的 tranche */
    int      entry_tranche;      /* entry 哈希表的 tranche */
    uint64   next_stash_id;      /* 原子递增值,保证唯一性 */
    dsa_handle area;             /* DSA 区域的 handle(进程重启后凭此 attach)*/
    dshash_table_handle stash_hash;
    dshash_table_handle entry_hash;
} pgsa_shared_state;

设计要点

双哈希表分工明确pgsa_stash_dshash(string→uint64)管理所有 stash 的元数据;pgsa_entry_dshash((stash_id,queryId)→advice_string)存储具体的 queryId 到建议字符串的映射。两个表分别使用 dshash_strhash/dshash_memhash 哈希算法。

dsa_pointer 间接寻址advice_string 存储为 dsa_pointer(DSA 指针),而非直接存字符串指针。这是因为 DSA 内存地址在跨进程场景下并不稳定,只有 handle + 偏移的组合才是可移植的。代码 pg_stash_advice.c:161-162 演示了如何通过 dsa_get_address() 将其转换为实际地址:

代码语言:javascript
复制
advice_string = pstrdup(dsa_get_address(pgsa_dsa_area, entry->advice_string));

LWLock 并发控制:所有对共享数据结构的操作(创建 stash、插入 entry 等)均需持有 pgsa_state->lockpg_stash_advice.c:544-560 详细注释了解锁语义的必要性——既要防止中断导致内存泄漏,又要防止与 pgsa_drop_stash() 的竞态条件。

进程重启后自动 attach:通过 GetNamedDSMSegment()pg_stash_advice.c:198-202)根据名字找回之前创建的 DSA 段,首次创建时调用 pgsa_init_shared_state() 初始化,后续进程直接 attach 复用已有的 handle(pg_stash_advice.c:220-222)。

1.3 与 pg_plan_advice 的集成

pg_plan_advicepg_plan_advice.h:19-23)定义了一个插件钩子类型:

代码语言:javascript
复制
typedef char *(*pg_plan_advice_advisor_hook) (PlannerGlobal *glob,
                                               Query *parse,
                                               const char *query_string,
                                               int cursorOptions,
                                               ExplainState *es);

pg_stash_advice_PG_init() 中(pg_stash_advice.c:103-107)通过 load_external_function 加载 pg_plan_advice 并调用 pg_plan_advice_add_advisor() 注册自身的 pgsa_advisor() 函数:

代码语言:javascript
复制
add_advisor_fn = load_external_function("pg_plan_advice", "pg_plan_advice_add_advisor", true, NULL);
(*add_advisor_fn)(pgsa_advisor);

每次查询规划时,pg_plan_advice 会遍历所有注册的兴趣模块(advisor),pgsa_advisor()pg_stash_advice.c:114-171)负责查找当前 queryId 是否在 stash 中有对应的 advice string——命中则返回给 pg_plan_advice 解析和应用。

1.4 命名验证与 GUC 绑定

pg_stash_advice 定义了一个 GUC 参数 pg_stash_advice.stash_namepg_stash_advice.c:89-101),通过 pgsa_check_stash_name_guc() 校验:

  • 非空、长度 ≤ NAMEDATALEN(默认 63 字节)
  • 纯 ASCII 字符(跨数据库可见性要求)
  • 必须形似标识符(字母或下划线开头,后续为字母/数字/下划线)

空字符串(默认值)等效于禁用该模块。


2. 应用场景及最佳实践

2.1 场景一:修复统计信息滞后导致的错误计划选择

背景:某张包含数百万行记录的 orders 表,status 字段分布不均(99% 是 'completed',1% 是 'pending')。autovacuum 因故长时间未运行,导致统计信息严重滞后。查询 WHERE status = 'pending' 本应走索引扫描,但 planner 因陈旧统计信息错误估算,选择了全表扫描。

使用前(无 pg_stash_advice)

代码语言:javascript
复制
-- 获取 query identifier
EXPLAIN (VERBOSE, COSTS OFF)
SELECT * FROM orders WHERE status = 'pending';
-- 输出: Query Identifier: 1234567890
-- 计划: Seq Scan on orders  (错误选择)

-- 需要手工将 advice 字符串作为 EXPLAIN 的 PLAN_ADVICE 传入
EXPLAIN (COSTS OFF, PLAN_ADVICE 'INDEX_SCAN(orders orders_status_idx)')
SELECT * FROM orders WHERE status = 'pending';

每次执行查询都要在 EXPLAIN 中传入 advice,无法自动化。

使用后(pg_stash_advice)

代码语言:javascript
复制
-- 初始化 extension(在 shared_preload_libraries 已加载的前提下)
CREATE EXTENSION pg_stash_advice;

-- 创建 advice stash
SELECT pg_create_advice_stash('orders_stash');

-- 获取 query identifier(通过 EXPLAIN VERBOSE)
EXPLAIN (VERBOSE) SELECT * FROM orders WHEREstatus = 'pending';
-- 假设得到 Query Identifier: 1234567890

-- 将 advice 存入 stash
SELECT pg_set_stashed_advice('orders_stash', 1234567890,
    'INDEX_SCAN(orders orders_status_idx)');

-- 设置当前会话使用该 stash
SET pg_stash_advice.stash_name = 'orders_stash';

-- 此后所有使用该 query identifier 的查询,自动注入建议
EXPLAIN (COSTS OFF) SELECT * FROM orders WHEREstatus = 'pending';
-- 计划变为: Index Scan using orders_status_idx

2.2 场景二:多表 JOIN 顺序固定化

背景:某复杂报表涉及 5 表 JOIN,历史分析表明将 fact_sales 置于驱动表是最优顺序,但 planner 在某些数据分布下会错误地先扫描维度表。

前后对照

状态

执行计划(简化)

无 advice(planner 自行选择)

Hash Join(d2→f) → Hash Join(f→d1) —— 次优

有 join_order(f d1 d2) advice

Hash Join(f→d1) → Hash Join(结果→d2) —— 最优

代码语言:javascript
复制
-- 获取 query identifier
SELECT get_query_id($$
  SELECT ... FROM fact_sales f
    LEFT JOIN dim_product d1 ON f.product_id = d1.id
    LEFT JOIN dim_customer d2 ON f.customer_id = d2.id
  WHERE ...$$) AS qid \gset

-- 设置 join 顺序 advice
SELECT pg_set_stashed_advice('report_stash', :'qid', 'join_order(f d1 d2)');

2.3 场景三:会话级 / 数据库级 / 角色级配置

文档推荐通过 GUC 逐级控制生效范围:

代码语言:javascript
复制
-- 数据库级别:某特定数据库启用
ALTER DATABASE my_reporting_db SET pg_stash_advice.stash_name = 'report_stash';

-- 角色级别:特定_role_只使用某个 stash
ALTER ROLE readonly_user SET pg_stash_advice.stash_name = 'readonly_stash';

-- 会话级别:临时覆盖
SET pg_stash_advice.stash_name = 'test_stash';

2.4 最佳实践要点

**必须加入 shared_preload_libraries**:要让所有会话自动加载模块,需在 postgresql.conf 中配置:

代码语言:javascript
复制
shared_preload_libraries = 'pg_plan_advice,pg_stash_advice'

并重启服务器。仅通过 CREATE EXTENSION 加载只对当前连接生效。

**必须启用 compute_query_id**:该模块依赖查询标识符来匹配建议。模块加载时会自动将 compute_query_id 设为 auto(如果尚未设置)。也可手动确认:

代码语言:javascript
复制
SET compute_query_id = on;

stash 内存管理:advice stash 存储在 DSA 内存中,服务器重启后消失。需要配合数据库启动脚本(如 pg_cron 钩子或 systemd service 的 ExecStartPost)重新创建和填充 stash。监控可用 pg_get_advice_stashes() 查看各 stash 的条目数量。

仅在必要时使用:文档明确指出——即使建议不改变执行计划,应用 advice 本身也有性能开销。建议仅对已知存在规划错误风险的特定查询使用,且 advice 字符串应尽量精简(只包含需要控制的部分)。

安全考量:非超级用户默认无权创建 stash 或修改其内容,但任何用户可以 SET pg_stash_advice.stash_name。DBA 应假设 stash 内容对所有用户可见,不应在 advice 字符串中存储敏感信息。

stash 名称规范:名称必须为 ASCII、符合标识符规范(字母/下划线开头,最长 63 字节),建议使用有意义的命名如 production_stable_queries 而非 cafe_stash


3. 小结和思考

pg_stash_advice 是 PostgreSQL 在查询计划自动化治理方向上的重要一步。它不是替代 pg_hint_plan,而是与 pg_plan_advice 形成互补——pg_plan_advice 负责"发现问题"(分析并生成建议字符串),pg_stash_advice 负责"解决问题"(将建议持久化并自动应用)。

从架构设计上看,该模块有几点值得称道:

  • 共享内存哈希表(dshash) 的使用使得跨会话查询标识符→建议字符串的映射既高效又并发安全;
  • DSA 指针间接寻址确保了字符串数据在进程重启后仍可通过 handle 正确 attach;
  • advisor hook 机制遵循了 PostgreSQL 扩展的"最小侵入"原则,无需修改 pg_plan_advice 本身;
  • 命名验证 + GUC check_hook的双重校验保证了即使在 GUC 赋值阶段也能提前拦截无效名称。

对 DBA 而言,这意味着:

  • 一次调优,长期生效:通过 pg_set_stashed_advice 写入的建议,在 stash 存活期间对所有匹配查询自动生效,无需修改应用代码;
  • 可审计、可回滚:通过 pg_get_advice_stash_contents() 可随时查看已存储的建议;将 advice_string 设为 NULL 即可撤销;
  • 告别手工 hint:对于确实存在规划器短视行为的查询,终于有了一种比外部 hint 工具更原生化、更易管理的方案。

可以预见,随着 PostgreSQL 查询规划器的持续进化以及 pg_plan_advice 建议质量的不断提升,pg_stash_advice 有望成为生产环境中查询性能治理的标配工具。

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-04-06,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 digoal德哥 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • pg_stash_advice:为 PostgreSQL 查询计划戴上"紧箍咒"
  • 1. 原理介绍
    • 1.1 整体架构
    • 1.2 核心数据结构(代码对照)
    • 1.3 与 pg_plan_advice 的集成
    • 1.4 命名验证与 GUC 绑定
  • 2. 应用场景及最佳实践
    • 2.1 场景一:修复统计信息滞后导致的错误计划选择
    • 2.2 场景二:多表 JOIN 顺序固定化
    • 2.3 场景三:会话级 / 数据库级 / 角色级配置
    • 2.4 最佳实践要点
  • 3. 小结和思考
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档