mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
1021 字
3 分钟
查询处理与优化:从 SQL 到执行计划
2024-06-25

你写了一条 SQL,数据库在毫秒级返回结果——这背后发生了什么?从你敲下回车到结果呈现在屏幕上,这条 SQL 经历了一段漫长而精密的旅程:被解析成语法树、通过语义检查、被重写和改写、经过优化器的反复权衡、最终生成一条执行计划,再由执行引擎一步步执行。

本章将完整追踪这条旅程。理解查询处理的全流程,是你从”写 SQL”到”调 SQL”的——当你读懂 EXPLAIN 输出的每一行时,优化器不再是黑盒,而是你可以与之对话的伙伴。

前置知识#

一、查询处理流水线#

数据库处理一条 SQL 的过程,本质上是一条精密的流水线。每一阶段都有明确的输入和输出,上一阶段的输出就是下一阶段的输入:

flowchart LR SQL["SQL 文本"] --> Parser["Parser<br/>词法+语法分析"] Parser --> AST["语法树<br/>AST"] AST --> Semantic["语义分析<br/>类型检查/权限/名称解析"] Semantic --> QueryTree["查询树<br/>Query Tree"] QueryTree --> Rewriter["查询重写<br/>视图展开/规则系统"] Rewriter --> RewrittenTree["重写后的查询树"] RewrittenTree --> Optimizer["查询优化器<br/>逻辑优化+物理优化"] Optimizer --> Plan["执行计划<br/>Execution Plan"] Plan --> Executor["执行引擎"] Executor --> Result["结果集"] style SQL fill:#e3f2fd,stroke:#1565c0 style Parser fill:#e8f5e9,stroke:#2e7d32 style Optimizer fill:#fff3e0,stroke:#e65100 style Executor fill:#fce4ec,stroke:#c62828 style Result fill:#f3e5f5,stroke:#6a1b9a

这条流水线可以分成四个核心阶段:

阶段输入输出核心任务
解析SQL 文本语法树词法分析 + 语法分析,验证 SQL 是否”合法”
语义分析与重写语法树查询树名称解析、类型检查、权限验证、视图展开
优化查询树执行计划逻辑优化 + 物理优化,找到”最优”执行路径
执行执行计划结果集按计划访问数据、计算结果
Note

优化器是整条流水线中最复杂的组件。一个查询的可能执行计划数量随表的数量呈指数增长——3 张表的 Join 就有 12 种排列,5 张表则有 7,920 种。优化器的核心挑战就是在有限时间内从海量候选中选出足够好的计划。

二、解析与重写#

2.1 语法解析(Parser)#

语法解析分为两步:词法分析(Lexer)和语法分析(Parser)。

词法分析将 SQL 文本拆成一个个 Token(词法单元)。例如:

SELECT name, age FROM users WHERE age > 18;

被拆分为:SELECTname,ageFROMusersWHEREage>18;

语法分析根据语法规则将 Token 序列组织成一棵抽象语法树(AST)。这棵树的结构反映了 SQL 的语义层次:

“ SELECT /
columns FROM / \ | name age WHERE /
age 18 >

