基于catalyst的物化视图改写引擎的实现

更新日志:

  1. 2020/06/16 group by 视图的部分描述错误,已修正。

什么是物化视图

我先用我的话解释一下什么是物化视图。假设我们已经有A,B两张表,现在我创建了一张表C,

C是由A,B两张表经过一条SQL处理得到的,这个时候我们就可以认为C是A,B的物化视图了。那怎么用呢?当一个用户写了一条使用A Join B表的SQL,系统会自动尝试能否改写成基于C表的查询,如果成功,那么可能查询速度就非常快了,因为避免了Join的发生,只是简单的基于C做了下过滤,但得到的结果和直接使用A,B 是一样的。

显然,物化视图有个很大的问题,就是更新问题,譬如A,B发生了变化,如何保证C 也得到更新。所以这里除了改写以外,还涉及到了C的创建,管理和更新问题。

现在让我们引入点术语了,前面我们提到的自动将基于A,B的查询改写成基于C的查询,我们叫Query Rewrite。Query Rewrite 就是将原有的查询不需要修改,引擎自动选择合适的物化视图进行查询重写,完全对应用透明。

物化视图和传统视图的最大的区别是,物化视图存储不仅存储了计算逻辑,还存储了计算结果,并且更进一步的是,作为用户你无需显示使用物化视图,系统会通过Query Rewrite自己来完成内部的改写。不过无论技术上多先进,我们最后都可以归纳到以空间换时间里去。

SQL Booster

