土法炼钢兴趣小组的算法知识备份

【PG 内核】执行器与表达式求值:从计划树到行数据的一趟流水

文章导航

分类入口
databasekernel
标签入口
#postgresql#pg-kernel#executor#volcano-model#expression-evaluation#hash-join#tupleslot#wait-event#pg-blocking-pids#explain-analyze

目录

执行器与表达式求值:从计划树到行数据的一趟流水

你给 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() 函数做三件事:

  1. 分配 *State 结构体:算子的运行时状态(如 HashJoinState),在 estate->es_query_cxt 的 memory context 中分配。
  2. 递归初始化子节点:调用 ExecInitNode(outerPlan(node), ...)ExecInitNode(innerPlan(node), ...)
  3. 编译表达式的 opcodeExecInitExpr()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.csrc/backend/executor/nodeHash.c。Hash Join 本身不管理 hash 表——它依赖一个子节点 HashT_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 的 xminxmaxctidt_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_typewait_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: 1

7.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


八、关键要点

  1. 火山模型的核心是统一接口ExecInitNode()ExecProcNode()ExecEndNode() 三个函数覆盖所有算子。parent 不知道 child 的具体类型,只管调 ExecProcNode() 拉数据。

  2. Hash Join 用 Grace Hash Join 应对溢出:inner 行按 batch hash 分到多个分区,当前分区在内存,其余写临时文件。EXPLAIN ANALYZEBatches > 1 意味着溢出发生——每个额外的 batch 都需要一次额外的磁盘 IO。

  3. EEO 把表达式求值从递归改成了线性:表达式树在 ExecInitExpr() 中编译成 opcode 数组,ExecInterpExpr() 在一个紧凑的循环中分发执行。这是 PG 10 之后表达式求值不再成为瓶颈的基础。

  4. TupleTableSlot 有三种实现,按需选择:Virtual slot 最轻量(纯内存数组),Minimal slot 用于 hash 表,Heap slot 用于从 buffer pool 直接引用页面数据。惰性解构在真正需要时才解构列值。

  5. 查询 hang 住时,wait_event 是第一个线索Lock 表示锁等待(用 pg_blocking_pids() 追踪),IO 表示磁盘等待(查 pg_stat_io),BufferPin 表示有人在 buffer 页上耗太久,ClientRead 表示等客户端——这不是 PG 的问题。

  6. EXPLAIN 中计划行数与实际行数的偏差是最直接的性能诊断:偏差超过 100 倍意味着统计信息不准。NestLoop 的内表扫描次数被低估、或 Hash Join 本该被使用却错选 NestLoop——行数估算错误是一切糟糕计划的根因。

下一章:JIT 编译——EEO 完成了从表达式树到 opcode 的编译,JIT 再进一步:把 opcode 序列编译成 LLVM native code,连 switch-case 的分发开销也消除。

上一章:查询规划器 — Join 顺序与路径生成


参考资料

源码(PG 17)

官方文档

论文

实验工具

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-06-16 · database / kernel

【PG 内核】查询规划器 — Join 顺序与路径生成:优化器如何选中 Nested Loop

拆解 PostgreSQL 查询优化器的路径生成:make_one_rel() 从基表访问到 Join 路径的完整流程、四种扫描路径 (SeqScan/IndexScan/IndexOnlyScan/BitmapScan) 的创建条件、三种 Join 方式 (NestLoop/HashJoin/MergeJoin) 的代价比较与选择逻辑、动态规划到 GEQO 遗传算法的切换条件 (geqo_threshold)、并行路径的生成机制。配 EXPLAIN (ANALYZE, BUFFERS) 输出与 planner 内部决策的逐项对照实验。

2026-06-16 · database / kernel

【PG 内核】JIT 编译:为什么 PG 要把 WHERE 子句编译成机器码

拆解 PostgreSQL 的 LLVM JIT 编译机制:JIT 编译的触发决策流程(jit_above_cost 三级阈值)、LLVM 模块管理与惰性编译、表达式求值从 EEO opcode 到 LLVM IR 再到机器码的完整路径、Tuple 变形(deforming)的 JIT 加速原理,以及 JIT 在 OLAP 场景的实际加速效果、编译开销和适用边界。

2026-06-16 · database / kernel

【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 的代价估算和路径选择。

2026-06-16 · database / kernel

【PG 内核】进程模型与共享内存:Postmaster 如何管理 100 个 Backend

拆解 PostgreSQL 多进程架构的核心:Postmaster 的启动与信号处理、Backend 进程的 fork()→InitPostgres→主循环生命周期、CreateSharedMemoryAndSemaphores() 的共享内存初始化流程、PGPROC/ProcArray/PGXACT 等关键共享内存结构的内存布局,以及 Background Worker 的注册与调度。理解了这个地基,才能理解 PG 为什么用进程而不是线程,以及 max_connections 为什么不能随便调大。


By .