# SQL 从文本到结果

LLMS 索引： [llms.txt](/llms.txt)

---

SQL 是声明式语言：调用者描述需要的关系结果和允许的变更，PostgreSQL 决定怎样执行。这个抽象让应用不必把“先扫哪张表、用哪个索引”写死，却也带来一个常见误区——把 SQL 文本、逻辑语义、执行计划和某次运行表现混成同一件事。本节先把四层拆开。

## 5.1.1 解析、重写、规划与执行 {#item-5-1-1}

一条 SQL 从客户端到结果并不是“解析后立刻跑”。对普通查询，可以用下面的主干理解：

```text
SQL text
  → raw parsing
  → parse analysis / transformation
  → rewrite
  → planning / optimization
  → execution
```

### raw parsing 只知道语法形状

lexer 把关键字、标识符、常量和运算符切成 token，grammar 再建立 raw parse tree。这个阶段能判断括号、关键字位置和语法结构是否成立，却不查询系统目录，因此还不知道 `shop.sales_order` 是否存在、`amount_minor` 是什么类型，也无法判定 `sum` 最后解析为哪个具体函数。

接下来的 parse analysis / transformation 才在事务上下文中解析：

- schema、relation、column、function 与 operator；
- 未限定名称所受的 `search_path` 影响；
- literal、parameter 与 expression 的数据类型；
- aggregate、window function、target list 与权限所需的语义信息。

所以“解析”在口语中常被用作总称，但诊断时要更精确。少一个右括号通常是 `42601 syntax_error`；表名不存在是 semantic analysis 期间的 `42P01 undefined_table`；整数除零则可能直到 executor 求值时才产生 `22012`。错误出现在哪一层，决定应该检查文本、catalog/类型，还是运行数据。

### rewrite 不是字符串替换

rewriter 接受和输出的都是 query tree。它最常见的用途是展开 view：查询 `shop_api.order_summary` 时，服务器不是把 view 当预先保存的一批行，而是把其定义纳入重写后的查询树。rules 也在这一层处理；row-level trigger 则不是 rewriter，它在执行期间按触发时点工作。

这一区分有两个工程后果：

1. view 后面仍要规划、执行和做 MVCC 可见性判断，普通 view 本身不是结果缓存；
2. `EXPLAIN SELECT ... FROM view` 展示的是重写之后形成的计划，不等于展示 raw parse tree 或每一步 rewrite 记录。

不要为了观察生产查询而打开 `debug_print_parse`、`debug_print_rewritten` 或 `debug_print_plan` 之类的全局调试输出；它们会改变日志量并可能暴露 SQL。教学上知道阶段即可，生产证据优先来自安全的 `EXPLAIN`、catalog、统计视图与受控日志。

### planner 选择路径，executor 消费计划

planner/optimizer 接收 rewritten query tree，为扫描、连接、排序与聚合生成候选 path，用统计估算行数，再用成本模型比较候选。选中的 cheapest path 被展开成 plan tree。

executor 递归执行这棵树。PostgreSQL 的主体模型是 demand-pull：父节点需要下一行时向子节点索取，直到返回 tuple 或结束。对写语句，`ModifyTable` 等节点取得目标 tuple，再执行 insert/update/delete/merge、约束、trigger 与 WAL 相关工作。计划是可执行说明，不是运行结果；真正读取哪些 block、遇到哪些可见版本和等待，只有执行时才知道。

### 协议与计划生命周期也会改变上下文

应用通过 simple query protocol 发送整段 SQL，或通过 extended query protocol 执行 Parse/Bind/Execute。prepared statement 可以把参数绑定与 statement 定义分开，还可能在 custom plan 和 generic plan 之间选择。连接池又可能让同一逻辑请求落到不同 backend。

本章只固定一个原则：保存 SQL 文本还不够，至少还要关联 database、role、`search_path`、参数类型/值范围、配置、server version 与计划时点。第 7 章会专门验证 prepared statement 的计划选择，不在这里提前给“预编译一定更快”之类错误结论。

用当前订单查询做一个边界观察：

```sql
EXPLAIN (VERBOSE, COSTS OFF)
SELECT order_no, order_status, item_subtotal_minor
FROM shop_api.order_summary
WHERE order_id = 1001;
```

它可以证明 planner 最终交给 executor 的树，也能看到 view 展开后的 base relation；它不能证明估算准确、实际耗时稳定、当前没有锁等待。要回答后面的问题，需要 `EXPLAIN (ANALYZE, BUFFERS)` 或运行时视图，而带 `ANALYZE` 会真实执行语句，绝不能对有副作用的 SQL 随意使用。

## 5.1.2 关系代数直觉与执行节点 {#item-5-1-2}

理解 plan tree 最有效的方式不是背节点列表，而是先问 SQL 需要哪些关系操作，再问 PostgreSQL 用什么物理算法实现。

| 逻辑意图 | SQL 表达 | 可能的物理节点 |
|---|---|---|
| 选择行 | `WHERE` | scan 中的 index condition / filter |
| 投影列/表达式 | `SELECT` list | scan、Result 或上层节点计算 |
| 连接关系 | `JOIN` | Nested Loop、Hash Join、Merge Join |
| 分组聚合 | `GROUP BY` | HashAggregate、GroupAggregate |
| 排序 | `ORDER BY` | Sort、Incremental Sort，或有序 index path |
| 去重 | `DISTINCT` | Unique、aggregate、排序/哈希组合 |
| 限制结果 | `LIMIT` | Limit，但子节点可能已经做了大量工作 |

逻辑操作与物理节点不是一一对应。例如，一个 B-tree index path 可以同时提供筛选和顺序；HashAggregate 可能同时承担分组与去重；planner 也可能把 predicate 下推到更低节点。反过来，SQL 文本中的一个 join 可能因 view 展开而变成多层 join tree。

