mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1840 字
5 分钟
PostgreSQL 查询优化器:如何选择最优执行计划
2023-01-26

前言#

当你向 PostgreSQL 发送一条 SELECT * FROM orders WHERE user_id = 42 时,数据库内部经历了怎样的旅程才返回结果?与 MySQL 不同,PostgreSQL 的优化器采用了截然不同的架构设计,其代价模型和统计信息系统更加精密。本文将深入 PostgreSQL 查询处理的全流程,揭示优化器选择执行计划的核心机制。

PostgreSQL 查询处理全流程#

flowchart TB subgraph 客户端 A[Client Application] end subgraph PostgreSQL Server B[Parser<br/>语法解析] --> C[Analyzer<br/>语义分析] C --> D[Rewriter<br/>查询重写] D --> E[Planner<br/>计划生成] E --> F[Executor<br/>执行器] end subgraph 存储层 G[Shared Buffer Pool] H[Table Files] I[WAL] end A --> B F --> G G --> H G --> I

整个流程可以归纳为五个阶段:

SQL 文本
┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
│ Parser │───▶│ Analyzer │───▶│ Rewriter │───▶│ Planner │───▶│ Executor │
│ 语法解析 │ │ 语义分析 │ │ 查询重写 │ │ 计划生成 │ │ 执行器 │
└──────────┘ └──────────┘ └──────────┘ └──────────┘ └──────────┘
生成 Parse 生成 Query 应用 RULE 生成 Plan 执行 Plan
Tree Tree 系统 Tree

一、Parser:语法解析#

1.1 词法分析与语法分析#

PostgreSQL 使用手写的递归下降解析器,将 SQL 文本转换为语法树(Parse Tree)。

flowchart LR A["SQL 文本"] --> B[词法分析器<br/>scan.l] B --> C["Token 流"] C --> D[语法分析器<br/>gram.y] D --> E["Parse Tree"]

源码入口在 src/backend/parser/parser.c

// src/backend/parser/parser.c - 简化
List *
raw_parser(const char *str, RawParseMode mode)
{
// 初始化词法分析器
yyscan_t scanner;
core_yyscan_t yyscanner;
yyscanner = scanner_init(str, &yyextra.core_yy_extra,
ScanKeywords, NumScanKeywords);
// 调用语法分析器
if (base_yyparse(yyscanner) != 0)
// 语法错误处理...
return yyextra.parsetree;
}

示例:解析 SELECT name FROM users WHERE id = 1

Parse Tree (简化表示):
┌─────────────────────────────────────┐
│ SelectStmt │
│ ├── targetList: │
│ │ └── ResTarget: name │
│ ├── fromClause: │
│ │ └── RangeVar: users │
│ └── whereClause: │
│ └── A_Expr: (=) │
│ ├── ColumnRef: id │
│ └── A_Const: 1 │
└─────────────────────────────────────┘

1.2 词法分析的 Token 分类#

SQL: SELECT name FROM users WHERE id = 1
Token 流:
┌──────────┬────────────┬──────────────────┐
│ Token │ 类型 │ 说明 │
├──────────┼────────────┼──────────────────┤
│ SELECT │ 关键字 │ SQL 命令标识 │
│ name │ 标识符 │ 列名引用 │
│ FROM │ 关键字 │ 子句标识 │
│ users │ 标识符 │ 表名引用 │
│ WHERE │ 关键字 │ 条件子句 │
│ id │ 标识符 │ 列名引用 │
│ = │ 操作符 │ 比较运算 │
│ 1 │ 整数常量 │ 字面值 │
└──────────┴────────────┴──────────────────┘

二、Analyzer:语义分析#

2.1 分析流程#

语义分析阶段(也称 Parse Analysis)将 Parse Tree 转换为 Query Tree。这个阶段的核心逻辑在 src/backend/parser/analyze.c

flowchart TB A[Parse Tree] --> B[解析 RangeVar<br/>确定表对象] B --> C[构建 Range Table<br/>范围表] C --> D[解析目标列<br/>确定列类型] D --> E[解析 WHERE 条件<br/>类型检查] E --> F[权限检查] F --> G[Query Tree]

2.2 范围表(Range Table)#

范围表是 PostgreSQL 查询树中的核心数据结构,记录查询涉及的所有关系(表、视图、子查询等):

