选择 打开 改范围 完整检索页

pgsql.cc 提供对 postgresql.org 官网内容的中文翻译,由 Pigsty 团队维护。

受支持版本: 当前版本 (18) / 17 / 16 / 15 / 14
测试与开发版本: 19 / devel
不受支持的版本: 13 / 12 / 11 / 10 / 9.6 / 9.5 / 9.4 / 9.3 / 9.2 / 9.1 / 9.0
历史版本PostgreSQL 9.1 已于 2016 年 10 月结束社区维护,本页译文保留供仍在使用旧版本的读者参考。新系统请看当前版本

第 14 章 性能提示

查询性能可能受许多因素影响。其中一些因素可以由用户控制,另一些则属于系统底层设计的基本特性。本章提供一些帮助理解和调优PostgreSQL性能的提示。

14.1. 使用EXPLAIN #

PostgreSQL会为收到的每个查询制定一个查询计划。选择与查询结构和数据特性相匹配的正确计划,对获得良好的性能至关重要,因此系统内置了一个复杂的规划器来尽量选出好的计划。可以使用EXPLAIN命令查看规划器为任意查询生成的查询计划。读懂计划是一门值得用长篇教程来阐述的技艺,本节并非那样的教程,这里只给出一些基本信息。

查询计划的结构是一棵由计划节点组成的树。树的最底层是表扫描节点,它们从表中返回原始行。不同的表访问方法对应不同类型的扫描节点,例如顺序扫描、索引扫描和位图索引扫描。如果查询需要对原始行执行连接、聚合、排序或其他操作,那么扫描节点之上还会出现额外的节点来完成这些操作。同样,这些操作通常不止一种实现方式,因此这里也会出现不同的节点类型。EXPLAIN会为计划树中的每个节点输出一行,显示基本节点类型以及规划器对该计划节点执行代价的估计值。第一行(最顶层节点)给出了整个计划的估计总执行代价;规划器力图最小化的正是这个数字。

下面是一个简单的示例,仅用于展示输出的样子: [9]

EXPLAIN SELECT * FROM tenk1;

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

EXPLAIN引用的数字从左到右依次表示:

  • 估计启动开销(输出扫描开始前需要消耗的时间,例如排序节点执行排序所需的时间)

  • 估计总开销(假定取回所有行,尽管可能并不会全部取回;例如,带LIMIT子句的查询将不会付清Limit计划节点的输入节点的全部代价)

  • 该计划节点输出行数的估计值(同样,也是假定该节点会运行到结束)

  • 预计该计划节点输出行的平均宽度,以字节计

这些开销使用由规划器代价参数决定的任意单位来衡量(见第 18.7.2 节)。传统上通常以磁盘页读取作为代价单位;也就是说,惯例上将seq_page_cost设为1.0,其他代价参数都相对它来设定。(本节中的示例都使用默认代价参数运行。)

需要注意的是,上层节点的开销包含了其所有子节点的开销。还要注意,这个开销只反映规划器关心的内容。特别是,它没有考虑将结果行传输给客户端所消耗的时间,而这些在实际耗时中可能是重要因素;但规划器会忽略这些代价,因为它无法通过改变计划来影响它们。(我们相信每个正确的计划都会输出相同的行集。)

rows值有些容易误解,因为它不是计划节点处理或扫描过的行数。它通常更小,反映了在该节点上应用的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,因此估计代价就是 (358 * 1.0) + (10000 * 0.01) = 458。

现在修改原始查询,添加一个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子句被作为一个过滤器条件;这表示计划节点会对扫描到的每一行检查该条件,并且只输出满足条件的行。由于有了WHERE子句,估计输出行数减少了。但扫描仍需访问全部 10000 行,因此代价没有下降;实际上还略有上升(精确地说,增加了 10000 * cpu_operator_cost),以反映检查WHERE条件所消耗的额外 CPU 时间。

这条查询实际选出的行数是 7000,但估计的rows只是近似值。如果重复这个实验,很可能会得到略有不同的估计值。此外,由于ANALYZE生成的统计信息来自该表的随机采样,这个估计值可能在每次执行ANALYZE之后发生变化。

现在让条件更严格一些:

EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100;

                                  QUERY PLAN
------------------------------------------------------------------------------
 Bitmap Heap Scan on tenk1  (cost=2.37..232.35 rows=106 width=244)
   Recheck Cond: (unique1 < 100)
   ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..2.37 rows=106 width=0)
         Index Cond: (unique1 < 100)

