postgresql plancache

plancache.c 设计与调用链说明

1. 简述

plancache用于PBE协议,prepare、execute语句,以及SPI、存储过程内sql的调用,通过缓存执行计划,优化sql多次执行时的时间(不需要重复parse和生成plan)

本文档归档 src/backend/utils/cache/plancache.c 的核心设计,补充以下内容:

  • 模块职责与整体分层
  • 关键数据结构含义
  • 主要函数的职责划分
  • PREPARE/EXECUTE、extended query protocol、SPIPL/pgSQL 等调用链
  • QueryEnvironment / ENR 对 plancache 的影响
  • 失效机制、锁策略、内存策略

对应源码:

  • src/backend/utils/cache/plancache.c
  • src/include/utils/plancache.h
  • src/include/nodes/plannodes.h
  • src/backend/commands/prepare.c
  • src/backend/tcop/unvdb.c
  • src/backend/executor/spi.c
  • src/pl/plpgsql/src/pl_exec.c
  • src/backend/utils/misc/queryenvironment.c

2. 模块职责

plancache 不是一个单纯的"执行计划缓存器",而是一个组合模块,承担 4 类职责:

  1. 缓存查询源信息:保存 SQL 文本、原始语法树、重写后的 Query 树、参数信息、结果描述等。
  2. 缓存可执行计划:在需要时生成 CachedPlan,并决定是否复用 generic plan。
  3. 跟踪依赖并处理失效:响应 relation/syscache invalidation,把过期对象标为无效。
  4. 在 generic plan 与 custom plan 之间动态选择:根据参数、环境和历史成本选择最合适的执行路径。

文件头注释已经明确指出两个主线:

  • 选择 generic plan 还是 custom plan( generic plan 参数未知,生成的为根据统计信息的通用计划,custom plan 传入参数,生成对应参数优化后的计划)
  • 跟踪 schema/object 变化导致的计划失效

2.1 总体架构图

下面这张图描述 plancache 在数据库内核中的位置。
SQL / 协议消息 / SPI / PL
Parser

raw parse tree
Analyzer + Rewriter

Query list
plancache

CachedPlanSource
Planner
plancache

CachedPlan
Portal / Executor
relcache / syscache invalidation
search_path / role / RLS / QueryEnvironment

从这张图可以看到:

  • plancache 夹在 Query 语义层和执行计划层之间
  • 它既面向上层调用者,也面向失效系统
  • 它不是 parser、planner、executor 的替代,而是三者之间的协调层

2.2 核心对象关系图

下面这张图描述 CachedPlanSourceCachedPlanCachedExpression 的关系。
CachedPlanSource
raw_parse_tree
query_string
query_list + relationOids + invalItems
resultDesc
gplan
cost statistics
CachedPlan
stmt_list
refcount
saved_xmin
cached_expression_list
CachedExpression
planned expr
relationOids + invalItems

可以把三者理解成:

  • CachedPlanSource:查询源与 Query 层缓存
  • CachedPlan:可执行计划对象
  • CachedExpression:表达式级别的小型缓存

2.3 分层视图

这张图强调"谁负责哪一层"。
语义与执行
plancache
上层调用者
PREPARE / EXECUTE
extended protocol PBE协议 PREPARE/BIND/EXEC
SPI
PL/pgSQL
CachedPlanSource

SQL文本 + RawStmt + Query树
决策逻辑

generic vs custom
失效逻辑

sinval + 环境校验
CachedPlan

PlannedStmt + refcount
Analyze / Rewrite
Planner
Executor / Portal

2.4 失效与重建架构图

这张图强调为什么 invalidation 只是"打标记",不直接重建。
DDL / catalog change
sinval callback
mark plansource invalid
mark gplan invalid
等待下一次 GetCachedPlan
RevalidateCachedQuery
Analyze + Rewrite
BuildCachedPlan
返回新 plan

设计意图:

  • 把失效广播做得尽量便宜

  • 把真正昂贵的 analyze/rewrite/plan 延后到"下一次真的有人需要执行"时再做

    c 复制代码
    static dlist_head saved_plan_list = DLIST_STATIC_INIT(saved_plan_list);
    static dlist_head cached_expression_list = DLIST_STATIC_INIT(cached_expression_list);
    // 创建了两个全局静态list,用于保存所有创建的plansource和expression,在收到失效消息后,遍历链表对对应的cache做检查失效

2.5 生命周期与持有关系图

这张图强调 CachedPlan 为什么必须用引用计数。
generic 引用
临时引用
持有引用
登记引用


CachedPlanSource
CachedPlan
当前执行
Portal
ResourceOwner
refcount == 0 ?
继续存活
释放 plan->context

一句话理解:

  • CachedPlanSource 可以消失
  • 但只要还有 Portal、执行器或 owner 在用,该 CachedPlan 仍需存活

3. 核心数据结构

3.1 关键数据结构字段详解

