# PostgreSQL 18.3 查询优化器流程:`standard_planner()` 源码导读

PostgreSQL 18.3 查询优化器流程:standard_planner() 源码导读

1. 文档目标

本文按照 PostgreSQL 18.3 中 standard_planner() 的执行顺序,讲解默认查询优化器从 QueryPlannedStmt 的完整流程:

版本说明: 本文以 PostgreSQL 官方源码标签

REL_18_3 为准。

该版本的 planner()standard_planner() 都是四参数形式。开发分支中的

函数签名、Hook 或辅助字段可能已经变化,阅读时不要把不同版本源码混在一起。

text 复制代码
1. 初始化 PlannerGlobal
        ↓
2. 判断是否允许并行规划
        ↓
3. 计算 tuple_fraction
        ↓
4. 调用 subquery_planner()
        ↓
5. 获得最终 RelOptInfo
        ↓
6. 选择最优 Path
        ↓
7. Path 转换为 Plan
        ↓
8. 处理游标和调试 Gather
        ↓
9. 完成 Param 和子计划处理
        ↓
10. 修正计划中的引用
        ↓
11. 构造 PlannedStmt
        ↓
12. 设置 JIT 标志
        ↓
13. 清理并返回

需要先明确:standard_planner() 是默认优化器的总调度函数,但它并不亲自实现所有算法。关系代数层面的逻辑变换、基表访问路径生成、连接顺序枚举、成本计算和上层操作规划,会继续委托给其他函数。

本文重点回答以下问题:

  • standard_planner() 的输入为什么已经是 Query
  • PlannerGlobalPlannerInfo 有什么区别?
  • 关系代数等价变换发生在哪里?
  • subquery_planner() 做什么,返回什么?
  • subquery_planner() 返回时是否已经产生最优 Path?
  • RelOptInfo 是什么?
  • 为什么 UPPERREL_FINAL 是完整查询候选实现的最终容器?
  • 最优 Path 由哪个函数选择?
  • Path 转换成 Plan 到底转换了什么?
  • Partial PathGatherGather Merge 在流程中的位置是什么?
  • Param、SubPlan、extParamallParam 为什么需要最后处理?
  • set_plan_references() 为什么在 create_plan() 之后?
  • PlannedStmt 为什么不能只用一个 Plan * 代替?
  • JIT 为什么在计划选定后才决定?

2. standard_planner() 位于整个 SQL 流程的什么位置

standard_planner() 并不是 SQL 进入 PostgreSQL 后调用的第一个函数。在进入优化器之前,SQL 已经完成词法分析、语法分析、语义分析和查询重写。

简单查询协议的主调用链可以概括为:

text 复制代码
exec_simple_query()
    ↓
pg_parse_query()
    ↓
raw_parser()
    ↓
RawStmt / SelectStmt
    ↓
pg_analyze_and_rewrite_fixedparams()
    ├── parse_analyze_fixedparams()
    └── pg_rewrite_query()
    ↓
一个或多个 Query
    ↓
pg_plan_queries()
    ↓
pg_plan_query()
    ↓
planner()
    ↓
standard_planner()

主要源码位置:

text 复制代码
src/backend/tcop/postgres.c
    exec_simple_query()
    pg_parse_query()
    pg_analyze_and_rewrite_fixedparams()
    pg_plan_queries()

src/backend/optimizer/plan/planner.c
    planner()
    standard_planner()
    subquery_planner()
    grouping_planner()

因此,standard_planner() 的参数:

c 复制代码
Query *parse

不是原始语法树,而是经过语义分析和规则重写的查询树。

可以把主要数据转换记成:

text 复制代码
SQL 字符串
    ↓
RawStmt / SelectStmt       原始语法结构
    ↓
Query                      已解析表、列、函数和类型的语义查询树
    ↓
PlannerInfo / RelOptInfo   优化状态与逻辑关系
    ↓
Path                       候选物理实现
    ↓
Plan                       最终静态执行计划
    ↓
PlannedStmt                完整的语句级规划结果
    ↓
PlanState                  执行器运行时状态

3. 默认优化器入口

优化器对外入口是:

c 复制代码
PlannedStmt *
planner(Query *parse,
        const char *query_string,
        int cursorOptions,
        ParamListInfo boundParams)

planner() 首先检查扩展是否注册了 planner_hook

c 复制代码
if (planner_hook)
    result = planner_hook(parse,
                          query_string,
                          cursorOptions,
                          boundParams);
else
    result = standard_planner(parse,
                              query_string,
                              cursorOptions,
                              boundParams);

所以需要区分:

text 复制代码
planner()
    优化器公开入口和扩展钩子入口

standard_planner()
    PostgreSQL 内置默认优化器的总入口

本文讨论的是 standard_planner()


4. 贯穿全文的示例 SQL

使用下面的查询贯穿整个优化过程:

sql 复制代码
SELECT c.region,
       SUM(o.amount) AS total
FROM customer AS c
JOIN orders AS o
  ON o.customer_id = c.id
WHERE c.active = true
  AND o.order_date >= DATE '2026-01-01'
GROUP BY c.region
HAVING SUM(o.amount) > 10000
ORDER BY total DESC
LIMIT 10;

假设存在:

text 复制代码
customer
    主键索引 customer_pkey(id)
    索引 customer_active_idx(active)

orders
    索引 orders_customer_id_idx(customer_id)
    索引 orders_order_date_idx(order_date)

该查询包含:

text 复制代码
两个基本表
两个过滤条件
一个等值连接
聚合
HAVING
排序
LIMIT

因此可以观察优化器的主要阶段。

它进入 standard_planner() 时,Query 大致表示:

text 复制代码
Query
├── commandType = CMD_SELECT
├── hasAggs = true
├── rtable
│   ├── customer
│   ├── orders
│   └── customer JOIN orders
├── jointree
│   ├── JoinExpr
│   │   └── o.customer_id = c.id
│   └── quals
│       ├── c.active = true
│       └── o.order_date >= DATE '2026-01-01'
├── targetList
│   ├── c.region
│   └── SUM(o.amount)
├── groupClause
│   └── c.region
├── havingQual
│   └── SUM(o.amount) > 10000
├── sortClause
│   └── total DESC
└── limitCount
    └── 10

5. 阅读前必须掌握的六个数据结构

5.1 Query

Query 描述查询的语义:

text 复制代码
访问哪些关系
输出哪些表达式
FROM 和 JOIN 是什么
WHERE、HAVING 条件是什么
是否有聚合、窗口函数或子查询
是否有排序、DISTINCT 和 LIMIT

它回答的是:

查询想得到什么结果?

它不回答:

应该使用 Seq Scan 还是 Index Scan?应该使用 Hash Join 还是 Nested Loop?


5.2 PlannerGlobal

PlannerGlobal 表示一次完整 planner() 调用共享的全局状态。

一条 SQL 可能包含多个 Query 层级:

text 复制代码
顶层 Query
├── CTE Query
├── FROM 子查询
└── SubLink Query

每个 Query 层级有自己的 PlannerInfo,但它们共享同一个 PlannerGlobal


5.3 PlannerInfo

PlannerInfo 表示一个 Query 层级的规划上下文:

text 复制代码
当前 Query
父 Query 的 PlannerInfo
当前查询层级
基本表 RelOptInfo 数组
JOIN RelOptInfo
EquivalenceClass
RestrictInfo
Upper RelOptInfo
候选 Path

关系是:

text 复制代码
PlannerGlobal
├── PlannerInfo:顶层 Query,query_level = 1
├── PlannerInfo:一级子查询,query_level = 2
└── PlannerInfo:二级子查询,query_level = 3

5.4 RelOptInfo

RelOptInfo 表示一个逻辑关系或逻辑结果。

它可能代表:

text 复制代码
一张基本表
多张表连接后的结果
聚合后的结果
窗口计算后的结果
排序后的结果
完整查询的最终结果

同一个 RelOptInfo 中保存能够产生相同逻辑结果的不同 Path。


5.5 Path

Path 表示一种候选物理实现:

text 复制代码
SeqScan Path
IndexPath
BitmapHeapPath
NestPath
HashPath
MergePath
AggPath
SortPath
GatherPath
LimitPath

这里需要区分"图中的候选路径名称"和"源码中的独立 C 结构体"。例如,

顺序扫描候选通常是一个 pathtype = T_SeqScan 的通用 Path,并不存在

名为 SeqScanPath 的独立结构体;聚合通常使用 AggPath,再通过

aggstrategy 区分 AGG_HASHEDAGG_SORTED 等策略。本文的结构图会尽量

使用源码中的真实名称,并在必要时用空格分开的概念标签辅助阅读。

Path 主要服务于优化器,保存:

text 复制代码
startup_cost
total_cost
rows
pathkeys
参数化信息
并行属性
子 Path

5.6 PlanPlannedStmt

Plan 是执行器能理解的静态计划节点:

text 复制代码
SeqScan
IndexScan
NestLoop
HashJoin
MergeJoin
Agg
Sort
Gather
Limit

PlannedStmt 是语句级容器:

text 复制代码
PlannedStmt
├── 主 Plan Tree
├── SubPlan
├── 最终 rtable
├── 权限信息
├── 行锁信息
├── 分区裁剪信息
├── 参数类型
├── 计划缓存依赖
├── 并行模式信息
└── JIT 标志

6. standard_planner() 的 13 步流程

第 1 步:初始化 PlannerGlobal

核心代码:

c 复制代码
glob = makeNode(PlannerGlobal);

随后初始化大量全局字段:

c 复制代码
glob->boundParams = boundParams;
glob->subplans = NIL;
glob->subpaths = NIL;
glob->subroots = NIL;
glob->rewindPlanIDs = NULL;
glob->finalrtable = NIL;
glob->partPruneInfos = NIL;
glob->relationOids = NIL;
glob->invalItems = NIL;
glob->paramExecTypes = NIL;
glob->parallelModeNeeded = false;
glob->partition_directory = NULL;

6.1.1 为什么要有全局对象

如果查询包含子查询:

sql 复制代码
SELECT *
FROM orders
WHERE amount > (
    SELECT AVG(amount)
    FROM order_history
);

可能形成:

text 复制代码
PlannerGlobal
├── 顶层 PlannerInfo:orders
└── 子查询 PlannerInfo:order_history

两个 Query 层级需要共同维护:

text 复制代码
subplans
subroots
PARAM_EXEC 类型
最终范围表
关系依赖
分区裁剪信息

如果全部放进某个局部 PlannerInfo,跨 Query 层级的子计划和参数就难以统一管理。

6.1.2 重要字段分组

字段 作用
boundParams 已知的外部参数值
subplans 最终产生的子计划
subpaths 子计划对应的 Path
subroots 子计划对应的 PlannerInfo
finalrtable 所有 Query 层级合并后的最终范围表
paramExecTypes 内部 PARAM_EXEC 参数类型
partPruneInfos 执行期分区裁剪信息
relationOids 计划依赖的关系 OID
invalItems 计划缓存失效依赖
rewindPlanIDs 需要支持 rewind 的子计划
partition_directory 规划期间的分区描述缓存

6.1.3 本步骤的输入和输出

text 复制代码
输入:
    boundParams

输出:
    一个初始化完成的 PlannerGlobal

本步骤没有生成 Path,也没有进行关系代数重写。


第 2 步:判断是否允许并行规划

首先执行低成本检查:

c 复制代码
if ((cursorOptions & CURSOR_OPT_PARALLEL_OK) != 0 &&
    IsUnderPostmaster &&
    parse->commandType == CMD_SELECT &&
    !parse->hasModifyingCTE &&
    max_parallel_workers_per_gather > 0 &&
    !IsParallelWorker())

条件含义:

条件 含义
CURSOR_OPT_PARALLEL_OK 调用方允许考虑并行计划
IsUnderPostmaster 当前是正常服务器后端进程
CMD_SELECT 当前是可并行考虑的查询命令
!hasModifyingCTE 没有修改数据的 CTE
max_parallel_workers_per_gather > 0 配置允许使用 worker
!IsParallelWorker() 当前进程本身不是 worker

低成本检查通过后,再遍历 Query 树:

c 复制代码
glob->maxParallelHazard =
    max_parallel_hazard(parse);

glob->parallelModeOK =
    glob->maxParallelHazard != PROPARALLEL_UNSAFE;

max_parallel_hazard() 会检查:

text 复制代码
targetList
WHERE 和 JOIN 条件
HAVING
函数调用
子查询
CTE

函数和表达式可能是:

text 复制代码
PROPARALLEL_SAFE
PROPARALLEL_RESTRICTED
PROPARALLEL_UNSAFE

6.2.1 parallelModeOK 不等于最终一定并行

c 复制代码
glob->parallelModeOK = true;

只表示:

后续允许生成和比较 Parallel Path。

是否最终选择并行计划,还要取决于:

text 复制代码
Parallel Path 是否能够生成
计划是否 parallel_safe
worker 数量
parallel_setup_cost
parallel_tuple_cost
并行方案总成本

6.2.2 本步骤的输入和输出

text 复制代码
输入:
    Query *parse
    cursorOptions
    并行相关 GUC

输出:
    glob->maxParallelHazard
    glob->parallelModeOK
    glob->parallelModeNeeded 的初始值

本步骤只判断并行资格,不生成 Gather,也不选择并行计划。


第 3 步:计算 tuple_fraction

核心代码:

c 复制代码
if (cursorOptions & CURSOR_OPT_FAST_PLAN)
{
    tuple_fraction = cursor_tuple_fraction;

    if (tuple_fraction >= 1.0)
        tuple_fraction = 0.0;
    else if (tuple_fraction <= 0.0)
        tuple_fraction = 1e-10;
}
else
{
    tuple_fraction = 0.0;
}

6.3.1 tuple_fraction 的含义

tuple_fraction 表示:

调用方预计会消费最终结果中的多少元组。

它不是:

text 复制代码
WHERE 条件选择率
表过滤后剩余比例
JOIN 选择率

一般含义:

text 复制代码
tuple_fraction = 0
    预计读取全部结果

0 < tuple_fraction < 1
    预计读取结果的一部分,数值表示比例

tuple_fraction >= 1
    在部分内部场景中表示预计读取的绝对行数

普通查询通常从:

c 复制代码
tuple_fraction = 0.0;

开始,也就是优先考虑完整执行的 total_cost

LIMIT 会在后续规划过程中进一步影响结果消费目标。游标也可以通过 CURSOR_OPT_FAST_PLAN 使用 cursor_tuple_fraction,但游标不是本文重点。

6.3.2 为什么它会改变最优 Path

Path 同时具有:

c 复制代码
startup_cost;
total_cost;

只读取部分结果时,近似成本为:

text 复制代码
fractional_cost
    = startup_cost
    + fraction × (total_cost - startup_cost)

例如:

text 复制代码
Path A:启动快,但完整执行慢
startup_cost = 5
total_cost   = 1000

Path B:启动慢,但完整执行快
startup_cost = 100
total_cost   = 500

读取全部结果时选择 B;只读取很少结果时可能选择 A。

6.3.3 贯穿示例

示例查询包含:

sql 复制代码
LIMIT 10

虽然 standard_planner() 初始可能设置:

c 复制代码
tuple_fraction = 0.0;

grouping_planner() 处理 LIMIT 时,会利用预计总行数和 limitCount 调整规划目标,更重视能够快速得到前 10 行的有序 Path。

6.3.4 本步骤的输入和输出

text 复制代码
输入:
    cursorOptions
    cursor_tuple_fraction

输出:
    tuple_fraction

本步骤不生成 Path,只确定后续成本选择的目标。


第 4 步:调用 subquery_planner()

核心代码:

c 复制代码
root = subquery_planner(glob,
                        parse,
                        NULL,
                        false,
                        tuple_fraction,
                        NULL);

这是 standard_planner() 中最重要的一步。

6.4.1 subquery_planner() 的作用

它负责:

对一个完整 Query 层级进行逻辑预处理和物理优化,返回该层级的 PlannerInfo

返回值是:

c 复制代码
PlannerInfo *root;

它不是:

text 复制代码
Plan
PlannedStmt
最终 best_path

第一次调用时:

c 复制代码
parent_root == NULL

所以处理的是顶层 Query,而不是子查询。

遇到真正的子 Query 时,规划器会再次调用 subquery_planner(),并设置:

text 复制代码
parent_root != NULL
query_level = parent_root->query_level + 1

6.4.2 第 4 步内部主调用链

可以将它概括为:

text 复制代码
subquery_planner()
    │
    ├── 创建 PlannerInfo
    ├── 处理 CTE 和 SubLink
    ├── 子查询提升
    ├── 表达式预处理
    ├── 外连接化简
    ├── 继承和分区展开
    │
    ▼
grouping_planner()
    │
    ├── 普通关系规划
    ├── GROUP BY / Aggregate
    ├── Window
    ├── DISTINCT
    ├── ORDER BY
    └── LIMIT
    │
    ▼
query_planner()
    │
    ├── 建立基本表 RelOptInfo
    ├── 分解 WHERE 和 JOIN 条件
    ├── 建立 RestrictInfo
    ├── 建立 EquivalenceClass
    ├── 生成基本表 Path
    └── 枚举连接关系和 Join Path

6.4.3 关系代数等价变换在哪里

在 13 步流程中,关系代数相关逻辑主要集中在第 4 步。

但还需要区分进入 standard_planner() 前的 Query Rewriter:

text 复制代码
进入 standard_planner() 之前:
    pg_rewrite_query()
    负责视图、Rule System 和 RLS 展开

第 4 步 subquery_planner():
    子查询提升
    SubLink 转 JOIN
    表达式简化
    简单 UNION ALL 展开
    外连接化简

第 4 步内部 query_planner():
    谓词分解
    等价类构造
    隐含条件推导

第 4 步内部 Join Search:
    利用连接交换律和结合律枚举合法连接顺序

典型逻辑变换包括:

text 复制代码
EXISTS SubLink
    → Semi Join

可以安全提升的 FROM 子查询
    → 合并到父 Query

LEFT JOIN + 拒绝 NULL 的 WHERE 条件
    → Inner Join

a.id = b.id,b.id = 10
    → 推导 a.id = 10

PostgreSQL 并不会为每个连接顺序复制一棵 Query 树。不同连接顺序会在后续表示为同一个 JOIN RelOptInfo 中的不同 Path。

6.4.4 基本表 Path 生成

query_planner() 最终会进入:

text 复制代码
make_one_rel()
    ↓
set_base_rel_sizes()
    ↓
set_base_rel_pathlists()

customer 可能生成:

text 复制代码
RelOptInfo(customer)
├── SeqScan Path
├── IndexPath(customer_active_idx)
└── BitmapHeapPath

orders 可能生成:

text 复制代码
RelOptInfo(orders)
├── SeqScan Path
├── IndexPath(orders_order_date_idx)
├── IndexPath(orders_customer_id_idx)
└── BitmapHeapPath

每个 Path 的成本由对应的 cost_xxx() 计算。

6.4.5 连接顺序和连接方法枚举

两个表时可能生成:

text 复制代码
RelOptInfo({customer, orders})
├── HashPath
├── NestPath
└── MergePath

更多表时,标准动态规划搜索按关系集合大小逐层进行:

text 复制代码
Level 1
{A} {B} {C}

Level 2
{A,B} {A,C} {B,C}

Level 3
{A,B,C}

主要调用链:

text 复制代码
make_one_rel()
    ↓
make_rel_from_joinlist()
    ↓
standard_join_search()
    ↓
join_search_one_level()
    ↓
add_paths_to_joinrel()

外连接、半连接、反连接和 LATERAL 依赖会通过 SpecialJoinInfo 等结构限制连接顺序,不能随意应用交换律和结合律。

6.4.6 Partial Path 的位置

普通完整 Path 保存在:

c 复制代码
rel->pathlist;

并行 Partial Path 保存在:

c 复制代码
rel->partial_pathlist;

Partial Path 执行一次只产生关系结果的一部分,多个 worker 的结果通过:

text 复制代码
Gather
Gather Merge

组合成完整结果。

典型结构:

text 复制代码
GatherPath
└── Partial SeqScan Path

需要区分三个属性:

text 复制代码
parallel_safe
    允许在 worker 中执行

parallel_aware
    节点知道并行环境并协调工作划分

partial_pathlist
    每个执行者只产生部分结果的候选 Path

parallel_safe 的 Path 不一定是 Partial Path。

6.4.7 聚合、排序和 LIMIT 的 Upper Path

示例查询还会继续产生:

text 复制代码
UPPERREL_GROUP_AGG
├── AggPath (AGG_HASHED)
└── AggPath (AGG_SORTED)

UPPERREL_ORDERED
├── SortPath
├── IncrementalSortPath
└── 已经满足排序要求的 Path

UPPERREL_FINAL
└── LimitPath 或最终完成 Path

6.4.8 subquery_planner() 是否已经生成最优 Path

准确答案是:

它已经生成并剪枝候选 Path,也通常已经为各个 RelOptInfo 记录最便宜的启动 Path 和总成本 Path,但顶层最终使用的 best_path 在返回后才明确选出。

返回时已经完成:

text 复制代码
Path 生成
成本计算
add_path() 剪枝
set_cheapest() 记录 cheapest Path
Upper RelOptInfo 构造

standard_planner() 仍要执行:

c 复制代码
get_cheapest_fractional_path()

根据最终 tuple_fraction 选出顶层 Path。

6.4.9 本步骤的输入和输出

text 复制代码
输入:
    PlannerGlobal
    Query
    tuple_fraction
    父查询 PlannerInfo

输出:
    PlannerInfo *root

root 内部已经包含:
    基本表 RelOptInfo
    JOIN RelOptInfo
    Upper RelOptInfo
    RestrictInfo
    EquivalenceClass
    剪枝后的候选 Path

第 5 步:获得最终 RelOptInfo

核心代码:

c 复制代码
final_rel =
    fetch_upper_rel(root,
                    UPPERREL_FINAL,
                    NULL);

6.5.1 RelOptInfo 是什么

RelOptInfo 表示:

一个逻辑结果,以及产生这个结果的候选物理实现和相关优化信息。

核心字段可以简化为:

c 复制代码
RelOptKind  reloptkind;
Relids      relids;
double      rows;
PathTarget *reltarget;

List       *pathlist;
List       *partial_pathlist;

Path       *cheapest_startup_path;
Path       *cheapest_total_path;
List       *cheapest_parameterized_paths;

同一个 RelOptInfo 中的 Path 应当产生相同逻辑结果,但可以具有不同:

text 复制代码
成本
排序属性
参数化属性
并行属性
物理算法

6.5.2 三类 RelOptInfo

text 复制代码
基本表 RelOptInfo
    RelOptInfo(customer)

JOIN RelOptInfo
    RelOptInfo({customer, orders})

Upper RelOptInfo
    UPPERREL_GROUP_AGG
    UPPERREL_ORDERED
    UPPERREL_FINAL

6.5.3 什么是 UPPERREL_FINAL

对于贯穿示例,逻辑阶段大致是:

text 复制代码
customer 和 orders 基本关系
        ↓
customer JOIN orders
        ↓
WHERE 过滤完成
        ↓
GROUP BY / Aggregate
        ↓
HAVING
        ↓
ORDER BY
        ↓
LIMIT
        ↓
UPPERREL_FINAL

UPPERREL_FINAL 表示:

已经满足整条 SQL 最终语义要求的逻辑结果。

它不是物理表,也不是最终 Plan。

6.5.4 "完整查询候选执行方式的最终容器"怎么理解

假设 final_rel->pathlist 中保留两个顶层 Path:

text 复制代码
候选 A
LimitPath
└── SortPath
    └── AggPath (AGG_HASHED)
        └── HashPath
            ├── SeqScan Path (customer)
            └── SeqScan Path (orders)
