数据库内核月报

数据库内核月报 - 2022 / 10

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

Author: zhilin

背景

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

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

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

SELECT
  		SUM(l_extendedprice) / 7.0 AS avg_yearly
FROM
        lineitem,
        part
WHERE
        p_partkey = l_partkey
        AND p_brand = 'Brand#55'
        AND p_container = 'JUMBO CASE'
        AND l_quantity < (
                SELECT
                       0.2 * AVG(l_quantity)
                FROM
                        lineitem
                WHERE
                        l_partkey = p_partkey
        )
LIMIT 1;

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

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

SELECT
        SUM(l_extendedprice) / 7.0 AS avg_yearly
FROM
        lineitem,
        part,
        (SELECT
                l_partkey as dt_partkey, 0.2 * AVG(l_quantity) as avg_quantity
         FROM
                lineitem
         GROUP BY l_partkey
        ) dt

WHERE
        p_partkey = l_partkey
        AND p_brand = 'Brand#55'
        AND p_container = 'JUMBO CASE'
        AND p_partkey = dt.dt_partkey
        AND l_quantity < dt.avg_quantity
LIMIT 1;

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

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

SELECT
       SUM(l_extendedprice) / 7.0 AS avg_yearly
FROM
      (SELECT
              l_quantity,
              l_extendedprice,
              (0.2 * AVG(l_quantity) OVER (PARTITION BY l_partkey)) as avg_quantity
        FROM
              lineitem,
              part
        WHERE
              p_partkey = l_partkey
              AND p_brand = 'Brand#55'
              AND p_container = 'JUMBO CASE'
       ) win
WHERE
        l_quantity < avg_quantity
LIMIT 1;

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

不同变换方式的性能分析

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

测试方法

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

CREATE TABLE part (
  p_partkey INT PRIMARY KEY
);

CREATE TABLE partsupp (
  ps_partkey INT,
  ps_supplycost DECIMAL(10,2),
  INDEX i1 (ps_partkey)
);

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

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

SELECT COUNT(*) FROM part, partsupp
WHERE p_partkey = ps_partkey
  AND ps_partkey <= 100000 * @selectivity
  AND ps_supplycost >= (
    SELECT AVG(ps_supplycost) FROM partsupp WHERE p_partkey = ps_partkey);

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

SELECT COUNT(*) FROM
    part, partsupp,
    (select ps_partkey, AVG(ps_supplycost) as avg_supplycost
        from partsupp group by ps_partkey) ps2
WHERE p_partkey = partsupp.ps_partkey
  AND partsupp.ps_partkey <= 100000 * @selectivity
  AND p_partkey = ps2.ps_partkey
  AND partsupp.ps_supplycost >= avg_supplycost;

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

SELECT COUNT(*) FROM part, partsupp
WHERE p_partkey = ps_partkey
  AND ps_partkey <= 100000 * @selectivity
  AND ps_supplycost >= (
    SELECT /*+ UNNEST(WINDOW_FUNCTION) */
		AVG(ps_supplycost) 
	FROM partsupp 
WHERE p_partkey = ps_partkey);

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

SELECT COUNT(*) FROM
    (
    select ps_partkey, ps_supplycost, AVG(ps_supplycost) over(partition by ps_partkey) as avg_supplycost
    from part, partsupp
    where p_partkey = partsupp.ps_partkey
    ) win
where
  win.ps_supplycost > win.avg_supplycost
  AND ps_partkey <= 100000 * @selectivity;

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

SELECT COUNT(*) FROM
    part, partsupp,
    (SELECT ps_partkey, AVG(ps_supplycost) as avg_supplycost
     FROM partsupp 
	 WHERE ps_partkey <= 100000 * 0.2
	 GROUP BY ps_partkey	
	) ps2
WHERE p_partkey = partsupp.ps_partkey
  AND partsupp.ps_partkey <= 100000 * 0.2
  AND p_partkey = ps2.ps_partkey
  AND partsupp.ps_supplycost >= avg_supplycost;

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

SELECT COUNT(*) FROM
    (
    select ps_partkey, ps_supplycost, AVG(ps_supplycost) over(partition by ps_partkey) as avg_supplycost
    from part, partsupp
    where p_partkey = partsupp.ps_partkey AND ps_partkey < 100000 * 0.2
    ) win
where
  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变换

| -> Limit: 1 row(s)  (actual time=12512.366..12512.366 rows=1 loops=1)
    -> Aggregate: sum(lineitem.l_extendedprice)  (actual time=12512.365..12512.365 rows=1 loops=1)
        -> Nested loop inner join  (actual time=12338.854..12511.773 rows=2644 loops=1)
            -> Nested loop inner join  (cost=40928.12 rows=58923) (actual time=0.198..153.232 rows=6688 loops=1)
                -> 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)
                    -> Table scan on part  (cost=20305.00 rows=198000) (actual time=0.027..94.947 rows=200000 loops=1)
                -> 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)
            -> Filter: (lineitem.l_quantity < derived_1_2.Name_exp_1)  (actual time=1.847..1.847 rows=0 loops=6688)
                -> 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)
                    -> Materialize  (actual time=1.846..1.847 rows=1 loops=6688)
                        -> Group aggregate: avg(lineitem.l_quantity)  (actual time=0.263..12091.061 rows=200000 loops=1)
                            -> 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变换

| -> Limit: 1 row(s)  (actual time=170.197..170.197 rows=1 loops=1)
    -> Aggregate: sum(derived_1_2.Name_exp_3)  (actual time=170.195..170.195 rows=1 loops=1)
        -> Filter: (derived_1_2.Name_exp_2 < derived_1_2.Name_exp_1)  (actual time=165.699..169.697 rows=2644 loops=1)
            -> Table scan on derived_1_2  (actual time=0.001..0.873 rows=6688 loops=1)
                -> Materialize  (actual time=165.696..168.067 rows=6688 loops=1)
                    -> Window aggregate with buffering  (actual time=144.326..162.817 rows=6688 loops=1)
                        -> Sort: <temporary>.l_partkey  (actual time=144.243..145.376 rows=6688 loops=1)
                            -> Stream results  (actual time=0.229..142.414 rows=6688 loops=1)
                                -> Nested loop inner join  (cost=40928.12 rows=58923) (actual time=0.226..139.011 rows=6688 loops=1)
                                    -> 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)
                                        -> Table scan on part  (cost=20305.00 rows=198000) (actual time=0.071..78.989 rows=200000 loops=1)
                                    -> 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点:

结论

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

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