SQL语句的生命周期¶
前面的内存架构,物理架构,进程架构偏向静态系统架构,有哪些部件,长什么样子,谁负责干什么。
现在可以通过描述一条SQL语句的生命周期将上面的各部分串起来,是一个偏动态的查询架构。一个SQL语句可以分为以下五部分:Parser → Analyzer → Rewriter → Planner → Executor。
从客户端报文到 exec_simple_query¶
这是语句生命的起始,客户端发送一条 Simple Query(文本协议)时,大致流程是:
// postgres.c
PostgresMain(...)
{
for (;;)
{
// 读取客户端发来的消息,区分类型(Q: simple query / P: parse / B: bind ...)
switch (firstchar)
{
case 'Q': // Simple Query
exec_simple_query(query_string);
break;
...
}
}
}
exec_simple_query()位于pquery.c。
除了启动包和 SSL 请求以外,所有协议消息结构如下:
PostgresMain() 中读第一字节 firstchar,然后 switch (firstchar) 分发:
switch (firstchar)
{
case 'Q': /* simple query */ exec_simple_query(...); break;
case 'P': /* parse */ exec_parse_message(...); break;
case 'B': /* bind */ exec_bind_message(...); break;
case 'E': /* execute */ exec_execute_message(...);break;
case 'D': /* describe */ exec_describe_message(...);break;
case 'S': /* sync */ exec_sync_message(...); break;
case 'H': /* flush */ ... break;
case 'C': /* close */ ... break;
case 'F': /* fastpath func */ exec_fastpath_message(...);break;
case 'X': /* terminate */ ... 退出 break;
...
}
其中 Q/P/B是三个最核心的协议。
Q:Simple Query¶
简单查询协议是最常见的一类查询协议,我们常见的查询就是使用这类协议。报文头部的消息类型是Q。一次 Q 可以包含多条 SQL,用 ; 分隔(所有语句共享一个隐式事务块,除非你显式 BEGIN/COMMIT)。文本模式,参数得拼在 SQL 字符串里。后端一次性 parse/analyze/plan/execute,不能复用 parse/plan 结果。具体的消息格式如下:
Byte1 'Q'
Int32 长度(包含长度字段自身)
String query_string,以 '\0' 结尾
P:Parse¶
Parse message扩展查询协议把 SQL 文本“预解析”为 Prepared Statement。Parser会把SQL文本和预期参数类型注册为一个prepared statement 对象,但不一定马上执行。消息格式如下:
Byte1 'P'
Int32 长度
String statement_name // 预编译语句名,空字符串表示 unnamed
String query_string // 只能包含一条 SQL(多条会报错)
Int16 numParamTypes // 后面参数类型 OID 的个数
Int32[n] paramTypeOids // 每个参数的类型 OID,0 表示未指定
exec_parse_message()的处理流程是,读取 statement_name / query_string / 参数类型 OID 列表,调用 pg_parse_query() 做 语法解析,生成 RawStmt 列表,对每个 RawStmt 调 parse_analyze() 做 语义分析,生成 Query,建立一个 PreparedStatement / CachedPlanSource,保存 SQL 文本/ Query 树/参数类型信息,规划一般延迟到 Bind 阶段(真正要执行时)。成功时,后端返回 ParseComplete;语法/语义出错则返回 ErrorResponse,当前事务出错状态等。
驱动(JDBC、libpq)一般会用 P 做预编译,好处是少一次解析/分析,可以重用计划而且支持参数化。另一面,该协议只能是一条 SQL,不能像 Q 那样一串多条。否则语法错误。而且该协议的生命周期是会话级的,直到连接断开或显式 Close,这点要特别注意,在pgbouncer连接池的配置上要有所体现。
B:Bind¶
Bind message 扩展查询协议把刚才 Parse 出来的 prepared statement,加上这次的参数值,绑定成一个 portal,并决定结果集的格式(text/binary)。其格式如下:
Byte1 'B'
Int32 长度
String portal_name // 目标 portal 名,空字符串 = unnamed portal
String statement_name // 源 prepared statement 名,空字符串 = unnamed stmt
Int16 nParamFormatCodes // 参数格式码个数 C
Int16[C] paramFormatCodes // 每个参数是 text(0) 还是 binary(1)
Int16 nParams // 参数个数 N
for each param:
Int32 paramLen // -1 = NULL
ByteN paramValue // 参数值,text 或 binary
Int16 nResultFormatCodes // 结果列格式码个数 R
Int16[R] resultFormatCodes // 每列 text/binary,或 1 个值表示所有列
exec_bind_message()的流程如下:
-
找到
statement_name对应的 prepared statement; -
检查参数个数、类型是否匹配;
-
根据参数格式码解析参数数据,构造
ParamListInfo; -
通常在这里进行规划(planning),若该 prepared statement 之前没规划过,
pg_plan_queries()生成计划,可能缓存为 generic plan,或者结合本次参数生成 custom plan,再决定是否缓存; -
创建一个
Portal对象,满足 “这个 prepared statement + 这组参数 + 这个结果格式” 的组合。记录顶层Plan、QueryDesc等。
E:Execute¶
Execute message和 P/B 搭配不可分,其消息格式如下:
Byte1 'E'
Int32 长度
String portal_name // 要执行的 portal 名,空 = unnamed
Int32 max_rows // 返回的最大行数,0 = 所有行
服务端exec_execute_message()的流程是,找前面Bind生成的 portal,调用 PortalStart()(第一次执行时),建立 EState、PlanState 树。调 PortalRun() 拉取结果,max_rows == 0时则一直跑到结束,max_rows > 0时则跑一段,可能返回 PortalSuspended,等待下一次 E 继续执行。函数返回类似 simple query:RowDescription / DataRow / CommandComplete / EmptyQueryResponse / ErrorResponse 等。
至此主要类型的的协议介绍完毕,下面是一些用于辅助控制和描述类的其他类型协议。
| 类型字节 | 消息名 | 用途分类 | 主要作用 | 生命周期/事务上的作用点 |
D |
Describe | 元信息描述 | 描述 prepared statement 或 portal 的参数/结果列信息(列名、类型等) | 不执行语句本身,只基于已存在的 stmt/portal 返回元信息 |
S |
Sync | 协议同步/事务 | 结束一批扩展查询消息,清理错误状态并返回 ReadyForQuery |
作为错误恢复的同步点,与事务自动提交/回滚结合 |
H |
Flush | 输出刷新 | 要求后端立即将缓冲区中的响应刷到网络(降低延迟) | 与生命周期无强耦合,只影响通信行为 |
C |
Close | 资源管理 | 关闭 named prepared statement 或 named portal,释放相关资源 | 影响后续能否再用该 stmt/portal 执行 |
F |
FunctionCall | fastpath 调用 | 通过 fastpath 接口直接调用后端函数(老机制,基本不推荐) | 可以绕过 SQL 层,直接触达执行层/函数接口 |
X |
Terminate | 连接结束 | 客户端通知后端结束会话,后端清理资源后退出当前 backend 进程 | 会话生命周期结束点 |
解析器 Paser¶
解析器的调用链入口位于pquery.c中的pg_parse_query。主要结构体有RawStmt,以及 SelectStmt、InsertStmt 等,这些结构体都位于parsenodes.h。调用链条如下:
pg_parse_query(query_string)
-> raw_parser(query_string)
-> yylex() // 词法分析,来自 scan.l
-> yyparse() // bison 生成的语法分析,来自 gram.y
-> 返回 List<RawStmt*>
RawStmt:外面包了一层,里面的 stmt 才是真正的语句节点(SelectStmt、InsertStmt…),还有 SQL 文本位置之类的元信息。
这个阶段会检查语法的正确性,包括关键字、括号、from/where等结构是否合法。而并不知道表、列、类型存不存在。这个阶段会输出一棵没有语义的语法树,例如:SELECT * FROM t WHERE a + 1 > 10;这个语句会被解析成解析成 SelectStmt,其中fromClause 里存的是一个 RangeVar(名字叫 t),targetList 里有一个特殊的 ResTarget 表示 *,whereClause 是一个表达式树(OpExpr 嵌套 A_Expr 等)。
这个树很接近 SQL 文本结构,但还无法执行,需要进入下一步分析。上面就是简要的解析器工作流程,为了更好的理解词法解析和语法解析,下面剖析这这两步。
词法分析¶
Lexer 的工作是将字符流切割成Token流,词法分析首先会做分词(Token),比如输入以下语句
SELECT a + 1 FROM t WHERE b > 10;
- 词法分析器会对这个sql字符串进行分词。
select是关键字Token,a是标识符Token,+是运算符Token,1则是常量Token。
之后,词法分析会进行规范化处理,注释、空白、转义、字符串字面量、参数引用、标识符大小写折叠等。
flex 词法规则核心实现在scan.l,一些关键字查表、标识符处理等辅助函数则在scansup.c
在 bison/flex 的经典组合里,yylex()每调用一次,返回一个整数枚举类型的 token 。同时会输出
yylval:token 的语义值(比如常量值、字符串内容、标识符文本)
在 PG 里,token 的语义值通常承载为 标识符、常量、运算符及参数
yylloc:token 在原 SQL 文本中的位置(用于报错定位)
scan.l 不是“单条正则从头匹配到尾”这么简单,它大量使用 exclusive start conditions(词法状态) 来处理 SQL 的复杂字面量语法。例如,单引号字符串 'it''s ok',必须识别 '' 表示单引号转义,还要支持 E'...' 的反斜杠转义语义。此外还有双引号标识符,双引号内不做大小写折叠,且允许关键字作为对象名。这些都需要 scanner 进入不同状态,才能正确消化掉内容并在结束时生成一个token。

