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

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

文章导航

分类入口
databasekernel
标签入口
#postgresql#pg-kernel#query-planner#join-order#nested-loop#hash-join#merge-join#geqo#dynamic-programming#seqscan#indexscan#bitmapscan#explain-analyze#allpaths#joinpath

目录

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

你在 EXPLAIN 输出里见过无数次 Nested LoopHash JoinMerge Join,也见过 Seq ScanIndex ScanBitmap Index Scan。但 PG 的优化器到底是怎么决定用哪个的?为什么有时候一个三表 Join 的查询,明明建了索引却走了 Seq Scan?为什么超过一定表数量后执行计划突然变差?

这些问题的答案都在 make_one_rel() 这个函数里。它是 PG 查询优化器的路径生成入口——从单表扫描方式的选择,到两表 Join 算法的决策,再到多表 Join 顺序的排列搜索,全在这里调度。

本文从 make_one_rel() 的调用链开始,拆解四种访问路径的创建条件、三种 Join 方式的代价对比、动态规划到 GEQO 的切换逻辑,最后用 EXPLAIN (ANALYZE, BUFFERS) 对照 planner 的内部决策。


一、从 Query 到 RelOptInfo:make_one_rel 的全景

调用链

规划器的主入口在 src/backend/optimizer/plan/planner.cstandard_planner()。它调用 subquery_planner() 做子查询规划,后者调用 grouping_planner(),最终到达 query_planner(),而 query_planner() 的核心调用就是 make_one_rel()

```text standard_planner() → subquery_planner() → grouping_planner() → query_planner() → add_base_rels_to_query() // 为 FROM 中每个表创建 RelOptInfo → build_base_rel_tlists() // 构建 target list → deconstruct_jointree() // 将 jointree 展平为 JoinExpr 列表 → make_one_rel() // ★ 核心:生成所有 Join 路径bash

make_one_rel() 的内部阶段

// src/backend/optimizer/path/allpaths.c
RelOptInfo *
make_one_rel(PlannerInfo *root, List *joinlist)
{
    RelOptInfo *rel;

    // 阶段 1:为每个基表生成访问路径(SeqScan, IndexScan 等)
    set_base_rel_pathlists(root);

    // 阶段 2:为所有基表的 RelOptInfo 构造 joinrel
    //         按 joinlist 顺序——先做 FROM/JOIN 子句,再做 WHERE
    rel = make_rel_from_joinlist(root, joinlist);

    return rel;
}

set_base_rel_pathlists() 为 FROM 子句中的每个基表(RelOptInfo)生成所有可行的访问路径,加入 rel->pathlistmake_rel_from_joinlist() 按 joinlist 中指定的 Join 配对关系,递归地构造 joinrel(Join 关系的 RelOptInfo),并在每个级别上调用 add_paths_to_joinrel() 生成 Join 路径。

关键数据结构

RelOptInfo 是优化器内部对”一个关系”(基表或 Join 中间结果)的抽象,其路径信息记录在:

// src/include/nodes/pathnodes.h
typedef struct RelOptInfo {
    Relids      relids;          // 该关系包含的 base rel OID 集合
    List       *pathlist;        // 该关系当前最优路径的列表(Path 链表)
    List       *partial_pathlist;// 可并行化的部分路径
    List       *cheapest_startup_path;   // 启动代价最低的路径
    List       *cheapest_total_path;     // 总代价最低的路径
    PathTarget *reltarget;       // 输出列
    // ... 大量其他字段(行数估算、宽度、索引信息等)
} RelOptInfo;
```text

`Path` 是单一执行路径的抽象,核心字段:

```c
typedef struct Path {
    NodeTag     type;
    RelOptInfo *parent;         // 所属的 RelOptInfo
    Cost        startup_cost;   // 启动代价(产生第一行之前的开销)
    Cost        total_cost;     // 总代价
    List       *pathkeys;       // 输出行的排序顺序
} Path;

每种扫描方式和 Join 方式都是 Path 的子结构:IndexPath 包含索引选择信息(indexclausesindexorderbys),JoinPath 包含内外表的路径指针和 Join 条件。


二、基表访问路径:四种扫描方式

set_base_rel_pathlists() 为每个基表生成访问路径。最终进入 rel->pathlist 的路径类型取决于表上有什么索引、统计信息怎么说、GUC 怎么配。源码在 src/backend/optimizer/path/allpaths.c

SeqScan(顺序扫描)

顺序扫描是最朴素的路径——从表的第一个页面读到最后一个页面。创建条件是无条件:只要表存在,就有一条 SeqScan 路径。

// src/backend/optimizer/path/allpaths.c, set_plain_rel_pathlist()
// 即使有其他路径可选,SeqScan 也永远被加入 pathlist
add_path(rel, create_seqscan_path(root, rel, required_outer, 0));
```text