CachedPlanSource 代表"一个可重复使用的查询源",它不是"执行计划",而是"生成执行计划的母体",主要保存:
c 复制代码
typedef struct CachedPlanSource
{
    int magic; /* 魔数,做对象类型与内存损坏检查 */
    // 原始语句相关,不随上下文变化
    struct RawStmt *raw_parse_tree;    /* 原始语法树。失效后重新 analyze/rewrite 时要从这里重新开始 */
    const char *query_string;    /* 原始 SQL 文本。用于报错、日志、调试、重新分析 */
    MemoryContext context; // 以上变量保存在该内存上下文中, 生命周期和PlanSource一致
   
    // query相关,依赖其他对象,失效后需要重建
    CommandTag commandTag;    /* 语句类型标签,例如 SELECT / INSERT / UPDATE */
    Oid *param_types;
    int num_params;    /* 固定参数类型定义。用于参数校验与重新分析 */
    ParserSetupHook parserSetup;
    void *parserSetupArg; /* 动态参数解析回调。SPI 等场景会使用 */
    int cursor_options; /* 规划选项,例如强制 generic/custom、并行能力等 */
    bool fixed_result; /* 结果结构是否必须固定。prepared statement 常设为 true */
    TupleDesc resultDesc;  /* 当前结果描述。Describe/EXECUTE/结果兼容检查要用它 */
    List *query_list; /* analyze + rewrite 后的 Query 列表。失效后可整体丢弃重建 */
    List *relationOids;  /* Query 层 relation 依赖,供 relation invalidation 使用 */
    List *invalItems; /* Query 层其他 catalog 依赖,主要是函数/类型等 syscache 对象 */
    struct OverrideSearchPath *search_path; /* 建立 query_list 时使用的 search_path。环境变化时要重解析 */
    MemoryContext query_context; /* 保存 query_list 与依赖信息的上下文。失效时可整体删除 */
    Oid rewriteRoleId;
    bool rewriteRowSecurity;
    bool dependsOnRLS;
    /* 记录 rewrite 时的角色与 row_security 状态,用于判断 RLS 是否要求重写 */

    // plan相关
    struct CachedPlan *gplan;
    /* generic plan 的引用。存在时表示当前有可复用的通用计划 */

    bool is_oneshot;
    /* 是否为 one-shot。为 true 时不做长期失效跟踪与深拷贝语义 */

    bool is_complete;
    /* 是否已经完成 CompleteCachedPlan。GetCachedPlan 的前置条件 */

    bool is_saved;
    /* 是否已经迁入长期存储并加入全局 saved_plan_list */

    bool is_valid;
    /* Query 层缓存是否仍有效。sinval、search_path、RLS 变化会把它打成 false */

    int generation;
    /* 每次新建 CachedPlan 时递增,可视作 plansource 的计划代数 */

    dlist_node node;
    /* 挂入全局 saved_plan_list 的链表节点,仅 is_saved 时有效 */

    double generic_cost;
    /* generic plan 成本估计;-1 表示还没真正试算过 generic 成本 */

    double total_custom_cost;
    int64 num_custom_plans;
    int64 num_generic_plans;
    /* generic/custom 决策统计数据,用于 choose_custom_plan 的经验判断 */
} CachedPlanSource;

字段使用节奏可以概括为:

  • 创建阶段主要写 raw_parse_treequery_string、参数定义与状态位
  • CompleteCachedPlan 主要写 query_list、依赖信息、search_pathresultDesc
  • 执行阶段主要读 is_validgplan、成本统计
  • 失效阶段主要改 is_validgplan->is_valid
CachedPlan 字段详解
c 复制代码
typedef struct CachedPlan
{
    int magic; /* 魔数,做对象完整性检查 */
    List *stmt_list;    /* 规划后的 PlannedStmt 列表。Executor、Portal、SPI 直接消费它 */
    bool is_oneshot; /* 是否来自 one-shot plansource。决定释放时能否删除独占上下文 */

    bool is_saved;
    /* 是否已迁入长期上下文。若要交给 ResourceOwner 托管,通常要求为 true */

    bool is_valid;
    /* 计划当前是否仍可执行。失效回调、角色变化、saved_xmin 变化会把它打成 false */

    Oid planRoleId;
    bool dependsOnRole;
    /* 角色依赖信息。若计划与角色绑定,则换用户后不能直接复用 */

    TransactionId saved_xmin;
    /* transient plan 的事务边界。TransactionXmin 变化后该计划要重建 */

    int generation;
    /* 对应父 plansource 的 generation 快照,用于调试与扩展安全检查 */

    int refcount;
    /* 引用计数。gplan、Portal、当前执行、ResourceOwner 都可能持有引用 */

    MemoryContext context;
    /* 计划自己的内存上下文。refcount 归零后通常整体删除 */
} CachedPlan;

字段使用节奏可以概括为:

  • BuildCachedPlan 负责一次性填满绝大多数字段
  • GetCachedPlan / ReleaseCachedPlan 主要维护 refcount
  • CheckCachedPlan 主要检查 is_validdependsOnRolesaved_xmin
  • Portal 与 Executor 主要消费 stmt_list
CachedExpression 字段详解
c 复制代码
typedef struct CachedExpression
{
    int magic;
    /* 魔数,做对象完整性检查 */

    Node *expr;
    /* 已经过 expression_planner 的表达式树,可直接供后续执行使用 */

    bool is_valid;
    /* 是否仍有效。plancache 只负责打失效标记,不负责自动重建 */

    List *relationOids;
    /* relation 依赖。表相关失效消息到达时用它判断是否过期 */

    List *invalItems;
    /* 其他 catalog 对象依赖,例如函数/类型等 */

    MemoryContext context;
    /* 表达式缓存对象的上下文 */

    dlist_node node;
    /* 挂入全局 cached_expression_list 的链表节点 */
} CachedExpression;

字段使用节奏可以概括为:

  • GetCachedExpression 一次性填完结构
  • 失效回调只改 is_valid
  • 调用方发现 is_valid = false 后必须自己重建
PlanInvalItem 字段详解
c 复制代码
typedef struct PlanInvalItem
{
    NodeTag type;
    /* 节点类型标签 */

    int cacheId;
    /* syscache ID,标识依赖的是哪一类 catalog cache */

    uint32 hashValue;
    /* 该对象查找键对应的 hash,用于在失效回调中快速匹配 */
} PlanInvalItem;

它的作用非常单一但很关键:

  • relation 用 relationOids 跟踪
  • 非 relation 的 catalog 依赖主要靠 PlanInvalItem

例如:

  • 函数定义变化
  • 类型定义变化
  • 某些系统 catalog 对象变化

3.6 这些数据结构分别适合何时使用

这一节从"调用者视角"解释什么时候该用哪个对象。

适合使用 CachedPlanSource 的场景
  • 你希望保存一条 SQL 的原始语义信息,而不是只执行一次
  • 你需要在未来因为 DDL、RLS、search_path 变化而重新分析/重写
  • 你希望让系统自动决定 generic/custom plan
  • 你正在实现 prepared statement、SPI plan、可重用 Portal 背后的计划源

不适合的场景:

  • 只执行一次,且生命周期严格受当前上下文控制
  • 不需要 invalidation 跟踪

这时更适合 CreateOneShotCachedPlan

oneshot plan主要在SPI_execute、SPI_execute_extended、SPI_execute_with_args 函数内调用调用 单次调用 计划执行后销毁

适合使用 CachedPlan 的场景
  • 你已经要开始执行一条语句
  • 你需要把 PlannedStmt 挂到 Portal 或 Executor
  • 你需要一个带 refcount 的可执行计划对象