今天我们探讨的重点是如何实现Query Rewrite。我去年写了一个Query Rewrite 引擎[s

ql-booster](https://github.com/aistack/sql-booster),其实是受到阿里李呈祥团队的relational cache启发。当时看了他们的分享觉得太棒了,很想立马就用,但是想着等他们推到开源项目里就太漫长了,加之目前大数据里的物化视图的实现,已经开源的貌似只有hive了,是基于Calcite实现的,而Spark 的话是自己开发的catlyst引擎,而我自己又重度使用Spark,所以干脆自己动手基于catalyst实现一个。后面在开发过程中也遇到了不少公司也在做类似的实现,也有问我的,可惜一直没有写文章,这次趁着周末,写了,既可以做为交流用,也可以作为备忘录。因为时间久了,代码和思路就很容易遗忘,文章可以很容易唤醒记忆,一箭双雕。

知识准备篇

一个物化视图由两部分构成:

1. 生成该物化视图的SQL

2. 表数据

表数据很简单,就是为了查询的。记录生成该物化视图的SQL的原因是,我们需要知道这个物化视图的数据是来源哪些表的,每个字段的是来源哪些表,不然没办法做改写。

Query Rewrite的基本步骤如下

1. 注册各个视图,这些视图都会以AST(Catalyst里的LogicalPlan)存在

2. 待改写的用户SQL,这些SQL不会显示使用物化视图。

3. 将SQL解析成方便遍历处理的AST,也是Catalyst里的LogicalPlan,并且经过Analyzed的,因为我们需要明确知道每个字段属于哪个表。

4. 处理待改写的LogicalPlan,然后去和每个已经存在的视图LogicalPlan匹配,对于匹配上的,则实行改写

5. 最后将该写过的LogicalPlan重新生成SQL或者直接执行得到结果。

所以实际上,上面涉及到如下几个概念,大家需要有个基本感知:

1. 所有视图的LogicalPlan

2. 待改写的查询LogicalPlan

Query Rewrite 的分而治之

在思考Query Rewrite实现的的时候,我想到的第一个问题就是,一条待改写的SQL是不是可能会使用到多个视图?

答案是肯定的。理由有三:

1. 如果一条SQL只匹配一个视图,如果该视图能覆盖到这条SQL的大部分表,那么该视图的通用性必然不好。如果只能覆盖SQL里的一小部分,那么如果只匹配到一个视图,那么可能最后查询速度的提升不会太好。

2. 匹配度太低,还会导致大量存储的使用,否则就相当于不起作用了。

3. 对于一条复杂的SQL,里面会包含各种子查询,所以作为一个整体的SQL去匹配一个视图,实现上也是有难度的。

实际上,一条SQL,其复杂度主要来源于子查询和join。 join是我们需要尽量通过物化视图消解掉的,而子查询,本质上就是SQL内置的虚拟视图,我们希望尽可能通过物化视图来替换掉这些虚拟视图(虚拟视图意味着大量的计算,因为虚拟视图里一般也会有复杂的Join查询)。这样,事情就变得简单了,我们只要把一条SQL里的所有子查询都拎出来,最后每个句子都会符合SPJG形式。

所谓SPJG形式的语句是指仅包含如下语法块的语句:

1. project

2. agg

3. filter

4. join

其他如limit,Order by之类的并不影响视图替换,我们无需考虑。

如果把子查询都拉出来,最后会形成一个子查询树状结构,理论上我们只要对叶子节点做处理即可(只包含基础表的SPJG语句),每个叶子节点一定是符合SPEG格式要求的。当然了,如果我们的物化视图还带有层级结构,也就是基于物化视图上再生成新的物化视图,那么还可以进一步按现在的逻辑匹配。不过我们先不搞他。我们先只处理非视图表替换成视图表的情况。

有了上面的思路,事情就简单了,因为我们是对很简单的SQL语句做视图替换匹配,而且因为一个复杂的SQL会包含很多只包含了基础表的SPJG语句,我们一一尝试用物化视图替换他们就好。

具体做法是,我们把SQL先用Catalyst解析成 Analyzed LogicalPlan,另外我们还要做一些适当的优化,我目前是做了EliminateOuterJoin,PushPredicateThroughJoin,这样可以将多种原先形式不同的 LogicalPlan 转化成相同的形式,可以提高命中率。得到了这个语法树后我们通过AST提供的transformDown/transformUp拿到所有符合SPEG形态的语句。接着拿着这些SPEG语句一一去匹配是不是有符合的物化视图。

接下来我们在具体看SEPG具体匹配和修改逻辑的之前,我们还需要解决一个问题,我们可能有几十个甚至上千个物化视图,一一去匹配效率肯定是不行的,如果快速缩小范围呢?

一个简单的视图倒排索引

我们在创建物化视图的时候,系统会自动拿到视图里的主表,也就是join最左侧的表。如果该主表被多个视图包含,最终会形成下面的结构:


主表 -> 视图1, 视图2,视图3...

注意,这里的主表和视图,都是Catalyst里的LogicalPlan。

当我们在处理SPEG 语句的时候,我们也按相同的方式拿到主表,然后以它为key去拿到对应的视图,这个过程是非常快的。得到视图后,我们会遍历这些视图,去看这些视图里的表是不是和SPEG里出现的表是一样的,如果是一样,就算匹配上了。完全匹配上了的视图,可能也会有多个,然后我们会进一步做测试他们的等价性,如果只有一个匹配上,那万事大吉,做改写就好,如果还有多个匹配上,那么可能就需有个打分模型了,不过我们也可以简单的取第一个匹配上的就完事。

当然了,如果你不怕空间浪费,也可以将每个视图涉及到的表都拿出来做形成前面的结构,性能上应该会更好,但是内存可能消耗会大一点,这个就要考实现者自己权衡了。

如何将SPEG使用物化视图进行改写

改写其实是要经历两个阶段的,第一个是匹配阶段,第二个才是改写阶段。

因为SPEG组成已经比较简单了,因为只包含了project/agg/filter/group/join 等几个部分。所以我们匹配和改写主要就是针对这么几个部分。这意味着我们至少需要五个匹配器,五个改写器。然后执行逻辑是,五个匹配器都去匹配,只有都符合了,才会触发五个改写器进行改写

下面是sql-booster的匹配器和改写器。


val pipeline = buildPipeline(rewriteContext: RewriteContext, Seq(

//filter条件子句的matcher/rewrite

new PredicateMatcher(rewriteContext),

new SPGJPredicateRewrite(rewriteContext),

//groupby 条件子句的matcher/rewrite

new GroupByMatcher(rewriteContext),

new GroupByRewrite(rewriteContext),

//聚合子句的matcher/rewrite

new AggMatcher(rewriteContext),

new AggRewrite(rewriteContext),

//join子句的matcher/rewrite

new JoinMatcher(rewriteContext),

new JoinRewrite(rewriteContext),

//select子句的matcher/rewrite

new ProjectMatcher(rewriteContext),

new ProjectRewrite(rewriteContext)

))

每个匹配器都需要实现一定的规则。比如where条件子句要求视图的过滤子句必须包含查询SQL的。什么意思呢?比如假设我们有基础表A,用户的原生查询如下:


select A.a from A where A.a<10;

物化视图C的定义是:


select a from A where a<11;

显然,在filter(where)里,C的数据集是包含用户原生查询的,所以对于where条件我们除了替换成C的a属性以外,其他的都不用动。


select a from C where a<10;

实际上,对于这一个简单的语句,我们至少需要检查如下两点:

1. 用户的project属性是不是都在 C的project属性(select语句里的属性)里

2. 用户的filter的过滤范围是不是都在C的filter过滤方位内。

对于带有agg/group 的则比较复杂。
比如视图C由如下SQL得到:

select m,c,count(*) as a from A group by m,c

用户查询语句如下:

select c,count(*) as a from A group by c 

这个时候匹配比较容易,我们需group by语句的条件视图是用户查询语句的超集,并且顺序必须是后面的。比如视图里是group by m,c 那用户的查询只能是m,c 或者c,同时依然要符合视图的project属性要包含用户的所有的project属性,但是改写上会麻烦一些,需要改写成如下形式才会是等价的:

select sum(a) from C group by c  

另外一个例子是avg,他最后要改写成sum(k)/a(a等于视图里的avg(k))。具体的一些改写规则我在文章中就不一一罗列,大家感兴趣可以去看看我上面罗列的五个改写器。

注意: 名词filter/predicate 是等价的,一般是指过滤条件;project 是指select语句部分;

如何从LogicalPlan转化会SQL

我其实我希望sql-booster是一个标准的Query Rewrite服务。只要把表和视图的定义注册进来,给定一条SQL,就能返回一条改写后的SQL。所以如何把LogicalPlan转换回SQL也是一个比较重要的工作。正如我们前面讨论的,无论SQL多复杂,最后都是由SPEG的树状结构构成,所以我们还原的语句其实会比较简单,核心就是递归处理子查询,把每个子查询都转化成一个标准的SPEG语句。

比较繁琐的是表达式需要还原回字符串,这个需要大量的枚举。具体参看org.apache.spark.sql.catalyst.sqlgenerator.LogicalPlanSQL,该代码主要修改自Moonbox项目,对此表示感谢。

当然了,很多情况我们可能也不需要这个步骤,仅仅需要直接执行改写后的LogicalPlan或者序列化LogicalPlan后直接发回执行即可。

最后的结束语

物化视图的Query Rewrite是个需要积累的活,目前sql-booster仅仅是实现了部分匹配/改写规则,毕竟当时自己好像只开发一到两个礼拜。不过后续我有时间会继续完善,也希望能够在公司应用起来。

你可能感兴趣的:(基于catalyst的物化视图改写引擎的实现)