第9章 查询性能调优

第9章 查询性能调优

9.1 通过EXPLAIN命令查看语句执行计划

要定位语句的性能问题,最简单直接的方法就是使用 EXPLAIN 和 EXPLAIN (ANALYZE) 命令来分析其执行计划。EXPLAIN 命令甚至支持将输出转储为 XML、JSON 或者 YAML 格式。

9.1.1 EXPLAIN选项

  • EXPLAIN 本身的执行效果是输出执行计划而并不执行 SQL 语句本身。
  • 加上 ANALYZE 参数之后(就像 EXPLAIN (ANALYZE))的执行效果是执行该 SQL 语句本身,而且会将实际执行情况与执行计划进行对比分析,这可以用来评估执行计划的准确性。
  • 在 EXPLAIN 后增加 VERBOSE 参数(语法是 EXPLAIN (VERBOSE))将使得输出的执行计划步骤精确到列级别。
  • 还有一个必须与 ANALYZE 参数联用的 BUFFERS 参数,其语法为 EXPLAIN (ANALYZE,BUFFERS),通过它可以显示出执行计划过程中重用缓存数据时的命中次数,这个数字越大就表示本次查询过程中从内存缓存中获取的记录数越多,这些数据是之前的查询执行过程中缓存下来的,缓存中已有的数据块就不需要再从磁盘读取了。

完整的 SQL 语句执行计划解释语法是这样的:EXPLAIN (ANALYZE, VERBOSE, BUFFERS) + 查询语句,执行后输出的结果包括执行时间、列的输出以及缓存命中次数等。

对于 UPDATE 或者 INSERT 这种 DML 语句来说,如果仅希望查看其通过 EXPLAIN (ANALYZE) 得到的执行计划而并不希望真正执行数据修改,可以把这个语句包装成一个事务块,即语句之前加 BEGIN,之后加 ROLLBACK。

9.1.2 运行示例以及输出内容解释

先测试语句不使用索引的情况,因此先将表上的主键删掉。

ALTER TABLE census.hisp_pop DROP CONSTRAINT IF EXISTS hisp_pop_pkey;

使用 EXPLAIN (ANALYZE) 查看全表扫描的执行计划

EXPLAIN (ANALYZE)
SELECT tract_id, hispanic_or_latino
FROM census.hisp_pop
WHERE tract_id = '25025010103';

Seq Scan on hisp_pop
	(cost=0.00..33.48 rows=1 width=16)
	(actual time=0.213..0.346 rows=1 loops=1)
	Filter: ((tract_id)::text = '25025010103'::text)
	Rows Removed by Filter: 1477
Planning time: 0.095 ms
Execution time: 0.381 ms

EXPLAIN 输出的执行计划会包含多个执行步骤。每一步都会有一个估算的执行成本范围,看起来像这样:cost=0.00…33.48,本例中,第一个数字 0.00 是估算的该步骤的起始执行成本,第二个数字 33.84 是估算的该步骤的总执行成本。起始执行时间点之前会执行一些后续计算的准备动作,而读取数据、索引扫描、多表数据关联整合等动作都是在起始执行时间点之后发生的。如果执行方式为全表扫描,那么其起始执行成本为 0,因为这种场景下规划器只是简单地立即开始扫描全表数据,没有什么预备动作。执行计划可以看到规划器选择了全表扫描策略,因为没有任何索引可用。下面输出的 Rows Removed by Filter:1477 是扫描过程中排除掉的不符合条件的记录数。

请注意,估算的执行成本值的单位并不是真实的时间单位,其单位取决于硬件环境以及执行成本相关的参数配置。因此,执行成本值仅具有相对意义,可用于比较同一台物理服务器上多个执行计划的效率。规划器的任务就是选出总体成本值最低的一个执行计划。

PostgreSQL 9.4 或者更高版本,EXPLAIN 输出的执行计划中还提供了执行计划分析时间和真正的执行时间。执行计划分析时间就是规划器分析出最终执行计划所消耗的时间;执行时间是按照执行计划执行并得到最终结果所用的时间。

把主键重新建起来:

ALTER TABLE census.hisp_pop ADD CONSTRAINT hisp_pop_pkey PRIMARY KEY(tract_id);

使用了索引的 EXPLAIN (ANALYZE) 执行计划

Index Scan using idx_hisp_pop_tract_id_pat on hisp_pop
	(cost=0.28..8.29 rows=1 width=16)
	(actual time=0.018..0.019 rows=1 loops=1)
Index Cond: ((tract_id)::text = '25025010103'::text)
Planning time: 0.110 ms
Execution time: 0.046 ms

