执行器与表达式求值:从计划树到行数据的一趟流水
你给 PG 发了一条 SQL,planner
给了一个计划——然后呢?执行器(Executor)负责把计划树变成实际的行数据。它按”火山模型”自上而下驱动算子树:父节点每次调用子节点的
ExecProcNode()
拉一行数据,处理完返回给更上层,最终回到客户端。这不是简单的
for
循环——每一步都涉及内存分配(TupleTableSlot)、表达式求值(WHERE
条件、SELECT
列表、聚合参数)、以及算子内部的复杂状态机(hash join
的构建/探测阶段切换)。
一条 SQL 执行到底要经过多少函数调用?本文从
execMain.c 的入口一路追到
execExprInterp.c 的 opcode 解释循环,再深入到
nodeHashjoin.c 的 hash
表构建与探测。最后讨论一个运维核心问题:查询 hang
住不动时,从哪里下手诊断。
一、火山模型:三条控制流
PG 执行器的设计遵循经典的
Volcano(迭代器)模型。每个算子(plan
node)对外暴露统一的接口:ExecInitNode()
初始化状态 → ExecProcNode() 每次调用返回一行 →
ExecEndNode() 清理资源。parent 算子不知道 child
是 SeqScan、IndexScan 还是 HashJoin——它只管调
ExecProcNode() 拉数据。
1.1 入口函数
执行器的入口在
src/backend/executor/execMain.c 中:
// src/backend/executor/execMain.c, ExecutorStart()
void ExecutorStart(QueryDesc *queryDesc, int eflags) {
// 1. 初始化执行状态(EState)
// 2. 为计划树中每个节点调用 ExecInitNode()
// 3. 为 expression tree 编译 opcode(EEO)
ExecInitPlan(queryDesc, estate, eflags);
...
}
// src/backend/executor/execMain.c, ExecutorRun()
void ExecutorRun(QueryDesc *queryDesc,
ScanDirection direction,
uint64 count, bool execute_once) {
// 循环调用 ExecProcNode(planstate->planTree) 直到:
// - 返回 NULL(没数据了)
// - 达到 count 条
// - execute_once 为 true(只跑一次,如 INSERT)
}
// src/backend/executor/execMain.c, ExecutorFinish()
void ExecutorFinish(QueryDesc *queryDesc) {
// 执行收尾:触发器、WITH CHECK OPTION 校验等
}
// src/backend/executor/execMain.c, ExecutorEnd()
void ExecutorEnd(QueryDesc *queryDesc) {
// 对每个节点调用 ExecEndNode() 释放资源
ExecEndPlan(queryDesc->planstate, estate);
}
```text
四个阶段的调用关系:ExecutorStart() // 初始化整棵树 → ExecInitPlan() // 遍历 plan tree,每个节点 ExecInitNode() → InitPlan() // 处理 InitPlan(子查询提前求值)
ExecutorRun() // 拉数据 → ExecutePlan() // while (true) { slot = ExecProcNode(root); … }
ExecutorFinish() // 收尾(INSERT/UPDATE/DELETE 的 trigger 等)
ExecutorEnd() // 释放所有资源 → ExecEndPlan() // 遍历每个节点 ExecEndNode()
### 1.2 ExecInitNode:递归初始化计划树
`ExecInitNode()` 根据节点类型(plan node 的 `type` 字段)分发到具体的 `ExecInit*` 函数:
```c
// src/backend/executor/execProcnode.c, ExecInitNode()
PlanState *ExecInitNode(Plan *node, EState *estate, int eflags) {
switch (nodeTag(node)) {
case T_SeqScan:
return ExecInitSeqScan((SeqScan *) node, estate, eflags);
case T_IndexScan:
return ExecInitIndexScan((IndexScan *) node, estate, eflags);
case T_HashJoin:
return ExecInitHashJoin((HashJoin *) node, estate, eflags);
case T_NestLoop:
return ExecInitNestLoop((NestLoop *) node, estate, eflags);
case T_MergeJoin:
return ExecInitMergeJoin((MergeJoin *) node, estate, eflags);
case T_Hash:
return ExecInitHash((Hash *) node, estate, eflags);
case T_Agg:
return ExecInitAgg((Agg *) node, estate, eflags);
case T_Sort:
return ExecInitSort((Sort *) node, estate, eflags);
// ... 还有几十种
}
}每个 ExecInit*Node() 函数做三件事:
- 分配
*State结构体:算子的运行时状态(如HashJoinState),在estate->es_query_cxt的 memory context 中分配。 - 递归初始化子节点:调用
ExecInitNode(outerPlan(node), ...)和ExecInitNode(innerPlan(node), ...)。 - 编译表达式的
opcode:
ExecInitExpr()把WHERE条件、join qual、projection 列表从表达式树编译成ExprState(EEO opcode 数组)。
1.3 ExecProcNode:每次返回一行
// src/include/executor/executor.h
static inline TupleTableSlot *
ExecProcNode(PlanState *node) {
return node->ExecProcNode(node);
}
```text
这是一个函数指针调用——每个算子注册自己的 `ExecProcNode` 实现。以 SeqScan 为例:
```c
// src/backend/executor/nodeSeqscan.c
static TupleTableSlot *SeqNext(SeqScanState *node) {
HeapScanDesc scandesc = node->ss.ss_currentScanDesc;
EState *estate = node->ss.ps.state;
TupleTableSlot *slot = node->ss.ss_ScanTupleSlot;
for (;;) {
// 从 heap 中读取下一个 tuple
HeapTuple tuple = heap_getnext(scandesc, direction);
if (tuple == NULL) return NULL; // 没有更多行了
// 把 heap tuple 放入 slot
ExecStoreHeapTuple(tuple, slot, false);
// 应用 scan qual(filter 条件下推到 scan 层)
if (!ExecQual(node->qual, econtext)) continue;
return slot;
}
}关键模式:每次返回一行(pull-based)。如果当前行不满足
filter 条件(ExecQual 返回
false),循环继续读下一行。这与 MySQL
的迭代器模型一致,但与向量化引擎(一次返回一批行)不同。
二、Hash Join:内存中的构建与探测
Hash Join 是 PG
执行器中逻辑最复杂的算子之一。它的实现位于
src/backend/executor/nodeHashjoin.c 和
src/backend/executor/nodeHash.c。Hash Join
本身不管理 hash 表——它依赖一个子节点
Hash(T_Hash 类型),由 Hash
节点负责构建 hash 表,Hash Join 节点只负责驱动探测。
2.1 两阶段流程
sequenceDiagram
participant HJ as HashJoin
participant H as Hash (child)
participant Outer as Outer Plan
participant Inner as Inner Plan
Note over HJ: ExecInitHashJoin()
HJ->>H: ExecInitNode(Hash)
HJ->>Outer: ExecInitNode(OuterPlan)
Note over HJ: 构建阶段 (build phase)
HJ->>H: ExecProcNode(Hash)
H->>Inner: ExecProcNode() 循环拉完 inner 所有行
H->>H: 构建 hash 表
Note over HJ: 探测阶段 (probe phase)
loop 每次拉一行 outer
HJ->>Outer: ExecProcNode()
HJ->>H: ExecHashGetBucketAndBatch() 查 hash 表
HJ->>HJ: ExecQual() 检查 join qual
HJ-->>Client: 返回匹配行
end
```text
Hash Join 的状态机(`ExecHashJoin()`):
```c
// src/backend/executor/nodeHashjoin.c, ExecHashJoin()
TupleTableSlot *ExecHashJoin(PlanState *pstate) {
HashJoinState *node = castNode(HashJoinState, pstate);
for (;;) {
switch (node->hj_JoinState) {
case HJ_BUILD_HASHTABLE:
// 第一次调用:构建 hash 表
ExecHashTableCreate(...);
// 拉完 inner 所有行,插入 hash 表
for (;;) {
TupleTableSlot *innerslot = ExecProcNode(innerPlan);
if (TupIsNull(innerslot)) break;
ExecHashTableInsert(hashstate->hashtable, innerslot);
}
node->hj_JoinState = HJ_NEED_NEW_OUTER;
// fall through
case HJ_NEED_NEW_OUTER:
// 从 outer 拉一行
node->hj_OuterTupleSlot = ExecProcNode(outerPlan);
if (TupIsNull(node->hj_OuterTupleSlot)) return NULL; // 结束
node->hj_JoinState = HJ_SCAN_BUCKET;
// fall through
case HJ_SCAN_BUCKET:
// 在 hash 表中匹配
if (ExecScanHashBucket(node, node->hj_OuterTupleSlot)) {
// 找到匹配:检查 join qual(hash key 相同不代表 WHERE 条件满足)
if (ExecQual(node->js.joinqual, econtext))
return ExecProject(node->js.ps.ps_ProjInfo);
} else {
node->hj_JoinState = HJ_NEED_NEW_OUTER; // 本行匹配完了
}
break;
}
}
}
2.2 Hash 表的内存结构
Hash 表实现在
src/backend/executor/nodeHash.c。核心数据结构:
// src/include/executor/hashjoin.h
typedef struct HashJoinTableData {
int nbuckets; // bucket 数量(2 的幂)
int log2_nbuckets;
int nbatch; // batch 数量(hash join 溢出分区数)
int curbatch; // 当前处理的 batch
// batched hash join 的内存管理
struct HashMemoryChunk *chunks; // 链表分配的内存块
// bucket 数组
HashJoinTuple *buckets; // buckets[0..nbuckets-1],每个 bucket 是 tuple 链表
// batch 文件(溢出到磁盘)
BufFile **innerBatchFile; // 每个 batch 的 inner 临时文件
BufFile **outerBatchFile; // 每个 batch 的 outer 临时文件
// 统计
Size spaceUsed; // hash 表当前使用内存
Size spaceAllowed; // 内存上限 = work_mem × hash_mem_multiplier (PG 15+)
Size spacePeak; // 内存使用峰值
} HashJoinTableData;
```text
Hash Join 用 **Grace Hash Join(hybrid hash join)** 作为磁盘溢出策略:当 inner 表太大无法全部放入 `work_mem × hash_mem_multiplier` 时,PG 用两个 hash 函数——第一个 hash 分 batch,第二个 hash 分 bucket。当前 batch 在内存中,其他 batch 的 inner 行写入临时文件;outer 行也按 batch 分流到临时文件。每个 batch 完成后再加载下一个 batch。观察 `EXPLAIN ANALYZE` 中的 `Buckets:`、`Batches:`、`Memory Usage:` 可以判断是否发生了磁盘溢出。
### 2.3 Hash Join 的执行示例
```sql
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF)
SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id;可能的输出(片段):
Hash Join (actual rows=10000 loops=1)
Hash Cond: (o.customer_id = c.id)
Buffers: shared hit=862 read=28
-> Seq Scan on orders o (actual rows=10000 loops=1)
Buffers: shared hit=442 read=8
-> Hash (actual rows=1000 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 44kB
Buffers: shared hit=6 read=16
-> Seq Scan on customers c (actual rows=1000 loops=1)
Buffers: shared hit=6 read=16
```text
`Batches: 1` 表示没有溢出。如果看到 `Batches: 8` 且 `Memory Usage` 接近 `work_mem`,说明发生了多趟溢出——性能会显著下降。
---
## 三、表达式求值:从 Tree 到 Opcode
查询的 `WHERE` 条件、`SELECT` 列表、`ON CONFLICT` 子句、聚合参数——执行器每处理一行都要对它们求值。传统方式是递归遍历表达式树,每次求值都在 C 函数帧间跳转。这在小数据集上无所谓,但在每次调用 ExecProcNode 都要重新求值的场景下,函数调用开销会成为热点。
PG 10 引入的 **EEO(Expression Evaluation)** 方案用编译技术解决这个问题:把表达式树编译成 opcode 数组,求值变成一个紧凑的 `while` 循环,在 switch-case 中按 opcode 跳转。
### 3.1 表达式树的结构
优化器输出的表达式是 `Expr` 树:
```c
// src/include/nodes/primnodes.h
typedef struct OpExpr {
Expr xpr; // base Expr node
Oid opno; // 操作符 OID(pg_operator)
Oid opfuncid; // 对应的 C 函数 OID
Oid opresulttype; // 返回值类型
List *args; // 参数列表(Expr 节点链表)
} OpExpr;
但执行器不直接操作 Expr 树——它在
ExecInitExpr() 中将其编译成
ExprState:
// src/include/executor/execExpr.h
typedef struct ExprState {
NodeTag tag;
Expr *expr; // 原始表达式树(调试用)
ExprEvalStep *steps; // 编译后的 opcode 数组
int steps_len; // 数组长度
int steps_alloc; // 已分配长度
Datum *results; // 中间结果数组
int ressize; // 结果数组大小
bool resnull; // 最终结果为 NULL
Datum resvalue; // 最终结果值
struct MemoryContext *eval_mcontext; // 求值用的 memory context
} ExprState;
```bash
### 3.2 Opcode 指令集
EEO 定义了约 30 种 opcode,每种对应一个表达式操作:
```c
// src/include/executor/execExpr.h
typedef enum ExprEvalOp {
EEOP_DONE, // 结束标记
// 常量
EEOP_CONST, // 把常量压入结果
EEOP_NULL_IF_NULL, // 一行中某列为 NULL 则整个表达式为 NULL
// 变量
EEOP_INNER_VAR, // 从 inner slot 读列值
EEOP_OUTER_VAR,
EEOP_SCAN_VAR,
EEOP_INNER_SYSVAR,
EEOP_OUTER_SYSVAR,
EEOP_SCAN_SYSVAR,
// 函数调用
EEOP_FUNCEXPR, // 调用 C 函数(如 int4eq, texteq 等)
EEOP_FUNCEXPR_STRICT, // 带 STRICT 语义(任一参数 NULL 则结果为 NULL)
// 布尔运算
EEOP_BOOL_AND_STEP_FIRST, // AND 短路求值
EEOP_BOOL_AND_STEP_LAST,
EEOP_BOOL_OR_STEP_FIRST,
EEOP_BOOL_OR_STEP_LAST,
EEOP_BOOL_NOT_STEP,
// CASE
EEOP_CASE_TESTVAL,
EEOP_MAKE_READONLY_CASE_TESTVAL,
EEOP_CASE_WHEN,
// 跳转与条件
EEOP_JUMP, // 无条件跳转
EEOP_JUMP_IF_NOT_TRUE, // 条件跳转(AND/OR 短路用)
EEOP_JUMP_IF_NULL, // 判定 NULL 后跳转
// 数组、行类型、XML、JSON 等
EEOP_ARRAYEXPR,
EEOP_ARRAYCOERCE,
EEOP_ROWEXPR,
EEOP_ROWCOMPARE_STEP,
// 其他
EEOP_ASSIGN_TMP, // 临时变量赋值
EEOP_ASSIGN_TMP_MAKE_RO,
// 子查询
EEOP_PARAM_EXEC,
EEOP_PARAM_EXTERN,
// 哈希——用于 hash join / hash agg
EEOP_HASHED_SCALAR, // 对单列做 hash
EEOP_HASHED_SCALAR_NULLS,
// 聚合
EEOP_AGGREF,
EEOP_GROUPING_FUNC,
EEOP_WINDOW_FUNC,
// 子计划
EEOP_SUBPLAN,
EEOP_ALTERNATIVE_SUBPLAN,
} ExprEvalOp;3.3 编译过程
ExecInitExpr() 在
src/backend/executor/execExpr.c
中实现。它是一个递归下降编译器,把表达式树转成 step
数组:
// src/backend/executor/execExpr.c, ExecInitExprRec()
// 递归编译表达式树为 step 数组(简化)
static void ExecInitExprRec(Expr *node, ExprState *state,
Datum *scratch, int *scratch_idx) {
switch (nodeTag(node)) {
case T_Var:
// 列引用 → EEOP_INNER_VAR / EEOP_OUTER_VAR / EEOP_SCAN_VAR
ExecPushVarStep(state, (Var *) node);
break;
case T_Const:
// 常量 → EEOP_CONST
ExecPushConstStep(state, (Const *) node);
break;
case T_OpExpr:
// 二元操作符 → 先编译左子树 → EEOP_FUNCEXPR_STRICT
ExecInitExprRec(lfirst(list_head(op->args)), ...);
ExecInitExprRec(lsecond(list_head(op->args)), ...);
ExecPushFuncExprStep(state, op->opfuncid, op->opresulttype);
break;
case T_BoolExpr:
// AND/OR → EEOP_BOOL_AND_STEP_FIRST 等
// 实现短路求值:AND 遇到 false 跳过剩余子表达式
ExecPushBoolExprSteps(state, (BoolExpr *) node);
break;
case T_CaseExpr:
// CASE WHEN ... THEN ... ELSE ...
ExecPushCaseSteps(state, (CaseExpr *) node);
break;
// ... 更多类型
}
}
```text
以 `x + y > 10 AND z IS NOT NULL` 为例,编译后可能生成如下 opcode 序列:
```text
1. EEOP_OUTER_VAR // 读 x 列值 → scratch[0]
2. EEOP_OUTER_VAR // 读 y 列值 → scratch[1]
3. EEOP_FUNCEXPR_STRICT // int4pl(scratch[0], scratch[1]) → scratch[2]
4. EEOP_CONST // 常量 10 → scratch[3]
5. EEOP_FUNCEXPR_STRICT // int4gt(scratch[2], scratch[3]) → result
6. EEOP_BOOL_AND_STEP_FIRST // AND 左分支:如果 false,跳到步骤 9
7. EEOP_OUTER_VAR // 读 z 列值 → scratch[0]
8. EEOP_BOOL_AND_STEP_LAST // AND 右分支:返回 IS NOT NULL 结果
9. EEOP_DONE // 结束3.4 解释执行循环
编译后的 opcode 由 ExecInterpExpr()
执行:
// src/backend/executor/execExprInterp.c, ExecInterpExpr()
// 核心解释循环(简化)
static pg_attribute_always_inline void
ExecInterpExpr(ExprState *state, ExprContext *econtext, bool *isnull) {
ExprEvalStep *op = state->steps;
int nsteps = state->steps_len;
Datum *results = state->results;
for (;;) {
switch (op->opcode) {
case EEOP_CONST:
*op->resvalue = op->d.constval.value;
*op->resnull = op->d.constval.isnull;
op++;
break;
case EEOP_OUTER_VAR: {
int attnum = op->d.var.attnum;
SlotGetSomeAttrs(econtext->ecxt_outertuple, attnum);
*op->resvalue = econtext->ecxt_outertuple->tts_values[attnum];
*op->resnull = econtext->ecxt_outertuple->tts_isnull[attnum];
op++;
break;
}
case EEOP_FUNCEXPR_STRICT: {
FunctionCallInfo fcinfo = op->d.func.fcinfo_data;
// ... 检查是否有 NULL 参数 ...
fcinfo->isnull = false;
*op->resvalue = op->d.func.fn_addr(fcinfo); // 调用 C 函数
*op->resnull = fcinfo->isnull;
op++;
break;
}
case EEOP_BOOL_AND_STEP_FIRST:
if (*op->resnull || !DatumGetBool(*op->resvalue)) {
// AND 短路:跳转到最后一个 AND step 之后
op = &state->steps[op->d.boolexpr.jumpdone];
} else {
op++;
}
break;
case EEOP_JUMP:
op = &state->steps[op->d.jump.jumpdone];
break;
case EEOP_DONE:
if (isnull) *isnull = state->resnull;
return; // 求值结束
}
}
}
```text
`ExecInterpExpr()` 被声明为 `always_inline` + `static`——编译器会将它内联到调用点,消除函数调用开销。整个循环在一个大的 `switch` 中按 opcode 跳转,没有递归调用,没有表达式树的指针追踪。
执行 overhead 的比较:
| 方式 | 每次求值的函数调用次数 | L1i cache 行为 |
|------|----------------------|---------------|
| 递归解释表达式树 | 每个节点至少一次 `ExecEvalExpr()` 调用 | 函数调用导致指令 cache 跳跃 |
| EEO opcode 循环 | 零次额外调用(在一次循环内) | opcode 循环紧凑,指令 cache 友好 |
### 3.5 JIT 的区别
EEO 是**解释执行**opcode(switch-case 分发),而 JIT(第 13 章主题)是用 LLVM 把 opcode 序列编译成**机器码**——连 switch-case 的分发开销都消除了。两者的关系是:EEO 编译出 opcode → JIT 再把 opcode 序列 JIT 成 native code。第 13 章会详细展开。
---
## 四、TupleTableSlot:行的三种承载方式
执行器中的每一行数据都封装在 `TupleTableSlot` 中。它不是简单的指针——它抽象了三种不同物理形态的 tuple 存储,让上层算子(Hash Join、Aggregate)不必关心下层传上来的是 heap tuple 还是虚拟 tuple。
### 4.1 三种 Slot 类型
```c
// src/include/executor/tuptable.h
typedef enum TupleTableSlotType {
T_TupleTableSlot_Virtual, // 虚拟 slot:数据在 tts_values[] / tts_isnull[] 数组中
T_TupleTableSlot_Minimal, // 最小化元组:只有列值,没有系统列(xmin/xmax 等)
T_TupleTableSlot_Heap, // 堆元组:完整的 HeapTupleData(含 xmin/xmax/ctid)
} TupleTableSlotType;核心结构:
// src/include/executor/tuptable.h
struct TupleTableSlot {
NodeTag tag;
uint16 tts_nvalid; // tts_values[] 中已填充的列数
const TupleTableSlotOps *tts_ops; // 虚函数表(根据实际 slot 类型不同)
TupleDesc tts_tupleDescriptor; // 列描述(列名、类型、长度)
Datum *tts_values; // 列值数组(Datum 是 PG 的值容器类型)
bool *tts_isnull; // 列的 NULL 标志数组
MemoryContext tts_mcxt; // 此 slot 的内存上下文
ItemPointerData tts_tid; // tuple 的物理位置 (block, offset)
int tts_tableOid; // 来源表的 OID
};
```text
`tts_ops` 虚函数表是关键——不同类型的 slot 有不同的 `tts_ops`,实现了不同的 `slot_getallattrs()`、`slot_getsomeattrs()` 等操作。
### 4.2 Virtual Slot:最轻量
虚拟 slot 的数据直接存储在 `tts_values[]` 和 `tts_isnull[]` 数组中——没有对应的 heap tuple。表达式求值的结果、aggregate 的中间结果、函数扫描输出都使用 virtual slot。
```c
// 典型用法:表达式求值结果用 virtual slot 承载
TupleTableSlot *slot = ExecInitExtraTupleSlot(estate, tupdesc, &TTSOpsVirtual);
slot->tts_values[0] = computed_value;
slot->tts_isnull[0] = false;
ExecStoreVirtualTuple(slot); // 标记为有效Virtual slot 不需要 heap_form_tuple() 或
heap_deform_tuple()——减少了一次数据拷贝和解构。
4.3 Heap Slot:完整的堆元组
Heap slot 包含一个完整的 HeapTupleData(含
header 的
xmin、xmax、ctid、t_infomask
等)。Seq Scan、Index Scan 从 buffer pool 读出来的行就是
heap slot。
// 从 buffer pool 读取到 heap slot
HeapTuple tuple = heap_getnext(scandesc, ForwardScanDirection);
ExecStoreHeapTuple(tuple, slot, false); // false = 不拷贝,直接引用 buffer 页
```text
`ExecStoreHeapTuple(tuple, slot, false)` 不做数据拷贝——slot 的 `tts_values[]` 指针直接指向 buffer 页内的数据。这意味着在 `ReleaseBuffer()`(unpin)之前必须消费完 slot 内容,否则悬空指针。
### 4.4 Minimal Slot:用于 Hash 表
Hash Join 在构建 hash 表时,inner 行需要被存储到 hash 表的 tuple 链表中——但此时 PG 不需要系统列(xmin/xmax)。Minimal slot 只保存用户列值,用于 hash 表存储和 hash join 匹配时的连接键抽取。
```c
// src/backend/executor/nodeHash.c
// hash join build 阶段:将 heap slot 转成 minimal tuple 存入 hash 表
ExecCopySlotMinimalTuple(slot); // 提取用户列,丢掉系统列Minimal tuple 不包含 tuple header,大小比 heap tuple 小 23+ 字节。在 hash join 需要存储大量 inner 行时,这能节省可观的内存。
4.5 Slot 操作接口
算子通过统一的宏操作 slot,而不关心底层类型:
// 从 slot 中取第 N 列的值
slot_getattr(TupleTableSlot *slot, int attnum, bool *isnull);
// 把所有列值填充到 tts_values[] / tts_isnull[](惰性解构)
slot_getallattrs(TupleTableSlot *slot);
// 只填充一部分列(只填充需要的,惰性)
slot_getsomeattrs(TupleTableSlot *slot, int attnum);
// 把 slot 内容物化成 heap tuple(如果是 virtual/minimal,需要构造 HeapTupleData)
ExecFetchSlotHeapTuple(TupleTableSlot *slot, bool materialize, bool *shouldFree);
```text
惰性解构(lazy deforming)是性能优化:从 heap page 读出的 tuple 以紧凑格式存储(列值不是对齐的数组),不需要立即"解构"成 `tts_values[]` 数组。`slot_getattr()` 在第一次访问某列时才解构,后续访问复用结果。
---
## 五、执行器与 Buffer Manager 的交互
执行器不直接读写磁盘——它通过 Buffer Manager 访问页面。以 Seq Scan 为例:
```c
// src/backend/access/heap/heapam.c, heap_getnext()
HeapTuple heap_getnext(TableScanDesc sscan, ScanDirection direction) {
HeapScanDesc scan = (HeapScanDesc) sscan;
for (;;) {
// 当前页面用完 → 读下一页
if (scan->rs_inited && !heap_page_is_all_visible(...)) {
// ... 处理 VM 检查和 pin 操作 ...
}
page = BufferGetPage(scan->rs_cbuf); // 获取 pinned buffer 的页面指针
lineoff = ItemPointerGetOffsetNumber(&scan->rs_ctup.t_self);
// 扫描页面中的 ItemId 数组,找下一个有效 tuple
lines = PageGetMaxOffsetNumber(page);
for (; lineoff <= lines; lineoff++) {
itemId = PageGetItemId(page, lineoff);
if (!ItemIdIsNormal(itemId)) continue;
// 设置返回 tuple 的位置
scan->rs_ctup.t_self = ItemPointer(block, lineoff);
// heap_getnext 不拷贝数据——返回指针指向 buffer 页
// buffer 的 pin 由执行器框架在释放 slot 时处理
return &scan->rs_ctup;
}
// 本页扫描完,unpin 旧页面,pin 新页面
scan->rs_cblock++; // 下一页
}
}这道出了 Seq Scan 的实际 IO
模式:不是把所有行读进内存再遍历,而是逐页 pin →
逐行扫描 → 逐页 unpin。每页可能包含几百行,一次
ReadBuffer() 带来的 IO
分摊到该页的每一行上。
六、运维:查询 hang 住不动时的诊断
执行器本身不会”hang 住”——没有无限循环设计。查询 hang
住的根因通常在锁等待或 IO
等待。这个场景下,执行器正在某处等待——可能是等 Heavyweight
Lock(LockAcquire())、等 LWLock(buffer 的
content lock)、等 Buffer Pin(PinBuffer()
等别人释放 pin)、等 IO(物理读)。
6.1 wait_event 是第一现场
PG 9.6 开始在 pg_stat_activity 中暴露
wait_event_type 和
wait_event,它告诉你后端当前”卡在什么等待上”。查询
hang 住时,第一步永远是查这个:
SELECT pid, state, wait_event_type, wait_event, query_start, LEFT(query, 120)
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY query_start;
```text
关键 `wait_event_type` 的解读:
| wait_event_type | 典型 wait_event | 含义 | 下一步 |
|-----------------|----------------|------|--------|
| `Lock` | `relation` / `transactionid` / `tuple` | 等待 heavyweight lock(表锁/行锁/事务锁) | 查 `pg_locks` + `pg_blocking_pids()` |
| `LWLock` | `buffer_content` / `WALWrite` / `lock_manager` | 等待轻量级锁 | 看具体 LWLock 名称判断热点模块 |
| `IO` | `DataFileRead` / `WALRead` / `WALWrite` | 等待物理 IO | 查 `pg_stat_io` / `iostat` 确认磁盘是否有问题 |
| `BufferPin` | `BufferPin` | 其他 backend 持有一个 buffer 的 pin 太久 | 查谁持有了这个 buffer——通常意味着有人在 buffer 页数据上做了长时间操作 |
| `Client` | `ClientRead` | 等待客户端发来下一条 SQL | 这不是 hang,是客户端没在发数据(idle in transaction 的典型表现) |
| `Timeout` | `PgSleep` | `pg_sleep()` 等主动休眠 | 查 SQL 里是否有 `pg_sleep` |
| `Activity` | `ArchiverMain` / `AutoVacuumMain` 等 | 辅助进程的主循环等待 | 属于正常状态 |
常见误诊:"查询 hang 住了,`wait_event=ClientRead`"——这不叫 PG hang 住,是客户端没有发下一条 SQL。如果一条连接 `state='idle in transaction'` 且 `wait_event='ClientRead'`,它是在等客户端提交/回滚——但这期间它持有的锁不释放,其他查询会被阻塞在 `wait_event_type='Lock'`。
### 6.2 锁等待链追踪
当一个查询 `wait_event_type='Lock'` 时,下一步是找到它在等谁的锁:
```sql
-- 查询所有未授予的锁及其阻塞者
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
blocked.wait_event_type,
blocked.wait_event
FROM pg_stat_activity blocked
CROSS JOIN LATERAL unnest(pg_blocking_pids(blocked.pid)) AS blocking_pid
JOIN pg_stat_activity blocking ON blocking.pid = blocking_pid
WHERE blocked.state = 'active';pg_blocking_pids() 返回直接阻塞当前
pid 的事务的 backend pid 列表。它的实现位于
src/backend/utils/adt/lockfuncs.c——遍历
LockAcquire()
的等待队列,找到所有前面持有冲突锁模式的 backend。
多级等待链(A 等 B,B 等 C)需要递归追踪:
WITH RECURSIVE lock_chain AS (
-- 初始:找到等待锁的会话
SELECT pid, pg_blocking_pids(pid) AS blockers, 1 AS level
FROM pg_stat_activity
WHERE wait_event_type = 'Lock'
UNION ALL
-- 递归:查找阻塞者的阻塞者
SELECT a.pid, pg_blocking_pids(a.pid), lc.level + 1
FROM pg_stat_activity a
JOIN lock_chain lc ON a.pid = ANY(lc.blockers)
WHERE lc.level < 10 -- 防止环
)
SELECT DISTINCT pid, blockers, level FROM lock_chain ORDER BY level;
```text
同时需要看锁的类型:
```sql
SELECT
l.pid, l.mode, l.granted, l.locktype,
l.relation::regclass AS table_name
FROM pg_locks l
JOIN pg_stat_activity a USING (pid)
WHERE l.pid = ANY(pg_blocking_pids(<hang_pid>))
OR l.pid = <hang_pid>;关键判断: - 如果阻塞者的
state='idle in transaction' 且
xact_start 很久以前 →
这是一个长事务持锁。可以考虑
pg_terminate_backend(pid)
杀掉它(代价:它的事务回滚,未提交数据丢失)。 -
如果阻塞者的 state='active' 且自己也在等锁 →
形成了等待链,需要往上游追踪。 - 如果 pg_locks
显示阻塞者持有 ExclusiveLock 且自己卡在
AccessExclusiveLock → DDL
在等查询结束。查阻塞者 state='active' 的
query 看是什么查询跑太久。
6.3 EXPLAIN 行数偏差诊断
EXPLAIN ANALYZE 输出中
(actual rows=...) 与 (rows=...)
的差异是最直接的性能诊断信息:
Nested Loop (cost=0.42..12345.67 rows=10 width=64)
(actual time=0.123..4523.456 rows=100000 loops=1)
```text
planner 估算 10 行,实际 100,000 行——偏差 10,000 倍。这会产生两种后果:
1. **NestLoop 的内表被重复扫描远多于预期**:NestLoop 对外表每一行扫描一次内表。外表数量被低估 10,000 倍,内表就被多扫描 10,000 次。
2. **上层算子在错误代价模型下做了错误决策**:如果 planner 知道这里是 100,000 行,可能选择 Hash Join 而非 NestLoop。
行数偏差最常见的三个根因:
**统计信息过时**:
```sql
-- 查表上次 ANALYZE 时间和 ESTIMATE 变化量
SELECT schemaname, relname,
last_analyze, last_autoanalyze,
n_mod_since_analyze, n_live_tup
FROM pg_stat_user_tables
WHERE relname = 'your_table';
n_mod_since_analyze 很大 → 统计信息已经不准
→ 手工 ANALYZE your_table;
多列相关未被统计:
-- 如果查询条件是 a > 100 AND b > 100,
-- 而 a 和 b 存在相关性(大数据 a 往往对应大数据 b),
-- 单列统计会严重低估过滤率。
-- 解决方案:
CREATE STATISTICS s1 ON a, b FROM your_table;
ANALYZE your_table;
```text
**参数化查询的通用计划问题**:
```sql
-- PREPARE 后的参数化查询,PG 可能使用通用计划(generic plan)
-- 固定使用一组代价估算,不考虑参数值的分布差异。
SHOW plan_cache_mode; -- 检查是用 'auto' 还是 'force_generic_plan'如果应用使用 prepared
statement,且某些参数值的过滤率极端(如一个值匹配 1
行、另一个匹配 100
万行),plan_cache_mode = 'force_custom_plan'
或 auto 模式下 PG 会根据前 5
次执行判断是否切换到 custom plan。
6.4 执行器层面的排查命令总结
-- 第一步:定位 hang 住的原因
SELECT pid, state, wait_event_type, wait_event, query
FROM pg_stat_activity
WHERE pid = <hang_pid>;
-- 第二步:如果是等锁,追踪锁等待链
SELECT unnest(pg_blocking_pids(<hang_pid>));
-- 第三步:看锁的细节
SELECT locktype, mode, granted, relation::regclass
FROM pg_locks WHERE pid IN (<hang_pid>, <blocker_pid>);
-- 第四步:如果 garented=false 且 mode='AccessExclusiveLock',
-- 查阻塞这条 DDL 的所有 backend
SELECT pid, state, query, xact_start
FROM pg_stat_activity
WHERE pid = ANY(pg_blocking_pids(<ddl_pid>));
-- 第五步:如果没在等锁,而是在 IO 等待
-- 查看 EXPLAIN 输出定位哪个节点产生大量 IO
-- 再用 pg_stat_io 确认是不是磁盘瓶颈
SELECT * FROM pg_stat_io WHERE backend_type = 'client backend';
-- 补救措施(仅在确认安全时使用)
SELECT pg_cancel_backend(<pid>); -- 取消查询(不 kill 连接)
SELECT pg_terminate_backend(<pid>); -- 终止连接(回滚事务)
```text
---
## 七、实验:观察执行器行为
### 7.1 用 GDB 追踪 ExecProcNode
```bash
# 1. 开一个 psql session
psql -c "SELECT pg_backend_pid();"
# 假设输出 12345
# 2. 在另一个终端 attach
gdb -p 12345
# 3. 设置断点
(gdb) break ExecProcNode
(gdb) continue
# 4. 在 psql 中执行一条查询,GDB 会在每次 ExecProcNode 调用时停住
# 通过 bt 看调用栈,追踪是哪一层算子在被调用
(gdb) bt
# #0 ExecProcNode ...
# #1 ExecNestLoop ...
# #2 ExecProcNode ...
# #3 ExecSort ...
# ...7.2 观察 Hash Join 的 batch 溢出
-- 先把 work_mem 调到极低,强制 hash join 溢出
SET work_mem = '64kB';
-- 制造一张大表
CREATE TABLE t_big AS SELECT generate_series(1, 100000) AS id,
md5(random()::text) AS data;
CREATE TABLE t_small AS SELECT generate_series(1, 1000) AS id;
ANALYZE t_big; ANALYZE t_small;
-- 执行 hash join
EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM t_big b JOIN t_small s ON b.id = s.id;
```text
输出会显示 `Batches:` 远大于 1,`Memory Usage:` 接近 64kB,说明发生了磁盘溢出。`Buffers:` 的 `read` 计数会显著增加——因为每个 batch 都要从临时文件重读数据。
对比正常模式:
```sql
SET work_mem = '64MB';
-- 同样查询,Batches: 17.3 观察 EEO 编译结果
```sql – 开启 debug 输出(需要 PG 编译时带 –enable-debug) SET debug_print_expression = on;
SELECT x, y, x + y > 10 AND z IS NOT NULL FROM test WHERE id = 1; – 日志中会输出编译后的 step 序列 ```text
八、关键要点
火山模型的核心是统一接口:
ExecInitNode()→ExecProcNode()→ExecEndNode()三个函数覆盖所有算子。parent 不知道 child 的具体类型,只管调ExecProcNode()拉数据。Hash Join 用 Grace Hash Join 应对溢出:inner 行按 batch hash 分到多个分区,当前分区在内存,其余写临时文件。
EXPLAIN ANALYZE中Batches > 1意味着溢出发生——每个额外的 batch 都需要一次额外的磁盘 IO。EEO 把表达式求值从递归改成了线性:表达式树在
ExecInitExpr()中编译成 opcode 数组,ExecInterpExpr()在一个紧凑的循环中分发执行。这是 PG 10 之后表达式求值不再成为瓶颈的基础。TupleTableSlot 有三种实现,按需选择:Virtual slot 最轻量(纯内存数组),Minimal slot 用于 hash 表,Heap slot 用于从 buffer pool 直接引用页面数据。惰性解构在真正需要时才解构列值。
查询 hang 住时,
wait_event是第一个线索:Lock表示锁等待(用pg_blocking_pids()追踪),IO表示磁盘等待(查pg_stat_io),BufferPin表示有人在 buffer 页上耗太久,ClientRead表示等客户端——这不是 PG 的问题。EXPLAIN 中计划行数与实际行数的偏差是最直接的性能诊断:偏差超过 100 倍意味着统计信息不准。NestLoop 的内表扫描次数被低估、或 Hash Join 本该被使用却错选 NestLoop——行数估算错误是一切糟糕计划的根因。
下一章:JIT 编译——EEO 完成了从表达式树到 opcode 的编译,JIT 再进一步:把 opcode 序列编译成 LLVM native code,连 switch-case 的分发开销也消除。
参考资料
源码(PG 17)
src/backend/executor/execMain.c:ExecutorStart(), ExecutorRun(), ExecutorFinish(), ExecutorEnd()src/backend/executor/execProcnode.c:ExecInitNode(), ExecProcNode(), ExecEndNode()src/backend/executor/nodeHashjoin.c:ExecHashJoin() — Hash Join 的主循环与状态机src/backend/executor/nodeHash.c:ExecHashTableCreate(), ExecHashTableInsert(), ExecHashGetBucketAndBatch()src/backend/executor/nodeNestloop.c:ExecNestLoop() — NestLoop 实现src/backend/executor/nodeSeqscan.c:SeqNext() — Seq Scan 的逐行读取src/backend/executor/execExpr.c:ExecInitExpr(), ExecInitExprRec() — 表达式树到 opcode 的编译src/backend/executor/execExprInterp.c:ExecInterpExpr() — EEO opcode 的解释执行循环src/include/executor/execExpr.h:ExprEvalOp 枚举定义、ExprEvalStep 结构体src/include/executor/tuptable.h:TupleTableSlot 结构体、TTSOps 虚函数表src/include/executor/hashjoin.h:HashJoinTableData 结构体src/backend/access/heap/heapam.c:heap_getnext() — heap scan 实现src/backend/utils/adt/lockfuncs.c:pg_blocking_pids() — 锁阻塞关系查询
官方文档
- PostgreSQL Documentation, Chapter 51: Overview of PostgreSQL Internals, Section 51.5 (Executor)
- PostgreSQL Documentation, Chapter 28: Monitoring
Database Activity —
pg_stat_activity,pg_locks - PostgreSQL Documentation, Section 14.4:
EXPLAIN— 执行计划输出解读 - PostgreSQL Documentation, Section 19.4: Resource
Consumption —
work_mem,hash_mem_multiplier
论文
- Graefe, G. Volcano — An Extensible and Parallel Query Evaluation System. IEEE TKDE 1994. — 火山模型的原始论文。
- Graefe, G. & McKenna, W. J. The Volcano Optimizer Generator: Extensibility and Efficient Search. ICDE 1993. — Volcano 查询优化器框架。
实验工具
- GDB:断点追踪
ExecProcNode()、ExecInterpExpr()调用栈 EXPLAIN (ANALYZE, BUFFERS, SETTINGS):观察实际行数、buffer 命中率、batch 溢出pg_stat_activity+pg_blocking_pids()+pg_locks:诊断查询 hang 住debug_print_expression = on:观察 EEO opcode 编译结果
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【PG 内核】查询规划器 — Join 顺序与路径生成:优化器如何选中 Nested Loop
拆解 PostgreSQL 查询优化器的路径生成:make_one_rel() 从基表访问到 Join 路径的完整流程、四种扫描路径 (SeqScan/IndexScan/IndexOnlyScan/BitmapScan) 的创建条件、三种 Join 方式 (NestLoop/HashJoin/MergeJoin) 的代价比较与选择逻辑、动态规划到 GEQO 遗传算法的切换条件 (geqo_threshold)、并行路径的生成机制。配 EXPLAIN (ANALYZE, BUFFERS) 输出与 planner 内部决策的逐项对照实验。
【PG 内核】JIT 编译:为什么 PG 要把 WHERE 子句编译成机器码
拆解 PostgreSQL 的 LLVM JIT 编译机制:JIT 编译的触发决策流程(jit_above_cost 三级阈值)、LLVM 模块管理与惰性编译、表达式求值从 EEO opcode 到 LLVM IR 再到机器码的完整路径、Tuple 变形(deforming)的 JIT 加速原理,以及 JIT 在 OLAP 场景的实际加速效果、编译开销和适用边界。
【PG 内核】扩展系统与 FDW:PG 的 hook 机制如何让扩展影响 Planner 决策
拆解 PostgreSQL 扩展系统的两种核心机制:全局 hook 机制全景(planner_hook、ExecutorStart_hook、ProcessUtility_hook 等覆盖七个子系统)和 FDW(Foreign Data Wrapper)的 FdwRoutine 回调接口。重点分析 postgres_fdw 的 pushdown 机制——哪些 WHERE/ORDER BY/LIMIT 能推到远端执行、哪些被留在本地——以及扩展如何通过 GetForeignRelSize→GetForeignPaths→GetForeignPlan 三个回调影响 planner 的代价估算和路径选择。
【PG 内核】进程模型与共享内存:Postmaster 如何管理 100 个 Backend
拆解 PostgreSQL 多进程架构的核心:Postmaster 的启动与信号处理、Backend 进程的 fork()→InitPostgres→主循环生命周期、CreateSharedMemoryAndSemaphores() 的共享内存初始化流程、PGPROC/ProcArray/PGXACT 等关键共享内存结构的内存布局,以及 Background Worker 的注册与调度。理解了这个地基,才能理解 PG 为什么用进程而不是线程,以及 max_connections 为什么不能随便调大。