5.1 SQL 从文本到结果
SQL 是声明式语言:调用者描述需要的关系结果和允许的变更,PostgreSQL 决定怎样执行。这个抽象让应用不必把“先扫哪张表、用哪个索引”写死,却也带来一个常见误区——把 SQL 文本、逻辑语义、执行计划和某次运行表现混成同一件事。本节先把四层拆开。
5.1.1 解析、重写、规划与执行
一条 SQL 从客户端到结果并不是“解析后立刻跑”。对普通查询,可以用下面的主干理解:
SQL text
→ raw parsing
→ parse analysis / transformation
→ rewrite
→ planning / optimization
→ executionraw 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,它在执行期间按触发时点工作。
这一区分有两个工程后果:
- view 后面仍要规划、执行和做 MVCC 可见性判断,普通 view 本身不是结果缓存;
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 的计划选择,不在这里提前给“预编译一定更快”之类错误结论。
用当前订单查询做一个边界观察:
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 关系代数直觉与执行节点
理解 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。
用树而不是“执行步骤清单”阅读计划
假设计划形状为:
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”不等于全计划只保留一行内存。
读计划时来回走两遍:
- 自下而上:base relation 如何进入 join/aggregate,数据量怎样放大或缩小;
- 自上而下:最终排序、LIMIT 和输出要求向子树施加了什么 property。
第 7 章会加入 estimated rows、actual rows、loops、buffers、memory、I/O timing 等证据。此处只要求先能指出“哪个节点实现哪个逻辑责任”。
SQL 文本顺序不保证物理顺序
inner join 在满足语义等价时可以重排;predicate 可以下推;subquery 可能被 pull up;CTE 是否 materialize 取决于语义、引用方式和显式关键字。不要把:
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 优化器为什么做估算而不是预言
planner 必须在执行前作选择,因此只能利用当时可得的信息估算候选 path。核心链条是:
table cardinality + column statistics + predicates
→ selectivity estimate
→ rows / width estimate at every node
→ CPU + page + parallel + startup/total cost
→ choose expected cheapest pathcost=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 章会用:
estimate ratio = max(actual rows / estimated rows,
estimated rows / actual rows)逐层寻找第一次显著偏离,并把它与统计和 predicate 对上。第 8 章则先判断 wall time 消耗在 CPU、I/O、lock、WAL、client 还是连接队列。现在只记住三条:
- estimated cost 只在同一 planning context 下比较候选,不跨服务器当 benchmark;
EXPLAIN ANALYZE是一次真实样本,不是未来流量的预言;- 修复顺序是语义正确 → 数据/统计正确 → 估算合理 → 成本假设校准,最后才考虑 hint-like 强制手段。
把 optimizer 当成使用不完整信息的工程决策器,比把它人格化为“聪明/愚蠢”更有用。计划异常通常意味着输入证据或成本假设与现实不匹配,而不是数据库在随机选择。
返回本章目录 · 下一节:MVCC 与可见性 · 查看全书目录 · 查看索引中心