首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

MongoDB 7.0 中新查询引擎架构

MongoDB最新的7.0版本,除了一系列功能更新,在性能、数据迁移、开发人员体验、安全等都有显著改善,当然最亮眼测无非是引入了一个新的查询引擎(SBE),关于该新引擎的相关技术信息MongoDB开发人员已经公开,今天就和虫虫一起来学习一下。

基本概念

从用户的角度来看,数据库管理系统(DBMS)主要功能是进行数据查询。数据查询最基本的用户与系统交互的方式。当然每一个查询从写下查询语句(MQL)到出现结果,DBMS在后台有大量的后台在执行相关的任务,比如存储引擎、复制、分片,以及其他一些东西,当然还有查询引擎

查询引擎本身通常由一系列组件构成:

查询解析器:负责将用户查询转换为某种内部表示形式并报告执行过程中的任何语法错误。

查询优化器:通常有多种方法来执行查询,具体取决于指定的谓词、它们的选择性、存在哪些索引等。 查询优化器从中选择执行查询的最佳策略。该部分通常涉及要大量数学知识,其中特定的公式和常数被专有数据库系统当做商业秘密。

查询执行引擎:一旦优化器选择了最佳策略,该组件的工作就是尽可能高效地执行该策略。高效的确切含义因数据库而异,但通常是延迟、吞吐量(指在给定时间范围内可以处理多少数据)、QPS(指在不使用任何数据的情况下可以发送多少查询)之间的某种平衡。

新一代查询引擎

需求

从头开始完全重写查询执行引擎是一项艰巨的任务,需要花费大量人力和精力进行开发和维护迁移。需要重写引擎的原因无非是遇到瓶颈。

最主要的需求主要体现在两个方面:性能和可维护性。

性能问题的根本原因是数据模型。MongoDB旧的查询引擎(经典引擎)是围绕JSON文档的概念构建的,大多数执行阶段都要以JSON对象作为输入并返回一个JSON对象。如果查询涉及多个阶段,则每个阶段都会生成中间文档以传递到下一个阶段。对用户来说,这些中间计算过程,大家都不感兴趣,只希望看到查询的最终结果,因此涉及了一些数据的冗余,任何一JSON为对象的系统,包括大量的Web系统和Web接口都存在这个问题。 如果对一个简单的查询这个影响不大,但是对一个的数据查询,涉及到大量的转化/转换/聚合/连接文档,会严重影响性能。

几个中间查询阶段,每个阶段都会创建一个JSON文档以提交到下一个阶段。除右侧最后一份结果外,其他中间的JSON文档均应该去除。

还有可维护性方面。由于历史原因,MongoDB有两种可用的查询语言,即 “find”和“aggregate”。人们可以将“find”视为一种表达谓词的语法,这些谓词通常会出现在WHERE类似SQL语句的子句。另一方面,“aggregate”提供了构建线性执行管道的能力:应用过滤器,然后按此字段对文档进行分组。虽然这两种语言看起来很相似,但它们的底层实现却完全不同。尽管在某些方面(例如比较和算术)提供了类似的功能,但该功能的代码是独立的,甚至有时提供不同的语义!这会导致代码和错误重复,使维护工作变得很痛苦。它还使得不可能对系统进行整体推理,为“find”语言想到的巧妙优化可能不适用于“aggregate”,反之亦然。

总而言之,“经典”引擎是一个成熟的系统,自诞生以来就服务于MongoDB客户端的查询。 然而,其数据模型和两种完全不同逻辑的查询方法导致性能不佳和严重的维护负担。

所以,需要构建一个新的查询引擎,来解决这方面的问题。

基本思想:Slot

“经典”引擎的主要问题之一是在执行阶段之间构建大量中间文档。这样做的主要原因是在传递一些值它们之间。 例如,一个 SCAN读取 ongoDB集合的阶段可能会将文档传递给PROJECT阶段,选择字段 "a",添加4并返回结果。这 PROJECTstage不需要整JSON文档来响应查询,只需要字段 "a"。如果SCAN阶段有某种方式知道这一点,并且只传递了必要的数据PROJECT阶段。在查询引擎中存在很多类似的机会,共同的主题是查询阶段根据值而不是文档进行思考。

