PostgreSQL学徒

再聊聊晦涩的join_collapse_limit参数

前言

关于 join_collapse_limit 参数我在之前的文章中也有所提及:深入剖析PostgreSQL优化器,但是今天细看发现有些瑕疵,理解有所偏差。今天在我复核到 Query Execution Stages 章节的时候 (到第 16 章节了,总共 29 章节,胜利曙光就在眼前🫠),又看到了对这个参数的说明,有必要再次深入这个参数的实现原理了。

温顾

先温顾一下前置知识,假如现在有个查询是 SELECT ... FROM a, b, c, d, e WHERE ...,生成的语法分析树的结构类似如下:

Image

这种写法是隐式连接,当然也可以使用显式连接,比如 SELECT ... FROM a, b JOIN c ON ..., d, e WHERE ...,显式指定 JOIN 关键字,那么其语法结构树类似如下:

Image

这个时候,优化器在 deconstruct_jointree 这个函数中会去"展平" JOINEXPR 节点,用 B、C 节点替代,使其和第一个例子中类似。但是这种展平有个限制——没错,join_collapse_limit,只有当展平后的元素不超过 join_collapse_limit 才会执行,也就意味着:

  • 表 B 必须与表 C 连接 (反之亦然,表 C 必须与表 B 连接;一对连接内的顺序不受限制)。
  • 表 A、D、E 和 JOINEXPR 节点可以按任何顺序连接。

因此,按照假如是第一种情况,SELECT ... FROM a, b, c, d, e WHERE ...,其排列组合共有 5! = 120 这么多种组合,即

  1. a, b, c, d, e
  2. a, b, c, e, d
  3. a, b, d, c, e
  4. a, b, d, e, c
  5. a, b, e, c, d
  6. a, b, e, d, c
  7. a, c, b, d, e
  8. a, c, b, e, d
  9. a, c, d, b, e
  10. a, c, d, e, b
  11. ....

假如 join_collapse_limit 为 1 的话,那么 B 必须先和 C 进行连接,然后作为一个整体 (不妨用 x 替代),那么其实就变成了 SELECT ... FROM a, x, d, e WHERE ... 四个表参与查询的结构,所以,其排列组合就变成了 4! = 24 种,足足少了 5 倍。不难想象,这种方式可以减少优化器的规划时间。

我之前也在优化器篇章里提过,PostgreSQL 采用了动态规划 (Bottom-up) 和遗传算法相结合的方式:

  1. 比如现在有个 SQL 是 select * from a where id=10,那么首先全表扫描肯定可以,然后去获取统计信息估算出相应的代价;假如 id 列有索引,那么再去估算索引扫描的代价;先为基表确定扫描路径,估计扫描路径的代价和大小,这样我们就得出了单表查询的最优解;
  2. 假如现在的 SQL 变复杂了,需要 join:select * from a join b on (a.id=b.id) where a.id>10;,那么现在就要考虑更多了,假如使用 nestloop,看看 join 能不能用上索引,被驱动表上有没有索引,估计其代价,hash join、merge join 也是类似,看看能不能将某个表物化成 hash table,merge join 要求数据先排序,那么看看有没有索引能消除 SORT 算子诸如此类;最终把这些可能的 join 方式全部列出来,然后分别计划其代价,筛选出代价最低的路径,现在我们就得到了两个表 join 的最优解。
  3. 那假如现在又多了一个表,通过前两步骤,现在已知{A B} 、{A C} 、{B C}这三种 JOIN 的最优解,开始求解三张表的最优解,分别求解 {A B} JOIN C、{A C} JOIN B、{B C} JOIN A 这三种 JOIN 的代价,得到代价最小的路径
  4. 四个表类似,最终得到四表 JOIN 的最优解。

Image

可以看到,动态规划就是一个依次求最优解的这么一个过程。不难想象,随着表数量的增多,优化器的 planning time 会随着表的数量增加呈指数级增长。所以又引入了遗传算法,借鉴了自然界中的进化理论。

$ SELECT i, (i)! AS num_comparisons FROM generate_series(8, 20) i;
 i  |   num_comparisons   
