第 17 章 理解性能
查询性能会受到许多因素的影响。其中一些可以由用户操纵,而另一些则是系统底层设计的根本所在。
一些性能问题(例如索引创建和批量数据装载)在别处讨论。本章将讨论EXPLAIN命令,并展示查询的细节如何影响查询计划,从而影响整体性能。
17.1. 使用EXPLAIN
作者
由 Tom Lane 撰写,摘自 2000-03-27 的电子邮件。
计划阅读是一门值得写一篇教程的艺术,而我还没来得及写。下面是一些快速而粗糙的解释。
目前 EXPLAIN 给出的数字包括:
估计的启动代价(在输出扫描可以开始之前花费的时间,例如在 SORT 节点中执行排序的时间)。
估计的总代价(如果检索所有元组——实际可能不会,例如 LIMIT 会在付出总代价之前停止)。
该计划节点估计输出的行数。
该计划节点估计输出的行的平均宽度(字节)。
代价以磁盘页获取次数为单位度量。(CPU 工作量估计用一些相当任意的主观因子换算成磁盘页单位。如果你想试验这些因子,请参阅
SET参考页。)重要的是要注意,上层节点的代价包括其所有子节点的代价。同样重要的是要认识到,代价只反映规划器/优化器关心的东西。特别地,代价不考虑把结果元组传输到前端所花的时间——这可能是实际耗时的一个相当主要的因素,但规划器忽略它,因为它无法通过改变计划来改变它。(我们相信每个正确的计划都会输出相同的元组集。)
输出行数有点棘手,因为它不是查询处理/扫描的行数——它通常更少,反映在该节点上应用的所有 WHERE 子句约束的估计选择率。
平均宽度是相当虚假的,因为系统实际上并不知道变长列的平均长度。我在考虑将来改进这一点,但可能不值得费这个麻烦,因为宽度的用途不多。
下面是一些例子(使用经过 vacuum analyze 的回归测试数据库,以及接近 7.0 的源代码):
regression=# explain select * from tenk1;
NOTICE: QUERY PLAN:
Seq Scan on tenk1 (cost=0.00..333.00 rows=10000 width=148)
这是最直白的情况。如果你执行
select * from pg_class where relname = 'tenk1';
你会发现 tenk1 有 233 个磁盘页和 10000 个元组。所以代价估计为
233 次块读取(每次定义为 1.0),加上 10000 * cpu_tuple_cost(当前为 0.01,试试 show cpu_tuple_cost)。
现在让我们修改查询,加一个限定子句:
regression=# explain select * from tenk1 where unique1 < 1000;
NOTICE: QUERY PLAN:
Seq Scan on tenk1 (cost=0.00..358.00 rows=1000 width=148)
由于 WHERE 子句,输出行数的估计下降了。(估计异常准确只是因为 tenk1 是一个特别简单的例子——unique1 列有 10000 个从 0 到 9999 的不同值,所以估计器在列的最小值和最大值之间做的线性插值正好命中。)然而,扫描仍然必须访问全部 10000 行,所以代价没有降低;实际上它还略微上升了,以反映检查 WHERE 条件额外花费的 CPU 时间。
再修改查询,进一步收紧限定:
regression=# explain select * from tenk1 where unique1 < 100;
NOTICE: QUERY PLAN:
Index Scan using tenk1_unique1 on tenk1 (cost=0.00..89.35 rows=100 width=148)
你会看到,如果我们让 WHERE 条件足够有选择性,规划器最终会判定索引扫描比顺序扫描便宜。由于索引,这个计划只需访问 100 个元组,所以尽管每次单独的获取很昂贵,它仍然胜出。
再给限定加一个条件:
regression=# explain select * from tenk1 where unique1 < 100 and
regression-# stringu1 = 'xxx';
NOTICE: QUERY PLAN:
Index Scan using tenk1_unique1 on tenk1 (cost=0.00..89.60 rows=1 width=148)
添加的子句 "stringu1 = 'xxx'" 降低了输出行数的估计,但没有降低代价,因为我们仍然要访问同一批元组。
让我们试着用一直在讨论的字段连接两个表:
regression=# explain select * from tenk1 t1, tenk2 t2 where t1.unique1 < 100
regression-# and t1.unique2 = t2.unique2;
NOTICE: QUERY PLAN:
Nested Loop (cost=0.00..144.07 rows=100 width=296)
-> Index Scan using tenk1_unique1 on tenk1 t1
(cost=0.00..89.35 rows=100 width=148)
-> Index Scan using tenk2_unique2 on tenk2 t2
(cost=0.00..0.53 rows=1 width=148)
在这个嵌套循环连接中,外层扫描与倒数第二个例子中的索引扫描相同,所以其代价和行数也相同,因为我们在该节点上应用的是
"unique1 < 100" WHERE 子句。"t1.unique2 = t2.unique2" 子句此时还不相关,所以它不影响外层扫描的行数。对于内层扫描,当前外层扫描元组的 unique2 值被代入内层索引扫描,产生一个形如 "t2.unique2 = 常量" 的索引限定。所以我们得到的内层扫描计划和代价,与例如 "explain
select * from tenk2 where unique2 = 42" 得到的相同。循环节点的代价随后在外层扫描代价的基础上设定,加上每个外层元组一次的内层扫描重复(这里是 100 * 0.53),再加上一点用于连接处理的 CPU 时间。
在这个例子中,循环的输出行数是两个扫描行数的乘积,但一般并非如此,因为一般你可能会有同时提到两个关系的 WHERE 子句,它们只能在连接点上应用,而不能应用于任一输入扫描。例如,如果我们加上 "WHERE ... AND t1.hundred < t2.hundred",那会降低连接节点的输出行数,但不改变任一输入扫描。
我们可以通过强制规划器无视它认为获胜的策略来查看不同的计划(一个相当粗糙的工具,但我们目前只有这个):
regression=# set enable_nestloop = off;
SET VARIABLE
regression=# explain select * from tenk1 t1, tenk2 t2 where t1.unique1 < 100
regression-# and t1.unique2 = t2.unique2;
NOTICE: QUERY PLAN:
Hash Join (cost=89.60..574.10 rows=100 width=296)
-> Seq Scan on tenk2 t2
(cost=0.00..333.00 rows=10000 width=148)
-> Hash (cost=89.35..89.35 rows=100 width=148)
-> Index Scan using tenk1_unique1 on tenk1 t1
(cost=0.00..89.35 rows=100 width=148)
这个计划提议用同样的老索引扫描提取 tenk1 的 100 个感兴趣的行,把它们存入一个内存散列表,然后对 tenk2 做顺序扫描,在每个 tenk2 元组上探查散列表以寻找 "t1.unique2 = t2.unique2" 的可能匹配。读取 tenk1 并建立散列表的代价完全是散列连接的启动代价,因为在我们能开始读取 tenk2 之前不会得到任何元组。连接的总时间估计还包括探查散列表 10000 次的相当可观的 CPU 时间费用。但注意,我们没有收取 10000 次 89.35;在这种计划类型中散列表的建立只做一次。