PolarDB · 功能特性 · 嵌套子查询优化的性能分析

Author: zhilin

背景

嵌套子查询的性能问题一直是数据库优化的核心问题,原因也很明显,由于子查询的嵌套导致子查询被重复执行多次,尤其当主查询的数据量比较大时,由于子查询的嵌套迭代执行导致的性能问题就越发明显,因此优化器在优化过程中会根据子查询的特点,分别进行针对性的优化改写,提升查询的性能。下面我们就PolarDB对嵌套子查询的优化做一些详细的分析,让用户更深入理解PolarDB在性能优化方面的成就,让用户使用的更放心更安心。

包含聚集函数的子查询的优化方法

子查询优化的方法有很多,比如单值子查询的优化,SEMI-JOIN优化等,本文主要介绍的针对包含聚集函数的相关子查询的优化方法。下面是一个示例:

  1. SELECT
  2. SUM(l_extendedprice) / 7.0 AS avg_yearly
  3. FROM
  4. lineitem,
  5. part
  6. WHERE
  7. p_partkey = l_partkey
  8. AND p_brand = 'Brand#55'
  9. AND p_container = 'JUMBO CASE'
  10. AND l_quantity < (
  11. SELECT
  12. 0.2 * AVG(l_quantity)
  13. FROM
  14. lineitem
  15. WHERE
  16. l_partkey = p_partkey
  17. )
  18. LIMIT 1;

这是一个TPCH测试中一条件查询语句Query 17, 查询中包含了一个相关子查询,并且子查询中有聚集函数AVG,正常执行时,当主查询中lineitem表和part执行JOIN后产生的每一行数据都要将子查询执行一遍,然后才能做WHERE条件的过滤,得到最终的结果。显然,当主查询的行数非常大时,执行子查询的次数也自然很多,查询效率自然不高。

对此PolarDB提供了两种更好的优化方法,一种是首先对子查询按关联键进行分组执行聚集,将子查询转为一个Derived Table,然后将其提到主查询中,与主查询中的lineitem,part直接JOIN,避免子查询的无数次重复执行,实现性能优化的目的。这种优化方法可以简称为group by + Derived Table变换,下面我们通过改写的SQL来示意它的变换过程。

  1. SELECT
  2. SUM(l_extendedprice) / 7.0 AS avg_yearly
  3. FROM
  4. lineitem,
  5. part,
  6. (SELECT
  7. l_partkey as dt_partkey, 0.2 * AVG(l_quantity) as avg_quantity
  8. FROM
  9. lineitem
  10. GROUP BY l_partkey
  11. ) dt
  12. WHERE
  13. p_partkey = l_partkey
  14. AND p_brand = 'Brand#55'
  15. AND p_container = 'JUMBO CASE'
  16. AND p_partkey = dt.dt_partkey
  17. AND l_quantity < dt.avg_quantity
  18. LIMIT 1;

通过提前将lineitem按l_partkey分组,计算出每组l_partkey的平均值,然后和外层查询中的表进行JOIN,就可以直接得到对应的平均值,然后执行Where条件过滤即可得到最终结果。

除此之外,还有另外一种变换方法,是通过window function计算每组l_partkey的值,然后再使用Where条件过滤,得到最终的结果。这种优化方法可以简单为Window function + Derived Table变换,下面我们通过改写的SQL来示意它的变换过程。

  1. SELECT
  2. SUM(l_extendedprice) / 7.0 AS avg_yearly
  3. FROM
  4. (SELECT
  5. l_quantity,
  6. l_extendedprice,
  7. (0.2 * AVG(l_quantity) OVER (PARTITION BY l_partkey)) as avg_quantity
  8. FROM
  9. lineitem,
  10. part
  11. WHERE
  12. p_partkey = l_partkey
  13. AND p_brand = 'Brand#55'
  14. AND p_container = 'JUMBO CASE'
  15. ) win
  16. WHERE
  17. l_quantity < avg_quantity
  18. LIMIT 1;

通过Window function将每组l_partkey的平均值计算出来,与group by + Derived Table变换不同的是,无需再多加一次JOIN,就可以直接过滤得到最终结果。

不同变换方式的性能分析

如下所示,对同一种SQL,有两种变换方式,那么到底应该选择哪一种变换方式呢?有没有可能不变换的性能更好呢?下面就这个问题进行详细的分析,首先进行一个测试,如下所示:

测试方法