| 状态 | 分组 | 进入触发 | 退出触发 | 产出/结果 |
INITIAL |
core | 默认 | 识别到注释/字符串/标识符起始符 | 输出关键字/IDENT/常量/运算符等 token |
xc |
comment | /* |
*/(嵌套深度归零)或 EOF |
回到 INITIAL;EOF 报未闭合注释 |
xd |
ident | " |
" 或 EOF |
IDENT;EOF 报错 |
xui |
ident | U&" |
" 或 EOF |
UIDENT;EOF 报错 |
xdolq |
string | \(tag\)/$$ |
匹配同分隔符或 EOF | SCONST;EOF 报错 |
xq |
string | '(标准语义) |
遇到候选结束引号跳 xqs |
由 xqs 决定是否结束 |
xe |
string | E'(或 legacy) |
候选结束引号跳 xqs;或 surrogate 进入 xeu |
由 xqs 结束为 SCONST |
xus |
string | U&' |
候选结束引号跳 xqs |
结束为 USCONST(实现里对应 token) |
xb |
string | B' |
候选结束引号跳 xqs |
结束为 BCONST |
xh |
string | X' |
候选结束引号跳 xqs |
结束为 XCONST |
xqs |
control | 从 xq/xe/xus/xb/xh 来 |
续写则回原状态;否则回 INITIAL | 统一做“续写判定”,并发出最终常量 token |
xeu |
string | xe 中 high-surrogate |
low-surrogate OK 回 xe;否则 ERROR |
用于 surrogate pair 合成 |
ERROR |
error | EOF 未闭合/非法 surrogate | — | 报错终止本次解析 |
PG的词法解析器识别到类似 [A-Za-z_][A-Za-z0-9_$]*的单词后,并不会直接返回IDENT,而是会将文本做规范化,比如折叠为小写,再按NAMEDATALEN截断等。kwlist.h生成关键字映射,词法解析器根据关键字类别返回保留关键字TOKEN(SELECT/FROM等),或 非保留关键字在语法里仍可能走 IDENT/unreserved_keyword 等路径。
对于运算和符号,词法分析器往往会区分单字符符号和多字符及自定义运算符。前者直接返回字符本身 token(比如 '+', ',', ';', '('),而后者返回通用 token(常见命名为 Op),并把具体文本放进 yylval,交给语法规则匹配 qual_Op / MathOp 等非终结符去处理。
语法分析¶
这一步才算真正的parser,前面的词法分析只知道这是一串token,语法分析要回答SELECT ... FROM ... WHERE ... 这种 token 序列,能不能组成一棵合法的 SQL 语法结构,如果合法,按 SQL 语法把它归约成树形结构(AST语法树),其中包含了SelectStmt,InsertStmt,JoinExpr,以及各节点的表达式A_Expr、ColumnRef、FuncCall等。
gramparse.h中包含了bison 语法实现,bison 生成的 base_yylex()是一个LR 语法分析器,其最终产出 raw parse tree(未语义分析),供 analyze.c 后续转换为 Query tree。
RawParseMode决定从哪个语法入口符号开始 parse,它明确了 raw_parser 接受的字符串形态 与 输出 List 里装什么节点:
-
RAW_PARSE_DEFAULT:解析分号分隔的 SQL 命令,返回List<RawStmt> -
RAW_PARSE_TYPE_NAME:解析类型名,返回List<TypeName>(单元素) -
RAW_PARSE_PLPGSQL_EXPR / ASSIGNn:给 PL/pgSQL 场景的特殊入口(单元素 List)
在 raw_parser() 内部,非 DEFAULT 模式会预置一个“lookahead token”(mode_token[])作为语法入口切换信号,写得非常清楚:数组按 RawParseMode 下标索引,设置 yyextra.have_lookahead/lookahead_token。
raw_parser()的职责是拿到 token 流后完成语法归约(shift/reduce)并产出 raw parsetree 列表(未 analyzed)。因为 bison 需要通过 yylex 取 token,raw_parser() 仍会创建 flex scanner。
base_yyparse是语法分析器的主体,负责拼接Token,做约规,拼接解析树,在执行机理上,base_yyparse() 就是典型 bison LALR(1) 流程:
-
维护 状态栈(states)+ 语义值栈(YYSTYPE)+ 位置栈(YYLTYPE)
-
每次通过
yylex(这里是base_yylex())取一个 token -
查 action 表:shift(压栈)或 reduce(按产生式归约)
-
在 reduce 时执行产生式对应的 C 代码块(semantic action),用
makeNode()/list_make*()等构造 AST 节点并返回给上层非终结符 -
最终从“语句列表”入口符号归约完成,形成
yyextra.parsetree
分析器 Analyze¶
分析器的工作是将Raw Parse Tree 转换为一棵Query Tree。analyze.c
这个阶段的核心工作原则如下:
-
可优化语句(optimizable statements):Analyze 阶段会对每个被引用表获取“合适的锁”,因此可以做“更重的语义分析”(因为语义依赖系统表/元数据,必须稳定)。
-
工具类语句(utility commands):一般不在这里做语义转换(否则执行阶段锁可能不再有效),而是直接包进一个
CMD_UTILITY的Query节点里。 -
但
DECLARE CURSOR / EXPLAIN / CREATE TABLE AS等是例外:它们内部包含可优化语句,所以仍需做 转换。
总结一下,就是分析器的设计哲学是能保证语义稳定的地方才做深语义绑定;否则就延迟到 utility 执行器自己处理。
注意:Analyze 阶段就可能产生 queryId,并且提供 post-parse-analyze hook 给插件,这也是很多安全/审计类插件切入点。parse_analyze_withcb()
在postgres.c中,分析器和重写器是一对绑定的组合,首先会调用 parse_analyze_fixedparams() 或 parse_analyze_varparams()进行执行解析分析,之后就是重写pg_rewrite_query()。Analyzer和Rewriter都有可能讲一条语句扩展为多条语句,所以返回的是List<Query>。
分析器的输入来自解析器的RawStmt,它由gram.y 输出,此时词法/语法已经完成。但是同时保留了大量语法形态的节点,比如还没绑定到具体表/列、运算符还是符号、常量可能还带 unknown 语义等。分析器的输出则是一个Query或是经由重写器产生的List<Query>,对于Planner而言,所有可解析的语义都被绑定成“OID + Var + 函数/运算符选择 + 类型/排序规则/权限依赖等内核可执行语义,Query 结构具备“可优化性。
Query内容¶
Query 是后续 optimizer / executor 理解的逻辑查询表示。其包含如下内容:
-
commandType:SELECT/INSERT/UPDATE/DELETE -
rtable:RangeTblEntry列表(所有表/子查询/函数等“范围”) -
jointree:FROM/JOIN 形成的逻辑连接树 -
targetList:TargetEntry列表,每个代表一个输出列 -
qual:WHERE 条件表达式 -
groupClause/havingQual/sortClause/limitOffset/limitCount等
除此,分析器还会进程名字解析 Name Resolution。比如FROM t1 a JOIN t2 b ON a.id = b.id WHERE a.col > 10;这句,Analyzer 会查系统 catalog 找表 t1/t2 是否存在、列有哪些。把 a.col 解析为特定 RangeTblEntry + 某个属性号(varno/varattno),对 * 展开成多个 TargetEntry(每个实际列)。
类型推断与强制类型转换Type Resolution & Coercion同样也是分析器的工作内容,比如WHERE id = '123'中的id是int类型,而'123'则是unknown。分析器会查找 pg_operator,找到 = 操作符对应的参数类型组合。决定把 '123' 转成 int4,在表达式树里插入 RelabelType 或 FuncExpr 做 cast。复杂表达式里根据上下文推断结果类型,比如 CASE、COALESCE 等。
Analyze 的过程分解¶
Analyze 的三个入口(fixed/var/withcb)都遵循同一套路:创建 ParseState,写入 p_sourcetext、p_queryEnv,然后进入 transformTopLevelStmt()。例如 parse_analyze_fixedparams() 的注释也明确:它把 raw parse tree 转成 Query;optimizable statements 做较多转换;utility statements 挂到 CMD_UTILITY Query 上。
Analyzer 并不是“全局无状态”的纯函数,它需要一个 ParseState 作为语义环境,包含命名空间、range table、当前解析层级(子查询/CTE)、参数类型解析策略等。
Planner 不能在参数类型未定的情况下稳定选择函数/运算符/索引策略,因此 Analyzer 必须确保:
- 要么外部给定参数类型(prepared statement 常见)
parse_analyze_fixedparams():调用方提供每个 $n 的类型,引用未定义参数会被禁止。
- 要么 Analyzer 能在语义上下文中推导,并最终确定下来
parse_analyze_varparams():允许从上下文推断 $n 的类型,并可能扩展 paramTypes[]。
而在 postgres.c 的 pg_analyze_and_rewrite_varparams() 里还有一个关键“收口动作”:Analyze 完成后会检查每个参数类型都必须被确定,否则直接报错could not determine data type of parameter $n。
Analyzer 允许做语法层面的规范化,但会非常克制。因为语义规则只允许顶层出现,所以这类转换通常只在顶层做。做完后把结构整理成后续 transform 过程更统一的形态,从而减少分支复杂度。非常典型的例子是,transformTopLevelStmt()处理 SELECT ... INTO 时,会将其转换为CreateTableAsStmt,同时会清空原 SelectStmt 的 intoClause,避免后续在不允许的位置出现 INTO 时无法报错。
Analyzer 的产物要给 Planner/Executor 用,但很多 utility 命令不走 Planner(或在 utility 执行时才解析内部对象),所以 Analyzer 采用只对需要优化/执行语义绑定的语句做重分析的策略。也就是说Analyzer 不是对所有语句都深度 transform。
stmt_requires_parse_analysis() 把语句分成三类:
-
Optimizable statements:
SELECT/INSERT/UPDATE/DELETE/MERGE/...等,返回 true -
Special cases:
DECLARE CURSOR/EXPLAIN/CREATE TABLE AS/CALL等也返回 true(因为内部有可优化语句) -
其它语句:一般不深度分析,最终会包进
CMD_UTILITYQuery
子查询分析必须满足两条,作用域隔离及语义一致。前者的意思是子查询能看到外层哪些名字、哪些锁/依赖可以继承,需要明确控制,后者则是 unknown 类型常量是否要在子层被强行 resolve,必须是可配置策略,避免破坏 SQL 语义。例如
在这个查询中,'1000' 是一个字符串字面量,它的类型是 unknown,因为它没有显式指定类型。如果 PostgreSQL 使用 配置策略,它可能会选择将 '1000' 强制转换为 int4,这样就可以和 salary 列(假设是 int4 类型)进行比较。
以 SELECT/DML 这类可优化 Query 为例,在 Analyze 的末段会将 ParseState 中统计出来的语义特征写回 Query(如 hasSubLinks/hasWindowFuncs/hasAggs 等),然后 assign_query_collations()。若包含聚合,再 parseCheckAggregates(),并且源码明确说明:聚合检查必须在 collation 之后做,才能可靠比较表达式
Planner 很依赖这些语义标志和表达式正规化结果。有没有子链接、窗口函数、目标 SRF、聚合等,会直接影响规划空间与执行器节点选择。collation(排序规则)影响等价类、排序/哈希策略、索引可用性。聚合一致性检查属于语义合法性关口,必须在表达式结构稳定后执行。
Analyzer 是语义已经绑定但尚未 rewrite/plan的关键阶段点。对审计/安全/统计插件而言,这是最佳切入位置之一(结构完整、语义稳定、仍可记录原始位置与上下文)。
最后总结一下Analyzer语义绑定的内容
| 语义问题 | Raw parse tree 里是什么样 | Analyzer 后 Query 必须保证什么 | 为什么必须在 Analyze 做 |
| 语句分类 | 各类语句混杂 | 需优化的语句生成“可优化 Query”;其它包为 CMD_UTILITY |
避免对 utility 做无效/不可靠的深分析 |
| 命名与作用域 | 表名/别名/列名是字符串形态 | 解析为 range table / namespace 结构,列引用最终落到稳定的 Var 语义 | Planner 不可能优化“未绑定”的名字 |
参数类型 $n |
$1 只是符号 |
fixed:必须匹配给定类型;var:必须被推断并最终确定 | 类型影响函数/运算符选择;未定类型直接拒绝 |
| 顶层语法糖 | SELECT ... INTO 等历史形态 |
规范化为统一结构(如 CTAS),并保证非法位置能报错 | 降低后续 transform 分支复杂度 |
| 表达式合法性 | 运算符/函数可能还是“符号” | 选择具体函数/运算符实现,表达式树类型化、可比较 | Planner 需要已解析的 OID 与类型信息 |
| collation / 聚合一致性 | 可能不完整 | assign_query_collations() 后,必要时 parseCheckAggregates() |
语义正确性关口;影响排序/哈希/索引等 |
| 多 Query 展开 | 还未展开 | Analyzer/rewriter 可能产生多条 Query | 执行与优化以 Query 列表为单位 |
重写器 Rewriter¶
Rewriter位于Analyzer之后,Planner之前。Analyze会产出query tree,还需要规则系统将这棵树进行重写,形成真正要被优化/执行的一组查询。规则可以让一条语句变成多条语句,或者直接变空。
重写器的工作流程¶
在 QueryRewrite()函数的注释里,非常明确的将重写器的工作分为了以下几步:
-
必要时先进行锁定和修补。
通常从Parser/Analyze出来的
query tree通常已经拿过该拿的锁。但是来自缓存或历史的query tree就必须补这一步。AcquireRewriteLocks()的职责不是改写逻辑,而是为改写/规划/执行提供 schema 稳定性。把涉及到的 relation 锁住,避免你在重写过程中表结构被并发 DDL 改掉;同时它还会做一些历史规则树里与 JOIN + dropped column 相关的修补(避免规则里保留了已删除列导致后续处理代价巨大)。 -
处理
non-select规则这一步会把 INSERT/UPDATE/DELETE/MERGE 等写类语句按规则系统做改写。
DO INSTEAD:用规则动作替换原语句;对视图插入实际是写入
base table-- 创建表和视图及插入规则 CREATE TABLE t ( id int primary key, val text ); CREATE VIEW v AS SELECT id, val FROM t; CREATE RULE v_ins AS ON INSERT TO v DO INSTEAD INSERT INTO t(id, val) VALUES (NEW.id, NEW.val); -- 当执行 INSERT INTO v(id, val) VALUES (1, 'a'); -- 重写器会将语句改写为 INSERT INTO t(id, val) VALUES (1, 'a');DO ALSO:在原语句基础上追加动作(因此可能产生多条 Query);比如更新业务表的同时写审计表
-- 原表 CREATE TABLE acct ( id int primary key, balance int ); -- 审计表 CREATE TABLE acct_audit ( id int, old_bal int, new_bal int, ts timestamptz default now() ); -- 规则 CREATE RULE acct_upd_audit AS ON UPDATE TO acct DO ALSO INSERT INTO acct_audit(id, old_bal, new_bal) VALUES (OLD.id, OLD.balance, NEW.balance); -- 当执行以下语句时 UPDATE acct SET balance = balance + 100 WHERE id = 1; -- 重写器等效 -- 1. 追加的审计 INSERT INSERT INTO acct_audit(id, old_bal, new_bal) VALUES (OLD.id, OLD.balance, NEW.balance); -- 2. 原始 UPDATE 仍然执行 UPDATE acct SET balance = balance + 100 WHERE id = 1; -- 所以最少产生了两条queryDO INSTEAD NOTHING:抑制原语句(可能导致最终 0 条 Query);无条件 NOTHING:整条语句被吃掉
CREATE TABLE t2(id int); CREATE RULE t2_no_delete AS ON DELETE TO t2 DO INSTEAD NOTHING; DELETE FROM t2; -- 没有任何可执行查询带条件 NOTHING:只拦截一部分行,就是分流
-
处理视图展开及安全条件注入
RIR(Retrieve-Instead-Retrieve)可以把它理解成,视图就是一条 SELECT 规则。因此在 这一步 会对每个 Query 做 视图展开(把对 view 的引用替换成 view 定义的子查询/连接树)。
QueryRewrite()在 Step 2 对每条 query 调用fireRIRrules()的动作在源码里是显式写出来的。同时,这一步也是RLS(Row Level Security)策略被注入
securityQuals/withCheckOptions的关键阶段。如果这些策略表达式里包含子查询(SubLinks),还需要额外处理锁与递归展开。 -
当规则把 1 条语句改写成多条语句时,PG 仍需要决定哪个 Query 可以设置
canSetTag(影响客户端看到的 command tag / 影响行数显示等)。QueryRewrite()在 Step 3 专门做了这件事。
| 改写点 | 触发条件 | 重写器做什么 | 可能输出多少条 Query | 常见用途/风险 |
| 事件规则(non-SELECT rules) | CREATE RULE ... ON INSERT/UPDATE/DELETE/MERGE |
生成 INSTEAD / ALSO / NOTHING 等动作,可能替换或追加原语句 | 0 / 1 / N | 实现复杂“规则型存储过程”;风险是语义隐蔽、容易造成意外多语句副作用 (Doxygen) |
| Qualified INSTEAD 分流 | rule 带 WHERE(qualified) | action 处理命中行;同时生成“带否定条件”的 modified original query 处理剩余行 | 0 / 1 / N | 条件组合复杂时可读性/可维护性差,容易触发递归与意外结果 (Doxygen) |
| 视图展开(RIR) | 查询引用 view | 把 view 替换为其定义(递归展开) | 通常 1(但内部结构会变) | 这是 Planner 真正看到的查询形态;性能分析要以展开后的树为准 (Doxygen) |
| 自动可更新视图改写 | 对简单 view 做 DML 且无 INSTEAD 规则/INSTEAD OF trigger | 尝试把 DML 改写到底层基表,否则报错 | 1 | 规则/触发器会覆盖默认自动改写路径 |
| RLS 策略注入 | 表启用 RLS 且存在 policy | 注入 securityQuals(可见性)与 withCheckOptions(写入检查);策略 OR/AND 组合 |
1(但条件树增强) | 策略里若含子查询/自引用,可能触发递归检测报错 (Doxygen) |
| AcquireRewriteLocks(锁与修补) | query tree 不是“刚解析出来”的,或需要 schema 稳定性 | 获取合适锁;修补 JOIN RTE dropped columns 等 | 不改变条数,但可能改树 | 避免重写/规划/执行期间 schema 漂移;避免历史规则树的性能陷阱 (Doxygen) |