----+---------------------
  8 |               40320
  9 |              362880
 10 |             3628800
 11 |            39916800
 12 |           479001600
 13 |          6227020800
 14 |         87178291200
 15 |       1307674368000
 16 |      20922789888000
 17 |     355687428096000
 18 |    6402373705728000
 19 |  121645100408832000
 20 | 2432902008176640000
(13 rows)

遗传算法默认是打开的,阈值是 12 (geqo_threshold) ,意味着超过了 12 个表进行关联,就会走到遗传算法,好处是可以减少 planning time,但坏处是可能找到的不是最优解,最优的执行计划,执行效率会有影响。

为此,PostgreSQL 又引入了 join_collapse_limit 这个参数,默认是 8。官网的解释有点晦涩,说白了就是有多少个显式关联操作会被优化器拉平,当做隐式关联,放到 FROM 子句中。

The planner will rewrite explicit JOIN constructs (except FULL JOINs) into lists of FROM items whenever a list of no more than this many items would result. Smaller values reduce planning time but might yield inferior query plans.

规划器会将显式的 JOIN (除了 FULL JOIN)重写为 FROM 项的列表,只要结果是不超过这么多项的列表。较小的值可以减少规划时间,但可能会产生次优的查询计划。

实践

以如下这个 SQL 为例,其涉及到 5 个关联操作

postgres=# EXPLAIN (COSTS OFF) WITH x AS (
    SELECT
        *                                   
    FROM
        generate_series(1, 1000) AS id
)          
SELECT                      
    *                       
FROM                        
    x AS a
    JOIN x AS b ON (a.id = b.id)
    JOIN x AS c ON (b.id = c.id)
    JOIN x AS d ON (c.id = d.id)
    JOIN x AS e ON (d.id = e.id)
    JOIN x AS f ON (e.id = f.id);

那么按照我们之前的分析,当 join_coallpase_limit = 1 的话,SQL 将严格按照我们写的顺序进行关联,即

postgres=# set join_collapse_limit to 1;
SET
postgres=# EXPLAIN (COSTS OFF) WITH x AS (
    SELECT
        *
    FROM
        generate_series(1, 1000) AS id
)
SELECT
    *
FROM
    x AS a
    JOIN x AS b ON (a.id = b.id)
    JOIN x AS c ON (b.id = c.id)
    JOIN x AS d ON (c.id = d.id)
    JOIN x AS e ON (d.id = e.id)
    JOIN x AS f ON (e.id = f.id);
                                 QUERY PLAN                                  
-----------------------------------------------------------------------------
 Merge Join
   Merge Cond: (f.id = a.id)
   CTE x
     ->  Function Scan on generate_series id
   ->  Sort
         Sort Key: f.id
         ->  CTE Scan on x f
   ->  Materialize
         ->  Merge Join
               Merge Cond: (e.id = a.id)
               ->  Sort
                     Sort Key: e.id
                     ->  CTE Scan on x e
               ->  Materialize
                     ->  Merge Join
                           Merge Cond: (d.id = a.id)
                           ->  Sort
                                 Sort Key: d.id
                                 ->  CTE Scan on x d
                           ->  Materialize
                                 ->  Merge Join
                                       Merge Cond: (c.id = a.id)
                                       ->  Sort
                                             Sort Key: c.id
                                             ->  CTE Scan on x c
                                       ->  Materialize
                                             ->  Merge Join
                                                   Merge Cond: (a.id = b.id)
                                                   ->  Sort
                                                         Sort Key: a.id
                                                         ->  CTE Scan on x a
                                                   ->  Sort
                                                         Sort Key: b.id
                                                         ->  CTE Scan on x b
(34 rows)

设为 6 及以上的时候,就不再变化,为最优执行计划。

postgres=# set join_collapse_limit to 6;
SET
postgres=# EXPLAIN (COSTS OFF) WITH x AS (
    SELECT
        *
    FROM
        generate_series(1, 1000) AS id
)
SELECT
    *
FROM
    x AS a
    JOIN x AS b ON (a.id = b.id)
    JOIN x AS c ON (b.id = c.id)
    JOIN x AS d ON (c.id = d.id)
    JOIN x AS e ON (d.id = e.id)
    JOIN x AS f ON (e.id = f.id);
                     QUERY PLAN                      