MongoDB 新查询引擎构建的是围绕Slot的概念。人们可以将Slot视为一种动态类型变量。Slot可以包含任何有效的JSON值-数字、字符串、数组、文档等,它是查询引擎不同部分相互传递数据的主要方式。在上面的例子中,SCAN阶段会暴露一个包含"a"字段值的特殊Slot1。然后,PROJECT阶段将从该Slot读取数据并执行计算。现在,中间无需再传递整个文档,而是仅传递实际回答查询所需的数据。

在执行查询时,逐个、逐Slot地组合查询结果,直到它们全部合并到仅包含用户请求的数据的最终 JSON 文档中。

也许并不奇怪,新引擎被称为“Slot Based Engine”或简称“SBE”。

架构:表达与阶段

从高层角度来看,SBE是建立在“火山”模型(也叫迭代器模型)之上的。它是1994年Goetz Graefe在其论文《Volcano, An Extensible and Parallel Query Evaluation System》中首次提出的。再从发布以后,至今仍在业界广泛使用。每种类型的操作(扫描表、基于谓词过滤某些内容、连接)都表示为数据流或迭代器。这个流抽象提供了open(), getNext()和close()接口,可以像这样使用:

stream.open();while (stream.getNext()) {}stream.close();

某些流可以将其他流作为输入。例如,过滤流接受一个输入流来获取需要过滤的数据。类似地,连接流可以接受两个输入流,连接操作的左侧和右侧。按照这个概念,流可组成一棵树,然后外部代码使用该树的根来获取查询结果。这些对数据的流式操作在SBE中称为阶段。

在这个例子中,有两个分支的树,一个执行集合扫描并在其顶部进行过滤,另一个执行来自另一个集合扫描的文档的某种聚合。然后两个流合并在一起并作为查询的输出返回。

除了阶段之外,SBE还有表达式。 如果阶段用于操作数据流,则表达式用于计算值。例如,过滤器阶段负责从流中省略与谓词不匹配的数据。但用于过滤数据的谓词是使用表达式指定的。另一个区别是表达式没有任何效果——它们不知道什么是数据库,甚至可以用作操作 JSON 数据的单独语言。另一方面,阶段与系统的其他组件紧密集成,例如存储和分片。

数据流

到目前为止,SBE还很像经典引擎,其中有阶段,通过getNext()界面。主要区别在于getNext()不会向调用者提供单个JSON文档,而是一组Slot。每个阶段都有一组Slot,可供树中的父节点使用。例如,投影阶段计算一些表达式并将结果分配给Slot。该Slot可供任何父阶段读取。

一个例子:

数据在这里向上流动,从scan阶段到project阶段。在scan阶段读取文档 "example"逐一收集并输出当前文档到slot s1。 所以调用后getNext()第一次在这个阶段,将得到第一个文档存储在s1。多一个getNext()调用将提供第二个文档s2。

这project阶段执行一个带有字段的表达式 "a"从存储在的文档中s1。它可以从中获取值s1因为它是通过共享上下文提供的scan阶段。执行表达式后,会将结果存储在s2,可供运行的外部代码使用project阶段。在下一个getNext()调用,project会先调用getNext()在scan阶段存储的文档s1然后它将使用这个Slot重新运行表达式以将结果存储在s2。

请注意,“调用”的逻辑getNext()在做任何事情之前“先了解你的孩子”是无条件的project阶段。没有像“表达式使用s1,这是scan阶段,所以需要调用在之上getNext()”。Project阶段总是在调用 getNext()子Slot。

尽管访问Slot的上下文是共享的,但Slot本身始终具有单个拥有阶段,并且在不同阶段之间始终是不可变的。getNext()来调用。 这允许引擎在查询的某些部分并行执行的情况下避免额外的数据同步。

类似地filter也是在S2基础上执行:

所有内容(包括查询结果)都存储在Slot中。每阶段产生的Slot在其结束后始终可用getNext()调用结束。那么如何在filter阶段确保它只输出正确的文档? 答案很简单,getNext()在满足谓词之前,实现不会返回:

Result FilterStage::getNext() {while (! predicate.run()) {if (child.getNext() == EOF) {return EOF;}}return OK;}

一旦filter阶段的谓词被满足,它返回一个OK结果返回给调用者,表明当前存储在Slo中的值通过了过滤。由于Slot的范围向上延伸(除了几个阶段),当前调用的外部代码getNext()在filter阶段可以从中获取与谓词匹配的文档s1。