如果 SQL 存在语法错误(比如 `SELEC name FORM users`),解析器会在这一步就报错,后续阶段不会执行。
### 2.2 语义分析
语法树只验证了 SQL 的"语法正确性",但不知道 `users` 表是否存在、`name` 列是什么类型、当前用户有没有查询权限。语义分析负责这些检查:
- **名称解析**:将 SQL 中的表名、列名绑定到数据库元数据中的实际对象
- **类型检查**:验证表达式类型是否兼容(如不能把字符串和整数相加)
- **权限验证**:检查当前用户是否拥有对相关对象的访问权限
语义分析完成后,语法树被转换为**查询树(Query Tree)**,其中每个节点都绑定到了具体的数据库对象。
### 2.3 查询重写
查询重写阶段对查询树进行等价变换,使其更利于后续优化。常见的重写规则包括:
**视图展开**:将视图引用替换为视图定义的子查询。例如:
```sql
-- 假设 active_users 视图定义为:
-- SELECT * FROM users WHERE status = 'active'
-- 原始查询
SELECT name FROM active_users WHERE age > 18;
-- 重写后
SELECT name FROM users WHERE status = 'active' AND age > 18;
```text
**子查询提升**:将相关子查询改写为 Semi Join,避免逐行执行子查询:
```sql
-- 原始:相关子查询(每行都执行一次子查询)
SELECT * FROM orders o
WHERE EXISTS (SELECT 1 FROM users u WHERE u.id = o.user_id AND u.city = '北京');
-- 重写后:Semi Join(一次扫描完成)
SELECT o.* FROM orders o
SEMI JOIN users u ON u.id = o.user_id AND u.city = '北京';
```text
**常量折叠与表达式简化**:
```sql
-- 原始
SELECT * FROM users WHERE age > 10 + 8 AND 1 = 1;
-- 重写后
SELECT * FROM users WHERE age > 18;
```text
PostgreSQL 的规则系统(Rule System)也在此阶段工作——用户定义的 RULE 可以在重写阶段修改查询树,实现行级安全策略等高级功能。
## 三、查询优化
查询优化是整条流水线的核心。优化器的目标是在有限时间内找到代价最低的执行计划。优化分为两个层次:**逻辑优化**(基于规则)和**物理优化**(基于代价)。
### 3.1 逻辑优化(规则优化 / 启发式优化)
逻辑优化基于启发式规则(Heuristic Rules),对查询树进行等价变换,无需了解数据分布。这些规则通常能"无脑"减少中间结果集的大小:
**谓词下推(Predicate Pushdown)**:将过滤条件尽早执行,减少上游的数据量。
```sql
-- 优化前:先 Join 再过滤(中间结果大)
SELECT o.* FROM orders o JOIN users u ON o.user_id = u.id
WHERE u.city = '北京';
-- 优化后:先过滤再 Join(中间结果小)
SELECT o.* FROM orders o JOIN (SELECT * FROM users WHERE city = '北京') u
ON o.user_id = u.id;
```text
**列裁剪(Column Pruning)**:只读取查询需要的列,减少 I/O 和内存占用。在[索引原理](./03-索引原理.md)中讨论的覆盖索引,正是列裁剪在索引层面的体现。
**Join 重排序**:调整 Join 的顺序,将小表或过滤后行数少的表放在前面。这是优化器搜索空间最大的来源——N 张表的 Join 有 N! 种排列顺序。
### 3.2 物理优化(代价优化)
逻辑优化决定了"做什么",物理优化决定"怎么做"。同一个逻辑算子有多种物理实现,代价各不相同:
```mermaid
flowchart TD
subgraph 逻辑算子["逻辑算子"]
SCAN["TableScan"]
FILTER["Filter"]
JOIN["Join"]
SORT["Sort"]
AGG["Aggregate"]
end
subgraph 物理实现["物理实现选择"]
SEQ["顺序扫描<br/>Seq Scan"]
IDX["索引扫描<br/>Index Scan"]
IDXONLY["索引只读扫描<br/>Index Only Scan"]
NL["Nested Loop Join"]
HJ["Hash Join"]
SMJ["Sort Merge Join"]
INMEM["内存排序"]
EXTSORT["外部排序"]
HASHAGG["Hash Aggregate"]
SORTAGG["Sort Aggregate"]
end
SCAN --> SEQ
SCAN --> IDX
SCAN --> IDXONLY
JOIN --> NL
JOIN --> HJ
JOIN --> SMJ
SORT --> INMEM
SORT --> EXTSORT
AGG --> HASHAGG
AGG --> SORTAGG
style 逻辑算子 fill:#e8f5e9,stroke:#2e7d32
style 物理实现 fill:#fff3e0,stroke:#e65100
```text
### 3.3 Join 算法选择
Join 是最复杂也最关键的算子。四种经典 Join 算法各有适用场景:
```mermaid
flowchart TD
START["Join 操作"] --> SIZE{"两表大小关系?"}
SIZE -->|"一表很小<br/>(可放内存)"| NL["Nested Loop Join<br/>小表做外表<br/>O(M×N)"]
SIZE -->|"两表都大<br/>无序"| HJ["Hash Join<br/>小表建 Hash 表<br/>O(M+N)"]
SIZE -->|"两表都大<br/>已排序"| SMJ["Sort Merge Join<br/>双指针扫描<br/>O(M+N)"]
SIZE -->|"两表都大<br/>内存不足"| GHJ["Grace Hash Join<br/>分区+逐区 Hash Join<br/>O(M+N) I/O"]
NL --> NL_NOTE["适合:有索引可用<br/>或小表驱动大表"]
HJ --> HJ_NOTE["适合:等值 Join<br/>无排序要求"]
SMJ --> SMJ_NOTE["适合:非等值 Join<br/>或数据已有序"]
GHJ --> GHJ_NOTE["适合:等值 Join<br/>内存不足场景"]
style START fill:#e3f2fd,stroke:#1565c0
style NL fill:#e8f5e9,stroke:#2e7d32
style HJ fill:#fff3e0,stroke:#e65100
style SMJ fill:#fce4ec,stroke:#c62828
style GHJ fill:#f3e5f5,stroke:#6a1b9a
```text
| Join 算法 | 时间复杂度 | 适用场景 | 优势 | 劣势 |
|-----------|-----------|---------|------|------|
| Nested Loop | O(M×N) | 小表驱动大表、有索引 | 简单、支持非等值 Join | 大表全扫描极慢 |
| Hash Join | O(M+N) | 等值 Join、两表都大 | 等值 Join 最快 | 只支持等值、需内存 |
| Sort Merge | O(MlogM+NlogN) | 数据已排序、非等值 | 支持非等值、可利用索引序 | 需要排序开销 |
| Grace Hash | O(M+N) I/O | 等值 Join、内存不足 | 突破内存限制 | 多轮 I/O |
> [!TIP]
> MySQL 8.0 之前只支持 Nested Loop Join(含 Block Nested Loop 变体),8.0 引入了 Hash Join,大幅提升了无索引等值 Join 的性能。PostgreSQL 则长期支持全部四种算法。更多实现细节参见 [MySQL 深入](./06-MySQL深入.md) 和 [PostgreSQL 深入](./07-PostgreSQL深入.md)。
## 四、代价模型
优化器如何从众多候选计划中选出"最优"?答案是**代价模型(Cost Model)**——为每个候选计划估算一个代价分数,选择代价最低的。
### 4.1 代价的三个维度
代价模型通常考虑三个维度:
| 维度 | 含义 | 典型权重 |
|------|------|----------|
| **I/O 代价** | 读写磁盘页面的次数 | 最高(磁盘比内存慢 10^5 倍) |
| **CPU 代价** | 计算表达式、比较元组的开销 | 中等 |
| **网络代价** | 分布式查询的数据传输量 | 分布式场景下最高 |
总代价 = seq_page_cost × I/O 页数 + cpu_tuple_cost × 元组数 + cpu_index_cost × 索引扫描次数 + ...
### 4.2 PostgreSQL 代价估算
PostgreSQL 的代价模型是最透明的。以下是一个 Seq Scan 的代价估算公式:

Seq Scan 代价 = seq_page_cost × 总页数 + cpu_tuple_cost × 总行数

其中: seq_page_cost = 1.0 (顺序读一页的代价,基准值) cpu_tuple_cost = 0.01 (处理一行的 CPU 代价)

Index Scan 的代价估算更复杂,需要考虑 B+ 树的高度、叶子页的随机 I/O、以及选择率:

Index Scan 代价 = random_page_cost × 索引页面数 + random_page_cost × 数据页面数 + cpu_index_tuple_cost × 索引条目数 + cpu_tuple_cost × 结果行数

其中: random_page_cost = 4.0 (随机读一页的代价,是顺序读的 4 倍) cpu_index_tuple_cost = 0.005

> [!WARNING]
> `random_page_cost` 默认值为 4.0,这是基于传统机械硬盘的假设。在 SSD 上,随机 I/O 和顺序 I/O 的差距远没有 4 倍。生产环境使用 SSD 时,建议将 `random_page_cost` 设为 1.1~1.5,否则优化器会过度偏好顺序扫描。
### 4.3 MySQL 代价估算
MySQL 的代价模型在 8.0 版本经历了重大重构,从硬编码改为可配置的代价常量:
```sql
-- 查看 MySQL 代价常量
SELECT * FROM mysql.server_cost;
SELECT * FROM mysql.engine_cost;
```text