代价计算在 `cost_seqscan()` 中:基本公式是 `N_page * seq_page_cost + N_tuple * cpu_tuple_cost`。`seq_page_cost` 默认 1.0,`cpu_tuple_cost` 默认 0.01。在 `random_page_cost` 远大于 `seq_page_cost` 的配置下(比如默认 4.0 vs 1.0),SeqScan 在规划器眼中对大表极为划算——这正是 NVMe 上 `random_page_cost=4.0` 导致优化器不选索引的原因。

### IndexScan(索引扫描)

IndexScan 用索引定位符合条件的行,然后到 heap 取对应的 tuple。创建条件是存在索引且索引列与查询条件相关。

```c
// src/backend/optimizer/path/indxpath.c, create_index_paths()
// 关键调用链:
//   get_index_paths()
//     → build_index_paths()
//       → create_index_path()

生成 IndexPath 时最关键的两个决策: - 选择率估算clauselist_selectivity() 根据 MCV/histogram 估算索引条件的过滤率。如果过滤率太高(比如返回超过表的 30%),IndexScan 的总代价可能高于 SeqScan。 - 排序利用:如果索引的排序与查询的 ORDER BY 一致,pathkeys 直接满足排序需求,省掉了显式 Sort 节点。

代价计算在 cost_index() 中:索引页面 I/O + heap 页面 I/O(随机读,用 random_page_cost)+ CPU 开销。索引扫描的 heap 读取是随机的(按 ctid 跳页),这是它的主要劣势——如果几乎所有页面都被遍历到,SeqScan 反而更便宜。

IndexOnlyScan(仅索引扫描)

IndexOnlyScan 不需要读取 heap——所有需要的列都在索引中。创建条件是查询的 target list 和 WHERE 条件中的所有列都被索引覆盖。

// IndexOnlyScan 的额外条件(除 IndexScan 条件外):
// 1. 索引包含所有需要的列(无论是作为 key column 还是 included column)
// 2. Visibility Map 标识对应 heap 页面为 all-visible
//    ——如果 VM 不认为页面 all-visible,必须回表查 heap 页面确认可见性
```text

IndexOnlyScan 的代价中,heap I/O 的部分乘以 `vm_all_visible_fraction`——即 VM 认为 all-visible 的页面比例。如果 VM 陈旧(很久没有 VACUUM),IndexOnlyScan 的优势大幅缩水。

### BitmapScan(位图索引扫描)

BitmapScan 分两步:先在内存中构建一个 bitmap(每个 heap 页面一位),然后按页面顺序扫描 heap。它可以合并多个索引的 bitmap(`BitmapAnd`、`BitmapOr`),并将随机 I/O 转换为顺序 I/O(bitmap 按页面号排序后批量读)。

```c
// src/backend/optimizer/path/indxpath.c
// 核心路径:create_index_paths() → generate_bitmap_or_paths()
// 为单个索引创建 BitmapPath:cost_index() + bitmap 代价
// 为 OR 条件创建 BitmapOrPath:合并多个 BitmapPath

BitmapScan 的创建条件是任何 IndexScan 的条件。对多个索引的条件组合(WHERE a = 1 OR b = 2),优化器可能为每个索引生成 BitmapPath,然后用 BitmapOr 合并。

它的优势场景是:返回行数中等(太多 IndexScan 回表随机读太贵,太少 Bitmap 构建开销不值),尤其在有多个 OR 条件的查询中。


三、Join 路径生成:三种 Join 方式的创建条件与代价

make_one_rel()make_rel_from_joinlist()standard_join_search(),优化器逐级构建 joinrel——先两表 Join,再三表 Join。

add_paths_to_joinrel() 是加入 Join 路径的核心函数,源码在 src/backend/optimizer/path/joinpath.c。对每一对内外表路径,它调用三个函数:

// src/backend/optimizer/path/joinpath.c, add_paths_to_joinrel()
match_unsorted_outer();   // 尝试 Nested Loop(外表不需要排序)
hash_inner_and_required(); // 尝试 Hash Join(内表需要 hashable)
match_unsorted_outer() 中 → sort_inner_and_outer(); // 尝试 Merge Join(需要排序)
```bash

