Skip to content

PostgreSQL 查询系列 — 1. 查询执行的各个阶段

原文:https://habr.com/en/companies/postgrespro/articles/574702/ (作者 Egor Rogov,PostgresPro)

引言

这是 Egor Rogov 为 PostgresPro 撰写的"PostgreSQL 中的查询"系列文章的第一篇。整个系列打算深入剖析 PostgreSQL 是如何把一条 SQL 语句变成实际的执行结果的:从文本解析、逻辑改写、代价估算与计划选择,到真正的执行引擎如何一步步把数据"抽"出来。本篇作为开篇,先给出整体的路线图——一条 SQL 语句从进入服务器到返回结果,要经过哪些阶段,每个阶段大致做什么,为后续几篇分别深入"统计信息""顺序扫描""索引扫描""嵌套循环""哈希""排序与归并"打好地基。

语句处理的四个主要阶段

一条查询在 PostgreSQL 内部大体要走过四个阶段:解析(parse)→ 改写(rewrite)→ 规划/优化(plan)→ 执行(execute)。

1)解析:从文本到语法树

解析本身又分为两步:

  • 词法与语法分析:借助 Flex 生成的词法分析器把 SQL 文本切成一个个词素(关键字、标识符、常量等),再用 Bison 生成的语法分析器按照 SQL 文法把这些词素组织成一棵抽象语法树(parse tree)。这一步只关心"这句话写得符不符合语法",完全不关心表是否存在、字段类型对不对。
  • 语义分析:拿着这棵语法树去系统目录(catalog)里核实——引用的表、视图、字段是否真实存在,当前用户是否有权限访问它们;同时把节点上的类型信息、对象 OID 等补全,让树从"纯语法结构"变成一棵携带了真实语义信息的树。

也就是说,第一阶段结束时,我们得到的是一棵已经通过校验、绑定了具体数据库对象的解析树,但它还只是对原始 SQL 意图的直译,还没有被做任何"改写"或"优化"。

2)改写:查询重写与规则系统

在真正开始考虑"怎么执行更快"之前,PostgreSQL 会先对解析树做一遍改写(rewrite),常见的用途包括:

  • 把查询里引用到的视图名展开成视图背后真正的查询定义,拼接进当前的树里;
  • 应用行级安全策略(Row-Level Security),把额外的过滤条件混入查询;
  • 处理递归查询中的 SEARCH 和 CYCLE 子句(PostgreSQL 14 引入),保证递归遍历顺序和防环逻辑正确。

这套改写机制的底层依托的是 PostgreSQL 的规则系统(rule system),它允许用户自定义在特定操作发生时如何对查询进行变换。文章特别提醒:规则系统固然强大,但调试和理解成本都很高,属于一种"能用但要谨慎用"的机制。视图本质上就是通过规则系统实现的(CREATE VIEW 背后会生成一条对 SELECT 的规则)。

3)规划(优化):从"做什么"到"怎么做"

改写之后得到的树仍然只描述了"要做什么"(select 哪些列、join 哪些表、过滤什么条件),并不涉及"具体怎么取数据、按什么顺序 join"。规划阶段(planner/optimizer)的任务就是把这棵树转成一棵可执行的计划树,计划树上的每个节点对应一种具体的物理操作(顺序扫描、索引扫描、嵌套循环、哈希连接、排序等)。

PostgreSQL 采用的是基于代价的优化(cost-based optimization):优化器会枚举多种可能的执行策略,给每一种都算出一个数字化的"代价估计值",然后选代价最低的那个。

计划空间的爆炸与裁剪:随着涉及的表数量增多,可能的连接顺序、连接算法组合会呈指数级增长,穷举变得不现实。PostgreSQL 用了几种手段控制搜索空间:

  • 采用动态规划配合启发式规则来决定表的连接顺序;
  • 参数 join_collapse_limit(默认 8)控制显式 JOIN 结构在多大程度上会被"拍平"参与整体的连接顺序搜索;
  • 参数 from_collapse_limit 类似地控制子查询被展开、纳入外层优化范围的限度;
  • 当参与连接的表(或子查询等价物)数量超过 geqo_threshold(默认 12)时,优化器会切换到遗传算法(GEQO)去启发式地搜索一个"足够好"而非"全局最优"的连接顺序,以避免搜索时间本身失控。

两套代价指标:计划树上每个节点都会同时给出两个代价数字——

  • 启动代价(startup cost):在这个节点能吐出第一行结果之前,必须先付出的代价(比如排序节点要先把所有输入排完序);
  • 总代价(total cost):把这个节点的全部结果都取完所需要的总代价。

选用哪一种代价作为决策依据,取决于查询的使用场景:如果只是想要完整结果集,优化器会以总代价为主要目标;但如果查询是通过游标(cursor)声明的,客户端可能只想尽快拿到前面一部分数据,这时候优化器会更看重启动代价,用参数 cursor_tuple_fraction(默认 0.1)来描述"预期只取整个结果集的多大比例",从而在两种代价之间做权衡。

4)执行:计划树如何真正跑起来