— server_cost 示例输出 +------------------------------+------------+---------------------+---------+ | cost_name | cost_value | default_value | comment | +------------------------------+------------+---------------------+---------+ | disk_temptable_create_cost | NULL | 20.0 | | | disk_temptable_row_cost | NULL | 0.5 | | | key_compare_cost | NULL | 0.05 | | | memory_temptable_create_cost | NULL | 1.0 | | | memory_temptable_row_cost | NULL | 0.1 | | | row_evaluate_cost | NULL | 0.1 | | +------------------------------+------------+---------------------+---------+

### 4.4 统计信息:代价估算的基石
代价估算的准确性完全依赖于**统计信息**。没有准确的统计信息,再精妙的代价模型也是空中楼阁。
核心统计指标:
| 统计指标 | 含义 | 用途 |
|---------|------|------|
| **NDV**(Number of Distinct Values) | 列的唯一值数量 | 估算等值条件的选择率 = 1/NDV |
| **直方图**(Histogram) | 列值分布的频率统计 | 估算范围条件的选择率 |
| **MCV**(Most Common Values) | 高频值及其频率 | 估算偏斜分布的选择率 |
| **相关性**(Correlation) | 列值物理排序与逻辑排序的相关度 | 决定 Index Scan 的额外随机 I/O |
选择率(Selectivity)是代价估算的核心概念——它决定了过滤后剩余多少行:
```python
# 选择率估算示例(简化版)
def estimate_selectivity(column, predicate, stats):
if predicate.type == "equality":
# 等值条件:1/NDV(均匀分布假设)
return 1.0 / stats.ndv[column]
elif predicate.type == "range":
# 范围条件:用直方图估算
return stats.histogram[column].fraction_in_range(
predicate.lower, predicate.upper
)
elif predicate.type == "in_list":
# IN 列表:每个值的频率之和
return sum(stats.mcv[column].get(v, 1.0/stats.ndv[column])
for v in predicate.values)
```text
> [!NOTE]
> 统计信息失真是执行计划劣化的头号原因。一张百万行的表,如果统计信息显示只有 1000 行,优化器可能选择 Index Scan;但实际扫描百万行的 Index Scan 比 Seq Scan 慢得多——因为随机 I/O 的代价远高于顺序 I/O。这就是为什么定期收集统计信息至关重要。
## 五、执行引擎
优化器生成执行计划后,执行引擎负责按计划执行。执行引擎的架构模型决定了查询的执行方式,对性能影响深远。
### 5.1 三种执行模型
```mermaid
flowchart TB
subgraph Volcano["Volcano/Iterator 模型(拉模型)"]
direction TB
V_OUT["Result"] -->|"next()"| V_JOIN["Hash Join"]
V_JOIN -->|"next()"| V_SCAN1["Scan: users"]
V_JOIN -->|"build()"| V_SCAN2["Scan: orders"]
end
subgraph Vectorized["向量化模型(批处理)"]
direction TB
VZ_OUT["Result"] -->|"next_batch()"| VZ_JOIN["Hash Join"]
VZ_JOIN -->|"next_batch()"| VZ_SCAN1["Scan: users"]
VZ_JOIN -->|"build()"| VZ_SCAN2["Scan: orders"]
end
subgraph Compiled["编译执行模型(推模型)"]
direction TB
C_CODE["编译生成的代码"] --> C_LOOP["for row in users:"]
C_LOOP --> C_HASH["hash_table.insert(row)"]
C_HASH --> C_LOOP2["for row in orders:"]
C_LOOP2 --> C_PROBE["hash_table.probe(row)"]
C_PROBE --> C_EMIT["emit(row)"]
end
style Volcano fill:#e3f2fd,stroke:#1565c0
style Vectorized fill:#e8f5e9,stroke:#2e7d32
style Compiled fill:#fff3e0,stroke:#e65100
```text
| 模型 | 执行方式 | 虚函数调用 | CPU 缓存 | 适用场景 |
|------|---------|-----------|---------|---------|
| **Volcano/Iterator** | 每次拉取一行 | 每行 N 次 | 差(逐行跳转) | 通用、易实现 |
| **Vectorized** | 每次拉取一批(如 1024 行) | 每批 N 次 | 好(批处理) | OLAP 分析查询 |
| **Compiled** | 编译为机器码 | 无虚函数调用 | 最好(紧凑循环) | 重复执行的查询 |
### 5.2 Volcano 模型详解
Volcano 模型(又称 Iterator 模型)是最经典的执行模型,几乎所有数据库都支持。每个算子实现三个接口:
- `open()`:初始化(如 Hash Join 建立哈希表)
- `next()`:返回下一行,没有更多行时返回 null
- `close()`:释放资源
```python
# Volcano 模型的 Hash Join 伪代码
class HashJoin(Operator):
def open(self):
self.hash_table = {}
self.build_child.open()
while (row := self.build_child.next()) is not None:
self.hash_table[self.key(row)] = row
self.build_child.close()
self.probe_child.open()
def next(self):
while (row := self.probe_child.next()) is not None:
if self.key(row) in self.hash_table:
return self.emit(self.hash_table[self.key(row)], row)
return None
```text
Volcano 模型的优势是**简洁和通用**——任何算子只需实现 `open/next/close`,即可自由组合。劣势是**虚函数调用开销**——每行数据都要经过多次虚函数调用,对 CPU 流水线不友好。
### 5.3 向量化执行
向量化执行(Vectorized Execution)是对 Volcano 模型的改进:每次 `next()` 返回一批行(通常 1024 行),而不是一行。这大幅减少了虚函数调用次数,并且批处理模式让 CPU 的 SIMD 指令和缓存预取得以发挥作用。
DuckDB、ClickHouse 等分析型数据库广泛采用向量化执行。在 OLAP 场景下,向量化比 Volcano 快 5-10 倍并不罕见。
### 5.4 编译执行
编译执行(Compiled Execution)将整个查询计划编译成一段机器码,消除了所有虚函数调用。Hyper 系统率先提出这一思路,后来被 Apache Spark 的 Whole-Stage Code Generation 和 SQL Server 采用。
编译执行在查询重复执行时性能最优,但编译本身有开销,对于只执行一次的查询(如 ad-hoc 查询)可能得不偿失。
## 六、EXPLAIN 解读
理解了查询处理的全流程,现在学会读懂 EXPLAIN——这是你与优化器对话的窗口。
### 6.1 MySQL EXPLAIN 字段详解
```sql
EXPLAIN SELECT u.name, COUNT(*) AS order_count
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.city = '北京' AND o.status = 'completed'
GROUP BY u.name;
```text