### Nested Loop Join(嵌套循环连接)

Nested Loop(NL)是最通用的 Join 方式:对外表的每一行,扫描内表(或在内表上做索引查找)找到匹配行。

```c
// src/backend/optimizer/path/joinpath.c, match_unsorted_outer()
// 对每一对 (outerpath, innerpath):
//   1. 检查 join 条件是否能用作 inner scan key(参数化路径)
//   2. 如果能:创建 parameterized NestLoop(内表是 IndexScan,外表行值作为扫描键)
//   3. 如果不能:创建 plain NestLoop(内表重复全扫描)

代价公式:\(C_{total} = C_{outer} + N_{outer} \times C_{inner\_rescan}\)

Nested Loop 的选择条件:外表小(行数少)且内表有高效索引,或 Join 条件不是等值条件(Nested Loop 是唯一支持非等值 Join 的方式)。它也是 LEFT JOIN 中唯一保证驱动表每一行都输出的方式。

Hash Join(哈希连接)

Hash Join 为内表构建内存 hash 表,然后扫描外表,每一行 probe hash 表找匹配。

// src/backend/optimizer/path/joinpath.c, hash_inner_and_required()
// 创建条件:
//   1. Join 条件是等值条件(equality operator,必须有 hash 函数)
//   2. 内表路径可哈希化
```text

代价公式:$C_{total} = C_{outer} + C_{inner} + N_{outer} \times cpu\_operator\_cost + N_{inner} \times cpu\_operator\_cost$

Hash Join 的关键风险在内存:如果 hash 表放不进 `work_mem * hash_mem_multiplier`(PG 15+ 的额外乘数),就会溢出到磁盘(分批 batch),代价骤增。`EXPLAIN (ANALYZE, BUFFERS)` 输出中的 `Buckets:`、`Batches:` 和 `Memory Usage:` 显示 hash 表的实际大小和溢出情况。

**Hash Join 的选择条件**:两个表都不算太小(否则 Nested Loop 用索引更快)、Join 条件是等值条件、内表能全部装入 work_mem 的 hash 表或溢出不太多。

### Merge Join(归并连接)

Merge Join 要求两个表都按 Join 键排序,然后双指针同步扫描。

```c
// src/backend/optimizer/path/joinpath.c, sort_inner_and_outer()
// 创建条件:
//   1. Join 条件是 mergejoinable 的等值条件(有 btree opfamily)
//   2. 两个表的路径都按 Join 键排序——要么天然有排序(索引),要么加 Sort 节点

代价公式:\(C_{total} = C_{outer\_sorted} + C_{inner\_sorted} + (N_{outer} + N_{inner}) \times cpu\_operator\_cost\)

其中 \(C_{outer\_sorted}\)\(C_{inner\_sorted}\) 包含可能的 Sort 代价。如果表本身已经按 Join 键排序(例如来自索引扫描),Sort 代价为零。

Merge Join 的选择条件:两个表都很大(Hash Join 的 hash 表装不下内存、Nested Loop 太贵),且至少一个表已经按 Join 键排序(或排序代价可接受)。Merge Join 还有一个附带好处——输出天然按 Join 键排序,如果 ORDER BY 与 Join 键一致,省掉最终的 Sort。

三种 Join 的选择对比

``````text 条件:两表大小相当,都有索引,等值 Join

内表小,有高效索引? → Nested Loop(外表每行一次索引查找) 内表中等,Hash 表能装进 work_mem? → Hash Join(一次构建 hash + 一次扫描外表) 两个表都大,排序开销可接受? → Merge Join(如果已排序则极快)


---

## 四、Join 顺序搜索:动态规划与 GEQO 的切换

### 动态规划:standard_join_search()

PG 对 Join 顺序的搜索使用自底向上的动态规划(DP)。源码在 `src/backend/optimizer/path/joinrels.c` 的 `standard_join_search()`。

```c
// 算法概要:
// levels_needed = FROM 子句中的基表数
for (lev = 2; lev <= levels_needed; lev++) {
    // 对每个 level(包含 k 个基表的 joinrel):
    //   生成所有包含 lev 个基表的 joinrel
    //   对每个 joinrel 的每种拆分方式 (outer_rel, inner_rel):
    //     调用 add_paths_to_joinrel() 创建 joinpath
    //     用 add_path() 加入 joinrel->pathlist(dominance check)
    //   保留下来的 joinpath 进入下一级
}