执行阶段,PostgreSQL 会创建一个 portal 对象来维护执行状态,这个 portal 内部对应着一棵和计划树结构一一对应的执行状态树。执行模型是经典的"拉模型"(pull-based,也叫火山模型/迭代器模型):每个节点像流水线上的一环,父节点向子节点"要一行",子节点内部处理完(可能又要向自己的子节点要数据)后把一行结果吐给父节点,父节点再对这行做自己该做的处理并继续向上传递。整个过程是逐行、按需进行的,而不是一次性把所有中间结果物化出来(当然某些节点,如排序、哈希,天然需要先把输入攒够才能开始吐结果,这就是前面提到的"启动代价"往往很高的原因)。

内存与临时文件:像排序、哈希这类需要在内存里缓冲数据的节点,各自会申请一份 work_mem(默认 4MB)大小的工作内存。注意这是"每个操作"各自独立申请的额度,一条复杂查询里如果同时存在多个排序/哈希节点(甚至并行执行时每个 worker 也各自申请),实际总内存消耗可能是 work_mem 的好几倍。一旦某个节点需要缓冲的数据量超过了它拿到的 work_mem,多余部分就会溢出(spill)到磁盘上的临时文件中,代价随之上升。

扩展查询协议:为什么会有"计划缓存"

上面说的是查询处理的"简单协议"路径(一条 SQL 文本进来,走完解析-改写-规划-执行)。但 PostgreSQL 客户端(尤其是使用预备语句 PREPARE / 参数化查询的场景)还支持扩展查询协议(extended query protocol),这套协议把查询的生命周期拆得更细:

  • 准备阶段(Parse):只做一次解析和改写,得到的语法树被缓存在服务器内存里,后续可以反复复用,不用每次都重新解析 SQL 文本。

  • 参数绑定(Bind):把语句里的占位符替换成客户端传入的实际参数值。因为树的结构本身在绑定阶段不会被改变,这天然杜绝了 SQL 注入攻击的可能。

  • 计划选择策略:这里有一个非常实用的细节——对于带参数的预备语句,PostgreSQL 并不会永远只用"通用计划"(generic plan,不知道具体参数值、按参数的统计特征做估算)。规则是:

    • 5 次执行(严格说是前 5 次里的前几次会先各自生成一个定制计划,即拿到具体参数值后专门为这次参数值定制的执行计划),并统计这些定制计划的平均代价;
    • 从第 5 次执行开始,如果测算下来"通用计划"的代价比"定制计划"的平均代价更低,PostgreSQL 就会把通用计划缓存下来,之后固定复用它,省去每次都重新规划的开销;否则就继续为每次不同的参数值单独生成定制计划。
    • PostgreSQL 12 引入的参数 plan_cache_mode 可以让用户手动强制使用 force_generic_plan(总是通用计划)或 force_custom_plan(总是定制计划),绕开这套自动判断逻辑。
  • 结果获取(Execute / Fetch):客户端可以按批次(比如每次取 100 行)拉取结果,而不必一次性把整个结果集都传回来,这样能降低单次网络往返的负担,也便于配合游标做增量处理。

统计信息是代价估算的根基

不管是简单协议还是扩展协议,代价估算的准确与否最终都取决于优化器手头掌握的统计信息——表有多少行、多大、每一列的值分布是什么样子。基数估算(cardinality estimation,即"某个节点大概会产出多少行")本质上是一个自底向上、逐层递推的过程:

  1. 先算出子节点大概会产出多少行;
  2. 再估算当前节点的"选择率"(selectivity),也就是这一层的条件大概会让多大比例的输入行留下来;
  3. 用子节点的行数乘以这个选择率,就得到了当前节点的估算行数。

这个过程是逐层向上传递的,如果底层的行数估计一旦出现偏差,误差会沿着整棵计划树向上累积、放大。业内共识(也是本系列后续文章反复强调的一点)是:基数估算不准,是绝大多数"烂计划"背后真正的元凶,比调整某个 cost 参数本身更值得关注。这也是为什么下一篇文章要专门讲统计信息(pg_statistic/pg_stats、直方图、MCV 列表、扩展统计等)。

小结

本篇文章搭建了整个系列的骨架:一条 SQL 语句要依次经过解析(词法/语法分析 + 语义分析)、改写(视图展开、行级安全、递归查询处理,底层依赖规则系统)、规划(基于代价的搜索,用 join_collapse_limit/from_collapse_limit/geqo_threshold 等参数控制搜索空间,产出带 startup/total 两种代价的计划树)、执行(拉模型的逐行流水线,work_mem 决定排序/哈希类节点是否需要落盘)四个阶段。此外,通过预备语句触发的扩展查询协议还引入了"通用计划 vs 定制计划"的自适应选择机制。最重要的一点是:整套优化器的判断力都建立在统计信息的准确性之上,这正是第二篇文章要展开的主题。也需要提醒一句:不同查询之间的 cost 数值并不具备可比性,cost 只在"同一条查询、比较不同执行策略"这个语境下才有意义。