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 -- 整个 SELECT 查询
├── commandType = CMD_SELECT -- SQL 类型:SELECT
├── hasAggs = true -- 包含聚合函数 SUM()
├── rtable -- 查询涉及的所有 Relation
│ ├── customer -- 表 customer
│ ├── orders -- 表 orders
│ └── customer JOIN orders -- JOIN 后产生的关系
├── jointree -- FROM + JOIN + WHERE
│ ├── JoinExpr -- JOIN 节点
│ │ └── o.customer_id = c.id -- ON 条件
│ └── quals -- WHERE 子句
│ ├── c.active = true -- WHERE 条件1
│ └── o.order_date >= '2026-01-01' -- WHERE 条件2
├── targetList -- SELECT 子句
│ ├── c.region -- 输出列1
│ └── SUM(o.amount) -- 输出列2(聚合)
├── groupClause -- GROUP BY 子句
│ └── c.region -- 按地区分组
├── havingQual -- HAVING 子句
│ └── SUM(o.amount) > 10000 -- 聚合后过滤
├── sortClause -- ORDER BY 子句
│ └── total DESC -- 按 total 降序
└── limitCount -- LIMIT 子句
└── 10 -- 返回前 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、FROM 子查询、SubLink 子查询等),每个需要独立优化的 Query 层级对应一个 PlannerInfo,这些 PlannerInfo 通过 glob 字段共享同一个 PlannerGlobal。
PlannerGlobal
│
├── PlannerInfo
│ └── Top Query
│
├── PlannerInfo
│ └── CTE Query
│
├── PlannerInfo
│ └── FROM Subquery
│
└── PlannerInfo
└── SubLink Query
CTE、FROM 子查询和 SubLink 子查询都是 SQL 中嵌套 Query 的形式,但它们在 SQL 中的位置以及在 PostgreSQL 优化器中的作用不同。
CTE(Common Table Expression) 通过 WITH 定义一个命名查询结果,可以被主查询引用,例如:
WITH t AS (
SELECT *
FROM orders
)
SELECT *
FROM t;
在 PostgreSQL 内部,CTE 由 CommonTableExpr 保存,并关联一个独立的 Query。CTE 可以被多次引用,优化器会根据情况选择将其内联(inline)或者物化(materialize)。
FROM 子查询 出现在 FROM 子句中,将子查询结果作为一张临时表使用,例如:
SELECT *
FROM (
SELECT *
FROM orders
) t;
内部对应:
RTE_SUBQUERY
└── Query
由于 FROM 子查询产生一个 Relation,因此会参与关系优化过程,优化器通常会尝试通过 Subquery Pull Up 将其展开,与外层查询一起优化。
SubLink 子查询 出现在 WHERE、HAVING 或 SELECT 等表达式位置,例如:
SELECT *
FROM customer
WHERE id IN (
SELECT customer_id
FROM orders
);
内部对应:
SubLink
└── Query
SubLink 本身不是一个 Relation,而是表达式树中的一个节点,用于表示 EXISTS、IN、ANY、ALL 或标量子查询等形式。优化器通常会尝试将 SubLink 转换为 Semi Join 或 Anti Join。
因此,从 Planner 角度看:
CTE → 查询结果复用(Query 级)
FROM 子查询 → 将查询结果作为 Relation
SubLink → 将查询结果作为 Expression
三者虽然都包含嵌套的 Query,但在优化器中的角色不同:CTE 用于组织和复用查询结果,FROM 子查询用于构造关系输入,SubLink 用于表达条件判断或计算逻辑。
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 可以理解为 PostgreSQL 优化器中的一张候选方案表:
它描述“需要计算出哪个关系结果”,并保存“有哪些执行方法可以得到这个结果”。
这里的“关系”不只是数据库中的物理表,也可以是若干张表连接后的中间结果。
例如:
SELECT *
FROM customer c
JOIN orders o ON c.id = o.customer_id;
优化器会为不同的逻辑结果建立不同的 RelOptInfo:
RelOptInfo({customer})
RelOptInfo({orders})
RelOptInfo({customer, orders})
其中:
RelOptInfo({customer})表示读取customer后得到的结果。RelOptInfo({orders})表示读取orders后得到的结果。RelOptInfo({customer, orders})表示两张表连接后得到的结果。
RelOptInfo 与 Path 的关系
以 RelOptInfo({customer, orders}) 为例,相同的连接结果可以通过多种方式产生:
RelOptInfo({customer, orders})
├── HashJoinPath
├── NestLoopPath
└── MergeJoinPath
这些 Path 的逻辑结果相同,但物理执行方式不同:
HashJoinPath使用哈希连接。NestLoopPath使用嵌套循环连接。MergeJoinPath使用归并连接。
因此:
RelOptInfo 描述“要得到什么结果”
Path 描述“怎样得到这个结果”
RelOptInfo 保存什么
一个 RelOptInfo 主要保存两类信息。
第一类是这个关系本身的信息:
relids 由哪些基础表组成
rows 预计输出多少行
reltarget 需要输出哪些列和表达式
第二类是候选执行路径:
pathlist 普通候选 Path
partial_pathlist 并行候选 Path
cheapest_startup_path 启动成本最低的 Path
cheapest_total_path 总成本最低的 Path
cheapest_parameterized_paths 参数化候选 Path
为什么需要 RelOptInfo
优化器不能在生成一个执行方式后立即确定它是最优的,因为局部成本最低的 Path 不一定能产生全局最优计划。
例如:
Path A:成本 100,没有顺序
Path B:成本 120,结果已经按 customer_id 排序
如果后面需要:
ORDER BY customer_id
那么 Path A 还需要额外排序,而 Path B 可以直接使用。因此,虽然 Path B 当前更贵,但整个查询使用它可能更便宜。
RelOptInfo 的作用就是把这些逻辑等价、物理属性不同的 Path 暂时保存下来。add_path() 会删除明显更差的候选,同时保留在成本、排序、参数化和并行能力等方面仍有价值的 Path。
从 RelOptInfo 到执行计划
优化器的工作过程可以概括为:
为每张基础表创建 RelOptInfo
↓
生成 SeqScanPath、IndexPath 等候选
↓
为不同的表集合创建连接 RelOptInfo
↓
生成 HashJoinPath、NestLoopPath、MergeJoinPath
↓
add_path() 比较并剪枝候选 Path
↓
从最终 RelOptInfo 中选择最优 Path
↓
create_plan() 将 Path 转换为 Plan
所以,三者的区别是:
RelOptInfo:一个逻辑结果及其候选执行方式的容器
Path:产生该逻辑结果的一种候选物理方案
Plan:最终选中并交给执行器的执行计划
最准确地说:
RelOptInfo是 PostgreSQL 优化器搜索空间中的一个逻辑等价状态。它把产生相同逻辑结果的候选 Path 组织在一起,使优化器能够比较、剪枝并最终选择全局成本较低的执行方案。
5.5 Path
Path 是 PostgreSQL 优化器生成的候选物理执行方案。它描述了使用什么方法产生某个 RelOptInfo 所代表的逻辑结果。
需要注意:
RelOptInfo不会直接转换成 Path。优化器先创建RelOptInfo,然后围绕它生成多个候选 Path。
1. 先创建 RelOptInfo
例如:
SELECT *
FROM orders
WHERE customer_id = 100;
优化器首先创建:
RelOptInfo(orders)
它表示“读取 orders 并应用相关条件后得到的逻辑结果”。刚创建时,pathlist 基本是空的。
2. 根据 RelOptInfo 生成 Path
优化器检查这个 RelOptInfo 对应的表、过滤条件和可用索引,然后生成不同的执行方式:
RelOptInfo(orders)
├── SeqScanPath
├── IndexPath
└── BitmapHeapPath
对应的创建函数包括:
create_seqscan_path()
create_index_path()
create_bitmap_heap_path()
每个 Path 都会指向它所服务的 RelOptInfo:
path->parent = rel;
这表示:
这个 Path 是产生 rel 所代表结果的一种方法
随后,候选 Path 通过下面的函数加入 RelOptInfo:
add_path(rel, path);
也就是加入:
rel->pathlist
3. Path 保存哪些信息
每个 Path 都会记录该执行方式的主要物理属性:
startup_cost 返回第一行之前的成本
total_cost 返回全部结果的成本
rows 预计输出行数
pathkeys 输出结果的排序方式
required_outer 依赖哪些外部关系
parallel_safe 是否支持并行执行
因此,两个 Path 即使产生相同结果,也可能具有不同的成本和输出属性。
4. 连接 Path 怎么产生
对于:
SELECT *
FROM customer c
JOIN orders o ON c.id = o.customer_id;
优化器先为两张基础表建立 RelOptInfo 和扫描 Path:
RelOptInfo(customer)
├── SeqScanPath
└── IndexPath
RelOptInfo(orders)
├── SeqScanPath
└── IndexPath
然后创建连接结果的 RelOptInfo:
RelOptInfo({customer, orders})
优化器从两个基础 RelOptInfo 中选取可用的 Path 进行合法组合,生成连接 Path:
RelOptInfo({customer, orders})
├── HashPath
│ ├── SeqScanPath(customer)
│ └── SeqScanPath(orders)
│
├── NestPath
│ ├── SeqScanPath(customer)
│ └── IndexPath(orders)
│
└── MergePath
├── IndexPath(customer)
└── IndexPath(orders)
所以,连接 Path 是由下层 RelOptInfo 中的 Path 组合而来的,并通过 outer_path、inner_path 引用子 Path。
5. Path 如何被筛选
新生成的 Path 会交给:
add_path(rel, path);
add_path() 会比较:
- 启动成本
- 总成本
- 输出顺序
- 参数化依赖
- 并行属性
如果一个 Path 在这些方面都不如另一个 Path,它就会被剪枝。仍有价值的候选保存在:
rel->pathlist
最后,set_cheapest() 标记其中成本最优的候选。
示例
SELECT r.zone, SUM(o.amount)
FROM customer c
JOIN orders o ON o.customer_id = c.id
JOIN region r ON r.region_code = c.region_code
WHERE c.active
GROUP BY r.zone;
优化器先为三张基础表创建:
RelOptInfo({customer})
RelOptInfo({orders})
RelOptInfo({region})
随后组合 customer 和 orders:
RelOptInfo({customer, orders})
└── HashPath
├── SeqScanPath(customer)
└── SeqScanPath(orders)
这个中间 Path 会继续与 region 的 Path 组合:
RelOptInfo({customer, orders, region})
└── HashPath
├── HashPath ← 来自 {customer, orders}
│ ├── SeqScanPath(customer)
│ └── SeqScanPath(orders)
└── SeqScanPath(region) ← 来自 {region}
因此,大 RelOptInfo 中的 Path 会引用较小 RelOptInfo 中保留下来的 Path,并逐层构成完整的 Path Tree。
完整关系
创建 RelOptInfo
↓
分析表、条件、索引和连接关系
↓
调用 create_xxx_path() 生成候选 Path
↓
Path 指向所属 RelOptInfo
↓
add_path() 添加并剪枝
↓
保存在 RelOptInfo->pathlist
↓
上层 RelOptInfo 继续组合这些 Path
↓
形成最终 Path Tree
↓
create_plan()
↓
Plan Tree
简而言之:
RelOptInfo:表示要得到什么逻辑结果
Path:表示怎样得到这个结果
基础表的 Path 根据表、条件和索引生成;连接关系的 Path 则通过组合下层 RelOptInfo 中保留下来的 Path 生成。
5.6 Plan 和 PlannedStmt
Plan 表示执行计划树中的节点,例如:
SeqScan、IndexScan、HashJoin、Sort、Aggregate
各个 Plan 节点通过子节点组成完整的 Plan Tree。
PlannedStmt 是交给执行器的完整计划包:
PlannedStmt
├── planTree 主 Plan Tree
├── subplans 子计划
├── rtable 表和列信息
├── paramExecTypes 参数类型
├── relationOids 依赖关系
└── JIT、并行、行锁等信息
简单来说:
Plan:执行计划树
PlannedStmt:Plan Tree 加执行整条语句需要的全局信息
EXPLAIN 打印什么
EXPLAIN 接收 PlannedStmt,主要遍历并格式化其中的:
PlannedStmt->planTree
所以它展示的是 Plan Tree 的摘要,而不是完整 Plan 对象。EXPLAIN ANALYZE 还会加入实际执行时间、行数等运行信息。
能否反序列化成 Plan
不能从标准 EXPLAIN 输出完整恢复出可执行的 Plan。
即使使用:
EXPLAIN (FORMAT JSON)
也只能解析出节点类型、成本和子节点等展示信息,缺少表达式树、OID、参数、Range Table 和子计划等内部数据。
PostgreSQL 内部虽然有 nodeToString() 和 stringToNode(),但这是版本相关的内部格式,不是 EXPLAIN 格式,也不是稳定的公开接口。
5.7 Path 的 Pareto Frontier
1.Pareto Frontier
Pareto Frontier,中文通常叫“帕累托前沿”,表示:
在多个比较维度下,所有没有被其他候选完全支配的方案集合。
假设两个 Path 产生相同的逻辑结果:
Path A:启动成本低,总成本高
Path B:启动成本高,总成本低
两者各有优势,不能简单删除任何一个:
Path A 适合 LIMIT、EXISTS
Path B 适合读取全部结果
因此,它们都会位于 Pareto Frontier 上。
2. 什么是支配关系
如果 Path A 满足以下条件:
在所有重要属性上都不比 Path B 差
并且至少有一个属性明显优于 Path B
那么就称:
Path A 支配 Path B
被支配的 Path B 不可能在后续规划中成为更优方案,因此可以安全剪枝。
例如:
| Path | startup | total | 排序 | 参数化 |
|---|---|---|---|---|
| A | 10 | 100 | 无序 | 无 |
| B | 20 | 150 | 无序 | 无 |
A 和 B 产生相同结果,且其他属性相同,但 A 的启动成本和总成本都更低:
A 支配 B
所以 B 可以删除。
3. 为什么不能只保留 total_cost 最低的 Path
考虑下面两个候选:
| Path | startup | total | 排序 |
|---|---|---|---|
| A | 5 | 150 | 无序 |
| B | 20 | 100 | 无序 |
A 的启动成本更低,B 的总成本更低:
A 适合只读取少量结果
B 适合读取全部结果
它们互不支配,因此都需要保留。
再考虑一个已经有序的 Path:
| Path | startup | total | 排序 |
|---|---|---|---|
| B | 20 | 100 | 无序 |
| C | 30 | 130 | 按 id 排序 |
虽然 C 的成本更高,但后面如果需要:
ORDER BY id
B 还需要增加排序:
B + Sort = 100 + 60 = 160
C = 130
因此,C 不能因为当前成本更高就被删除。
4. PostgreSQL 比较哪些属性
PostgreSQL 的 add_path() 主要比较以下属性:
disabled_nodes
startup_cost
total_cost
pathkeys
required_outer
rows
parallel_safe
disabled_nodes
表示 Path 使用了多少个被配置参数禁用的计划节点。
例如:
SET enable_seqscan = off;
使用顺序扫描的 Path 会增加 disabled_nodes。
在 PostgreSQL 中,更少的 disabled_nodes 具有更高优先级,甚至可以压过普通成本差异。
startup_cost
表示返回第一行之前的成本。
它主要在以下场景中有价值:
LIMIT
EXISTS
游标
只预计读取部分结果
但 PostgreSQL 只有在:
rel->consider_startup
或者参数化路径的:
rel->consider_param_startup
为真时,才会把低启动成本作为保留 Path 的理由。
total_cost
表示返回全部结果的成本,是大多数查询最重要的成本指标。
pathkeys
表示 Path 的输出顺序。
例如:
Path A:无序
Path B:按照 customer_id 排序
Path B 可能支持:
ORDER BY
GROUP BY
Merge Join
Incremental Sort
因此,即使 B 成本更高,也可能需要保留。
required_outer
表示参数化 Path 依赖哪些外部关系。
例如:
IndexPath(orders)
required_outer = {customer}
表示只有获得当前 customer.id 后,这个索引 Path 才能执行。
一般来说:
required_outer 越少
Path 越通用
如果两个 Path 成本相近,依赖更少外部关系的 Path 更有优势。
rows
通常同一个 RelOptInfo 的普通 Path 输出行数相同,但参数化 Path 可能因为应用了额外连接条件而输出更少的行。
例如:
普通 SeqScanPath:rows = 100000
参数化 IndexPath:rows = 10
参数化 Path 虽然依赖外部关系,但输出行数更少,因此两个 Path 可能互不支配。
parallel_safe
如果一个 Path 是 parallel_safe,而另一个不是,那么前者可以参与并行计划,具有更大的后续使用范围。
5. 一个完整示例
假设某个 RelOptInfo 有以下候选:
| Path | startup | total | 排序 | required_outer | rows | 并行安全 |
|---|---|---|---|---|---|---|
| A | 5 | 150 | 无序 | {} |
1000 | 是 |
| B | 20 | 100 | 无序 | {} |
1000 | 是 |
| C | 25 | 160 | 无序 | {} |
1000 | 是 |
| D | 30 | 130 | 按 id 排序 | {} |
1000 | 是 |
| E | 10 | 110 | 无序 | {customer} |
20 | 是 |
分析结果:
A 保留:启动成本最低
B 保留:总成本最低
C 删除:启动成本和总成本都比 B 差,没有其他优势
D 保留:提供按 id 排序的结果
E 保留:虽然依赖 customer,但预计只输出 20 行
最终 Pareto Frontier 是:
{A, B, D, E}
其中 C 被完全支配,因此被剪枝。
6. add_path() 如何维护 Pareto Frontier
每生成一个新 Path,都会调用:
add_path(rel, new_path);
其逻辑可以简化为:
new_path
↓
依次与 rel->pathlist 中已有 Path 比较
↓
┌─────────────────────────────┐
│ 旧 Path 支配 new_path │
│ 删除 new_path,停止比较 │
└─────────────────────────────┘
┌─────────────────────────────┐
│ new_path 支配旧 Path │
│ 从 pathlist 删除旧 Path │
└─────────────────────────────┘
┌─────────────────────────────┐
│ 两个 Path 互不支配 │
│ 两者都保留,继续比较 │
└─────────────────────────────┘
最终:
RelOptInfo->pathlist
保存的就是经过剪枝后仍有潜在价值的 Path 集合。
随后:
set_cheapest(rel);
从这些候选中记录:
cheapest_startup_path
cheapest_total_path
cheapest_parameterized_paths
set_cheapest() 并不会删除 Pareto Frontier 中的其他 Path,因为这些 Path 仍可能被上层连接、聚合或排序使用。
7. 普通 Path 和 Partial Path 分开维护
PostgreSQL 分别维护:
rel->pathlist;
rel->partial_pathlist;
其中:
pathlist
保存能够产生完整关系结果的 Path
partial_pathlist
保存每个并行 worker 只产生部分结果的 Path
add_path() 维护普通 Path 的前沿,add_partial_path() 维护 Partial Path 的前沿。两者不会直接混在同一个候选集合中比较。
8. PostgreSQL 的实现不是严格数学模型
PostgreSQL 的 Path Pareto Frontier 是一种工程化的近似实现,并非严格的通用多目标优化算法。
它加入了一些控制搜索空间的策略:
成本使用模糊容差比较
参数化 Path 的 pathkeys 通常按 NIL 处理
只有需要快速启动时才比较 startup_cost
disabled_nodes 的优先级高于普通成本
普通 Path 和 Partial Path 分开维护
这样做的目的是避免保存过多仅有微小差异的候选 Path,否则多表连接时搜索空间会迅速爆炸。
PostgreSQL 源码对支配关系的概括是:新 Path 成本更低、排序不差、输出行数不多、外部依赖不多,并且并行安全性不差时,可以删除旧 Path。PostgreSQL add_path() 源码
总结
Pareto Frontier
=
一个 RelOptInfo 中所有未被完全支配的候选 Path
它的作用是:
删除未来不可能成为最优方案的 Path,同时保留在启动成本、总成本、排序、参数化或并行能力方面仍有独特价值的 Path。
这样既能控制优化器的搜索空间,又尽量避免过早删除可能形成全局最优计划的候选方案。
5.8 Gather 和 Gather Merge 的区别
两者都负责将并行 worker 的结果发送给 leader,区别在于是否保持全局顺序。
Gather
Gather 直接收集 worker 产生的结果,不保证输出顺序。
假设两个 worker 分别产生:
Worker 1:1, 4, 7
Worker 2:2, 5, 8
Gather 的输出可能是:
1, 4, 2, 5, 7, 8
也可能是其他顺序,取决于哪个 worker 先产生数据。
典型计划:
Gather
└── Parallel Seq Scan
适用于不要求结果有序的查询。
Gather Merge
Gather Merge 要求每个 worker 的输出已经按照相同规则排序,然后由 leader 进行多路归并。
Worker 1:1, 4, 7
Worker 2:2, 5, 8
Gather Merge 输出:
1, 2, 4, 5, 7, 8
典型计划:
Gather Merge
└── Sort
└── Parallel Seq Scan
或者 worker 使用能够直接产生有序结果的并行索引扫描:
Gather Merge
└── Parallel Index Scan
核心区别
| 特性 | Gather | Gather Merge |
|---|---|---|
| 收集 worker 结果 | 是 | 是 |
| 保持全局顺序 | 否 | 是 |
| 要求 worker 输出有序 | 否 | 是 |
| leader 需要归并 | 否 | 是 |
| 执行开销 | 较低 | 较高 |
| 典型用途 | 普通并行查询 | ORDER BY 等有序查询 |
可以简单记成:
Gather
只负责收集结果
Gather Merge
收集结果并按顺序归并
另外,前面提到的调试 Gather 使用的是:
Gather + single_copy
它只是让一个 worker 执行完整计划,不需要多个有序数据流,因此不会使用 Gather Merge。
5.9 worker和leader的关系
在 PostgreSQL 并行查询中:
Leader
负责接收客户端查询、启动 worker、汇总结果
Worker
负责执行计划中可以并行的部分
它们的关系可以表示为:
客户端
↓
Leader
├── Worker 1
├── Worker 2
└── Worker 3
↓
Gather / Gather Merge
↓
Leader
↓
客户端
Leader
Leader 就是原本负责当前客户端连接的 PostgreSQL backend 进程。它负责:
解析和规划查询
申请并启动 parallel worker
向 worker 传递计划、参数和事务快照
通过 Gather 收集结果
执行 Gather 上方的计划节点
把最终结果返回客户端
Leader 也可以参与执行 Gather 下方的并行计划,是否参与受以下参数影响:
parallel_leader_participation
Worker
Worker 是 Leader 临时启动的后台进程,负责执行 Gather 下方的并行部分:
Parallel Seq Scan
Parallel Index Scan
Parallel Hash Join
Partial Aggregate
Parallel Append
Worker 不直接连接客户端,也不直接返回最终结果,而是通过共享内存和 tuple queue 把结果发送给 Leader。
计划中的分工
例如:
Finalize Aggregate ← Leader 执行
└── Gather ← Leader 收集结果
└── Partial Aggregate ← Leader 和 Worker 可能共同执行
└── Parallel Seq Scan
多个进程分别计算局部聚合结果:
Worker 1 → partial sum
Worker 2 → partial sum
Worker 3 → partial sum
Leader 通过 Gather 收集这些结果,再由 Finalize Aggregate 计算最终结果。
核心关系
Leader 是并行查询的发起者和汇总者
Worker 是 Leader 临时调度的执行者
Gather 是 Worker 向 Leader 传递结果的边界
Worker 数量不足时,查询可以使用更少的 Worker,甚至完全由 Leader 执行。调试 Gather 的 single_copy 模式则通常只启动一个 Worker,由它执行完整子计划,Leader 只负责收集结果。
6. standard_planner() 的 13 步流程
startup_cost 和 total_cost 的关系
startup_cost 表示执行计划节点返回第一行之前需要付出的成本。
total_cost 表示执行计划节点返回全部结果需要付出的总成本。
两者关系可以理解为:
total_cost
=
startup_cost
+
返回剩余结果需要的运行成本
因此通常有:
startup_cost <= total_cost
示例
顺序扫描可以很快返回第一行:
SeqScan
startup_cost = 0
total_cost = 1000
排序必须读取并排序所有输入后,才能返回第一行:
Sort
startup_cost = 800
total_cost = 900
所以:
- 查询需要全部结果时,优化器主要比较
total_cost。 LIMIT、EXISTS或游标只读取部分结果时,优化器会更关注startup_cost。
预计只读取比例 f 时,可以近似计算:
fractional_cost
=
startup_cost
+
f × (total_cost - startup_cost)
例如只读取 10%:
startup_cost = 10
total_cost = 110
fractional_cost
= 10 + 0.1 × (110 - 10)
= 20
需要注意,这些成本不是实际毫秒,而是优化器用于比较候选 Path 的相对成本单位。
第 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);
subquery_planner() 是 PostgreSQL 对一个完整 Query 层级进行优化的主要入口。它完成逻辑预处理、扫描路径生成、连接搜索以及聚合、排序等上层规划。
它返回:
PlannerInfo *root;
返回值不是 Plan、PlannedStmt 或最终 best_path,而是该查询层级的完整规划状态。
4.1 输入和输出
第一次从 standard_planner() 调用时:
parent_root == NULL
表示当前处理的是顶层 Query。
如果遇到无法提升、需要独立规划的子查询,则会递归调用:
subquery_planner(...)
此时:
parent_root != NULL
query_level = parent_root->query_level + 1
其输入和输出可以概括为:
输入:
PlannerGlobal
Query
tuple_fraction
父查询 PlannerInfo
输出:
PlannerInfo *root
root 中保存了:
预处理后的 Query
基础表 RelOptInfo
连接 RelOptInfo
Upper RelOptInfo
RestrictInfo
EquivalenceClass
剪枝后的候选 Path
4.2 整体调用关系
subquery_planner() 的主流程可以概括为:
subquery_planner()
├── 创建 PlannerInfo
├── 处理 CTE 和 SubLink
├── 提升可以合并的子查询
├── 预处理表达式
├── 化简外连接
├── 展开继承表和分区表
│
└── grouping_planner()
├── query_planner()
│ ├── 创建基础 RelOptInfo
│ ├── 分解 WHERE 和 JOIN 条件
│ ├── 创建 RestrictInfo
│ ├── 创建 EquivalenceClass
│ ├── 生成基础表 Path
│ └── 枚举连接顺序和连接方法
│
├── 创建聚合 Path
├── 创建 Window Path
├── 创建 DISTINCT Path
├── 创建排序 Path
└── 创建 LIMIT 和最终 Path
query_planner() 不是在 grouping_planner() 之后调用,而是 grouping_planner() 内部负责扫描和连接优化的步骤。
4.3 逻辑预处理
subquery_planner() 首先对 Query 进行逻辑预处理。典型变换包括:
EXISTS SubLink
→ 满足条件时转换为 Semi Join
可以安全提升的 FROM 子查询
→ 合并到父 Query
简单 UNION ALL 子查询
→ 展开为 Append 关系
LEFT JOIN + 拒绝 NULL 的 WHERE 条件
→ Inner Join
例如:
SELECT *
FROM customer c
WHERE EXISTS (
SELECT 1
FROM orders o
WHERE o.customer_id = c.id
);
在满足转换条件时,可以变成类似:
customer SEMI JOIN orders
需要注意,在进入 standard_planner() 之前,Query Rewriter 已经完成了视图、Rule System 和 RLS 等展开:
pg_rewrite_query()
↓
standard_planner()
↓
subquery_planner()
因此,这里进行的是优化器内部的逻辑简化,而不是 SQL Rule 重写。
4.4 扫描和连接优化
完成逻辑预处理后,grouping_planner() 调用 query_planner(),处理 FROM、WHERE 和 JOIN。
主要调用链是:
query_planner()
↓
make_one_rel()
├── set_base_rel_sizes()
├── set_base_rel_pathlists()
└── make_rel_from_joinlist()
创建基础 RelOptInfo
对于:
FROM customer c
JOIN orders o ON o.customer_id = c.id
优化器首先创建:
RelOptInfo({customer})
RelOptInfo({orders})
然后根据过滤条件、索引和统计信息生成扫描 Path:
RelOptInfo({customer})
├── Path(pathtype = T_SeqScan)
├── IndexPath
└── BitmapHeapPath
RelOptInfo({orders})
├── Path(pathtype = T_SeqScan)
├── IndexPath
└── BitmapHeapPath
候选 Path 的成本由对应的 cost_xxx() 函数计算,然后通过:
add_path(rel, path);
加入:
rel->pathlist
add_path() 会同时剪掉被其他候选完全支配的 Path。
完全支配
完全支配就是 PostgreSQL 剪枝候选 Path 的重要依据,“被其他候选完全支配”表示:
两个 Path 产生相同的逻辑结果,而其中一个 Path 在所有重要属性上都不比另一个更好,并且至少有一项更差。
例如:
Path A
startup_cost = 10
total_cost = 100
pathkeys = 无序
required_outer = {}
parallel_safe = true
Path B
startup_cost = 20
total_cost = 150
pathkeys = 无序
required_outer = {}
parallel_safe = true
A 和 B 的排序、参数化和并行属性相同,但 A 的启动成本和总成本都更低。因此 B 不可能在后续规划中成为更优方案,可以直接删除:
Path A 支配 Path B
什么情况下不能删除
如果 B 虽然更贵,但提供了有价值的排序:
Path A:成本 100,无序
Path B:成本 120,按照 customer_id 排序
B 不能被删除,因为它可能避免后续的 Sort。
类似地:
Path A:启动成本 20,总成本 100
Path B:启动成本 5,总成本 130
两者也不能互相支配:
- A 返回全部结果更便宜。
- B 返回第一批结果更快。
LIMIT 或 EXISTS 查询可能更适合 B。
因此,add_path() 会综合比较:
启动成本
总成本
输出顺序 PathKeys
参数化依赖 required_outer
预计输出行数
并行安全属性
只有一个 Path 在这些方面都没有优势时,才会被认为是完全支配并被剪枝。
保留下来的 Path 形成一个 Pareto Frontier:
更低启动成本的 Path
更低总成本的 Path
提供不同排序的 Path
具有不同参数化方式的 Path
支持不同并行方式的 Path
4.5 连接顺序和连接方法
基础表 Path 创建完成后,优化器开始构造连接 RelOptInfo。
两个表可能产生:
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()
↓
make_join_rel()
↓
add_paths_to_joinrel()
对于相同的关系集合,PostgreSQL 通常只创建一个 JOIN RelOptInfo。不同连接顺序和连接方法表现为其中不同的 Path Tree。
例如:
RelOptInfo({A,B,C})
├── HashPath
│ ├── Path({A,B})
│ └── Path({C})
│
└── NestPath
├── Path({A,C})
└── Path({B})
外连接、半连接、反连接和 LATERAL 依赖不能随意重排。PostgreSQL 使用 SpecialJoinInfo 等结构限制合法连接顺序。
4.6 等价条件推导
query_planner() 还会将连接和过滤条件包装为 RestrictInfo,并构造 EquivalenceClass。
例如:
a.id = b.id
b.id = 10
可以推导出:
a.id = 10
这些信息会用于:
生成索引条件
生成连接条件
判断 Merge Join 可行性
推导排序等价关系
减少重复条件比较
因此,关系代数相关优化分布在多个位置:
subquery_planner()
子查询提升、SubLink 转换、外连接化简
query_planner()
谓词分解、等价类构造、隐含条件推导
Join Search
利用交换律和结合律枚举合法连接顺序
连接方法选择则属于物理优化:
Nested Loop
Hash Join
Merge Join
4.7 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
一个 Path 即使 parallel_safe,也不一定是 Partial Path。
4.8 聚合、排序和 LIMIT
query_planner() 完成扫描和连接规划后,grouping_planner() 在其结果之上继续创建 Upper RelOptInfo。
例如:
UPPERREL_GROUP_AGG
├── AggPath(AGG_HASHED)
└── AggPath(AGG_SORTED)
UPPERREL_ORDERED
├── SortPath
├── IncrementalSortPath
└── 已经满足排序要求的 Path
UPPERREL_FINAL
├── LimitPath
└── 其他最终完成 Path
可以理解为:
扫描和连接 Path
↓
聚合 Path
↓
Window / DISTINCT Path
↓
排序 Path
↓
LIMIT 和最终 Path
这些上层 Path 仍然通过 add_path() 加入对应 Upper RelOptInfo,并进行成本比较和剪枝。
4.9 是否已经产生最优 Path
subquery_planner() 返回时,已经完成:
候选 Path 生成
成本计算
add_path() 剪枝
set_cheapest() 标记最便宜 Path
Upper RelOptInfo 构造
但它不会直接返回最终 best_path,而是返回:
PlannerInfo *root;
回到 standard_planner() 后,还需要执行:
final_rel = fetch_upper_rel(root,
UPPERREL_FINAL,
NULL);
best_path = get_cheapest_fractional_path(final_rel,
tuple_fraction);
这里才会根据 tuple_fraction,从最终 RelOptInfo 中明确选出顶层最佳 Path。
随后:
top_plan = create_plan(root, best_path);
将最佳 Path Tree 转换为 Plan Tree。
4.10 本步骤的最终结果
第 4 步结束时,优化器得到的是完整的候选 Path 搜索结果:
PlannerInfo
├── 基础表 RelOptInfo
│ └── 扫描 Path
│
├── JOIN RelOptInfo
│ └── Join Path
│
├── Upper RelOptInfo
│ └── 聚合、排序和 LIMIT Path
│
└── 各层剪枝后保留下来的 cheapest Path
因此,subquery_planner() 的核心作用可以总结为:
对一个 Query 层级进行逻辑预处理,建立各层
RelOptInfo,生成、估价并剪枝候选 Path,最终将完整规划状态封装在PlannerInfo中返回。
第 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 步:完成 Param 和子计划处理
核心代码:
if (glob->paramExecTypes != NIL)
{
forboth(lp, glob->subplans,
lr, glob->subroots)
{
Plan *subplan = (Plan *) lfirst(lp);
PlannerInfo *subroot =
lfirst_node(PlannerInfo, lr);
lfirst(lp) =
SS_finalize_plan(subroot, subplan);
}
top_plan =
SS_finalize_plan(root, top_plan);
}
核心函数:
SS_finalize_plan()
源码位于:
src/backend/optimizer/plan/subselect.c
6.8.1 这一步在做什么
查询中可能包含子查询,或者 Nested Loop 内外层之间需要传递数据。PostgreSQL 使用内部参数 PARAM_EXEC 完成这种值传递。
SS_finalize_plan() 的作用是:
找出参数由哪个计划节点提供、被哪些节点使用,以及参数改变后哪些节点需要重新执行。
它不会重新选择 Path,也不会立即执行子计划,只是在已经生成的 Plan Tree 上补充参数依赖信息。
内部参数的类型记录在:
glob->paramExecTypes;
例如:
PARAM_EXEC 0:integer
PARAM_EXEC 1:numeric
执行期间,参数值保存在:
ParamExecData[];
可以把它理解成执行器内部的一组参数格子。
6.8.2 示例:相关子查询
SELECT c.id,
(
SELECT MAX(o.amount)
FROM orders o
WHERE o.customer_id = c.id
) AS max_amount
FROM customer c;
子查询使用了外层查询的:
c.id
因此,子计划每次执行前,都需要取得当前客户的 c.id。
生成的计划可以简化为:
SeqScan(customer)
└── SubPlan 1
└── Aggregate: MAX(amount)
└── IndexScan(orders)
Index Cond:
customer_id = PARAM_EXEC 0
这里的 PARAM_EXEC 0 是 PostgreSQL 内部参数,不是客户端 SQL 中的 $1。
6.8.3 参数如何传递
假设主计划读取到:
customer.id = 42
执行过程为:
读取 customer.id = 42
↓
写入 PARAM_EXEC 0
↓
执行 SubPlan 1
↓
扫描 customer_id = 42 的订单
↓
计算 MAX(amount)
↓
返回当前客户的 max_amount
读取下一个客户时:
customer.id = 43
↓
PARAM_EXEC 0 = 43
↓
SubPlan 1 需要重新执行
在 SubPlan 中,这种关系可以表示为:
parParam = {0}
args = {c.id}
plan_id = 1
含义是:
parParam = {0}
子计划需要 PARAM_EXEC 0
args = {c.id}
参数值来自当前外层行的 c.id
plan_id = 1
使用 PlannedStmt->subplans 中的第 1 个子计划
6.8.4 extParam 和 allParam
SS_finalize_plan() 会为 Plan 节点计算:
plan->extParam;
plan->allParam;
extParam 表示:
当前 Plan 子树需要从外部获得哪些参数。
例如:
IndexScan(orders)
Index Cond: customer_id = PARAM_EXEC 0
extParam = {0}
allParam 表示:
哪些参数发生变化时,当前节点或其子树需要重新执行。
例如:
Aggregate
└── IndexScan(orders)
虽然 Aggregate 不直接读取 PARAM_EXEC 0,但它的子节点依赖这个参数。因此参数改变后,整个聚合子树都需要重新执行:
Aggregate.allParam = {0}
IndexScan.allParam = {0}
可以简单记成:
extParam
当前子树需要从外部取得哪些参数
allParam
哪些参数变化会影响当前子树的结果
执行器会根据这些信息决定是否调用:
ExecReScan();
6.8.5 为什么先处理子计划
处理顺序是:
先处理所有 SubPlan
↓
再处理主 Plan Tree
主计划中通常只通过:
SubPlan 1
SubPlan 2
引用子计划。主计划要计算完整的参数依赖,必须先知道每个子计划:
依赖哪些参数
产生哪些参数
哪些参数变化会影响子计划结果
因此,PostgreSQL先调用:
SS_finalize_plan(subroot, subplan);
然后再处理:
SS_finalize_plan(root, top_plan);
6.8.6 本步骤的结果
输入:
主 Plan Tree
所有子 Plan Tree
PARAM_EXEC 类型信息
处理:
分析主计划与子计划之间的参数传递
输出:
每个 Plan 节点的 extParam
每个 Plan 节点的 allParam
完整的主计划与子计划参数依赖关系
这一步的核心作用可以总结为:
给主计划和子计划建立完整的参数传递线路,并告诉执行器参数变化后应该重新执行哪些 Plan 节点。
它和下一步 set_plan_references() 的区别是:
SS_finalize_plan()
解决参数由谁提供、谁使用、谁需要重新扫描
set_plan_references()
解决列值应该从哪个 TupleSlot 的哪个位置读取
第 9 步:修正计划中的引用
核心代码:
top_plan = set_plan_references(root, top_plan);
子计划也需要分别处理:
lfirst(lp) = set_plan_references(subroot, subplan);
源码位于:
src/backend/optimizer/plan/setrefs.c
这一阶段可以简单理解为:
把 Plan Tree 中“按照表和列定位数据”的引用,改成执行器能够直接使用的“按照子节点输出位置定位数据”的引用。
它不会重新选择执行计划,只是在为执行器整理最终的列地址。
6.9.1 将表列引用改成 TupleSlot 引用
在优化器中,一个 Var 可能表示:
Var(varno=1, varattno=2)
含义是:
第 1 个 RangeTblEntry 对应表的第 2 列
例如:
customer.name
orders.amount
但是执行 JOIN 时,父节点不会直接访问 customer 和 orders 表。它看到的是两个子计划产生的结果:
左侧子计划的输出 TupleSlot
右侧子计划的输出 TupleSlot
假设计划为:
Hash Join
├── Seq Scan(customer)
└── Seq Scan(orders)
两个子节点输出:
左侧输出:
第 1 列 customer.id
第 2 列 customer.region
右侧输出:
第 1 列 orders.customer_id
第 2 列 orders.amount
原来的引用:
customer.region
orders.amount
会被改写为:
Var(OUTER_VAR, 2)
Var(INNER_VAR, 2)
它们分别表示:
从左侧子计划输出的第 2 列读取
从右侧子计划输出的第 2 列读取
这样,执行器不需要重新根据表名查找列,只需要从相应的 TupleSlot 中读取指定位置。
6.9.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
否则执行器会错误地认为它扫描的是 customer。
set_plan_references() 会在合并不同 Query 层级时修正这些编号。
6.9.3 收集语句级信息
遍历 Plan Tree 时,它还会整理最终执行计划需要的全局信息:
glob->finalrtable;
glob->finalrteperminfos;
glob->finalrowmarks;
glob->resultRelations;
glob->appendRelations;
这些信息随后会被放入 PlannedStmt,供执行器进行表访问、权限检查、行锁和数据修改。
6.9.4 为什么在 create_plan() 之后处理
在 Path 阶段,优化器还没有确定具体的 Plan 节点结构,例如:
targetlist
lefttree
righttree
scanrelid
子节点输出列的位置
只有执行:
create_plan(root, best_path);
生成 Plan Tree 后,才能知道:
某个列值来自哪个子节点
某个列位于子节点输出的第几项
某个扫描节点对应最终 rtable 的哪个位置
因此,列引用修正必须在 create_plan() 之后进行。
6.9.5 输入和输出
输入:
已经生成但仍使用规划器引用方式的 Plan Tree
当前 Query 层级的 PlannerInfo
输出:
执行器可以直接使用的 Plan Tree
统一后的 finalrtable
最终权限、行锁和目标关系信息
可以把它理解为一次“地址转换”:
优化器引用:
customer.region
转换后:
从左侧子节点输出的第 2 列读取
最后需要区分两个函数:
SS_finalize_plan()
处理参数依赖
确定参数由谁产生、谁使用,以及谁需要重新扫描
set_plan_references()
处理列引用
确定列值来自哪个 TupleSlot 的哪个位置
第 10 步:构造 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.10.1 为什么不能只返回 Plan *
Plan *top_plan 只表示主 Plan Tree。
执行器还需要:
语句类型
SubPlan
最终范围表
权限信息
目标关系
行锁
分区裁剪
内部参数类型
计划缓存依赖
并行模式
JIT 标志
所以需要 PlannedStmt。
6.10.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.10.3 计划缓存依赖
如果计划使用:
orders_customer_id_idx
随后索引被删除或相关目录对象发生变化,缓存计划必须失效并重新规划。
relationOids;
invalItems;
帮助计划缓存判断何时需要重新规划。
6.10.4 本步骤的输入和输出
输入:
top_plan
PlannerGlobal 中收集的全局结果
Query 中的命令信息
输出:
PlannedStmt *result
本步骤主要是收集、整理和封装,不继续枚举 Path。
第 11 步:设置 JIT 标志
首先:
result->jitFlags = PGJIT_NONE;
满足成本条件后:
if (jit_enabled &&
jit_above_cost >= 0 &&
top_plan->total_cost > jit_above_cost)
{
result->jitFlags |= PGJIT_PERFORM;
...
}
6.11.1 本步骤没有立即编译机器码
standard_planner() 只设置:
PlannedStmt.jitFlags
真正的 LLVM IR 生成、优化和机器码生成发生在执行阶段。
6.11.2 主要标志
| 标志 | 含义 |
|---|---|
PGJIT_PERFORM |
启用 JIT 总开关 |
PGJIT_EXPR |
JIT 编译表达式求值 |
PGJIT_DEFORM |
JIT 编译 tuple deforming |
PGJIT_INLINE |
执行函数内联 |
PGJIT_OPT3 |
执行更昂贵的 LLVM 优化 |
6.11.3 为什么根据成本决定
JIT 有额外启动开销:
生成 LLVM IR
执行优化 Pass
生成机器码
装载机器码
短 OLTP 查询可能因为编译开销变慢;扫描大量行、执行复杂表达式和聚合的分析查询更可能受益。
6.11.4 为什么在计划选定后设置
判断依据是:
top_plan->total_cost;
只有完成 Path 搜索、选出 best_path 并创建 Plan 后,才有最终完整计划成本。
因此:
先选择最优 Path
再决定如何执行这个 Plan
JIT 标志通常不会触发重新进行连接顺序和访问路径搜索。
6.11.5 本步骤的输入和输出
输入:
top_plan->total_cost
JIT 相关 GUC
输出:
result->jitFlags
第 12 步:清理并返回
最后:
if (glob->partition_directory != NULL)
DestroyPartitionDirectory(
glob->partition_directory);
return result;
6.12.1 清理分区目录
partition_directory 是本次规划期间使用的分区描述缓存:
分区边界
分区层次
分区描述
规划期间重复使用的分区元数据
最终执行器需要的信息已经转换为:
result->partPruneInfos;
result->appendRelations;
result->rtable;
result->planTree;
所以规划器临时使用的 PartitionDirectory 可以销毁。
这不会:
删除分区表
删除 PlannedStmt 的运行时裁剪信息
破坏最终 Plan Tree
6.12.2 为什么不逐个释放 Path 和 RelOptInfo
PostgreSQL 使用 MemoryContext 批量管理内存。
规划期间产生的大量:
PlannerInfo
RelOptInfo
Path
RestrictInfo
EquivalenceClass
通常不在这里逐一 pfree(),而是在对应内存上下文生命周期结束时整体释放。
必须保留:
result
result->planTree
result->subplans
result->rtable
result->partPruneInfos
因为它们是最终规划结果的一部分。
6.12.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~9 步
补充执行要求、参数依赖和运行时列引用
第 10~12 步
封装 PlannedStmt、设置 JIT,并清理规划临时资源
最重要的概念关系是:
Query
描述查询要什么
RelOptInfo
描述正在优化哪个逻辑结果
Path
描述有哪些物理方法可以得到该结果
best_path
表示成本模型最终选中的方法
Plan
表示执行器可以初始化的静态计划
PlannedStmt
表示整条语句完整、可执行、可缓存的规划结果
未经作者同意请勿转载
本文来自博客园作者:aixueforever,原文链接:https://www.cnblogs.com/aslanvon/p/22004320

浙公网安备 33010602011771号