不适合的场景:

  • 仅想查看 SQL 的参数定义、结果结构或 Query 语义信息

这时应该看 CachedPlanSource,而不是直接追 CachedPlan

适合使用 CachedExpression 的场景
  • 你缓存的是标量表达式,不是完整 SQL
  • 调用方能够接受"失效后自己重建"
  • 你只需要 expression planner 结果

典型场景:

  • PL/pgSQL 内部 cast/简单表达式缓存
适合使用 one-shot 计划的场景
  • 语句只执行一次
  • 希望减少树复制和失效跟踪开销
  • 执行链较短,且不要求长期缓存

典型场景:

  • SPI_execute() 一次性语句
  • 某些内部临时执行路径
适合使用 generic plan 的场景
  • 语句会被频繁执行
  • 参数值分布对计划影响不大
  • 规划成本相对执行成本占比高
适合使用 custom plan 的场景
  • 参数值强烈影响索引选择、分区裁剪或 join 路径
  • 同一语句不同参数会导致很不一样的最优计划
  • 规划额外成本能被执行收益抵消

4. 核心设计思路

4.1 分层缓存

模块把缓存拆成两层:

  • CachedPlanSource 缓存 Query 语义层
  • CachedPlan 缓存 PlannedStmt 执行层

这样做的原因是:

  • Query 层和 Plan 层的依赖不同
  • Query 层和 Plan 层的失效条件不同
  • Query 层和 Plan 层的生命周期不同

4.2 懒失效、懒重建

收到 invalidation 时不立即重建,而是:

  • plansource->is_valid = false
  • 如果有 generic plan,则把 gplan->is_valid = false

等下次真正调用 GetCachedPlan() 时,再重做:

  • parse analyze
  • rewrite
  • planning

4.3 环境参与缓存键

SQL 文本一样,不代表查询语义一致。以下环境都会影响缓存合法性:

  • search_path
  • 当前用户
  • row_security
  • TransactionXmin
  • QueryEnvironment

其中 search_path 和 RLS 直接在 plancache.c 中检查;QueryEnvironment 则通过上层调用路径传入分析与规划流程。

4.4 generic/custom 自适应决策

如果语句带参数,系统不会总是固定用 generic 或 custom,而是:

  1. 先做若干次 custom plan(默认5次)
  2. 累积 custom 平均成本
  3. 生成 generic plan 并估算其成本 (在做完5次custom plan后,生成)
  4. 比较 generic_cost 与平均 custom 成本,(对比的是之前生成的平均 custom 成本,并不直接重新生成custom plan,只有比generic_cost 更优时才再次生成计划)
  5. 选择更优路径

4.5 内存上下文分治

plancache 的内存布局非常重要:

  • plansource->context:保存 SQL 文本、raw tree、参数、结果描述等稳定信息
  • plansource->query_context:保存当前有效的 Query 树和依赖,可整体删除重建
  • plan->context:保存 PlannedStmt 等执行计划对象

这种设计让"失效后整体丢弃 Query 层"变得非常便宜。

5. 关键函数分组

5.1 创建与保存

  • CreateCachedPlan
  • CreateOneShotCachedPlan
  • CompleteCachedPlan
  • SaveCachedPlan
  • DropCachedPlan
  • CopyCachedPlan

职责:

  • 构造 CachedPlanSource
  • 填充分析/重写结果
  • 在需要时迁入长期内存
  • 支持复制和销毁

5.2 重验证与建计划

  • RevalidateCachedQuery
  • CheckCachedPlan
  • BuildCachedPlan
  • GetCachedPlan
  • ReleaseCachedPlan

职责:

  • 检查缓存是否仍然合法
  • 在需要时重做分析/重写
  • 生成 generic 或 custom plan
  • 管理计划引用计数

5.3 generic/custom 决策

  • choose_custom_plan
  • cached_plan_cost

职责:

  • 根据参数、GUC、cursor options、历史统计,判断当前应使用哪一类计划

5.4 快速有效性检查

  • CachedPlanAllowsSimpleValidityCheck
  • CachedPlanIsSimplyValid

职责:

  • 给 PL/pgSQL 等高频简单表达式提供快路径
  • 避免每次都走完整 revalidate + lock + replan

5.5 锁与结果辅助

  • AcquirePlannerLocks
  • AcquireExecutorLocks
  • ScanQueryForLocks
  • ScanQueryWalker
  • PlanCacheComputeResultDesc
  • QueryListGetPrimaryStmt

职责:

  • 获取 planning/execution 所需锁
  • 遍历 Query 树中的子查询与子链接
  • 计算结果 tuple descriptor

5.6 失效回调

  • InitPlanCache
  • PlanCacheRelCallback
  • PlanCacheObjectCallback
  • PlanCacheSysCallback
  • ResetPlanCache

职责:

  • 向 inval 系统注册回调
  • 响应 relation / syscache 失效
  • 在必要时精准或全量标记缓存无效

5.7 表达式缓存

  • GetCachedExpression
  • FreeCachedExpression

职责:

  • 缓存独立标量表达式的 planned form

6. 主流程

6.1 查询从 SQL 到 CachedPlan

text 复制代码
SQL
  -> raw_parser / pg_parse_query
  -> CreateCachedPlan / CreateOneShotCachedPlan
  -> analyze + rewrite
  -> CompleteCachedPlan
  -> (可选) SaveCachedPlan
  -> GetCachedPlan
     -> RevalidateCachedQuery
     -> choose_custom_plan
     -> CheckCachedPlan / BuildCachedPlan
  -> 执行
  -> ReleaseCachedPlan

6.2 invalidation 到重建

text 复制代码
DDL / catalog 变化
  -> sinval 回调
  -> PlanCacheRelCallback / PlanCacheObjectCallback / PlanCacheSysCallback
  -> plansource/gplan 标记 invalid
  -> 下一次 GetCachedPlan()
  -> RevalidateCachedQuery()
  -> analyze + rewrite + plan 重建

6.3 关键代码伪代码

本节不是源码逐行翻译,而是抽取核心控制逻辑,帮助快速建立执行模型。

RevalidateCachedQuery 伪代码

适合阅读时机:

  • 你想理解"为什么计划失效后不是立即重建"
  • 你想理解 search_path、RLS、锁与 race condition 的关系
