跳转到主要内容

2 规划与执行

跟踪查询从解析到执行的过程,阅读 EXPLAIN 输出,并理解基数、代价估计、预备语句和游标。

1. 简单查询协议

先从最简单的情况开始:当你在 psql 中输入一条 SELECT 命令时会发生什么?查询处理会经历以下几个阶段:

  1. 解析

  1. 重写

  1. 规划(优化)

  2. 执行

解析

语法分析

第一步是解析:PostgreSQL 将查询文本拆分为标识符、数字、字符串字面量等词法单元,并检查这些词法单元能否组成语法有效的结构。

以一条简单查询为例:

SELECT  schemaname, tablename
FROM pg_tables
WHERE tableowner = 'postgres'
ORDER BY tablename;
查询树将目标列表、FROM 子句、排序子句以及 pg_tables 上的 tableowner 过滤条件分开。
图 2.1 语法分析后的查询树

在这个阶段,后端会在内存中构建一棵语法解析树(parse tree)。图中展示了这棵树的简化版本。颜色表示查询文本各部分与树中节点之间的大致对应关系。

RTE 是范围表项(Range Table Entry)的缩写,这个名称从外部看并不直观。PostgreSQL 在内部使用 RTE 表示表、子查询、连接结果以及 SQL 查询中可能出现的其他行集。

语义分析

接下来是语义分析,在 PostgreSQL 中称为解析分析(parse analysis)。在这个阶段,PostgreSQL 会确定查询引用了哪些数据库对象,以及用户是否有权访问这些对象。

SELECT  schemaname, tablename
FROM pg_tables
WHERE tableowner = 'postgres'
ORDER BY tablename;

pg_tables 的范围表项被解析为对象标识符,并检查访问权限。
图 2.2 语义分析与访问控制

在解析分析期间,PostgreSQL 会查询系统目录。在这个例子中,它把名称 pg_tables 解析为目录中由 oid 标识的某个具体视图,同时也会检查该视图上的访问权限。

重写

然后,查询会根据规则进行重写。

其中一个重要的特殊情况是视图:对视图的引用会展开为定义该视图的查询。严格来说,处理过程并不像这个简化序列所暗示的那样泾渭分明,因为视图定义本身也必须经过解析和分析。

对于我们的示例,可以近似地把结果画成下面这样:

SELECT  schemaname, tablename
FROM (
  SELECT ...
  FROM pg_class c
    LEFT JOIN pg_namespace n
      ON n.oid = c.relnamespace
    LEFT JOIN pg_tablespace t
      ON t.oid = c.reltablespace
  WHERE c.relkind = 'r'::"char"
)
WHERE tableowner = 'postgres'
ORDER BY tablename;
pg_tables 视图展开为对 pg_class、pg_namespace 和 pg_tablespace 的查询。
图 2.3 查询重写期间展开视图

这里的 SQL 仅用于说明。重写操作的对象是查询树,而不是替换 SQL 文本。在树中,与视图查询对应的节点会挂接到 pg_tables 的 RTE 下方。

规划

接下来 PostgreSQL 会规划查询。

规划器(planner)会考虑执行查询的不同方式,并通过数学模型估计它们的代价。这个模型使用有关数据的信息,称为统计信息(statistics)。估计代价最低的候选方案会表示为一棵计划树。

我们的示例对应的计划片段如下:

Sort
  Sort Key: c.relname
  ->  Nested Loop Left Join
        Join Filter: (n.oid = c.relnamespace)
        ->  Seq Scan on pg_class c
              Filter: ((relkind = 'r'::"char") AND pg_ge...
        ->  Seq Scan on pg_namespace n
一个规划好的语句对 pg_class 和 pg_namespace 进行顺序扫描,通过嵌套循环连接两者,再对输出排序。
图 2.4 从查询树到执行计划

Seq Scan 从表中读取行,而 Nested Loop 连接两个行集。图中的文本表示形式就是 EXPLAIN 命令生成的形式。后续章节会详细讨论访问方法和连接方法。

注意,原来的三张表中现在只剩下两张:规划器已经确定其中一张表不是生成结果所必需的,因此将它从计划中移除了。

执行

最后,执行器运行选定的计划,并将结果返回给客户端。

计划树的工作方式类似一条流水线。下图重新绘制了计划树,只保留其基本结构。

顺序扫描访问目录数据,嵌套循环连接两者的行,排序节点再按关系名排列结果。
图 2.5 执行计划节点

执行从树的根节点开始。在这个例子中,根节点是 Sort,它向子节点请求数据。获得所需数据后,根节点执行自己的操作并将行向上返回,最终送达客户端。

Nested Loop 这样的节点会组合来自不同来源的数据。连接节点向两个子节点请求行,根据连接条件比较这些行,然后把匹配的行对返回给父节点。下方的 Seq Scan 节点从对应的表中读取行,并将行向上交给连接节点。

有些节点必须读取完所需的全部输入后才能返回任何结果。普通排序就是一个例子:它必须先看到输入,才能确定排序后的顺序。

另一些节点可以随着行到达而返回行。例如,表扫描通常可以在读取表的同时产生行。这样就有可能迅速返回结果的前一部分,例如应用程序逐页显示数据时。PostgreSQL 文档把这种执行器机制称为按需拉取的流水线(demand-pull pipeline):父节点向子节点请求下一行,这个请求沿着计划树向下传播。

查询文本依次经过解析、重写、规划和执行;目录、规则和统计信息为这些阶段提供信息。
图 2.6 查询处理阶段

2. 扩展查询协议

默认情况下,客户端使用简单查询协议:它向服务器发送一条 SQL 语句,然后对于 SELECT 或带有 RETURNING 的命令接收结果行。

例如:

SELECT model FROM airplanes WHERE airplane_code = '77W';
      model
------------------
 Boeing 777-300ER
(1 row)

使用简单查询协议时,即使同一条查询被反复执行,每次查询仍然会经历上面描述的全部处理阶段。

PostgreSQL 还提供扩展查询协议,把查询处理分成更明确的步骤。在实践中,这使得准备语句和使用游标成为可能。

预备语句

为了准备一条语句,客户端向服务器发送一个查询,其中可以包含参数。服务器会解析、分析并重写查询,然后在后端进程中保留预备语句状态。

要执行预备语句,客户端为它的参数提供值。然后,PostgreSQL 可以构建一份考虑了这些具体值的自定义计划(custom plan)并执行它。

准备语句可以避免在同一会话中反复执行同一条语句时重复解析、分析和重写。

如果语句没有参数,那么没有必要为每次执行重新规划,因此 PostgreSQL 可以复用一份通用计划(generic plan)。

如果语句确实有参数,PostgreSQL 起初可以使用自定义计划;当它估计通用计划的平均表现不会显著变差时,之后也可能切换到通用计划。我们会在 第六章“统计信息”中回到这个决策。

预备语句也是避免 SQL 注入的重要手段:来自不可信来源的值可以作为参数单独发送,而不必拼接进 SQL 文本。这种保护适用于作为参数传递的值;对于表名、列名等动态 SQL 结构,仍然必须采用安全方式构造。

准备一条参数化语句:

PREPARE model(varchar) AS
  SELECT model
  FROM airplanes
  WHERE airplane_code = $1;
PREPARE

按名称执行它:

EXECUTE model('CR7');
       model
-------------------
 Bombardier CRJ700
(1 row)

在执行时,参数值会被绑定,语句按需进行规划,然后执行。

可以通过 pg_prepared_statements 视图检查预备语句:

SELECT * FROM pg_prepared_statements \gx
-[ RECORD 1 ]---+------------------------------
name            | model
statement       | PREPARE model(varchar) AS    +
                |   SELECT model               +
                |   FROM airplanes             +
                |   WHERE airplane_code = $1;
prepare_time    | 2026-09-25 17:14:48.990998+03
parameter_types | {"character varying"}
result_types    | {text}
from_sql        | t

使用通用计划的执行次数

generic_plans   | 0
custom_plans    | 1

使用自定义计划的执行次数

最后两列显示该语句使用通用计划和自定义计划的次数。当预备语句不再需要时,可以使用 DEALLOCATE 将其移除。

PREPARE、EXECUTE 和 DEALLOCATE 是 SQL 命令。其他编程语言以不同方式提供相同的功能,但数据库驱动最终都会使用相同的客户端/服务器协议。

游标

游标可以让我们逐步获取查询结果,而不是一次接收所有行。游标很常见,因为应用程序通常使用命令式语言编写,并且经常迭代处理结果集。

打开游标的信息保存在后端进程中。

在 SQL 中声明游标并获取第一行。游标通常存在于事务内部:

BEGIN;
BEGIN
DECLARE c CURSOR FOR SELECT * FROM airplanes;
DECLARE CURSOR
FETCH c;
 airplane_code |      model      | range | speed
---------------+-----------------+-------+-------
 32N           | Aerobus A320neo |  6500 |   830
(1 row)

获取第二行(事务结束时游标会自动关闭):

FETCH c;
 airplane_code |        model        | range | speed
---------------+---------------------+-------+-------
 339           | Aerobus A330-900neo | 13300 |   870
(1 row)
COMMIT;
COMMIT

数据库 API 提供了判断是否还有结果数据的方法,因此应用程序可以在循环中从游标获取数据。

PREPARE 解析并重写参数化查询;EXECUTE 绑定参数,使用通用计划或自定义计划,并通过执行产生部分结果。
图 2.7 预备语句与游标执行

3. 深入了解规划

规划值得进一步了解,因为它既是查询性能的核心,也是查询处理过程中较为复杂的阶段之一。

规划过程

规划器会探索不同的执行计划,估计它们的代价,并从中选择找到的代价最低者。

计划节点的代价取决于正在执行的操作以及它所处理的数据的性质。显然,按顺序从表中读取行与通过索引获取行使用的是不同的代价模型,而正在处理的数据量也非常重要。

估计数据量时,有两个概念尤其重要:

  • 基数(cardinality):节点产生的行数估计值

  • 选择率(selectivity):满足某个条件或谓词的输入行所占的比例

估计选择率和基数需要有关数据的信息:表的大小、列中的值分布等等。在实践中,这会把我们引向统计信息。PostgreSQL 通过 autovacuum 的自动分析工作以及 ANALYZE 命令收集和刷新统计信息。基数错误是造成糟糕计划的最重要原因之一。

基数错误可能来自统计信息不足,可能来自现有统计信息没有描述的相关性,可能来自没有可用有效统计信息的表达式,也可能来自规划器估计模型的局限。即使基数估计正确,也不能保证代价估计完美——代价模型及其参数都是近似值——但基数是后续几乎每项规划决策的重要输入。

基数估计

基数估计是递归进行的。为了估计某个节点的基数,PostgreSQL 会先估计其子节点的基数,然后根据节点类型和节点的谓词估计该节点本身会产生多少行。

这个计划结合两个目录扫描的行数估计和选择率估计、它们的嵌套循环连接以及最后的排序。
图 2.8 基数和选择率估计

这个过程从叶节点开始,访问方法在叶节点读取数据。估计叶节点需要知道关系的大小以及其谓词的选择率。后续章节会说明这些估计来自哪里。

暂且假设我们可以估计简单谓词的选择率。这样,就可以使用如下公式近似计算复合谓词的选择率:

sel⁡x and y=sel⁡xsel⁡ysel⁡x or y=1−(1−sel⁡x)(1−sel⁡y) \begin{aligned} \operatorname{sel}_{x\ \mathrm{and}\ y} = \operatorname{sel}_x\operatorname{sel}_y \\ \operatorname{sel}_{x\ \mathrm{or}\ y} = 1 - (1 - \operatorname{sel}_x)(1 - \operatorname{sel}_y) \end{aligned}

这些公式假定条件 x 和 y 相互独立。如果谓词之间存在相关性,估计就可能出错。PostgreSQL 的扩展统计信息可以改善其中一些情况。

接下来是连接。我们已经有了两个输入行集的基数估计,因此还需要估计连接条件的选择率。具体细节暂且留到后面,之后会回到具体示例。

其他节点也可以用类似方式处理,例如聚合。排序通常保持基数不变,而其他操作可能会改变基数。

计划树底部引入的估计误差会向上传播。随着更多操作符依赖错误估计,误差可能会被放大,最终导致规划器选择一个糟糕的计划。

代价估计

代价估计同样是递归进行的。为了估计计划子树的代价,PostgreSQL 会从子节点的代价开始,再加上当前节点执行操作的代价。

同一计划展示每次扫描、嵌套循环和排序的启动代价与总代价。
图 2.9 启动代价与总代价估计

节点的代价由规划器的数学模型计算得出,该模型使用了为节点估计的基数和其他属性。

EXPLAIN 展示两个代价组成部分:启动代价定义了节点开始产生行之前必须完成的工作量估计值;总代价定义了产生全部行的代价估计值。

有些操作几乎不需要启动工作。另一些操作则必须在返回第一行之前完成大量工作。

例如,普通排序必须先读取要排序的输入,才能输出排好序的结果。这样的节点具有非零启动代价,即使消费者只需要最终结果中的一小部分,也必须支付这部分代价。

必须理解,代价反映的是规划器的估计,而且可能与实际执行时间没有关联。可以把代价看作用任意单位表示的量,这些单位本身没有意义。代价的用途只是让规划器能够比较同一查询的不同计划。

选择最佳计划

优化器会尝试在不过度耗费规划时间的前提下找到最佳计划。

对于连接,PostgreSQL 通常使用动态规划:从较小的连接关系构建更大的连接关系,同时丢弃那些按照规划器标准不可能胜出的替代方案。随着关系数量增加,穷举考虑所有连接顺序的代价会变得过高。PostgreSQL 可以通过只考虑部分成对连接方案,或切换到遗传查询优化器(Genetic Query Optimizer,GEQO),来减少需要考虑的计划数量。这又带来了计划不佳的另一种可能来源:问题不在于代价估计错误,而在于最佳计划从未被考虑。在这种情况下,查询作者有时可以通过约束连接顺序来提供帮助。

对于普通查询,什么样的计划算是“最佳”计划?PostgreSQL 通常会优化获取完整结果的过程,因此总代价占主导地位。

然而,使用游标时,快速获得前几行可能更加重要。参数 cursor_tuple_fraction(默认值为 0.1)告诉规划器预计会获取游标结果的多少比例。这个值越小,启动代价相对于总代价的影响就越大。

查看查询计划

EXPLAIN 会显示查询计划。它是查询性能工作不可或缺的工具,学会正确阅读它十分重要。下面是上面使用过的查询对应的计划:

EXPLAIN SELECT schemaname, tablename
FROM pg_tables
WHERE tableowner = 'postgres'
ORDER BY tablename;
                         QUERY PLAN
------------------------------------------------------------
 Sort  (cost=22.36..22.37 rows=1 width=128)
   Sort Key: c.relname
   -> Nested Loop Left Join
       (cost=0.00..22.35 rows=1 width=128)
       Join Filter: (n.oid = c.relnamespace)
       -> Seq Scan on pg_class c
           (cost=0.00..21.26 rows=1 width=72)
           Filter: ((relkind = ANY ('{r,p}'::"char"[])) ...
       -> Seq Scan on pg_namespace n
           (cost=0.00..1.04 rows=4 width=68)
(10 rows)

加入 analyze 后,PostgreSQL 会执行查询,并显示实际执行统计信息以及估计值:

EXPLAIN (analyze, buffers off) SELECT schemaname, tablename
FROM pg_tables
WHERE tableowner = 'postgres'
ORDER BY tablename;
                         QUERY PLAN
------------------------------------------------------------
 Sort  (cost=22.36..22.37 rows=1 width=128)
   (actual time=0.101..0.103 rows=77.00 loops=1)
   Sort Key: c.relname
   Sort Method: quicksort  Memory: 35kB
   -> Nested Loop Left Join
       (cost=0.00..22.35 rows=1 width=128)
       (actual time=0.009..0.080 rows=77.00 loops=1)
       Join Filter: (n.oid = c.relnamespace)
       Rows Removed by Join Filter: 112
       -> Seq Scan on pg_class c
           (cost=0.00..21.26 rows=1 width=72)
           (actual time=0.006..0.039 rows=77.00 loops=1)
           Filter: ((relkind = ANY ('{r,p}'::"char"[])) ...
           Rows Removed by Filter: 382
       -> Seq Scan on pg_namespace n
           (cost=0.00..1.04 rows=4 width=68)
           (actual time=0.000..0.000 rows=2.45 loops=77)
 Planning Time: 0.080 ms
 Execution Time: 0.113 ms
(19 rows)

对会修改数据的语句使用 EXPLAIN ANALYZE 时要小心:该语句确实会被执行。

在某些平台上,为每个计划节点收集计时信息可能会明显拖慢查询。如果不需要每个节点的计时信息,可以使用 timing off 将其禁用。

参考资源