// src/include/nodes/parsenodes.h - 简化
typedef struct RangeTblEntry
{
NodeTag type;
RTEKind rtekind; // 关系类型: 表、子查询、函数等
Oid relid; // 表的 OID
char *relalias; // 别名
Alias *eref; // 有效列名列表
bool inh; // 是否继承子表
AclMode requiredPerms; // 所需权限
} RangeTblEntry;

Query Tree 核心结构

// src/include/nodes/parsenodes.h - 简化
typedef struct Query
{
NodeTag type;
CmdType commandType; // SELECT, INSERT, UPDATE, DELETE
List *rtable; // 范围表
List *targetList; // 目标列
Node *jointree; // 连接树 (FROM + WHERE)
List *groupClause; // GROUP BY
Node *havingQual; // HAVING
List *sortClause; // ORDER BY
Node *limitOffset; // OFFSET
Node *limitCount; // LIMIT
} Query;

2.3 语义分析过程#

sequenceDiagram participant P as Parse Tree participant A as Analyzer participant S as System Catalog participant Q as Query Tree P->>A: 输入 SelectStmt A->>S: 查找表 users 的 OID S-->>A: 返回 pg_class 记录 A->>S: 获取表的列信息 S-->>A: 返回 pg_attribute 列表 A->>A: 解析列名 name, id A->>A: 类型检查 (id = 1) A->>S: 检查 SELECT 权限 S-->>A: 权限确认 A->>Q: 生成 Query Tree

系统目录查询

-- 查看表信息 (等价于 Analyzer 的查询)
SELECT oid, relname, relnamespace, relkind
FROM pg_class WHERE relname = 'users';
-- 查看列信息
SELECT attname, atttypid, attnotnull, atthasdef
FROM pg_attribute
WHERE attrelid = 'users'::regclass AND attnum > 0;
-- 查看表权限
SELECT has_table_privilege('users', 'SELECT');

三、Rewriter:查询重写#

3.1 RULE 系统#

PostgreSQL 拥有独特的 RULE 系统,可以在查询重写阶段修改查询。重写器入口在 src/backend/rewrite/rewriteHandler.c

flowchart TB A[Query Tree] --> B{查询目标是否<br/>有 RULE?} B -->|有 INSTEAD 规则| C[替换为规则查询] B -->|有 ALSO 规则| D[追加规则查询] B -->|无规则| E[保持原查询] C --> F[递归处理] D --> F E --> F F --> G[重写后的 Query Tree]

3.2 视图展开#

视图查询重写是最常见的场景。当你查询一个视图时,重写器会将视图引用替换为视图定义:

-- 创建视图
CREATE VIEW active_users AS
SELECT id, name, email FROM users WHERE status = 'active';
-- 查询视图
SELECT name FROM active_users WHERE id > 100;

重写过程

flowchart TB A["SELECT name FROM active_users WHERE id > 100"] --> B[发现 active_users 是视图] B --> C[获取视图定义] C --> D["替换为: SELECT name FROM (SELECT id, name, email FROM users WHERE status = 'active') WHERE id > 100"] D --> E[合并条件] E --> F["最终: SELECT name FROM users WHERE status = 'active' AND id > 100"]

3.3 重写示例:行安全策略#

-- 启用行级安全
ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
-- 创建策略:用户只能看自己的订单
CREATE POLICY user_orders ON orders
USING (user_id = current_user_id());
-- 当执行 SELECT * FROM orders 时
-- 重写器自动追加条件
-- 等价于: SELECT * FROM orders WHERE user_id = current_user_id()

四、Planner:计划生成(逻辑优化)#

4.1 优化器架构#

PostgreSQL 的优化器分为逻辑优化和物理优化两个层次。入口在 src/backend/optimizer/plan/planner.c

flowchart TB A[重写后的 Query Tree] --> B[预处理] B --> C[子链接处理] C --> D[表达式简化] D --> E[连接预处理] E --> F[路径生成] F --> G[代价计算] G --> H[选择最优路径] H --> I[生成 Plan Tree]
// src/backend/optimizer/plan/planner.c - 简化
PlannedStmt *
planner(Query *parse, const char *query_string,
int cursorOptions, ParamListInfo boundParams)
{
PlannedStmt *result;
// 核心规划入口
result = standard_planner(parse, query_string,
cursorOptions, boundParams);
return result;
}