在像上面这样的简单查询中,数据流非常简单。然而,人们可以想象像嵌套循环连接这样更复杂的阶段在如何以及何时调用方面可以有更加复杂的逻辑 getNext()子Slot。 如果阶段下有多个子树,那么从一个子树访问另一棵子树中的Slot也非常有用。开发人员需要记住如何定义Slot范围的规则,如果查询包含了诸如5层嵌套连接,那么它很快就会失控 ,当然这种情况基本不可能出现。

汇编

表达式和阶段被有意设计为不包含任何有关“find”或“aggregate”语言的知识。

例如,与类似的运算符相比,表达式中的相等和“find”运算符的$eq语义略有不同 $eq。SBE的目标之一是为“find”和“aggregate”语言提供统一的执行运行时。为此,SBE提供了具有一些明确定义的语义的基本原语,并依赖查询编译器从这些构建块构建必要的语义。

为了从优化器转换查询计划,SBE使用“简单”访问者编译器,它遍历查询树并从中构造一堆阶段和表达式。从某种意义上说,这很简单,目前几乎没有什么巧妙的优化。然而,正确表达MongoDB查询语言的所有语义所需的工作量绝对是巨量的。由于没有查询语言中某些操作的直接类似物,因此需要生成相当多的字节码来表达完整的语义。例如,如果虚拟机的内置加法运算符计算1 + NaN作为一个NaN值,但是$sum查询语言中的运算符将其计算为null,需要为这种情况构建一个if语句。代替left + right,现在有 if(right is NaN, null, left + right)。 对于复杂的查询来说,类似的事情加起来很快。

然而,最糟糕的部分是,这会导致一遍又一遍地重新评估常量表达式。 例如,如果求和运算的右侧参数是常数2,然后在编译时知道谓词检查NaN值永远不会是真实的。这使得整个检查毫无用处,因为可以直接执行求和。

为了缓解这个问题,新引入了中间表示,编译器将查询转换为中间表示。翻译查询后,会运行一些非常简单的优化过程,以便折叠常量表达式并使代码在某些情况下更加简单,这在执行过程中节省了大量工作。

然而,问题的根源并不在于编译器。表达式代码大小直接影响查询性能,查询优化器的工作是选择执行查询的最佳策略,包括使用哪些表达式。然而,在重写整个执行引擎的同时重写优化器来理解SBE原语对我们来说并不是一个可行的方法,因为这会使本来就非常雄心勃勃的范围变得更大。因此,决定尝试逐步进行改进,并稍后在新优化器中重用这些简单优化过程的部分内容。

虚拟机

SBE使用自己的虚拟机来执行表达式 它被有意创建为相对简单的基于堆栈的虚拟机,其中包含非常有限的操作集。

该虚拟机的定义特征之一是它管理表达式使用的所有内存。如果在表达式求值期间分配了数组,则最终由VM负责在不再需要时释放所分配的内存。它使评估代码变得更加麻烦,到处都有明确的生命周期管理,但这种托管方法意味着将会有 生成的表达式中存在非常有限的与内存相关的错误。

在虚拟机中包含哪些内置操作一直是内部争论的热点话题。一方面,如果指令集太简单,即使对于最简单的查询,最终也会得到巨大的表达式,从而损害性能。 如果为每个小任务创建内置函数,生成的代码将非常小且速度很快,但最终会遇到与经典引擎类似的情况,需要无数单一用途的C++函数来维护和优化。

最终得出的一个好的经验法则是,仅在代表性基准测试上优化热路径时才尝试添加内置函数,否则尝试使用现有功能。

结论

SBE旨在解决当前MogonDB存在的性能和可维护性的问题的需求,基于老的“火山模型”和Slot思想以及分阶段的架构,以及汇编和虚拟机来应对多引擎查询语义的统一问题。

最终,目标是实现和SQL数据库一样高性能和维护,而能保证传统查询引擎的所有优点和使用习惯。

  • 发表于:
  • 原文链接https://page.om.qq.com/page/OSXyY2a_xURbAfJjExzYXJBA0
  • 腾讯「腾讯云开发者社区」是腾讯内容开放平台帐号(企鹅号)传播渠道之一,根据《腾讯内容开放平台服务协议》转载发布内容。
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券