通过一条SQL 理解 Doris 优化器(RBO/CBO)原理

文摘   2024-11-01 00:00   重庆  
Doris2.0 拥有非常出色的查询性能,这很大一部分归功于Doris基于(RBO/CBO)新一代优化器的出色设计。今天我们从一条SQL开始,详细解释一下什么是Doris优化器。

01

优化的本质

下面从 SQL 的本质来讲解一下优化器的地位。

首先,SQL 的核心是一种描述性语言,使用者只需定义所需的数据及数据的获取方式由优化器来决定。可以说,执行引擎相当于汽车的发动机,而查询优化器则是方向盘。若没有合适的优化器指引,再强大的引擎也可能导致我们在高速行驶中撞上内存溢出的障碍。因此,优化器是 SQL 中极为关键的组件。

在一条 SQL 查询的处理流程中,优化器扮演着什么样的角色呢?如图中标示的红框区域所示,当一条 SQL 查询被提交后,它首先经历的是语法解析阶段,生成相应的抽象语法树,随后进行语义分析。在这一系列步骤之后,进入逻辑计划的重写阶段,在此过程中会应用多种规则。完成重写后,系统会生成若干个备选的执行计划,并通过成本模型评估这些计划,最终挑选出最佳的执行方案交付给查询引擎执行。Nereids 优化器主要负责的就是查询计划的改写与优化这两大部分的工作。

具体而言,改写部分被称为基于规则的优化(RBO),而代价估算则被称为基于代价的优化(CBO)。为了确保与现有的优化器和执行引擎的兼容性,还需要一个“计划翻译”的步骤,将新优化器产生的执行计划转换为后端(BE)能够理解和执行的查询计划。

在 RBO 的规则集合中,包含了诸如谓词下推、表达式重写、常量折叠以及消除无用算子等操作。这些规则通常能够提升查询的效率,因此由它们生成的执行计划将会取代初始的计划。有时候,某些规则会被多次应用,直到执行计划不再发生变化。

当 RBO 的改写过程结束后,所产生的执行计划会被作为输入传递给 CBO 阶段。在 CBO 阶段,最重要的任务之一就是确定连接操作的执行顺序,因为这对接口计划的性能有重大影响。此外,CBO 还需处理其他多项任务,例如决定是否将公共表表达式(CTE)即带有 WITH 子句生成的视图,作为独立计算的一部分还是嵌入到整个查询中;同时还要选择聚合操作的策略,例如采用单阶段、两阶段、三阶段或是更多阶段的方式来执行。所有这些都是在 CBO 阶段完成的。

上述内容讨论了众多的优化规则,其核心目标都是为了让 SQL 查询执行得更快。然而,正如科学研究中所强调的那样,明确地定义问题往往比直接解决问题更为关键。那么,如何才算恰当地定义了一个问题呢?这就需要我们找到一个恰当的度量标准来评估问题。如果我们仅仅以查询的执行时间作为衡量标准,虽然直观但却缺乏指导意义。根据我的理解,问题应当被定义为:尽可能早地减少涉及的数据量。接下来,我们将从这一视角出发,重新审视那些优化规则。

通过一个简化的 TPC-H 示例来说明这一点。在这个例子中,我们进行了查询改写。场景描述了一个包含大量订单的情况,其中订单的买卖双方都有各自的国籍信息。我们的目标是从中筛选出中国和美国之间交易的订单,并据此生成下表。

理解了这条 SQL 的处理方式,我们就能明白优化器在此过程中扮演的角色。根据条件:(卖方的国籍为中国且买方的国籍为美国)或(卖方的国籍为美国且买方的国籍为中国)来筛选中美之间的贸易订单。由于这个条件不可分割,一个直观的方法是首先将 orders 表与 customer 表进行连接,然后再与 supplier 表连接,接着与 nation 表连接,最后执行过滤操作。这是第一种处理方法,但其效率相对较低。

更优的做法是,鉴于我们要筛选的是中美之间的贸易记录,可以预先设定一个条件:仅选择国籍为中国或美国的 customer 和 supplier。这是优化器实施的一项重要优化措施——尽管这些额外的条件看起来可能有些多余,但实际上它们对于提前缩减 customer 和 supplier 的数据规模至关重要。这意味着,在与 orders 表进行连接时,右侧的表不再是全部的 customer 数据,而仅仅是其中的一部分;同样地,在与 supplier 表连接时,右侧表的数据也仅限于部分 supplier。同时,左侧的 orders 表也会被筛选,只保留那些 customer 国籍为中国或美国的订单。这样一来,在执行 join 操作时,无论是左侧还是右侧的数据量都得到了有效减少。这种优化在 TPC-H 查询中的效果尤为明显,性能提升了约 2 到 3 倍。这里的关键理念在于尽早减少数据规模。希望这个例子能够帮助大家更好地理解优化器努力追求的目标。

