PostgreSQL 10 - 查询优化 1

PostgreSQL 10 - 查询优化

  • 查询优化器
    • 评估join选项
      • Nested loops
      • Hash joins
      • Merge joins
    • 转换
      • 内联视图
      • 消除子查询
      • 等式约束
    • 详尽的搜索
    • 试一试
    • 使过程失败
    • 常量折叠
    • 函数内联
    • 连接修剪
    • 加速集合操作
  • 理解执行计划
    • 让EXPLAIN更冗长
    • 发现问题
    • 检查估计
    • 检查buffer的使用

查询优化器

假设,我们有三张表,一个视图,每张表都有索引:

CREATE TABLE a (aid int, ...); -- 一亿行
CREATE TABLE b (bid int, ...); -- 二亿行
CREATE TABLE c (cid int, ...); -- 三亿行

CREATE INDEX idx_a ON a (aid);
CREATE INDEX idx_b ON b (bid);
CREATE INDEX idx_c ON c (cid);

CREATE VIEW v AS SELECT * FROM a, b WHERE aid = bid;

假设用户想运行下面的查询。优化器会做什么?它的选择是什么?

SELECT *
FROM v, c
WHERE v.aid = c.cid AND cid = 4;

在看真是的优化器如何处理之前,先看看优化器有什么选项。

评估join选项

假设优化器从头开始,计算视图的输出。连接一亿行和两亿行有什么好办法?

Nested loops

一个办法是使用嵌套循环。原理很简单,下面是伪代码:

for x in table1:
    for y in table2:
        if x.field == y.field
            issue row
        else
            keep doing

嵌套循环常用于一方很小,数据不多的时候。而我们的例子,要处理那么多数据,显然不会这样处理。

Hash joins

可以使用下面的策略,解决我们的问题:

← Hash join
    ← Sequentialscan table 1
    ← Sequentialscan table 2

双方都被hash,然后比较hash key,返回join结果。问题是所有的值都需要做hash,并暂存在某处。

Merge joins

最后是归并连接-使用排序列表连接返回值。如果要连接的双方都已经排好序了,系统能从顶部取一些行,如果匹配就返回。所以,最重要的是数据要先排序。计划是这样的:

← Merge join
    ← Sort table 1
        ← Sequential scan table 1
    ← Sort table 2
        ← Sequential scan table 2

连接的一方或者双方可以使用来自计划的底层的排序数据。如果直接访问表,索引是一个很好的选择,但是前提是返回的数据比整个表小很多。否则,我们的开销会增加很多-先读整个索引,再读整个表。如果返回集是表的很大一部分,顺序扫描更高效-尤其是以主键顺序访问的时候。
使用归并连接,可以处理很多数据。但是,数据必须先排序,或者从索引读数据。
排序成本是_O(n * log(n))_。因此,为3亿行数据排序也是没什么吸引力。
这几种连接都支持并行版本。

转换

既然几种连接都不可行。可以考虑使用逻辑转换加速查询。要执行下列几步:

内联视图

SELECT * FROM
    (SELECT * FROM a, b WHERE aid = bid) AS v, c
WHERE v.aid = c.cid AND cid = 4;

该视图是内联的,转换成了子查询。这样,为进一步的优化打开了大门。

消除子查询

SELECT * FROM a, b, c WHERE a.aid = c.cid AND aid = bid AND cid = 4;

消除子查询以后,变成了这样的。现在是一个普通连接。

等式约束

下来增加等式约束(equality constraints)-检测其他约束,连接选项和过滤等。让我们仔细观察一下这个查询:如果aid = cid而且aid = bid,我们知道bid = cid。如果cid = 4,则其他几个也等于4:

SELECT *
FROM a, b, c
WHERE a.aid = c.cid
    AND aid = bid
    AND cid = 4
    AND bid = cid
    AND aid = 4
    AND bid = 4

这几列都可以使用索引,就不使用可怕的视图了。PostgreSQL选择从索引检索几行以后,使用什么连接都可以了。

详尽的搜索

现在,所有的形式转换都结束了,PostgreSQL会执行详尽的搜索。它会试各种可能的计划,使用最廉价的方案。PostgreSQL还要决定最好的连接顺序。原始查询中,连接顺序是A → B和A → C。等式约束以后,可能先连接B → C,然后再连接A。优化器对所有的选项都持开放的态度。

试一试

PostgreSQL可能选择这样的执行计划:

postgres=# explain SELECT * FROM v, c WHERE v.aid = c.cid AND cid = 4;
QUERY PLAN
----------------------------------------------------------------
Nested Loop (cost=1.71..17.78 rows=1 width=12)
    -> Nested Loop (cost=1.14..9.18 rows=1 width=8)
        -> Index Only Scan using idx_a on a
            (cost=0.57..4.58 rows=1 width=4)
            Index Cond: (aid = 4)
        -> Index Only Scan using idx_b on b
            (cost=0.57..4.59 rows=1 width=4)
            Index Cond: (bid = 4)
    -> Index Only Scan using idx_c on c
        (cost=0.57..8.59 rows=1 width=4)
        Index Cond: (cid = 4)
(8 rows)

PostgreSQL会使用三个索引,使用嵌套循环连接数据。

使过程失败

你看到了,PostgreSQL可以为查询提速。PostgreSQL够聪明,但是也需要聪明的用户。用户做了愚蠢的事情,也会影响优化效果。我们先删掉视图:

DROP VIEW v;

然后重新增加视图:

CREATE VIEW v AS SELECT * FROM a, b WHERE aid = bid 
OFFSET 0;

视图的逻辑和之前的一样。但是,优化器会用不同的方式处理。每个非0的OFFSET,会改变结果,因此,视图不得不被计算。

postgres=# EXPLAIN SELECT * FROM v, c WHERE v.aid = c.cid AND cid = 4;
QUERY PLAN
----------------------------------------------------------------
Nested Loop (cost=120.71..7949879.40 rows=1 width=12)
    -> Subquery Scan on v
                (cost=120.13..7949874.80 rows=1 width=8)
        Filter: (v.aid = 4)
            -> Merge Join (cost=120.13..6699874.80
                    rows=100000000 width=8)
                Merge Cond: (a.aid = b.bid)
               -> Index Only Scan using idx_a on a
                    (cost=0.57..2596776.57 rows=100000000
                    width=4)
               -> Index Only Scan using idx_b on b
                    (cost=0.57..5193532.33 rows=199999984
                    width=4)
    -> Index Only Scan using idx_c on c
        (cost=0.57..4.59 rows=1 width=4)
        Index Cond: (cid = 4)
(9 rows)

只看看预测的成本-从两位数飙升到惊人的数字。显然,查询性能非常糟糕。

常量折叠

PostgreSQL还有很多优化措施,比如常量折叠(constant folding)-把表达式转换成常量:

postgres=# explain SELECT * FROM a WHERE aid = 3 + 1;
                             QUERY PLAN                             
--------------------------------------------------------------------
 Index Only Scan using idx_a on a  (cost=0.43..8.45 rows=1 width=4)
   Index Cond: (aid = 4)
(2 行记录)

你看到了,PostgreSQL会查找4,因为aid有索引,PostgreSQL使用了索引扫描。因为我们的表只有一列,只访问索引就可以返回结果。
如果表达式在左边怎么办?

postgres=# explain SELECT * FROM a WHERE aid - 1 = 3;
                              QUERY PLAN                               
-----------------------------------------------------------------------
 Gather  (cost=1000.00..23350.00 rows=10000 width=4)
   Workers Planned: 2
   ->  Parallel Seq Scan on a  (cost=0.00..21350.00 rows=4167 width=4)
         Filter: ((aid - 1) = 3)
(4 行记录)

就成顺序扫描了。

函数内联

PostgreSQL可以内联静态SQL函数。用来减少调用的函数数量,提高速度。
我们先增加一个函数:

postgres=# CREATE OR REPLACE FUNCTION ld(int)
RETURNS numeric AS
$$
SELECT log(2, $1);
$$
LANGUAGE 'sql' IMMUTABLE;
CREATE FUNCTION

该函数用来计算以2为底的对数。

postgres=# SELECT ld(1024);
         ld          
---------------------
 10.0000000000000000
(1 行记录)

为了演示工作原理,我们清空表,插入1000条记录,以加速索引的速度。

postgres=# TRUNCATE a;
TRUNCATE TABLE
postgres=# INSERT INTO a SELECT generate_series(1, 10000);
INSERT 0 10000
postgres=# CREATE INDEX idx_ld ON a (ld(aid));
CREATE INDEX

然后,看一下执行计划:

postgres=# EXPLAIN SELECT * FROM a WHERE ld(aid) = 10;
                            QUERY PLAN                             
-------------------------------------------------------------------
 Index Scan using idx_ld on a  (cost=0.29..8.30 rows=1 width=4)
   Index Cond: (log('2'::numeric, (aid)::numeric) = '10'::numeric)