PostgreSQL 会尝试将子查询(SubLink)提升为半连接(Semi Join),以便优化器能选择更优的连接算法:

-- 原始查询
SELECT * FROM orders
WHERE user_id IN (SELECT id FROM users WHERE status = 'active');
-- 提升为 Semi Join
SELECT orders.*
FROM orders SEMI JOIN users
ON orders.user_id = users.id AND users.status = 'active';
flowchart TB subgraph 提升前 A1[Seq Scan on orders] --> A2[Filter: SubLink] A2 --> A3[Seq Scan on users] end subgraph 提升后 B1[Hash Semi Join] B1 --> B2[Seq Scan on orders] B1 --> B3[Hash on users.id] B3 --> B4[Seq Scan on users] end

4.3 谓词下推(Predicate Pushdown)#

将过滤条件尽可能下推到扫描节点,减少上层处理的数据量:

SELECT o.*, u.name
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE u.status = 'active' AND o.amount > 100;

优化过程

优化前 (逻辑表示):
┌─────────────────────────────┐
│ Filter: u.status='active' │
│ AND o.amount > 100 │
│ ┌────────────────────────┐ │
│ │ Join ON o.user_id=u.id │ │
│ │ ├── Seq Scan: orders │ │
│ │ └── Seq Scan: users │ │
│ └────────────────────────┘ │
└─────────────────────────────┘
优化后 (谓词下推):
┌─────────────────────────────┐
│ Join ON o.user_id = u.id │
│ ├── Filter: amount > 100 │
│ │ └── Seq Scan: orders │
│ └── Filter: status='active'│
│ └── Seq Scan: users │
└─────────────────────────────┘

4.4 连接消除(Join Elimination)#

当连接不会影响结果时,优化器会消除多余的连接:

-- orders 表有外键约束: user_id REFERENCES users(id)
-- 如果查询不使用 users 的任何列
SELECT o.*
FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.amount > 1000;
-- 优化器检测到:
-- 1. users 的列未被使用
-- 2. 外键保证 user_id 一定存在
-- 消除连接:
SELECT * FROM orders WHERE amount > 1000;

4.5 DISTINCT 消除#

当优化器能证明结果已经唯一时,会移除 DISTINCT:

-- 主键列 DISTINCT 无意义
SELECT DISTINCT id, name FROM users;
-- 等价于
SELECT id, name FROM users;

五、Planner:计划生成(物理优化)#

5.1 代价模型#

PostgreSQL 的代价模型基于磁盘 I/O 和 CPU 消耗。代价单位不是时间,而是相对权重。核心实现在 src/backend/optimizer/path/costsize.c

总代价 = 启动代价 + 运行代价
启动代价: 获取第一行之前的代价
运行代价: 获取所有剩余行的代价
总代价: 启动代价 + 运行代价

代价参数

-- 查看代价参数
SHOW seq_page_cost; -- 顺序读一个页的代价 (默认 1.0)
SHOW random_page_cost; -- 随机读一个页的代价 (默认 4.0)
SHOW cpu_tuple_cost; -- 处理一行的 CPU 代价 (默认 0.01)
SHOW cpu_index_tuple_cost; -- 处理一个索引项的代价 (默认 0.005)
SHOW cpu_operator_cost; -- 执行一个操作符的代价 (默认 0.0025)
SHOW effective_cache_size; -- 预期可用的操作系统缓存 (默认 4GB)

代价计算示例

顺序扫描 10000 行的表(1000 页):
startup_cost = 0
run_cost = seq_page_cost * pages + cpu_tuple_cost * tuples
= 1.0 * 1000 + 0.01 * 10000
= 1000 + 100
= 1100
索引扫描选择 10 行(B+树深度 3):
startup_cost = 随机 I/O 读树节点
= random_page_cost * tree_height
= 4.0 * 3
= 12
run_cost = random_page_cost * data_pages + cpu_tuple_cost * tuples
= 4.0 * 10 + 0.01 * 10
= 40 + 0.1
= 40.1
total_cost = 12 + 40.1 = 52.1

5.2 统计信息#

PostgreSQL 依靠 pg_statistic 系统表中的统计信息来估算选择率和行数。统计信息由 ANALYZE 命令收集:

-- 手动收集统计信息
ANALYZE users;
-- 查看统计信息
SELECT attname, n_distinct, null_frac,
avg_width, most_common_vals, most_common_freqs,
histogram_bounds, correlation
FROM pg_stats
WHERE tablename = 'users';

统计信息字段含义

┌───────────────────┬────────────────────────────────────────────┐
│ 字段 │ 说明 │
├───────────────────┼────────────────────────────────────────────┤
│ n_distinct │ 不重复值的估计数量 │
│ null_frac │ NULL 值的比例 │
│ avg_width │ 列的平均字节宽度 │
│ most_common_vals │ 最常见值列表 (MCV) │
│ most_common_freqs │ 最常见值的频率列表 │
│ histogram_bounds │ 等深直方图的边界值 │
│ correlation │ 物理顺序与逻辑顺序的相关性 │
└───────────────────┴────────────────────────────────────────────┘

统计信息如何影响代价

flowchart TB A["WHERE status = 'active'"] --> B[查找 MCV 列表] B --> C{status 在 MCV 中?} C -->|是| D[使用 most_common_freqs<br/>选择率 = 0.7] C -->|否| E[使用直方图估算<br/>选择率 = 1/ndistinct] D --> F[估算行数 = 总行数 × 选择率] E --> F

correlation 的作用

correlation 接近 1.0: 物理存储顺序与索引顺序一致
→ 索引扫描的 I/O 更顺序化
→ 实际 random_page_cost 更低
correlation 接近 0.0: 物理存储随机
→ 索引扫描需要大量随机 I/O
→ 可能不如顺序扫描
correlation 接近 -1.0: 物理存储与索引顺序相反
→ 类似接近 0 的情况

5.3 扫描方式选择#

PostgreSQL 支持多种扫描方式,优化器根据统计信息和代价模型选择最优方式:

flowchart TB A[选择扫描方式] --> B{有可用索引?} B -->|否| C[Sequential Scan<br/>顺序扫描] B -->|是| D{选择率高?} D -->|高 返回大部分行| C D -->|低 返回少量行| E{索引条件?} E -->|等值/范围| F[Index Scan<br/>索引扫描] E -->|多个条件组合| G[Bitmap Scan<br/>位图扫描] E -->|TID 条件| H[TID Scan<br/>TID 扫描]

顺序扫描(Sequential Scan)#

最简单的扫描方式,逐页读取整个表:

EXPLAIN SELECT * FROM users;
-- 结果:
-- Seq Scan on users (cost=0.00..15.00 rows=1000 width=68)

代价计算逻辑(cost_seqscan):

// 简化的代价计算
void cost_seqscan(Path *path, PlannerInfo *root,
RelOptInfo *baserel)
{
double pages = baserel->pages;
double tuples = baserel->tuples;
// I/O 代价: 读所有页
run_cost += seq_page_cost * pages;
// CPU 代价: 处理每一行
run_cost += cpu_tuple_cost * tuples;
}

索引扫描(Index Scan)#

通过 B+ 树索引查找数据,适合返回少量行的查询:

-- 假设 users.id 有主键索引
EXPLAIN SELECT * FROM users WHERE id = 42;
-- 结果:
-- Index Scan using users_pkey on users
-- (cost=0.28..8.29 rows=1 width=68)
-- Index Cond: (id = 42)
B+ 树索引查找过程:
┌─────────────┐
│ Root Page │ Level 2
│ [10|20|30] │
└──────┬───────┘
┌────┼────────────┐
▼ ▼ ▼
┌────┐ ┌────┐ ┌────┐ Level 1
│<10 │ │10-20│ │20-30│ (Internal)
└──┬─┘ └──┬─┘ └──┬──┘
│ │ │
▼ ▼ ▼
[Leaf] [Leaf] [Leaf] Level 0
指向 指向 指向 (Leaf)
CTID CTID CTID
→ 表行 → 表行 → 表行

位图扫描(Bitmap Scan)#

当查询条件涉及多个索引或需要返回较多行时,PostgreSQL 使用位图扫描:

-- 假设有 idx_name(name) 和 idx_age(age) 两个索引
EXPLAIN SELECT * FROM users
WHERE name = 'Alice' AND age > 25;
-- 结果:
-- Bitmap Heap Scan on users
-- (cost=8.50..25.30 rows=5 width=68)
-- Recheck Cond: (name = 'Alice' AND age > 25)
-- -> BitmapAnd
-- -> Bitmap Index Scan on idx_name
-- Index Cond: (name = 'Alice')
-- -> Bitmap Index Scan on idx_age
-- Index Cond: (age > 25)
flowchart TB subgraph "Bitmap Scan 流程" A[索引扫描 idx_name] --> C["Bitmap AND"] B[索引扫描 idx_age] --> C C --> D[Bitmap Heap Scan] D --> E[按物理顺序<br/>访问数据页] end

位图扫描 vs 索引扫描

索引扫描: 适合返回极少行 (< 表的 1-5%)
每找到一行就回表访问
位图扫描: 适合返回较多行 (5-20%)
先构建位图,再按物理顺序批量回表
减少随机 I/O
顺序扫描: 适合返回大量行 (> 20%)
直接全表扫描更高效

六、连接算法#

6.1 Nested Loop Join#

最基本的连接算法。对外表的每一行,扫描内表寻找匹配:

flowchart TB A[遍历外表每一行] --> B{外表当前行} B --> C[扫描内表] C --> D{匹配连接条件?} D -->|是| E[输出连接结果] D -->|否| F[继续扫描内表] F --> C E --> G{内表扫描完?} G -->|是| H{外表还有行?} H -->|是| B H -->|否| I[连接完成] G -->|否| C

代价公式:

Nested Loop 代价:
startup_cost = outer_startup_cost
run_cost = outer_total_cost +
inner_cost_per_row × outer_rows
如果内表有索引:
inner_cost_per_row = 索引查找代价 (较低)
如果内表无索引:
inner_cost_per_row = 内表全表扫描代价 (极高)

适用场景:外表小,内表有索引。

6.2 Merge Join#

两个表按连接键排序后进行归并连接:

sequenceDiagram participant O as 外表 (已排序) participant I as 内表 (已排序) participant R as 结果 Note over O,I: 两个游标同时前进 O->>O: 读取当前行 outer_row I->>I: 读取当前行 inner_row loop 直到某一表读完 alt outer_key < inner_key O->>O: 前进到下一行 else outer_key > inner_key I->>I: 前进到下一行 else outer_key == inner_key R->>R: 输出匹配行 I->>I: 前进到下一行 end end
Merge Join 示例 (连接键 = id):
外表 (按 id 排序): 内表 (按 id 排序):
id | name id | order_no
---+------ ---+---------
1 | Alice 1 | ORD-001
3 | Bob 2 | ORD-002
5 | Carol 3 | ORD-003
5 | ORD-004
归并过程:
外表 id=1, 内表 id=1 → 匹配 → 输出
外表 id=3, 内表 id=2 → 内表前进
外表 id=3, 内表 id=3 → 匹配 → 输出
外表 id=5, 内表 id=5 → 匹配 → 输出

适用场景:数据已排序或需要排序后连接。

6.3 Hash Join#

PostgreSQL 中最常用的等值连接算法,尤其适合大表连接:

flowchart TB subgraph "阶段 1: Build" A[扫描内表 (较小的表)] --> B[计算 Hash 值] B --> C[构建 Hash Table] end subgraph "阶段 2: Probe" D[扫描外表 (较大的表)] --> E[计算 Hash 值] E --> F[在 Hash Table 中查找] F --> G{匹配?} G -->|是| H[输出连接结果] G -->|否| I[跳过] end C --> F

代价公式:

Hash Join 代价:
startup_cost = inner_total_cost + hash_build_cost
run_cost = (outer_total_cost - outer_startup_cost) +
cpu_hash_cost × outer_rows +
cpu_tuple_cost × result_rows
Hash Table 构建代价:
hash_build_cost = cpu_operator_cost × inner_rows
EXPLAIN SELECT * FROM orders o
JOIN users u ON o.user_id = u.id;
-- 结果:
-- Hash Join
-- Hash Cond: (o.user_id = u.id)
-- -> Seq Scan on orders
-- -> Hash
-- -> Seq Scan on users

三种连接算法对比

