欢迎光临seo外链资源网站,我们为你英文友情链接的信息及服务

seo外链资源

一个资源好的推广优化外链发布网站,为你解决外链获客难题

数据库查询引擎的进化之路

作者:jcmp      发布时间:2021-04-29      浏览量:0
现任蚂蚁金服OceanBase团队技术专

现任蚂蚁金服OceanBase团队技术专家,自2013年加入OceanBase一直从事SQL方向的解析,执行以及优化的相关工作。

正文:

在关系数据库中,当大家提到SQL查询,自然而然的想到查询优化器,毋庸置疑,这是关系数据计算中非常重要并且复杂的一个模块,它决定了查询关系以哪种方式执行能够得到一个最优的结果。但是在关系计算的过程中,还有两个同等重要的模块,那就是查询调度器和计划执行器。

在关系数据库发展的早期,受制于计算机IO能力的约束,计算在查询整体的耗时占比并不明显,这个时候调度器和执行器的作用被弱化,一个查询的好坏更主要取决于优化器对执行计划的选择好坏。但是在今天,随着计算机硬件的发展,调度器和执行器也逐渐彰显了它们的重要地位, 这里我们重点介绍下执行器发展过程中的一些演变。

绕不开的火山

Volcano Model是一种经典的基于行的流式迭代模型(Row-BasedStreaming Iterator Model),在我们熟知的主流关系数据库中都采用了这种模型,例如Oracle,SQL Server, MySQL等。

在Volcano模型中,所有的代数运算符(operator)都被看成是一个迭代器,它们都提供一组简单的接口:open()—next()—close(),查询计划树由一个个这样的关系运算符组成,每一次的next()调用,运算符就返回一行(Row),每一个运算符的next()都有自己的流控逻辑,数据通过运算符自上而下的next()嵌套调用而被动的进行拉取。

上图展示了Spark1.0的查询迭代器模型(OceanBase0.5中采用了相同的结构),这是一个最简单的火山模型例子,拉取数据的控制命令从最上层的Aggregate运算符依次传递到执行树的最下层,而数据流动的方向正好相反。

火山模型中每一个运算符都将下层的输入看成是一张表,next()接口的一次调用就获取表中的一行数据,这样设计的优点是:

但是这种运算符的嵌套模型也有它的缺点:

通常,一个查询的直接开销主要取决于两个因素:第一个因素就是 跨存储和计算操作符之间的数据传输开销 ,另一个因素就是 数据运算所花费的时间 。

火山模型最早于1990年Goetz Graefe在Volcano, an Extensible and Parallel Query Evaluation System这篇论文中提出,在90年代早期,计算机的内存资源十分昂贵,相对于CPU的执行效率,IO效率要差得多,因此运算符和存储之间的IO交换是影响查询效率的主要因素(IO墙效应),火山模型将更多的内存资源用于IO的缓存设计而没有优化CPU的执行效率是在当时的硬件基础上很自然的权衡。

随着硬件的发展,计算机的内存容量变得越来越大,更多的数据可以直接存储在内存中,而单核CPU的运算能力并没有显著提升,这个时候CPU对执行效率的影响就变得更加重要,因此越来越多对CPU执行效率的优化被提出。

操作符融合

优化操作符的执行效率最简单有效的方法就是减少执行过程中操作符的函数调用。由于ProjectOp和FilterOp在计划树中最为常见的两个操作符,因此在OceanBase1.0中,我们将这两种操作符融合在其它特定的代数操作符中,这大大减少了计划树中的运算符的个数以及运算符之间的next()嵌套调用,同时,PorjectOp和FilterOp变成每个操作符内部的一种能力,这也增强了代码局部性能力,优化了CPU的分支预测能力。

上图展示了select count(*) from store_sales where ss_item_sk=1000;这条查询在OceanBase1.0的执行计划,操作符从原来的4个变成了2个,而FilterOp和SelectOp成为了操作符中的一个局部运算。

RowSet迭代

在火山模型的基础上另一种简单有效的优化方式就是RowSet机制,简而言之就是每一次数据流的传递不再是单行的形式,而是若干的row组成的集合,这使得运算更多的停留在next()内部,而不是在函数调用间频繁的切换,从而保证代码局部性和减少函数间调用次数。

RowSet机制将数据在各个环节的流向变成了一个局部范围的循环操作,这迎合了现代编译器技术和CPU动态指令预测技术对简单循环指令的极致优化,同时RowSet的构造可以通过CPU的SIMD指令进行加速,这也比单行数据在内存中的拷贝更加高效,这使得一些结果集比较大的查询执行效率能够明显提升。

推送模型

推送模型最早在一些流媒体计算中被使用,随着大数据时代的来临,在一些基于内存设计的OLAP数据库也被大量使用起来,例如HyPer、LegoBase等。

上图展示了拉取模型和推送模型两种不同的控制流和数据流方向,从图中我们可以看出拉取模型的控制流程更符合查询执行的直观印象,上层运算符按需的向下层运算符获取数据并执行,这本质是一层层的函数嵌套调用。而推送引擎刚好相反,通过将上层的计算下推到数据产生的操作符中,由数据的最终生产者驱动上层运算符对数据进行消费。

为了更直观的比较拉取模型和推送模型对代码结构的影响,我们将图1中提到的简单查询各个运算符中next()接口的实现展开成伪代码,如下图所示:

通过上图的对比很容易可以看出,相比于拉取模型,推送模型改变了数据迭代过程中的嵌套调用关系,大大简化了查询过程中的指令跳转流程,从而使得推送模型有更好的代码局部性,优化了CPU执行效率。但是推送模型实现也更为复杂,Hyper系统通过设计模式中的Visitor模式来实现了计算推送引擎,基本思想是每个operator提供了两个接口:produce()用来生产数据,consume()接口用来消费数据,和拉取模型不同的是,consume()并不像next()那样需要关心流控逻辑,而只用关心operator本身的关系代数逻辑,流控逻辑被produce()接管。

拉取&推送模型融合

对于一个通用的关系数据库可能不得不考虑推送模型中这些缺点,因为在OLTP场景下,merge join、nested loop join这类操作出现的频率可能远远高于hash join操作,所以在通用数据库的执行引擎中融合使用拉取模型和推送模型是一个较好的选择:在一些比较耗时的物化操作中(例如Hash Join中的hash table构建,Aggregate操作等),通过next()接口调用向下层传递一个callback函数,将耗时的阻塞运算通过callback下压到下一个阻塞操作符中,当下层运算符生产好数据后,再依次调用callback list中的回调函数。

而对于limit、merge join、nested loop join这类运算则不会通过callback下压,这种实现方式还有一个好处就是,原有的火山模型并不需要大改,同时也能发挥出推送模型的一些优势,这也是一个比较自然的权衡选择。

编译执行

拉取模型和推送模型影响的是执行流程的代码布局,但只要是解释执行,就无法避免运算符之间的虚函数调用。随着计算机硬件的发展,内存变得越来越大,这意味着越来越多的数据可以直接cache在内存中,而访问磁盘的频率被大幅度的降低,这个时候“IO墙”的效应被削弱,而由于解释执行无法感知CPU寄存器,高频的内存访问,使CPU和内存之间形成了“内存墙”效应,为了解决这个问题,越来越多的内存数据库开始使用编译执行来优化自己的查询效率,例如HyPer、MemSQL、Hekaton、Impala、Spark Tungsten等。

相比于解释执行,编译执行具有以下优点:

1. inline虚函数调用 :在火山模型中,处理一行数据最少需要调用一次next()。这些函数的调用是由编译器通过虚函数调度实现(vtable),而编译执行的过程中没有函数调用,并且大量裁剪了解释执行中的流控指令,这使得CPU执行会更加高效。

2.内存和CPU寄存器中的临时数据 :在火山模型中,运算符在传递行数据的时候,都需要将行数据存储在一个内存buffer中,每一次执行至少需要访问一次内存,而编译执行没有数据的迭代过程,临时数据被直接存放在CPU寄存器中(当然前提是寄存器的数量足够多),而直接访问寄存器的效率要比访问内存高一个数量级。

3. 循环展开(Loop unrolling)和SIMD :当运行简单的循环时,现代编译器和CPU是令人难以置信的高效。编译器会自动展开简单的循环,甚至在每个CPU指令中产生SIMD指令来处理多个元组。CPU的特性,比如管道(pipelining)、预取(prefetching)以及指令重排序(instruction reordering)使得运行简单的循环非常地高效。然而编译器和CPU对复杂函数调用的优化极少,而火山模型具有非常复杂的流控调用。

在OceanBase2.0中,我们利用LLVM对执行引擎中的表达式运算和PL也进行了编译执行的优化。这里主要介绍下OceanBase的表达式编译执行:

在编译阶段,主要有三个步骤:

1. IR代码生成,对于表达式(c1+c2)*c1+(c1+c2)*3,我们假设所有的类型都为bigint,通过分析表达式语义树,使用LLVM codegen的API生成IR代码如图(a)。

2. 代码优化,我们可以看到,c1+c2表达式做了两次计算,而这个表达式是可以作为公共表达式进行抽取的,经过LLVM 优化后,IR代码如图(b), 只进行了一次c1+c2的计算,总的执行命令也得到减少。另外,使用表达式解释执行时,所有的中间结果都需要在内存中物化;而编译后的代码可以将中间结果保存在CPU的寄存器中,直接用于下一步的计算,大大的提升了执行效率。LLVM还提供了很多类似的优化,我们都可以直接使用,从而大大提升表达式的计算速度。 3. 即时编译, 通过LLVM中ORC(On-RequestCompilation) JIT对优化IR代码进行即时编译,生成可执行机器码,并获取该函数指针。

结果对比

我们使用TPC-H lineitem表1G数据(约600w行),对不同数据库在同样的测试环境进行了性能测试对比,执行SQL:

SELECT SUM(CASE WHEN l_partkey IN(1,2,3,7)。

THEN l_linenumber + l_partkey +10。

ELSE l_linenumber + l_partkey +5。

END) AS result

FROM lineitem;

从上图的结果可以看出,在大数据量的情况下,编译执行相比于解释执行有明显的优势,当数据量变得更大的时候,这种优势会更加明显。但编译执行也有它本身的缺点:

写在最后

软件技术的发展总是和硬件技术的发展紧密结合在一起的,迎合硬件技术的发展而改变软件技术栈的相应策略能够使得设计的系统获得更大的受益。虽然OceanBase不是一个单纯的内存数据库,但是通过合理的分区方式,我们可以将用户访问较热的数据cache在内存中,使其近似于一个分布式的纯内存数据库,因此针对于内存数据库设计的这些优化思想也同样适合OceanBase的架构模型。

OceanBase技术交流群

想跟本文作者OB第一小鲜肉 聿明 深入交流吗?

想认识蚂蚁金服OceanBase团队的一线技术专家吗?

扫描下方二维码联系蚂蚁金服加群小助手, 快速加入OceanBase技术交流群!