+----+-------------+-------+------------+------+-------------------+-------------------+---------+----------------+------+----------+-------------------------------------------+ | id | select_type | table | type | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+-------------------+-------------------+---------+----------+-------------------------------------------+ | 1 | SIMPLE | u | ref | idx_city | 152 | const | 500 | 100.00 | Using index condition | | 1 | SIMPLE | o | ref | idx_user_status | 12 | test.u.id | 10 | 33.33 | Using where; Using index; Using temporary | +----+-------------+-------+------------+------+-------------------+-------------------+---------+----------+-------------------------------------------+

关键字段解读:
| 字段 | 含义 | 关注点 |
|------|------|--------|
| **type** | 访问类型 | 从好到差:system > const > eq_ref > ref > range > index > ALL |
| **key** | 实际使用的索引 | NULL 表示未使用索引(全表扫描) |
| **rows** | 预估扫描行数 | 越小越好,依赖统计信息 |
| **filtered** | 过滤比例 | 100% 最好,10% 表示 90% 的行被丢弃 |
| **Extra** | 额外信息 | Using filesort/Using temporary 是危险信号 |
### 6.2 PostgreSQL EXPLAIN ANALYZE
PostgreSQL 的 EXPLAIN ANALYZE 不仅显示预估代价,还显示实际执行时间,是诊断计划偏差的利器:
```sql
EXPLAIN ANALYZE
SELECT u.name, COUNT(*) AS order_count
FROM users u
JOIN orders o ON u.id = o.user_id
WHERE u.city = '北京' AND o.status = 'completed'
GROUP BY u.name;
```text