估算的执行时间从 33.48 降为 8.29。起始执行成本也不再是 0,因为规划器需要先扫描索引,然后才能把命中的记录从磁盘取出来(如果所需数据已经存在于内存缓存中,也有可能是直接从内存取)。也可以看到规划器不再需要扫描 1477 条记录,这极大地降低了执行成本。

较复杂的查询,其执行计划中会包含更多的步骤,这些步骤也称为子执行计划。每一个子执行计划都有它自己的成本估算值,这些值会被累加到总执行计划的成本估算值中。父执行计划显示时总是排在最前面,其中记录的成本估算值和真实时间值就是其所有子计划相应项目值之和。子计划在显示时是按照其层级向右逐级缩进的。

带 GROUP BY 和 SUM 的语句的 EXPLAIN (ANALYZE) 分析

EXPLAIN (ANALYZE)
SELECT left(tract_id,5) AS county_code, SUM(white_alone) As w
FROM census.hisp_pop
WHERE tract_id BETWEEN '25025000000' AND '25025999999'
GROUP BY county_code;

HashAggregate
	(cost=29.57..32.45 rows=192 width=16)
	(actual time=0.664..0.664 rows=1 loops=1)
	Group Key: "left"((tract_id)::text, 5)
	-> Bitmap Heap Scan on hisp_pop
		(cost=10.25..28.61 rows=192 width=16)
		(actual time=0.441..0.550 rows=204 loops=1)
		Recheck Cond:
			(((tract_id)::text >= '25025000000'::text) AND
			((tract_id)::text <= '25025999999'::text))
		Heap Blocks: exact=15
		-> Bitmap Index Scan on hisp_pop_pkey
			(cost=0.00..10.20 rows=192 width=0)
			(actual time=0.421..0.421 rows=204 loops=1)
			Index Cond:
				(((tract_id)::text >= '25025000000'::text) AND
				((tract_id)::text <= '25025999999'::text))
Planning time: 4.835 ms
Execution time: 0.732 ms

所示执行计划的顶层步骤是一个散列聚合操作。该操作包含一个位图表扫描子计划,该位图表扫描子计划又包含了一个位图索引扫描子计划。在本例中,因为我们是第一次执行此语句,所以执行计划分析时间远远超过了真正的执行时间。但 PostgreSQL 有执行计划以及数据缓存功能,所以如果我们再次执行该语句,或者执行一个可以共享缓存下来的执行计划的类似语句,那么执行计划的分析时间就会大大减少,而且真正的执行时间也可能会减少,因为该语句执行期间所需的很多数据可能已经缓存在内存中了。

第二次的运行时间统计数据如下:

Planning time: 0.200 ms
Execution time: 0.635 ms

9.1.3 图形化展示执行计划

