TiDB逻辑优化规则
目录
逻辑算子
发现对于零基础的读者,如果不先讲算子,后面都没法讲。所以还是先写一点。
- DataSource 这个就是数据源,也就是表。
select * from t
里面的 t - Selection 选择,就是
select xxx from t where xx = 5
里面的 where 过滤条件条件 - Projection 投影,也就是
select c from t
里面的列 c - Join 连接,
select xx from t1, t2 where t1.c = t2.c
就是把 t1 t2 两个表做 join,这个连接条件一个简单的等值连接。join 有好多种,内关联,左关联,右关联,全关联...
选择,投影,连接(SPJ) 是最基本的算子。
select b from t1, t2 where t1.c = t2.c and t1.a > 5
变成逻辑查询计划之后,t1 t2 对应的 DataSource,负责将数据捞上来。上面接个 Join,将两个表的结果按 t1.c = t2.c 连接,再按 t1.a > 5 做一个 Selection 过滤,最后将 b 列投影。下图是未经优化的直接表示:
- Sort 就是
select xx from xx order by
里面的 order by,排序。无序的数据,通过这个算子之后,向父结点输出有序的数据。 - Aggregation 就是
select sum(xx) from xx group by yy
的 group by yy,按某些列分组。分组之后,可能带一些聚合操作,比如 Max/Min/Sum/Count/Average 等,这个例子里面是一个 sum。 - Apply 这个是用来做子查询的,后面再讲它的作用。
逻辑优化做的事情,就是把 SQL 转成由逻辑算子构成的查询计划,这颗树的结构会被变来变去,使得执行时代价尽量的小。
物理算子跟逻辑算子的不同之处,在于一个逻辑算子可能对于多种物理算子的实现,比如 Join 的实现,可以用 NestLoop Join,可以用 HashJoin,可以用 MergeSort Join等。比如 Aggregation,可以用 Hash 或者排序后分组不同的做法,比如 DataSource 可以直接扫表,也可以利用索引读数据。
列裁剪
列裁剪的思想是这样子的:对于用不上的列,没有必要读取它们的数据,去浪费无谓的IO。
比如说表 t 里面有 a b c d 四列。
select a from t where b > 5
这个查询里面明显只有 a b 两列被用到了,所以 c d 的数据是不需要读取的。那么 Selection 算子用到 b 列,下面接一个 DataSource 用到 a b 两列,剩下 c 和 d 都可以裁剪掉,DataSource 读数据时不需要将它们读进来。
算法是自顶向下地把算子过一遍。某个结点需要用到的列,等于它自己需要用到的列,加上它的父亲结点需要用到的列,将没用到的裁掉。从上到下的结点,需要用到的列越来越多。
主要影响的Projection,DataSource,Aggregation,因为它们跟列更相关。Projection 里面干掉用不上的列。DataSource 里面裁剪掉不需要使用的列。
Aggregation 涉及哪些列?group by 用到的列,以及聚合函数里面用到的列。比如 select avg(a), sum(b) from t group by c d
,这里面 group by 用到的 c 和 d,聚合函数用到的 a 和 b。
Selection 就是看它父亲要哪些列,然后它自己的条件里面要用到哪些列。Sort 就看 order by 里面用到了哪些列。Join 里面,把 join 条件用到的各种列都要加进去。
通过列裁剪这一步操作之后,查询计划里面各个算子,只会记录下它实际需要用到的那些列。
最大最小消除
select min(id) from t
换一种写法,可以变成
select id from t order by id desc limit 1
严格意义上,这并不算标准的逻辑优化里面需要做的事情。那么这个改动有什么意义呢?
前一个语句,生成执行计划,是一个 TableScan 上面接一个 Aggregation,也就是这是一个全表扫描的操作。对于后一个语句,TableScan + Sort + Limit,在某些情况,比如 id 是主键或者是存在索引,数据本身是有序的, Sort 就可以消除,最终变成 TableScan 或者 IndexLookUp 加 Limit,这样子就不需要全表扫了,读到第一条数据就得到结果!全表扫跟只查一条数据,这可是天壤之别。也许这一点点写法上的区别,就是几分钟甚至更长,跟毫秒级响应的差距。
最大最小消除,做的事情就是由 SQL 优化器“自动”地做这个变换。比如说,
select max(id) from t
生成的查询树会被转换成下面这种:
select max(id) from (select id from t order by id desc limit 1 where id is not null) t
min也是类似:
select min(id) from t
select min(id) from (select id from t order by id limit 1 where id is not null) t
注意这只是其中一个变换规则,经过这步变换之后,还会执行其它变换。所以中间生成的算子,还有可能在其它变换里面被继续修改掉。总之,这是一个挺好的例子,说明逻辑优化做的事情:对这颗树,通过一系列规则,做各种变换,得到一个最优的逻辑查询计划。
投影消除
投影消除把不必要的 Projection 算子给消除掉。那么,什么情况下,投影算子是可消除的呢?
首先,如果 Projection 算子要投影的列,跟它的孩子结点的输出列,是一模一样的,那么投影就是一个废操作,可以干掉。比如说 select a,b from t 在表 t 里面就正好就是 a b 两列,那就没必要 TableScan 上面做一次 Projection 了。
然后,Projection 下面孩子结点又是一个 Projection 这种,那孩子结点这次投影操作就没意义,可以干掉。像 Projection(A) -> Projection(A,B,C) (->的含义就是DAG树的指针)只需要保留 Projection(A) 就够了。
类似的,在投影消除步骤里,Aggregation 也算某种意义上的投影操作,因为从这个结点出来的都是列的概念了。因此在 Aggregation -> Projection,这个 Projection 也是可以干掉的。
这个代码应该怎么写呢?
func eliminate(p Plan, canEliminate bool) {
对 p 的每个孩子,递归地调用 eliminate
如果 p 是 Project
如果 canEliminate 为真 消除 p
如果 p 的孩子的输出列,跟 p 的输出列相同,消除 p
}
注意 canEliminate 那个参数,它是代表是否处于一个可被消除的“上下文”里面。比如 Projection(A) -> Projection(A, B, C) 或者 Aggregation -> Projection 递归调用到孩子结点 Projection 时,该 Projection 就处理一个 canEliminate 的上下文。
说白了就是,一个 Projection 结点是否可消除,
- 一方面由它父结点告诉它,它是否是一个冗余的 Projection 操作。( Projection(A) -> Projection(A,B,C) 只需要保留 Projection(A) 就够了)
- 另一方面是由它自己和孩子结点的输入列做比较,看是否是可消除的。(select a,b from t 在表 t 里面就正好就是 a b 两列)
谓词下推
这一次讲谓词下推。谓词下推是相当重要的一个优化。比如 select * from t1, t2 where t1.a > 3 and t2.b > 5
,假设 t1 和 t2 都是 100 条数据。如果把 t1 和 t2 两个表做笛卡尔集了再过滤,我们要处理 10000 条数据,而如果先做过滤条件,那么数据量就会少很多。这就是谓词下推的作用,我们尽量把过滤条件下靠近叶子节点做,可以减少访问许多数据。
谓词下推实现的接口函数大概是这样子:
func (p *baseLogicalPlan) PredicatePushDown(predicates []expression.Expression) ([]expression.Expression, LogicalPlan)
函数会处理当前的算子 p,假设在 p 上层要添加 predicates 这些过滤条件。函数返回的下推之后,还剩下的条件,以及生成的新 plan。这个函数会把能推的尽量往下推,推不下去了剩下的,加一个 Selection 条件在当前节点上面,变在新的 plan。比如说现在有条件 a > 3 AND b = 5 AND c < d,其中 a > 3 和 b = 5 都推下去了,那剩下就接一个 c < d 的 Selection。
DataSource 算子很简单,会直接把过滤条件加入到 CopTask 里面。
我们看一下 Join 算子是如何做谓词下推的。
首先会做一个简化,将左外连接和右外连接转化为内连接。什么情况下外连接可以转内连接?
左向外连接的结果集包括左表的所有行,而不仅仅是连接列所匹配的行。如果左表的某行在右表中没有匹配行,则在结果集右边补 NULL。谓词下推时,外连接转换为内连接的规则:如果我们知道接下来的的谓词条件一定会把包含 NULL 的行全部都过滤掉,那么做外连接就没意义了,可以直接改写成内连接。
什么情况会过滤掉 NUll 呢?比如,某个谓词的表达式用 NULL 计算后会得到 false,比如说谓词里面用 OR 多个条件,其中有条件会过滤 NULL,又或者用 AND 条件连接,并且每个都是过滤 NULL 的。
用 OR 连接起来的条件这个说法比较 low,有个高大上的术语叫析取范式 DNF (disjunctive normal form)。对应的还有合取范式 CNF (conjunctive normal form)。反正如果看到代码里面写 DNFCondition 之类的,知道术语会好理解一些。
接下来,把所有条件全收集起来,然后区分哪些是 Join 的等值条件,哪些是 Join 需要用到的条件,哪些全部来自于左孩子,哪些全部来自于右孩子。
区分开来之后,对于内连接,可以把左条件,和右条件,分别向左右孩子下推。等值条件和其它条件保留在当前的 Join 算子中,剩下的返回,也就是在这个 Join 上面接一个 Selection 节点作为新的 Plan 返回。
谓词下推不能推过 MaxOneRow 和 Limit 节点。
先 Limit N 行,然后再做 Selection 操作,跟先做 Selection 操作,再 Limit N 行得到的结果是不一样的。比如数据是 1 到 100,Limit 10 就是从 1 到 10,再 Select 大于 5 的,一个得到的是 1 到 10,另一个得到的是 5 到 15。MaxOneRow 也是同理,跟 Limit 1 效果一样。
构建节点属性
说一下构建 unique key 和 MaxOneRow 属性。这个不属于优化的步骤,但是优化过程需要用来这里的一些信息。
收集关于唯一索引的信息。我们知道某些列,是主键或者唯一索引,这种情况一列不会在多个相同的值。构建 unique key 就是要将这个信息,从叶子节点,传递到 LogicalPlan 树上的所有节点,让每个节点都知道这些属性。
对于 DataSource,对于主键列,和唯一索引列,值都具有唯一属性。注意处理 NULL,考虑列的 NotNull 标记,以及联合索引。
对于 Projection,它的孩子中的唯一索引信息,再到它投影表达式里面用到的那些。比如 a b c 列的值都具备唯一属性,投影其中的 b 列,则输入仍然具有值唯一的属性。
哪些节点会设置 MaxOneRow 信息?
- 如果一个算子的孩子是 MaxOneRow 算子,那它就也带上 MaxOneRow 属性,而且会继续往上传递。
- 如果 Limit 只有一列,它就可以设置 MaxOneRow 属性。
- 如果是 Selection,并且过滤条件是 unique key 等于某个常量,或者 unique key 是某个关联列,那么它也具有 MaxOneRow 属性。
- 对于 Join,如果它的左右孩子都是 MaxOneRow 属性,那 Join 出来仍然会有 MaxOneRow 属性。
聚合消除
聚合消除会检查 SQL 查询中 Group By
语句所使用的列是否具有唯一性属性,如果满足,则会将执行计划中相应的 LogicalAggregation
算子替换为 LogicalProjection
算子。这里的逻辑是当聚合函数按照具有唯一性属性的一列或多列分组时,下层算子输出的每一行都是一个单独的分组,这时就可以将聚合函数展开成具体的参数列或者包含参数列的普通函数表达式,具体的代码实现在 rule_aggregation_elimination.go
文件中。下面举一些具体的例子。
例一:
下面这个 Query 可以将聚合函数展开成列的查询:
select max(a) from t group by t.pk;
被等价地改写成:
select a from t;
例二:
下面这个 Query 可以将聚合函数展开为包含参数列的内置函数的查询:
select count(a) from t group by t.pk;
被等价地改写成:
select if(isnull(a), 0, 1) from t;
这里其实还可以做进一步的优化:如果列 a
具有 Not Null
的属性,那么可以将 if(isnull(a), 0, 1)
直接替换为常量 1(目前 TiDB 还没做这个优化,感兴趣的同学可以来贡献一个 PR)。
另外提一点,对于大部分聚合函数,参数的类型和返回结果的类型一般是不同的,所以在展开聚合函数的时候一般会在参数列上构造 cast 函数做类型转换,展开后的表达式会保存在作为替换 LogicalAggregation
算子的 LogicalProjection
算子中。
这个优化过程中,有一点非常关键,就是如何知道 Group By
使用的列是否满足唯一性属性,尤其是当聚合算子的下层节点不是 DataSource
的时候?我们在 (七)基于规则的优化 一文中的“构建节点属性”章节提到过,执行计划中每个算子节点会维护这样一个信息:当前算子的输出会按照哪一列或者哪几列满足唯一性属性。因此,在聚合消除中,我们可以通过查看下层算子保存的这个信息,再结合 Group By
用到的列判断当前聚合算子是否可以被消除。
外连接消除
不同于 (七)基于规则的优化 一文中“谓词下推”章节提到的将外连接转换为内连接,这里外连接消除指的是将整个连接操作从查询中移除。
外连接消除需要满足一定条件:(原理下面要讲)
- 条件 1 :
LogicalJoin
的父亲算子只会用到LogicalJoin
的 outer plan (外表)所输出的列 - 条件 2 :
- 条件 2.1 :
LogicalJoin
中的 join key 在 inner plan (内表)的输出结果中满足唯一性属性 - 条件 2.2 :
LogicalJoin
的父亲算子会对输入的记录去重
- 条件 2.1 :
条件 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);
-- 相关子查询 子查询使用父查询中的列 t1.b
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;
(先做子查询select distinct(t2.a) as a from t2;将结果作为sub表与t1做inner join)
如果 t2.a
满足唯一性属性(必定满足,有distinct,而有distinct又是使用IN语义的必然),根据上面介绍的聚合消除规则(2.1),查询会被进一步改写成:
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
在执行时只能选择类似循环嵌套连接(这里是类似方法,不是一样的,一个的inner plan是直接取,另一个是使用一次子查询得到的结果)的方式,而普通的 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);
其生成的最初执行计划片段会是:
(semi-join:http://book.51cto.com/art/201312/422461.htm 找到一个满足条件的就返回,就相当于去重,符合IN语义,SQL语句中本来没有semiJoin,但是很多数据库实现的逻辑算子中有)
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 。
上一篇: 深度学习笔记4:正则化和dropout
下一篇: 关于dropout