text 复制代码
候选 B
LimitPath
└── IncrementalSortPath
    └── AggPath (AGG_SORTED)
        └── NestPath
            ├── IndexPath(customer)
            └── IndexPath(orders)

每个顶层 Path 都通过子 Path 指针形成一棵完整 Path Tree。沿着其中任何一个顶层 Path 向下遍历,都能得到从最终输出到底层扫描的端到端实现。

因此:

text 复制代码
final_rel
    是完整查询逻辑结果的 RelOptInfo

final_rel->pathlist
    保存经过剪枝后仍然有价值的完整候选 Path 根节点

这里的"所有候选"不是历史上生成过的每一个 Path。add_path() 已经删除被完全支配的方案。

6.5.5 fetch_upper_rel() 做了什么

概念上:

text 复制代码
在 root 的 Upper Relation 集合中,
查找 kind = UPPERREL_FINAL 的 RelOptInfo。

fetch_upper_rel() 具有获取或创建 Upper RelOptInfo 的能力,但在这里,grouping_planner() 已经创建并填充了最终关系,所以主要是在取回它。

第三个参数为:

c 复制代码
NULL

表示这个最终 Upper Relation 不再按某个具体基本表集合区分;当前 Query 层级通常只有一个最终输出关系。

6.5.6 本步骤的输入和输出

text 复制代码
输入:
    PlannerInfo *root

输出:
    RelOptInfo *final_rel

final_rel 中包含:
    最终输出 PathTarget
    最终结果行数估计
    完整查询候选 Path 根节点

本步骤只是取得最终关系,不重新执行 Path 搜索。


第 6 步:选择最优 Path

核心代码:

c 复制代码
best_path =
    get_cheapest_fractional_path(final_rel,
                                 tuple_fraction);

最终顶层选择函数是:

c 复制代码
get_cheapest_fractional_path()

但最优 Path 的形成经历四个阶段:

text 复制代码
cost_xxx()
    计算候选成本
        ↓
add_path()
    删除被支配候选
        ↓
set_cheapest()
    记录每个 RelOptInfo 的 cheapest Path
        ↓
get_cheapest_fractional_path()
    选择完整查询最终 Path

6.6.1 成本如何产生

典型成本函数:

text 复制代码
cost_seqscan()
cost_index()
cost_bitmap_heap_scan()

initial_cost_nestloop()
final_cost_nestloop()

initial_cost_hashjoin()
final_cost_hashjoin()

initial_cost_mergejoin()
final_cost_mergejoin()

cost_sort()
cost_incremental_sort()
cost_agg()
cost_gather()
cost_gather_merge()

每个 Path 保存:

c 复制代码
path->startup_cost;
path->total_cost;
path->rows;

这些是估算成本,不是实际毫秒数。

6.6.2 add_path() 如何剪枝

新 Path 通过:

c 复制代码
add_path(rel, new_path);

尝试加入:

c 复制代码
rel->pathlist;

核心源码位置:

text 复制代码
src/backend/optimizer/util/pathnode.c
    compare_path_costs_fuzzily()
    add_path_precheck()
    add_path()
    add_partial_path_precheck()
    add_partial_path()
    set_cheapest()

这里的剪枝不是"只留下 total_cost 最低的 Path",而是维护一组:

在成本、排序、参数化、行数和并行安全性等维度上互不支配的 Path。

这组候选可以理解为 Path 的 Pareto Frontier(帕累托前沿)。

每次加入新 Path 时,add_path() 会把它和 rel->pathlist 中已经存在的

Path 逐个比较:

text 复制代码
新 Path 被旧 Path 支配
    ↓
丢弃 new_path

新 Path 支配旧 Path
    ↓
删除 old_path,保留 new_path

双方各有优势
    ↓
两个 Path 都保留

函数内部使用两个关键标志:

c 复制代码
bool accept_new = true;
bool remove_old = false;

其中:

text 复制代码
accept_new = false
    已经找到一个能够支配 new_path 的旧 Path

remove_old = true
    new_path 能够支配当前 old_path

一个新 Path 可能同时支配并删除多个旧 Path。

6.6.3 剪枝比较哪些维度

add_path() 综合比较:

维度 Path 中的信息 哪一方更有优势
被禁用节点数 disabled_nodes 越少越好
启动成本 startup_cost 越小越好
总成本 total_cost 越小越好
排序能力 pathkeys 能满足更多有用顺序更好
外部参数依赖 PATH_REQ_OUTER(path) 依赖集合越小,适用范围越广
输出行数 rows 在其他条件不差时越少越好
并行安全性 parallel_safe truefalse 更有价值

disabled_nodes 是高优先级比较维度。例如用户设置:

sql 复制代码
SET enable_seqscan = off;

并不意味着优化器完全不能生成顺序扫描,而是使用被禁用节点的 Path 会带有

更高的 disabled_nodes。只要存在不使用被禁用节点的可行 Path,它就会优先。

6.6.4 成本采用模糊比较

成本比较主要通过:

c 复制代码
compare_path_costs_fuzzily(new_path,
                           old_path,
                           STD_FUZZ_FACTOR);

其中:

c 复制代码
#define STD_FUZZ_FACTOR 1.01

这表示约 1% 以内的成本差异可以被视为"模糊相等"。这样做能够:

text 复制代码
避免浮点误差导致计划不稳定
避免为没有实际意义的微小成本差异保留大量 Path
降低优化器的时间和内存开销

返回值有四种:

text 复制代码
COSTS_EQUAL
    启动成本和总成本都近似相同

COSTS_BETTER1
    第一个 Path 在成本上支配第二个

COSTS_BETTER2
    第二个 Path 在成本上支配第一个

COSTS_DIFFERENT
    一个启动成本更好,另一个总成本更好

例如:

Path startup_cost total_cost
Index Path 2 130
SeqScan Path 20 100

Index Path 更快产生第一批元组,SeqScan Path 读取全部结果的成本更低。

双方不能互相支配,所以都会保留:

text 复制代码
Index Path
    适合 LIMIT、游标或只消费少量结果

SeqScan Path
    适合读取完整结果

这也是为什么剪枝阶段不能只保留 cheapest_total_path

6.6.5 排序能力为什么会阻止剪枝

成本比较之后,add_path() 通过:

c 复制代码
compare_pathkeys(new_path_pathkeys,
                 old_path_pathkeys);

比较两个 Path 的排序能力,可能得到:

text 复制代码
PATHKEYS_EQUAL
PATHKEYS_BETTER1
PATHKEYS_BETTER2
PATHKEYS_DIFFERENT

例如:

text 复制代码
Path A
    total_cost = 100
    无序

Path B
    total_cost = 105
    已按 total DESC 排序

虽然 Path B 略贵,但它可能直接满足示例查询:

sql 复制代码
ORDER BY total DESC
LIMIT 10;

如果删除 Path B,后续可能必须在 Path A 上增加:

text 复制代码
SortPath
└── Path A

因此,只要 Path B 的排序能力有后续价值,它就可能继续保留。

如果一个 Path 按 (a) 排序,另一个按 (b) 排序,两种顺序互不包含,

compare_pathkeys() 会返回 PATHKEYS_DIFFERENT,通常两个 Path 都会保留。

6.6.6 参数化 Path 怎么比较

参数化 Path 通过:

c 复制代码
PATH_REQ_OUTER(path)

记录执行它之前必须由哪些外部关系提供参数。

例如:

text 复制代码
普通 SeqScan Path
    required_outer = {}

参数化 orders IndexPath
    required_outer = {customer}

参数化 orders IndexPath 只有获得当前 customer.id 后才能执行:

text 复制代码
NestPath
├── customer Path
└── parameterized orders IndexPath
    Index Cond: orders.customer_id = customer.id

参数依赖集合越小,通常代表适用范围越广。但参数化更强的 Path 可能已经应用

更多连接条件,因此会产生更少的行。add_path() 需要同时比较:

text 复制代码
required_outer 的包含关系
rows
成本
并行安全性