介绍一个表格形式的执行计划展示工具(http://explain.depesz.com/)。打开工具地址,然后将文本格式的执行计划复制过去,就可以得到一个格式化得非常漂亮的表格。

postgres=# EXPLAIN (ANALYZE) select count(1) from ufo110;
                                                               QUERY PLAN                                           
                     
--------------------------------------------------------------------------------------------------------------------

 Finalize Aggregate  (cost=6053.68..6053.69 rows=1 width=8) (actual time=161.722..161.723 rows=1 loops=1)
   ->  Gather  (cost=6053.67..6053.68 rows=2 width=8) (actual time=161.702..170.273 rows=3 loops=1)
         Workers Planned: 2
         Workers Launched: 2
         ->  Partial Aggregate  (cost=6053.67..6053.68 rows=1 width=8) (actual time=108.655..108.656 rows=1 loops=3)
               ->  Parallel Seq Scan on ufo110  (cost=0.00..5606.73 rows=178773 width=0) (actual time=0.189..59.606 
rows=143019 loops=3)
 Planning Time: 0.146 ms
 Execution Time: 170.568 ms

在这里插入图片描述
执行计划表中会以显眼的颜色高亮显示有问题的部分。表格中的 exclusive 列表示当前步骤的操作所耗时间,inclusive列表示当前步骤及其所有子步骤的操作所耗时间。(黄色、褐色以及红色的格子是潜在的性能瓶颈)。

rows x 这一栏表示预估查询出的记录数,rows 栏显示的是执行完毕后实际查出的记录数。上表中显示的就是预估能返回 192 行记录,但实际仅返回 1 条。估算的记录数不准,一般是因为表的统计信息未及时更新所导致。特别是刚刚对表进行过大规模的更新或者插入操作后,表分析操作很有必要。
在这里插入图片描述

9.2 搜集语句的执行统计信息

PostgreSQL 提供了一个名为 pg_stat_statements 的性能监控扩展包以帮助用户找出耗时最长的语句。该扩展包能提供所有执行过的 SQL 语句的统计度量信息,包括哪些语句执行得最频繁以及每个语句的执行总耗时等。基于这些信息我们可以知道应将优化的重点放在哪里。

大多数 PostgreSQL 版本都自带 pg_stat_statements 扩展包,但启动时必须明确指定预加载其动态库,这样系统才会启动用于搜集统计数据的后台进程。

(1) 在 postgresql.conf 配置文件中,将 shared_preload_libraries = " 更改为

shared_preload_libraries = 'pg_stat_statements'

(2) 在 postgresql.conf 文件的自定义选项部分,添加以下几行。

pg_stat_statements.max = 10000
pg_stat_statements.track = all

(3) 重启 postgresql 服务。
(4) 登录到每一个希望进行 SQL 语句性能统计的 database 中并执行语句:

CREATE EXTENSION pg_stat_statements;
  • 一个名为 pg_stat_statements 的视图,其中可以查询到当前登录用户在各 database 中执行过的所有 SQL 语句的统计信息。
  • 一个名为 pg_stat_statements_reset 的函数,该函数可以将到目前为止的语句执行统计信息全部清空,不过只有超级用户才有权限执行此函数。

查出 postgresql_book 这个 database 中最耗时的 5 个 SQL 语句

SELECT
	query, calls, total_time, rows,
	100.0*shared_blks_hit/NULLIF(shared_blks_hit+shared_blks_read,0) AS hit_percent
FROM pg_stat_statements As s INNER JOIN pg_database As d On d.oid = s.dbid
WHERE d.datname = 'postgresql_book'
ORDER BY total_time DESC LIMIT 5;

9.3 编写更好的SQL语句

在有更多表参与关联运算的情况下,最好是使用内连接。对 PostgreSQL 新版本中引入的那些新语法积极地学习并利用起来。

9.3.1 在SELECT语句中滥用子查询

从多个子查询中取数据与从多个表或者视图中取数据一样重要,代码写得不好效率就会很低。

SELECT tract_id,
	(SELECT COUNT(*) FROM census.facts As F
WHERE F.tract_id = T.tract_id) As num_facts,
	(SELECT COUNT(*)
	FROM census.lu_fact_types As Y
	WHERE Y.fact_type_id IN (
		SELECT fact_type_id
		FROM census.facts F
		WHERE F.tract_id = T.tract_id
	)
	) As num_fact_types
FROM census.lu_tracts As T;

合并了多个 SELECT 动作并使用了关联查询机制,不但比上面的语句更简短,速度也更快。如果表的数据量很大或者硬件性能较差,这两种写法之间的性能差异会更明显。

SELECT T.tract_id,
	COUNT(f.fact_type_id) As num_facts,
	COUNT(DISTINCT fact_type_id) As num_fact_types
FROM census.lu_tracts As T LEFT JOIN census.facts As F ON T.tract_id = F.tract_id
GROUP BY T.tract_id;

请牢记:子查询不是独立的黑盒数据块,应与主语句通盘考虑后再结合使用。

9.3.2 尽量避免使用SELECT *语法

SELECT * 经常会导致性能浪费,会出现仅仅需要 10 页数据却查出 1000 页数据这种情况,这显然会导致网络传输负担加大,而且还会出现两个你可能意想不到的问题。

第一个问题与大对象有关。PostgreSQL 会使用 TOAST(The Oversized-Attribute Storage Technique,即超大尺寸属性存储技术)机制来存储二进制大对象以及超大文本。TOAST 机制会将超过主表存储限制的数据存储到一张辅助表中,并可能把单个文本字段拆分为多行存储。因此,读取超大字段就需要从辅助的TOAST 表中查询出多条记录。

第二个问题与视图有关。在视图定义语句中包含复杂运算表达式以及关联查询。这些创建视图的语句都是合法的,没有任何问题,但用户访问时就麻烦了,一旦对这种复杂视图执行 SELECT * 查询,那么视图定义中所有的复杂列都会经历漫长的运算过程,总体查询速度会很慢。

创建一个视图

CREATE OR REPLACE VIEW vw_stats AS
SELECT tract_id,
(SELECT COUNT(*)
FROM census.facts As F
WHERE F.tract_id = T.tract_id) As num_facts,
(SELECT COUNT(*)
FROM census.lu_fact_types As Y
WHERE Y.fact_type_id IN (
SELECT fact_type_id
FROM census.facts F
WHERE F.tract_id = T.tract_id
)
) As num_fact_types
FROM census.lu_tracts As T; CREATE
    OR REPLACE VIEW vw_stats AS
SELECT tract_id,
       (
SELECT COUNT(*)
  FROM census.facts As F
 WHERE F.tract_id= T.tract_id) As num_facts,
       (
SELECT COUNT(*)
  FROM census.lu_fact_types As Y
 WHERE Y.fact_type_id IN(
SELECT fact_type_id
  FROM census.facts F
 WHERE F.tract_id= T.tract_id)) As num_fact_types
  FROM census.lu_tracts As T;

针对此视图执行以下语句:

SELECT tract_id FROM vw_stats;

执行大约耗时 21 毫秒,速度很快,因为该语句没有访问 num_facts 和num_fact_type 这两个视图字段。

SELECT * FROM vw_stats;

执行时间将飙升到 681 毫秒。

9.3.3 善用CASE语法

在很多需要聚合运算的场景中,使用 CASE 语法能够有效替代子查询。

使用子查询而非 CASE

SELECT T.tract_id,
       COUNT(*)  As tot,
       type_1.tot AS type_1
  FROM census.lu_tracts AS T
  LEFT JOIN(
SELECT tract_id, COUNT(*)  As tot
  FROM census.facts
 WHERE fact_type_id= 131
 GROUP BY tract_id)  As type_1 ON T.tract_id= type_1.tract_id
  LEFT JOIN census.facts AS F ON T.tract_id= F.tract_id
 GROUP BY T.tract_id,
         type_1.tot;

使用 CASE 语法替代子查询

SELECT T.tract_id,
       COUNT(*)  As tot,
       COUNT(CASE WHEN F.fact_type_id= 131 THEN 1 ELSE NULL END)  AS type_1
  FROM census.lu_tracts AS T
  LEFT JOIN census.facts AS F ON T.tract_id= F.tract_id
 GROUP BY T.tract_id;

尽管优化后的语句依然没用上 fact_type 索引,但其执行效率还是提升了,因为规划器仅对 facts表做了一次扫描。一般来说,执行计划越短小,其执行过程就越容易理解,执行效率也越高,不过这并不是绝对的。

9.3.4 使用Filter语法替代CASE语法

PostgreSQL 9.4 中引入了新的 FILTER 关键字,在使用了 CASE 的聚合函数中总是可以用 FILTER 来代替 CASE,替换以后不但语法上看起来更整洁,而且在很多场景下执行效率也会有所提高。

使用 FILTER 语法来替代子查询

SELECT T.tract_id,
       COUNT(*)  As tot,
       COUNT(*)  FILTER(
 WHERE F.fact_type_id= 131)  AS type_1
  FROM census.lu_tracts AS T
  LEFT JOIN census.facts AS F ON T.tract_id= F.tract_id
 GROUP BY T.tract_id;

用 FILTER 替换 CASE 后,性能提升后仅有大约 1 毫秒。

9.4 并行化语句执行

并行化特性在 PostgreSQL 9.6 中引入,并行化语句的执行过程会被规划器分发给多个后台进程去执行。通过这种并行执行方式,PostgreSQL 能够充分发挥多核处理器的威力,从而让语句执行更快完成。两核的机器上可能节省 50% 的时间,四核的机器上可能节省 75% 的时间。

在 PostgreSQL 10 中,不能并行化的语句类型如下。

  • 所有 DML 数据操纵语句,比如更新、插入和删除操作。
  • 所有 DDL 数据定义语句,比如建表、加字段、建索引等。
  • 在游标遍历过程中或者循环体中执行的查询语句。
  • 部分聚合操作。常见的 COUNT 和 SUM 聚合都已支持并行化,但 DISTINCT 和 ORDER BY 还不支持。
  • 自定义函数。默认情况下,自定义函数被设置为 PARALLEL UNSAFE 模式,即不能安全地进行并行化操作,但如果你确定你的自定义函数是可以并行的,那么可以通过PARALLEL 相关参数来启用自定义函数的并行化能力。

如需开启并行化语句执行能力,请遵循以下指导进行系统参数配置。

  • dynamic_shared_memory 不允许设置为 none。
  • max_worker_processes 需要设置为大于 0。
  • max_parallel_workers 是 PostgreSQL 10 中新引入的一个参数,需要设置为大于 0 并且小于max_worker_processes 的值。
  • max_parallel_workers_per_gather 需要设置为大于 0 并且小于等于max_worker_processes 的值。对 PostgreSQL 10 来说,该参数的值还必须小于等于max_parallel_workers。该参数可以在会话级或者函数级进行设置。

9.4.1 并行化的执行计划是什么样子

并行化功能是通过规划器中一个名为采集节点(gather node)的模块实现的。因此如果你在执行计划中看到了 gather node 字样,那么说明该语句已经或多或少地用上了并行化功能了。一个采集节点仅包含一个执行计划,然后会把该执行计划分发给多个worker 去执行。每个 worker 就是一个独立的 PostgreSQL 后台进程,每个后台进程负责处理整个查询中的一部分工作。所有 worker 的工作成果会被一个担任 leader 角色的 worker 搜集到一起。担任 leader 角色的 worker 和其他普通 worker 一样,也要处理 gather node 分派给自己的计算任务,但它比其他普通 worker 多一项任务,就是要搜集所有普通worker 的工作成果。如果 gather node 是整个执行计划的根节点,那么说明整个执行计划是彻底并行化执行的;如果 gather node 出现在比较低的层级中,那么只有对应层级的工作是并行化的。

force_parallel_mode 参数。当该参数被设置为 true 时,规划器会用并行模式去执行所有可并行化的语句,即使规划器判定使用并行模式并不会比非并行模式快,它也会这么干。当需要判定一个语句为什么没有被并行化执行时,该参数非常有用。但请切记,不要在正式的生产环境中打开该参数!

为了确认有的语句在并行化执行的情况下反而比没有并行化更慢,先设置该参数:

Set force_parallel_mode = true;
Gather
	(cost=1029.57..1051.65 rows=192 width=64)
	(actual time=12.881..13.947 rows=1 loops=1)
	Workers Planned: 1
	Workers Launched: 1
	Single Copy: true
	-> HashAggregate
		(cost=29.57..32.45 rows=192 width=64)
		(actual time=0.230..0.231 rows=1 loops=1)
		Group Key: "left"((tract_id)::text, 5)
		-> Bitmap Heap Scan on hisp_pop
			(cost=10.25..28.61 rows=192 width=36)
			(actual time=0.127..0.184 rows=204 loops=1)
			Recheck Cond:
				(((tract_id)::text >= '25025000000'::text) AND
				((tract_id)::text <= '25025999999'::text))
				-> Bitmap Index Scan on hisp_pop_pkey
					(cost=0.00..10.20 rows=192 width=0)
					(actual time=0.106..0.106 rows=204 loops=1)
					Index Cond:
						(((tract_id)::text >= '25025000000'::text) AND
						((tract_id)::text <= '25025999999'::text))
Planning time: 0.416 ms
Execution time: 16.160 ms

可以看到,启动额外的 worker(即使只启动一个)带来的时间消耗显著地增加了总体查询所需的时间。

一般来说,对于耗时几毫秒的查询语句做并行化是没什么必要的。但需要访问极大数据集的查询通常需要耗时几秒甚至几分钟,此时并行化的价值就能体现出来了,初始化时启动额外 worker 的成本会远远小于并行化带来的收益。

并行化的 GROUP BY 操作,650万数据。

set max_parallel_workers_per_gather=4;

EXPLAIN ANALYZE VERBOSE
SELECT COUNT(*), area_type_code
FROM labor
GROUP BY area_type_code
ORDER BY area_type_code;

Finalize GroupAggregate
	(cost=104596.49..104596.61 rows=3 width=10)
	(actual time=500.440..500.444 rows=3 loops=1)
	Output: COUNT(*), area_type_code
	Group Key: labor.area_type_code
	-> Sort
		(cost=104596.49..104596.52 rows=12 width=10)
		(actual time=500.433..500.435 rows=15 loops=1)
		Output: area_type_code, (PARTIAL COUNT(*))
		Sort Key: labor.area_type_code
		Sort Method: quicksort Memory: 25kB
		-> Gather
			(cost=104595.05..104596.28 rows=12 width=10)
			(actual time=500.159..500.382 rows=15 loops=1)
			Output: area_type_code, (PARTIAL COUNT(*))
			Workers Planned: 4
			Workers Launched: 4
			-> Partial HashAggregate
				(cost=103595.05..103595.08 rows=3 width=10)
				(actual time=483.081..483.082 rows=3 loops=5)
				Output: area_type_code, PARTIAL count(*)
				Group Key: labor.area_type_code
				Worker 0: actual time=476.705..476.706 rows=3 loops=1
				Worker 1: actual time=480.704..480.705 rows=3 loops=1
				Worker 2: actual time=480.598..480.599 rows=3 loops=1
				Worker 3: actual time=478.000..478.000 rows=3 loops=1
				-> Parallel Seq Scan on public.labor
					(cost=0.00..95516.70 rows=1615670 width=2)
					(actual time=1.550..282.833 rows=1292543 loops=5)
					Output: area_type_code
					Worker 0: actual time=0.078..282.698 rows=1278313 loops=1
					Worker 1: actual time=3.497..282.068 rows=1338095 loops=1
					Worker 2: actual time=3.378..281.273 rows=1232359 loops=1
					Worker 3: actual time=0.761..278.013 rows=1318569 loops=1
Planning time: 0.060 ms
Execution time: 512.667 ms

设置 max_parallel_workers_per_gather=0,非并行化执行的 GROUP BY 操作。

set max_parallel_workers_per_gather=0;

EXPLAIN ANALYZE VERBOSE
SELECT COUNT(*), area_type_code
FROM labor
GROUP BY area_type_code
ORDER BY area_type_code;

Sort
	(cost=176300.24..176300.25 rows=3 width=10)
	(actual time=1647.060..1647.060 rows=3 loops=1)
	Output: (COUNT(*)), area_type_code
	Sort Key: labor.area_type_code
	Sort Method: quicksort Memory: 25kB
	-> HashAggregate
		(cost=176300.19..176300.22 rows=3 width=10)
		(actual time=1647.025..1647.025 rows=3 loops=1)
		Output: count(*), area_type_code
		Group Key: labor.area_type_code
		-> Seq Scan on public.labor
			(cost=0.00..143986.79 rows=6462679 width=2)
			(actual time=0.076..620.563 rows=6462713 loops=1)
			Output: series_id, year, period, value, footnote_codes,area_type_code
			
Planning time: 0.054 ms
Execution time: 1647.115 ms

在并行化模式下,总共四个 worker,每个 worker 完成自己的工作大约耗时 280 毫秒。

9.4.2 并行化扫描

并行执行模式下有专门的扫描策略来将整个扫描任务切分给多个 worker。在 PostgreSQL 9.6 中,只有全表扫描可以并行执行。PostgreSQL 10 中新增了对位图堆扫描(bitmap heap scan)、索引扫描(index scan)以及仅索引扫描 (index-only scan)的并行化支持。然而对于索引扫描和仅索引扫描,目前还只有当使用了 B-树索引时才能实现并行。位图堆扫描则无此限制,基于任何类型索引的位图堆扫描操作过程都可以实现并行。但请注意,位图堆扫描过程中的建位图索引步骤是不可并行的,因此所有的 worker 必须等到位图索引构建完毕才能开始并行工作。

9.4.3 并行化关联操作

关联操作也可以实现并行化。在 PostgreSQL 9.6 中,嵌套循环关联和散列关联这两种关联模式都是可并行化的。

在嵌套循环关联过程中,每个 worker 只需对分配给它的数据子集做关联运算,关联对象是所有 worker共享的被关联表。

在散列关联过程中,每个 worker 会先构造一份全量的散列表,然后拿自己负责的那部分其他表数据和这个全量散列表做关联条件判定。由于每个 worker 都需要构建一个全量的散列表,而它自己不可能用到全量的散列数据,因此显然 worker 之间有很多冗余运算。所以,如果创建散列表是个很昂贵的操作,并行化的散列关联的速度会比非并行化关联操作慢。

PostgreSQL 10 中支持 merge 关联的并行化。merge 关联与散列关联存在类似的问题,即每个 worker都要对关联操作某一侧的表进行完全处理,因此 worker 之间必然会存在重复运算。

9.5 人工干预规划器生成执行计划的过程

规划器生成执行计划的行为会受到多方面因素的影响,具体包括:是否有合适的索引、执行成本设置、执行策略设置以及数据如何分布等。

9.5.1 策略设置

PostgreSQL 查询规划器不接受索引提示,但你可以逐个查询或永久禁用各种策略设置(比如全表扫描、位图扫描、散列聚合、散列关联等都是一种执行策略,都有相应的策略设置),以阻止规划器选出某些效率较低的执行策略。不过请注意,即使你设置了某种策略为禁用,也并不意味着规划器就一定不会使用该策略。规划器仅将这些设置当作用户的建议,最终的决定权还是在规划器。

我们有时候会将 enable_nestloop(嵌套循环)和 enable_seqscan(全表扫描)这两个设置设为“禁用”,因为这两种执行策略在大多数情况下的效率是很低的,但规划器在没有别的选择时还是可能会使用的。

核查到底是因为规划器已经找不到更好的策略所以不得不用,还是规划器选择了错误的策略。一个快速鉴别的办法是先禁用该策略,然后对禁用前后的执行计划进行比较。如果禁用前的执行计划中使用了该策略但禁用后却不使用了,那么再进一步比较一下这两种情况下执行计划的真实成本,就可以看出选择该策略到底是快了还是慢了。

9.5.2 你的索引被用到了吗

有两个错误是大家经常会犯的,一个是表上缺少必要的索引,另一个是索引建得不对而导致查询语句用不上。通过查询 pg_stat_user_indexes 和 pg_stat_user_tables 这两个视图可以很方便地得知你的索引是否被用上了。如需了解哪些语句执行得慢,请安装 pg_stat_statements 扩展包。

在数组列 fact_subcats上建立一个 GIN 索引,GIN 类型的索引是为数不多的能够支持数组类型的索引。

CREATE INDEX idx_lu_fact_types ON census.lu_fact_types USING gin (fact_subcats);

允许规划器选择全表扫描策略

set enable_seqscan = true;

EXPLAIN(ANALYZE)
SELECT *
FROM census.lu_fact_types
WHERE fact_subcats && '{White alone, Black alone}'::varchar[];

Seq Scan on lu_fact_types
	(cost=0.00..2.85 rows=2 width=200)
	(actual time=0.066..0.076 rows=2 loops=1)Filter: (fact_subcats && '{"White alone","Black alone"}'::character varying[])
	Rows Removed by Filter: 66

Planning time: 0.182 ms
Execution time: 0.108 ms

请注意,在启用全表扫描策略的情况下,规划器忽略了索引而选用了全表扫描策略。这可能是因为表的规模很小或者是因为索引不适用于本语句中的查询条件。

禁用全表扫描策略,从而强行要求使用索引

set enable_seqscan = false;

EXPLAIN (ANALYZE)
SELECT *
FROM census.lu_fact_types
WHERE fact_subcats && '{White alone, Black alone}'::varchar[];

Bitmap Heap Scan on lu_fact_types
	(cost=12.02..14.04 rows=2 width=200)
	(actual time=0.058..0.058 rows=2 loops=1)
	Recheck Cond: (fact_subcats && '{"White alone","Black alone"}'::character varying[])
	Heap Blocks: exact=1
	-> Bitmap Index Scan on idx_lu_fact_types
		(cost=0.00..12.02 rows=2 width=0)
		(actual time=0.048..0.048 rows=2 loops=1)
		Index Cond: (fact_subcats && '{"White alone","Black alone"}'::character varying[])

Planning time: 0.230 ms
Execution time: 0.119 ms

通过该执行计划可以看到,索引建得没问题,是可以使用的,却使得整个查询的执行耗时更长,因为基于索引的查询成本要比基于全表扫描的成本高。因此,正常情况下规划器将选择使用全表扫描策略。但随着表中数据的增加,我们将会看到规划器优先选择索引查询策略。

9.5.3 表的统计信息

虽然规划器的算法严重依赖于表的统计信息,但规划器不会在每次生成执行计划之前临时扫描所有的相关表以获取其统计信息,因为如果那么做的话任何语句的执行都将巨慢无比,完全没有执行效率可言,所以规划器会依赖预先搜集好的表统计信息。

如果统计信息与实际情况相差太大,规划器很可能会常常推导出错误的执行计划,最差的情况就是错误地选择了全表扫描策略。一般来说,平均一张表只有 20% 的记录采样率,统计信息会基于这些参与采样的记录来生成。对于非常大的表来说,采样率可能更低。你可以通过设置 STATISTICS 值来修改在每一列上采样的行数。

通过查询 pg_stats 表,可以了解到规划器剔除了哪些统计信息以及使用了哪些统计信息

SELECT
	attname As colname,
	n_distinct,
	most_common_vals AS common_vals,
	most_common_freqs As dist_freq
FROM pg_stats
WHERE tablename = 'facts'
ORDER BY schemaname, tablename, attname;

colname 	 | n_distinct | common_vals 	 | dist_freq
-------------+------------+------------------+-----------------
fact_type_id | 68 		  | {
    
    135,113... 	 | {
    
    0.0157,0.0156333,...
perc 	     | 985 	      | {
    
    0.00,...        | {
    
    0.1845,0.0579333,0.056...
tract_id 	 | 1478 	  | {
    
    25025090300...  | {
    
    0.00116667,0.00106667,0.0...
val 		 | 3391 	  | {
    
    0.000,1.000,2...| {
    
    0.2116,0.0681333,0...
yr 			 | 2 		  | {
    
    2011,2010}      | {
    
    0.748933,0.251067}

pg_stats 表给出了表中指定列的值域分布图,规划器会根据此信息制订相应的执行计划。系统后台会有一个进程持续不断地更新 pg_stats 表。当表中插入或者删除大量数据后,你应该手动执行 VACUUM ANALYZE 来更新表的统计信息。VACCUM 指示将已删除的记录永久性地从表中移除,ANALYZE 指示更新表的统计信息。(对于频繁变化的表,统计信息可能会很旧,执行计划变得不可靠

对于经常参与关联查询并且在 WHERE 子句中频繁使用的列,应该考虑提升采样的行数。

ALTER TABLE census.facts ALTER COLUMN fact_type_id SET STATISTICS 1000;

PostgreSQL 10 中新增了对于多字段统计信息的支持,相应的语法是 CREATE STATISTICS。该特性使得用户能够针对多个字段的组合来创建统计对象。如果表中某些字段的值之间存在关联,那么多字段统计信息就能发挥作用。

CREATE STATISTICS census.stats_facts_type_yr_dep_dist (dependencies, ndistinct)
ON fact_type_id, yr FROM census.facts;

ANALYZE census.facts;

另外,建议为统计信息起个名字,当然这个名字其实是可选的。如果你在起名时带了 schema 的名字(上例中 census.stats_facts_type_yr_dep_dist 里面的 census 就是 schema 的名字),那么该统计信息默认会被创建到该 schema 中,不指定则会被创建到默认 schema 中。

可以采集两种统计信息,创建时必须至少指定其中一种。

  • 第一种是数据依赖统计信息,记录了不同字段间值的依赖性。例如,只有当城市名为波士顿时才会出现邮政编码 02109。只有当优化器对带等值判定的语句进行优化时,数据依赖统计信息才能发挥作用,例如这种条件:city = ‘Boston’ and zip = ‘02109’。
  • 第二种是 ndistinct 统计信息,记录了不同字段值一起出现的频率,并且会针对字段列表中每种排列组合都进行统计。ndistinct 统计信息专用于提升 GROUP BY 操作的效率,而且仅当 GROUP BY 子句中的字段全部落在统计信息所包含的字段列表中时,才能发挥作用。

CREATE STATISTICS 所创建的统计信息都存在 pg_statistic_ext 表中,可以使用 DROP STATISTICS 命令删除。与其他所有统计信息类似,该类统计信息是在 ANALYZE 命令执行期间计算出来的,而 ANALYZE 操作是由系统的自动清理及分析进程(官方名称为 autovacuum 进程)自动执行的。建议在建完表之后立即对其运行一次 ANALYZE 命令,这样该表的统计信息立即就可以被优化器用到。

9.5.4 磁盘页的随机访问成本以及磁盘驱动器的性能

另一个会影响规划器执行策略选择的设置是 random_page_cost(随机页访问成本比,简称 RPC)比率,它表示在磁盘上顺序读取和随机读取同一条记录的性能之比。一般来说,物理磁盘速度越快(一般也会越贵),该比率就会越小。RPC 的默认值是 4,该值适用于目前市面上的大多数机械硬盘。但如果使用的是固态硬盘或者 SAN 存储系统,有必要对此值进行调整。

你可以在 database、服务器、表空间这三个级别设置 RPC 比率。如果要在服务器级别设置该比率,直接在 postgresql.conf 文件中设置即可。如果同一台数据库服务器上使用了不同类型的硬盘,并且不同的表空间落在不同的硬盘上,那么可以在表空间级别设置 RPC 比率。

ALTER TABLESPACE pg_default SET (random_page_cost=2);
  • 高端 NAS/SAN 存储:2.5 或者 3.0
  • 亚马逊 EBS 和 Heroku 云平台:2.0
  • iSCSI 和其他普通 SAN 存储:6.0,但可能变化比较大,需要按照实际情况设定
  • 固态硬盘:2.0 至 2.5
  • NvRAM(也叫 NAND):1.5

9.6 数据缓存机制

如果你之前执行过一个复杂且耗时较长的查询,那么后续再次执行此查询时会发现快了很多,这是因为系统的数据缓存机制发挥了作用。如果语句中使用了 CTE 表达式和结果不变式函数(这类函数的运算结果不依赖外部数据,仅依赖输入的数据,也就是说固定的输入一定能得到固定的输出),那么系统会更加倾向于进行结果集缓存。

那么如何查看系统中缓存了哪些数据呢?可以通过安装 pg_buffercache 扩展包来查看。

CREATE EXTENSION pg_buffercache;

查询 pg_buffercache 视图,查看表数据是否已被缓存。

SELECT
	C.relname,
	COUNT(CASE WHEN B.isdirty THEN 1 ELSE NULL END) As dirty_buffers,
	COUNT(*) As num_buffers
FROM
	pg_class AS C INNER JOIN
	pg_buffercache B ON C.relfilenode = B.relfilenode INNER JOIN
	pg_database D ON B.reldatabase = D.oid AND D.datname = current_database()
WHERE C.relname IN ('facts','lu_fact_types')
GROUP BY C.relname;

relname       | dirty_buffers | num_buffers
--------------+---------------+------------
facts         | 0             | 736
lu_fact_types | 0             | 4

用于缓存数据的内存大小是可指定的,该值越大,能缓存的数据就越多。postgresql.conf 中的 shared_buffers 就是用于设置此值的,但不应设得过大,否则会耗费过多时间去扫描缓存,反而降低了性能。

由于如今的物理内存已经极其廉价,因此一般不会再出现内存不够的情况。基于这一点,我们很容易就想到,可以将一些常用的表预先缓存到内存中,这样就可以提高后续访问效率。有一个名为 pg_prewarm 的扩展包可以用于实现此功能。pg_prewarm 会将指定的常用表预加载到缓存中,此后不管该表是首次被用户访问还是非首次访问,响应速度总是很快。

猜你喜欢

转载自blog.csdn.net/qq_42226855/article/details/110444048
今日推荐