需要注意的是,重写器和触发器的区别,两者看似有相似的功能,但是本质不同。
- 规则(RULE)属于查询重写(Rewriter):在规划器之前,把你的语句改写/展开为另一棵查询树(甚至多条查询),然后再去计划与执行。
RULE:宏展开/改写 SQL(执行前改要执行什么)
- 触发器(TRIGGER)属于执行器(Executor):语句开始执行后,在
BEFORE/AFTER/INSTEAD OF的时点被调用,可按“每行”或“每语句”触发
TRIGGER:运行时回调(执行中/执行后做“额外动作或校验”)
规划器 Planner/Optimizer¶
Rewriter 之后的 Query tree具备语义已确定、规则已展开、视图/规则已改写的特征。通过planner最终输出为可执行的 Plan tree,一棵封装于PlannedStmt数据结构中的规划树。Executor 只需要按节点类型逐层执行即可。
Planner的本质并不是字面上的生成一个计划,而是枚举候选执行路径,而后基于统计信息与代价模型(Cost Model)给每条路径定价,在这些路径中选择全局最优或近似最优的路径组合,最后把选中的 Path 具体化成 Plan 节点树。
Planner的核心模型¶
Planner的核心数据模型有如下三个
| 对象 | 代表什么 | 你可以把它理解成 |
PlannerInfo |
一条 SQL(或子查询)在规划期的“全局上下文” | 规划期的“上下文对象/黑板”,装着范围表、约束、参数化信息、路径集合入口等 |
RelOptInfo |
一个“关系”的优化信息(基础表、子查询结果、join 结果都算) | 以“关系”为粒度的候选路径容器:里面挂着 pathlist(各种走法) |
Path |
一种“怎么得到某个 Rel 的结果”的候选方案 | 例如 SeqScanPath、IndexPath、HashJoinPath、MergeJoinPath…每个 Path 都带估算行数与 cost (Doxygen) |
规划的核心动作,就是不断构造 RelOptInfo,往里面塞入不同的 Path(候选走法),再做比较选择。pathnode.c 里能看到 Path 成本比较的思想:比较的是 startup/total cost 等指标,而不是看起来哪个更快。
Planner的主流程¶
1. 把 Query 变成更适合优化的形态¶
在 planner.c 里能看到大量 preprocess_*:
-
preprocess_expression():对表达式做预处理,为后续等价推导、常量折叠、规范化做铺垫。 -
preprocess_qual_conditions():处理 WHERE/JOIN 条件,使其更利于选择性估算和路径生成。 -
preprocess_limit():把 LIMIT/OFFSET 这种“只要前 N 行”的信息提早注入优化过程,会显著影响 join 策略、排序策略。 -
grouping_planner():负责聚合、排序、窗口函数、distinct、limit 等“上层关系”的规划。
这一阶段的目的是让查询树更规整、更可推导,把会影响全局策略的信息(如 LIMIT)提前暴露给代价模型。
2. 扫描路径生成¶
对每个 base relation(单表),Planner 会生成一组访问路径(pathlist):
-
Seq Scan(全表扫)
-
Index Scan / Index Only Scan(索引扫)
-
Bitmap Index + Bitmap Heap(位图路径)
-
Parallel 相关路径(如果可并行)
在 allpaths.c 的 set_rel_pathlist()/相关链路里,你可以看到它会把 SeqScan 和 Index paths 都先塞进候选集中(例如 create_seqscan_path()、create_index_paths()),后面再让 cost 模型去淘汰。
这一阶段的目的是构造一个全集性的可行性路径集合。
3. join搜索¶
当 FROM 里有多表,Planner 会经历一个关键阶段:枚举 join 关系。先把单表 RelOptInfo 都建好,然后尝试把它们两两 join 成更大的 RelOptInfo,再把更大的 Rel 与剩余 Rel 继续 join,直到形成最终的all-join Rel。
make_one_rel() 是典型的“把 join 问题收敛到一个最终 RelOptInfo的关键入口之一。
而 join 顺序搜索可能采用:
-
动态规划式的标准 join search
-
或达到阈值后启用 GEQO(遗传算法) 做近似搜索,避免组合爆炸。
join 顺序决定了中间结果规模,而中间结果规模又决定了整体代价,所以必须作为全局优化问题处理。
4. 上层关系规划¶
这一步很多人容易误解为执行器的事,但其实它强烈影响 join 策略和 scan 策略。
grouping_planner()在这一层做以下工作:
-
聚合(HashAgg/SortAgg)
-
DISTINCT(通常转成 Unique/HashAggregate 等变体)
-
ORDER BY(Sort/Incremental Sort)
-
WINDOW(WindowAgg)
-
LIMIT(Limit)
组合成上层 Rel 的候选路径,并把“上层需求”(比如必须有序)用 PathKeys 等形式传递下去(从而反向影响下层是否选择能天然输出有序结果的 IndexPath)。
5. 执行方案落地¶
当 cheapest Path 确定后,Planner 进入“具体化”阶段,把抽象的 Path 递归转换成具体 Plan 节点(SeqScan、IndexScan、NestLoop、HashJoin、Sort…)。
在 createplan.c 你能看到大量 create_*_plan() 用于把不同 Path 落成对应 Plan 节点。例如 create_seqscan_plan(),其中会对 qual 做排序、抽取实际表达式、替换 nestloop 参数等。
优化阶段只需要比较走法,真正执行需要具体节点与可执行表达式结构。
Planner的影响因素¶
Planner的选择不是靠拍脑袋,主要依赖两方面因素:
-
统计信息
统计信息来自
ANALYZE收集的统计(以及扩展统计),影响 selectivity、基数估算、join 结果行数估算。 -
代价参数
如
random_page_cost、seq_page_cost、cpu_tuple_cost、work_mem、并行相关参数等,用于把 I/O 与 CPU 折算成统一成本标尺。成本计算相关实现在costsize.c等模块中。
对应上面两个因素,我们在explain的时候,结果的偏差往往也可以归结于这两方面:
-
估算行数不准,统计问题、谓词相关性、数据倾斜、参数化谓词导致 generic plan 偏差。
-
成本参数与真实硬件/负载不匹配。尤其
random_page_cost、并行收益、work_mem 对 hash/sort 的拐点。
这里不展开讨论调优问题。往往调优是系统性的平衡问题,在调优之上则是安全,这会在其他章节详细讨论。
执行器 Executor¶
执行器是整个流程的最后一步,Planner解决的是怎么做便宜,Executor解决的是怎么把它做出来,就是把 Plan tree 实例化成 PlanState tree,静态的规划树的每个节点都会变为一个运行态对象PlanState。其中包含了子节点指针(left/right),输出 slot(TupleTableSlot),节点内部状态(例如 HashJoin 的 hash table、Sort 的 tuplestore),编译好的表达式执行状态(ExprState)。
之后会按节点类型分派执行逻辑,不同节点类型有不同的执行函数,ExecProcNode(node)里根据 nodeTag 分派到 ExecSeqScan / ExecHashJoin / ExecSort / ExecAgg 。
以迭代器的火山模型驱动整个树,顶层不断向下要一行,下层算出一行再返回,直到返回 NULL 表示结束。
Plan tree 是程序的 AST,Executor 是解释执行 AST 的虚拟机。
运行时系统¶
除了上面的plan tree转换工作,执行器还有一个重要的职能就是运行时系统。具体而言,就是数据库执行需要大量运行期协作,Executor 需要承担更多的任务:
- MVCC 可见性与快照
快照Snapshot决定select 读到哪个版本的行,行是否可见。Executor 在扫描阶段会对拿到的 tuple 做可见性判断,不符合当前快照的版本会被直接跳过。
- 锁与并发一致性
UPDATE/DELETE 必须锁住目标行(行锁),并处理并发冲突。READ COMMITTED 下还可能需要 EPQ(EvalPlanQual)重检:发现行在你扫描后被别人改了,需要重新拉取并用原条件再判断一次,以保证语义一致。
- 表达式求值与 per-tuple 内存管理
WHERE 条件、JOIN 条件、SELECT 列表达式、函数调用、类型转换,全部在 Executor 里执行。为了性能与防泄漏,Executor 使用每行重置的内存上下文(per-tuple context),一行计算完就 reset,一次性释放这一行求值产生的临时对象。
- 触发器、约束、索引维护
这可以看做是DML 的真正成本。
BEFORE/AFTER 触发器、约束检查(NOT NULL/CHECK/外键等)、更新索引条目,全部发生在执行期。所以同样一条 UPDATE,Planner 选了同样的 scan/join 路径,执行时间也可能因为触发器/索引数量差异而天差地别。
产生结果行与输出¶
执行器的输出有两类:
-
SELECT:产生一个个结果行(tuple),送给 DestReceiver,客户端
/CTAS/INSERT SELECT的接收端等。 -
INSERT/UPDATE/DELETE/MERGE:通过 ModifyTable 节点驱动,产生受影响行数,并进行写入与索引维护/触发器执行。
SELECT 路径¶
SELECT 的执行几乎都可理解为顶层节点(Limit / Sort / Agg / Result)开始循环,然后顶层向子节点拉取 tuple(ExecProcNode(child))。子节点继续向更下层拉取,直到 scan 节点从表/索引里拿到 tuple。拿到tuple后会对 tuple 执行过滤ExecQual(WHERE / JOIN qual)和投影ExecProject(SELECT 列表达式)。最后返回一行给上层,最终交给 DestReceiver。为了更直观理解这个过程,举例如下:
典型计划树可能是Limit → Sort → SeqScan(customer)。
Executor 的工作过程如下
-
Limit 节点要 10 行 → 找 Sort 要行
-
Sort 必须先把下层全部读完(阻塞算子)→ 去 SeqScan 拉行
-
SeqScan 每拿到一行就做可见性判断 → ExecQual 过滤 status
-
合格行交给 Sort 收集、排序
-
Sort 排完开始逐行吐 → Limit 吐前 10 行 → DestReceiver 输出
DML 路径¶
DML 的核心不是“输出行”,而是把上游产生的行变成实际写入动作。这条链路的总入口节点通常是 ModifyTable。
上游子计划负责找出要改哪些行 / 新行是什么,而ModifyTable 负责对目标表执行 Insert/Update/Delete/Merge;触发器与约束;索引维护;统计受影响行数;如果有 RETURNING,再把返回行送给 DestReceiver。
UPDATE 的逐行执行过程
-
定位目标行。上游子计划产生目标行标识,比如 ctid 或者旧 tuple。
-
行锁与并发处理。对目标行加锁,如在 READ COMMITTED 下发现并发更新,可能触发 EPQ 重检:重新取最新版本,并重新判断条件
-
计算新行(new tuple)。评估
SET表达式、类型转换、生成新 tuple -
BEFORE ROW 触发器。触发器可以修改 NEW 值,甚至跳过本次更新
-
约束检查。
NOT NULL / CHECK和外键约束 -
执行 heap 更新。写入新版本 tuple,MVCC:旧版本并不物理删除。可能发生 HOT 更新(减少索引维护),也可能需要更新索引。
-
索引维护。对涉及的索引写入/更新条目(多索引会显著增加开销)。
-
AFTER ROW 触发器
-
RETURNING(若存在)。将 RETURNING 计算结果作为 tuple 输出给 DestReceiver
update,delete,insert的过程类似,只是核心动作从更新旧行版本变成插入新行或标记删除版本。
执行器的核心原理¶
在 PostgreSQL 的执行器里,每个 Plan 节点在运行时都会对应一个 PlanState,并遵循同一套生命周期接口。
-
Init:初始化节点状态(打开关系/准备表达式/准备 slot/准备私有状态)ExecInitNode(node)
-
Next:获取下一行(返回一个
TupleTableSlot*,没有更多行时返回空)ExecProcNode(nodeState) -
End:释放资源 ExecEndNode(nodeState)
其中Next是核心操作。整个的过程是拉取式的,或者说是消费者驱动的。顶层节点(例如 Limit / Result / Gather / ModifyTable)需要一行结果时,调用子节点的 ExecProcNode()。子节点为了得到一行,继续调用它的子节点 ExecProcNode()。一直递归到最底层的 Scan 节点(SeqScan/IndexScan…)从存储层取到一行,然后逐层返回。当某个节点没有更多行时,它的 ExecProcNode() 返回空(NULL slot),上层据此认为该节点结束。
当一个节点不需要先读完全部输入就能输出第一行,并且能持续边读边吐,它就是流式/可流水线pipelining的。
典型流式节点有以下几类:
-
Scan:拿一行吐一行
-
Filter(
Qual):拿一行判断通过就吐,不通过就继续拿下一行 -
Project:对一行计算表达式后吐出
-
Nested Loop Join:外表一行一行驱动内表探测,匹配到了就吐
阻塞算子会先把输入收集到内存/磁盘,处理完一大批后才开始输出第一行。这意味着流水线被破坏。阻塞节点有以下几类:
-
Sort:必须收集足够多数据排序后才开始输出 -
HashJoin的 build 侧:通常要先 build 哈希表(至少要先读完 build 输入) -
HashAgg:通常要读完输入才能确定聚合结果(除非特殊场景) -
Materialize/CTE Scan(视具体实现/计划形态):可能提前物化 -
WindowAgg:很多窗口需要看到分区内多行才能吐结果(部分可以边处理边吐,但仍可能需要缓冲)
所以我们在explain的时候要关注这些阻塞算子节点,因为其中的绝大部分节点超过work_mem就会产生磁盘IO,将中间结果写入临时文件
| 节点类型 | 是否能尽快输出第一行 | 是否典型阻塞 | 直觉解释 |
| SeqScan / IndexScan | 是 | 否 | 读一行吐一行 |
| Filter / Project | 是 | 否 | 对当前行做判断/计算即可 |
| NestLoop | 是 | 否(通常) | 找到匹配就能吐;外表驱动 |
| MergeJoin | 取决于输入是否已排序 | 可能 | 若需 Sort,则先阻塞 |
| HashJoin | 通常否(build 阶段) | 是 | build hash table 后才 probe 输出 |
| Sort | 否 | 是 | 必须先收集排序 |
| HashAgg | 通常否 | 是 | 常需读完输入才能 finalize |
| Limit | 是 | 否 | 计数到 N 立刻停 |
当上层不再拉取,下层自然停止。这就是早停机制。在拉取式模型中,停止执行不需要广播信号。只要上层节点在满足条件后不再调用子节点的 ExecProcNode(),子节点就不会继续做任何工作。
最常见触发早停的上层节点有Limit,Exists/SemiJoin 相关节点,某些聚合/子查询场景。下面举两个例子加深理解:
-
有效早停
若存在匹配的索引(例如
created_at DESC),Planner 可能选IndexScan直接按顺序吐行,Limit → IndexScan(t using idx_created_at_desc),执行顺序如下:-
Limit拉 10 行 -
IndexScan只需要读到 10 行就可以停 -
全链路工作量近似 O(10)
-
-
早停失效
同样的 SQL,如果没有合适索引,计划可能就会是
Limit → Sort → SeqScan。执行顺序如下:-
Limit的确只需要 10 行 -
但
Sort为了输出第一行就要先拿到全部输入并完成排序。所以SeqScan会被读完,Limit的“早停”到Sort这一层就被阻断了。
LIMIT下方存在必须物化的节点(Sort/HashAgg/Hash build),早停就会大幅衰减甚至失效。 -
所以,早停能否带来收益,取决于 LIMIT 下面是不是阻塞算子。