参数集合的比较使用:

c 复制代码
bms_subset_compare(PATH_REQ_OUTER(new_path),
                   PATH_REQ_OUTER(old_path));

可能得到:

text 复制代码
BMS_EQUAL
BMS_SUBSET1
BMS_SUBSET2
BMS_DIFFERENT

为了限制参数化 Path 数量,PostgreSQL 在 add_path() 中把参数化 Path

pathkeys 当成 NIL

c 复制代码
new_path_pathkeys =
    new_path->param_info ? NIL : new_path->pathkeys;

也就是说,参数化 Path 不能只依靠排序优势战胜其他参数化 Path。

6.6.7 一个 Path 何时能够支配另一个

忽略源码中的特殊分支后,可以近似理解为:

c 复制代码
if (new_cost <= old_cost &&
    new_pathkeys >= old_pathkeys &&
    new_required_outer ⊆ old_required_outer &&
    new_rows <= old_rows &&
    new_parallel_safe >= old_parallel_safe)
{
    remove(old_path);
}

这里的 <=>= 是"在对应维度上不差",不是简单的数值比较。

假设已有:

text 复制代码
Old Path
    startup_cost   = 10
    total_cost     = 100
    pathkeys       = (a)
    required_outer = {}
    rows           = 1000
    parallel_safe  = true

新生成:

text 复制代码
New Path
    startup_cost   = 8
    total_cost     = 90
    pathkeys       = (a)
    required_outer = {}
    rows           = 900
    parallel_safe  = true

新 Path:

text 复制代码
启动成本更低
总成本更低
排序相同
外部依赖相同
输出行数更少
并行安全性相同

所以 New Path 支配 Old Path,旧 Path 会被删除。

如果新 Path 改为按 (b) 排序,那么两个排序可能互不包含。即使新 Path

成本更低,也不能保证它对所有后续操作都更好,两个 Path 可能都要保留。

6.6.8 add_path_precheck():构造 Path 前的预剪枝

某些候选 Path 的完整创建和成本计算本身就比较昂贵。PostgreSQL 可以先调用:

c 复制代码
add_path_precheck(parent_rel,
                  disabled_nodes,
                  startup_cost,
                  total_cost,
                  pathkeys,
                  required_outer);

此时甚至还没有完整的 Path 对象,只使用:

text 复制代码
成本下界
排序信息
参数化信息
disabled_nodes

进行快速判断:

text 复制代码
已有 Path 显然在成本、排序和参数化上支配该候选
        ↓
add_path_precheck() 返回 false
        ↓
不再构造完整 Path
        ↓
节省成本计算、内存和比较时间

预检查不能证明候选一定胜出,只能尽早排除明显不可能胜出的候选。

rel->pathlist 会按:

text 复制代码
disabled_nodes
    ↓
total_cost

排序,低成本 Path 靠前。这样 add_path_precheck()add_path() 更容易

尽早找到支配者并退出比较。

6.6.9 Partial Path 如何剪枝

并行 Partial Path 保存在:

c 复制代码
rel->partial_pathlist;

使用:

c 复制代码
add_partial_path(rel, new_path);

进行剪枝。

与普通 add_path() 相比,它主要比较:

text 复制代码
disabled_nodes
total_cost
pathkeys

通常不需要比较:

text 复制代码
startup_cost
参数化
rows

原因是 PostgreSQL 18.3 不生成参数化 Partial Path;并行候选通常预计执行

到完成,而且同一关系的 Partial Path 应产生相同的完整结果行数。

Partial Path 必须满足:

c 复制代码
new_path->parallel_safe == true;
parent_rel->consider_parallel == true;

相应的快速预检查函数是:

c 复制代码
add_partial_path_precheck();

6.6.10 被淘汰的 Path 怎么处理

如果新 Path 被旧 Path 支配:

text 复制代码
accept_new = false
    ↓
不加入 rel->pathlist
    ↓
释放 new_path

如果新 Path 支配旧 Path:

text 复制代码
remove_old = true
    ↓
从 rel->pathlist 删除 old_path
    ↓
释放 old_path

源码通常只释放 Path 节点本身,不递归释放共享的子结构,因为表达式、

PathTarget、子 Path 或 Query 树节点可能被其他候选共同引用。

IndexPath 还有特殊处理:它可能被 BitmapHeapPath 引用,因此被淘汰时

不能像普通 Path 一样立即 pfree()

6.6.11 set_cheapest() 做什么

完成候选生成后:

c 复制代码
set_cheapest(rel);

设置:

c 复制代码
rel->cheapest_startup_path;
rel->cheapest_total_path;
rel->cheapest_parameterized_paths;

含义:

text 复制代码
cheapest_startup_path
    最快返回第一批元组

cheapest_total_path
    返回全部元组的总成本最低

cheapest_parameterized_paths
    不同外部参数依赖下值得保留的 Path

需要特别区分:

text 复制代码
add_path()
    剪掉被支配的候选,维护互不支配的 pathlist

set_cheapest()
    不负责主要剪枝,从幸存 Path 中记录几个代表性最优 Path

6.6.12 最终分数成本选择

如果:

c 复制代码
tuple_fraction <= 0.0

通常直接使用:

c 复制代码
final_rel->cheapest_total_path;

如果只预计读取部分结果,则近似比较:

text 复制代码
fractional_cost
    = startup_cost
    + tuple_fraction × (total_cost - startup_cost)

绝对行数形式会先根据预计总行数换算成比例。

6.6.13 贯穿示例

示例中有:

sql 复制代码
ORDER BY total DESC
LIMIT 10

可能存在:

text 复制代码
Path A
    HashAggregate 后全局 Sort
    startup_cost 较高
    total_cost 较低

Path B
    利用已有顺序或 Incremental Sort
    startup_cost 较低
    total_cost 略高

如果只需要前 10 行,B 可能胜出;如果需要全部结果,A 可能胜出。

把整个候选生成和剪枝过程串起来:

text 复制代码
cost_xxx()
    估算候选成本
        ↓
add_path_precheck()
    构造前排除明显失败者
        ↓
create_xxx_path()
    创建完整候选 Path
        ↓
add_path()
    维护互不支配的 rel->pathlist
        ↓
set_cheapest()
    记录最低启动成本、最低总成本和参数化代表 Path
        ↓
get_cheapest_fractional_path()
    根据 tuple_fraction 选择顶层 best_path

最重要的结论是:

PostgreSQL 剪掉的是"无论后续如何使用都不可能更优"的 Path,而不是

简单剪掉当前 total_cost 不是最低的 Path。

6.6.14 本步骤的输入和输出

text 复制代码
输入:
    final_rel->pathlist
    final_rel->cheapest_total_path
    tuple_fraction

输出:
    Path *best_path

此时仍然是 Path,不是执行器 Plan。


第 7 步:将 Path 转换为 Plan

核心代码:

c 复制代码
top_plan = create_plan(root, best_path);

6.7.1 为什么 Path 不能直接执行

Path 主要包含优化器信息:

text 复制代码
RelOptInfo
成本
PathKey
参数化关系集合
候选子 Path

执行器需要更具体的信息:

text 复制代码
输出 targetlist
过滤 qual
扫描哪个关系
使用哪个索引
JOIN 条件
Hash 条件
排序列编号和操作符
左右子 Plan

因此转换不是强制类型转换,而是重新递归构造 Plan Tree。

6.7.2 常见映射

