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

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.0 已于 2015 年 10 月结束社区维护,本页译文保留供仍在使用旧版本的读者参考。新系统请看当前版本

第 14 章 性能提示

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

14.1. Using EXPLAIN #

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

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

这是一个最简单的例子,只为了展示输出的样子: [10]

EXPLAIN SELECT * FROM tenk1;

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

EXPLAIN引用的这些数字依次是(从左到右):

  • 估计启动代价(在输出扫描开始之前消耗的时间,例如排序节点中完成排序所需的时间)

  • 估计总代价(如果所有行都被检索;不过它们也可能不被检索,例如带LIMIT子句的查询会在付出Limit计划节点的输入节点的全部代价之前停止)

  • 该计划节点输出的估计行数(同样,仅在完全执行的情况下)

  • 该计划节点输出行的估计平均宽度(以字节计)

这些开销使用由规划器代价参数决定的任意单位来衡量(见第 18.6.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: (t2.unique2 = t1.unique2)

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

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

查看不同计划的一种办法,是使用第 18.6.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: (t2.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的结果外推到实际测试场景之外的情况;例如,不能假定在很小的表上得到的结果也适用于大型表。规划器的代价估计不是线性的,因此它可能会为更大或更小的表选择不同的计划。一个极端示例是:对于只占用一个磁盘页的表,无论索引是否可用,几乎总会得到顺序扫描计划。规划器认识到,无论如何处理该表都需要一次磁盘页读取,因此再额外读取页面去查看索引并没有价值。



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

提交更正

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