创建2个表,一个表是小表,其主键是另一个表的外键,查询中会利用这个主外键做关联进行子查询的关联查询。

  1. CREATE TABLE part (
  2. p_partkey INT PRIMARY KEY
  3. );
  4. CREATE TABLE partsupp (
  5. ps_partkey INT,
  6. ps_supplycost DECIMAL(10,2),
  7. INDEX i1 (ps_partkey)
  8. );

我们在part表中插入100000条数据。在每一轮实验中,针对每一个p_partkey值,在partsupp表中插入不同factor(1,2,4,8,16,32)条数据来模拟不同关联条数的重复值的影响。通过过滤条件中不同的@selectivity(0.1~1)来模拟选择率的影响。

测试语句为一个类似Query 17的嵌套子查询:

  1. SELECT COUNT(*) FROM part, partsupp
  2. WHERE p_partkey = ps_partkey
  3. AND ps_partkey <= 100000 * @selectivity
  4. AND ps_supplycost >= (
  5. SELECT AVG(ps_supplycost) FROM partsupp WHERE p_partkey = ps_partkey);

这里为了测试不同选择率对性能的影响,使用了一个@selectivity变量,它是一个session级的变量,并且为了测试选择率的影响,分别选择了两种模式,一种是这个影响选择率的条件在变换后不能下推到Derived Table中;另一种是这个影响选择率的条件在变换后可以下推到Derived Table中。 影响选择率的条件不能下推的Group by + Derived Table的变换示意如下:

  1. SELECT COUNT(*) FROM
  2. part, partsupp,
  3. (select ps_partkey, AVG(ps_supplycost) as avg_supplycost
  4. from partsupp group by ps_partkey) ps2
  5. WHERE p_partkey = partsupp.ps_partkey
  6. AND partsupp.ps_partkey <= 100000 * @selectivity
  7. AND p_partkey = ps2.ps_partkey
  8. AND partsupp.ps_supplycost >= avg_supplycost;

影响选择率的条件不能下推的Window function + Derived Table的变换示意如下: 在window function + Derived Table变换中,如果存在session变量不能下推的情况,PolarDB默认是不做Window function变换的,因为这种变换的性能可能会变差,测试中通过在子查询添加hint来强制Window function变换。

  1. SELECT COUNT(*) FROM part, partsupp
  2. WHERE p_partkey = ps_partkey
  3. AND ps_partkey <= 100000 * @selectivity
  4. AND ps_supplycost >= (
  5. SELECT /*+ UNNEST(WINDOW_FUNCTION) */
  6. AVG(ps_supplycost)
  7. FROM partsupp
  8. WHERE p_partkey = ps_partkey);

Window function + Derived Table变换后的查询:

  1. SELECT COUNT(*) FROM
  2. (
  3. select ps_partkey, ps_supplycost, AVG(ps_supplycost) over(partition by ps_partkey) as avg_supplycost
  4. from part, partsupp
  5. where p_partkey = partsupp.ps_partkey
  6. ) win
  7. where
  8. win.ps_supplycost > win.avg_supplycost
  9. AND ps_partkey <= 100000 * @selectivity;

影响选择率的条件可以下推的Group by + Derived Table的变换示意如下:

  1. SELECT COUNT(*) FROM
  2. part, partsupp,
  3. (SELECT ps_partkey, AVG(ps_supplycost) as avg_supplycost
  4. FROM partsupp
  5. WHERE ps_partkey <= 100000 * 0.2
  6. GROUP BY ps_partkey
  7. ) ps2
  8. WHERE p_partkey = partsupp.ps_partkey
  9. AND partsupp.ps_partkey <= 100000 * 0.2
  10. AND p_partkey = ps2.ps_partkey
  11. AND partsupp.ps_supplycost >= avg_supplycost;

影响选择率的条件可以下推的Window function + Derived Table的变换示意如下:

  1. SELECT COUNT(*) FROM
  2. (
  3. select ps_partkey, ps_supplycost, AVG(ps_supplycost) over(partition by ps_partkey) as avg_supplycost
  4. from part, partsupp
  5. where p_partkey = partsupp.ps_partkey AND ps_partkey < 100000 * 0.2
  6. ) win
  7. where
  8. win.ps_supplycost > win.avg_supplycost;

测试结果如下所示: 相同选择率不同重复值的测试结果 img

● 红色曲线是不变换的查询性能,受重复值的影响最大,当重复值大越多,性能越差。 ● 蓝色曲线是groupby变换,并且条件可以pushdown的场景,性能最好。 ● 绿色曲线是window变换,并且条件可以pushdown的场景,性能次优。 ● 浅蓝色曲线是group by变换,但条件不能pushdown的场景,性能又稍差。 ● 紫色曲线是window变换,但条件不能pushdown的场景,性能也不好很好,当重复值小于14左右,性能最差,之后略好于不变换的查询性能。