-----------------------------------------------------
 Merge Join
   Merge Cond: (a.id = d.id)
   CTE x
     ->  Function Scan on generate_series id
   ->  Merge Join
         Merge Cond: (c.id = a.id)
         ->  Sort
               Sort Key: c.id
               ->  CTE Scan on x c
         ->  Materialize
               ->  Merge Join
                     Merge Cond: (a.id = b.id)
                     ->  Sort
                           Sort Key: a.id
                           ->  CTE Scan on x a
                     ->  Sort
                           Sort Key: b.id
                           ->  CTE Scan on x b
   ->  Materialize
         ->  Merge Join
               Merge Cond: (f.id = d.id)
               ->  Sort
                     Sort Key: f.id
                     ->  CTE Scan on x f
               ->  Materialize
                     ->  Merge Join
                           Merge Cond: (d.id = e.id)
                           ->  Sort
                                 Sort Key: d.id
                                 ->  CTE Scan on x d
                           ->  Sort
                                 Sort Key: e.id
                                 ->  CTE Scan on x e
(33 rows)

让我们再看个例子。先建 7 个表,插入点数据:

postgres=# create table t1(id int, info text);
CREATE TABLE
postgres=# create table t2(id int, info text);
CREATE TABLE
postgres=# create table t3(id int, info text);
CREATE TABLE
postgres=# create table t4(id int, info text);
CREATE TABLE
postgres=# create table t5(id int, info text);
CREATE TABLE
postgres=# create table t6(id int, info text);
CREATE TABLE
postgres=# create table t7(id int, info text);
CREATE TABLE
postgres=# insert into t1 select n,md5(random()::text) from generate_series(1,100) as n;
INSERT 0 100
postgres=# insert into t2 select n,md5(random()::text) from generate_series(1,1000) as n;
INSERT 0 1000
postgres=# insert into t3 select n,md5(random()::text) from generate_series(1,10000) as n;
INSERT 0 10000
postgres=# insert into t4 select n,md5(random()::text) from generate_series(1,10000) as n;
INSERT 0 10000
postgres=# insert into t5 select n,md5(random()::text) from generate_series(1,10000) as n;
INSERT 0 10000
postgres=# insert into t6 select n,md5(random()::text) from generate_series(1,10000) as n;
INSERT 0 10000
postgres=# insert into t7 select n,md5(random()::text) from generate_series(1,10000) as n;
INSERT 0 10000

然后构造一个较复杂的查询:

postgres=# EXPLAIN (costs off)                      
SELECT
    *
FROM ( (t4 JOIN t7 USING (id))
    JOIN (t6 JOIN t5 USING (id)) USING (id) )
JOIN (t3 JOIN (t2 JOIN t1 USING (id)) USING (id))
USING (id);
                                        QUERY PLAN                                        
------------------------------------------------------------------------------------------
 Hash Join
   Hash Cond: (t3.id = t4.id)
   ->  Seq Scan on t3
   ->  Hash
         ->  Hash Join
               Hash Cond: (t5.id = t4.id)
               ->  Seq Scan on t5
               ->  Hash
                     ->  Hash Join
                           Hash Cond: (t6.id = t4.id)
                           ->  Seq Scan on t6
                           ->  Hash
                                 ->  Hash Join
                                       Hash Cond: (t7.id = t4.id)
                                       ->  Seq Scan on t7
                                       ->  Hash
                                             ->  Hash Join
                                                   Hash Cond: (t4.id = t2.id)
                                                   ->  Seq Scan on t4
                                                   ->  Hash
                                                         ->  Hash Join
                                                               Hash Cond: (t2.id = t1.id)
                                                               ->  Seq Scan on t2
                                                               ->  Hash
                                                                     ->  Seq Scan on t1
(25 rows)

这么长一坨的SQL,假如按照我们的"本意",那么其逻辑应该

  1. 首先,t1 和 t2 通过它们的 id 列进行 JOIN 操作,形成一个中间结果。
  2. 然后,这个中间结果再与 t3 进行 JOIN 操作,同样是通过 id 列,形成另一个中间结果。
  3. 接下来,t5 和 t6 通过它们的 id 列进行 JOIN 操作,形成一个中间结果。
  4. t4 和 t7 也通过它们的 id 列进行 JOIN 操作,形成另一个中间结果。
  5. 然后,t4 和 t7 的 JOIN 结果与 t5 和 t6 的 JOIN 结果进行 JOIN 操作,通过 id 列,形成又一个中间结果。
  6. 最后,这个中间结果与前面由 t1, t2, 和 t3 形成的 JOIN 结果进行最终的 JOIN 操作,仍然是通过 id 列。