DP 算法保证找到理论上的最优 Join 顺序(在代价模型正确的前提下),因为所有可能的拆分方式都被遍历。但搜索空间是 Catalan number 增长:n 个基表的 Join 顺序数量是 \(C_{n-1} = \frac{(2n-2)!}{n!(n-1)!}\),即 \(n=5\) 时 14 种、\(n=10\) 时 4862 种、\(n=15\) 时约 267 万种。

GEQO:geqo_threshold 的切换

levels_needed >= geqo_threshold(默认 12)时,PG 不再用 DP 做穷举搜索,而是切换到 GEQO(Genetic Query Optimizer,遗传算法)。源码在 src/backend/optimizer/geqo/geqo_main.c

// src/backend/optimizer/path/allpaths.c
// 决策点:
if (levels_needed <= geqo_threshold)
    rel = standard_join_search(root, levels_needed, joinlist);
else
    rel = geqo(root, levels_needed, joinlist);  // 遗传算法
```text

GEQO 的遗传算法流程(`geqo_main.c`):

``````text
1. 初始化种群:生成 NumberGenerations 个随机 join 顺序(默认 pool_size=0 时自适应)
2. 对每个个体(join 顺序)评估代价(fitness),保留 best_keep 个(默认 2
3. 对每一代:
   a. 选择(selection):biased linear selection——代价低的个体更可能被选中
   b. 交叉(crossover):交换两个亲代的部分 join 顺序,生成子代
   c. 变异(mutation):随机交换子代中的两个位置
4. 重复直到 max_generations 代,选择最优个体

GEQO 不保证最优解,但搜索时间是多项式级别而非指数级。geqo_threshold 的默认 12 是基于 1990 年代的硬件设定的——今天的 CPU 可以在 DP 下处理更多表。geqo_effort(默认 5,范围 1-10)控制种群大小和代数,但 GEQO 的结果天然不稳定——同一查询可能两次得到不同计划。

关键运维事实:当查询涉及 12+ 个表时,你看到的执行计划不一定是最优的,只是 GEQO 在有限代数内找到的较优解。如果 12 表查询的性能不稳定,考虑降低 join_collapse_limit 或将部分 Join 编码为子查询/CTE 来减少搜索空间。

join_collapse_limit 与 from_collapse_limit

这些 GUC 限制优化器重排 Join 顺序的范围。默认值(与 geqo_threshold 相同)让优化器自由重排所有显式 Join 和 FROM 列表中的表,但保留显式括号的语义。当这些值设为 1 时,优化器完全按照 SQL 中写的 Join 顺序执行——这是排查”优化器选错顺序”时的有用工具。


五、并行路径生成

当查询涉及大表扫描或大 Join 时,PG 可以为路径生成并行版本。入口在 set_plain_rel_pathlist() 中:

// src/backend/optimizer/path/allpaths.c, set_plain_rel_pathlist()
if (rel->pages > parallel_threshold) {  // min_parallel_table_scan_size
    create_plain_partial_paths(root, rel);
}
```text

`create_plain_partial_paths()` 生成 **partial path**(部分路径)——表示可以由一个并行 worker 执行的那部分扫描。这些路径被加入 `rel->partial_pathlist`,然后在 Join 路径生成时,优化器可以将 partial path 与 `Gather`/`Gather Merge` 节点组合,生成并行执行计划。

并行 Join 的三种方式:
- **Parallel Hash Join**:多个 worker 共享一个 hash 表(`SharedHashInfo`),每个 worker 并行扫描外表并与共享 hash 表做 probe。
- **Parallel Merge Join**:每个 worker 并行扫描外表的一部分(数据已排序并分区),与共享的内表扫描指针做 merge。
- **Parallel Nested Loop**:外表被分区,每个 worker 扫描一个分区并独立执行内表索引查找。

`parallel_setup_cost` 和 `parallel_tuple_cost` 分别控制启动并行 worker 和 worker 间传递 tuple 的代价估算。

---

## 六、实验:EXPLAIN 输出与 Planner 决策对照

### 实验环境

