本文翻译自:postgres/src/backend/executor/README
PostgreSQL 执行器
执行器处理由"计划节点"构成的树形结构。计划树本质上是一个基于拉取的流水线式元组处理操作序列(火山模型)。每个节点在被调用时,会生成其输出序列中的下一个元组,若无可供元组则返回NULL。若节点非基础关系扫描节点,则其会调用子节点来获取输入元组。
基础模型上的优化改进包括:
- 扫描方向选择(前向/后向)。注意:当前支持尚不完善。基础扫描节点可用,但连接、聚合等操作支持有限。
- 重扫描指令可重置节点,使其重新生成输出序列。
- 可修改节点结果的参数。调整参数后,需对该节点及其上层节点应用重扫描指令。存在优化机制避免不必要的重扫描(例如,若输入参数未变化,Sort节点不会重扫描输入,而是直接读取已存储的排序数据)。
对于SELECT查询,仅需将顶层结果元组传递给客户端。对于INSERT/UPDATE/DELETE,实际的表修改操作发生在顶层的ModifyTable计划节点中。若查询包含RETURNING子句,ModifyTable节点将计算得到的RETURNING行作为输出,否则不返回任何结果。INSERT处理较为直接:来自下层计划树的元组被插入到目标表中。对于UPDATE,计划树返回更新列的新值,以及标识目标表行的"junk"(隐藏)列。ModifyTable节点需获取该行以提取未修改列的值,组合成新行后执行更新(对于堆表,行标识junk列是CTID,其他表类型可能使用不同标识)。对于DELETE,计划树只需提供行标识junk列,ModifyTable节点遍历这些行并标记删除。
计划树与状态树
规划器生成的计划树包含Plan节点结构体构成的树形结构(派生自struct Plan)。执行器初始化时会构建结构相同的与之对应的状态树——通常每种计划节点类型都有对应的执行器状态节点类型。状态树中的每个节点包含指向计划树对应节点的指针,以及执行所需的运行时数据。这种设计使计划树对执行器保持完全只读,所有执行期间的修改都发生在状态树中。只读特性极大简化了计划缓存和重用。
执行器初始化时可能跳过某些子计划的状态节点创建(当执行时分区剪枝判定该子计划无匹配记录时)。当前仅Append和MergeAppend节点支持此优化。此时被跳过的子计划将被忽略,导致状态节点的子节点数组与计划树子计划列表顺序不一致。
每个Plan节点可能关联多个表达式树,用于表示目标列表、过滤条件等。这些树对执行器同样只读,但表达式评估的状态管理不采用树形镜像结构(详见下文)。每个表达式树对应一个ExprState节点,对于复杂表达式类型可能包含子节点。
总体存在四类节点:Plan节点及其对应的PlanState节点,Expr节点及对应的ExprState节点。(实际还存在作为"粘合剂"的List节点,用于三种树形结构的连接)
表达式树与ExprState节点
与计划树不同,表达式树不采用状态节点树形镜像。每个可独立执行的表达式树(如Plan的过滤条件或目标列表)由单个ExprState节点表示。该节点以紧凑的线性形式存储表达式评估信息(通过ExprState->steps[]数组,元素类型为ExprEvalStep而非指针)。
采用此表示法的原因包括:
- 通常单个Expr类型节点的计算量较小,递归树遍历会产生显著开销
- 线性结构可在单函数内非递归执行,减少栈深度和函数调用开销
- 该表示法既适用于快速解释执行,也可编译为原生代码
表达式树的Plan形式通过ExecInitExpr()编译为ExprState节点。应尽可能在ExecInitExpr()(及其辅助函数)中处理复杂性,而非在执行时处理(解释执行和编译执行均需处理)。除避免重复工作外,运行时初始化检查在每次表达式评估时也会产生可观测成本。因此允许ExecInitExpr()预计算在单次查询执行中不变的信息(如应用于域类型的CHECK约束表达式集合)。若在计划阶段处理此类信息,将大幅增加计划失效触发条件。(早期版本在每次表达式评估时重新检查此类信息,但存在不必要的开销)
表达式初始化
在ExecInitExpr()等例程中,Expr树被转换为线性表示。每个Expr节点可能对应零个、一个或多个ExprEvalStep。
每个ExprEvalStep的操作由其opcode(ExprEvalOp枚举)决定,结果存储到ExprEvalStep->resvalue/resnull指向的Datum变量和布尔空值标志。复杂表达式通过串联多个步骤实现。例如"a + b"(包含两个Var表达式的OpExpr)将表示为两个Var取值步骤和一个加法运算符函数调用步骤。Var步骤的resvalue/resnull直接指向函数调用步骤的fcinfo->args[].value/.isnull元素,避免结果值复制开销。
ExprState->steps数组的末元素总是EEOP_DONE步骤,省去遍历时的数组越界检查。若表达式包含变量引用(指向ExprContext的INNER、OUTER或SCAN元组的用户列),数组开头会包含EEOP_*_FETCHSOME步骤,确保相关元组已被解构以供直接访问(参考slot_getsomeattrs())。这使得单个Var获取步骤基本等同于数组查找。
ExecInitExpr()的主要工作由递归函数ExecInitExprRec()及其子程序完成。该函数将Expr节点映射为执行步骤,递归处理子表达式。
每次ExecInitExprRec()调用需指定子表达式结果的存储位置(通过resv/resnull参数)。这允许将子表达式结果直接存入fcinfo->args[].value/isnull,但需注意:除非后续步骤不再使用目标存储位置,否则不同ExecInitExprRec()调用不得共享目标Datum/isnull变量。由于ExprEvalStep的非递归特性,通常容易保证。
ExecInitExprRec()使用ExprEvalPushStep()向ExprState->steps数组添加新操作。为保持数组连续存储,空间不足时需重新分配整个数组。因此表达式初始化期间禁止直接引用步骤数组元素。子表达式的resv/resnull通常指向独立于步骤数组分配的内存(如函数调用步骤的FunctionCallInfoBaseData单独分配)。完整表达式的最终结果通常存入ExprState节点的resvalue/resnull字段。
某些步骤(如布尔表达式)允许跳过子表达式评估。在线性表示中体现为跳转至后续步骤而非顺序执行。跳转目标通过步骤数组索引指定(参考execExprInterp.c中的EEO_NEXT和EEO_JUMP宏)。
通常,ExecInitExprRec()先压入跳转步骤,递归生成可能跳过的子表达式步骤,最后根据子表达式步骤长度修正跳转目标。这通过execExpr.c中的adjust_jumps列表处理。
构造ExprState的最后步骤是应用ExecReadyExpr(),根据选定执行方法完成准备。
表达式评估
为支持不同评估方法及优化分支/跳转预测,表达式评估通过调用ExprState->evalfunc(经由ExecEvalExpr()等)实现。
ExecReadyExpr()通过设置evalfunc选择解释方法。默认执行函数ExecInterpExpr实现在execExprInterp.c(参见头注释)。特定简单表达式使用特例evalfunc。
注意许多复杂表达式评估步骤(性能要求低于简单操作)实现为独立函数,供解释执行和编译执行共享。这些辅助函数不得自行调度表达式步骤,因调度方式随调用者而异。因此辅助函数所需子表达式结果必须由前期步骤计算,返回后需执行后续步骤调度。
目标列表评估
ExecBuildProjectionInfo构建的ExprState实现将目标列表评估结果存入ExprState->resultslot。通用目标列表表达式通过评估(结果存入ExprState的resvalue/resnull字段)后,使用EEOP_ASSIGN_TMP步骤将结果移入结果插槽的tts_values[]和tts_isnull[]数组。特殊快速路径步骤类型(EEOP_ASSIGN_*_VAR)处理简单Var目标列表项,只需单一步骤而非两步。
内存管理
CreateExecutorState()创建"per query"内存上下文,执行期间所有存储分配均在此上下文或其子上下文中进行。这简化了执行器关闭时的存储回收——无需逐个pfree,直接销毁内存上下文即可。
特别地,前文所述的计划状态树和表达式状态树均分配在per-query上下文中。
为避免查询内存泄漏,查询运行期间的大部分处理在"per tuple"上下文中进行(通常每个元组处理完成后重置)。此类上下文通常关联ExprContext,每个PlanState节点通常拥有独立ExprContext用于评估条件和目标列表表达式。
查询处理控制流
完整查询处理的简要控制流:
CreateQueryDesc
ExecutorStart
CreateExecutorState
创建per-query上下文
切换至per-query上下文运行ExecInitNode
AfterTriggerBeginQuery
ExecInitNode --- 递归扫描计划树
ExecInitNode
递归处理子节点
CreateExprContext
创建per-tuple上下文
ExecInitExpr
ExecutorRun
ExecProcNode --- 在per-query上下文中递归调用
ExecEvalExpr --- 在per-tuple上下文中调用
ResetExprContext --- 释放内存
ExecutorFinish
ExecPostprocessPlan --- 执行未完成的ModifyTable节点
AfterTriggerEndQuery
ExecutorEnd
ExecEndNode --- 递归释放资源
FreeExecutorState
释放per-query上下文及其子上下文
FreeQueryDesc
如前所述,ExecEndNode释放内存并非必需(所有内存最终由FreeExecutorState释放),但需确保关闭关系、释放缓冲区pin等资源,因此仍需扫描计划状态树处理此类资源。
执行器也可用于评估无计划树的简单表达式("简单"指无聚合和子选择,但函数内可能隐含)。控制流如下:
CreateExecutorState
创建per-query上下文
CreateExprContext -- 或使用GetPerTupleExprContext(estate)
创建per-tuple上下文
ExecPrepareExpr
临时切换至per-query上下文
通过expression_planner处理表达式
ExecInitExpr
循环执行:
ExecEvalExprSwitchContext
ExecEvalExpr --- 在per-tuple上下文中调用
ResetExprContext --- 释放内存
FreeExecutorState
释放per-query上下文及ExprContext
(无需单独调用FreeExprContext)
EvalPlanQual(READ COMMITTED更新检查)
对于简单SELECT,执行器只需关注当前事务快照有效的元组(即由已提交事务插入且未被已提交事务删除)。但UPDATE/DELETE不得修改或删除被未提交或并行提交事务修改过的元组。在SERIALIZABLE隔离级别下,此情况直接报错。在READ COMMITTED级别下需复杂处理。
READ COMMITTED模式的核心思想是:获取并行事务提交后的修改元组(必要时等待其提交),重新评估查询条件。若满足条件,则基于修改后的元组重新生成更新元组(UPDATE时),最终更新/删除该元组。SELECT FOR UPDATE/SHARE类似,但动作为锁定修改后的元组并返回基于该版本的结果。
为实现此检查,实际会为每个修改元组(或SELECT FOR UPDATE的元组集合)重新执行查询,调整关系扫描节点仅返回当前元组——原始版本或修改后(已锁定)版本。若查询返回元组,则修改后的元组通过条件检查(UPDATE时返回修改后的元组);若无返回,则忽略当前结果元组继续原始查询。
在UPDATE/DELETE中,仅需处理目标关系。SELECT FOR UPDATE可能涉及多个FOR UPDATE关系,需在执行重检查前获取每个关系中当前元组版本的锁。
查询中可能存在非锁定关系(非UPDATE/DELETE目标也非FOR UPDATE指定)。重执行测试查询时需使用与锁定行连接的相同行。普通关系可通过在连接输出中包含行TID并重新获取实现(重获取成本高,但优化了通常无需重测的情况)。还需考虑ValuesScan或FunctionScan等非表关系。因无TID等价物,唯一可行方案是在连接输出行中包含完整行值。
禁止在SELECT FOR UPDATE的目标列表中使用集合返回函数,确保每组扫描元组至多返回一个元组,避免原始查询多次返回相同扫描元组导致的重复。UPDATE目标列表同样禁用SRF,否则将导致同一行被多次更新(后续更新无效)。
异步执行
当节点需等待数据库系统外部事件时(如ForeignScan等待网络I/O),节点应指示当前无法返回元组但后续可能返回。虽然可通过阻塞处理,但可能浪费可执行其他计划部分的时间。这在包含Append节点的计划树中尤为明显。异步执行通过并发运行Append节点的多个子节点提升性能。
异步执行时,Append节点首先通过ExecAsyncRequest向支持异步的子节点请求元组,然后通过ExecAppendAsyncEventWait执行异步事件循环。最终当子节点产生元组时,Append节点通过ExecAsyncResponse从事件循环接收。当前实现中,仅Append节点会向异步子节点请求元组,仅ForeignScan可能支持异步。
通常,需要异步请求元组的节点只需实现ExecAsyncResponse回调。异步节点通常需实现三个方法:
-
异步请求发起时,触发节点的ExecAsyncRequest回调。应使用ExecAsyncRequestPending表示请求待处理,或使用ExecAsyncRequestDone立即返回结果。
-
当事件循环需等待/轮询文件描述符事件时,触发节点的ExecAsyncConfigureWait回调配置等待事件。
-
文件描述符就绪时,触发节点的ExecAsyncNotify回调。类似步骤1,使用ExecAsyncRequestPending等待或ExecAsyncRequestDone立即返回。