这里规划器决定使用两步计划:底层计划节点访问索引,找出匹配索引条件的行的位置,然后上层计划节点再从表本身取出这些行。分别取出各行的代价远高于顺序读取,但由于不必访问表的所有页,这仍然比顺序扫描便宜。(使用两层计划的原因是,上层计划节点在读取行之前,会先把索引确定的行位置按物理顺序排列,以尽量降低逐行读取的代价。节点名称中的位图就是执行这种排序的机制。)

如果WHERE条件的选择性足够强,规划器可能改用一种简单索引扫描计划:

EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 3;

                                  QUERY PLAN
------------------------------------------------------------------------------
 Index Scan using tenk1_unique1 on tenk1  (cost=0.00..10.00 rows=2 width=244)
   Index Cond: (unique1 < 3)

在这种计划中,表行按索引顺序取出,因此读取代价更高,但行数很少,不值得为排序行位置付出额外代价。对于仅取出一行的查询,以及带有与索引顺序匹配的ORDER BY条件的查询,最常见到这种计划。

再向WHERE子句添加另一个条件:

EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 3 AND stringu1 = 'xxx';

                                  QUERY PLAN
------------------------------------------------------------------------------
 Index Scan using tenk1_unique1 on tenk1  (cost=0.00..10.01 rows=1 width=244)
   Index Cond: (unique1 < 3)
   Filter: (stringu1 = 'xxx'::name)

新增的条件stringu1 = 'xxx'降低了估计输出行数,却没有降低代价,因为仍然必须访问同一组行。注意,stringu1子句不能用作索引条件(因为该索引只建立在unique1列上)。它只能作为过滤条件,应用到通过索引取出的行上。因此,代价实际上略有增加,以反映这项额外检查。

如果在WHERE引用的多个列上建有索引,规划器可能选择将这些索引做 AND 或 OR 组合:

EXPLAIN SELECT * FROM tenk1 WHERE unique1 < 100 AND unique2 > 9000;

                                     QUERY PLAN
-------------------------------------------------------------------------------------
 Bitmap Heap Scan on tenk1  (cost=11.27..49.11 rows=11 width=244)
   Recheck Cond: ((unique1 < 100) AND (unique2 > 9000))
   ->  BitmapAnd  (cost=11.27..11.27 rows=11 width=0)
         ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..2.37 rows=106 width=0)
               Index Cond: (unique1 < 100)
         ->  Bitmap Index Scan on tenk1_unique2  (cost=0.00..8.65 rows=1042 width=0)
               Index Cond: (unique2 > 9000)

但这需要访问两个索引,因此与只使用一个索引、把另一个条件作为过滤条件相比,并不一定更划算。如果调整条件中的范围,就会看到计划相应改变。

使用之前讨论的列,尝试连接两个表:

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)
   ->  Bitmap Heap Scan on tenk1 t1  (cost=2.37..232.35 rows=106 width=244)
         Recheck Cond: (unique1 < 100)
         ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..2.37 rows=106 width=0)
               Index Cond: (unique1 < 100)
   ->  Index Scan using tenk2_unique2 on tenk2 t2  (cost=0.00..3.01 rows=1 width=244)
         Index Cond: (unique2 = t1.unique2)

在这个嵌套循环连接中,外侧(上层)扫描与我们前面看到的位图索引扫描相同,因此其代价和行计数也相同,因为WHERE子句unique1 < 100正是在该节点上应用的。t1.unique2 = t2.unique2子句此时还无关,因此不会影响外侧扫描的行计数。对于内侧(下层)扫描,当前外侧行的unique2值会被代入内侧索引扫描,产生一个形如unique2 = constant的索引条件。因此,得到的内侧扫描计划和代价与例如EXPLAIN SELECT * FROM tenk2 WHERE unique2 = 42的结果相同。随后,循环节点的代价建立在外侧扫描代价之上,再加上每个外侧行都要执行一次内侧扫描的代价(这里是 106 * 3.01),以及少量连接处理的 CPU 时间。

本例中,连接的输出行数等于两个扫描行数的乘积,但并非所有情况都如此,因为可能有同时涉及两个表的WHERE子句,因此只能在连接处应用,不能应用到任一输入扫描。例如,如果添加WHERE ... AND t1.hundred < t2.hundred,将减少连接节点的输出行数,但不改变任一输入扫描。

