使用EXPLAIN

OushuDB 对每个查询产生一个 查询规划 。为匹配查询结构和数据属性选择正确的规划对性能绝对有关键性的影响。 因此系统包含了一个复杂的规划器用于寻找最优的规划。 你可以使用EXPLAIN命令查看规划器为每个查询生成的查询规划是什么。 阅读查询规划是一门需要掌握一些经验的艺术,但是这节给出一些基础信息。

查询规划的结构是一个 规划节点 的树。最底层的节点是表扫描节点: 它们从表中返回原始数据行。不同的表访问模式有不同的扫描节点类型: 顺序扫描、索引扫描、位图索引扫描。如果查询需要连接、聚合、排序、或者对原始行的其它操作, 那么就会在扫描节点之上有其它额外的节点。并且,做这些操作通常都有多种方法, 因此在这些位置也有可能出现不同的节点类型。EXPLAIN给规划树中每个节点都输出一行, 显示基本的节点类型和规划器为执行这个规划节点预计的开销值。 第一行(最上层的汇总行节点)是对该规划的总执行开销的预计;这个数值就是规划器试图最小化的数值。

这里是一个简单的例子,只是用来显示输出会有些什么内容:

EXPLAIN SELECT * FROM tenk1;

                         QUERY PLAN
-------------------------------------------------------------
 Seq Scan on tenk1  (cost=0.00..458.00 rows=10000 width=244)

括号中引用的数值是:

  • 预计的启动开销。在输出扫描开始之前消耗的时间,比如在一个排序节点里执行排序的时间。
  • 预计总开销。(实际中可能不会检索所有行:比如,一个带有LIMIT 字句的查询预计开销少于Limit计划节点输入节点的总开销。)
  • 预计这个规划节点输出的行数。(被假定执行到完成为止。)
  • 预计这个规划节点的行平均宽度(以字节计算)。

开销是用规划器的成本参数决定的任意的单位来衡量的。习惯上以磁盘页面抓取为单位, 也就是seq_page_cost将被按照习惯设为1.0, 其它开销参数将参照它来设置。本节的例子都假定这些参数使用默认值。

有一点很重要:一个上层节点的开销包括它的所有子节点的开销。还有一点也很重要: 这个开销只反映规划器关心的东西。尤其是没有把结果行传递给客户端的时间考虑进去, 这个时间可能在实际的总时间里占据相当重要的分量,但是被规划器忽略了, 因为它无法通过修改规划来改变。(我们相信,每个正确的规划都将输出同样的记录集。)

行值有一些小技巧,因为它 是规划节点处理或扫描过的行数。通常会少于扫描数,正如应用于此节点上的任意WHERE子句条件的过滤结果。通常而言, 顶层的行预计会接近于查询实际返回、更新、删除的行数。

回到我们的例子:

EXPLAIN SELECT * FROM tenk1;

                         QUERY PLAN
-------------------------------------------------------------
 Seq Scan on tenk1  (cost=0.00..458.00 rows=10000 width=244)

这些数字的获得非常直截了当。如果你这样做:

SELECT relpages, reltuples FROM pg_class WHERE relname = 'tenk1';

你会发现tenk1有358个磁盘页面和 10000 行。(磁盘页面读取*seq_page_cost)+(行扫描*cpu_tuple_cost)计算。默认情况下, seq_page_cost是1.0,cpu_tuple_cost是0.01。

现在让我们修改查询并增加一个WHERE条件:

EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 7000;

                         QUERY PLAN
------------------------------------------------------------
 Seq Scan on tenk1  (cost=0.00..483.00 rows=7033 width=244)
   Filter: (unique1 < 7000)

请注意EXPLAIN输出显示WHERE子句当作一个”filter”条件附属于顺序扫描计划节点。 这意味着规划节点为它扫描的每一行检查该条件,并且只输出符合条件的行。 预计的输出行数降低了,因为有WHERE子句。不过,扫描仍将必须访问所有 10000 行, 因此开销没有降低;实际上它还增加了一位以反映检查WHERE条件的额外CPU时间。

这条查询实际选择的行数是7000 ,但是预计的行数只是个大概。如果你试图重复这个试验, 那么你很可能得到不同的预计。还有,这个预计会在每次ANALYZE命令之后改变, 因为ANALYZE生成的统计是从该表中随机抽取的样本计算的。

让我们试着连接两个表:

EXPLAIN SELECT * FROM tenk1 t1, tenk2 t2 WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;

                                      QUERY PLAN
--------------------------------------------------------------------------------------
->  Nested Loop  (cost=2.37..553.11 rows=106 width=488)
      Join Filter: t2.uniquef2 = t1.unique2
  ->  Append-only Scan on tenk2 t2  (cost=2.37..232.35 rows=106 width=244)
         ->  Append-only Scan on tenk1_unique1  (cost=0.00..2.37 rows=106 width=0)
            Filter: unique1 < 100
 Settings:  default_hash_table_bucket_number=6; enable_nestloop=on

在这个例子里,连接的输出行数与两个扫描的行数的乘积相同,但通常并不是这样的, 因为可能会有同时涉及两个表的WHERE子句,它只能应用于连接(join)点而不能是任何一个输入扫描。 例如,如果你加了WHERE … AND t1.hundred < t2.hundred,这减少了连接节点的预计输出行数, 但不改变任何一个输入扫描。

找其他计划的一个方法就是强迫规划器抛弃它认为优秀的(扫描)策略,设置每种规划类型的允许/禁止开关。这个工具目前比较原始,但很有用。

SET enable_nestloop = off;
EXPLAIN SELECT * FROM tenk1 t1, tenk2 t2 WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;

                                        QUERY PLAN
------------------------------------------------------------------------------------------
 Hash Join  (cost=232.61..741.67 rows=106 width=488)
   Hash Cond: ("outer".unique2 = "inner".unique2)
   ->  Append-only Scan on tenk2 t2  (cost=0.00..458.00 rows=10000 width=244)
   ->  Hash  (cost=232.35..232.35 rows=106 width=244)
         ->  Append-only Scan on tenk1 t1  (cost=2.37..232.35 rows=106 width=244)
               Filter: unique1 < 100
  Settings:  default_hash_table_bucket_number=6; enable_nestloop=off

我们可以用EXPLAIN的ANALYZE检查规划器的估计值的准确性。 这个命令实际上执行该查询,然后显示每个规划节点的实际行计数和实际运行时间,以及单纯的EXPLAIN显示的估计成本。 比如,我们可能会得到一个类似下面的结果:

EXPLAIN ANALYZE SELECT * FROM tenk1 t1, tenk2 t2 WHERE t1.unique1 < 100 AND t1.unique2 = t2.unique2;

                                                            QUERY PLAN
----------------------------------------------------------------------------------------------------------------------------------
 Nested Loop  (cost=2.37..553.11 rows=106 width=488) (actual time=1.392..12.700 rows=100 loops=1)
         Join Cond: (unique1 < 100)
         ->  Append-only Scan on tenk1 t1  (cost=0.00..2.37 rows=106 width=0) (actual time=0.546..0.546 rows=100 loops=1)
               Filter: (unique1 < 100)
   ->  Append-only Scan on tenk2 t2  (cost=0.00..3.01 rows=1 width=244) (actual time=0.067..0.078 rows=1 loops=100)

 Total runtime: 14.452 ms

请注意”actual time”数值是以真实时间的毫秒计的,而cost估计值则是以任意的单位; 因此它们很可能不一致。 通常最重要的事情是看是否估计行数相当接近于现实。

在一些查询规划里,一个子规划节点很可能运行多次。这种情况下,loops报告该节点执行的总数目, 而显示的实际时间和行数目是每次执行的平均值。这么做的原因是令这些数字与开销预计显示的数字具有可比性。 要乘以loops值才能获得在该节点花费的总时间。

EXPLAIN ANALYZE显示的Total runtime包括执行器启动和关闭的时间, 以及被激发的任何触发器运行时间。但它不包括分析、重写、规划的时间。对于SELECT查询,总运行时间通常会比顶层计划节点报告的总时间稍长。对于INSERT,UPDATE和DELETE命令,总运行时间可能会相当长,因为它包括处理结果行所花费的时间。在这些命令中,顶层计划节点的时间本质上是计算新行和/或查找旧行的时间,但不包括应用这些更改的时间。花费时间 触发器(如果有的话)也在顶部计划节点之外,并针对每个触发器单独显示。

EXPLAIN的结果不应该被外推到和你实际测试的场景差别巨大的其它场景中。 比如,在一个小表上的结果不能适用于大表。规划器的开销计算不是线性的, 因此它很可能对大些或者小些的表选择不同的规划。