text 复制代码
function RevalidateCachedQuery(plansource, queryEnv):
    if plansource is oneshot or statement does not require revalidation:
        assert plansource is valid
        return NIL

    if plansource is currently valid:
        if saved search_path != current search_path:
            invalidate plansource and gplan

    if plansource is currently valid and dependsOnRLS:
        if rewrite role or row_security changed:
            invalidate plansource

    if plansource is still valid:
        acquire planner locks on query_list
        if plansource is still valid:
            return NIL
        else:
            release planner locks

    discard old query_list, dependency info, search_path, query_context
    release generic plan if any

    ensure snapshot if needed
    copy raw_parse_tree
    analyze + rewrite again using fixed params or parserSetup callback

    recompute resultDesc
    if resultDesc changed and fixed_result is true:
        ERROR
    else if resultDesc changed:
        update plansource->resultDesc

    copy new querytree into new query_context
    extract dependencies
    save current RLS info and search_path
    mark plansource valid
    return transient querytree list
BuildCachedPlan 伪代码

适合阅读时机:

  • 你想理解 Query 层对象是如何变成 PlannedStmt
  • 你想理解 transient plan、role dependency、saved_xmin 的来源
text 复制代码
function BuildCachedPlan(plansource, qlist, boundParams, queryEnv):
    if plansource is not valid:
        qlist = RevalidateCachedQuery(plansource, queryEnv)

    if qlist is NIL:
        if not oneshot:
            qlist = deep copy of plansource->query_list
        else:
            qlist = plansource->query_list

    ensure snapshot if planning requires one

    plist = pg_plan_queries(qlist, query_string, cursor_options, boundParams)

    if not oneshot:
        create plan_context
        copy plist into plan_context

    plan = new CachedPlan
    plan->stmt_list = plist
    plan->planRoleId = current user
    plan->dependsOnRole = plansource->dependsOnRLS or any plannedstmt dependsOnRole
    plan->saved_xmin = TransactionXmin if any plannedstmt is transient
    plan->refcount = 0
    plan->is_valid = true
    plan->generation = ++plansource->generation

    return plan
choose_custom_plan 伪代码

适合阅读时机:

  • 你想理解 generic/custom 到底怎么选
  • 你在分析为什么某条 prepared statement 总是 custom 或总是 generic
text 复制代码
function choose_custom_plan(plansource, boundParams):
    if oneshot:
        return custom

    if boundParams is NULL:
        return generic

    if statement does not require replanning:
        return generic

    if GUC forces generic/custom:
        obey GUC

    if cursor option forces generic/custom:
        obey cursor option

    if num_custom_plans < 5:
        return custom

    avg_custom_cost = total_custom_cost / num_custom_plans

    if generic_cost < avg_custom_cost:
        return generic
    else:
        return custom
GetCachedPlan 伪代码

适合阅读时机:

  • 你想知道外部调用 GetCachedPlan() 后内部到底做了多少事
  • 你在排查"为什么本次执行拿到的是 generic/custom"
text 复制代码
function GetCachedPlan(plansource, boundParams, owner, queryEnv):
    ensure plansource complete
    if owner is given, plansource must be saved

    qlist = RevalidateCachedQuery(plansource, queryEnv)

    customplan = choose_custom_plan(plansource, boundParams)

    if customplan is false:
        if generic plan exists and passes CheckCachedPlan:
            plan = gplan
        else:
            plan = BuildCachedPlan(plansource, qlist, NULL, queryEnv)
            replace old gplan with new generic plan
            generic_cost = cached_plan_cost(plan, include_planner = false)

            customplan = choose_custom_plan(plansource, boundParams)
            qlist = NIL

    if customplan is true:
        plan = BuildCachedPlan(plansource, qlist, boundParams, queryEnv)
        total_custom_cost += cached_plan_cost(plan, include_planner = true)
        num_custom_plans++
    else:
        num_generic_plans++

    increase refcount
    if owner exists:
        remember plan in ResourceOwner

    if customplan and plansource is saved:
        move plan to CacheMemoryContext

    return plan
CachedPlanIsSimplyValid 伪代码

适合阅读时机:

  • 你在看 PL/pgSQL simple expression 快路径
  • 你想区分"完整重验证"和"简单有效性检查"
text 复制代码
function CachedPlanIsSimplyValid(plansource, plan, owner):
    if plansource is invalid:
        return false
    if plan is NULL:
        return false
    if plan != plansource->gplan:
        return false
    if plan->is_valid is false:
        return false
    if saved search_path != current search_path:
        return false

    if owner is provided:
        increase refcount and record owner

    return true

6.4 哪些接口适合何时调用

这一节回答"该代码何时适合调用"。

接口 适合调用时机 不适合调用时机
CreateCachedPlan 需要构造可重用计划源,且后续还要 CompleteCachedPlan 只执行一次且不想复制数据
CreateOneShotCachedPlan 一次性执行路径,想避免复制和失效跟踪 需要长期缓存、需要 SaveCachedPlan
CompleteCachedPlan 已经拿到 analyze/rewrite 后的 Query 列表 还没做 analyze/rewrite
SaveCachedPlan 准备把计划源挂入长期结构或全局可重用容器 one-shot 计划、尚未 complete
GetCachedPlan 真正即将执行,需要可执行 PlannedStmt 仅想查看计划源信息,不准备执行
ReleaseCachedPlan 执行结束或不再需要该 plan 引用时 还在被 Portal/owner 使用时贸然提前释放
CachedPlanAllowsSimpleValidityCheck 已拿到 generic plan,想为简单表达式建立快路径资格 plan 不是 generic,或不是"极简单"查询
CachedPlanIsSimplyValid 已经确认该 plan 允许 simple validity check 从未经过 CachedPlanAllowsSimpleValidityCheck 认证
GetCachedExpression 想缓存标量表达式的 planned form 你需要完整 SQL 级缓存与自动重建
ResetPlanCache 全局环境变化后需要批量失效计划 普通执行路径里频繁调用

7. 调用链补充

本节补充谁在调用 plancache,以及为什么这样调用。

7.1 SQL PREPARE / EXECUTE 调用链

入口文件:

  • src/backend/commands/prepare.c
PREPARE 路径

PrepareQuery() 的主链路:

text 复制代码
PrepareQuery
  -> CreateCachedPlan
  -> pg_analyze_and_rewrite_varparams
  -> CompleteCachedPlan
  -> StorePreparedStatement
  -> SaveCachedPlan

设计原因:

  • PREPARE 必须保存原始语句和参数类型,供后续多次执行复用
  • fixed_result = true,因为 SQL 层 prepared statement 默认不允许结果结构悄悄变化
  • StorePreparedStatement() 最后才 SaveCachedPlan(),这样中途报错不会把半成品挂进长期存储
EXECUTE 路径

ExecuteQuery() 的主链路:

text 复制代码
ExecuteQuery
  -> FetchPreparedStatement
  -> EvaluateParams
  -> CreateNewPortal
  -> GetCachedPlan
  -> PortalDefineQuery
  -> PortalStart
  -> PortalRun
  -> PortalDrop

这里 GetCachedPlan() 负责:

  • 检查计划是否过期
  • 必要时重规划
  • 按参数决定 generic/custom

注意源码中特意强调:

  • GetCachedPlan()PortalDefineQuery() 之间不要放会抛错的逻辑

原因是:

  • GetCachedPlan() 已经增加了 plan refcount
  • 如果中间抛错,可能导致 plan 引用泄漏

7.2 extended query protocol 调用链

入口文件:

  • src/backend/tcop/unvdb.c

主要包括 Parse/Bind 两段。

Parse message 路径

exec_parse_message() 的关键链路:

text 复制代码
Parse message
  -> pg_parse_query
  -> CreateCachedPlan
  -> pg_analyze_and_rewrite_varparams
  -> CompleteCachedPlan
  -> StorePreparedStatement 或 SaveCachedPlan

说明:

  • named statement:进入 prepared statement 哈希表
  • unnamed statement:保存到 unnamed_stmt_psrc

这条路径和 SQL PREPARE 类似,但入口来自前后端协议,而不是 SQL utility。

Bind message 路径

exec_bind_message() 的关键链路:

text 复制代码
Bind message
  -> 找到 CachedPlanSource
  -> 解析并转换参数
  -> CreatePortal
  -> GetCachedPlan
  -> PortalDefineQuery
  -> PortalStart

这里有几个值得注意的设计点:

  1. 在调用 GetCachedPlan() 之前,先把 query string、stmt name、参数值等复制到 portal 上下文。
  2. 如果参数输入或重新规划可能需要快照,就提前 PushActiveSnapshot()
  3. 参数都被标记为 PARAM_FLAG_CONST,这样 custom plan 可以充分利用参数常量。

这和 plancache 的设计是吻合的:

  • generic plan 用 boundParams = NULL
  • custom plan 用真实参数值驱动优化器

7.3 SPI 调用链

入口文件:

  • src/backend/executor/spi.c

SPI 是存储过程语言、触发器、内部执行框架的重要基础设施,因此它是 plancache 的大调用者。

SPI_prepare 路径

对应内部函数 _SPI_prepare_plan()

text 复制代码
_SPI_prepare_plan
  -> raw_parser
  -> 对每个 RawStmt:
       CreateCachedPlan
       pg_analyze_and_rewrite_withcb / fixedparams
       CompleteCachedPlan
  -> plan->plancache_list

特点:

  • 生成的是 unsaved CachedPlanSource
  • SPIPlan 可以包含多个 CachedPlanSource
  • queryEnv 会透传到 analyze/rewrite 过程
SPI one-shot 路径

对应 _SPI_prepare_oneshot_plan()

text 复制代码
_SPI_prepare_oneshot_plan
  -> raw_parser
  -> 对每个 RawStmt:
       CreateOneShotCachedPlan
  -> 延迟到执行时再 CompleteCachedPlan

为什么要有 one-shot:

  • 减少拷贝和 invalidation 开销
  • 适合一次性执行
  • 避免前一条 DDL 改写后一条语句时过早分析带来的问题
SPI_execute_plan 路径

对应 _SPI_execute_plan()

text 复制代码
_SPI_execute_plan
  -> 遍历 plan->plancache_list
  -> 如果是 oneshot:
       analyze + rewrite
       CompleteCachedPlan
  -> GetCachedPlan(plansource, options->params, plan_owner, _SPI_current->queryEnv)
  -> 执行 stmt_list
  -> ReleaseCachedPlan

设计重点:

  • plan_owner 只对 saved plan 有意义
  • queryEnv 会继续传给 GetCachedPlan()
  • one-shot 计划直到执行点才真正补全 Query 层缓存
SPI_cursor_open 路径

SPI 打开游标时也会走 plancache

text 复制代码
SPI_cursor_open_internal
  -> GetCachedPlan
  -> 如果 plan 未保存:
       copy stmt_list 到 portal 上下文
       ReleaseCachedPlan
  -> PortalDefineQuery
  -> PortalStart

设计原因:

  • portal 不应依赖一个短生命周期的 unsaved CachedPlanSource
  • 对 unsaved plan,宁可复制 stmt_list,也不要让 portal 持有不安全依赖
SPI_plan_get_cached_plan

这是一个专门给 PL/pgSQL 等上层使用的桥接函数:

text 复制代码
SPI_plan_get_cached_plan
  -> 如果不是 oneshot 且仅有一个 CachedPlanSource
  -> GetCachedPlan(plansource, NULL, owner, _SPI_current->queryEnv)
  -> 返回 generic plan

它的用途是:

  • 上层语言拿到一个稳定的 generic plan 引用
  • 再结合 CachedPlanAllowsSimpleValidityCheck() 走简单表达式快路径

7.4 PL/pgSQL 调用链

入口文件:

  • src/pl/plpgsql/src/pl_exec.c

PL/pgSQLplancache 结合最紧的部分,不是普通 SQL 执行,而是"简单表达式优化"。

首次识别 simple expression

exec_simple_check_plan() 的关键链路:

text 复制代码
exec_simple_check_plan
  -> SPI_plan_get_plan_sources
  -> exec_is_simple_query
  -> SPI_plan_get_cached_plan
  -> CachedPlanAllowsSimpleValidityCheck
  -> 保存 expr_simple_plansource / expr_simple_plan

含义:

  • 先判定查询是不是足够简单
  • 再确认 plancache 也认可这个 plan 可以走快路径