```bash
# PG 17, shared_buffers=512MB, work_mem=4MB, random_page_cost=4.0

1. 构造三表 Join 和索引选择

CREATE TABLE orders (
    order_id   INTEGER PRIMARY KEY,
    customer_id INTEGER,
    order_date DATE,
    amount     NUMERIC
);

CREATE TABLE customers (
    customer_id INTEGER PRIMARY KEY,
    name        TEXT,
    region      TEXT
);

CREATE TABLE products (
    product_id  INTEGER PRIMARY KEY,
    order_id    INTEGER,
    product_name TEXT
);

-- 灌入数据
INSERT INTO orders SELECT g, (g % 1000) + 1,
    '2025-01-01'::DATE + (g % 365),
    (random() * 1000)::NUMERIC(10,2)
FROM generate_series(1, 100000) g;

INSERT INTO customers SELECT g, 'customer_' || g,
    CASE WHEN g % 4 = 0 THEN 'East' ELSE 'West' END
FROM generate_series(1, 1000) g;

INSERT INTO products SELECT g, (g % 100000) + 1, 'product_' || g
FROM generate_series(1, 200000) g;

-- 创建外键索引(products 表)
CREATE INDEX idx_products_order ON products(order_id);

-- ANALYZE
ANALYZE orders;
ANALYZE customers;
ANALYZE products;
```bash

### 2. 观察三表 Join 的计划选择

```sql
EXPLAIN (ANALYZE, BUFFERS, TIMING)
SELECT c.name, o.order_date, p.product_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
JOIN products p ON o.order_id = p.order_id
WHERE c.region = 'West';

输出解读:

```text Nested Loop (cost=0.29..8912.45 rows=1500 width=...) (actual time=0.045..18.234 rows=150000 loops=1) Buffers: shared hit=4623 -> Seq Scan on customers c (cost=0.00..19.00 rows=750 width=...) (actual time=0.012..0.284 rows=750 loops=1) Filter: (region = 'West') Buffers: shared hit=13 -> Nested Loop (cost=0.29..11.82 rows=2 width=...) (actual time=0.008..0.022 rows=200 loops=750) Buffers: shared hit=4610 -> Index Scan using orders_customer_id_idx on orders o (cost=0.29..4.31 rows=1 width=...) (actual time=0.004..0.006 rows=100 loops=750) Index Cond: (customer_id = c.customer_id) Buffers: shared hit=1360 -> Index Scan using idx_products_order on products p (cost=0.42..7.50 rows=2 width=...) (actual time=0.003..0.008 rows=200 loops=75000) Index Cond: (order_id = o.order_id) Buffers: shared hit=3250text

对照分析

  1. 最外层 Seq Scan on customers——customers 表只有 1000 行,SEQSCAN 扫描 13 个 buffer(8KB × 13 ≈ 104KB),optimizer 认为选索引不如直接扫。region = 'West' 过滤掉 25%(750 行),优化器估算准确(rows=750)。

  2. 内层 Nested Loop——外表 customers 的 750 行作为驱动表,每行触发一次 orders_customer_id_idx 索引扫描(loops=750)。每个 customer_id 平均匹配 100 个订单(因为 100000 orders / 1000 customers = 100),所以 actual rows=100 per loop。

  3. 最内层 Nested Loop——上一步返回的每行订单(750 × 100 = 75000),再触发一次 idx_products_order 索引扫描。每个 order_id 平均匹配 2 个产品(200000 products / 100000 orders = 2),所以 actual rows=200 per loop,总 loops=75000

  4. 优化器为什么选 Nested Loop 而不是 Hash Join? 因为 customersWHERE region = 'West' 过滤后只有 750 行(估算准确),作为驱动表很划算。内表的每次索引扫描代价很低。如果优化器错误估算 customers 的行数(比如统计信息过时导致认为只有 10 行),可能选 Hash Join 更优。

3. 强制观察 Hash Join 和 Merge Join

-- 强制禁用特定 join 方式来观察替代计划
SET enable_nestloop = off;

EXPLAIN (ANALYZE, BUFFERS)
SELECT c.name, o.order_date, p.product_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
JOIN products p ON o.order_id = p.order_id
WHERE c.region = 'West';