关键数据结构¶
执行器有四个关键对象 EState、PlanState、TupleTableSlot、ExprContext。
数据结构总览:
| 对象 | 粒度 | 你可以把它理解成 | 核心职责 |
| EState | 整条语句一次执行 | 执行器的运行时总控上下文 | 统一持有快照、参数、目标表、触发器/约束上下文、tuple table、子计划状态、内存上下文等 |
| PlanState | 计划树上每个节点 | 节点的运行时实例 | 持有节点私有状态(hash 表、sort 缓冲、scan 游标等)、子节点、输出 slot、qual/投影执行状态 |
| TupleTableSlot | 一行数据在节点间流动 | 一行 tuple 的通用容器 | 统一承载 heap tuple/virtual tuple/minimal tuple;避免拷贝;供表达式求值取列 |
| ExprContext | 表达式求值环境 | 当前这行的变量绑定 + 内存上下文 | 指定本次求值的输入行(scan/outer/inner)、参数、per-tuple 内存;支撑 ExecQual/ExecProject |
EState¶
EState在初始化阶段ExecutorStart时被创建,贯穿 ExecutorRun 全过程,ExecutorEnd时被释放,或错误回滚时由事务/portal 上下文回收。
Estats里面的内容主要分为以下6类:
-
一致性与并发
当前 Snapshot即MVCC 可见性依据。当前命令 ID、事务语义相关状态。FOR UPDATE/行锁相关结构。
-
参数与子计划
外部参数、执行期参数,如嵌套循环参数、
SubPlan输出参数。子查询/子计划SubPlanState管理。 -
目标关系
ResultRelInfo列表,INSERT/UPDATE/DELETE的目标表信息。索引信息、触发器信息、约束检查所需元数据。DML专用的核心内容。
-
TupleTable
语句执行期会创建大量 slots,例如scan 输出、join 输出、投影输出、临时存放。它们会统一挂在 EState 的 tuple table 里,便于统一释放。
-
内存上下文
这部分内容非常关键。
es_query_cxt,per-query 内存包含节点状态、表达式编译结果、哈希表/tuplestore 等。
es_tupleTable_cxt,执行器在计划树上会创建大量
TupleTableSlot,这些 slot 自身会分配values[] / nulls[]数组、values[] / nulls[]数组、还有一些 slot 内部结构。将 slot 相关分配集中到es_tupleTable_cxt的好处是slot 对象数量多、但结构相对规整,集中管理更清晰。Reset per-tuple 时不会影响 slot 自身结构。ExprContext ,per-tuple reset 的上下文。表达式求值非常依赖分配内存,
text || text拼接生成新 varlena,regexp_*/to_char/ JSON 处理生成中间对象,类型转换、数组构造、函数返回临时结构,TOAST 解压/解包等等,这些对象的生命周期都具备一个特点,只对当前一行有效,下一行就不需要了。所以执行器为表达式求值准备一个 per-tuple 上下文,每处理完一行:Reset 一次。好处是成本极低,且不会泄露。这也是PG可以跑大量表达式查询而内存不会被撑爆的原因之一。