┌───────────────┬──────────────────┬───────────────────┬──────────────────┐
│ 算法 │ 适用场景 │ 时间复杂度 │ 内存需求 │
├───────────────┼──────────────────┼───────────────────┼──────────────────┤
│ Nested Loop │ 小表 + 索引内表 │ O(M × log N) │ 低 │
│ Merge Join │ 已排序数据 │ O(M + N) │ 中 (排序) │
│ Hash Join │ 大表等值连接 │ O(M + N) │ 高 (Hash Table) │
└───────────────┴──────────────────┴───────────────────┴──────────────────┘
M = 外表行数, N = 内表行数

七、并行查询#

7.1 并行查询架构#

PostgreSQL 从 9.6 开始支持并行查询,采用多进程模型(而非多线程):

flowchart TB A[Leader 进程] --> B[Gather 节点] B --> C[Worker 1] B --> D[Worker 2] B --> E[Worker 3] C --> F[Parallel Seq Scan<br/>处理块 1-33] D --> G[Parallel Seq Scan<br/>处理块 34-66] E --> H[Parallel Seq Scan<br/>处理块 67-100]

7.2 并行扫描#

Parallel Sequential Scan:将表的页面块分配给多个 Worker 并行扫描:

SET max_parallel_workers_per_gather = 4;
EXPLAIN SELECT count(*) FROM orders;
-- 结果:
-- Finalize Aggregate
-- -> Gather
-- Workers Planned: 3
-- -> Partial Aggregate
-- -> Parallel Seq Scan on orders

并行扫描的协调机制:

// src/backend/access/transam/parallel.c - 简化
// Worker 通过共享内存协调扫描进度
typedef struct ParallelBlockTableScanDescData
{
BlockNumber phs_nblocks; // 表的总块数
pg_atomic_uint64 phs_startblock; // 起始块
pg_atomic_uint64 phs_nallocated; // 已分配的块数
} ParallelBlockTableScanDescData;
// 每个 Worker 获取下一个要扫描的块
BlockNumber
parallel_seqscan_get_next(ParallelBlockTableScanDesc pscan)
{
// 原子地递增已分配块数
return pg_atomic_fetch_add_u64(&pscan->phs_nallocated, 1);
}

7.3 并行连接#

EXPLAIN SELECT * FROM orders o
JOIN users u ON o.user_id = u.id
WHERE o.amount > 1000;
-- 结果:
-- Gather Merge
-- Workers Planned: 2
-- -> Merge Join
-- Merge Cond: (o.user_id = u.id)
-- -> Sort
-- -> Parallel Seq Scan on orders o
-- Filter: (amount > 1000)
-- -> Index Scan using users_pkey on users u
flowchart TB A[Gather Merge] --> B[Worker 1: Merge Join] A --> C[Worker 2: Merge Join] A --> D[Worker 3: Merge Join] B --> E[Parallel Scan<br/>orders 块 1-33] C --> F[Parallel Scan<br/>orders 块 34-66] D --> G[Parallel Scan<br/>orders 块 67-100] E --> H[Sort] F --> I[Sort] G --> J[Sort] H --> K[Merge Join with<br/>Index Scan users] I --> K J --> K

7.4 并行相关参数#

-- 最大并行 Worker 数
SHOW max_parallel_workers; -- 默认 8
-- 每个 Gather 节点的最大 Worker 数
SHOW max_parallel_workers_per_gather; -- 默认 2
-- 并行扫描的最小表大小 (8MB)
SHOW min_parallel_table_scan_size; -- 默认 8MB
-- 是否启用并行 Append
SHOW enable_parallel_append; -- 默认 on
-- 并行 Worker 的代价因子
SHOW parallel_tuple_cost; -- 默认 0.1
SHOW parallel_setup_cost; -- 默认 1000.0

八、执行计划解读#