-- 观察计划是否变为 Hash Join
-- 恢复
SET enable_nestloop = on;
-- 有排序需求时观察 Merge Join
EXPLAIN (ANALYZE, BUFFERS)
SELECT c.name, o.order_date, p.product_name
FROM customers c
JOIN orders o ON c.customer_id = o.customer_id
JOIN products p ON o.order_id = p.order_id
ORDER BY c.customer_id, o.order_id;
-- customer_id 和 order_id 都有索引,Merge Join 可能直接利用排序
```bash

### 4. 触发 GEQO

```sql
-- 查看当前 geqo_threshold
SHOW geqo_threshold;  -- 默认 12

-- 修改为较低值来触发 GEQO
SET geqo_threshold = 3;

EXPLAIN SELECT * FROM
  (SELECT generate_series(1,100) AS a) t1,
  (SELECT generate_series(1,100) AS b) t2,
  (SELECT generate_series(1,100) AS c) t3,
  (SELECT generate_series(1,100) AS d) t4
WHERE t1.a = t2.b AND t2.b = t3.c AND t3.c = t4.d;

-- 注意 EXPLAIN 输出中不再保证最优 Join 顺序
-- 恢复
RESET geqo_threshold;

5. 观察并行查询

-- 确保并行查询已启用
SET max_parallel_workers_per_gather = 4;

EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM orders WHERE amount > 900;

-- 输出可能包含 Gather 节点:
-- Gather  (cost=1000.00..1500.00 rows=10000 width=8)
--   Workers Planned: 2
--   ->  Partial Aggregate
--         ->  Parallel Seq Scan on orders
```text

---

## 七、运维:路径选择的常见问题与排查

### 1. 优化器不选索引:random_page_cost 过高

**现象**:表上建了索引,`EXPLAIN` 仍然显示 Seq Scan。

**根因**:`random_page_cost` 默认 4.0,意味着优化器假设一次随机页面读取的成本是一次顺序页面读取的 4 倍。在 NVMe SSD 上,这个假设严重高估了随机读的成本,导致优化器认为 SeqScan(全顺序读)比 IndexScan(随机读 + 回表)更便宜。

**排查**

```sql
-- 查看当前配置
SHOW random_page_cost;

-- 用 pg_test_fsync 实测磁盘随机 vs 顺序读的性能比
-- 然后按比例设置。NVMe 上通常 1.0-1.5 合理
ALTER SYSTEM SET random_page_cost = 1.5;
SELECT pg_reload_conf();

2. 统计信息漂移导致 Join 顺序错乱

现象:曾经很快的查询突然变慢,EXPLAIN 显示 Join 顺序变了或选错了 Join 方式。

根因:大表频繁插入/删除后统计信息过期。pg_stat_user_tables.n_mod_since_analyze 过大,优化器对行数和选择率的估算严重偏离实际。

排查

-- 查看统计信息的新鲜度
SELECT relname, n_live_tup, n_dead_tup, n_mod_since_analyze,
       last_analyze, last_autoanalyze
FROM pg_stat_user_tables
WHERE relname IN ('orders', 'customers', 'products');

-- 如果 n_mod_since_analyze 远大于 n_live_tup * autovacuum_analyze_scale_factor(默认 0.1)
-- 说明 auto-analyze 没有及时触发或阈值太高
ANALYZE orders;  -- 立即更新统计信息
```bash

### 3. work_mem 与 Hash Join 溢出的互相影响

**现象**:`EXPLAIN (ANALYZE, BUFFERS)` 中 Hash Join 的 `Batches:` 大于 1,查询比预期慢很多。

**根因**:`work_mem` 太小(或并发连接太多,每连接的实际可用内存远小于 `work_mem`),Hash Joinhash 表装不下内存,溢出到磁盘批次处理。每次溢出产生额外的磁盘 I/O——内表数据被多次写入和读出临时文件。

**排查**

```sql
EXPLAIN (ANALYZE, BUFFERS, SETTINGS)
SELECT ...;

-- 观察 Hash Join 节点的输出:
-- Hash Join  (cost=...) (actual time=...)
--   Hash Cond: (...)
--   Buckets: 16384  Batches: 8  Memory Usage: 2049kB
--   ↑ Batches > 1 表示发生了溢出

-- 溢出时增加 work_mem:
SET work_mem = '64MB';  -- 仅当前 session
-- 或全局:
-- ALTER SYSTEM SET work_mem = '64MB';

注意 work_mem 的危险性:设太大的 work_mem + 高并发 = 后端内存爆炸。hash_mem_multiplier(PG 15+)给 hash 操作额外的乘数,work_mem * hash_mem_multiplier 才是 hash 操作能用的内存上限。