但是查看执行计划的话,可以看到优化器进行了改写,完全进行打乱了

  1. 最内层的哈希连接是 t2 和 t1,基于条件 t2.id = t1.id。
  2. 结果哈希表被用于与 t4 进行连接,基于条件 t4.id = t2.id。
  3. 接着是 t7 与 t4 的连接,基于条件 t7.id = t4.id。
  4. 然后是 t6 与 t4 的连接,基于条件 t6.id = t4.id。
  5. 接下来是 t5 与 t4 的连接,基于条件 t5.id = t4.id。
  6. 最后是 t3 与 t4 的连接,基于条件 t3.id = t4.id。

现在让我们调整一下 join_collapse_limit,看看会发生什么:

postgres=# set join_collapse_limit to 5;
SET
postgres=# EXPLAIN (costs off)                      
SELECT
    *
FROM ( (t4 JOIN t7 USING (id))
    JOIN (t6 JOIN t5 USING (id)) USING (id) )
JOIN (t3 JOIN (t2 JOIN t1 USING (id)) USING (id))
USING (id);
                      QUERY PLAN                      
------------------------------------------------------
 Hash Join
   Hash Cond: (t4.id = t2.id)
   ->  Hash Join
         Hash Cond: (t4.id = t5.id)
         ->  Hash Join
               Hash Cond: (t4.id = t6.id)
               ->  Hash Join
                     Hash Cond: (t4.id = t7.id)
                     ->  Seq Scan on t4
                     ->  Hash
                           ->  Seq Scan on t7
               ->  Hash
                     ->  Seq Scan on t6
         ->  Hash
               ->  Seq Scan on t5
   ->  Hash
         ->  Hash Join
               Hash Cond: (t3.id = t2.id)
               ->  Seq Scan on t3
               ->  Hash
                     ->  Hash Join
                           Hash Cond: (t2.id = t1.id)
                           ->  Seq Scan on t2
                           ->  Hash
                                 ->  Seq Scan on t1
(25 rows)

postgres=# set join_collapse_limit to 3;
SET
postgres=# EXPLAIN (costs off)                      
SELECT
    *
FROM ( (t4 JOIN t7 USING (id))
    JOIN (t6 JOIN t5 USING (id)) USING (id) )
JOIN (t3 JOIN (t2 JOIN t1 USING (id)) USING (id))
USING (id);
                      QUERY PLAN                      
------------------------------------------------------
 Hash Join
   Hash Cond: (t4.id = t2.id)
   ->  Hash Join
         Hash Cond: (t4.id = t6.id)
         ->  Hash Join
               Hash Cond: (t4.id = t7.id)
               ->  Seq Scan on t4
               ->  Hash
                     ->  Seq Scan on t7
         ->  Hash
               ->  Hash Join
                     Hash Cond: (t6.id = t5.id)
                     ->  Seq Scan on t6
                     ->  Hash
                           ->  Seq Scan on t5
   ->  Hash
         ->  Hash Join
               Hash Cond: (t3.id = t2.id)
               ->  Seq Scan on t3
               ->  Hash
                     ->  Hash Join
                           Hash Cond: (t2.id = t1.id)
                           ->  Seq Scan on t2
                           ->  Hash
                                 ->  Seq Scan on t1
(25 rows)

可以看到,PostgreSQL 会根据 join_collapse_limit 的设置,当需要关联的个数超过阈值时,超出的部分不会继续调整了。让我们省略掉几个关联,然后从 1 开始调整

EXPLAIN (costs off)                      
SELECT
    *
FROM ( (t4 JOIN t7 USING (id))
    JOIN (t6 JOIN t5 USING (id)) USING (id) )
JOIN t3 USING (id);