相同重复值不同选择率的测试结果 img

● 红色曲线表示不做任何变换的查询,性能会随着选择率的增长,性能越来越差,当超过80%时,最差。 ● 蓝色曲线表示groupby变换,并且条件可以pushdown,性能最好,由于测试精度问题,当选择率为100%时,应该和条件不pushdown的性能相当,此处显示会略好,只是2次测试之间的误差问题,因为本身执行时间就比较短,只有0.5s左右。 ● 绿色曲线表示window变换,并且条件可以pushdown,性能次优,随着选择率趋进于100%,和不能pushdown的性能相当。 ● 浅蓝色曲线表示groupby变换,但条件不能pushdown,性能在条件不能pushdown中是最优的。 ● 紫色曲线表示window变换,但条件不能pushdown,相比同样是window变换,但条件可以pushdown的性能相关比较大,当条件不能pushdown时,条件对性能的影响几乎可以忽略不记。

初步的性能分析结论

根据以上的测试和分析,发现这个性能结果出乎我们的意料之外,window变换很多情况下应该是最优的,但实际的测试结果和数据分析结果都与其相违背。而且对于TPCH中的Q2/Q17,window变换相比groupby变换有明显优势,到底问题出在哪里呢?因此后续对Q17又做了一些对比测试。

Q17的性能分析

为了解决上面测试出现的疑问,有必要对Q2/Q17的变换做深入分析,以解决上面结论中的问题。下面以Q17为例进行性能分析和测试。 测试数据集为10s,数据特点为对于partkey,lineitem中的重复值大约为30,比较大。另外Q17的选择率比较小,大约是0.11%。通过Explan analyze得到Q17执行过程中的性能数据:

Group by+Derived table变换

  1. | -> Limit: 1 row(s) (actual time=12512.366..12512.366 rows=1 loops=1)
  2. -> Aggregate: sum(lineitem.l_extendedprice) (actual time=12512.365..12512.365 rows=1 loops=1)
  3. -> Nested loop inner join (actual time=12338.854..12511.773 rows=2644 loops=1)
  4. -> Nested loop inner join (cost=40928.12 rows=58923) (actual time=0.198..153.232 rows=6688 loops=1)
  5. -> Filter: ((part.p_container = 'JUMBO CASE') and (part.p_brand = 'Brand#55')) (cost=20305.00 rows=1980) (actual time=0.087..137.618 rows=222 loops=1)
  6. -> Table scan on part (cost=20305.00 rows=198000) (actual time=0.027..94.947 rows=200000 loops=1)
  7. -> Index lookup on lineitem using l_partkey_idx (l_partkey=part.p_partkey) (cost=7.44 rows=30) (actual time=0.052..0.063 rows=30 loops=222)
  8. -> Filter: (lineitem.l_quantity < derived_1_2.Name_exp_1) (actual time=1.847..1.847 rows=0 loops=6688)
  9. -> Index lookup on derived_1_2 using <auto_key2> (Name_exp_2=part.p_partkey) (actual time=0.001..0.001 rows=1 loops=6688)
  10. -> Materialize (actual time=1.846..1.847 rows=1 loops=6688)
  11. -> Group aggregate: avg(lineitem.l_quantity) (actual time=0.263..12091.061 rows=200000 loops=1)
  12. -> Index scan on lineitem using l_partkey_idx (cost=607940.30 rows=5953163) (actual time=0.239..10743.582 rows=6001215 loops=1)

Window function+Derived table变换

  1. | -> Limit: 1 row(s) (actual time=170.197..170.197 rows=1 loops=1)
  2. -> Aggregate: sum(derived_1_2.Name_exp_3) (actual time=170.195..170.195 rows=1 loops=1)
  3. -> Filter: (derived_1_2.Name_exp_2 < derived_1_2.Name_exp_1) (actual time=165.699..169.697 rows=2644 loops=1)
  4. -> Table scan on derived_1_2 (actual time=0.001..0.873 rows=6688 loops=1)
  5. -> Materialize (actual time=165.696..168.067 rows=6688 loops=1)
  6. -> Window aggregate with buffering (actual time=144.326..162.817 rows=6688 loops=1)
  7. -> Sort: <temporary>.l_partkey (actual time=144.243..145.376 rows=6688 loops=1)
  8. -> Stream results (actual time=0.229..142.414 rows=6688 loops=1)
  9. -> Nested loop inner join (cost=40928.12 rows=58923) (actual time=0.226..139.011 rows=6688 loops=1)
  10. -> Filter: ((part.p_container = 'JUMBO CASE') and (part.p_brand = 'Brand#55')) (cost=20305.00 rows=1980) (actual time=0.109..121.276 rows=222 loops=1)
  11. -> Table scan on part (cost=20305.00 rows=198000) (actual time=0.071..78.989 rows=200000 loops=1)
  12. -> Index lookup on lineitem using l_partkey_idx (l_partkey=part.p_partkey) (cost=7.44 rows=30) (actual time=0.062..0.072 rows=30 loops=222)