4. 多表 Join 计划不稳定(GEQO 区)

现象:12+ 表 Join 的查询,每次 EXPLAIN 输出可能不同,执行时间在几秒到几十秒之间波动。

根因geqo_threshold 默认为 12,超过此数量的表使用 GEQO 遗传算法搜索 Join 顺序——遗传算法不保证找到最优解,每次运行可能产生不同的计划。

排查与缓解

```sql – 方案 A:提高 geqo_threshold 让更多表走 DP SET geqo_threshold = 15; – 小心:DP 的复杂度是 Catalan 数

– 方案 B:降低 join_collapse_limit 限制 DP 搜索空间 SET join_collapse_limit = 8;

– 方案 C:将部分 Join 编码为子查询或 CTE WITH sub AS ( SELECT … FROM t1 JOIN t2 … WHERE … ) SELECT … FROM sub JOIN t3 …;

– 方案 D:固定部分 Join 顺序(显式括号 + join_collapse_limit=1) – 让优化器按 SQL 书写的顺序执行 ```text


八、关键要点

  1. make_one_rel() 是 Join 路径生成的调度中心。先为每个基表生成扫描路径(set_base_rel_pathlists()),然后按 joinlist 逐级构造 joinrel 并生成 Join 路径(make_rel_from_joinlist())。

  2. 四种扫描路径各有用武之地:SeqScan 无条件存在,IndexScan 依赖索引和选择率估算,IndexOnlyScan 需要索引覆盖且依赖 VM all-visible,BitmapScan 适合中等过滤率和多索引 OR 组合。

  3. 三种 Join 方式的选择来自代价比较而非硬规则:Nested Loop 适合驱动表小 + 内表有索引;Hash Join 适合等值 Join + 内表能装进 work_mem;Merge Join 适合大表且至少一个表已排序。

  4. GEQO 在 geqo_threshold(默认 12)个表时接管——遗传算法不保证最优,可能导致计划不稳定。超出此阈值时排查计划波动是 GEQO 而非统计信息的问题。

  5. EXPLAIN (ANALYZE, BUFFERS) 是 plan 决策的”事后解剖”actual rows vs plan rows 的差异暴露统计信息问题,Batches > 1 暴露 Hash Join 内存溢出,Buffers: read 暴露磁盘 I/O。

下一章:执行器与表达式求值,拆解火山模型的 ExecProcNode 调度、EEO (Expression Evaluation) 的 opcode 编译和 TupleTableSlot 机制。

上一章:查询规划器 — 统计信息与代价模型


参考资料

源码(PG 17)

官方文档

论文

同主题继续阅读

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

2026-06-16 · database / kernel

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

拆解 PostgreSQL 执行器的火山模型(ExecInitNode→ExecProcNode→ExecEndNode)、Hash Join 内存化实现、EEO 表达式求值的 opcode 编译与解释执行机制、TupleTableSlot 的三种数据承载方式(virtual/heap/minimal)。附带查询 hang 住的完整诊断路径:pg_stat_activity 的 wait_event + pg_blocking_pids() 追踪锁等待链 + EXPLAIN ANALYZE 计划行数与实际行数差异定位。

2026-06-16 · database / kernel

【PG 内核】查询规划器 — 统计信息与代价模型:优化器为什么选错了索引

拆解 PostgreSQL 查询优化器的决策基础:pg_statistic 中 MCV/histogram/correlation 的存储结构、ANALYZE 的采样流程与精度边界、clauselist_selectivity 如何逐层估算选择率、seq_page_cost 等代价常量的物理意义与调优依据、CREATE STATISTICS 解决多列相关性问题、以及统计信息漂移的诊断 SQL 与排查路径。读完你能回答:优化器为什么选 Seq Scan 而不是你建的索引,以及怎么定位根因。

2026-06-16 · database / kernel

【PG 内核】PostgreSQL 内核机制深度拆解

从进程模型到磁盘页面、从 MVCC 到流复制——对 PostgreSQL 内核做完整的源码级拆解。不止步于源码分析:26 篇中 6 篇是运维实战——经典故障的根因与排查路径、性能调查的五层工具链、配置陷阱与恢复边界。面向想读懂 PG 内核源码、在生产环境排查过问题、准备给 PG 贡献代码的工程师。

2026-06-16 · database / kernel

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

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


By .