# PostgreSQL 18.3 查询优化器流程:`standard_planner()` 源码导读
PostgreSQL 18.3 查询优化器流程:standard_planner() 源码导读
1. 文档目标
本文按照 PostgreSQL 18.3 中 standard_planner() 的执行顺序,讲解默认查询优化器从 Query 到 PlannedStmt 的完整流程:
版本说明: 本文以 PostgreSQL 官方源码标签
REL_18_3为准。
该版本的planner()和standard_planner()都是四参数形式。开发分支中的
函数签名、Hook 或辅助字段可能已经变化,阅读时不要把不同版本源码混在一起。
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?PlannerGlobal和PlannerInfo有什么区别?- 关系代数等价变换发生在哪里?
subquery_planner()做什么,返回什么?subquery_planner()返回时是否已经产生最优 Path?RelOptInfo是什么?- 为什么
UPPERREL_FINAL是完整查询候选实现的最终容器? - 最优 Path 由哪个函数选择?
Path转换成Plan到底转换了什么?Partial Path、Gather和Gather Merge在流程中的位置是什么?- Param、SubPlan、
extParam和allParam为什么需要最后处理? set_plan_references()为什么在create_plan()之后?PlannedStmt为什么不能只用一个Plan *代替?- JIT 为什么在计划选定后才决定?
2. standard_planner() 位于整个 SQL 流程的什么位置
standard_planner() 并不是 SQL 进入 PostgreSQL 后调用的第一个函数。在进入优化器之前,SQL 已经完成词法分析、语法分析、语义分析和查询重写。
简单查询协议的主调用链可以概括为:
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()
主要源码位置:
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() 的参数:
Query *parse
不是原始语法树,而是经过语义分析和规则重写的查询树。
可以把主要数据转换记成:
SQL 字符串
↓
RawStmt / SelectStmt 原始语法结构
↓
Query 已解析表、列、函数和类型的语义查询树
↓
PlannerInfo / RelOptInfo 优化状态与逻辑关系
↓
Path 候选物理实现
↓
Plan 最终静态执行计划
↓
PlannedStmt 完整的语句级规划结果
↓
PlanState 执行器运行时状态
3. 默认优化器入口
优化器对外入口是:
PlannedStmt *
planner(Query *parse,
const char *query_string,
int cursorOptions,
ParamListInfo boundParams)
planner() 首先检查扩展是否注册了 planner_hook:
if (planner_hook)
result = planner_hook(parse,
query_string,
cursorOptions,
boundParams);
else
result = standard_planner(parse,
query_string,
cursorOptions,
boundParams);
所以需要区分:
planner()
优化器公开入口和扩展钩子入口
standard_planner()
PostgreSQL 内置默认优化器的总入口
本文讨论的是 standard_planner()。
4. 贯穿全文的示例 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;
假设存在:
customer
主键索引 customer_pkey(id)
索引 customer_active_idx(active)
orders
索引 orders_customer_id_idx(customer_id)
索引 orders_order_date_idx(order_date)
该查询包含:
两个基本表
两个过滤条件
一个等值连接
聚合
HAVING
排序
LIMIT
因此可以观察优化器的主要阶段。
它进入 standard_planner() 时,Query 大致表示:
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 描述查询的语义:
访问哪些关系
输出哪些表达式
FROM 和 JOIN 是什么
WHERE、HAVING 条件是什么
是否有聚合、窗口函数或子查询
是否有排序、DISTINCT 和 LIMIT
它回答的是:
查询想得到什么结果?
它不回答:
应该使用 Seq Scan 还是 Index Scan?应该使用 Hash Join 还是 Nested Loop?
5.2 PlannerGlobal
PlannerGlobal 表示一次完整 planner() 调用共享的全局状态。
一条 SQL 可能包含多个 Query 层级:
顶层 Query
├── CTE Query
├── FROM 子查询
└── SubLink Query
每个 Query 层级有自己的 PlannerInfo,但它们共享同一个 PlannerGlobal。
5.3 PlannerInfo
PlannerInfo 表示一个 Query 层级的规划上下文:
当前 Query
父 Query 的 PlannerInfo
当前查询层级
基本表 RelOptInfo 数组
JOIN RelOptInfo
EquivalenceClass
RestrictInfo
Upper RelOptInfo
候选 Path
关系是:
PlannerGlobal
├── PlannerInfo:顶层 Query,query_level = 1
├── PlannerInfo:一级子查询,query_level = 2
└── PlannerInfo:二级子查询,query_level = 3
5.4 RelOptInfo
RelOptInfo 表示一个逻辑关系或逻辑结果。
它可能代表:
一张基本表
多张表连接后的结果
聚合后的结果
窗口计算后的结果
排序后的结果
完整查询的最终结果
同一个 RelOptInfo 中保存能够产生相同逻辑结果的不同 Path。
5.5 Path
Path 表示一种候选物理实现:
SeqScan Path
IndexPath
BitmapHeapPath
NestPath
HashPath
MergePath
AggPath
SortPath
GatherPath
LimitPath
这里需要区分“图中的候选路径名称”和“源码中的独立 C 结构体”。例如,
顺序扫描候选通常是一个 pathtype = T_SeqScan 的通用 Path,并不存在
名为 SeqScanPath 的独立结构体;聚合通常使用 AggPath,再通过
aggstrategy 区分 AGG_HASHED、AGG_SORTED 等策略。本文的结构图会尽量
使用源码中的真实名称,并在必要时用空格分开的概念标签辅助阅读。
Path 主要服务于优化器,保存:
startup_cost
total_cost
rows
pathkeys
参数化信息
并行属性
子 Path
5.6 Plan 和 PlannedStmt
Plan 是执行器能理解的静态计划节点:
SeqScan
IndexScan
NestLoop
HashJoin
MergeJoin
Agg
Sort
Gather
Limit
PlannedStmt 是语句级容器:
PlannedStmt
├── 主 Plan Tree
├── SubPlan
├── 最终 rtable
├── 权限信息
├── 行锁信息
├── 分区裁剪信息
├── 参数类型
├── 计划缓存依赖
├── 并行模式信息
└── JIT 标志
6. standard_planner() 的 13 步流程
第 1 步:初始化 PlannerGlobal
核心代码:
glob = makeNode(PlannerGlobal);
随后初始化大量全局字段:
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 为什么要有全局对象
如果查询包含子查询:
SELECT *
FROM orders
WHERE amount > (
SELECT AVG(amount)
FROM order_history
);
可能形成:
PlannerGlobal
├── 顶层 PlannerInfo:orders
└── 子查询 PlannerInfo:order_history
两个 Query 层级需要共同维护:
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 本步骤的输入和输出
输入:
boundParams
输出:
一个初始化完成的 PlannerGlobal
本步骤没有生成 Path,也没有进行关系代数重写。
第 2 步:判断是否允许并行规划
首先执行低成本检查:
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 树:
glob->maxParallelHazard =
max_parallel_hazard(parse);
glob->parallelModeOK =
glob->maxParallelHazard != PROPARALLEL_UNSAFE;
max_parallel_hazard() 会检查:
targetList
WHERE 和 JOIN 条件
HAVING
函数调用
子查询
CTE
函数和表达式可能是:
PROPARALLEL_SAFE
PROPARALLEL_RESTRICTED
PROPARALLEL_UNSAFE
6.2.1 parallelModeOK 不等于最终一定并行
glob->parallelModeOK = true;
只表示:
后续允许生成和比较 Parallel Path。
是否最终选择并行计划,还要取决于:
Parallel Path 是否能够生成
计划是否 parallel_safe
worker 数量
parallel_setup_cost
parallel_tuple_cost
并行方案总成本
6.2.2 本步骤的输入和输出
输入:
Query *parse
cursorOptions
并行相关 GUC
输出:
glob->maxParallelHazard
glob->parallelModeOK
glob->parallelModeNeeded 的初始值
本步骤只判断并行资格,不生成 Gather,也不选择并行计划。
第 3 步:计算 tuple_fraction
核心代码:
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 表示:
调用方预计会消费最终结果中的多少元组。
它不是:
WHERE 条件选择率
表过滤后剩余比例
JOIN 选择率
一般含义:
tuple_fraction = 0
预计读取全部结果
0 < tuple_fraction < 1
预计读取结果的一部分,数值表示比例
tuple_fraction >= 1
在部分内部场景中表示预计读取的绝对行数
普通查询通常从:
tuple_fraction = 0.0;
开始,也就是优先考虑完整执行的 total_cost。
LIMIT 会在后续规划过程中进一步影响结果消费目标。游标也可以通过 CURSOR_OPT_FAST_PLAN 使用 cursor_tuple_fraction,但游标不是本文重点。
6.3.2 为什么它会改变最优 Path
Path 同时具有:
startup_cost;
total_cost;
只读取部分结果时,近似成本为:
fractional_cost
= startup_cost
+ fraction × (total_cost - startup_cost)
例如:
Path A:启动快,但完整执行慢
startup_cost = 5
total_cost = 1000
Path B:启动慢,但完整执行快
startup_cost = 100
total_cost = 500
读取全部结果时选择 B;只读取很少结果时可能选择 A。
6.3.3 贯穿示例
示例查询包含:
LIMIT 10
虽然 standard_planner() 初始可能设置:
tuple_fraction = 0.0;
但 grouping_planner() 处理 LIMIT 时,会利用预计总行数和 limitCount 调整规划目标,更重视能够快速得到前 10 行的有序 Path。
6.3.4 本步骤的输入和输出
输入:
cursorOptions
cursor_tuple_fraction
输出:
tuple_fraction
本步骤不生成 Path,只确定后续成本选择的目标。
第 4 步:调用 subquery_planner()
核心代码:
root = subquery_planner(glob,
parse,
NULL,
false,
tuple_fraction,
NULL);
这是 standard_planner() 中最重要的一步。
6.4.1 subquery_planner() 的作用
它负责:
对一个完整 Query 层级进行逻辑预处理和物理优化,返回该层级的
PlannerInfo。
返回值是:
PlannerInfo *root;
它不是:
Plan
PlannedStmt
最终 best_path
第一次调用时:
parent_root == NULL
所以处理的是顶层 Query,而不是子查询。
遇到真正的子 Query 时,规划器会再次调用 subquery_planner(),并设置:
parent_root != NULL
query_level = parent_root->query_level + 1
6.4.2 第 4 步内部主调用链
可以将它概括为:
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:
进入 standard_planner() 之前:
pg_rewrite_query()
负责视图、Rule System 和 RLS 展开
第 4 步 subquery_planner():
子查询提升
SubLink 转 JOIN
表达式简化
简单 UNION ALL 展开
外连接化简
第 4 步内部 query_planner():
谓词分解
等价类构造
隐含条件推导
第 4 步内部 Join Search:
利用连接交换律和结合律枚举合法连接顺序
典型逻辑变换包括:
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() 最终会进入:
make_one_rel()
↓
set_base_rel_sizes()
↓
set_base_rel_pathlists()
对 customer 可能生成:
RelOptInfo(customer)
├── SeqScan Path
├── IndexPath(customer_active_idx)
└── BitmapHeapPath
对 orders 可能生成:
RelOptInfo(orders)
├── SeqScan Path
├── IndexPath(orders_order_date_idx)
├── IndexPath(orders_customer_id_idx)
└── BitmapHeapPath
每个 Path 的成本由对应的 cost_xxx() 计算。
6.4.5 连接顺序和连接方法枚举
两个表时可能生成:
RelOptInfo({customer, orders})
├── HashPath
├── NestPath
└── MergePath
更多表时,标准动态规划搜索按关系集合大小逐层进行:
Level 1
{A} {B} {C}
Level 2
{A,B} {A,C} {B,C}
Level 3
{A,B,C}
主要调用链:
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 保存在:
rel->pathlist;
并行 Partial Path 保存在:
rel->partial_pathlist;
Partial Path 执行一次只产生关系结果的一部分,多个 worker 的结果通过:
Gather
Gather Merge
组合成完整结果。
典型结构:
GatherPath
└── Partial SeqScan Path
需要区分三个属性:
parallel_safe
允许在 worker 中执行
parallel_aware
节点知道并行环境并协调工作划分
partial_pathlist
每个执行者只产生部分结果的候选 Path
parallel_safe 的 Path 不一定是 Partial Path。
6.4.7 聚合、排序和 LIMIT 的 Upper Path
示例查询还会继续产生:
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在返回后才明确选出。
返回时已经完成:
Path 生成
成本计算
add_path() 剪枝
set_cheapest() 记录 cheapest Path
Upper RelOptInfo 构造
但 standard_planner() 仍要执行:
get_cheapest_fractional_path()
根据最终 tuple_fraction 选出顶层 Path。
6.4.9 本步骤的输入和输出
输入:
PlannerGlobal
Query
tuple_fraction
父查询 PlannerInfo
输出:
PlannerInfo *root
root 内部已经包含:
基本表 RelOptInfo
JOIN RelOptInfo
Upper RelOptInfo
RestrictInfo
EquivalenceClass
剪枝后的候选 Path
第 5 步:获得最终 RelOptInfo
核心代码:
final_rel =
fetch_upper_rel(root,
UPPERREL_FINAL,
NULL);
6.5.1 RelOptInfo 是什么
RelOptInfo 表示:
一个逻辑结果,以及产生这个结果的候选物理实现和相关优化信息。
核心字段可以简化为:
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 应当产生相同逻辑结果,但可以具有不同:
成本
排序属性
参数化属性
并行属性
物理算法
6.5.2 三类 RelOptInfo
基本表 RelOptInfo
RelOptInfo(customer)
JOIN RelOptInfo
RelOptInfo({customer, orders})
Upper RelOptInfo
UPPERREL_GROUP_AGG
UPPERREL_ORDERED
UPPERREL_FINAL
6.5.3 什么是 UPPERREL_FINAL
对于贯穿示例,逻辑阶段大致是:
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:
候选 A
LimitPath
└── SortPath
└── AggPath (AGG_HASHED)
└── HashPath
├── SeqScan Path (customer)
└── SeqScan Path (orders)
候选 B
LimitPath
└── IncrementalSortPath
└── AggPath (AGG_SORTED)
└── NestPath
├── IndexPath(customer)
└── IndexPath(orders)
每个顶层 Path 都通过子 Path 指针形成一棵完整 Path Tree。沿着其中任何一个顶层 Path 向下遍历,都能得到从最终输出到底层扫描的端到端实现。
因此:
final_rel
是完整查询逻辑结果的 RelOptInfo
final_rel->pathlist
保存经过剪枝后仍然有价值的完整候选 Path 根节点
这里的“所有候选”不是历史上生成过的每一个 Path。add_path() 已经删除被完全支配的方案。
6.5.5 fetch_upper_rel() 做了什么
概念上:
在 root 的 Upper Relation 集合中,
查找 kind = UPPERREL_FINAL 的 RelOptInfo。
fetch_upper_rel() 具有获取或创建 Upper RelOptInfo 的能力,但在这里,grouping_planner() 已经创建并填充了最终关系,所以主要是在取回它。
第三个参数为:
NULL
表示这个最终 Upper Relation 不再按某个具体基本表集合区分;当前 Query 层级通常只有一个最终输出关系。
6.5.6 本步骤的输入和输出
输入:
PlannerInfo *root
输出:
RelOptInfo *final_rel
final_rel 中包含:
最终输出 PathTarget
最终结果行数估计
完整查询候选 Path 根节点
本步骤只是取得最终关系,不重新执行 Path 搜索。
第 6 步:选择最优 Path
核心代码:
best_path =
get_cheapest_fractional_path(final_rel,
tuple_fraction);
最终顶层选择函数是:
get_cheapest_fractional_path()
但最优 Path 的形成经历四个阶段:
cost_xxx()
计算候选成本
↓
add_path()
删除被支配候选
↓
set_cheapest()
记录每个 RelOptInfo 的 cheapest Path
↓
get_cheapest_fractional_path()
选择完整查询最终 Path
6.6.1 成本如何产生
典型成本函数:
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 保存:
path->startup_cost;
path->total_cost;
path->rows;
这些是估算成本,不是实际毫秒数。
6.6.2 add_path() 如何剪枝
新 Path 通过:
add_path(rel, new_path);
尝试加入:
rel->pathlist;
核心源码位置:
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 逐个比较:
新 Path 被旧 Path 支配
↓
丢弃 new_path
新 Path 支配旧 Path
↓
删除 old_path,保留 new_path
双方各有优势
↓
两个 Path 都保留
函数内部使用两个关键标志:
bool accept_new = true;
bool remove_old = false;
其中:
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 |
true 比 false 更有价值 |
disabled_nodes 是高优先级比较维度。例如用户设置:
SET enable_seqscan = off;
并不意味着优化器完全不能生成顺序扫描,而是使用被禁用节点的 Path 会带有
更高的 disabled_nodes。只要存在不使用被禁用节点的可行 Path,它就会优先。
6.6.4 成本采用模糊比较
成本比较主要通过:
compare_path_costs_fuzzily(new_path,
old_path,
STD_FUZZ_FACTOR);
其中:
#define STD_FUZZ_FACTOR 1.01
这表示约 1% 以内的成本差异可以被视为“模糊相等”。这样做能够:
避免浮点误差导致计划不稳定
避免为没有实际意义的微小成本差异保留大量 Path
降低优化器的时间和内存开销
返回值有四种:
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 读取全部结果的成本更低。
双方不能互相支配,所以都会保留:
Index Path
适合 LIMIT、游标或只消费少量结果
SeqScan Path
适合读取完整结果
这也是为什么剪枝阶段不能只保留 cheapest_total_path。
6.6.5 排序能力为什么会阻止剪枝
成本比较之后,add_path() 通过:
compare_pathkeys(new_path_pathkeys,
old_path_pathkeys);
比较两个 Path 的排序能力,可能得到:
PATHKEYS_EQUAL
PATHKEYS_BETTER1
PATHKEYS_BETTER2
PATHKEYS_DIFFERENT
例如:
Path A
total_cost = 100
无序
Path B
total_cost = 105
已按 total DESC 排序
虽然 Path B 略贵,但它可能直接满足示例查询:
ORDER BY total DESC
LIMIT 10;
如果删除 Path B,后续可能必须在 Path A 上增加:
SortPath
└── Path A
因此,只要 Path B 的排序能力有后续价值,它就可能继续保留。
如果一个 Path 按 (a) 排序,另一个按 (b) 排序,两种顺序互不包含,
compare_pathkeys() 会返回 PATHKEYS_DIFFERENT,通常两个 Path 都会保留。
6.6.6 参数化 Path 怎么比较
参数化 Path 通过:
PATH_REQ_OUTER(path)
记录执行它之前必须由哪些外部关系提供参数。
例如:
普通 SeqScan Path
required_outer = {}
参数化 orders IndexPath
required_outer = {customer}
参数化 orders IndexPath 只有获得当前 customer.id 后才能执行:
NestPath
├── customer Path
└── parameterized orders IndexPath
Index Cond: orders.customer_id = customer.id
参数依赖集合越小,通常代表适用范围越广。但参数化更强的 Path 可能已经应用
更多连接条件,因此会产生更少的行。add_path() 需要同时比较:
required_outer 的包含关系
rows
成本
并行安全性
参数集合的比较使用:
bms_subset_compare(PATH_REQ_OUTER(new_path),
PATH_REQ_OUTER(old_path));
可能得到:
BMS_EQUAL
BMS_SUBSET1
BMS_SUBSET2
BMS_DIFFERENT
为了限制参数化 Path 数量,PostgreSQL 在 add_path() 中把参数化 Path
的 pathkeys 当成 NIL:
new_path_pathkeys =
new_path->param_info ? NIL : new_path->pathkeys;
也就是说,参数化 Path 不能只依靠排序优势战胜其他参数化 Path。
6.6.7 一个 Path 何时能够支配另一个
忽略源码中的特殊分支后,可以近似理解为:
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);
}
这里的 <= 和 >= 是“在对应维度上不差”,不是简单的数值比较。
假设已有:
Old Path
startup_cost = 10
total_cost = 100
pathkeys = (a)
required_outer = {}
rows = 1000
parallel_safe = true
新生成:
New Path
startup_cost = 8
total_cost = 90
pathkeys = (a)
required_outer = {}
rows = 900
parallel_safe = true
新 Path:
启动成本更低
总成本更低
排序相同
外部依赖相同
输出行数更少
并行安全性相同
所以 New Path 支配 Old Path,旧 Path 会被删除。
如果新 Path 改为按 (b) 排序,那么两个排序可能互不包含。即使新 Path
成本更低,也不能保证它对所有后续操作都更好,两个 Path 可能都要保留。
6.6.8 add_path_precheck():构造 Path 前的预剪枝
某些候选 Path 的完整创建和成本计算本身就比较昂贵。PostgreSQL 可以先调用:
add_path_precheck(parent_rel,
disabled_nodes,
startup_cost,
total_cost,
pathkeys,
required_outer);
此时甚至还没有完整的 Path 对象,只使用:
成本下界
排序信息
参数化信息
disabled_nodes
进行快速判断:
已有 Path 显然在成本、排序和参数化上支配该候选
↓
add_path_precheck() 返回 false
↓
不再构造完整 Path
↓
节省成本计算、内存和比较时间
预检查不能证明候选一定胜出,只能尽早排除明显不可能胜出的候选。
rel->pathlist 会按:
disabled_nodes
↓
total_cost
排序,低成本 Path 靠前。这样 add_path_precheck() 和 add_path() 更容易
尽早找到支配者并退出比较。
6.6.9 Partial Path 如何剪枝
并行 Partial Path 保存在:
rel->partial_pathlist;
使用:
add_partial_path(rel, new_path);
进行剪枝。
与普通 add_path() 相比,它主要比较:
disabled_nodes
total_cost
pathkeys
通常不需要比较:
startup_cost
参数化
rows
原因是 PostgreSQL 18.3 不生成参数化 Partial Path;并行候选通常预计执行
到完成,而且同一关系的 Partial Path 应产生相同的完整结果行数。
Partial Path 必须满足:
new_path->parallel_safe == true;
parent_rel->consider_parallel == true;
相应的快速预检查函数是:
add_partial_path_precheck();
6.6.10 被淘汰的 Path 怎么处理
如果新 Path 被旧 Path 支配:
accept_new = false
↓
不加入 rel->pathlist
↓
释放 new_path
如果新 Path 支配旧 Path:
remove_old = true
↓
从 rel->pathlist 删除 old_path
↓
释放 old_path
源码通常只释放 Path 节点本身,不递归释放共享的子结构,因为表达式、
PathTarget、子 Path 或 Query 树节点可能被其他候选共同引用。
IndexPath 还有特殊处理:它可能被 BitmapHeapPath 引用,因此被淘汰时
不能像普通 Path 一样立即 pfree()。
6.6.11 set_cheapest() 做什么
完成候选生成后:
set_cheapest(rel);
设置:
rel->cheapest_startup_path;
rel->cheapest_total_path;
rel->cheapest_parameterized_paths;
含义:
cheapest_startup_path
最快返回第一批元组
cheapest_total_path
返回全部元组的总成本最低
cheapest_parameterized_paths
不同外部参数依赖下值得保留的 Path
需要特别区分:
add_path()
剪掉被支配的候选,维护互不支配的 pathlist
set_cheapest()
不负责主要剪枝,从幸存 Path 中记录几个代表性最优 Path
6.6.12 最终分数成本选择
如果:
tuple_fraction <= 0.0
通常直接使用:
final_rel->cheapest_total_path;
如果只预计读取部分结果,则近似比较:
fractional_cost
= startup_cost
+ tuple_fraction × (total_cost - startup_cost)
绝对行数形式会先根据预计总行数换算成比例。
6.6.13 贯穿示例
示例中有:
ORDER BY total DESC
LIMIT 10
可能存在:
Path A
HashAggregate 后全局 Sort
startup_cost 较高
total_cost 较低
Path B
利用已有顺序或 Incremental Sort
startup_cost 较低
total_cost 略高
如果只需要前 10 行,B 可能胜出;如果需要全部结果,A 可能胜出。
把整个候选生成和剪枝过程串起来:
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 本步骤的输入和输出
输入:
final_rel->pathlist
final_rel->cheapest_total_path
tuple_fraction
输出:
Path *best_path
此时仍然是 Path,不是执行器 Plan。
第 7 步:将 Path 转换为 Plan
核心代码:
top_plan = create_plan(root, best_path);
6.7.1 为什么 Path 不能直接执行
Path 主要包含优化器信息:
RelOptInfo
成本
PathKey
参数化关系集合
候选子 Path
执行器需要更具体的信息:
输出 targetlist
过滤 qual
扫描哪个关系
使用哪个索引
JOIN 条件
Hash 条件
排序列编号和操作符
左右子 Plan
因此转换不是强制类型转换,而是重新递归构造 Plan Tree。
6.7.2 常见映射
| Path | Plan |
|---|---|
SeqScan Path(Path,pathtype = T_SeqScan) |
SeqScan |
IndexPath |
IndexScan 或 IndexOnlyScan |
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 是:
LimitPath
└── SortPath
└── AggPath (AGG_HASHED)
└── HashPath
├── IndexPath(customer_active_idx)
└── SeqScan Path (orders)
create_plan() 可能转换为:
Limit
└── Sort
└── HashAggregate
└── HashJoin
├── IndexScan(customer_active_idx)
└── Hash
└── SeqScan(orders)
Hash Join 的内侧会增加执行器需要的 Hash 节点。
6.7.4 转换过程中补充的信息
create_plan() 会:
把 PathTarget 转成 Plan.targetlist
把 RestrictInfo 中的表达式取出并分配到 qual
区分 joinqual、hashclauses 和 mergeclauses
建立 lefttree 和 righttree
确定 scanrelid 和 indexid
建立 Index Cond
建立 NestLoopParam
将 PathKey 转成 Sort 的列编号、操作符和 NULL 顺序
复制成本、行数、宽度和并行属性
6.7.5 只转换胜出的 Path
如果:
final_rel->pathlist
├── Path A
├── Path B
└── Path C
最终:
best_path = Path B;
通常只将 Path B 及其子 Path 转换为 Plan,其他候选不会转换。
6.7.6 Path、Plan 和 PlanState
Path
优化器候选物理实现
Plan
最终静态执行计划
PlanState
执行器运行时状态
完整转换:
Path
↓ create_plan()
Plan
↓ ExecutorStart() / ExecInitNode()
PlanState
↓ ExecutorRun()
实际执行
6.7.7 本步骤的输入和输出
输入:
PlannerInfo *root
Path *best_path
输出:
Plan *top_plan
第 8 步:处理游标和调试 Gather
这一部分发生在最优 Path 已经转换为 Plan 之后,主要是额外执行包装,不是主优化流程。
6.8.1 可滚动游标
if (cursorOptions & CURSOR_OPT_SCROLL)
{
if (!ExecSupportsBackwardScan(top_plan))
top_plan =
materialize_finished_plan(top_plan);
}
如果 SCROLL 游标要求反向读取,而 Plan Tree 不支持 backward scan,则增加:
Material
└── 原 top_plan
Material 将结果保存到 tuplestore,使游标可以向前、向后和重新读取。
6.8.2 调试 Gather
debug_parallel_query 开启时,如果最终 Plan:
top_plan->parallel_safe
PostgreSQL 可以强制包装:
Gather
└── 原 top_plan
这个 Gather:
不参与正常 Path 成本竞争
不以性能优化为目标
主要用于验证 parallel_safe 标记是否正确
为什么在选出最优计划后才添加:
避免改变正常 Path 搜索
避免调试 Gather 因成本高而被淘汰
只测试正常情况下真正胜出的完整 Plan
此时才能访问 targetlist、initPlan 等具体 Plan 信息
需要区分:
正常 Gather
来源于 GatherPath
在 Path 阶段参与成本比较
调试 Gather
在 Plan 生成后强制包装
仅用于测试
6.8.3 Gather 和 Gather Merge
Gather
收集 worker 输出,不保证顺序
Gather Merge
对多个按同一 PathKey 排序的 worker 输出做多路归并,
保持全局顺序
这部分只需要知道其存在即可,主查询优化仍然发生在前面的 Path 阶段。
第 9 步:完成 Param 和子计划处理
核心代码:
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);
}
核心函数:
SS_finalize_plan()
6.9.1 两类主要 Param
PARAM_EXTERN
客户端提供的参数,例如 SQL 中的 $1
PARAM_EXEC
计划内部在节点之间传递的执行参数
glob->paramExecTypes 记录内部参数类型:
PARAM_EXEC 0:integer
PARAM_EXEC 1:numeric
PARAM_EXEC 2:date
执行器使用:
ParamExecData[]
保存这些值。
6.9.2 InitPlan 示例
SELECT *
FROM orders
WHERE amount > (
SELECT AVG(amount)
FROM order_history
);
不相关子查询可能成为 InitPlan:
InitPlan
└── Aggregate
└── SeqScan(order_history)
它先计算:
PARAM_EXEC 0 = AVG(amount)
主计划使用:
SeqScan(orders)
└── Filter: amount > PARAM_EXEC 0
6.9.3 参数化 Nested Loop 示例
Nested Loop
├── SeqScan(customer)
└── IndexScan(orders_customer_id_idx)
每个外表元组将:
customer.id
↓
NestLoopParam
↓
PARAM_EXEC 0
↓
orders.customer_id = PARAM_EXEC 0
参数变化后,内侧 IndexScan 需要重新扫描。
6.9.4 extParam 和 allParam
每个 Plan 节点包含:
Bitmapset *extParam;
Bitmapset *allParam;
概念上:
extParam
当前 Plan 子树依赖、但由子树外部提供的 PARAM_EXEC
allParam
影响当前节点或其子树的全部执行参数集合
执行器根据这些集合判断:
参数变化后哪些节点需要 ExecReScan()
6.9.5 为什么先处理子计划
执行顺序是:
先 SS_finalize_plan(subplan)
再 SS_finalize_plan(main plan)
因为主计划可能引用子计划产生或依赖的参数。只有先完成子计划的参数集合,才能正确计算主计划的 extParam 和 allParam。
6.9.6 本步骤的输入和输出
输入:
主 Plan Tree
glob->subplans
glob->subroots
glob->paramExecTypes
输出:
每个 Plan 节点正确的 extParam 和 allParam
完成的 InitPlan/SubPlan 参数依赖
本步骤不改变连接顺序,也不重新选择 Path。
第 10 步:修正计划中的引用
核心代码:
top_plan =
set_plan_references(root, top_plan);
子计划也需要调用:
lfirst(lp) =
set_plan_references(subroot, subplan);
核心源码:
src/backend/optimizer/plan/setrefs.c
6.10.1 为什么需要修正 Var
优化器阶段的 Var 可能表示:
Var(varno = 1, varattno = 2)
customer 的第 2 列
Var(varno = 2, varattno = 4)
orders 的第 4 列
但执行 JOIN 时,父节点面对的是:
左子计划的 tuple slot
右子计划的 tuple slot
所以需要转换为:
OUTER_VAR
从左侧子计划输出槽读取
INNER_VAR
从右侧子计划输出槽读取
例如:
customer.region
↓
Var(OUTER_VAR, 左子计划输出列位置)
orders.amount
↓
Var(INNER_VAR, 右子计划输出列位置)
执行器不需要重新根据表名或关系集合查找列,只需要按 tuple slot 位置读取。
6.10.2 合并多个 Query 层级的 rtable
每个 Query 都有自己的 rtable,且下标从 1 开始:
顶层 Query.rtable
1 = customer
2 = orders
子 Query.rtable
1 = order_history
最终 PlannedStmt 只有一个统一 rtable:
finalrtable
1 = customer
2 = orders
3 = order_history
子计划原来的:
scanrelid = 1
需要调整为:
scanrelid = 3
set_plan_references() 会完成这类编号偏移。
6.10.3 收集最终语句级信息
遍历 Plan Tree 时,会填充:
glob->finalrtable;
glob->finalrteperminfos;
glob->finalrowmarks;
glob->resultRelations;
glob->appendRelations;
这些信息随后进入 PlannedStmt。
6.10.4 为什么必须在 create_plan() 后进行
在 Path 阶段还没有具体:
Plan targetlist
lefttree
righttree
scanrelid
JOIN 子节点输出列位置
只有生成 Plan Tree 后,才能确定每个表达式应该从哪个运行时 tuple slot 读取。
6.10.5 本步骤的输入和输出
输入:
仍带有规划器引用形式的 Plan Tree
当前 Query 层级的 PlannerInfo
输出:
执行器可直接使用列引用的 Plan Tree
合并后的 finalrtable
最终权限、行锁和目标关系信息
SS_finalize_plan() 和 set_plan_references() 的区别:
SS_finalize_plan()
解决参数由谁产生、谁使用、变化后谁需要重扫
set_plan_references()
解决列值从哪个 tuple slot、哪个输出位置读取
第 11 步:构造 PlannedStmt
核心代码:
result = makeNode(PlannedStmt);
随后将规划结果封装到语句级容器:
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。
执行器还需要:
语句类型
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 计划缓存依赖
如果计划使用:
orders_customer_id_idx
随后索引被删除或相关目录对象发生变化,缓存计划必须失效并重新规划。
relationOids;
invalItems;
帮助计划缓存判断何时需要重新规划。
6.11.4 本步骤的输入和输出
输入:
top_plan
PlannerGlobal 中收集的全局结果
Query 中的命令信息
输出:
PlannedStmt *result
本步骤主要是收集、整理和封装,不继续枚举 Path。
第 12 步:设置 JIT 标志
首先:
result->jitFlags = PGJIT_NONE;
满足成本条件后:
if (jit_enabled &&
jit_above_cost >= 0 &&
top_plan->total_cost > jit_above_cost)
{
result->jitFlags |= PGJIT_PERFORM;
...
}
6.12.1 本步骤没有立即编译机器码
standard_planner() 只设置:
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 有额外启动开销:
生成 LLVM IR
执行优化 Pass
生成机器码
装载机器码
短 OLTP 查询可能因为编译开销变慢;扫描大量行、执行复杂表达式和聚合的分析查询更可能受益。
6.12.4 为什么在计划选定后设置
判断依据是:
top_plan->total_cost;
只有完成 Path 搜索、选出 best_path 并创建 Plan 后,才有最终完整计划成本。
因此:
先选择最优 Path
再决定如何执行这个 Plan
JIT 标志通常不会触发重新进行连接顺序和访问路径搜索。
6.12.5 本步骤的输入和输出
输入:
top_plan->total_cost
JIT 相关 GUC
输出:
result->jitFlags
第 13 步:清理并返回
最后:
if (glob->partition_directory != NULL)
DestroyPartitionDirectory(
glob->partition_directory);
return result;
6.13.1 清理分区目录
partition_directory 是本次规划期间使用的分区描述缓存:
分区边界
分区层次
分区描述
规划期间重复使用的分区元数据
最终执行器需要的信息已经转换为:
result->partPruneInfos;
result->appendRelations;
result->rtable;
result->planTree;
所以规划器临时使用的 PartitionDirectory 可以销毁。
这不会:
删除分区表
删除 PlannedStmt 的运行时裁剪信息
破坏最终 Plan Tree
6.13.2 为什么不逐个释放 Path 和 RelOptInfo
PostgreSQL 使用 MemoryContext 批量管理内存。
规划期间产生的大量:
PlannerInfo
RelOptInfo
Path
RestrictInfo
EquivalenceClass
通常不在这里逐一 pfree(),而是在对应内存上下文生命周期结束时整体释放。
必须保留:
result
result->planTree
result->subplans
result->rtable
result->partPruneInfos
因为它们是最终规划结果的一部分。
6.13.3 返回后去哪里
standard_planner()
↓
planner()
↓
pg_plan_query()
↓
pg_plan_queries()
↓
Portal 或 Plan Cache
↓
QueryDesc
↓
ExecutorStart()
↓
ExecInitNode()
↓
PlanState
↓
ExecutorRun()
7. 贯穿示例的完整数据流
再次观察示例:
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 进入优化器
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 基本表优化
RelOptInfo(customer)
├── SeqScan Path
├── IndexPath(customer_active_idx)
└── BitmapHeapPath
RelOptInfo(orders)
├── SeqScan Path
├── IndexPath(orders_order_date_idx)
├── IndexPath(orders_customer_id_idx)
└── BitmapHeapPath
7.3 JOIN 优化
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 聚合和上层操作
UPPERREL_GROUP_AGG
├── AggPath (AGG_HASHED)
└── AggPath (AGG_SORTED)
UPPERREL_ORDERED
├── SortPath
├── IncrementalSortPath
└── 已有序 Path
UPPERREL_FINAL
└── 完成 LIMIT 和最终投影的 Path
7.5 最终选择
final_rel =
fetch_upper_rel(root,
UPPERREL_FINAL,
NULL);
best_path =
get_cheapest_fractional_path(
final_rel,
tuple_fraction);
7.6 转换为 Plan
假设选择:
LimitPath
└── SortPath
└── AggPath (AGG_HASHED)
└── HashPath
转换为:
Limit
└── Sort
└── HashAggregate
└── HashJoin
├── IndexScan(customer_active_idx)
└── Hash
└── SeqScan(orders)
7.7 最终封装
PlannedStmt
├── planTree
│ └── Limit → Sort → HashAggregate → HashJoin
├── rtable
│ ├── customer
│ └── orders
├── permInfos
├── paramExecTypes
├── relationOids
├── invalItems
├── parallelModeNeeded
└── jitFlags
8. 最容易混淆的问题
8.1 Query 是原始语法树吗
不是。
SelectStmt
原始语法树
Query
经过语义分析后的查询树
进入 standard_planner() 的 Query 通常还已经经过 pg_rewrite_query()。
8.2 subquery_planner() 只处理子查询吗
不是。
第一次调用处理顶层 Query,遇到子 Query 后再递归处理子查询层级。
8.3 subquery_planner() 返回最优 Path 吗
它返回:
PlannerInfo *
它已经生成并筛选候选 Path,但最终顶层:
Path *best_path
由 get_cheapest_fractional_path() 在它返回后选出。
8.4 RelOptInfo 是 Path 吗
不是。
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 有什么区别
Path Tree
用于搜索和成本比较
Plan Tree
用于执行器初始化
只有胜出的 Path Tree 会被转换。
8.8 Partial Path 是否只用于并行
在 PostgreSQL 优化器术语中,partial_pathlist 是并行规划专用概念。
一个 Partial Path 只产生部分结果,需要 Gather 或 Gather Merge 组合。
但:
parallel_safe Path
不一定是 Partial Path。
8.9 正常 Gather 和调试 Gather 是否相同
不同。
正常 Gather
来自 GatherPath,参与成本比较
调试 Gather
在 Plan 生成后强制包装,只用于 parallel safety 测试
8.10 set_plan_references() 是否还在优化
不是。
它将规划器形式的引用转换成执行器形式:
表列 Var
→ OUTER_VAR / INNER_VAR / tuple slot 位置
每个 Query 独立 rtable
→ PlannedStmt 统一 finalrtable
8.11 设置 JIT 标志是否已经执行 JIT
没有。
优化器只设置 jitFlags,真正的 LLVM 编译在执行阶段按需发生。
9. 适合源码跟踪的断点顺序
第一轮只观察主流程:
break planner
break standard_planner
break subquery_planner
break grouping_planner
break query_planner
break make_one_rel
break create_plan
第二轮观察 Path 生成和选择:
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 最终化:
break SS_finalize_plan
break set_plan_references
break DestroyPartitionDirectory
在 get_cheapest_fractional_path() 中重点观察:
print tuple_fraction
print rel->rows
print rel->pathlist
print rel->cheapest_startup_path
print rel->cheapest_total_path
在 standard_planner() 中重点观察:
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. 推荐源码阅读路线
第一遍:控制流程
src/backend/tcop/postgres.c
pg_plan_queries()
pg_plan_query()
src/backend/optimizer/plan/planner.c
planner()
standard_planner()
subquery_planner()
grouping_planner()
目标:
知道 Query 如何进入优化器
知道每个 Query 层级如何建立 PlannerInfo
知道最终如何得到 PlannedStmt
第二遍:关系与 Path 搜索
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()
目标:
理解 RelOptInfo
理解候选 Path 如何生成
理解连接顺序如何枚举
理解 Path 如何剪枝和比较
第三遍:Plan 和执行器交接
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()
目标:
理解 Path 如何变成 Plan
理解 Param 和 SubPlan
理解 Var 如何变成 tuple slot 引用
理解 Plan 如何初始化为 PlanState
11. 最终总结
standard_planner() 本身可以压缩成四次关键数据转换:
Query
↓ subquery_planner()
PlannerInfo + RelOptInfo + Path
↓ get_cheapest_fractional_path()
best Path
↓ create_plan()
Plan Tree
↓ finalize + setrefs + package
PlannedStmt
13 步的职责可以进一步归纳为:
第 1~3 步
建立优化环境和优化目标
第 4 步
完成逻辑预处理、关系构造、Path 生成和成本搜索
第 5~6 步
取得最终逻辑关系并选出顶层最优 Path
第 7 步
把胜出的 Path Tree 转换成 Plan Tree
第 8~10 步
补充执行要求、参数依赖和运行时列引用
第 11~13 步
封装 PlannedStmt、设置 JIT,并清理规划临时资源
最重要的概念关系是:
Query
描述查询要什么
RelOptInfo
描述正在优化哪个逻辑结果
Path
描述有哪些物理方法可以得到该结果
best_path
表示成本模型最终选中的方法
Plan
表示执行器可以初始化的静态计划
PlannedStmt
表示整条语句完整、可执行、可缓存的规划结果
未经作者同意请勿转载
本文来自博客园作者:aixueforever,原文链接:https://www.cnblogs.com/aslanvon/p/22004320

浙公网安备 33010602011771号