为了分析简明方便,有如下的定义: 使用cnt表示需要读取表的行数,dup表示对于关联列l_partkey在表中的重复的平均行数,sel%表示选择率,则 不同阶段的COST如下表所示: | | group by + Derived Table变换 | window function + Derived Table变换 | | ——– | ——– | ——– | | io_cost | cnt(lineitem) + dup * cnt(part) * sel% + cnt(part) | cnt(part)+dup * cnt(part) * sel% | | join_cost | cnt(part) * sel% * dup | cnt(part) * sel% * dup | | win_cost | 0 | cnt(part) * sel% * dup | | avg_cost | cnt(lineitem) | 0 | | sort_cost | 0 | cnt(part)* sel% * dup | | mat_cost | cnt(lineitem) * 1/dup | cnt(part) * sel% * dup | | sum_cost | cnt(part) * sel% * dup * 40% | cnt(part) * sel% * dup * 40% |

其中,根据实际的数据估算,sel%=0.11,dup大约为30,40%为最终l_quantity < 0.8 * avg的选择率,TPCH原来为0.2,为了数据量更大,测试时调整为了0.8.

最终的性能分析结论

通过Q17的性能数据,可以发现window变换后的性能远远高于group by的变换,再经过详细的分析数据发现,写之前测试结论不同的原因主要有2点:

  • Q17中的选择率非常低,大约在0.11%,而我们之前测试的至少都在10%左右,当选择率可以下推时,影响比较大。
  • 另外一个更重要的一点是Q17在group by + Derived Table变换后的IO非常高,但在之前的测试中group by+Derived Table变换后IO并不是非常高,这也是导致性能相关较大的最主要的原因。通过深入分析发现,Q17的子查询中使用的是索引扫描,选择率的过滤条件在Q17中是通过filter来实现的,所以即使条件pushdown到子查询中后,仍需要扫描全表,而之前的测试是通过索引做range scan,并且ICP条件pushdown实现的,这样就导致在子查询中IO会大量减少,对group by+Derived Table变换的影响非常大,如果group by+Derived Table变换后不能使用索引来做range scan,条件也不能ICP下推,那么IO会非常大,是整个大表如lineitem表,或partsupp表的行数。而采用range scan+icp,就会直接是fitler过后的io,当filter很小时,IO就会非常小。 当使用windows变换时,首先是在小表part表中过滤,然后通过索引lookup大表lineitem表,这样IO只是part表的行数,以及过滤过的partkey对应在lineitem中的lookup的行数,因此io与lineitem的大小关系不大。

结论

通过以上的测试和性能分析结果,可以得出这样的结论,通过优化器的查询变换大多数情况下都可以提升性能,但在一些特殊场景下,无论是group by+Derived Table变换还是window+Derived Table变换有可能导致变换后的性能回退,不同变换的性能差异也比较明显,甚至不做变换反而性能是最优的。其中有几个因素对性能有比较大的影响:

  • 影响外查询的选择率的条件,尽可能的下推到子查询中,当不能下推时,外查询的选择率越低,性能回退的可能性越高。
  • 子查询中大表中包含关联列的重复值影响也很大,当重复值越大,变换后的性能提升越明显,这主要是因为提前做AGG聚集操作有利于减少中间结果;
  • 子查询中是否可以将影响选择率的条件下推到存储层,当可以在存储层过滤大量数据时,group by+Derived Table变换的性能会更优,否则window + Derived Table变换的性能更优。
  • 当条件不能pushdown到子查询中时,在重复值较小时,不变换性能更优;重复值稍大时,group by + Derived Table变换更优。当重复值超过某个阀值时,不变换的性能最差。

总之,对于包含聚集操作的嵌套子查询来说,采用什么样的变换方式需要根据实际的COST来决定,不同场景下数据集的特点相差很大,不同的场景下的查询选择率也相差很大,不能简单指定某个规则就应用于所有场景。

原文:http://mysql.taobao.org/monthly/2022/10/06/