Spark sql是Apache spark在即将发布的1.0版本的新特性。本文从SQL解析,分析器解析,查询优化到物理执行计划执行,结合spark core模块详细分析spark sql的实现细节。

Spark sql中,TreeNode贯穿始终,继承于TreeNode的有三类数据结构,分别为LogicalPlan,SparkPlan和Expression(LogicalPlan和SparkPlan继承于QueryPlan)。首先通过Sqlparser模块解析生成抽象语法树(AST),即未经过解析的逻辑计划,然后分析器(Anlysis)解析AST生成逻辑计划,再通过优化器(Optimizer)提供的规则(Rules)转化为优化过的逻辑计划(Optimized Plan),最后利用策略(Strategies)计算代价选择具体物理执行算子,在合适的地方加上混洗操作(Exchange)生成物理计划得以执行。




sql parser是将expression及其每个孩子构造一个operator,将operator之间连接起来。


operator是未经解析的AST中的逻辑算子表示


expression是OR--AND--Expression+Expression的表示


和每个与表属性相关的标志被解析成了UnresovledAttribute(name: String)(在Analysis中要将name解析成Attribute,通过Catalog),生成的AST树为:


base--withfilter--withprojection--withdistinct--withhaving--withorder--withlimit




第一部分(待完善)。




第二部分,Spark sql中的查询优化(optimizer),将case样例组成的偏函数当做规则遍历整棵逻辑查询树,针对于暂时存在的三类规则(Limit合并、常量折叠和过滤下推,这三类规则中同时含有若干条规则),遍历的次数分别都为100次,判断前后两次相同即停止。下面针对于每类规则及每条规则做详细分析。




查询优化规则之Limit合并


1,Limit合并


     场景比较少见,例如(本文例子均采用Catalyst的DSL书写):


     testRelation.select('a).limit(10).limit(5)     optimizeTo     testRelation.select('a).limit(5)


     做法即将limit1中的Literal和它孩子limit2中的Literal做比较,小者保留,且将limit2的孩子作为limit3的孩子,返回limit3  




查询优化规则之常量折叠


1,null值传递


     在逻辑层将能够确定null值的表达式计算出来,避免到物理层需要对每个元祖都计算出相同的结果,例如:


     IsNull(Literal(null)) as 'c1     optimizeTo     Literal(null) as 'c1


     当然还有其他的情况,如Equals(Literal(null, IntegerType), 1), 首先Equals会返回一个BooleanType,其次表达式中有一个null,最终可以优化为Literal(null, BooleanType)


     做法即判断是什么操作类型,然后根据操作最终用null表示




2,常量折叠


     所谓常量折叠即为Literal之间能够最大限度的计算出值,例如:


     a, 只有Literal


          testRelation.select(Literal(2)+Literal(3) as Symbol("2+3")).where(Literal(2)>Literal(1))     optimizeTo     testRelation.select(Literal(5)) as Symbol("2+3").where(Literal(true))


          只有Literal的表达式直接可以计算出最终结果用Literal表示。


          做法即提前调用evaluation计算出结果,虽然在逻辑层,但是不涉及attribute references还是可以完成计算


     b, 在arithmetic operations中含有Literals和attribute references


          testRelation.select(Literal(2)+Literal(3)+'a as Symbol("c1"))     optimizeTo     testRelation.select(Literal(5)+'a as Symbol("c1"))


          将除了attribute references的Literal都计算出来


          做法是遍历整棵树的情况下对可以直接计算出的Literal做计算


     c, 在predicates中含有Literals和attribute references


          与b同理,只不过由arithmetic变为了predicate