Path Plan
SeqScan Path(Pathpathtype = T_SeqScan SeqScan
IndexPath IndexScanIndexOnlyScan
BitmapHeapPath BitmapHeapScan
NestPath NestLoop
HashPath HashJoin
MergePath MergeJoin
AggPath Agg
SortPath Sort
IncrementalSortPath IncrementalSort
MemoizePath Memoize
GatherPath Gather
GatherMergePath GatherMerge
AppendPath Append
MaterialPath Material
LimitPath Limit

6.7.3 贯穿示例的转换

假设胜出的 Path Tree 是:

text 复制代码
LimitPath
└── SortPath
    └── AggPath (AGG_HASHED)
        └── HashPath
            ├── IndexPath(customer_active_idx)
            └── SeqScan Path (orders)

create_plan() 可能转换为:

text 复制代码
Limit
└── Sort
    └── HashAggregate
        └── HashJoin
            ├── IndexScan(customer_active_idx)
            └── Hash
                └── SeqScan(orders)

Hash Join 的内侧会增加执行器需要的 Hash 节点。

6.7.4 转换过程中补充的信息

create_plan() 会:

text 复制代码
把 PathTarget 转成 Plan.targetlist
把 RestrictInfo 中的表达式取出并分配到 qual
区分 joinqual、hashclauses 和 mergeclauses
建立 lefttree 和 righttree
确定 scanrelid 和 indexid
建立 Index Cond
建立 NestLoopParam
将 PathKey 转成 Sort 的列编号、操作符和 NULL 顺序
复制成本、行数、宽度和并行属性

6.7.5 只转换胜出的 Path

如果:

text 复制代码
final_rel->pathlist
├── Path A
├── Path B
└── Path C

最终:

c 复制代码
best_path = Path B;

通常只将 Path B 及其子 Path 转换为 Plan,其他候选不会转换。

6.7.6 Path、Plan 和 PlanState

text 复制代码
Path
    优化器候选物理实现

Plan
    最终静态执行计划

PlanState
    执行器运行时状态

完整转换:

text 复制代码
Path
    ↓ create_plan()
Plan
    ↓ ExecutorStart() / ExecInitNode()
PlanState
    ↓ ExecutorRun()
实际执行

6.7.7 本步骤的输入和输出

text 复制代码
输入:
    PlannerInfo *root
    Path *best_path

输出:
    Plan *top_plan

第 8 步:处理游标和调试 Gather

这一部分发生在最优 Path 已经转换为 Plan 之后,主要是额外执行包装,不是主优化流程。

6.8.1 可滚动游标

c 复制代码
if (cursorOptions & CURSOR_OPT_SCROLL)
{
    if (!ExecSupportsBackwardScan(top_plan))
        top_plan =
            materialize_finished_plan(top_plan);
}

如果 SCROLL 游标要求反向读取,而 Plan Tree 不支持 backward scan,则增加:

text 复制代码
Material
└── 原 top_plan

Material 将结果保存到 tuplestore,使游标可以向前、向后和重新读取。

6.8.2 调试 Gather

debug_parallel_query 开启时,如果最终 Plan:

c 复制代码
top_plan->parallel_safe

PostgreSQL 可以强制包装:

text 复制代码
Gather
└── 原 top_plan

这个 Gather:

text 复制代码
不参与正常 Path 成本竞争
不以性能优化为目标
主要用于验证 parallel_safe 标记是否正确

为什么在选出最优计划后才添加:

text 复制代码
避免改变正常 Path 搜索
避免调试 Gather 因成本高而被淘汰
只测试正常情况下真正胜出的完整 Plan
此时才能访问 targetlist、initPlan 等具体 Plan 信息

需要区分:

text 复制代码
正常 Gather
    来源于 GatherPath
    在 Path 阶段参与成本比较

调试 Gather
    在 Plan 生成后强制包装
    仅用于测试

6.8.3 Gather 和 Gather Merge

text 复制代码
Gather
    收集 worker 输出,不保证顺序

Gather Merge
    对多个按同一 PathKey 排序的 worker 输出做多路归并,
    保持全局顺序

这部分只需要知道其存在即可,主查询优化仍然发生在前面的 Path 阶段。


第 9 步:完成 Param 和子计划处理

核心代码:

c 复制代码
if (glob->paramExecTypes != NIL)
{
    forboth(lp, glob->subplans,
            lr, glob->subroots)
    {
        Plan *subplan = (Plan *) lfirst(lp);
        PlannerInfo *subroot =
            lfirst_node(PlannerInfo, lr);

        SS_finalize_plan(subroot, subplan);
    }

    SS_finalize_plan(root, top_plan);
}

核心函数:

c 复制代码
SS_finalize_plan()

6.9.1 两类主要 Param

text 复制代码
PARAM_EXTERN
    客户端提供的参数,例如 SQL 中的 $1

PARAM_EXEC
    计划内部在节点之间传递的执行参数

glob->paramExecTypes 记录内部参数类型:

text 复制代码
PARAM_EXEC 0:integer
PARAM_EXEC 1:numeric
PARAM_EXEC 2:date

执行器使用:

c 复制代码
ParamExecData[]

保存这些值。

6.9.2 InitPlan 示例

sql 复制代码
SELECT *
FROM orders
WHERE amount > (
    SELECT AVG(amount)
    FROM order_history
);

不相关子查询可能成为 InitPlan:

text 复制代码
InitPlan
└── Aggregate
    └── SeqScan(order_history)

它先计算:

text 复制代码
PARAM_EXEC 0 = AVG(amount)

主计划使用:

text 复制代码
SeqScan(orders)
└── Filter: amount > PARAM_EXEC 0

6.9.3 参数化 Nested Loop 示例

text 复制代码
Nested Loop
├── SeqScan(customer)
└── IndexScan(orders_customer_id_idx)

每个外表元组将:

text 复制代码
customer.id
    ↓
NestLoopParam
    ↓
PARAM_EXEC 0
    ↓
orders.customer_id = PARAM_EXEC 0

参数变化后,内侧 IndexScan 需要重新扫描。

6.9.4 extParamallParam

每个 Plan 节点包含:

c 复制代码
Bitmapset *extParam;
Bitmapset *allParam;

概念上:

text 复制代码
extParam
    当前 Plan 子树依赖、但由子树外部提供的 PARAM_EXEC

allParam
    影响当前节点或其子树的全部执行参数集合

执行器根据这些集合判断:

text 复制代码
参数变化后哪些节点需要 ExecReScan()

6.9.5 为什么先处理子计划

执行顺序是:

text 复制代码
先 SS_finalize_plan(subplan)
再 SS_finalize_plan(main plan)

因为主计划可能引用子计划产生或依赖的参数。只有先完成子计划的参数集合,才能正确计算主计划的 extParamallParam

6.9.6 本步骤的输入和输出

text 复制代码
输入:
    主 Plan Tree
    glob->subplans
    glob->subroots
    glob->paramExecTypes

输出:
    每个 Plan 节点正确的 extParam 和 allParam
    完成的 InitPlan/SubPlan 参数依赖

本步骤不改变连接顺序,也不重新选择 Path。


第 10 步:修正计划中的引用

核心代码:

c 复制代码
top_plan =
    set_plan_references(root, top_plan);

子计划也需要调用:

c 复制代码
lfirst(lp) =
    set_plan_references(subroot, subplan);

核心源码:

text 复制代码
src/backend/optimizer/plan/setrefs.c

6.10.1 为什么需要修正 Var

优化器阶段的 Var 可能表示:

text 复制代码
Var(varno = 1, varattno = 2)
    customer 的第 2 列

Var(varno = 2, varattno = 4)
    orders 的第 4 列

但执行 JOIN 时,父节点面对的是:

text 复制代码
左子计划的 tuple slot
右子计划的 tuple slot

所以需要转换为:

text 复制代码
OUTER_VAR
    从左侧子计划输出槽读取

INNER_VAR
    从右侧子计划输出槽读取

例如:

text 复制代码
customer.region
    ↓
Var(OUTER_VAR, 左子计划输出列位置)

orders.amount
    ↓
Var(INNER_VAR, 右子计划输出列位置)

执行器不需要重新根据表名或关系集合查找列,只需要按 tuple slot 位置读取。

6.10.2 合并多个 Query 层级的 rtable

每个 Query 都有自己的 rtable,且下标从 1 开始:

text 复制代码
顶层 Query.rtable
    1 = customer
    2 = orders

子 Query.rtable
    1 = order_history

最终 PlannedStmt 只有一个统一 rtable

text 复制代码
finalrtable
    1 = customer
    2 = orders
    3 = order_history

子计划原来的:

text 复制代码
scanrelid = 1

需要调整为:

text 复制代码
scanrelid = 3

set_plan_references() 会完成这类编号偏移。

6.10.3 收集最终语句级信息

遍历 Plan Tree 时,会填充:

c 复制代码
glob->finalrtable;
glob->finalrteperminfos;
glob->finalrowmarks;
glob->resultRelations;
glob->appendRelations;

这些信息随后进入 PlannedStmt

6.10.4 为什么必须在 create_plan() 后进行

在 Path 阶段还没有具体:

text 复制代码
Plan targetlist
lefttree
righttree
scanrelid
JOIN 子节点输出列位置

只有生成 Plan Tree 后,才能确定每个表达式应该从哪个运行时 tuple slot 读取。

6.10.5 本步骤的输入和输出

text 复制代码
输入:
    仍带有规划器引用形式的 Plan Tree
    当前 Query 层级的 PlannerInfo

输出:
    执行器可直接使用列引用的 Plan Tree
    合并后的 finalrtable
    最终权限、行锁和目标关系信息

SS_finalize_plan()set_plan_references() 的区别:

text 复制代码
SS_finalize_plan()
    解决参数由谁产生、谁使用、变化后谁需要重扫

set_plan_references()
    解决列值从哪个 tuple slot、哪个输出位置读取

第 11 步:构造 PlannedStmt

核心代码:

c 复制代码
result = makeNode(PlannedStmt);

随后将规划结果封装到语句级容器:

c 复制代码
result->commandType = parse->commandType;
result->queryId = parse->queryId;
result->planTree = top_plan;
result->subplans = glob->subplans;
result->rtable = glob->finalrtable;
result->partPruneInfos = glob->partPruneInfos;
result->paramExecTypes = glob->paramExecTypes;

6.11.1 为什么不能只返回 Plan *

Plan *top_plan 只表示主 Plan Tree。

执行器还需要:

text 复制代码
语句类型
SubPlan
最终范围表
权限信息
目标关系
行锁
分区裁剪
内部参数类型
计划缓存依赖
并行模式
JIT 标志

所以需要 PlannedStmt

6.11.2 主要字段

字段 作用
commandType SELECT、INSERT、UPDATE、DELETE 或 MERGE
queryId 查询标识
planTree 主 Plan Tree
subplans SubPlan 和 InitPlan 对应的计划
rtable 合并后的最终范围表
permInfos 权限检查信息
resultRelations 数据修改目标关系
appendRelations 继承和分区列映射
partPruneInfos 运行时分区裁剪步骤
rowMarks FOR UPDATE 等行锁信息
paramExecTypes PARAM_EXEC 类型
rewindPlanIDs 需要 rewind 的子计划编号
relationOids 计划依赖的关系 OID
invalItems 计划缓存失效依赖
parallelModeNeeded 执行时是否进入并行模式
jitFlags JIT 执行选项

6.11.3 计划缓存依赖

如果计划使用:

text 复制代码
orders_customer_id_idx

随后索引被删除或相关目录对象发生变化,缓存计划必须失效并重新规划。

c 复制代码
relationOids;
invalItems;

帮助计划缓存判断何时需要重新规划。

6.11.4 本步骤的输入和输出

text 复制代码
输入:
    top_plan
    PlannerGlobal 中收集的全局结果
    Query 中的命令信息

输出:
    PlannedStmt *result

本步骤主要是收集、整理和封装,不继续枚举 Path。


第 12 步:设置 JIT 标志

首先:

c 复制代码
result->jitFlags = PGJIT_NONE;

满足成本条件后:

c 复制代码
if (jit_enabled &&
    jit_above_cost >= 0 &&
    top_plan->total_cost > jit_above_cost)
{
    result->jitFlags |= PGJIT_PERFORM;
    ...
}

6.12.1 本步骤没有立即编译机器码

standard_planner() 只设置:

c 复制代码
PlannedStmt.jitFlags

真正的 LLVM IR 生成、优化和机器码生成发生在执行阶段。

6.12.2 主要标志

标志 含义
PGJIT_PERFORM 启用 JIT 总开关
PGJIT_EXPR JIT 编译表达式求值
PGJIT_DEFORM JIT 编译 tuple deforming
PGJIT_INLINE 执行函数内联
PGJIT_OPT3 执行更昂贵的 LLVM 优化

6.12.3 为什么根据成本决定

JIT 有额外启动开销:

text 复制代码
生成 LLVM IR
执行优化 Pass
生成机器码
装载机器码

短 OLTP 查询可能因为编译开销变慢;扫描大量行、执行复杂表达式和聚合的分析查询更可能受益。

6.12.4 为什么在计划选定后设置

判断依据是:

c 复制代码
top_plan->total_cost;

只有完成 Path 搜索、选出 best_path 并创建 Plan 后,才有最终完整计划成本。

因此:

text 复制代码
先选择最优 Path
再决定如何执行这个 Plan

JIT 标志通常不会触发重新进行连接顺序和访问路径搜索。

6.12.5 本步骤的输入和输出

text 复制代码
输入:
    top_plan->total_cost
    JIT 相关 GUC

输出:
    result->jitFlags

第 13 步:清理并返回

最后:

c 复制代码
if (glob->partition_directory != NULL)
    DestroyPartitionDirectory(
        glob->partition_directory);

return result;

6.13.1 清理分区目录

partition_directory 是本次规划期间使用的分区描述缓存:

text 复制代码
分区边界
分区层次
分区描述
规划期间重复使用的分区元数据

最终执行器需要的信息已经转换为:

c 复制代码
result->partPruneInfos;
result->appendRelations;
result->rtable;
result->planTree;

所以规划器临时使用的 PartitionDirectory 可以销毁。

这不会:

text 复制代码
删除分区表
删除 PlannedStmt 的运行时裁剪信息
破坏最终 Plan Tree

6.13.2 为什么不逐个释放 Path 和 RelOptInfo

PostgreSQL 使用 MemoryContext 批量管理内存。

规划期间产生的大量:

text 复制代码
PlannerInfo
RelOptInfo
Path
RestrictInfo
EquivalenceClass

通常不在这里逐一 pfree(),而是在对应内存上下文生命周期结束时整体释放。

必须保留:

text 复制代码
result
result->planTree
result->subplans
result->rtable
result->partPruneInfos

因为它们是最终规划结果的一部分。

6.13.3 返回后去哪里

text 复制代码
standard_planner()
    ↓
planner()
    ↓
pg_plan_query()
    ↓
pg_plan_queries()
    ↓
Portal 或 Plan Cache
    ↓
QueryDesc
    ↓
ExecutorStart()
    ↓
ExecInitNode()
    ↓
PlanState
    ↓
ExecutorRun()

7. 贯穿示例的完整数据流

再次观察示例:

sql 复制代码
SELECT c.region,
       SUM(o.amount) AS total
FROM customer AS c
JOIN orders AS o
  ON o.customer_id = c.id
WHERE c.active = true
  AND o.order_date >= DATE '2026-01-01'
GROUP BY c.region
HAVING SUM(o.amount) > 10000
ORDER BY total DESC
LIMIT 10;

7.1 进入优化器

text 复制代码
Query
├── rtable:customer、orders、JOIN
├── jointree:JOIN + WHERE
├── targetList:region、SUM(amount)
├── groupClause:region
├── havingQual:SUM(amount) > 10000
├── sortClause:total DESC
└── limitCount:10

7.2 基本表优化

text 复制代码
RelOptInfo(customer)
├── SeqScan Path
├── IndexPath(customer_active_idx)
└── BitmapHeapPath
text 复制代码
RelOptInfo(orders)
├── SeqScan Path
├── IndexPath(orders_order_date_idx)
├── IndexPath(orders_customer_id_idx)
└── BitmapHeapPath

7.3 JOIN 优化

text 复制代码
RelOptInfo({customer, orders})
├── HashPath
│   ├── customer Path
│   └── orders Path
├── NestPath
│   ├── customer outer Path
│   └── parameterized orders IndexPath
└── MergePath
    ├── ordered customer Path
    └── ordered orders Path

add_path() 删除被完全支配的方案,set_cheapest() 记录最低启动和最低总成本 Path。

7.4 聚合和上层操作

text 复制代码
UPPERREL_GROUP_AGG
├── AggPath (AGG_HASHED)
└── AggPath (AGG_SORTED)
text 复制代码
UPPERREL_ORDERED
├── SortPath
├── IncrementalSortPath
└── 已有序 Path
text 复制代码
UPPERREL_FINAL
└── 完成 LIMIT 和最终投影的 Path

7.5 最终选择

c 复制代码
final_rel =
    fetch_upper_rel(root,
                    UPPERREL_FINAL,
                    NULL);

best_path =
    get_cheapest_fractional_path(
        final_rel,
        tuple_fraction);

7.6 转换为 Plan

假设选择:

text 复制代码
LimitPath
└── SortPath
    └── AggPath (AGG_HASHED)
        └── HashPath

转换为:

text 复制代码
Limit
└── Sort
    └── HashAggregate
        └── HashJoin
            ├── IndexScan(customer_active_idx)
            └── Hash
                └── SeqScan(orders)

7.7 最终封装

text 复制代码
PlannedStmt
├── planTree
│   └── Limit → Sort → HashAggregate → HashJoin
├── rtable
│   ├── customer
│   └── orders
├── permInfos
├── paramExecTypes
├── relationOids
├── invalItems
├── parallelModeNeeded
└── jitFlags

8. 最容易混淆的问题

8.1 Query 是原始语法树吗

不是。

text 复制代码
SelectStmt
    原始语法树

Query
    经过语义分析后的查询树

进入 standard_planner()Query 通常还已经经过 pg_rewrite_query()

8.2 subquery_planner() 只处理子查询吗

不是。

第一次调用处理顶层 Query,遇到子 Query 后再递归处理子查询层级。

8.3 subquery_planner() 返回最优 Path 吗

它返回:

c 复制代码
PlannerInfo *

它已经生成并筛选候选 Path,但最终顶层:

c 复制代码
Path *best_path

get_cheapest_fractional_path() 在它返回后选出。

8.4 RelOptInfo 是 Path 吗

不是。

text 复制代码
RelOptInfo
    表示一个逻辑结果,包含多个候选 Path

Path
    表示产生该逻辑结果的一种物理实现

8.5 UPPERREL_FINAL 是最终 Plan 吗

不是。

它是最终逻辑结果对应的 RelOptInfo,其中仍然保存候选 Path。

8.6 cheapest_total_path 一定是最终 best_path

不一定。

如果 tuple_fraction > 0,启动更快的 Path 可能在部分结果成本上更优。

8.7 Path Tree 和 Plan Tree 有什么区别

text 复制代码
Path Tree
    用于搜索和成本比较

Plan Tree
    用于执行器初始化

只有胜出的 Path Tree 会被转换。

8.8 Partial Path 是否只用于并行

在 PostgreSQL 优化器术语中,partial_pathlist 是并行规划专用概念。

一个 Partial Path 只产生部分结果,需要 Gather 或 Gather Merge 组合。

但:

text 复制代码
parallel_safe Path

不一定是 Partial Path。

8.9 正常 Gather 和调试 Gather 是否相同

不同。

text 复制代码
正常 Gather
    来自 GatherPath,参与成本比较

调试 Gather
    在 Plan 生成后强制包装,只用于 parallel safety 测试

8.10 set_plan_references() 是否还在优化

不是。

它将规划器形式的引用转换成执行器形式:

text 复制代码
表列 Var
    → OUTER_VAR / INNER_VAR / tuple slot 位置

每个 Query 独立 rtable
    → PlannedStmt 统一 finalrtable

8.11 设置 JIT 标志是否已经执行 JIT

没有。

优化器只设置 jitFlags,真正的 LLVM 编译在执行阶段按需发生。


9. 适合源码跟踪的断点顺序

第一轮只观察主流程:

gdb 复制代码
break planner
break standard_planner
break subquery_planner
break grouping_planner
break query_planner
break make_one_rel
break create_plan

第二轮观察 Path 生成和选择:

gdb 复制代码
break set_base_rel_sizes
break set_base_rel_pathlists
break add_path
break set_cheapest
break standard_join_search
break join_search_one_level
break get_cheapest_fractional_path
break compare_fractional_path_costs

第三轮观察 Plan 最终化:

gdb 复制代码
break SS_finalize_plan
break set_plan_references
break DestroyPartitionDirectory

get_cheapest_fractional_path() 中重点观察:

gdb 复制代码
print tuple_fraction
print rel->rows
print rel->pathlist
print rel->cheapest_startup_path
print rel->cheapest_total_path

standard_planner() 中重点观察:

gdb 复制代码
print parse->commandType
print parse->hasAggs
print parse->hasSubLinks
print glob->parallelModeOK
print root->query_level
print final_rel->rows
print best_path->startup_cost
print best_path->total_cost
print top_plan->type
print result->jitFlags

add_path() 调用次数可能很多,第一次跟踪时可以先禁用,理解基本流程后再单独研究。


10. 推荐源码阅读路线

第一遍:控制流程

text 复制代码
src/backend/tcop/postgres.c
    pg_plan_queries()
    pg_plan_query()

src/backend/optimizer/plan/planner.c
    planner()
    standard_planner()
    subquery_planner()
    grouping_planner()

目标:

text 复制代码
知道 Query 如何进入优化器
知道每个 Query 层级如何建立 PlannerInfo
知道最终如何得到 PlannedStmt

第二遍:关系与 Path 搜索

text 复制代码
src/backend/optimizer/plan/planmain.c
    query_planner()

src/backend/optimizer/path/allpaths.c
    make_one_rel()
    set_base_rel_sizes()
    set_base_rel_pathlists()
    standard_join_search()

src/backend/optimizer/path/joinrels.c
    join_search_one_level()

src/backend/optimizer/path/joinpath.c
    add_paths_to_joinrel()

src/backend/optimizer/util/pathnode.c
    add_path()
    set_cheapest()
    create_xxx_path()

src/backend/optimizer/path/costsize.c
    cost_xxx()

目标:

text 复制代码
理解 RelOptInfo
理解候选 Path 如何生成
理解连接顺序如何枚举
理解 Path 如何剪枝和比较

第三遍:Plan 和执行器交接

text 复制代码
src/backend/optimizer/plan/createplan.c
    create_plan()

src/backend/optimizer/plan/subselect.c
    SS_finalize_plan()

src/backend/optimizer/plan/setrefs.c
    set_plan_references()

src/backend/executor/execMain.c
    ExecutorStart()
    ExecutorRun()

目标:

text 复制代码
理解 Path 如何变成 Plan
理解 Param 和 SubPlan
理解 Var 如何变成 tuple slot 引用
理解 Plan 如何初始化为 PlanState

11. 最终总结

standard_planner() 本身可以压缩成四次关键数据转换:

text 复制代码
Query
    ↓ subquery_planner()
PlannerInfo + RelOptInfo + Path
    ↓ get_cheapest_fractional_path()
best Path
    ↓ create_plan()
Plan Tree
    ↓ finalize + setrefs + package
PlannedStmt

13 步的职责可以进一步归纳为:

text 复制代码
第 1~3 步
    建立优化环境和优化目标

第 4 步
    完成逻辑预处理、关系构造、Path 生成和成本搜索

第 5~6 步
    取得最终逻辑关系并选出顶层最优 Path

第 7 步
    把胜出的 Path Tree 转换成 Plan Tree

第 8~10 步
    补充执行要求、参数依赖和运行时列引用

第 11~13 步
    封装 PlannedStmt、设置 JIT,并清理规划临时资源

最重要的概念关系是:

text 复制代码
Query
    描述查询要什么

RelOptInfo
    描述正在优化哪个逻辑结果

Path
    描述有哪些物理方法可以得到该结果

best_path
    表示成本模型最终选中的方法

Plan
    表示执行器可以初始化的静态计划

PlannedStmt
    表示整条语句完整、可执行、可缓存的规划结果