看PostgreSQL内核代码,很多人第一次被“InitPlan”这个词吸引,往往是因为EXPLAIN输出里有一行“InitPlan 1 (returns $0)”。但真要追到源码里去,你会发现事情没那么简单:execPlan.c里既有ExecInitPlan,又有ExecInitSubPlan,还有一个ExecSetParamPlan。这几个函数名字很像,职责却分得很开,绕得人头晕。这篇文章我围绕“InitPlan函数”这条主线,把从规划器如何处理子查询、到执行器如何初始化、再到运行时如何传递参数的完整链路讲一遍。内容不追求一行行贴完整源码,而是把关键函数的行为、触发时机、数据结构和排查方法讲透,适合正在啃PostgreSQL源码,或者想真正看懂执行计划里InitPlan/SubPlan含义的同学。
1. InitPlan在查询执行里的准确位置
1.1 从执行器启动顺序看InitPlan的触发时机
一条SQL从文本变成结果,大致经历parser生成语法树、analyzer生成查询树、rewriter做视图重写、planner生成执行计划,最后才轮到executor真正干活。执行器的入口在execMain.c,standard_ExecutorStart会创建EState(执行状态),初始化范围表,然后调用ExecInitNode去递归初始化计划树。
这里有个很容易忽略的点:计划树通常只代表主计划,而子计划分两类。一类藏在表达式里的SubPlan节点,另一类挂在PlannedStmt的initPlan链表上。对于后者,执行器必须在主计划真正开始执行前,就为它们单独建立PlanState。完成这个任务的就是ExecInitPlan。它遍历plannedstmt->initPlan,逐个调用ExecInitNode完成初始化,再把初始化得到的PlanState挂到estate->es_init_plans链表中。
有人会问:既然都是调用ExecInitNode,为什么不干脆把InitPlan也挂在计划树上?原因是InitPlan与主计划之间没有直接的子节点关系。它的输出不是作为某个算子的输入,而是以参数形式被主计划任意节点引用。挂在独立链表上,既方便统一初始化,也方便在真正拉取数据前统一执行。这个设计也让计划树本身变得干净,主执行循环不需要感知复杂的子查询子树。
1.2 InitPlan与SubPlan:一字之差,天壤之别
把InitPlan和SubPlan放在一起对比,是理解这两条路径最快的方式。
| 对比维度 | InitPlan | SubPlan |
|---|---|---|
| 是否依赖外层变量 | 不能依赖,必须是全局独立的子查询 | 可以是相关子查询,也可以是非相关 |
| 执行时机 | 主计划输出第一行之前,统一执行一次 | 每次表达式求值时按需执行 |
| 结果存放位置 | 写入PARAM_EXEC参数槽,上层直接引用 | 写入参数槽,或作为表达式计算结果返回 |
| 计划挂在什么地方 | PlannedStmt->initPlan独立链表 | 表达式树里的SubPlan节点 |
| 典型执行次数 | 1次 | 可能等于外层行数 |
| EXPLAIN特征 | InitPlan 1 (returns $0) | SubPlan 1,显示在具体算子下方 |
这个表背后最关键的区别就是相关性。一个子查询如果引用了外层查询的列,比如SELECT * FROM a WHERE x = (SELECT max(y) FROM b WHERE b.id = a.id),那它只能作为SubPlan,因为每一行外层数据变化后,子查询都需要重新计算。反过来,如果子查询完全不引用外层任何变量,那它只依赖数据库当前数据,执行多少次结果都一样,适合提前执行一次。
很多文章把InitPlan简单理解为“非相关子查询的SubPlan”,方向没错,但严格说InitPlan是独立维护的Plan节点,SubPlan才是一种表达式节点类型。两者结构不同,初始化路径不同,运行时行为也不同。
1.3 InitPlan到底解决了什么问题
最直观的价值是消除重复计算。拿标量子查询举例,SELECT (SELECT count(*) FROM t) FROM big_table,外层big_table如果有十万行,不做InitPlan就得重扫十万次t表,性能直接崩溃。做成InitPlan后,count(*)只算一次,结果放进PARAM_EXEC参数槽,后面每一行都直接取参数,效率完全不在一个量级。
第二个价值是参数化。InitPlan的结果以参数形式暴露给主计划,上层任何一个节点都可以通过PARAM_EXEC引用它。这种参数传递方式不依赖传统算子树的行流,而是通过EState里的参数栈做解耦,给执行器带来很大的灵活性。
第三个价值是让执行计划更干净。表达式里的复杂子查询被替换成一个Param节点后,主执行循环根本不需要知道这个值是从哪来的,只需要在需要的时候去参数槽取数。这也是PostgreSQL把InitPlan选型为“预先执行的SubPlan”的原因。
2. 规划阶段的甄别:一个子查询如何被选为InitPlan
2.1 从SubLink到SubPlan的第一步转换
子查询最初并不是子计划。parser阶段,子查询会被表示为SubLink节点,里面记录着subLinkType(EXISTS_SUBLINK、EXPR_SUBLINK、ANY_SUBLINK、ALL_SUBLINK、ARRAY_SUBLINK、ROWCOMPARE_SUBLINK)以及子查询的查询树。到了planner阶段,subselect.c里的SS_process_sublinks会递归地把表达式树中的SubLink替换为SubPlan,同时为它准备一个可被父计划引用的参数编号,也就是后面EXPLAIN里看到的$0、$1之类。
SS_make_subplan是这个过程的核心函数之一。它会调用subquery_planner递归规划子查询,得到Plan树,再封装成SubPlan结构。SubPlan里有几个字段特别值得记住:plan代表内部执行计划,parParam列出子计划执行时需要从外层传入的参数,setParam列出计算完成后要写回哪些参数槽。这几个字段直接决定了子计划后续是走InitPlan还是SubPlan路径。
这里想提醒一句:不要一看到SubPlan就认定它是相关子查询。非相关子查询在规划初期也会先生成SubPlan节点,只是到后面finalize阶段才被提升为InitPlan。所以源码里SubPlan这个词的含义比执行计划里通常说的“相关子查询”要更宽。
2.2 相关性与非相关性:决定命运的第一次判断
真正决定一个SubPlan能否变成InitPlan的,是finalize_plan阶段。规划器会在生成最终计划前遍历整个计划树,检查每个SubPlan是否包含对上层变量的引用。如果某个SubPlan的parParam为空,也就是它不依赖任何外层参数,那它就有资格被提升为InitPlan。反过来,parParam非空就必须保留为SubPlan,在执行阶段跟着外层行反复求值。
可以打个比方:InitPlan像是一开始就把葱姜蒜全部切好备齐,后面炒菜直接取用;SubPlan则是每炒一道菜才发现要临时切葱花,菜谱一多,重复劳动会非常明显。这个类比虽然粗糙,但很能说明两者执行次数的差距。
不过要特别强调,“不依赖外层参数”只是必要不充分条件。规划器出于语义保守,某些查询形态即使非相关也可能不提升。比如子查询出现在某些特殊位置,或者里面存在易变函数、锁语义等影响执行结果的行为,规划器就会更谨慎。所以在实际EXPLAIN里,偶尔能看到本可以成为InitPlan的子查询仍然以SubPlan形式出现。
2.3 SS_make_initplan_from_plan的搬运过程
当规划器认定一个SubPlan可以提前执行时,会调用SS_make_initplan_from_plan之类的辅助函数,把内部计划从SubPlan结构中拆出来,加入root->init_plans链表,最终进入PlannedStmt->initPlan。同时,原表达式里的SubPlan节点会被替换成一个Param节点,参数编号指向子计划输出结果的槽位。
这里有个容易混淆的点:一个InitPlan不一定只输出一个参数。有些子查询可能同时产生多个结果,比如在一个子查询里通过多个setParam字段设置多个槽位,这样EXPLAIN里就会出现InitPlan 1 (returns $0, $1)。理解了这个机制,再看执行器代码就会发现,参数槽的数量和顺序不是随便定的,而是规划阶段写死在setParam列表里的。
这个阶段还要注意参数引用替换的彻底性。规划器会把所有指向这个子计划的表达位置都替换为Param,并确保没有任何残留的SubPlan节点指向已经被提升的计划。如果替换不彻底,执行器运行时就会出现“参数槽没有初始化”或者“执行了两次子计划”的诡异错误。这类问题通常在开发自定义执行节点时会遇到,属于比较隐蔽的内核调试难题。
3. 执行器初始化阶段的两个入口
3.1 ExecInitPlan函数核心逻辑拆解
整个InitPlan机制在执行器里的初始化入口,就是ExecInitPlan。它做的事情用一段简化代码可以说明。
static void ExecInitPlan(QueryDesc *queryDesc, EState *estate, int eflags) { PlannedStmt *plannedstmt = queryDesc->plannedstmt; ListCell *l; foreach(l, plannedstmt->initPlan) { Plan *initplan = (Plan *) lfirst(l); PlanState *initplanstate; Assert(initplan->plan_node_id > 0); initplanstate = ExecInitNode(initplan, estate, eflags); estate->es_init_plans = lappend(estate->es_init_plans, initplanstate); /* * 注意:这里只负责建立PlanState,并不实际执行计划。 * 真正的执行发生在ExecutorRun真正拉取数据之前。 */ } }这个函数本身并不复杂,但有三个细节值得拎出来讲。
第一个细节,它通过estate->es_init_plans保存所有InitPlan的PlanState,这个链表和plannedstmt->initPlan里的计划一一对应。后面统一执行时,执行器就靠这个链表找到每个InitPlan的运行时状态。
第二个细节,初始化顺序就是链表顺序。规划阶段生成InitPlan的顺序通常会决定PARAM_EXEC参数编号的顺序,所以$0对应的往往是第一个被放入initPlan链表的计划。一旦顺序错乱,参数槽语义就会全部错位。
第三个细节,ExecInitPlan只搭框架,不跑数据。数据执行要等到运行阶段。这样设计的原因很简单:初始化阶段要做的事情是分配资源、准备状态、打开底层存储扫描,而不是真正去计算。把计算提前到初始化阶段反而会破坏执行器“先初始化、后执行”的阶段性划分。
3.2 ExecInitSubPlan的触发路径和差异
如果说ExecInitPlan是顶层计划驱动的初始化,那ExecInitSubPlan就是表达式递归初始化时被撞见才触发的。执行器在初始化表达式时,遇到T_SubPlan节点会调用ExecInitSubPlan。这个函数会为SubPlan创建SubPlanState,再调用ExecInitNode初始化内部计划,最后根据subLinkType准备好不同的结果转换方式。
比如EXISTS_SUBLINK需要把子计划的执行结果变成布尔值,ARRAY_SUBLINK需要把多行结果收集成数组,EXPR_SUBLINK则通常只取第一行单个值。这些转换逻辑虽然是在运行时完成的,但初始化阶段就要把类型信息、结果存放方式、参数映射关系都准备好。
对比一下就很清楚:ExecInitPlan一条路径从PlannedStmt->initPlan出发,处理的是全局不依赖外层的子计划;ExecInitSubPlan一条路径从表达式节点出发,处理的是挂在表达式树里的SubPlan。前者结果只需计算一次,后者往往要跟着外层行反复计算。两条路径最终都会走到ExecInitNode去初始化真正的算子树,区别在于外层包装和调度方式。
3.3 ParamExecData与参数栈的建立
InitPlan和SubPlan的结果传递,都离不开EState里的参数栈。参数栈本质上是es_param_exec_vals数组,每个元素是一个ParamExecData结构。
typedef struct ParamExecData { void *value; /* 参数值 */ bool isnull; /* 是否为NULL */ bool execPlan; /* 是否需要通过执行子计划来获得值 */ } ParamExecData;三个字段各有分工。value保存具体值,isnull表示值是否为NULL,execPlan标志当前槽位是否已经计算完毕。对InitPlan来说,启动阶段统一执行完成后,目标槽位的execPlan会被改成false,之后主计划读取时不会再次触发计算。对SubPlan来说,每次外层行变化时,只要参数依赖发生变化,execPlan会被重新置为true,随后触发ExecSetParamPlan去重新计算。
这个execPlan标志就是“是否已计算”的状态锁。理解这个锁,对排查很多怪问题很有帮助。比如如果你在调试一个自定义执行节点时发现取到的值是旧的,很可能就是因为某个参数槽的execPlan没有按预期重置。这种问题不会报错,只会表现为“结果不对”,非常考验对参数生命周期和内存上下文的把握。
4. 运行时的数据流动与结果分发
4.1 ExecSetParamPlan执行子计划并写回参数
运行时真正负责执行子计划并把结果写回参数槽的核心函数,是ExecSetParamPlan。它接收SubPlanState和ExprContext,整体逻辑可以概括为几步。
void ExecSetParamPlan(SubPlanState *node, ExprContext *econtext) { SubPlan *subplan = node->subplan; PlanState *planstate = node->planstate; EState *estate = planstate->state; ... if (subplan->subLinkType == EXISTS_SUBLINK) { /* 执行并转成布尔结果 */ } else if (subplan->subLinkType == ARRAY_SUBLINK) { /* 收集全部行,组装成数组 */ } else { /* 取第一行,作为普通标量结果 */ } foreach(pl, subplan->setParam) { int paramId = lfirst_int(pl); ParamExecData *prm = &estate->es_param_exec_vals[paramId]; prm->value = value; prm->isnull = isnull; prm->execPlan = false; } }这段伪代码把最重要的行为浓缩在了最后那个循环里。setParam列表让子计划直接把结果写到指定参数槽,而不需要经过上级算子树的行流,这是PostgreSQL不同算子之间传递结果的一种解耦方式。主计划里任何节点只要引用对应参数编号,就能拿到这个值。
要注意的是,ExecSetParamPlan并不区分这个子计划到底是相关还是非相关。对于InitPlan,它在启动阶段被调用一次;对于SubPlan,它在每次表达式求值时按需被调用。同一套写参数逻辑,服务于两类路径,这也是为什么阅读源码时经常会在ExecSetParamPlan附近同时看到两条调用链。
4.2 InitPlan在“第一行数据之前”被统一执行
回到InitPlan的执行时序。执行器在真正开始拉取主计划数据之前,会先处理estate->es_init_plans链表里的所有InitPlan。每个InitPlan被ExecProcNode拉出结果后,再通过类似ExecSetParamPlan的逻辑把结果写入对应的PARAM_EXEC槽位。主计划从第一行开始,就只需要从参数槽取数。
具体到不同PostgreSQL版本,这个“统一切入点”可能放在standard_ExecutorRun的函数体里,也可能放在首次拉取数据的路径上。不同版本细节有差异,但语义是一致的:主计划输出任何一行之前,所有InitPlan必须已经完成计算。这个约束也解释了一个现象——如果某个InitPlan执行得很慢,EXPLAIN ANALYZE里你会看到它的耗时被算在“启动”阶段,而不是出现在扫描过程中。
理解这个时序后,再去看EXPLAIN ANALYZE的输出就很有帮助。当InitPlan的耗时高时,不要只盯着外层算子的执行时间,要看启动阶段那部分。这种问题的优化方向通常也和普通算子不同:要么改进子查询本身的计划,要么考虑把全局计算放到物化表或者APP端。
4.3 从EXPLAIN看InitPlan的数据引用关系
看执行计划是最直观的验证方式。举两个例子对比。
EXPLAIN SELECT a, (SELECT max(b) FROM t2 WHERE t2.id = a) FROM t1;这个子查询引用了外层t1.a,是相关子查询,执行计划里会出现SubPlan相关节点,而且子查询的计划会挂在表达式所在的节点下方,运行时逐行求值。
再看非相关版本:
EXPLAIN SELECT a, (SELECT max(b) FROM t2) FROM t1;执行计划大致长这样:
Result (cost=...) InitPlan 1 (returns $0) -> Aggregate (cost=...) -> Seq Scan on t2 (cost=...) -> Seq Scan on t1 (cost=...)这里的关键就是“InitPlan 1 (returns $0)”。1表示这是第几个InitPlan,$0表示它会写入PARAM_EXEC的0号参数。上层Seq Scan t1完全不需要知道子计划内部细节,取值时直接引用$0。你还会发现t2的Scan不是作为t1的子节点出现,而是挂在独立的一段里。这是InitPlan和SubPlan在EXPLAIN里最直观的区别:SubPlan会内嵌在表达式所在节点下方,InitPlan则是以独立段落出现在主计划上方。
5. 常见问题与调试实录
5.1 明明是非相关子查询,为什么没被提升成InitPlan
这是我自己被问得最多的问题。明明子查询看起来完全独立,EXPLAIN却显示SubPlan,性能也出问题。原因通常出在几个方面。
第一,表达式里藏着外层引用。比如子查询内部某个条件被函数包裹,外层Var被隐式传了进去,规划器检查参数依赖时会发现parParam非空,于是拒绝提升。第二,子查询里使用了易变函数或者存在特殊语义,规划器保守起见不提升。第三,某些子链接类型本身不太适合提前物化,比如ROWCOMPARE_SUBLINK涉及比较运算,提升逻辑更复杂。第四,查询被某种视图或规则展开后,外层引用被“包装”得比较隐蔽。
排查方法,我建议先做一件事:把子查询单独拿出来执行,看它是否真的不依赖外层。再通过改写为CTE或者FROM子句里的JOIN,验证规划器对原始形态的限制。很多时候,一个看起来应该被提升的子查询,只是因为在某个不起眼的表达式里多引了一个外层列,导致全盘皆输。
5.2 用调试器顺藤摸瓜看运行路径
读源码的时候,我习惯在四个函数上打断点:ExecInitPlan、ExecInitSubPlan、ExecSetParamPlan、ExecProcNode。这组断点基本覆盖了InitPlan/SubPlan从初始化到执行的完整路径。
常用调试命令可以做这些事。
(gdb) b ExecSetParamPlan (gdb) c (gdb) p node->subplan->subLinkType (gdb) p node->subplan->plan->plan_node_id (gdb) p estate->es_param_exec_vals[0].execPlan (gdb) p list_length(estate->es_init_plans)通过观察subLinkType可以快速判断当前处理的是哪一类子查询。通过es_param_exec_vals[0].execPlan的值,可以确认这个参数槽是否已经“计算完毕”。如果在主计划执行阶段发现execPlan还是true,说明程序流程出了问题,参数没有被提前填充。
调试有个小经验:编译PostgreSQL时最好打开调试符号,并且关闭优化。优化会把关键变量搬来搬去,单步跟踪时看到的值经常是不可读的。另外InitPlan相关的逻辑分散在execMain.c、execExpr.c、subselect.c几个文件里,跳转比较多,建议在gdb里提前打好“函数断点”,而不是手动一行行找。
5.3 几个实际踩坑记录
第一个坑是关于性能的。某次我在一个业务系统里排查慢查询,一个很简单的标量子查询耗时极高。EXPLAIN ANALYZE显示它走的是SubPlan,而不是InitPlan。追查后发现子查询内部条件引用了外层别名,虽然从数据语义上这个外层的值是恒定的,但规划器没有做外连接简化和去相关优化,所以退化成逐行执行。解决方案是把子查询改写进FROM子句,用JOIN配合分组来消除逐行依赖。这个案例说明,规划器并不会对你做逻辑推理,它只认结构上的变量依赖。
第二个坑是关于EXPLAIN模式和真实执行的差异。有人用EXPLAIN不带ANALYZE时发现InitPlan段不执行,于是怀疑代码有问题。这是正常现象。EXPLAIN只是打印计划结构,不真正跑InitPlan;只有EXPLAIN ANALYZE才会真正执行子计划并统计耗时。调试时如果发现InitPlan“没跑”,先确认自己用的是不是ANALYZE。
第三个坑和内存生命周期有关。InitPlan执行结果放在查询级内存上下文里,通常生命周期能覆盖整个查询过程。但如果在自定义节点里把结果缓存到了更长生命周期的内存上下文,就可能出现数据被覆盖或不可用的问题。遇到“结果偶尔正确、偶尔不正确”的疑难杂症时,优先确认值存放在哪个内存上下文,以及在什么时机被读取。
6. 从实践里沉淀的几点体会
我自己啃这块代码的顺序是:先读subselect.c里的子计划生成和finalize逻辑,再看execMain.c里的ExecInitPlan,最后回到execExpr.c看表达式初始化。这个顺序能让你先理解“为什么会有InitPlan”,再去看“执行器怎么初始化它”,最后理解“表达式系统怎么使用它”。跳过第一步直接读执行器代码,很容易把SubPlan和InitPlan混在一起。
调试时我的习惯是先用EXPLAIN ANALYZE确认执行次数,再决定要不要上调试器。很多性能问题在计划阶段就能看出来:同一个子查询被标记为SubPlan并且处以成本较高的位置,基本就可以断定是相关子查询退化问题。只有在确认执行路径异常时,才值得花时间打断点逐层跟踪。
最后一个建议是关注PostgreSQL版本差异。不同版本对InitPlan的优化和参数处理方式有细微不同,比如参数编号分配时机、子计划缓存策略等。你在网上看到的源码片段可能来自老版本,直接套到新版本上容易踩坑。遇到出入时,以当前版本源码为准,把它对应的行为差异记录下来,是理解整个执行器演进很好的学习素材。