除了上述方法外,另一个至关重要的减少数据规模的策略是调整 join 操作的顺序。当事实表与维度表进行 join 时,通常维度表上会施加一些过滤条件,这些条件对事实表具有显著的过滤效果。然而,这引出了一个新的挑战:join 顺序的优化(join reorder)是一个 NP 完全问题,随着表数量的增加,候选执行计划的数量将以指数形式增长。长期以来,针对这一难题并未出现特别创新的解决方案,NP 完全问题通常只能通过动态规划的方法寻求次优解。目前我们所见的各种方法,本质上都是动态规划的不同应用形式,例如 DPSize、DPSub、DPhyper、Cascading 等。在我们的新型优化器 Nereids 中,同时采用了两种动态规划技术:基本的 Cascading 方法,以及 DPhyper 方法,二者相辅相成,共同发挥作用。

这或许是一个对你有用的开源项目data-warehouse-learning 项目是一套基于 MySQL + Kafka + Hadoop + Hive + Dolphinscheduler + Doris + Seatunnel + Paimon + Hudi + Iceberg + Flink + Dinky + DataRT + SuperSet 实现的实时离线数仓(数据湖)系统,以大家最熟悉的电商业务为切入点,详细讲述并实现了数据产生、同步、数据建模、数仓(数据湖)建设、数据服务、BI报表展示等数据全链路处理流程。

https://gitee.com/wzylzjtn/data-warehouse-learning

https://github.com/Mrkuhuo/data-warehouse-learning

https://bigdatacircle.top/

项目演示:

02

优化瓶颈


首个重要的性能提升源自于 RBO 阶段的一次关键重构。在 Cascading 框架中,存在一个名为 Memo 的结构,类似于一个小账本,用于记录所有由规则生成的执行计划。这是因为 CBO 阶段在执行动态规划算法时需要依赖这些信息。众所周知,所有动态规划方法都会使用某种形式的“小账本”,而在 Cascading 中,这个“小账本”就被称作 Memo。最初,我们按照文献中的流程,将执行计划存储在 Memo 中,当对执行计划进行修改时,需要从 Memo 中取出相应的计划片段,这个过程分别称为 copyIn 和 copyOut。在 RBO 阶段,通常是用一个全新的执行计划来替换旧的执行计划,我们依旧沿用了这种方式,即每次生成新的执行计划时,将其存入 Memo,待下一次规则替换时再从 Memo 中取出。然而,反复进行 copyIn 和 copyOut 的操作实际上是不必要的。为此,美团的华建老师花费了几周时间闭关开发,最终提交了一个大型的 PR,极大地提高了 RBO 的效率。尽管这要求我们重新审查一遍 RBO 的所有代码,带来了一定的挑战,但我们非常欢迎这样的改进带来的“痛苦”。

在 RBO 阶段实现了性能的大幅提升之后,CBO 阶段的性能瓶颈便显得尤为突出。这时,我们团队中的 ACM 冠军莫琛辉同学挺身而出。在接下来的几周内,他仔细研究了上百个火焰图,经历了多轮代码的改写与优化,最终成功将原本需要数秒的分析时间缩短至大约 20 毫秒。如果你未来也有构建一个类似 Cascading 框架的需求,那么对 CostAndEnforce 部分的性能优化绝对是一个值得深入探索的重点领域。

03

挑战

下面再介绍下开发过程中所面临的各种各样的挑战。这部分可能是介绍中最有意义的部分。