(2 行记录)

可以看到,优化器使用log函数代替了ld函数。和下面的查询等价:

postgres=# EXPLAIN SELECT * FROM a WHERE log(2, aid) = 10;
                            QUERY PLAN                             
-------------------------------------------------------------------
 Index Scan using idx_ld on a  (cost=0.29..8.30 rows=1 width=4)
   Index Cond: (log('2'::numeric, (aid)::numeric) = '10'::numeric)
(2 行记录)

连接修剪

join pruning的意思是删除不必要的连接。比如一些中间件或者ORM产生的SQL语句,删除连接,可以减轻负担。
我们先建表:

postgres=# CREATE TABLE x (id int, PRIMARY KEY (id));
CREATE TABLE
postgres=# CREATE TABLE y (id int, PRIMARY KEY (id));
CREATE TABLE
postgres=# INSERT INTO x SELECT generate_series(1, 1000);
INSERT 0 1000
postgres=# INSERT INTO y SELECT generate_series(1, 1000);
INSERT 0 1000

执行下面的查询:

postgres=# EXPLAIN SELECT *
FROM x LEFT JOIN y ON (x.id = y.id)
WHERE x.id = 3;
                                QUERY PLAN                                 
---------------------------------------------------------------------------
 Nested Loop Left Join  (cost=0.55..16.60 rows=1 width=8)
   Join Filter: (x.id = y.id)
   ->  Index Only Scan using x_pkey on x  (cost=0.28..8.29 rows=1 width=4)
         Index Cond: (id = 3)
   ->  Index Only Scan using y_pkey on y  (cost=0.28..8.29 rows=1 width=4)
         Index Cond: (id = 3)
(6 行记录)

PostgreSQL直接连接了这些表。我们修改一下SQL-不再选择所有的列,而只选择连接左边表的所有的列:

postgres=# EXPLAIN SELECT x.*
FROM x LEFT JOIN y ON (x.id = y.id)
WHERE x.id = 3;
                             QUERY PLAN                              
---------------------------------------------------------------------
 Index Only Scan using x_pkey on x  (cost=0.28..8.29 rows=1 width=4)
   Index Cond: (id = 3)
(2 行记录)

PostgreSQL不再连接,直接从x内部扫描。这么做,有两条理由:

  • 不从连接右边的表选择列
  • 右边是唯一的-连接不会通过复制右边而增加行的数量

加速集合操作

集合操作允许把多个查询的结果组合成一个结果集。包括UNION、INTERSECT和EXCEPT。PostgreSQL为这些操作做了很多优化工作。
优化器会把限制下放到集合操作中。看下面的例子:

postgres=# EXPLAIN SELECT * FROM
(
SELECT aid AS xid FROM a
UNION ALL
SELECT bid FROM b
) AS y
WHERE xid = 3;
                                QUERY PLAN                                
--------------------------------------------------------------------------
 Append  (cost=0.29..16.61 rows=2 width=4)
   ->  Index Only Scan using idx_a on a  (cost=0.29..8.30 rows=1 width=4)
         Index Cond: (aid = 3)
   ->  Index Only Scan using idx_b on b  (cost=0.28..8.29 rows=1 width=4)
         Index Cond: (bid = 3)
(5 行记录)

可以看到,唯一的限制在子查询外。但是,PostgreSQL把过滤条件下沉,xid = 3的条件给了aid和bid,这样,两张表都可以使用索引了。
注意,UNION和UNION ALL不同:UNION ALL盲目地加上该数据,并传给两张表的结果,而UNION会过滤掉重复的内容:

postgres=# EXPLAIN SELECT * FROM
(
SELECT aid AS xid FROM a
UNION   
SELECT bid FROM b
) AS y
WHERE xid = 3;
                                      QUERY PLAN                                      
--------------------------------------------------------------------------------------
 Unique  (cost=16.64..16.65 rows=2 width=4)
   ->  Sort  (cost=16.64..16.64 rows=2 width=4)
         Sort Key: a.aid
         ->  Append  (cost=0.29..16.63 rows=2 width=4)
               ->  Index Only Scan using idx_a on a  (cost=0.29..8.30 rows=1 width=4)
                     Index Cond: (aid = 3)
               ->  Index Only Scan using idx_b on b  (cost=0.28..8.29 rows=1 width=4)
                     Index Cond: (bid = 3)
(8 行记录)

PostgreSQL不得不在Append节点上增加一个Sort节点,确保重复的数据能被过滤掉。