后续重用 simple expression

后续执行时:

text 复制代码
exec_eval_simple_expr / related path
  -> EnsurePortalSnapshotExists
  -> CachedPlanIsSimplyValid
  -> 若有效则直接复用
  -> 若无效:
       SPI_plan_get_cached_plan
       CachedPlanAllowsSimpleValidityCheck
       exec_save_simple_expr

这条路径依赖 plancache 提供的两个快路径函数:

  • CachedPlanAllowsSimpleValidityCheck
  • CachedPlanIsSimplyValid

因此可以把 plancache 看成 PL/pgSQL 简单表达式优化的核心支点之一。

7.5 QueryEnvironment / ENR 调用链

相关文件:

  • src/backend/utils/misc/queryenvironment.c
  • src/backend/executor/spi.c

QueryEnvironment 是一个"查询环境容器",目前主要用于保存 ENR(Ephemeral Named Relation,临时命名关系)信息。

基本能力:

text 复制代码
create_queryEnv
  -> 创建 QueryEnvironment

register_ENR / unregister_ENR
  -> 注册或注销 ENR

get_ENR / get_visible_ENR_metadata
  -> 查询 ENR 元数据

SPI 里的链路:

text 复制代码
SPI_register_relation
  -> 如果 _SPI_current->queryEnv 为空则 create_queryEnv
  -> register_ENR

SPI 执行 / 准备
  -> pg_analyze_and_rewrite_* (..., _SPI_current->queryEnv)
  -> GetCachedPlan(..., _SPI_current->queryEnv)
  -> portal->queryEnv = _SPI_current->queryEnv

这意味着:

  • QueryEnvironment 不是 plancache 自己维护的
  • 但它会参与 analyze/rewrite/plan 阶段
  • 所以 plancache 的重规划逻辑必须接受 queryEnv

换句话说,plancache 缓存的不是"完全脱离上下文的抽象 SQL",而是"在某个查询环境中分析和规划过的 SQL"

8. generic plan 与 custom plan 选择

核心函数:

  • choose_custom_plan
  • cached_plan_cost

决策规则可概括为:

  1. oneshot 总是 custom
  2. 无参数时没有 custom 的意义
  3. plan_cache_mode 或 cursor options 可强制决策
  4. 前 5 次先走 custom,积累统计
  5. 再比较 generic_cost 和平均 custom 成本

注意:

  • custom 成本包含规划开销
  • generic 成本通常只算执行成本

因此 generic plan 只要"执行成本低于 custom 的平均总成本"就值得复用。

9. transient plan 说明

9.1 transient plan 是什么

transient plan 指的是:

  • 当前可以生成并执行
  • 但它的正确性还依赖当前事务可见性边界
  • 一旦 TransactionXmin 变化,就应该重新规划

对应字段:

  • PlannedStmt.transientPlan
  • PlannerGlobal.transientPlan
  • CachedPlan.saved_xmin

可以把它理解成:

  • 普通 plan:依赖 schema/object/search_path/RLS 等环境
  • transient plan:除了上面这些,还额外依赖当前事务快照边界

9.2 plancache 如何处理 transient plan

planner 生成 PlannedStmt 后,会把 glob->transientPlan 写入 PlannedStmt.transientPlan

之后 BuildCachedPlan() 会扫描所有 PlannedStmt

  • 如果发现任一 plannedstmt->transientPlan = true
  • 则整个 CachedPlan 被视为 transient
  • 并记录当前 TransactionXminplan->saved_xmin

后续 CheckCachedPlan() 在复用 generic plan 前会检查:

  • saved_xmin 是否有效
  • 是否仍等于当前 TransactionXmin

如果不相等,就把 plan 标记失效并触发重建。

9.3 planner 通常在什么场景下置位 transientPlan

在当前代码中,planner 的明确置位点主要位于:

  • src/backend/optimizer/util/plancat.c

核心场景是:

  • 某个索引 indisvalid = true
  • 但是 indcheckxmin = true
  • 并且该索引对应 catalog tuple 的 xmin 还没有早于当前 TransactionXmin

此时 planner 会:

  • 不使用这个索引
  • 同时把 root->glob->transientPlan = true

也就是说,当前规划结果之所以成立,依赖于"这个索引此刻还不能安全使用"这个事务可见性事实

一旦事务边界推进,这个索引可能变得可用,那么原计划就不再是最合适或最安全的复用对象,因此需要重规划。

可以把这段逻辑概括成:

text 复制代码
if index is valid but still behind indcheckxmin horizon:
    mark current plan as transient
    ignore this index for current planning

9.4 为什么会出现 indcheckxmin 这种情况

这通常和索引可见性、HOT 链、以及某些索引构建/状态切换阶段有关。

从 planner 的角度,不需要理解所有底层细节,只需要记住:

  • 某些索引虽然已经"有效"
  • 但对当前事务快照来说还"不能安全用于查询"
  • 如果 planner 因此跳过了该索引,那么生成的计划就不应被长期稳定复用

源码注释也直接提示参考:

  • src/backend/access/heap/README.HOT

9.5 一个直观例子

假设某张表上新出现了一个"对未来事务会可用、但对当前事务还不安全"的索引。

当前事务做规划时:

  • planner 发现这个索引不能安全使用
  • 因此选择 seqscan 或其他 index path
  • 同时把 plan 标记为 transient

后面换到新的事务边界后:

  • 该索引可能已经进入可安全使用状态
  • planner 此时可能会选择完全不同的访问路径

所以旧 plan 不能被盲目复用。

9.6 伪代码

text 复制代码
planner build relation paths:
    for each index on relation:
        if index is invalid:
            skip it
        else if index->indcheckxmin is true
             and index xmin is not older than TransactionXmin:
            mark root->glob->transientPlan = true
            skip this index for current planning
        else:
            index can participate in path generation

BuildCachedPlan:
    if any plannedstmt.transientPlan is true:
        plan.saved_xmin = TransactionXmin
    else:
        plan.saved_xmin = InvalidTransactionId

CheckCachedPlan:
    if saved_xmin is valid and saved_xmin != current TransactionXmin:
        invalidate plan

9.7 目前代码层面的结论