查看其他候选计划的一种方法,是使用第 18.7.1 节中介绍的启用/禁用标志,强制规划器忽略它认为代价最低的策略。(这是一种粗略但有用的工具。另请参见第 14.3 节。)

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: (t2.unique2 = t1.unique2)
   ->  Seq Scan on tenk2 t2  (cost=0.00..458.00 rows=10000 width=244)
   ->  Hash  (cost=232.35..232.35 rows=106 width=244)
         ->  Bitmap Heap Scan on tenk1 t1  (cost=2.37..232.35 rows=106 width=244)
               Recheck Cond: (unique1 < 100)
               ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..2.37 rows=106 width=0)
                     Index Cond: (unique1 < 100)

这个计划提议用同一个旧索引扫描取出tenk1的 100 个感兴趣的行,把它们存入一个内存哈希表,然后对tenk2做顺序扫描,并为每个tenk2行探查哈希表中t1.unique2 = t2.unique2的可能匹配。读取tenk1并建立哈希表的代价是哈希连接的启动代价,因为在开始读取tenk2之前不会有任何输出。连接的总时间估计还包括一笔可观的 CPU 时间,用于探查哈希表 10000 次。但请注意,我们不是按 10000 次 232.35 计费;在这种计划类型中,哈希表的建立只进行一次。

可以通过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)
   ->  Bitmap Heap Scan on tenk1 t1  (cost=2.37..232.35 rows=106 width=244) (actual time=0.878..2.367 rows=100 loops=1)
         Recheck Cond: (unique1 < 100)
         ->  Bitmap Index Scan on tenk1_unique1  (cost=0.00..2.37 rows=106 width=0) (actual time=0.546..0.546 rows=100 loops=1)
               Index Cond: (unique1 < 100)
   ->  Index Scan using tenk2_unique2 on tenk2 t2  (cost=0.00..3.01 rows=1 width=244) (actual time=0.067..0.078 rows=1 loops=100)
         Index Cond: (unique2 = t1.unique2)
 Total runtime: 14.452 ms

注意,actual time值是实际时间的毫秒数,而cost估计值则以任意单位表示,因此两者不太可能直接吻合。值得关注的是实际时间与估计代价之间的比率是否一致。

在某些查询计划中,一个子计划节点可能会执行多次。例如,上面那个嵌套循环计划中的内侧索引扫描会对外侧的每一行执行一次。在这种情况下,loops值报告的是该节点的总执行次数,而 actual time 和 rows 显示的是每次执行的平均值。这样做是为了让这些数字更容易与代价估计的展示方式相比较。将它们乘以loops值,就能得到该节点实际消耗的总时间。

EXPLAIN ANALYZE显示的Total runtime包含执行器的启动和关闭时间,但不包括解析、重写或计划时间。对于INSERTUPDATEDELETE命令,应用表更改所花费的时间被记入顶层的 Insert、Update 或 Delete 计划节点。(该节点之下的计划节点表示定位旧行和/或计算新行的工作。)如果有BEFORE触发器,执行它们所花费的时间被记入相关的 Insert、Update 或 Delete 节点,而执行AFTER触发器的时间则不会。每个触发器(BEFOREAFTER)所花费的时间也会单独显示,并计入总运行时间。但要注意,延迟约束触发器直到事务结束才会执行,因此不会被EXPLAIN ANALYZE显示。

EXPLAIN ANALYZE测得的运行时间,可能以两种重要方式偏离同一查询的正常执行时间。第一,由于不会向客户端传递任何输出行,因此网络传输开销和 I/O 格式化开销均不会被计入。第二,EXPLAIN ANALYZE增加的开销本身可能相当可观,尤其是在gettimeofday()内核调用较慢的机器上。

值得注意的是,不应将EXPLAIN结果外推到与实际测试不同的场景。例如,在一个很小的表上得到的结果,不能假定也适用于大型表。规划器的代价估计不是线性的,因此它可能会为更大或更小的表选择不同的计划。一个极端示例是:对于只占用一个磁盘页的表,无论索引是否可用,几乎总会得到顺序扫描计划。规划器认识到,无论如何处理该表都需要一次磁盘页读取,因此再额外读取页面去查看索引并没有价值。



[9] 本节中的示例取自执行过VACUUM ANALYZE的回归测试数据库,使用的是 8.2 开发版本源码。如果自行尝试这些示例,通常应当能得到相近的结果,但估计代价和行计数可能略有差异,因为ANALYZE生成的统计信息来自随机采样而非精确计数。

提交更正

译文有误、术语不当或页面显示问题,请到译文仓库 pgsty/pgdoc 报告译文问题。 英文原文本身的问题,请在当前版本的对应页面向上游反馈;上游不再修订已结束维护的版本。