理解执行计划

EXPLAIN ANALYZE会执行查询,返回计划,包括实际的运行时信息。下面是一个例子:

postgres=# EXPLAIN ANALYZE SELECT * FROM
(   
SELECT * FROM b LIMIT 1000000
) AS b
ORDER BY cos(bid);
                                                              QUERY PLAN                                                              
--------------------------------------------------------------------------------------------------------------------------------------
 Sort  (cost=145669.91..148169.91 rows=1000000 width=12) (actual time=635.926..778.192 rows=1000000 loops=1)
   Sort Key: (cos((b.bid)::double precision))
   Sort Method: external merge  Disk: 25496kB
   ->  Subquery Scan on b  (cost=0.00..28921.57 rows=1000000 width=12) (actual time=19.451..317.650 rows=1000000 loops=1)
         ->  Limit  (cost=0.00..13921.57 rows=1000000 width=4) (actual time=19.445..194.510 rows=1000000 loops=1)
               ->  Seq Scan on b b_1  (cost=0.00..314160.80 rows=22566480 width=4) (actual time=19.444..140.805 rows=1000000 loops=1)
 Planning Time: 0.154 ms
 Execution Time: 830.165 ms
(8 行记录)

这个计划看着有点可怕,不要惊慌,我们一步一步分析。读计划的时候,要从里向外读。我们的这个例子,从顺序扫描b开始。这里实际上有两块信息:成本块和实际的时间块,成本块包含估计,实际时间块显示实际的执行时间。这里的顺序扫描执行了140.805毫秒。
然后,数据被传给Limit节点,它确保没有太多的数据。注意,每个执行阶段也会向我们显示涉及的行的数量。你看到了,
通过Limit,确保PostgreSQL只得到100万条记录。实际的运行时间是317毫秒。最后,数据被排序,这要花费很多时间。查看计划,可以知道时间都流失在什么地方了。这个例子,顺序扫描花了一些时间,但是,这个阶段很难加速。然后,排序花了太多的时间。
排序是可以加速的,以后再讲。

让EXPLAIN更冗长

EXPLAIN的输出可以提供更多信息。比如我们使用这些选项的时候:

postgres=# EXPLAIN (analyze, verbose, costs, timing, buffers)
SELECT * FROM a ORDER BY random();
                                                    QUERY PLAN                                                     
-------------------------------------------------------------------------------------------------------------------
 Sort  (cost=834.39..859.39 rows=10000 width=12) (actual time=7.079..7.980 rows=10000 loops=1)
   Output: aid, (random())
   Sort Key: (random())
   Sort Method: quicksort  Memory: 853kB
   Buffers: shared hit=45
   ->  Seq Scan on public.a  (cost=0.00..170.00 rows=10000 width=12) (actual time=0.021..2.189 rows=10000 loops=1)
         Output: aid, random()
         Buffers: shared hit=45
 Planning Time: 0.095 ms
 Execution Time: 9.232 ms
(10 行记录)

analyze是实际上执行查询,verbose会增加更多的信息,costs会显示成本信息,timing提供运行时数据,让我们观察时间都花在什么地方了,buffers很有启发性,表示执行该计划,需要访问多少个buffers。

发现问题

看计划的时候,要问自己两个问题:

  • EXPLAIN ANALYZE所显示的运行时合理吗?
  • 如果查询慢,运行时跳到了哪儿

比如前面的例子,顺序扫描完成时是2.189毫秒,排序完成时是7.980毫秒。所以,排序用了将近6毫秒,占了本次查询的大部分时间。
想解决慢查询,无法提供一般性的建议,这是因为影响的因素太多了。

检查估计

但是:要确保估计值和实际值比较接近。有时候,因为估计值的原因(比如系统统计信息没及时更新),优化器会作出糟糕的决定。一般来说,autovacuum期间会做优化器统计。
看下面的例子:

postgres=# CREATE TABLE t_estimate AS
SELECT * FROM generate_series(1, 10000) AS id;
SELECT 10000

然后增加优化器统计:

postgres=# ANALYZE t_estimate;
ANALYZE

我们现在看看估计:

postgres=# EXPLAIN ANALYZE SELECT * FROM t_estimate WHERE cos(id) < 4;
                                                 QUERY PLAN                                                  
-------------------------------------------------------------------------------------------------------------
 Seq Scan on t_estimate  (cost=0.00..220.00 rows=3333 width=4) (actual time=0.017..2.753 rows=10000 loops=1)
   Filter: (cos((id)::double precision) < '4'::double precision)
 Planning Time: 0.101 ms
 Execution Time: 3.309 ms