3,布尔表达式简化


     因为布尔表达式针对And和Or两种操作,会在某种情况下提前短路,例如:


     testRelation.where(Literal(true) || 'a > Literal(1))     optimizeTo     testRelation.where(Literal(true))


     做法较为简单,用case样例列举出针对于And和Or表达式的所有情况,相应情况直接输出Literal(true)、Literal(false)等。




4,filter简化


     由于filter中可能出现不必要的逻辑,所以filter是可以简化的,直接看代码:


     case Filter(Literal(true, BooleanType), child) => child 这条语句可以直接将filter省去,直接将filter的孩子当做孩子,因为filter此时选择率100%




5,Cast简化


     此条规则还比较简单,直接上代码:


     Cast(e, dataType) if e.dataType == dataType => e 当Cast需要转换的值相同的时候,直接用该值




6,还有一条查询优化规则是优化我实现的一个操作,这里就不拿出来讲了-_-||




查询优化规则之filter下推


1,filter合并


     当多个filter连在一起的时候,可以用And连接谓词将他们合并(expression要给力才行),例如:


     testRelation.select('a).where('a==1).where('a==2)     optimizeTo     testRelation.where('a==1 && 'a==2).select('a)


     做法同样也是在遍历树的情况下,直接上代码:case ff @ Filter(fc, nf @ Filter(nc, grandchild)) => Filter(And(nc, fc), grandchild)




2,project相关filter下推


     顾名思义,如果project作为filter的叶子节点存在,project在物理层会处理更多的数据,但是如果将project和filter的位置互换,project在物理层处理的记录条数将变少。




3-1,join相关filter下推之 f @ Filter(filterCondition, Join(left, right, joinType, joinCondition))


     同上,目的即为减少join在物理层处理的记录条数,对于join的filter下推,需要分inner join和outer join来考虑。


     首先,将join相关的filter分为join谓词和where谓词,首先join谓词是写在* join * on后面的谓词(即为3-1标题中的joinCondition),where谓词是写在where后面的谓词(常见情况)。    


     a, 对于inner join来说,做法是将所有的相关谓词,无论是join谓词还是where谓词,都下推到join的两个孩子之下。但是涉及到两个表的条件,比如T1.a>T2.b。这样的条件无法作为filter推下去,只能放在join物理算子中,但是如果是hashjoin,因为hashjoin不适用于非等值,只能将其作为一个hashjoin之后的filter。将这个filter作为hashjoin的父亲。


          做法是将所有的filter操作首先都放到join的顶端,在此需要用到filter的合并操作,然后将join顶端的所有filter操作重新分类,将相关filter谓词全部下推,而将类似于T1.a=T2.b的谓词用来构造两边的joinKeys。


     b,对于outer join来说,新增两个概念,nonull端和null端,分情况讨论:


          b1,对于full outer join,我们什么都不用做了


          b2,对于left outer join,列表如下:


               



nonull端

null端

join谓词

不能下推

下推

where谓词

下推

不能下推



          下面我们举个例子来说明上面的情况(以下例子未给出数据,可以自己设计数据验证):
          eg. 对于右边的两张表:T1(a int, b int) T2(b int, c int)。


               若为如右查询:select * from T1 left join T2 on T1.b=T2.b where T1.b > 2。在此query中有join谓词和where谓词,利用上文列表中的规则,将where谓词下推,而不要将join谓词下推,因为join谓词涉及nonull端。


               若为如右查询:select * from T1 left join T2 on T1.b=T2.b where T2.b > 2。在此query中有join谓词和where谓词,两者皆不能下推,所以乖乖的做broadcast join然后再做两个谓词过滤。


               若为如右查询:select * from T1 left join T2 on T2.b > 2。将T2.b > 2直接下推没有问题。


*若为如右查询:select * from T1 left join T2 on T1.b > 2。这个不能下推,否则违反nonull端的原The table in an Outer Join that must return all rows


          b3,对于right outer join,同b2。


3-2,join相关filter下推之子查询处理 f @ Join(left, right, joinType, joinCondition)


     其实这条很简单,从代码可以看出,如果Join前面没有filter就优化不了吗?不是这样的,还可以只是Join,也就是没有where谓词,匹配的就是这个case了。(在此有个疑问,多层子查询能搞定吗?嵌套循环多次遍历可以解决,见代码Analyzer.scala中的case Subquery(_, child)=> child。




4,列剪枝ColumnPruning


     减少不必要的Column的读取,有利于接近root节点的操作减少处理的数据量


     a, Aggregation的输出属性被包含于它孩子的属性的时候,在Aggregation和它孩子中间加一个project操作,观一段代码:


          eg. case a @ Aggregate(_, _, child) if (child.outputSet -- a.references).nonEmpty => a.copy(child = Project(a.references.toSeq, child)),这段代码已经相当清楚


     b, Join的左孩子和右孩子可能需要剪枝,因为左孩子和右孩子的输入属性之和比做完之后的属性多,所以要向两个孩子都插入一个对应的project操作


     c, 相邻的两个Project完全可以合并


     d, 消除不必要的Project


     


第三部分,Spark sql和Spark core的结合:


spark sql逻辑计划转化为物理执行计划在QueryPlanner中有apply函数,将策略strategies用来生成物理算子,生成的时候,每个logicalPlan会生成若干个physicalPlan。(待完善)