首要问题是关于公平与效率之间的平衡。其实质在于,在进行 join 顺序优化时,目标是找到最优的执行计划,但这实际上是一个 NP 问题,这意味着我们必须采取剪枝策略,不可能给予每一个可能的执行计划平等的验证机会——有些计划在未经测试的情况下就必须被淘汰。最简单的树形结构是左深树(Left Deep Tree)。在这种结构中,通常将较大的表置于最左侧,其背后的假设是我们正在执行的是哈希连接(hash join),即使用右表来构建哈希表,而左表用于探测。鉴于构建哈希表的单行成本远高于探测哈希表的成本,因此应将高成本的计算放在行数较少的表上执行,而将低成本的计算放在行数较多的表上执行。这是构建左深树的基本原则,即将最大的表(通常是事实表)置于最左侧,接着按顺序将较小的表(通常是维度表)放置于右侧,与大表进行连接。这种方法的优势在于搜索空间相对较小(相较于后述的两种方法),从而使得优化器的运行速度更快。然而,这也可能导致错过许多潜在的优秀执行计划,增加了执行引擎的负担。
为了实现更好的平衡,提出了 ZigZag 树的概念。其核心思想是:当大表与小表连接后,所得结果集的数据量可能会相对较小,因此在与另一张表进行连接时,可能需要将该结果集置于右侧,从而形成了中间所示的执行计划。每一步都需要判断左表和右表哪个更大,由此构建出 ZigZag 树。进一步扩展搜索空间,则会涉及到布什树(Bushy Tree),即考虑所有可能的二叉树结构。这样做虽然会大幅增加搜索空间,但当表的数量过多时,优化器的执行时间可能会超过执行引擎的执行时间,因此在许多情况下,无法进行全面的 Bushy Tree 搜索。

这里提供了一个基于表数量与 Left Deep Tree、ZigZag Tree、Bushy Tree 搜索空间增长情况的估算。在实际应用中,当表的数量较少时,倾向于使用 Cascading 方法,其优点在于能够将除 join 顺序优化之外的多种规则与 join 顺序优化相结合使用。不过,这种方法的效率相对较低,因此当表的数量较多时,通常会转而采用 DPhyper 方法。

第二个挑战在于我们不得不与误差共存,但这并不意味着我们放弃努力去减少这些误差。首先,让我们探讨一下误差是如何产生的。在进行 join 顺序优化时,这一步骤高度依赖于各种统计信息,包括每次 join 操作后预计的结果行数以及过滤后的行数等。因此,第一类误差来源于统计信息本身,其产生原因主要是抽样。例如,在计算某个字段中不同值的数量(即 NDV,number of distinct values)时,由于无法对全部数据进行精确计算,通常会采用抽样的方法,而这正是误差的一个重要来源。

在对表中的数据进行统计并开始计算时,例如一张包含学生信息的表,若有一个过滤条件为“选出其中的男生数量”。假设该表共有 100 行,优化器预估男生的比例为 50%。然而,如果这张表的数据来源于国防科技大学,实际选出的男生比例可能高达 98%,这就导致了统计信息推断上的误差。这类误差产生的主要原因有两个方面:一是统计信息本身的不准确,如前所述的抽样误差;二是引入了一些假设,比如假设数据是均匀分布的,或者假设不同字段之间不存在相关性等。因此,在统计信息的推导过程中,不仅原有的统计信息误差被保留,还额外引入了因假设不准确而导致的新误差。这些误差中,有的需要我们积极努力去消除,而有的则是特定应用场景下不可避免的。

要如何验证统计信息的推导是否准确呢?我们开发了一个名为 qError 的工具,它通过对比每个操作符预测的行数与实际执行时的行数,来检查推导的准确性。一旦我们生成了这些推导信息,下一步就是计算各个执行计划的真实代价,这一过程称为代价模型。在这一步中,必须考虑到查询引擎的特性、运行环境的差异等因素,比如是否更注重减少数据在网络中的传输,还是更关注机器内存的消耗,或者是 CPU 的利用效率等。不同的情况需要做出不同的权衡。因此,即便是在统计信息推导误差的基础上,代价模型还会引入新的误差。为了衡量代价模型的质量,我们推出了一款名为 Plan Ranker 的工具。无论是采用 Cascading 还是 DPhyper 动态规划方法,我们都会使用 Memo 来记录不同的执行计划。通过 Plan Ranker,我们可以提取出预计排名前十的执行计划,并通过实际执行来检验这些计划的效率是否与我们的预期相符。通过将实际执行后的计划序列与理论推断的计划序列进行比较,计算它们之间的距离,以此来评估代价模型的有效性。

最后,还有一个“游戏规则改变者”——Runtime Filter,它的出现彻底改变了我们对 join 顺序优化及整体优化策略的传统认知。让我们通过一个简单的例子来理解 Runtime Filter 的作用及其对 join 操作的重大影响。假设存在一个订单表,包含订单号、商品ID及其他字段;同时还有一个商品表,记录了商品ID和品牌信息,其中每个品牌下有多个商品。现在,我们的任务是找出所有属于“华为”品牌的订单。首先,我们会对商品表进行过滤,筛选出品牌为“华为”的商品,然后将这些商品与订单表进行 join 操作。之前提到过,优化的核心目标在于尽早减少数据规模,因此在这种情况下,我们可能会产生一个想法。