(4 行记录)

很多时候,PostgreSQL可能很好地不能处理WHERE,这是因为它只有列的统计,而没有表达式的统计。这里,我们看到了对WHERE子句返回的数据的严重低估。
当然,也可能高估数据量:

postgres=# EXPLAIN ANALYZE SELECT * FROM t_estimate WHERE cos(id) > 4;
                                               QUERY PLAN                                                
---------------------------------------------------------------------------------------------------------
 Seq Scan on t_estimate  (cost=0.00..220.00 rows=3333 width=4) (actual time=1.953..1.953 rows=0 loops=1)
   Filter: (cos((id)::double precision) > '4'::double precision)
   Rows Removed by Filter: 10000
 Planning Time: 0.066 ms
 Execution Time: 1.970 ms
(5 行记录)

幸运的是,可以解决此问题:

postgres=# CREATE INDEX idx_cosine ON t_estimate (cos(id));
CREATE INDEX
postgres=# ANALYZE t_estimate;
ANALYZE

再看看执行计划:

postgres=# EXPLAIN ANALYZE SELECT * FROM t_estimate WHERE cos(id) > 4;
                                                      QUERY PLAN                                                       
-----------------------------------------------------------------------------------------------------------------------
 Index Scan using idx_cosine on t_estimate  (cost=0.29..8.30 rows=1 width=4) (actual time=0.007..0.007 rows=0 loops=1)
   Index Cond: (cos((id)::double precision) > '4'::double precision)
 Planning Time: 0.292 ms
 Execution Time: 0.027 ms
(4 行记录)

错误估计时常发生,比如经常被低估的一个问题是跨列相关(cross-column correlation)。比如这样一个例子,20%的人喜欢滑雪,20%的人来自非洲。如果想要非洲滑雪者的数量,计算一下是20% * 20%=4%。但是,非洲很少下雪,所以滑雪者的数量应该很少。PostgreSQL的统计不跨列,所以会导致比较差的结果。
从PostgreSQL 10.0开始,有了多变量统计,可以一劳永逸地解决此问题。

检查buffer的使用

很多时候,内存和缓存能导致意外的行为。
看个例子:

postgres=# CREATE TABLE t_random AS
SELECT * FROM generate_series(1, 10000000) AS id ORDER BY random();
SELECT 10000000
postgres=# ANALYZE t_random;
ANALYZE

我们查询一小部分记录:

postgres=# EXPLAIN (analyze true, buffers true, costs true, timing true)
SELECT * FROM t_random WHERE id < 1000;
                                                        QUERY PLAN                                                         
---------------------------------------------------------------------------------------------------------------------------
 Gather  (cost=1000.00..97431.21 rows=1000 width=4) (actual time=0.902..225.346 rows=999 loops=1)
   Workers Planned: 2
   Workers Launched: 2
   Buffers: shared hit=2176 read=42072
   ->  Parallel Seq Scan on t_random  (cost=0.00..96331.21 rows=417 width=4) (actual time=1.160..219.202 rows=333 loops=3)
         Filter: (id < 1000)
         Rows Removed by Filter: 3333000
         Buffers: shared hit=2176 read=42072
 Planning Time: 0.070 ms
 Execution Time: 225.431 ms
(10 行记录)

检查数据前,确保执行两次查询。看起来,应该使用索引。实际上,PostgreSQL找到2176个缓冲,而从操作系统读取了42072个缓冲。如果你够幸运,操作系统的缓冲命中率高,你的查询就快。如果命中率低,就需要从磁盘读数据。所以,执行时间会剧烈波动。查询从缓存读取的速度是从磁盘随机读速度的100倍。
让我们想像一个简单的例子。假如我们有一个电话系统,保存了100亿条记录。数据流增长得很快,而用户想查询这些数据。如果你有100亿行,只有部分数据能在内存找到,因此大部分来自磁盘。
比如,我们执行这样一条查询:

SELECT * FROM data WHERE phone_number = '+12345678';

你的通话记录会分散在各处-如果你挂断一个电话,再接起下一个,同时有数以万计的人都在这样做,所以,你的两条通话之间可能保存到同一个块的几率基本上是0。所以,如果想查询某电话的5000条通话记录,可能要访问5000个块。每个块需要5毫秒,一共就要执行25秒。当然,如果缓存命中率高一点,你的查询能执行得快一些。

你可能感兴趣的:(PostgreSQL)