现在的查询逻辑变成了:

  • t4 JOIN t7 USING (id): 首先,t4 和 t7 根据它们的 id 列进行 JOIN 操作。
  • t6 JOIN t5 USING (id): 同时,t6 和 t5 也根据它们的 id 列进行 JOIN 操作。
  • 然后,这两组 JOIN 的结果(t4 和 t7 的结果与 t6 和 t5 的结果)再次根据 id 列进行 JOIN 操作。
  • 接着,上一步的结果与 t3 进行 JOIN 操作,仍然是基于 id 列。

Image

join_collapse_limit = 1

现在从 1 开始调整,可以看到,执行计划严格按照我们的写法进行关联

postgres=# set join_collapse_limit to 1;
SET
postgres=# EXPLAIN (costs off)                      
SELECT
    *
FROM ( (t4 JOIN t7 USING (id))
    JOIN (t6 JOIN t5 USING (id)) USING (id) )
JOIN t3 USING (id);
                   QUERY PLAN                   
------------------------------------------------
 Hash Join
   Hash Cond: (t4.id = t3.id)
   ->  Hash Join
         Hash Cond: (t4.id = t6.id)
         ->  Hash Join
               Hash Cond: (t4.id = t7.id)
               ->  Seq Scan on t4
               ->  Hash
                     ->  Seq Scan on t7
         ->  Hash
               ->  Hash Join
                     Hash Cond: (t6.id = t5.id)
                     ->  Seq Scan on t6
                     ->  Hash
                           ->  Seq Scan on t5
   ->  Hash
         ->  Seq Scan on t3
(17 rows)

join_collapse_limit = 2

postgres=# set join_collapse_limit to 2;
SET
postgres=# EXPLAIN (costs off)                      
SELECT
    *
FROM ( (t4 JOIN t7 USING (id))
    JOIN (t6 JOIN t5 USING (id)) USING (id) )
JOIN t3 USING (id);
                   QUERY PLAN                   
------------------------------------------------
 Hash Join
   Hash Cond: (t4.id = t3.id)
   ->  Hash Join
         Hash Cond: (t4.id = t6.id)
         ->  Hash Join
               Hash Cond: (t4.id = t7.id)
               ->  Seq Scan on t4
               ->  Hash
                     ->  Seq Scan on t7
         ->  Hash
               ->  Hash Join
                     Hash Cond: (t6.id = t5.id)
                     ->  Seq Scan on t6
                     ->  Hash
                           ->  Seq Scan on t5
   ->  Hash
         ->  Seq Scan on t3
(17 rows)

join_collapse_limit = 3

postgres=# set join_collapse_limit to 3;
SET
postgres=# EXPLAIN (costs off)                      
SELECT
    *
FROM ( (t4 JOIN t7 USING (id))
    JOIN (t6 JOIN t5 USING (id)) USING (id) )
JOIN t3 USING (id);
                   QUERY PLAN                   
------------------------------------------------
 Hash Join
   Hash Cond: (t4.id = t3.id)
   ->  Hash Join
         Hash Cond: (t4.id = t6.id)
         ->  Hash Join
               Hash Cond: (t4.id = t7.id)
               ->  Seq Scan on t4
               ->  Hash
                     ->  Seq Scan on t7
         ->  Hash
               ->  Hash Join
                     Hash Cond: (t6.id = t5.id)
                     ->  Seq Scan on t6
                     ->  Hash
                           ->  Seq Scan on t5
   ->  Hash
         ->  Seq Scan on t3
(17 rows)

join_collapse_limit = 4

可以看到,当变成 4 的时候,SQL 的逻辑就不再是我们写的本意那样了。将 4 个关联操作全部展开之后,优化器发现用这种执行计划更加高效。

Image

postgres=# set join_collapse_limit to 4;
SET
postgres=# EXPLAIN (costs off)                      
SELECT
    *
FROM ( (t4 JOIN t7 USING (id))
    JOIN (t6 JOIN t5 USING (id)) USING (id) )
JOIN t3 USING (id);
                   QUERY PLAN                   