8.1 EXPLAIN 输出分析#

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT u.name, count(*) as order_count
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.status = 'active'
GROUP BY u.name
ORDER BY order_count DESC
LIMIT 10;
Limit (cost=35.80..35.82 rows=10 width=24)
(actual time=0.152..0.155 rows=10 loops=1)
Buffers: shared hit=15
-> Sort (cost=35.80..35.85 rows=20 width=24)
(actual time=0.151..0.153 rows=10 loops=1)
Sort Key: count(*) DESC
Sort Method: top-N heapsort Memory: 25kB
Buffers: shared hit=15
-> HashAggregate (cost=35.50..35.70 rows=20 width=24)
(actual time=0.128..0.135 rows=20 loops=1)
Group Key: u.name
Batches: 1 Memory Usage: 24kB
Buffers: shared hit=15
-> Hash Join (cost=10.25..32.50 rows=200 width=16)
(actual time=0.042..0.090 rows=200 loops=1)
Hash Cond: (o.user_id = u.id)
Buffers: shared hit=15
-> Seq Scan on orders o
(cost=0.00..16.00 rows=1000 width=8)
(actual time=0.005..0.025 rows=1000 loops=1)
Buffers: shared hit=10
-> Hash (cost=10.12..10.12 rows=10 width=16)
(actual time=0.028..0.028 rows=10 loops=1)
Buckets: 1024 Batches: 1 Memory Usage: 9kB
Buffers: shared hit=5
-> Seq Scan on users u
(cost=0.00..10.12 rows=10 width=16)
(actual time=0.005..0.023 rows=10 loops=1)
Filter: (status = 'active'::text)
Rows Removed by Filter: 90
Buffers: shared hit=5
Planning:
Buffers: shared hit=12
Execution Time: 0.210 ms

8.2 EXPLAIN 关键字段解读#

┌─────────────────┬────────────────────────────────────────────────┐
│ 字段 │ 含义 │
├─────────────────┼────────────────────────────────────────────────┤
│ cost=X..Y │ 启动代价..总代价 (估算) │
│ rows=N │ 估算返回行数 │
│ width=W │ 估算每行字节数 │
│ actual time=X..Y│ 实际启动时间..实际完成时间 (毫秒) │
│ rows=N │ 实际返回行数 │
│ loops=L │ 执行循环次数 │
│ Buffers │ 缓冲区访问统计 (shared hit/read/dirtied) │
│ Sort Method │ 排序算法和内存使用 │
│ Hash Cond │ Hash Join 的连接条件 │
│ Filter │ 过滤条件 │
│ Rows Removed │ 被过滤掉的行数 │
└─────────────────┴────────────────────────────────────────────────┘

8.3 常见执行计划模式#

索引查找模式

EXPLAIN SELECT * FROM users WHERE id = 42;
-- Index Scan using users_pkey on users
-- Index Cond: (id = 42)

多表连接模式

EXPLAIN SELECT * FROM orders o
JOIN users u ON o.user_id = u.id
JOIN products p ON o.product_id = p.id;
-- Hash Join on (o.product_id = p.id)
-- -> Hash Join on (o.user_id = u.id)
-- -> Seq Scan on orders
-- -> Hash -> Seq Scan on users
-- -> Hash -> Seq Scan on products

子查询优化模式

EXPLAIN SELECT * FROM orders
WHERE user_id IN (SELECT id FROM users WHERE status = 'active');
-- Hash Semi Join
-- Hash Cond: (orders.user_id = users.id)
-- -> Seq Scan on orders
-- -> Hash -> Seq Scan on users
-- Filter: (status = 'active')

九、优化器实战技巧#

9.1 统计信息调优#

-- 增大统计目标以提高估算精度
ALTER TABLE users ALTER COLUMN status SET STATISTICS 500;
-- 对特定列收集扩展统计信息
CREATE STATISTICS users_stats (ndistinct, dependencies, mcv)
ON status, city FROM users;
ANALYZE users;
-- 查看扩展统计信息
SELECT * FROM pg_statistic_ext
WHERE stxname = 'users_stats';

扩展统计信息的作用

┌──────────────────────┬──────────────────────────────────────────┐
│ 类型 │ 解决的问题 │
├──────────────────────┼──────────────────────────────────────────┤
│ ndistinct │ 多列组合的唯一值估算 │
│ dependencies │ 列之间的函数依赖关系 │
│ mcv (最常见值) │ 多列组合的频率分布 │
└──────────────────────┴──────────────────────────────────────────┘
示例:
WHERE city = 'Beijing' AND status = 'active'
无扩展统计: 选择率 = P(city) × P(status) = 0.1 × 0.7 = 0.07
有 dependencies: 发现 status 依赖 city
实际选择率 = P(city='Beijing' AND status='active') = 0.09

9.2 强制/禁用特定扫描方式#

