做者:姚珂男git
在 TiDB 源码阅读系列文章(七)基于规则的优化 一文中,咱们介绍了几种 TiDB 中的逻辑优化规则,包括列剪裁,最大最小消除,投影消除,谓词下推和构建节点属性,本篇将继续介绍更多的优化规则:聚合消除、外链接消除和子查询优化。github
聚合消除会检查 SQL 查询中 Group By
语句所使用的列是否具备惟一性属性,若是知足,则会将执行计划中相应的 LogicalAggregation
算子替换为 LogicalProjection
算子。这里的逻辑是当聚合函数按照具备惟一性属性的一列或多列分组时,下层算子输出的每一行都是一个单独的分组,这时就能够将聚合函数展开成具体的参数列或者包含参数列的普通函数表达式,具体的代码实如今 rule_aggregation_elimination.go
文件中。下面举一些具体的例子。sql
例一:express
下面这个 Query 能够将聚合函数展开成列的查询:缓存
select max(a) from t group by t.pk;
被等价地改写成:函数
select a from t;
例二:优化
下面这个 Query 能够将聚合函数展开为包含参数列的内置函数的查询:3d
select count(a) from t group by t.pk;
被等价地改写成:code
select if(isnull(a), 0, 1) from t;
这里其实还能够作进一步的优化:若是列 a
具备 Not Null
的属性,那么能够将 if(isnull(a), 0, 1)
直接替换为常量 1(目前 TiDB 还没作这个优化,感兴趣的同窗能够来贡献一个 PR)。blog
另外提一点,对于大部分聚合函数,参数的类型和返回结果的类型通常是不一样的,因此在展开聚合函数的时候通常会在参数列上构造 cast 函数作类型转换,展开后的表达式会保存在做为替换 LogicalAggregation
算子的 LogicalProjection
算子中。
这个优化过程当中,有一点很是关键,就是如何知道 Group By
使用的列是否知足惟一性属性,尤为是当聚合算子的下层节点不是 DataSource
的时候?咱们在 (七)基于规则的优化 一文中的“构建节点属性”章节提到过,执行计划中每一个算子节点会维护这样一个信息:当前算子的输出会按照哪一列或者哪几列知足惟一性属性。所以,在聚合消除中,咱们能够经过查看下层算子保存的这个信息,再结合 Group By
用到的列判断当前聚合算子是否能够被消除。
不一样于 (七)基于规则的优化 一文中“谓词下推”章节提到的将外链接转换为内链接,这里外链接消除指的是将整个链接操做从查询中移除。
外链接消除须要知足必定条件:
LogicalJoin
的父亲算子只会用到 LogicalJoin
的 outer plan 所输出的列LogicalJoin
中的 join key 在 inner plan 的输出结果中知足惟一性属性LogicalJoin
的父亲算子会对输入的记录去重条件 1 和条件 2 必须同时知足,但条件 2.1 和条件 2.2 只需知足一条便可。
知足条件 1 和 条件 2.1 的一个例子:
select t1.a from t1 left join t2 on t1.b = t2.pk;
能够被改写成:
select t1.a from t1;
知足条件 1 和条件 2.2 的一个例子:
select distinct(t1.a) from t1 left join t2 on t1.b = t2.b;
能够被改写成:
select distinct(t1.a) from t1;
具体的原理是,对于外链接,outer plan 的每一行记录确定会在链接的结果集里出现一次或屡次,当 outer plan 的行不能找到匹配时,或者只能找到一行匹配时,这行 outer plan 的记录在链接结果中只出现一次;当 outer plan 的行能找到多行匹配时,它会在链接结果中出现屡次;那么若是 inner plan 在 join key 上知足惟一性属性,就不可能存在 outer plan 的行可以找到多行匹配,因此这时 outer plan 的每一行都会且仅会在链接结果中出现一次。同时,上层算子只须要 outer plan 的数据,那么外链接能够直接从查询中被去除掉。同理就能够很容易理解当上层算子只须要 outer plan 的去重后结果时,外链接也能够被消除。
这部分优化的具体代码实如今 rule_join_elimination.go 文件中。
子查询分为非相关子查询和相关子查询,例如:
-- 非相关子查询 select * from t1 where t1.a > (select t2.a from t2 limit 1); -- 相关子查询 select * from t1 where t1.a > (select t2.a from t2 where t2.b > t1.b limit 1);
对于非相关子查询, TiDB 会在 expressionRewriter
的逻辑中作两类操做:
子查询展开
即直接执行子查询得到结果,再利用这个结果改写本来包含子查询的表达式;好比上述的非相关子查询,若是其返回的结果为一行记录 “1” ,那么整个查询会被改写为:
select * from t1 where t1.a > 1;
详细的代码逻辑能够参考 expression_rewriter.go 中的 handleScalarSubquery 和 handleExistSubquery 函数。
子查询转为 Join
对于包含 IN (subquery) 的查询,好比:
select * from t1 where t1.a in (select t2.a from t2);
会被改写成:
select t1.* from t1 inner join (select distinct(t2.a) as a from t2) as sub on t1.a = sub.a;
若是 t2.a
知足惟一性属性,根据上面介绍的聚合消除规则,查询会被进一步改写成:
select t1.* from t1 inner join t2 on t1.a = t2.a;
这里选择将子查询转化为 inner join 的 inner plan 而不是执行子查询的缘由是:以上述查询为例,子查询的结果集可能会很大,展开子查询须要一次性将 t2
的所有数据从 TiKV 返回到 TiDB 中缓存,并做为 t1
扫描的过滤条件;若是将子查询转化为 inner join 的 inner plan ,咱们能够更灵活地对 t2
选择访问方式,好比咱们能够对 join 选择 IndexLookUpJoin
实现方式,那么对于拿到的每一条 t1
表数据,咱们只需拿 t1.a
做为 range 对 t2
作一次索引扫描,若是 t1
表很小,相比于展开子查询返回 t2
所有数据,咱们可能总共只须要从 t2
返回不多的几条数据。
注意这个转换的结果不必定会比展开子查询更好,其具体状况会受 t1
表和 t2
表数据的影响,若是在上述查询中, t1
表很大而 t2
表很小,那么展开子查询再对 t1
选择索引扫描可能才是最好的方案,因此如今有参数控制这个转化是否打开,详细的代码能够参考 expression_rewriter.go 中的 handleInSubquery 函数。
对于相关子查询,TiDB 会在 expressionRewriter
中将整个包含相关子查询的表达式转化为 LogicalApply
算子。LogicalApply
算子是一类特殊的 LogicalJoin
,特殊之处体如今执行逻辑上:对于 outer plan 返回的每一行记录,取出相关列的具体值传递给子查询,再执行根据子查询生成的 inner plan ,即 LogicalApply
在执行时只能选择相似循环嵌套链接的方式,而普通的 LogicalJoin
则能够在物理优化阶段根据代价模型选择最合适的执行方式,包括 HashJoin
,MergeJoin
和 IndexLookUpJoin
,理论上后者生成的物理执行计划必定会比前者更优,因此在逻辑优化阶段咱们会检查是否能够应用“去相关”这一优化规则,试图将 LogicalApply
转化为等价的 LogicalJoin
。其核心思想是将 LogicalApply
的 inner plan 中包含相关列的那些算子提高到 LogicalApply
之中或之上,在算子提高后若是 inner plan 中再也不包含任何的相关列,即再也不引用任何 outer plan 中的列,那么 LogicalApply
就会被转换为普通的 LogicalJoin
,这部分代码逻辑实如今 rule_decorrelate.go 文件中。
具体的算子提高方式分为如下几种状况:
inner plan 的根节点是 LogicalSelection
则将其过滤条件添加到 LogicalApply
的 join condition 中,而后将该 LogicalSelection
从 inner plan 中删除,再递归地对 inner plan 提高算子。
以以下查询为例:
select * from t1 where t1.a in (select t2.a from t2 where t2.b = t1.b);
其生成的最初执行计划片断会是:
LogicalSelection
提高后会变成以下片断:
到此 inner plan 中再也不包含相关列,因而 LogicalApply
会被转换为以下 LogicalJoin :
inner plan 的根节点是 LogicalMaxOneRow
即要求子查询最多输出一行记录,好比这个例子:
select *, (select t2.a from t2 where t2.pk = t1.a) from t1;
由于子查询出如今整个查询的投影项里,因此 expressionRewriter
在处理子查询时会对其生成的执行计划在根节点上加一个 LogicalMaxOneRow
限制最多产生一行记录,若是在执行时发现下层输出多于一行记录,则会报错。在这个例子中,子查询的过滤条件是 t2
表的主键上的等值条件,因此子查询确定最多只会输出一行记录,而这个信息在“构建节点属性”这一步时会被发掘出来并记录在算子节点的 MaxOneRow
属性中,因此这里的 LogicalMaxOneRow
节点其实是冗余的,因而咱们能够将其从 inner plan 中移除,而后再递归地对 inner plan 作算子提高。
inner plan 的根节点是 LogicalProjection
则首先将这个投影算子从 inner plan 中移除,再根据 LogicalApply
的链接类型判断是否须要在 LogicalApply
之上再加上一个 LogicalProjection
,具体来讲是:对于非 semi-join 这一类的链接(包括 inner join 和 left join ),inner plan 的输出列会保留在 LogicalApply
的结果中,因此这个投影操做须要保留,反之则不须要。最后,再递归地对删除投影后的 inner plan 提高下层算子。
inner plan 的根节点是 LogicalAggregation
首先咱们会检查这个聚合算子是否能够被提高到 LogicalApply
之上再执行。以以下查询为例:
select *, (select sum(t2.b) from t2 where t2.a = t1.pk) from t1;
其最初生成的执行计划片断会是:
将聚合提高到 LogicalApply
后的执行计划片断会是:
即先对 t1
和 t2
作链接,再在链接结果上按照 t1.pk
分组后作聚合。这里有两个关键变化:第一是无论提高前 LogicalApply
的链接类型是 inner join 仍是 left join ,提高后必须被改成 left join ;第二是提高后的聚合新增了 Group By
的列,即要按照 outer plan 传进 inner plan 中的相关列作分组。这两个变化背后的缘由都会在后面进行阐述。由于提高后 inner plan 再也不包含相关列,去相关后最终生成的执行计划片断会是:
聚合提高有不少限定条件:
LogicalApply
的链接类型必须是 inner join 或者 left join 。 LogicalApply
是根据相关子查询生成的,只可能有 3 类链接类型,除了 inner join 和 left join 外,第三类是 semi join (包括 SemiJoin
,LeftOuterSemiJoin
,AntiSemiJoin
,AntiLeftOuterSemiJoin
),具体能够参考 expression_rewriter.go
中的代码,限于篇幅在这里就不对此作展开了。对于 semi join 类型的 LogicalApply
,由于 inner plan 的输出列不会出如今链接的结果中,因此很容易理解咱们没法将聚合算子提高到 LogicalApply
之上。
LogicalApply
自己不能包含 join condition 。以上面给出的查询为例,能够看到聚合提高后会将子查询中包含相关列的过滤条件 (t2.a = t1.pk
) 添加到 LogicalApply
的 join condition 中,若是 LogicalApply
自己存在 join condition ,那么聚合提高后聚合算子的输入(链接算子的输出)就会和在子查询中时聚合算子的输入不一样,致使聚合算子结果不正确。
子查询中用到的相关列在 outer plan 输出里具备惟一性属性。以上面查询为例,若是 t1.pk
不知足惟一性,假设 t1
有两条记录知足 t1.pk = 1
,t2
只有一条记录 { (t2.a: 1, t2.b: 2) }
,那么该查询会输出两行结果 { (sum(t2.b): 2), (sum(t2.b): 2) }
;但对于聚合提高后的执行计划,则会生成错误的一行结果{ (sum(t2.b): 4) }
。当 t1.pk
知足惟一性后,每一行 outer plan 的记录都对应链接结果中的一个分组,因此其聚合结果会和在子查询中的聚合结果一致,这也解释了为何聚合提高后须要按照 t1.pk
作分组。
聚合函数必须知足当输入为 null
时输出结果也必定是 null
。这是为了在子查询中没有匹配的特殊状况下保证结果的正确性,以上面查询为例,当 t2
表没有任何记录知足 t2.a = t1.pk
时,子查询中不论是什么聚合函数都会返回 null
结果,为了保留这种特殊状况,在聚合提高的同时, LogicalApply
的链接类型会被强制改成 left join(改以前多是 inner join ),因此在这种没有匹配的状况下,LogicalApply
输出结果中 inner plan 部分会是 null
,而这个 null
会做为新添加的聚合算子的输入,为了和提高前结果一致,其结果也必须是 null
。
对于根据上述条件断定不能提高的聚合算子,咱们再检查这个聚合算子的子节点是否为 LogicalSelection
,若是是,则将其从 inner plan 中移除并将过滤条件添加到 LogicalApply
的 join condition 中。这种状况下 LogicalAggregation
依然会被保留在 inner plan 中,但会将 LogicalSelection
过滤条件中涉及的 inner 表的列添加到聚合算子的 Group By
中。好比对于查询:
select *, (select count(*) from t2 where t2.a = t1.a) from t1;
其生成的最初的执行计划片断会是:
由于聚合函数是 count(*)
,不知足当输入为 null
时输出也为 null
的条件,因此它不能被提高到 LogicalApply
之上,但它能够被改写成:
注意 LogicalAggregation
的 Group By
新加了 t2.a
,这一步将本来的先作过滤再作聚合转换为了先按照 t2.a
分组作聚合,再将聚合结果与 t1
作链接。 LogicalSelection
提高后 inner plan 已经再也不依赖 outer plan 的结果了,整个查询去相关后将会变为:
这是基于规则优化的第二篇文章,后续咱们还将介绍更多逻辑优化规则:聚合下推,TopN 下推和 Join Reorder 。