------------------------------------------------
 Hash Join
   Hash Cond: (t4.id = t3.id)
   ->  Hash Join
         Hash Cond: (t4.id = t5.id)
         ->  Hash Join
               Hash Cond: (t4.id = t6.id)
               ->  Hash Join
                     Hash Cond: (t4.id = t7.id)
                     ->  Seq Scan on t4
                     ->  Hash
                           ->  Seq Scan on t7
               ->  Hash
                     ->  Seq Scan on t6
         ->  Hash
               ->  Seq Scan on t5
   ->  Hash
         ->  Seq Scan on t3
(17 rows)

from_collapse_limit

还有个类似的参数,仅当子查询的数量小于 from_collapse_limit 时,这些子查询才会被提升。

SELECT ...
FROM a,
 (
  SELECT ... FROM b, c WHERE ...
 ) bc,
 d, e
WHERE ...

Image

技巧

通过 offset 0 可以达到和 join_collapse_limit = 1 类似的效果。

EXPLAIN (COSTS OFF)
SELECT subq.b_id, a.value
FROM a JOIN
   (SELECT a_id, b.b_id, c.c_id
    FROM b
       JOIN c USING (a_id)
    WHERE c.c_id < 300
    OFFSET 0
   ) AS subq
      USING (a_id);

                 QUERY PLAN                 
═══════════════════════════════════════════
 Nested Loop
   ->  Hash Join
         Hash Cond: (c.a_id = b.a_id)
         ->  Seq Scan on c
               Filter: (c_id < 300)
         ->  Hash
               ->  Seq Scan on b
   ->  Memoize
         Cache Key: b.a_id
         Cache Mode: logical
         ->  Index Scan using a_pkey on a
               Index Cond: (a_id = b.a_id)
(12 rows)

SET join_collapse_limit = 1;

 EXPLAIN (COSTS OFF)
SELECT b.b_id, a.value
FROM b
   JOIN c USING (a_id)
   JOIN a USING (a_id)
WHERE c.c_id < 300;

                 QUERY PLAN                 
═══════════════════════════════════════════
 Nested Loop
   ->  Hash Join
         Hash Cond: (c.a_id = b.a_id)
         ->  Seq Scan on c
               Filter: (c_id < 300)
         ->  Hash
               ->  Seq Scan on b
   ->  Memoize
         Cache Key: b.a_id
         Cache Mode: logical
         ->  Index Scan using a_pkey on a
               Index Cond: (a_id = b.a_id)
(12 rows)

以及搭配 MATERIALIZED,防止将 CTE 提升至主查询中 (12以前,CTE是无法提升到主查询中的)。

EXPLAIN (COSTS OFF)
WITH subq AS MATERIALIZED (
   SELECT a_id, b.b_id, c.c_id
   FROM b
      JOIN c USING (a_id)
   WHERE c.c_id < 300
)
SELECT subq.b_id, a.value
FROM a
   JOIN subq USING (a_id);

                QUERY PLAN               
════════════════════════════════════════
 Hash Join
   Hash Cond: (subq.a_id = a.a_id)
   CTE subq
     ->  Hash Join
           Hash Cond: (c.a_id = b.a_id)
           ->  Seq Scan on c
                 Filter: (c_id < 300)
           ->  Hash
                 ->  Seq Scan on b
   ->  CTE Scan on subq
   ->  Hash
         ->  Seq Scan on a
(12 rows)

小结

通过 join_collapse_limit 参数,可以固定关联顺序,在某些场景下改写 SQL 以提升性能,更高的值可以生成更好的执行计划,但是代价便是规划时间可能变长,另外要注意基因遗传算法的触发阈值是 12,此参数默认是 8。

再次感叹优化器太过于晦涩,其中原理过于复杂,衷心佩服那些优化器内核人员。

参考

https://github.com/duke-lv/blog-2/blob/master/201609/20160926_01.md

https://www.cybertec-postgresql.com/en/forcing-a-join-order-in-postgresql/

https://www.cybertec-postgresql.com/en/postgressql-implicit-vs-explicit-joins/

 Image

推荐阅读

📙 PostgreSQL优化器解析
📙 深入剖析PostgreSQL优化器

Feel free to contact me 

  • 微信公众号:PostgreSQL学徒
  • Github:https://github.com/xiongcccc
  • 微信:_xiongcc
  • 知乎:xiongcc
  • 墨天轮:https://www.modb.pro/u/39588