-- 调试时临时禁用某种扫描方式
SET enable_seqscan = off; -- 禁用顺序扫描
SET enable_indexscan = off; -- 禁用索引扫描
SET enable_bitmapscan = off; -- 禁用位图扫描
SET enable_hashjoin = off; -- 禁用 Hash Join
SET enable_mergejoin = off; -- 禁用 Merge Join
SET enable_nestloop = off; -- 禁用 Nested Loop
-- 查看优化器决策过程
SET enable_seqscan = off;
EXPLAIN SELECT * FROM users WHERE status = 'active';
-- 现在会优先选择索引扫描 (如果有的话)

9.3 pg_hint_plan 扩展#

-- 使用提示控制优化器
/*+ HashJoin(users orders) */
EXPLAIN SELECT * FROM users u
JOIN orders o ON u.id = o.user_id;
/*+ SeqScan(users) IndexScan(orders idx_user_id) */
EXPLAIN SELECT * FROM users u
JOIN orders o ON u.id = o.user_id;

总结#

PostgreSQL 查询处理完整流程#

flowchart TB A[SQL 文本] --> B[Parser<br/>语法解析] B --> C[Analyzer<br/>语义分析] C --> D[Rewriter<br/>查询重写] D --> E[Planner] subgraph "Planner (计划生成)" E1[逻辑优化<br/>子链接提升/谓词下推] --> E2[物理优化<br/>代价计算/路径选择] E2 --> E3[扫描方式选择<br/>Seq/Index/Bitmap] E3 --> E4[连接算法选择<br/>NL/Merge/Hash] E4 --> E5[并行度决策] E5 --> E6[生成 Plan Tree] end E --> E1 E6 --> F[Executor<br/>执行器] subgraph "Executor" F1[Seq Scan] --> F2[Index Scan] F3[Hash Join] --> F4[Sort] F5[Aggregate] --> F6[Limit] end F --> G[返回结果]

核心要点#

  1. Parser:手写递归下降解析器,生成 Parse Tree
  2. Analyzer:语义分析,结合系统目录生成 Query Tree
  3. Rewriter:RULE 系统和视图展开
  4. Planner(逻辑优化):子链接提升、谓词下推、连接消除、DISTINCT 消除
  5. Planner(物理优化):基于代价模型和统计信息选择扫描方式与连接算法
  6. 并行查询:Gather 模型,多进程并行扫描和连接

常见问题#

Q1:PostgreSQL 和 MySQL 的优化器有什么区别?#

PostgreSQL 的优化器基于代价(CBO),统计信息更丰富(支持扩展统计信息如 ndistinct、dependencies、MCV)。MySQL 的优化器也是 CBO,但统计信息相对简单。PostgreSQL 支持 RULE 系统做查询重写,MySQL 没有等价机制。并行查询方面,PostgreSQL 使用多进程模型,MySQL 使用多线程。

Q2:什么时候该用位图扫描而不是索引扫描?#

当查询返回的行数占表总行数比例较高(通常 5-20%)时,位图扫描更优。位图扫描先收集所有匹配的 CTID,再按物理页顺序访问,减少了随机 I/O。返回行数极少时索引扫描更高效。

Q3:如何判断统计信息是否准确?#

对比 EXPLAIN 中的估算行数(rows)和 EXPLAIN ANALYZE 中的实际行数(actual rows)。如果差距很大(比如 10 倍以上),说明统计信息不准确。运行 ANALYZE 更新统计信息,或增大 STATISTICS 目标。

Q4:为什么有时候 PostgreSQL 不走索引?#

常见原因:统计信息不准导致优化器误判选择率;返回行数太多(全表扫描更快);random_page_cost 设置过高;列上没有合适的索引;使用了函数导致索引失效(如 WHERE lower(name) = 'alice',需要建表达式索引)。

Q5:并行查询有什么限制?#

并行查询不适用于:带 FOR UPDATE/SHARE 的查询;包含 CTE(WITH 子句)中写操作的查询;在事务中已经修改了数据的查询;嵌套过深的子查询。max_parallel_workers_per_gather 限制了每个 Gather 的 Worker 数量。

参考资料#

支持与分享

如果这篇文章对你有帮助,欢迎支持作者或分享给更多人

PostgreSQL 查询优化器:如何选择最优执行计划
https://blog.souloss.com/posts/principles/postgresql-query-optimizer/
作者
Souloss
发布于
2023-01-26
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时