-
统计与可观测性
EXPLAIN ANALYZE 的 instrumentation,节点计时、行数、缓冲等。
EState 把执行器运行所需的全局状态集中管理,避免每个节点各自持有互相引用导致生命周期难控。
PlanState¶
plan是Planner 的产物,是静态的,描述节点类型、表达式树、子节点结构等等。
PlanState是Executor 的产物,是动态的,即运行态。其中包含子节点 PlanState 指针,输出 slot、表达式执行状态ExprState
/ProjectionInfo,节点私有运行时结构,例如 HashJoin 的 hash table、Sort 的 tuplestore。
PlanState 的通用字段如下:
-
ps.lefttree / ps.righttree:子节点运行态
-
ps.ps_ResultTupleSlot:该节点对外输出的 slot
-
ps.qual(过滤条件):WHERE/JOIN qual 的执行态
Selection, σ关系代数中的选择在这一阶段执行,Planner会将选择这一操作分发到多个位置执行,而执行器在运行期对每一行调用 ExecQual() 来判断是否保留。
-
Scan 节点上的过滤,
SeqScan的 Filter,IndexScan的 indexqual/recheck -
Join 节点上的连接条件,joinqual(用于匹配)+ otherqual(用于额外过滤)
-
上层节点的残余过滤,有些条件不能下推,就会留在更上层节点过滤。
-
ps.targetlist / ProjectionInfo:SELECT 列投影(或上层节点需要的投影)
-
ps.ExprContext:该节点专属的 ExprContext(变量绑定、per-tuple 内存)
-
ExecProcNode 回调:该节点“拉取下一行”的具体实现(迭代器模型)
关系代数算子到 PG 执行节点的对应关系
| 关系代数 | SQL 语义 | Planner 产出的典型 Plan 节点 | Executor 的运行时动作 |
| 选择 σ | WHERE / join 条件过滤 | Scan/Join 节点的 qual |
ExecQual 判定每行是否通过 |
| 投影 π | SELECT 列、表达式、别名 | 各节点 targetlist |
ExecProject 计算输出 slot |
| 连接 ⨝ | INNER/OUTER JOIN | NestLoop / HashJoin / MergeJoin | 节点特定算法产生匹配行 |
| 聚合 γ | GROUP BY / 聚合函数 | Agg(HashAgg/GroupAggregate) | 累积状态、产生分组结果 |
| 排序 τ | ORDER BY | Sort / Incremental Sort | tuplesort,必要时溢写临时文件 |
| 去重 δ | DISTINCT | Unique / HashAggregate(某些情况) | 去重逻辑(可能阻塞) |
| 并/交/差 | UNION/INTERSECT/EXCEPT | Append / SetOp / Unique | 组合与去重/计数逻辑 |
| 重命名 ρ | 别名/列别名 | 多在解析/分析层体现 | 主要影响元数据与输出列名 |
TupleTableSlot¶
TupleTableSlot是执行器中的核心容器,贯穿整个执行器生命周期。
执行器里 tuple 的形态很多:
-
来自 heap 的物理行。通常就是我们观察
base table中带有ctid的的一行数据 -
来自 join/投影计算的虚拟行。这些输出行往往没有对应的 HeapTuple,因此不会有
ctid、不会有 buffer、也不一定有物理行头部。 -
为了减少拷贝的minimal tuple。HeapTuple 是面向表存储的结构,包含大量与存储/并发相关的信息,但执行器在很多场景并不需要完整的 heap 行头,用户列数据以及必要的 null bitmap/长度信息。
MinimalTuple更小、更轻,拷贝成本更低,适合在执行器内部暂存中间结果。 -
buffer pin、TOAST展开策略等。
当你从 heap 表读取一行时,这行数据在内存里通常来自共享缓冲区(shared buffers)中的某个 page。为了保证你在访问这行内容时,该 page 不会被回收/替换,tuple 指针在有效期内不悬空。执行器/存储层会对该 buffer 做 pin,就是钉住该页面,slot 持有的 heap tuple 指向 buffer page,buffer 被 pin 住,直到 slot 释放或被覆盖。slot 有时不是拷贝一份行数据,而是“引用 buffer 里的行并确保 buffer 不被换出。
TOAST 用于存储超长的变长字段(text/bytea/jsonb 等),可能以压缩或外部存储指针形式存在。对执行器来说有两个选择,立即展开和延迟展开,前者就是读到行就把大字段解压/拉回内存,后者只有当表达式/输出真的需要该列时才展开。PostgreSQL 倾向于能延迟就延迟。
Slot的作用,就是将这些形态各异,功能不同的tuple,统一到一种容器内,从而达到统一接口 + 延迟物化 + 降低拷贝。
Slot里有三类关键信息:
-
TupleDesc,这行有哪些列,类型是什么。
-
values/nulls 数组,解构后的列值缓存。
-
底层存储指针,
heap tuple / minimal tuple / virtual tuple。还有是否解构的标记
节点拿到一条 heap tuple 时,不一定立刻把每列都解出来。上层表达式要用某列时,slot 才去 deform(解构)并填 values/nulls(缓存起来)。这样能显著减少无用列的解构成本。
在 Volcano 模型里,ExecProcNode() 返回的就是 slot。一行在树上向上传递时,不是 copy 一份 heap tuple,而是返回一个 slot 引用。上层可以在自己输出 slot 中重新组装(投影/计算),也可以直接转发下层 slot。
ExprContext¶
表达式树(qual/targetlist)求值时,需要知道以下信息
-
Var(列引用)应该从哪一行取值?scantuple/outertuple/innertuple? -
参数(Param)从哪取?
-
这次求值产生的临时数据放哪?怎么快速释放?
ExprContext 正是把这些运行期的上下文绑定集中起来。
ecxt_scantuple,当前扫描输入行。
ecxt_outertuple,join 外表当前行。
ecxt_innertuple,join 内表当前行。
以上是三个使用非常频繁的slot指针。 Join 节点每比较一次匹配,都会反复更新 ExprContext 中的 outer/inner slot 指针,然后调用 ExecQual(joinqual)。
ExprContext 通常有两类内存上下文概念,per-query,表达式初始化后的执行态结构(长期存在);per-tuple:每行求值产生的临时对象(短期存在)。上面也提到过per-tuple这类内存上下文,每处理完一行就 reset per-tuple context,把临时分配一次性清空,目的是大查询不会因为表达式求值泄漏内存。
| 对象 | 创建时机 | 释放时机 | 主要内存归属 | 典型内容 |
| EState | ExecutorStart | ExecutorEnd | per-query(语句级) | snapshot、params、result rel、tuple table、subplan 等 |
| PlanState | ExecInitNode | ExecEndNode | per-query(节点级) | 子节点指针、运行态结构、输出 slot、ExprContext |
| ExprContext | 节点 init 时创建/绑定 | 节点 end | per-query + per-tuple | outer/inner/scan slot 指针、per-tuple ctx |
| TupleTableSlot | 节点 init 或需要时 | ExecutorEnd/节点 end | tuple table ctx | tupledesc、values/nulls 缓存、底层 tuple 引用 |