如果严格按当前代码追踪,planner 里 transientPlan 的明确置位点主要就是这一类:

  • indcheckxmin 导致某些索引当前事务不可安全使用

所以可以说:

  • "通常"是这个场景
  • "在当前代码实现里,核心且明确的置位场景也是这个场景"

10. 失效机制

10.1 relation 级失效

PlanCacheRelCallback 负责处理 relation invalidation:

  • 检查 plansource->relationOids
  • 再检查 gplan->stmt_list 中的 plannedstmt->relationOids

这样做的原因是:

  • planner 阶段的依赖可能比 Query 层更多

10.2 syscache 对象失效

PlanCacheObjectCallback 处理 PROCOIDTYPEOID 等对象:

  • 通过 PlanInvalItem(cacheId, hashValue) 精准匹配

10.3 全量失效

PlanCacheSysCallback 和某些外部调用会直接触发:

  • ResetPlanCache

典型入口:

  • DISCARD PLANS
  • DISCARD ALL
  • session_replication_role 变更

10.4 失效回调伪代码

这一节帮助理解 invalidation 回调"为什么只是打标,不直接重建"。

PlanCacheRelCallback 伪代码

适合阅读时机:

  • 你在分析某张表 DDL 后,为什么某些计划失效、某些没有
  • 你想理解 Query 层依赖和 Plan 层依赖为什么都要检查
text 复制代码
function PlanCacheRelCallback(relid):
    for each saved plansource:
        if plansource already invalid:
            continue
        if statement does not require revalidation:
            continue

        if relid matches plansource->relationOids:
            invalidate plansource
            invalidate gplan if any

        if gplan still valid:
            scan gplan->stmt_list relation dependencies
            if relid matches any plannedstmt dependency:
                invalidate gplan only

    for each cached expression:
        if expression already invalid:
            continue
        if relid matches expression relation dependencies:
            invalidate expression
PlanCacheObjectCallback 伪代码

适合阅读时机:

  • 你在分析函数替换、类型变化为什么会导致计划失效
text 复制代码
function PlanCacheObjectCallback(cacheid, hashvalue):
    for each saved plansource:
        if plansource already invalid:
            continue
        if statement does not require revalidation:
            continue

        if any plansource->invalItems match (cacheid, hashvalue):
            invalidate plansource
            invalidate gplan if any

        if gplan still valid:
            if any plannedstmt->invalItems match (cacheid, hashvalue):
                invalidate gplan only

    for each cached expression:
        if any expression->invalItems match (cacheid, hashvalue):
            invalidate expression
ResetPlanCache 伪代码

适合阅读时机:

  • 你在看 DISCARD PLANSDISCARD ALL、全局环境切换
text 复制代码
function ResetPlanCache():
    for each saved plansource:
        if already invalid:
            continue
        if statement does not require revalidation:
            continue

        invalidate plansource
        invalidate gplan if any

    for each cached expression:
        invalidate expression

关键点:

  • 回调只打失效标记,不做 analyze/rewrite/plan
  • 真正重建延迟到下一次 GetCachedPlan
  • 这样做能把失效广播的成本压到最低

11. 锁策略

11.1 planning 锁

AcquirePlannerLocks / ScanQueryForLocks 负责在 Query 层递归获取锁:

  • relation
  • subquery
  • CTE
  • SubLink

11.2 execution 锁

AcquireExecutorLocks 负责对 PlannedStmtrtable 获取执行锁。

11.3 为什么要"加锁后再检查一次 is_valid"

因为存在竞争窗口:

text 复制代码
先检查有效
  -> 还没拿到锁
  -> 期间收到 invalidation
  -> 如果不复查,就可能使用过期计划

所以代码采用:

text 复制代码
检查看起来有效
  -> 获取锁
  -> 再检查有效位

这正是 RevalidateCachedQuery()CheckCachedPlan() 里的关键防线。

12. 内存与生命周期

12.1 CachedPlanSource 生命周期

  • 刚创建时,通常挂在当前工作内存上下文下
  • SaveCachedPlan() 后迁入 CacheMemoryContext
  • DropCachedPlan() 删除 plansource 本体

12.2 CachedPlan 生命周期

  • refcount 决定
  • plansource->gplan 会持有一个引用
  • portal / ResourceOwner / 当前执行也可能持有引用
  • ReleaseCachedPlan() 降到 0 后释放 plan->context

12.3 one-shot 生命周期

  • 不复制底层树
  • 不加入全局失效链表
  • 不支持保存和复制
  • 生命周期绑定调用方上下文

12.4 引用持有关系矩阵

这一节专门回答一个常见问题:谁在持有 CachedPlan 的引用?

持有者 持有方式 何时建立 何时释放
CachedPlanSource->gplan 永久引用 建立 generic plan 时 ReleaseGenericPlan / DropCachedPlan
当前执行者 临时引用 GetCachedPlan 返回前 ReleaseCachedPlan
Portal 通过 PortalDefineQuery 关联 获取 plan 后定义 portal portal 销毁时
ResourceOwner 资源拥有者登记 GetCachedPlan(owner != NULL) owner 清理或显式 ReleaseCachedPlan
simple expression resowner 专用快路径持有 CachedPlanAllowsSimpleValidityCheck 事务结束或重规划

可以把 CachedPlan 想成"多方共享的只读执行计划对象":

  • plansource 持有它,是为了 generic 复用
  • Portal 持有它,是为了延迟执行与游标执行
  • ResourceOwner 持有它,是为了异常路径也能兜底释放

12.5 生命周期常见组合

组合 1:长期 prepared statement
text 复制代码
CreateCachedPlan
  -> CompleteCachedPlan
  -> SaveCachedPlan
  -> 多次 GetCachedPlan / ReleaseCachedPlan
  -> 最终 DropCachedPlan

适合场景:

  • PREPARE/EXECUTE
  • 协议级 named statement
组合 2:SPI 非持久化计划
text 复制代码
CreateCachedPlan
  -> CompleteCachedPlan
  -> 不 SaveCachedPlan
  -> GetCachedPlan
  -> 执行
  -> ReleaseCachedPlan

适合场景:

  • SPI 内部普通计划
  • 生命周期不需要跨更长范围
组合 3:one-shot 执行
text 复制代码
CreateOneShotCachedPlan
  -> 执行前 CompleteCachedPlan
  -> GetCachedPlan
  -> 执行
  -> 不做长期保存

