跳至内容

5.1 SQL 从文本到结果

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

5.1.1 解析、重写、规划与执行

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

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_parsedebug_print_rewrittendebug_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 表达可能的物理节点
选择行WHEREscan 中的 index condition / filter
投影列/表达式SELECT listscan、Result 或上层节点计算
连接关系JOINNested Loop、Hash Join、Merge Join
分组聚合GROUP BYHashAggregate、GroupAggregate
排序ORDER BYSort、Incremental Sort,或有序 index path
去重DISTINCTUnique、aggregate、排序/哈希组合
限制结果LIMITLimit,但子节点可能已经做了大量工作

逻辑操作与物理节点不是一一对应。例如,一个 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”不等于全计划只保留一行内存。

读计划时来回走两遍:

  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 取决于语义、引用方式和显式关键字。不要把:

FROM a
JOIN b ...
JOIN c ...

理解成服务器必然先 a→b→c。也不要用随意设置 enable_seqscan=offjoin_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 path

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

统计是有意近似的

pg_class.reltuplesrelpages 不随每行写入实时更新;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 还是连接队列。现在只记住三条:

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

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


返回本章目录 · 下一节:MVCC 与可见性 · 查看全书目录 · 查看索引中心

最后更新于