假设从商品表中过滤出“华为”品牌对应的商品ID为 p001、p003,形成集合 A。是否可以将集合 A 发送给订单表,先依据商品ID对订单集合进行一次预过滤,这样原本6亿条的订单数据可能只剩下2400万条来进行 join 操作。这正是 TPC-H 中的一个典型场景,其数据比例也与此相似。通过这种方式,可以显著减轻最后一步 join 操作的负担,进而大幅提升整个查询的效率。起初,Runtime Filter 被视为优化器中规则的一种附加奖励,如果说优化器内的规则是“一等公民”,那么 Runtime Filter 就像是“二等公民”。然而,这位“二等公民”却以其独特的方式颠覆了我们对优化策略的传统看法。

让我们来看一个稍显复杂的例子。假设任务是找出位于亚洲的供应商(supplier),已知 supplier 表中包含 nation id,而 nation 表中又包含 region id(这里只考虑亚洲)。传统的处理方式是先将 supplier 表与 nation 表进行 join,然后再与 region 表 join。在 TPC-H 测试中,region 表涵盖了5大洲,nation 表则列出了25个国家,每个大洲恰好有5个国家。每个国家拥有一部分供应商,总计 supplier 表有1千万条记录,并且这些供应商在各个国家间均匀分布。相比之下,左侧的执行计划不如右侧的高效。右侧的方案首先从 region 表中筛选出亚洲,然后与 nation 表 join,这样就只选出了亚洲的5个国家。接着,再与 supplier 表进行 join,直接锁定了这5个亚洲国家下的2百万供应商。尽管左右两侧的执行计划都涉及两次 join 操作,但处理的数据量级却大相径庭。右侧的方案仅对1千万条数据进行了一次 join 操作,而左侧的方案却进行了两次。根据传统观点,右侧的执行计划显然远胜于左侧。接下来,我们将探讨为何 Runtime Filter 被誉为“颠覆者”。

这种“颠覆性”的效果体现在如下方面:左侧是我们之前认为较为优秀的执行计划,而右侧则是被认为不太理想的计划。但在引入 Runtime Filter 后,情况发生了变化。右侧的方案中,由于 region 表只选择了亚洲,因此会将亚洲的 region id 发送给 nation 表,这使得 nation 表在扫描时仅会提取出5条数据,因为 nation 表已经通过 Runtime Filter 进行了初步过滤。nation 表过滤完成后,会生成下一个 Runtime Filter,将5个国家的 id 发送至 supplier 表,从而使 supplier 表直接过滤出2百万条数据。如果采用右侧的执行计划,参与 join 操作的数据量从未达到1千万,这意味着右侧的执行计划反而占据了优势。此外,除了对 join 操作的影响外,Runtime Filter 对延迟物化也有显著的帮助。在 Doris 的存储层中,除了需要获取 key 字段外,还需读取许多其他字段。Doris 采用列式存储,当 nation 表过滤出的5个国家 id 被发送给 supplier 表后,supplier 表中其他字段的访问数据量也随之减少,这就是所谓的延迟物化。相比之下,左侧的方案中,supplier 表中的其他字段都需要被读取出来。而在右侧的方案中,通过索引可以直接定位并提取相关行,无需读取无关数据。
然而,Runtime Filter 也带来了不确定性,因为其过滤效率难以预估,这依赖于统计信息的推导。同时,由于等待 Runtime Filter 的生成可能导致整个查询过程延长,如果 supplier 表的扫描必须等到 region 表和 nation 表的扫描完成才能获得有效的 Runtime Filter,那么一旦 region 表的扫描速度变慢,nation 表未能及时接收到 region 表的扫描结果,而提前生成并传递给 supplier 表的 Runtime Filter 实际上将不会有任何过滤效果。因此,Runtime Filter 的过滤效果具有一定的动态性,这给查询优化带来了极大的挑战。这也是我们未来需要重点解决的问题之一。
参考:https://mp.weixin.qq.com/s/OHTFoJlw2knd-28nH3CACQ
更多详情可文末点击原文

04

进交流群群添加作者

推荐阅读系列文章

如果喜欢 请点个在看分享给身边的朋友

大数据技能圈
分享大数据前沿技术,实战代码,详细文档
 最新文章