适合场景:

  • 一次性动态执行
  • 不值得做长期 invalidation 跟踪

13. 典型调用模板

这一节从"如果你自己要写调用代码,该怎么接"来总结。

13.1 模板一:实现一个可重用 prepared statement

适合场景:

  • 你要把 SQL 语句保存起来,后续重复执行
text 复制代码
raw = raw_parser(sql)
plansource = CreateCachedPlan(raw, sql, commandTag)

querytree = analyze_and_rewrite(raw, params, queryEnv)

CompleteCachedPlan(plansource,
                   querytree,
                   querytree_context_or_NULL,
                   param_types,
                   num_params,
                   parserSetup,
                   parserSetupArg,
                   cursor_options,
                   fixed_result = true)

SaveCachedPlan(plansource)

for each execution:
    cplan = GetCachedPlan(plansource, boundParams, owner, queryEnv)
    execute(cplan->stmt_list)
    ReleaseCachedPlan(cplan, owner)

调用要点:

  • 如果结果结构不允许变化,fixed_result 应设为 true
  • 如果打算让 ResourceOwner 托管,必须先 SaveCachedPlan

13.2 模板二:实现一次性 SPI 风格执行

适合场景:

  • 只执行一次,且不希望额外复制和 invalidation 跟踪
text 复制代码
raw = raw_parser(sql)
plansource = CreateOneShotCachedPlan(raw, sql, commandTag)

querytree = analyze_and_rewrite(raw, params, queryEnv)

CompleteCachedPlan(plansource,
                   querytree,
                   NULL,
                   param_types,
                   num_params,
                   parserSetup,
                   parserSetupArg,
                   cursor_options,
                   fixed_result = false)

cplan = GetCachedPlan(plansource, boundParams, NULL, queryEnv)
execute(cplan->stmt_list)
ReleaseCachedPlan(cplan, NULL)

调用要点:

  • 不要对 one-shot 调用 SaveCachedPlan
  • 不要指望 one-shot 能自动处理后续 invalidation

13.3 模板三:实现简单表达式快路径

适合场景:

  • 查询足够简单,想用 CachedPlanIsSimplyValid 降低重复检查成本
text 复制代码
cplan = SPI_plan_get_cached_plan(spiplan)

if cplan exists and CachedPlanAllowsSimpleValidityCheck(plansource, cplan, simple_resowner):
    save plansource and cplan for fast path
else:
    fallback to normal execution path

on later execution:
    if CachedPlanIsSimplyValid(plansource, cplan, simple_resowner):
        reuse directly
    else:
        rebuild through normal GetCachedPlan path

调用要点:

  • 只能对已确认"足够简单"的 generic plan 用
  • 不能跳过第一次资格判定,直接调用 CachedPlanIsSimplyValid

14. 常见误用与风险点

14.1 在 one-shot 计划上调用 SaveCachedPlan

这是错误用法。

原因:

  • one-shot 不保证原始树和分析树可长期安全保存
  • 代码中会直接报错

14.2 在 GetCachedPlanPortalDefineQuery 之间插入可能报错的逻辑

这是很危险的用法。

原因:

  • GetCachedPlan 已经增加了 plan refcount
  • 中间报错会导致引用泄漏

14.3 把 CachedPlan 当成可随意修改的对象

这也是错误用法。

原因:

  • generic plan 是可复用对象
  • 多处可能共享同一 CachedPlan
  • 调用方应把它视为只读

14.4 忘记 ReleaseCachedPlan

这是最常见资源泄漏点之一。

后果:

  • refcount 无法归零
  • plan->context 无法释放
  • 长期运行可能累积内存泄漏

14.5 用 CachedPlanIsValid 代替完整重验证

这通常不够安全。

原因:

  • CachedPlanIsValid 只读 plansource->is_valid
  • 它不处理并发窗口,不获取锁,不保证 race-free

14.6 把 QueryEnvironment 变化当成无关因素

这在 SPI/ENR 场景下容易出错。

原因:

  • queryEnv 会影响 analyze/rewrite/plan 过程
  • 尤其是 ENR 可见性会改变名字解析与对象绑定结果

15. 读代码建议

建议按下面顺序读源码:

  1. src/include/utils/plancache.h
  2. src/backend/utils/cache/plancache.c 文件头与结构化注释
  3. RevalidateCachedQuery
  4. BuildCachedPlan
  5. choose_custom_plan
  6. GetCachedPlan
  7. prepare.cunvdb.cspi.c 的调用点
  8. pl_exec.c 中 simple expression 快路径

16. 结论

plancache 的核心不是"把计划存起来"这么简单,而是:

  • 按层缓存 Query 与 Plan
  • 把运行环境纳入合法性判断
  • 依赖 invalidation 做懒失效
  • 在执行前重验证并补锁
  • 自适应选择 generic/custom
  • 给上层语言提供快路径能力

如果要继续深入,下一步建议顺着下面两条继续读:

  1. extract_query_dependencies() 如何抽取 relation/syscache 依赖

  2. Portal 生命周期如何承接 CachedPlan 的 refcount

  3. plancache的具体使用场景:prepare、spi、plsql以及PBE协议中的使用

相关推荐
倔强的石头_1 小时前
云原生环境下的存储弹性与自动化:表空间目录动态挂载与冷热分层实践
数据库
无限进步_2 小时前
【C++】从红黑树到 map 和 set:封装设计与迭代器实现
开发语言·数据结构·数据库·c++·windows·github·visual studio
橙子圆1232 小时前
Redis知识2
java·数据库·redis
过期动态2 小时前
【RabbitMQ基础篇】RabbitMQ从入门到实战
java·jvm·数据库·分布式·spring·rabbitmq·intellij-idea
MandalaO_O2 小时前
MySQL:数据库约束
数据库·mysql
刘~浪地球2 小时前
MongoDB聚合管道进阶:数据处理与统计分析
数据库·mongodb
瀚高PG实验室2 小时前
debezium在LANG=zh_CN.UTF-8下,无法解析timestamp类型的列值为BC的字段
服务器·数据库·postgresql·瀚高数据库
刘~浪地球2 小时前
MongoDB索引优化实战:让查询飞起来
数据库·mongodb