HashAggregate (cost=1250.00..1260.00 rows=200 width=20) (actual time=8.234..8.456 rows=150 loops=1) Group Key: u.name Batches: 1 Memory Usage: 40kB -> Hash Join (cost=450.00..1200.00 rows=5000 width=20) (actual time=3.123..7.890 rows=4800 loops=1) Hash Cond: (o.user_id = u.id) -> Seq Scan on orders o (cost=0.00..350.00 rows=5000 width=12) (actual time=0.012..2.345 rows=4800 loops=1) Filter: (status = ‘completed’::text) Rows Removed by Filter: 5200 -> Hash (cost=200.00..200.00 rows=500 width=12) (actual time=0.789..0.789 rows=500 loops=1) Buckets: 1024 Batches: 1 Memory Usage: 28kB -> Seq Scan on users u (cost=0.00..200.00 rows=500 width=12) (actual time=0.008..0.567 rows=500 loops=1) Filter: (city = ‘北京’::text) Planning Time: 0.234 ms Execution Time: 8.567 ms

> [!IMPORTANT]
> 注意 `cost`(预估)和 `actual`(实际)的对比。如果两者差距很大,说明统计信息失真——这是执行计划劣化的最常见原因。上例中预估 5000 行、实际 4800 行,偏差在可接受范围内。
### 6.3 跨数据库 EXPLAIN 对比
| 特性 | MySQL | PostgreSQL |
|------|-------|------------|
| 基本命令 | `EXPLAIN SQL` | `EXPLAIN SQL` |
| 实际执行 | `EXPLAIN ANALYZE`(8.0.18+) | `EXPLAIN ANALYZE` |
| 输出格式 | 表格 / JSON / Tree | 文本 / JSON / YAML |
| 预估代价 | 不显示 | 显示(cost=...) |
| 实际行数 | 8.0.18+ 支持 | 一直支持 |
| 执行时间 | 8.0.18+ 支持 | 一直支持 |
| Buffer 统计 | 不支持 | `BUFFERS` 选项 |
| WAL 统计 | 不支持 | `WAL` 选项 |
### 6.4 实战案例:从慢到优
**案例 1:隐式类型转换导致索引失效**
```sql
-- 慢:phone 是 VARCHAR,传入整数导致隐式转换,索引失效
EXPLAIN SELECT * FROM users WHERE phone = 13800138000;
-- type: ALL, rows: 1000000, Extra: Using where
-- 快:传入字符串,走索引
EXPLAIN SELECT * FROM users WHERE phone = '13800138000';
-- type: ref, key: idx_phone, rows: 1
```text
**案例 2:最左前缀原则违反**
```sql
-- 慢:联合索引 (city, age, name),跳过 city 直接查 age
EXPLAIN SELECT * FROM users WHERE age > 18;
-- type: ALL, rows: 1000000
-- 快:遵循最左前缀
EXPLAIN SELECT * FROM users WHERE city = '北京' AND age > 18;
-- type: range, key: idx_city_age_name, rows: 5000
```text
**案例 3:ORDER BY 导致 filesort**
```sql
-- 慢:索引在 (status),但 ORDER BY created_at 无索引
EXPLAIN SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 10;
-- Extra: Using where; Using filesort
-- 快:建立覆盖排序的联合索引
CREATE INDEX idx_status_created ON orders(status, created_at);
EXPLAIN SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 10;
-- type: ref, key: idx_status_created, Extra: Using index condition
```text
**案例 4:小表驱动大表**
```sql
-- 慢:大表做外表,扫描 100 万行
EXPLAIN SELECT * FROM large_table l JOIN small_table s ON l.key = s.key;
-- Nested Loop, 驱动表 large_table
-- 快:小表做外表,只扫描 1000 行
EXPLAIN SELECT /*+ QB_NAME(main) */ * FROM small_table s
JOIN large_table l ON l.key = s.key;
-- Hash Join, build 侧 small_table
```text
## 七、统计信息与绑定
### 7.1 统计信息收集
统计信息不会自动保持最新——大量 INSERT/UPDATE/DELETE 后,统计信息会逐渐失真。必须定期收集:
```sql
-- PostgreSQL:手动收集统计信息
ANALYZE users; -- 只收集 users 表
ANALYZE users(name, city); -- 只收集指定列
VACUUM ANALYZE; -- 同时回收空间和收集统计信息
-- 设置自动收集的阈值
ALTER TABLE users SET (autovacuum_analyze_threshold = 100);
ALTER TABLE users SET (autovacuum_analyze_scale_factor = 0.05);
```text
```sql
-- MySQL:手动收集统计信息
ANALYZE TABLE users;
-- 查看统计信息
SHOW INDEX FROM users; -- 查看索引的 Cardinality
SELECT * FROM information_schema.STATISTICS
WHERE TABLE_NAME = 'users';
```text
PostgreSQL 还支持扩展统计信息,用于捕获多列相关性:
```sql
-- 创建多列统计信息(捕获 city 和 age 的相关性)
CREATE STATISTICS s1 (ndistinct, dependencies, mcv) ON city, age FROM users;
ANALYZE users;
-- 查看统计信息
SELECT * FROM pg_stats WHERE tablename = 'users';
```text
### 7.2 计划缓存与参数化
数据库会缓存执行计划,避免重复优化。但参数化查询的计划缓存有一个经典陷阱——**参数嗅探(Parameter Sniffing)**:
```sql
-- 第一次执行:city = '北京' 返回 50 万行,优化器选择 Seq Scan
PREPARE get_users(VARCHAR) AS SELECT * FROM users WHERE city = $1;
EXECUTE get_users('北京');
-- 第二次执行:city = '拉萨' 返回 100 行,但复用了 Seq Scan 的计划!
EXECUTE get_users('拉萨');
```text
PostgreSQL 使用**通用计划(Generic Plan)**和**自定义计划(Custom Plan)**的自动切换来缓解此问题。MySQL 8.0 则引入了 `optimizer_switch` 中的条件来控制计划缓存行为。
### 7.3 Plan Hint
当优化器选错计划时,可以通过 Hint 强制指定执行路径:
```sql
-- MySQL:USE INDEX / FORCE INDEX
SELECT * FROM users USE INDEX(idx_city) WHERE city = '北京';
SELECT * FROM users FORCE INDEX(idx_city) WHERE city = '北京';
-- USE INDEX:建议使用,优化器仍可忽略
-- FORCE INDEX:强制使用,除非无法使用(如索引不包含所需列)
```text
```sql
-- PostgreSQL:通过 pg_hint_plan 扩展
/*+ SeqScan(users) HashJoin(users orders) */
SELECT u.name, COUNT(*)
FROM users u JOIN orders o ON u.id = o.user_id
WHERE u.city = '北京' GROUP BY u.name;
```text
### 7.4 绑定执行计划
比 Hint 更可靠的方式是**绑定执行计划**——将特定 SQL 模式与固定的执行计划关联:
| 数据库 | 机制 | 说明 |
|--------|------|------|
| **MySQL** | SQL Profile | 记录查询的资源消耗,辅助优化器决策 |
| **MySQL** | Optimizer Hint | `SET_VAR(optimizer_switch='...')` |
| **PostgreSQL** | Plan Guide(pg_plan_filter) | 第三方扩展,限制计划选择 |
| **SQL Server** | Plan Guide | 原生支持,最成熟的计划绑定机制 |
| **Oracle** | SQL Profile / Baseline | 自动捕获 + 手动固定 |
> [!TIP]
> 绑定执行计划是"双刃剑"——它绕过了优化器,数据分布变化后可能适得其反。只在确认优化器反复选错计划时才使用,并定期审查绑定的计划是否仍然最优。更多调优策略参见 [性能优化](./09-数据库性能优化.md)。
## ·附、实践:PostgreSQL EXPLAIN 解读
> 本节用 PostgreSQL 的 EXPLAIN ANALYZE 观察查询执行计划。需要 PostgreSQL 环境。
### 1. 创建测试数据
```sql
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INTEGER,
amount NUMERIC,
status TEXT,
created_at TIMESTAMP DEFAULT NOW()
);
-- 插入 1000 行测试数据
INSERT INTO orders (user_id, amount, status)
SELECT
(random() * 100)::int,
(random() * 1000)::numeric(10,2),
CASE WHEN random() < 0.3 THEN 'pending'
WHEN random() < 0.7 THEN 'shipped'
ELSE 'delivered' END
FROM generate_series(1, 1000);
CREATE INDEX idx_orders_user_id ON orders(user_id);
```text
### 2. 索引扫描 vs 全表扫描
```sql
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 42;
```text
```text
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------
Bitmap Heap Scan on orders (cost=4.33..12.56 rows=7 width=30) (actual time=0.126..0.159 rows=7 loops=1)
Recheck Cond: (user_id = 42)
Heap Blocks: exact=5
-> Bitmap Index Scan on idx_orders_user_id (cost=0.00..4.33 rows=7 width=0) (actual time=0.075..0.075 rows=7 loops=1)
Index Cond: (user_id = 42)
Planning Time: 1.552 ms
Execution Time: 0.346 ms
```text
**解读**:
- `Bitmap Index Scan`:先在索引中找到所有满足条件的行,收集为位图
- `Bitmap Heap Scan`:根据位图回表读取数据页(`Heap Blocks: exact=5` 表示访问了 5 个数据页)
- `actual time=0.126..0.159`:启动时间 0.126ms,总时间 0.159ms
- `rows=7`:实际返回 7 行
### 3. 聚合查询的执行计划
```sql
EXPLAIN ANALYZE SELECT status, COUNT(*), AVG(amount) FROM orders GROUP BY status;
```text
```text
QUERY PLAN
--------------------------------------------------------------------------------------------------------------
HashAggregate (cost=25.50..25.54 rows=3 width=48) (actual time=0.271..0.273 rows=3 loops=1)
Group Key: status
Batches: 1 Memory Usage: 24kB
-> Seq Scan on orders (cost=0.00..18.00 rows=1000 width=14) (actual time=0.004..0.096 rows=1000 loops=1)
Planning Time: 1.123 ms
Execution Time: 0.437 ms

支持与分享

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

查询处理与优化:从 SQL 到执行计划
https://blog.souloss.com/posts/database/query-processing-and-optimization/
作者
Souloss
发布于
2024-06-25
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时