### 用树而不是“执行步骤清单”阅读计划

假设计划形状为：

```text
Sort
  → HashAggregate
      → Hash Join
          → Seq Scan on sales_order_item
          → Hash
              → Seq Scan on sales_order
```

缩进表示父子关系，不表示“第一行先完整执行，第二行再完整执行”。父节点通常向子节点拉取 tuple；`Seq Scan` 可以边读边交付，`Hash` 必须先构建内表，`Sort` 通常要取得足够输入后才能输出有序行。很多节点可 pipeline，一些节点会阻塞或 materialize；“executor 是 pull model”不等于全计划只保留一行内存。

读计划时来回走两遍：

1. 自下而上：base relation 如何进入 join/aggregate，数据量怎样放大或缩小；
2. 自上而下：最终排序、LIMIT 和输出要求向子树施加了什么 property。

第 7 章会加入 estimated rows、actual rows、loops、buffers、memory、I/O timing 等证据。此处只要求先能指出“哪个节点实现哪个逻辑责任”。

### SQL 文本顺序不保证物理顺序

inner join 在满足语义等价时可以重排；predicate 可以下推；subquery 可能被 pull up；CTE 是否 materialize 取决于语义、引用方式和显式关键字。不要把：

```sql
FROM a
JOIN b ...
JOIN c ...
```

理解成服务器必然先 `a→b→c`。也不要用随意设置 `enable_seqscan=off` 或 `join_collapse_limit=1` 作为长期“修计划”方案；这些开关最多用于诊断假设，会改变整个 search space。

另一个必须从关系语义继承的规则是：没有 `ORDER BY` 就没有结果顺序合同。某次 Seq Scan 看似按 heap 位置返回、某次 Index Scan 看似按 key 返回，都不是 API 可以依赖的排序。VACUUM、并行执行、plan change 或一次普通 UPDATE 都可能改变观测顺序。

### 节点名也不是性能判决

- 小表 Seq Scan 往往比 index traversal 更便宜；
- Nested Loop 在外表很小、内表有高选择性索引时很好；
- Hash Join 不是天然“吃内存的坏节点”，是否 spill 才需要证据；
- Sort 可能完全在内存，也可能写 temporary files；
- `Limit 1` 若没有可利用的顺序或选择性，下面仍可能扫描很多行。

节点只描述算法和责任。性能结论必须同时看输入规模、估算误差、loops、filter 丢弃量、buffer/I/O、等待与并发环境。

## 5.1.3 优化器为什么做估算而不是预言 {#item-5-1-3}

planner 必须在执行前作选择，因此只能利用当时可得的信息估算候选 path。核心链条是：

```text
table cardinality + column statistics + predicates
  → selectivity estimate
  → rows / width estimate at every node
  → CPU + page + parallel + startup/total cost
  → choose expected cheapest path
```

`cost=0.42..8.44` 是按配置成本单位计算的比较量，不是 0.42–8.44 毫秒。estimated rows 也不是承诺返回的行数，而是影响 join order、join algorithm、scan path、parallelism 和 memory assumptions 的关键输入。

### 统计是有意近似的

`pg_class.reltuples`、`relpages` 不随每行写入实时更新；`pg_stats` 的 most common values、histogram、null fraction 与 distinct estimate 来自 `ANALYZE` 样本。即使刚分析完，它们仍是近似。planner 还常对多个条件采用独立性假设；城市和邮编、订单状态和支付时间这类相关列可能让乘法选择率严重失真，需要有证据地引入 extended statistics。

常见估算偏差来源包括：

- bulk load 后尚未 ANALYZE，或分布最近发生突变；
- 极端 skew 被有限 MCV/histogram 粒度抹平；
- 多列相关性没有 dependencies / MCV extended statistics；
- expression、function 或 cast 让现有统计不对应实际谓词；
- prepared statement 的未知参数与 generic plan 无法代表特定值；
- 跨表相关、数据新鲜度和未来并发本来就不在单列统计中。

### 成本模型也不知道未来

cost 参数表达 planner 对 sequential page、random page、CPU tuple/operator、parallel setup 等工作的相对假设。它不知道查询真正运行时：

- 数据页会在 shared buffers、OS cache 还是存储设备；
- 同一磁盘是否正被 checkpoint、backup 或其他查询占用；
- 会不会等待 row lock、LWLock、buffer pin、WAL flush 或客户端；
- 当前主机是否 CPU throttling；
- 结果集会不会因刚提交的数据而改变。

所以 plan 可以“按已知信息做了正确选择”但运行仍慢；也可以因估算错误选错 path。前者应调查资源与等待，后者才进入统计、SQL 形状或索引修正。

### 用误差定位，不用节点偏好代替诊断

第 7 章会用：

```text
estimate ratio = max(actual rows / estimated rows,
                     estimated rows / actual rows)
```

逐层寻找第一次显著偏离，并把它与统计和 predicate 对上。第 8 章则先判断 wall time 消耗在 CPU、I/O、lock、WAL、client 还是连接队列。现在只记住三条：

1. estimated cost 只在同一 planning context 下比较候选，不跨服务器当 benchmark；
2. `EXPLAIN ANALYZE` 是一次真实样本，不是未来流量的预言；
3. 修复顺序是语义正确 → 数据/统计正确 → 估算合理 → 成本假设校准，最后才考虑 hint-like 强制手段。

把 optimizer 当成使用不完整信息的工程决策器，比把它人格化为“聪明/愚蠢”更有用。计划异常通常意味着输入证据或成本假设与现实不匹配，而不是数据库在随机选择。

---

[返回本章目录](../) · [下一节：MVCC 与可见性](../02/) ·
[查看全书目录](/toc/) · [查看索引中心](/indexes/)