总结¶
SQL 全链路(Parser → Analyzer → Rewriter → Planner → Executor)

附录¶
动态规划算法¶
动态规划算法相较暴力破解可以快速的找出最优解。其核心是最优子结构和重叠子问题,前者的意思是整体最优解可以由若干个子问题的最优解组合出来,后者的意思是很多不同的决策路径会反复遇到相同子问题;DP 用记忆/表让每个子问题只算一次。
下面举个例子说明DP是如何进行规划的。
1. 问题的定义¶
4 张表 join,找最小 cost 的 join 顺序。
有 4 个关系 relation:A, B, C, D。
简化的基数与 join 选择性join selectivity:
- 行数(rows)
|A|=1000,|B|=100,|C|=10000,|D|=500
-
join 图
-
A ⋈ B,选择性sAB = 0.01 -
B ⋈ C,选择性sBC = 0.001 -
C ⋈ D,选择性sCD = 0.05
其他组合视为没有 join 条件。在真实 planner 里通常会被认为是笛卡尔积,代价极高,通常会尽量避免/剪枝。
代价模型
扫描成本:cost_scan(X) = |X|
join 输出行数估算: rows(X ⋈ Y) = rows(X) * rows(Y) * sXY
join 成本估算:cost_join(X, Y) = cost(X) + cost(Y) + rows(X) * rows(Y)
这里把 join 的主要开销粗暴近似成两边输入行数的乘积。真实 PG 会更复杂:HashJoin/Sort/Merge/NL 的不同成本、I/O、CPU、work_mem 等。
2. DP 的状态定义¶
对 join 顺序问题,经典 DP 定义是dp[S] = 生成子集 S结果的最优方案。其中S 是表集合。
每个dp[S]有三个要素,best_cost[S],best_rows[S],best_plan[S]。
边界则是,对单表子集:dp[{X}] = (cost=|X|, rows=|X|, plan=Scan(X))。
事实上,PG 里不会只存一个标量 dp[S],它会把子集 S 对应成一个 joinrel:
-
RelOptInfo(S):对应集合S的优化对象 -
pathlist(S):一组候选路径(Paths) -
cheapest_total_path(S)/cheapest_startup_path(S):从候选里选出来的“最优”路径
dp[S] = Paths(S)(一组非劣解),而不是单个解。
3. 确定最优子结构¶
假设 S={A,B,C,D} 的最优计划 P* 最后一步是把两棵子计划 join 起来,
那么可以断言,P1 必须是子集 X 的最优计划(同理 P2 是 Y 的最优计划)。
假设 P1 不是 X 的最优计划,那就存在另一个计划 P1',使得 cost(P1') < cost(P1),且输出等价(同样得到 X 的结果集合)。
那么把 P* 里的 P1 替换为 P1',得到新计划 P' = Join(P1', P2)。
由于 join 成本函数里包含 cost(P1) 这一项,且其它项不因替换而变大到抵消。会有 cost(P') < cost(P*),与 P* 最优矛盾。
所以 P1 必须最优。反证法证毕。
在真实 Planner 里,注意输出等价还包含:输出顺序、参数化依赖等维度,所以 PG 不是只存一个最优解,而是存一组 Pareto-最优 Path。因此 PG 的状态更像被 dominance 剪枝后的非劣集Paths(S) = { p1, p2, ... }
4. 确定重叠子问题¶
很多不同的大问题,都会用到同一子集的最优解:
-
求
dp[{A,B,C}]会用到dp[{A,B}]、dp[{B,C}] -
求
dp[{A,B,D}]也可能用到dp[{A,B}] -
求
dp[{B,C,D}]会用到dp[{B,C}]
dp[{A,B}]、dp[{B,C}] 这些子问题会被反复引用——这就是重叠子问题。
DP 把它们缓存下来,每个子集只算一次。
5. 决策与转移¶
决策就是每一步要选择什么。这包含三个层面:
-
把 S 切成哪两块。
Partition选一个非空真子集
X ⊂ S,令L = X,L = X。这代表最终计划的最后一步是Join( dp[L], dp[R] )。本质上,这就是 join 顺序问题的结构决策,最后一步 join 哪两块中间结果。
-
用哪种 join 算法。
Algorithm choice同一个
(L, R),可能有多种 join 算法候选,Nested Loop、Hash Join、Merge Join,是否parallel等。可以把 join 算法抽象为
m ∈ Methods(L, R)。 -
可行性。
Feasibility连接性(joinable):
L与R之间必须存在可用 join 条件(或可推导的等价连接),否则就是笛卡尔积候选,通常应强剪枝。外连接合法性(legal):外连接/半连接会限制 join 重排,很多拆分是非法的。
物理性质约束(properties):例如 MergeJoin 要求两边可按 join key 排序(或能排序);参数化路径要求能用外层变量等。
所以一个可考虑的决策其实是三元组,决策 d = (L, R, m) 且满足可行性约束。
转移就是子问题“合成”父问题的过程。
一旦选定了 (L, R, m),转移就是取子问题最优解或候选集里某个解。在PG里用 Paths(L) 与 Paths(R)候选多解。
而后估算输出规模,rows_out = f_rows(rows[L], rows[R], join_clauses(L,R))。
估算代价,cost_out = f_cost(cost[L], cost[R], rows[L], rows[R], m, other_params)。
形成候选父解,candidate = (cost_out, rows_out, plan = (L,R,m))。
最后用 min/剪枝更新父状态。把 candidate path 加入 pathlist(S),再做 dominance 剪枝,最后更新 cheapest path。
6. 编写动态转移方程¶
对任意 S,|S| ≥ 2:
这句方程里每个部分是什么意思:
-
S:目标集合(要把这些表 join 起来) -
X ⊂ S且X ≠ ∅且X ≠ S:枚举所有“非空真子集”,等价于枚举所有二分切割(L=X, R=S\X) -
Methods(X, S\X):在这个切割上允许的 join 算法集合(含可行性过滤) -
Join_m(dp[X], dp[S\X]):把两个子计划用算法 m 合成父计划,得到一个候选计划(含 cost/rows) -
外层
min:从所有候选里挑 cost 最小的那个作为dp[S]
7. 手动计算¶
一步步填写DP表
| 子集 S | rows[S] | cost[S] | plan |
| {A} | 1000 | 1000 | Scan(A) |
| {B} | 100 | 100 | Scan(B) |
| {C} | 10000 | 10000 | Scan(C) |
| {D} | 500 | 500 | Scan(D) |
单表之后是两表join,只计算有jion关系的
| 子集 S | 是否有 join 条件 | 选择性 sXY | 输入行数(左×右) | 输出行数 rows[S] | 成本计算式 cost[S] | 成本数值 |
| {A,B} | 是(A⋈B) | 0.01 | 1000 × 100 | 1000×100×0.01 = 1,000 | 1000 + 100 + (1000×100) | 101,100 |
| {B,C} | 是(B⋈C) | 0.001 | 100 × 10,000 | 100×10,000×0.001 = 1,000 | 100 + 10,000 + (100×10,000) | 1,010,100 |
| {C,D} | 是(C⋈D) | 0.05 | 10,000 × 500 | 10,000×500×0.05 = 250,000 | 10,000 + 500 + (10,000×500) | 5,010,500 |
| {A,C} | 否 | — | — | — | 不生成(等价于笛卡尔积候选,通常剪枝) | — |
| {A,D} | 否 | — | — | — | 不生成(同上) | — |
| {B,D} | 否 | — | — | — | 不生成(同上) | — |
然后是三表
-
S = {A,B,C}
候选切分(L | R) dp[L] (rows,cost) dp[R] (rows,cost) 连接点选择性 s 输出行数 rows(S) = rows(L)rows(R)s 总成本 cost(S)=cost(L)+cost(R)+rows(L)*rows(R) 结论 {A} | (1000, 1000) (1000, 1,010,100) sAB=0.01 1000*1000*0.01 = 10,000 1000 + 1,010,100 + 1000*1000 = 2,011,100 候选① {C} | (10,000, 10,000) (1000, 101,100) sBC=0.001 10,000*1000*0.001 = 10,000 10,000 + 101,100 + 10,000*1000 = 10,111,100 候选② dp[{A,B,C}] 取最小:
cost=2,011,100,对应切分{A} | {B,C}(即A ⋈ (B ⋈ C))。 -
S = {B,C,D}
候选切分(L | R) dp[L] (rows,cost) dp[R] (rows,cost) 连接点选择性 s 输出行数 rows(S) 总成本 cost(S) 结论 {B} | (100, 100) (250,000, 5,010,500) sBC=0.001 100*250,000*0.001 = 25,000 100 + 5,010,500 + 100*250,000 = 30,010,600 候选① {D} | (500, 500) (1000, 1,010,100) sCD=0.05 500*1000*0.05 = 25,000 500 + 1,010,100 + 500*1000 = 1,510,600 候选② dp[{B,C,D}] 取最小:
cost=1,510,600,对应切分{D} | {B,C}(即D ⋈ (B ⋈ C))。
最后是4表
| 候选切分(L | R) | dp[L] (rows,cost) | dp[R] (rows,cost) | 连接点选择性 s | 输出行数 rows(S) | 总成本 cost(S) | 结论 |
| {A} | | (1000, 1000) | (25,000, 1,510,600) | sAB=0.01 | 1000*25,000*0.01 = 250,000 | 1000 + 1,510,600 + 1000*25,000 = 26,511,600 | 候选① |
| {A,B,C} | | (10,000, 2,011,100) | (500, 500) | sCD=0.05 | 10,000*500*0.05 = 250,000 | 2,011,100 + 500 + 10,000*500 = 7,011,600 | 候选②(最优) |
| {A,B} | | (1000, 101,100) | (250,000, 5,010,500) | sBC=0.001 | 1000*250,000*0.001 = 250,000 | 101,100 + 5,010,500 + 1000*250,000 = 255,111,600 | 候选③ |

8. 伪代码¶
# 初始化
for each base rel i:
rel[i] = build_base_rel(i)
generate_base_paths(rel[i]) # seq/index/bitmap/partial...
# DP 按子集大小推进
join_rel_level[1] = { rel[i] }
for level = 2..N:
join_rel_level[level] = empty
# 生成 level 大小的 joinrel:常见两种扩展方式
# 1) left-deep: (level-1) + 1
for rel1 in join_rel_level[level-1]:
for rel2 in join_rel_level[1]:
if disjoint(rel1.relids, rel2.relids) and joinable(rel1, rel2) and legal(rel1, rel2):
joinrel = get_or_make_joinrel(rel1.relids ∪ rel2.relids)
generate_join_paths(joinrel, rel1, rel2) # NLJ/MJ/HJ + param paths + pathkeys
add_to_level(join_rel_level[level], joinrel)
# 2) bushy: k + (level-k)(能显著提升质量,但组合更多)
for k = 2..floor(level/2):
for rel1 in join_rel_level[k]:
for rel2 in join_rel_level[level-k]:
if disjoint(...) and joinable(...) and legal(...):
...
遗传算法¶
有了DP为什么还要遗传算法呢?
暴力枚举 join 顺序接近 n!,按照子集做动态规划,通常复杂度也会是指数级的,只枚举 left-deep 时常用近似 \(O_{(n^2·2^n)}\),若允许 bushy进行更完整的二分拆分,复杂度在最坏情况下会更接近 \(O_{(3^n)}\) 量级。指数级说到底也不是常量级,可以参考下面的保守估计表
| n(表数) | 2ⁿ(子集数) |
| 12 | 4,096 |
| 15 | 32,768 |
| 20 | 1,048,576 |
| 25 | 33,554,432 |
光 n=25 的子集数就三千多万;而 DP 并不是“每个子集算一次就完”,还要对每个子集做切分、生成 join path、比较成本。最终只能是穷尽CPU算力,中间结果填满内存。当估算本来就有偏差时,花极大规划时间去找模型内的全局最优,对真实运行未必带来同等收益,反而可能让planning time成为瓶颈。
当 n 很大时,Planner 的目标从证明最优变为在可接受时间内找到足够好的计划。遗传算法不再遍历几百万/几千万个子集组合,转而仅仅进行评估,种群规模 × 代数 次候选顺序。用选择/交叉/变异把搜索集中在更可能低成本的区域。其哲学是牺牲最优保证,换取规划时间可控。
动态规划的算法枚举S子集,并构造 joinrel(S),复杂度指数级增长。遗传算法 GEQO(Genetic Query Optimizer)是在jion数量超过阈值的时候,不再做完整 DP 枚举,而是把join 顺序当作搜索空间,用遗传算法在有限评估次数内找一个足够好的 join 顺序。后者替代的是前者的搜索策略,而不是整个优化器,后者仍然要依赖以下对象:
-
单表访问路径生成(seq/index/bitmap/parallel)
-
join 算法选择(NLJ/HJ/MJ 等)
-
代价模型(cost + rows 估算)
-
上层规划(group/order/limit 等)
1. 标记染色体¶
遗传算法的状态不是 dp[S],而是一个候选 join 顺序。
假设需要 join 的关系集合为 \({R_1..R_n}\),则一个个体(individual)用一个排列表示:
你可以把它理解为基因序列:每个基因是一个关系 ID(或 Planner 内部对应的 relid)。
每个个体都有一个 fitness(适应度),通常由计划的 估算 cost 反推得到(cost 越小越好)。个体会组成种群:
2. 基因筛选策略¶
-
Selection
从当前种群
P中按适应度偏好选出父代,更低 cost 的个体,被选中的概率更大,但不会只选最优,目的是要保留多样性,避免陷入局部最优。 -
Crossover / Recombination
把两个父代 join 顺序组合成新的顺序,但必须满足排列约束:子代不能重复某个关系,以及子代不能丢失某个关系。在PG的
GEQO算法实现中,采用适合排列问题的交叉算子,尽量保留父代中较好的相邻结构/片段,否则子代质量极不稳定。 -
Mutation
突变就是对新生成的顺序做小幅随机扰动,常见方法有交换两个位置、反转一段、插入移动等。其目的还是跳出局部最优,维持搜索空间探索能力。
3. 遗传过程¶
-
从 \(P_t\)选择父代对
-
对每对父代做交叉得到子代
-
对子代做一定概率突变
-
评估子代 fitness,怎么评估一个 join 顺序的 fitness是整个遗传过程的关键部分。
-
用某种“替换策略”形成下一代 \(P_{t+1}\)。常见做法是保留部分精英(
elitism),其余用子代填充。
GEQO 的目标与 Planner 的目标一致,都是最小化估算执行成本(total cost)
4. 评估函数 Evaluation¶
fitness函数往往是遗传算法的核心所在,但是和以往的纯算法不同,PG的GEQO是调用 Planner 的能力对该顺序做一次受约束的规划。
给定顺序
评估通常会构造一个jion过程,直觉上类似left-deep
\(J_2 = best\_join(r_1, r_2)\)
\(J_3 = best\_join(J_2, r_3)\)
...
\(J_n = best\_join(J_{n-1}, r_n)\)
其中 best_join(X,Y) 并不是固定 HashJoin 或 NLJ,而是在给定输入形态下,挑出代价最低的 join path。所以 GEQO 并不是顺序一固定就死了,它仍会在每一步使用代价模型挑 join 方法与访问路径。它只是把顺序搜索从 DP 枚举换成 GA 搜索。
join 顺序决定中间结果规模,影响最大。在固定顺序下再做局部最优(每一步挑 cheapest join 方法),能在有限时间内给出顺序的可比成本,用这个成本做 fitness,就能驱动 GA 在顺序空间里进化。
5. 伪代码¶
令 Order 表示一个排列(join 顺序):
其中 Plan(Order) 是在该顺序约束下由 Planner 生成的计划,Cost 是 Planner 的成本估算输出。
一代的伪代码如下
P0 = InitPopulation(k) # k 个随机顺序(排列)
EvaluateFitness(P0) # 调 Planner 估算 cost → fitness
for gen = 1..G:
P_new = EliteKeep(P_{gen-1}) # 可选:保留少量最优个体(精英策略)
while |P_new| < k:
p1 = Select(P_{gen-1}) # 按 fitness 偏置选择
p2 = Select(P_{gen-1})
child = Crossover(p1, p2) # 排列交叉(保证无重复无缺失)
child = Mutate(child, pm) # 以概率 pm 做小扰动
EvaluateFitness(child)
P_new.add(child)
P_gen = Replace(P_{gen-1}, P_new) # 形成新一代
return BestIndividual(P_G)
