CMU-17-799-数据库查询优化笔记-全-

CMU 17-799 数据库查询优化笔记(全)

001:课程介绍与背景知识

在本节课中,我们将学习数据库查询优化的基本概念、课程目标以及为什么查询优化是数据库系统中至关重要且极具挑战性的部分。我们还将概述本学期的课程安排和评估方式。

课程概述与目标

本课程的主题是查询优化。与教授数据库系统架构的课程不同,本课程的核心在于如何构建一个查询优化器。查询优化器可以说是数据库系统中最关键的部分。

本学期的目标是让你学习现代实践和方法,从而能够实际实现数据库查询优化器。最终,你将有能力从零开始构建一个查询优化器,或者修改现有的优化器。由于数据库系统的正确性至关重要,我们也将投入大量精力在文档编写和测试上,这些都是系统开发中必需的软技能。

本学期我们将涵盖查询优化器的基础材料。下一节课,我们将讨论1979年IBM发表的原始查询优化器论文。之后,我们将在此基础上,探讨现代实践和数学方法。

为什么学习查询优化

如今,数据库比以往任何时候都多,数据库领域的资金也空前充裕。查询优化是区分不同系统的关键因素。如果一个系统无法生成高效的查询计划,那么无论其执行引擎多快,如果它执行的是最愚蠢的操作,也毫无意义。

从研究角度看,查询优化是一个非常困难的问题。在整个学期中,我会反复强调:查询优化是一个尚未解决的问题。在工业界,擅长此道的人能获得丰厚的报酬。在卡内基梅隆大学的近十年里,我收到的招聘邮件中,对方总会询问是否有学生在研究查询优化,因为很难找到有相关经验的人。

预备知识

本课程假设你已经学习过数据库系统导论,重点是“系统”层面。如果你上过类似1545或645的课程,那么你就具备了所需的背景知识。我们将直接深入,讨论SQL、关系代数、系统实现、存储模型和处理模型等概念。我们假设你已经了解这些内容,不会花时间重新解释。

本课程也不是机器学习课程。虽然我们会涉及一些基于机器学习的算法和技术,但不会深入讲解机器学习算法本身。我们只关心那些能让数据库运行更快的技术。

课程安排与评分

课程网站已经上线,教学大纲已发布。所有课程物流信息都可在网站上找到。

卡内基梅隆大学非常重视学术诚信,我也同样重视。请务必遵守相关政策,包括关于使用人工智能的规定。如果不确定某件事是否允许,请发邮件询问我。

我的办公时间是周三下课后3:30,地点在我的办公室,或者通过预约。助教Wan Shan Lim也会提供办公时间,并在Piazza上发布信息。

你的最终成绩将由以下几部分构成:

  • 阅读作业与总结:每次课都有指定的阅读材料(主要是论文,有时是教科书章节)。你需要提交阅读总结,概述论文内容,列出三个好的想法、三个不好的想法,以及论文使用了什么工作负载进行评估。你每学期可以缺席一次总结提交。
  • 课堂笔记整理:每位学生将被分配至少一次课的笔记整理任务。你需要将课堂讨论浓缩成结构化的笔记。
  • 项目:共有两个项目。
  • 期末考试:最后一次课进行,将是长篇论述形式,旨在综合整个学期讨论的不同主题,并将其应用到新场景中。

以下是两个项目的简要介绍:

项目一:你将开始使用一个现有的“查询优化器即服务”工具,很可能是Apache Calcite。我们会给你一组查询、步骤和规则,让你尝试优化查询,使其在InductDB中运行得更快。这个项目旨在让你理解可以进行哪些转换,以及使用Calcite等工具的经验。该项目个人独立完成。

项目二:这是一个为期约半学期的小组项目,相对开放。你需要提出一个与本课程范围相关的、你想研究的课题。在春假后提交提案,一个月后进行进度更新,并在期末考试周进行最终演示。

请务必不要抄袭。如果对项目、阅读作业、笔记整理或期末考试有任何疑问,请随时提问。

查询优化简介

现在,让我们快速介绍本课程的核心内容,这将为我们下一节课讨论查询优化器的早期实现做好铺垫。

为什么需要查询优化器?

像SQL这样的查询语言是声明式的。这意味着应用程序或用户告诉数据库系统他们想要得到什么答案,或者希望系统对他们的数据执行什么计算,而不指定具体如何计算。

数据库系统的魔力在于,它们会尝试为给定的查询找到一个正确的执行计划(可能在没有先验知识的情况下),并且会尝试找到成本最低的那个计划。这里的“成本”是一个相对概念,我们将在整个学期中讨论。关键是计划必须正确。

我们需要理解SQL所指定的查询语义,然后尝试找到以高效方式运行查询的最佳替代方案。这就是本课程的全部内容:如何快速、高效且正确地做到这一点。

成本通常以I/O操作数等内部指标来衡量,作为实际执行时间的代理,用于比较不同查询计划的效率。

优化的重要性

通过一个简单的例子,我们可以看到查询优化器能带来的性能提升有多么显著。

假设数据库中有两个表:departmentsemployees。我们想找出在“玩具”部门工作的所有不重复的员工姓名。

如果我们采用一种近乎字面翻译的方式将SQL转换为关系代数计划,可能会得到一个包含笛卡尔积的低效计划。根据示例中的目录统计信息(如表大小、索引),这种“朴素”计划可能需要执行200万次I/O操作

然而,通过一系列优化,我们可以显著减少I/O:

  1. 用内连接替换笛卡尔积:将笛卡尔积和其上的过滤条件合并为一个内连接操作。
  2. 选择更高效的连接算法:例如,从页嵌套循环连接切换到排序合并连接。
  3. 利用流水线处理模型:从物化模型切换到向量化模型,避免中间结果的磁盘写入。
  4. 调整连接顺序和谓词下推:将过滤条件尽可能下推到连接操作之下,减少需要处理的数据量。

经过这些优化,同样的查询可能只需要37次I/O操作。从200万到37,这就是查询优化器至关重要的原因。这还是一个简单的例子。当查询涉及数十甚至数百张表,并包含CTE(公共表表达式)和嵌套查询等复杂结构时,手动找出最优计划变得极其困难。

查询处理架构

在一个典型的数据库系统中,查询处理的流水线如下:

  1. 解析器:接收SQL查询字符串,根据SQL方言的语法将其解析为抽象语法树。
  2. 绑定器:将抽象语法树中的标记(如表名、列名)映射到数据库中的实际对象(如表ID、列ID),并检查其存在性。它会产生一个逻辑计划。逻辑计划描述了要执行的操作(如扫描表、连接表),但不指定具体如何执行(如使用哪种扫描方式或连接算法)。
  3. 查询优化器:接收逻辑计划,并利用系统目录中的信息(如模式、统计信息)和成本模型,在大量语义等效的可能执行计划中搜索,试图找到成本最低的那个。成本模型使用统计信息来估算每个操作符的预期成本。
  4. 执行引擎:优化器输出一个物理计划,该计划明确定义了如何执行查询。然后由执行引擎运行该计划。

本课程关注的核心就是查询优化器成本模型

逻辑计划与物理计划

我们需要区分逻辑操作符和物理操作符:

  • 逻辑操作符:高层次地表达你想要做什么(例如,“连接表A和表B”)。
  • 物理操作符:指定用于执行该操作的具体算法(例如,“使用哈希连接算法连接表A和表B”)。

优化过程中的转换可以是:

  • 逻辑到逻辑:例如,将 JOIN(A, B) 转换为 JOIN(B, A)
  • 逻辑到物理:例如,将逻辑操作符 JOIN(A, B) 转换为物理操作符 HASH_JOIN(A, B)

转换通常是单向的(逻辑->逻辑->物理),并且不一定是一对一的。多个逻辑操作符可能被合并为一个物理操作符,反之亦然。

优化器实现方法概览

在高层次上,构建查询优化器主要有两种方法:

  1. 基于启发式规则的方法:使用一组硬编码的“如果-那么”规则。例如,如果看到“笛卡尔积后紧跟一个过滤条件”的模式,就将其转换为内连接。许多早期的数据库系统和一些简单系统只使用这种方法。它的优点是实现简单快速,但长期来看不够灵活和强大。

  2. 基于成本的搜索方法:使用经典的搜索算法(如动态规划)在可能的计划空间中寻找成本最低的计划。优化器会尝试大量语义等效的计划,并使用成本模型来估算每个计划的成本,而无需实际运行它们。这是现代数据库系统的核心。

许多系统结合使用这两种方法:先应用一些快速的启发式规则进行初步优化和剪枝,然后再进行更耗时的基于成本的搜索。

本学期我们将重点讨论两种主要的搜索算法实现方式:

  • 自底向上优化:从空计划开始,逐步添加物理操作符来构建最终计划。例如,在连接A、B、C三张表时,先决定是连接A和B,还是B和C,然后在此基础上添加下一个连接。
  • 自顶向下优化:从期望的最终结果(如连接A、B、C)开始,向下遍历,通过转换添加所需的操作符。

这两种方法各有优劣,我们将花费大量时间讨论它们的含义、算法效率、成本计算以及复杂性。下一节课要读的IBM System R论文采用的是自底向上的动态规划方法。

其他重要概念

  • 目标函数:成本模型计算出的“成本”数字。它通常是查询延迟或响应时间的代理。不同系统的成本模型内部计算方式不同,因此它们的成本数字不能直接跨系统比较。
  • 多查询优化:同时优化一批查询,寻找全局最优计划,可能涉及查询合并或结果共享。这比单查询优化复杂得多,目前主要处于研究阶段。
  • 挑战与现状:尽管名为“优化器”,但我们几乎永远找不到真正最优的计划,这在计算上是不可行的。特别是在数据湖或云环境中,可能根本没有数据的统计信息。因此,我们需要使用各种技术来估算成本,并尽量减少搜索本身的计算时间。自适应查询处理等技术可以在运行时进行调整。

总结

本节课我们一起学习了数据库查询优化的核心地位与挑战。我们了解到,查询优化器通过将声明式的SQL转换为高效的物理执行计划,能够带来数量级的性能提升。课程将涵盖从经典的自底向上动态规划方法到现代的自顶向下优化框架,以及成本模型、统计信息利用和自适应优化等关键主题。查询优化是一个复杂且尚未完全解决的领域,但掌握它对于构建高性能数据库系统至关重要。

下一节课,我们将深入探讨查询优化器的早期经典实现——IBM System R优化器,开启我们的具体技术学习之旅。

002:查询优化器历史与IBM System R 🗃️

在本节课中,我们将从查询优化器的起源开始,探讨20世纪70年代早期的实现,特别是聚焦于IBM System R这篇具有开创性意义的论文。我们将了解从启发式优化到基于成本搜索的演变过程,并理解这些早期思想如何为现代查询优化器奠定了基础。

背景:关系模型与声明式查询的兴起

上一节我们介绍了课程目标和查询优化的重要性,本节中我们来看看查询优化器诞生的历史背景。

在20世纪60年代末至70年代初,人们开始构建最早的通用数据库系统。当时,用户与数据库交互的方式主要是编写过程式代码(如CODASYL),程序员需要明确指定数据库应如何执行查询以获取答案。这要求程序员做出大量底层决策,例如选择扫描方式和使用哪个索引。

然而,数据会随时间变化和演进。最初编写时有效的执行计划,在数据分布或规模改变后可能变得低效。由于执行计划被硬编码在应用程序中,任何优化都需要重写代码,这带来了巨大的维护负担。

关系模型的伟大思想在于,它将那些复杂的程序步骤提炼成一种声明式的形式。用户只需声明“我想要什么答案”(例如,通过连接三个关系来获取艺术家姓名),而无需指定谓词的应用顺序或连接顺序。这样,数据库系统就处于做出最佳执行决策的理想位置,并且随着数据的演进,查询计划也可以随之演进,而无需更改任何应用程序代码。

早期主要有四个关系模型的实现:

  • IBM英国实验室的“Peterlee关系测试载体”,是一个早期原型。
  • IBM圣何塞研究实验室的System R,这是第一个严肃构建的关系数据系统。
  • 加州大学伯克利分校的Ingres(后来发展为Postgres)。
  • Larry Ellison阅读IBM论文后创建的Oracle

所有这些系统最终都需要构建查询优化器,因为声明式语言要求系统自行找出最佳执行方式。

查询优化器的设计选择概览 🧭

以下是构建优化器时可能的不同设计选择,我们将在课程中涵盖大部分内容:

  • 启发式优化:基于硬编码规则和模式匹配进行转换,不考虑成本估算。Ingres和早期的Oracle采用此法。
  • 基于成本的搜索:使用成本模型估算不同计划的代价,并搜索最佳计划。System R 是开创者,其方法成为后续几乎所有数据库系统的基础。
  • 分层搜索 vs. 统一搜索:System R采用分层(先逻辑转换,后物理计划生成)。Cascades等系统采用更统一的搜索,但界限可能模糊。
  • 自底向上 vs. 自顶向下:System R使用自底向上的动态规划。Cascades使用自顶向下的转换式搜索。这对实现有重大影响。
  • 随机化搜索:如模拟退火、遗传算法(PostgreSQL中有原型实现)。
  • AI/机器学习方法:使用神经网络等技术进行优化。

接下来,让我们从最初的启发式优化器开始。

启发式优化器:基于规则的转换 🔧

早期查询优化器的基本思想是在数据库系统中硬编码一些逻辑,用于查找查询计划中的特定模式,并应用转换规则。

其实现方式可以想象为一系列 if-then-else 语句:如果看到逻辑运算符A后面跟着逻辑运算符B,那么就执行某种转换。这种方法之所以能工作得相当好,是因为我们可以应用关于数据库系统的领域知识,知道某些操作几乎总是有益的(例如,优先执行选择性最高的过滤条件、谓词下推)。

Ingres最早采用了这种方法,Oracle直到20世纪90年代也一直使用它,MongoDB等许多现代新兴数据库系统初期也常采用此法,因为构建一个完整的基于成本的优化器非常复杂。

我们能够进行这些转换的关键在于,我们依赖关系代数中的等价规则。这些规则保证了在修改现有表达式后,所生成的新关系代数表达式是等价的,即会产生相同的答案。这为我们的转换提供了正确性保障。

我们主要关注选择和连接的两种规则:

  1. 选择操作的等价规则:我们可以将包含多个AND条件的谓词拆分成多个独立的选择操作符,并移动它们的位置(谓词下推)。我们还可以简化谓词表达式(例如,将 x = y AND y = 3 重写为 x = 3 AND y = 3)。
  2. 连接操作的等价规则:主要是交换律(可以交换连接中关系的顺序)和结合律(可以改变连接操作的组合顺序)。这些规则导致了连接顺序组合的爆炸式增长(n个表的连接顺序数是卡特兰数),使得找到最优连接顺序成为一个NP难问题。

应用启发式转换:一个示例

以下是如何对一个查询应用启发式规则:

  1. 初始逻辑计划:SQL查询经过绑定器后,生成初始逻辑计划,通常包含表扫描、笛卡尔积和将所有WHERE条件放在顶部的单个过滤操作符。
  2. 拆分谓词:利用等价规则,将顶部的复合过滤条件拆分为多个独立的过滤操作符。
  3. 谓词下推:识别出只引用单个表属性的谓词,并将其下推到该表的扫描操作符之上。将连接条件(引用两个表的谓词)下推到笛卡尔积之上。
  4. 转换笛卡尔积为连接:如果一个过滤谓词紧随笛卡尔积之后,并且该谓词引用了连接的两边,那么可以将这个过滤谓词和笛卡尔积合并转换成一个内连接操作符。
  5. 投影下推:如果发现向上传递了不需要的属性,可以添加投影操作符来提前过滤掉它们(具体是否进行取决于系统实现,如列存/行存)。

所有这些操作都无需查看成本模型或统计信息,仅仅是“如果看到模式X,就执行操作Y”的硬编码规则。

Ingres处理连接排序的独特方法

那么,如果没有统计信息,如何决定连接顺序呢?Ingres采用了一种独特的方法:它通过将多表查询分解为一系列单关系查询来执行连接。

以下是其步骤:

  1. 分解查询:将原始的多表连接查询,分解成多个只访问一个关系(或一个临时结果)的查询。
  2. 物化临时结果:执行第一个查询(通常从具有直接谓词的表开始),将其结果写入临时表。
  3. 查询重写与迭代执行:在原始查询中,用对临时表的引用替换原表。然后递归地将新查询再次分解为单关系查询,并执行下一个。如此反复,直到所有连接完成。
  4. 处理多值:如果中间结果包含多个值,则可能需要对下一个查询执行多次。

这种方法在今天看来效率不高,但其思想——将复杂查询分解、优化并执行子部分——在某些情况下仍有体现(例如,MySQL有时会这样处理嵌套子查询)。

启发式优化的优缺点

优点

  • 对于简单查询非常快。
  • 实现相对简单,能让新系统快速运行起来。
  • 利用领域知识,对常见模式效果良好。

挑战与缺点

  • 严重依赖“魔法常数”或“总是这样做”的规则,无法前瞻性地评估转换的后果。
  • 随着规则增多,代码会变成一堆寻找特定模式的 if-then-else 语句(“面条代码”),难以维护。
  • 规则的应用顺序隐含在代码顺序中,这可能无法产生最佳计划。
  • 无法处理复杂查询的最优连接顺序选择。

Oracle早期曾为其“语法优化器”(即按照SQL中表的出现顺序进行连接)进行市场宣传,但最终在90年代放弃了它,转向了System R风格的优化器。

核心启示:使用启发式规则引导查询计划优化本身不是坏主意,但像早期系统那样用过程式 if-then-else 代码来实现并非良策。我们需要更高级的抽象,能够声明式地定义规则,并由优化器自行决定在识别匹配时以何种顺序应用它们。

System R:基于成本的查询优化器 🚀

接下来,我们进入本节课的核心:IBM System R的基于成本查询优化器。这篇1979年的论文奠定了现代查询优化器的基础。

System R优化器仍然包含一个初始的启发式阶段(类似于Ingres,可能也是一堆 if-then-else)。关键创新在于,当需要将逻辑计划转换为物理计划(特别是确定连接顺序)时,它使用了一个动态规划算法进行基于成本的搜索

核心架构:查询块与分层优化

System R将查询分解为查询块(Query Block)。一个查询块通常对应一个SELECT-FROM-WHERE结构,可能包含GROUP BY或ORDER BY。嵌套子查询会被视为独立的查询块进行优化。

优化过程分为两步:

  1. 为每个查询块内的单个关系选择最佳访问路径:对于每个表,考虑所有可能的索引和全表扫描,使用成本模型选择代价最低的访问方式。
  2. 为整个查询块确定最佳连接顺序:使用动态规划,自底向上地枚举所有可能的连接顺序和连接算法,选择总成本最低的计划。

成本模型:IO与CPU的权衡

System R使用一个简单的成本公式:Cost = (# of page fetches) + W * (# of RSI calls)

  • # of page fetches:预期的IO页面读取数。
  • RSI calls:一个代表CPU工作量的代理指标。
  • W:一个权重因子,用于平衡IO和CPU的相对成本。

这个基本思想——比较存储IO(或网络IO)与CPU成本——至今仍被大多数系统使用(例如PostgreSQL的 random_page_cost, cpu_tuple_cost 等配置参数)。

选择性估计与统计信息

成本估算的核心是选择性因子,它表示一个元组满足给定谓词的概率。System R论文提供了一页公式,用于基于数据统计信息(如不同值数量、最大值/最小值)来估算各种谓词的选择性。

然而,这里存在一个关键挑战:这些公式依赖于强假设,例如数据均匀分布、谓词之间相互独立。在现实世界中,这些假设常常不成立(例如邮政编码的人口分布),导致估计错误。错误的估计会向上传播,使得即使搜索了所有连接顺序,也可能因为基于错误成本而选择了一个糟糕的计划。

处理“有趣的排序”

System R引入了有趣的排序概念,即查询所需的输出顺序(由ORDER BY或GROUP BY引起,因为当时GROUP BY也需排序)。在优化过程中,系统会为每个关系保留两条信息:

  1. 能产生所需排序的最佳计划及其成本。
  2. 不能产生所需排序的最佳计划及其成本。
    然后比较:成本(无序计划) + 排序成本成本(有序计划),选择总成本更低的方案。这是一种在优化后期处理排序需求的“补丁”式方法。

连接排序算法:自底向上的动态规划

以下是System R确定连接顺序的步骤:

  1. 步骤1 - 最佳单表访问路径:为查询中涉及的每个单独的表,找到成本最低的访问路径(考虑索引和排序需求)。
  2. 步骤2 - 枚举并递推
    • 基于步骤1的结果,枚举所有连接两个表的方式(连接算法、顺序),为每对表保留成本最低的计划。
    • 然后,基于已有的两表连接最佳计划,枚举添加第三个表的所有可能方式,并为每个三表集合保留成本最低的计划。
    • 以此类推,直到构建出包含所有表的最佳计划。
  3. 剪枝策略:为了应对搜索空间爆炸,System R应用了启发式剪枝:
    • 忽略笛卡尔积。
    • 只考虑左深连接树,而不考虑布什连接树。这在当时硬件条件下(内存小,流水线执行重要)通常是合理的选择。

自底向上 vs. 自顶向下:这是整个课程的关键区别。System R是自底向上的:从零开始,逐步添加连接和物理操作符,直到构建出完整计划。而后续的Cascades优化器采用自顶向下的转换方式:从完整的逻辑计划开始,不断应用规则进行转换,直至得到物理计划。

处理嵌套子查询

System R处理嵌套子查询的方式与Ingres类似:将其视为独立的查询块,识别依赖关系,然后先优化并执行子查询,将其结果物化到临时表中,再将其代入外层查询进行优化。这种方法(有时被称为“物化”或“执行-代入”)并非最优,我们将在后续课程中学习更先进的子查询重写技术。

System R的贡献与局限

优势

  • 能够在合理时间内找到相当好的查询计划,而无需穷举搜索。
  • 奠定了基于成本优化的基础框架,影响深远。

局限

  • 启发式规则部分仍用过程式代码实现,难以维护。
  • 只考虑左深连接树,可能错过更优的布什连接计划。
  • 物理属性(如数据分布、分区)在搜索算法中被很大程度上忽略,仅通过事后检查来处理排序需求。
  • 成本模型中的简化假设(均匀分布、独立谓词)可能导致严重的估计错误。

尽管如此,System R论文是一篇里程碑式的著作,其核心思想至今仍然相关。如果你想构建一个新型查询优化器,从这篇论文入手是一个绝佳的起点。

总结与展望 📚

本节课中,我们一起学习了查询优化器的早期历史。我们从20世纪70年代的关系模型和声明式查询需求讲起,探讨了最早的启发式优化器(如Ingres)如何基于硬编码规则和关系代数等价式进行查询转换。接着,我们深入研究了IBM System R这篇开创性论文,它首次引入了基于成本的搜索和动态规划算法来解决连接排序问题,并建立了基本的成本模型框架。

我们看到了从过程式、基于规则的优化,向声明式、基于成本的搜索的演变。同时,也指出了早期方法的局限性,如规则实现的复杂性、成本估计的假设问题以及搜索空间的剪枝策略。

这些早期思想为后续发展奠定了基础。在下节课中,我们将进入下一个阶段,学习IBM在System R之后构建的Starburst优化器,它将我们引向更分层、基于规则的优化器架构。再之后,我们将探讨Cascades框架,它代表了统一、自顶向下的查询优化范式。

003:IBM Starburst 查询重写器与优化器

概述

在本节课中,我们将学习IBM Starburst查询优化器。我们将了解其作为“优化器生成器”的设计理念,探索其将查询表示为高级关系演算的“查询图模型”,并详细解析其分阶段(分层)的优化策略:先进行基于规则的查询重写,再进行基于成本的计划枚举。


背景与动机

上一节我们介绍了System R,它是第一个基于成本的查询优化器。本节中,我们来看看IBM在1980年代开发的Starburst优化器,其复杂性和先进性有了显著提升。

在1980年代中后期,数据库领域出现了两大趋势:

  1. 面向对象数据库:旨在原生存储应用程序中的对象,以应对非表格形式的新应用需求。
  2. 主动数据库:支持触发器,使数据库能对数据变化做出反应。

这些趋势促使人们希望数据库系统能够易于扩展,以支持新的数据类型和操作符,同时保持竞争力。这就要求查询优化器也必须能够感知这些新增的功能,而无需重写整个优化器。由此催生了优化器生成器的概念。

优化器生成器的核心思想是:将优化器的核心框架与具体的优化规则分离开来。开发者可以定义规则(例如:如果查询计划符合某种模式,则进行某种转换),然后由框架负责应用这些规则来寻找最优计划。这允许一个团队专注于规则开发,另一个团队专注于搜索引擎。


分层搜索 vs. 统一搜索

在构建优化器时,主要有两种处理优化规则的方式:

分层搜索(Starburst采用的方法):

  • 查询优化被分为多个阶段。
  • 首先进行查询重写阶段,应用一系列基于启发式的规则来转换查询的逻辑表示。此阶段无需成本模型,仅基于逻辑等价性。
  • 目标是将查询(特别是嵌套查询)转换为更易于后续基于成本优化的形式(例如,将嵌套子查询转换为连接)。
  • 重写完成后,进入计划优化阶段,进行基于成本的搜索,生成物理执行计划。

统一搜索(将在后续课程中介绍):

  • 将所有转换规则(包括基于成本的和基于启发式的)混合在一起。
  • 由一个统一的搜索引擎在单次搜索过程中决定何时以及如何应用这些规则。
  • 通常更重量级,但可能更灵活。

Starburst采用了分层搜索策略,其优化管道如下图所示:


查询图模型与关系演算

为了进行有效的重写,Starburst引入了一种称为查询图模型的内部查询表示法。它基于关系演算,而非关系代数。

关系代数是指定操作顺序的过程性语言。而关系演算是更声明性的逻辑语言,它描述所需的元组应满足的条件,而不指定求值顺序。

一个基本的关系演算表达式形式为:{ t | P(t) },表示“所有满足谓词P的元组t的集合”。

例如,查询“工资大于50000的员工的ID和薪水”可以表示为:

{ t | ∃ s ∈ Employees ( t.id = s.id ∧ t.salary = s.salary ∧ s.salary > 50000 ) }

查询图模型将这种关系演算表达式图形化。它由“盒子”组成,每个盒子代表一个查询块(如SELECT语句)。盒子内包含:

  • :描述操作的量化符(如存在量词∃)。
  • :描述输出列的元数据及其属性(例如,是否保证结果唯一DISTINCT)。

通过这种高级的、非过程性的表示,优化器可以更容易地识别模式并进行逻辑转换,而不受初始操作顺序的束缚。


查询重写阶段

查询重写是Starburst优化器的第一个主要阶段。其目标是应用一系列重写规则,将初始的查询图模型转换为一个更优、更“平坦”的模型,特别是消除嵌套子查询。

每个重写规则包含两部分:

  1. 匹配条件:一个检查查询图模型中是否存在特定模式的函数。
  2. 动作函数:如果模式匹配,则执行转换的函数。

规则保证:只要输入是有效的查询图模型,应用规则后输出仍然是有效的查询图模型。这意味着重写过程可以在任何时刻安全终止。

以下是重写规则的一个简化示例,用于将视图引用合并到外层查询中:

规则:合并Select盒子
条件:
    - 存在一个上层Select盒子(U)
    - U包含一个量词(Q),该量词基于一个集合构造器(读取基表或视图)
    - Q引用的目标是一个下层Select盒子(L)
    - L只有一个引用者(即只有Q引用它)
    - 满足关于DISTINCT属性的某些条件(例如,U不要求DISTINCT,或L保证DISTINCT)
动作:
    - 将L合并到U中
    - 调整输出属性的DISTINCT标志

通过不断应用此类规则,优化器可以将复杂的嵌套查询“展平”为单个查询块,为后续的基于成本优化做好准备。


计划优化阶段:从“星”到“棒棒糖”

重写阶段结束后,我们得到一个优化后的查询图模型。接下来需要将其转换为可执行的物理计划。Starburst使用“星”“棒棒糖” 的概念来完成这一转换。

  • 棒棒糖:代表一个可执行的物理操作符(如TableScan, HashJoin, Sort)。一个棒棒糖接受一个或多个输入表,产生一个输出表。
  • :代表一个高级的逻辑或物理操作抽象。它通过规则被重写为一个或多个更具体的“星”或最终的“棒棒糖”。

这个过程类似于语法解析,但关键区别在于:一个“星”可能被重写为多个备选方案(例如,Join星可以重写为NestedLoopJoin棒棒糖或HashJoin棒棒糖),从而创建不同的计划候选。

此外,Starburst引入了“胶水” 星的概念。它用于管理和强制执行计划所需的属性(例如,数据必须按某个键排序)。如果下层输入不满足“胶水”星要求的属性,“胶水”星会重写为一个能强制执行该属性的棒棒糖(如Sort);如果已满足,则“胶水”星被重写为空操作并丢弃。

执行成本也被建模为计划树的一个属性,在规则应用过程中进行累加计算。


搜索终止与总结

优化器需要决定何时停止搜索。Starburst及后续的DB2采用了基于转换次数限制的策略:无论系统负载如何,优化器在应用了预定数量的规则转换后即停止,并选择当前找到的最佳计划。这保证了计划生成时间的可预测性。

本节课中我们一起学习了:

  1. IBM Starburst作为“优化器生成器”的背景和动机。
  2. 分层搜索架构:先进行基于规则的查询重写,再进行基于成本的计划优化
  3. 使用关系演算查询图模型进行高级、声明性的查询表示。
  4. 通过定义重写规则来转换查询图模型,特别是消除嵌套子查询。
  5. 在计划优化阶段,使用“星”“棒棒糖”“胶水” 规则,将逻辑表示转换为物理执行计划,同时管理计划属性和成本。

Starburst的设计理念对现代查询优化器产生了深远影响,其分阶段处理、基于规则的框架为数据库系统的可扩展性奠定了基础。下一节课,我们将探讨采用统一搜索策略的优化器生成器,如Volcano和Cascades框架。

004:查询优化-CMU-15-799--(2025-年春季)-p04-P04-#04---EXODUS-+-Volcano-Query-Optimizer-Generators-(CMU-Optimize!)

在本节课中,我们将学习查询优化器生成器的概念,重点分析EXODUS和Volcano这两个系统。我们将了解它们如何将优化器构建为可配置、可集成的工具包,从而避免为每个新数据库系统重复实现查询优化器。

概述

上一节我们讨论了Starburst的查询优化器模型。本节中,我们将探讨查询优化器生成器,即专门设计用于构建和配置查询优化器的工具包。这些工具包并非完整的数据库系统,但可以与您正在构建的任何数据库系统集成。

课程通知与更正

项目一已发布。数据集已于今晨更新。如果您直接重命名了文件,请注意更正拼写错误。我们将在下周三的课程结束时简要概述项目一的预期内容和要求。项目说明应相当详尽,您应能快速上手,并通过GradeScope提交作业。

请尚未填写GitHub用户名和AWS账户信息的同学,在Piazza上发布的电子表格中填写。我们将提交信息至亚马逊,以便为每位同学的项目使用提供100美元额度。

关于课程内容,首先进行一些更正。上周课程中展示Starburst的查询图模型时,有一处拼写错误:“T1.price is less equal to all speak Q2.price”应为“T1.price is less equal to all Q2.price”。我们将在幻灯片中修正。

回顾Starburst与优化器生成器理念

上节课我们讨论了Starburst。我们研究了分层搜索方法,即查询优化器包含两个不同的阶段。

第一阶段是重写,您将查询图模型转换为另一个查询图模型。此阶段应用无需成本模型的转换或规则,您确切知道这是需要进行的优化。其中一个核心思想是处理可能位于独立查询框中的嵌套子查询,将其整合到同一查询框(如查询图模型的根节点)中,以便执行一系列连接操作,而无需为外部表的每个元组反复调用嵌套查询。重写阶段完全基于启发式方法。

目标是获得一个可以进入计划枚举过程的查询图模型表示形式。在此过程中,我们使用规则将QGM转换为星型结构,这些规则可以将一元星型操作符转换为另一星型操作符,或将星型操作符转换为棒棒糖型操作符。不断应用这些规则,遍历查询计划中所有可能的选择,最终在时间耗尽或搜索穷尽时得到可实际执行的最终计划。

需要指出的重要一点是,除了这两个阶段(Volcano优化器没有此阶段,并特别指出Starburst这样做不好,我们稍后会探讨原因),他们还引入了属性作为查询计划和规则中的一等组件。规则可以感知转换是否会以某种方式改变查询计划,导致其无法满足查询计划中上层所需的属性。排序是我们反复看到的一个属性。如果您知道某个操作符的输入要求数据已排序,那么在应用Starburst中的规则并进行转换时,若违反了该属性,则这是一个无效的转换,不应执行。

相比之下,早期的System R则采用不同方法:它跟踪关心的排序,仍然运行基于成本的搜索过程,但不会因为不满足排序属性而使选择无效或丢弃,而是在之后回溯并评估:如果目前看到的最佳计划不满足我所需的排序属性,我在末尾添加一个排序操作的成本是多少?是否比另一个保持了该属性的计划更好?现在,我们要摒弃所有这些技巧。EXODUS仍会使用一些技巧,但Volcano将尝试将属性视为一等属性。

我们在学期初描述过这一点,现在再次提及,因为今天的所有内容都围绕这个核心理念:查询优化器是数据库系统中最难实现的组件之一。每次有人想构建新系统时,如果都必须重新实现查询优化器,那么构建新数据库系统的门槛就会提高。

优化器生成器的理念是将优化器的功能抽象出来,提供一个API,使得实现新数据系统的人可以向某个优化器生成器提供必要的输入。然后,这个生成器可以作为一组服务在侧运行,与您的现有系统集成,从而避免重复造轮子。

两个最著名的独立或模块化查询优化器是Calcite和Orca。严格来说,它们并非Volcano论文所定义的优化器生成器,因为Volcano的定义是关于编写规则,然后将其转换为过程代码并为您编译,最后链接到数据库系统中。而Calcite和Orca,您只需用Java实现其API并进行编译。这是否相同并不重要,因为您没有进行翻译。但核心理念是相同的:拥有一个独立的事物,可以为您的数据库系统生成优化器,无论数据模型是什么,这都是这些系统试图实现的关键。

实现这一点的很大一部分还在于将转换规则与逻辑操作符、物理操作符分离,让这些成为系统中的独立概念,而不是试图一次性混合所有内容。我们在Starburst中看到了一点区别,他们区分了星型和棒棒糖型。今天我们将在EXODUS和Volcano中再次看到这一点。

此外,用于确定可以将哪些规则应用于哪些操作符的机制,将与搜索策略独立开来。这样,如果有人想替换您正在实现的用于确定查询计划的任何分支定界搜索,他们可以在不重写所有内容的情况下做到。

我们首先讨论EXODUS,因为许多核心思想将被延续到Volcano中。但我个人非常喜欢你们阅读的Volcano论文,因为它在讨论自己先前系统的错误的同时,也与IBM的系统进行了比较和对比。在我看来,在Grayson(Grffy)撰写的关于他的三个优化器(EXODUS、Volcano和Cascades)的所有论文中,他写得最好的一篇是Volcano那篇。因为您可以借鉴它并实际重新实现很多东西。

Grayson Grffy是一位非常著名的数据库研究员,他在1980年代和1990年代致力于三个查询优化器,在近十年的时间里真正推动了他的优化器生成器理念,并为此奠定了基础。

EXODUS优化器

EXODUS优化器是威斯康星大学正在构建的名为Exodus的更大系统的一部分。与IBM的Starburst类似,它旨在成为一个可扩展的数据库系统项目,因为这是1980年代的热门话题,旨在回应当时出现的面向对象数据库。这些工具包和可扩展系统旨在证明,您实际上可以在关系数据库系统内部完成所有您想在对象上做的事情,而其中的一个重要部分就是提供一个可以扩展的查询优化器。

威斯康星大学通常是世界顶尖的数据库研究机构之一。David Dewitt是Jignesh的博士导师,显然也是Grffy的博士导师,他可能是世界上仅次于Stonebraker的第二著名的数据库研究员。

EXODUS将实现以下理念:将我们从解析器和绑定器输出的逻辑计划进行转换的逻辑,与最终生成可实际运行的物理执行计划的过程分离开来。同时,我们还将保持用于维护所有这些内容的内部数据结构的分离。

我们将避免System R所采用的穷举搜索,不是通过丢弃可能不想查看的计划(如浓密连接树),而是通过使用动态方法和分支定界剪枝来识别是否在树中之前见过此路径。尽管EXODUS在这方面做得不好,但Volcano会做得更好。其思想是,如果遍历树的一部分的成本永远不会超过目前所见的最佳查询计划的成本,则尝试避免那些不必要的计算。

EXODUS将做的一件很酷的事情是尝试学习规则的应用顺序以及规则对成本模型的影响,这是Volcano和Cascades不会做的,但我们将在学期后期看到。这意味着,如果我为某个查询运行查询优化器,并且看到某些规则的特定更改能更快或更早地带来益处,或者看到某些规则转换对我的查询计划有益,我会在下一次运行查询优化器时尝试优先考虑它们,因为我可能了解到这些做法会是好主意。

他的问题是,他们是如何做到这一点的?我们稍后会看到。但这基本上是在搜索策略内部完成的,是您无需在查询计划中修改的东西。这不是额外的规则。其思想是,您有一个成本函数来评估执行此查询操作符(物理操作符)的成本。我可以了解实际执行能带来多大改善。这一切都在查询优化过程中完成。所以,实际上并不是在我实际运行查询时,它是否真的产生了影响。我们将在学期后期看到,他们确实尝试去学习:在优化器中,我在查询计划中尝试了此规则,并获得了预期的收益;当我实际运行时,是否真的获得了该收益?然后通过反馈循环来判断这是好是坏。而EXODUS所做的只是在系统内部进行,您实际上并没有运行查询,只是说,根据我的目标函数和成本模型,应用此转换是否会带来收益?如果是,则未来尝试更快、更多地应用它。

与Starburst和System R一样,EXODUS将采用自底向上的方法。另一种表述方式是前向链接,因为那是80年代。我们稍后将解释Volcano和EXODUS在前向链接与后向链接之间的区别。

我很高兴您在这里。EXODUS中构建的第一个查询优化器的初始实现,他们知道会使用规则,因为那是80年代,人们在进行实验。他们认为可以直接使用基于逻辑编程的深奥编程语言(这是他们推广的一等概念),并在该编程语言中定义所有规则。然后,该语言将拥有自己的运行时,可以自动进行模式匹配、规则实例化、应用以及不同“事实”(在查询计划中)的扩展。

所以他们说,好吧,这是80年代,我们试试看。他们选择了Prolog。但后来他们谈到如何最终抛弃了所有内容,并用C语言重写了所有东西,因为他们发现该语言的运行时内置了深度优先搜索算法,无法真正根据规则应用的结果在运行时改变和操作其行为。就像您告诉Prolog:这是我所有的规则,事情不会改变,直接运行吧。此外,我认为他们有自己的Prolog解释器实现(在互联网普及、广泛可用版本出现之前的80年代),而那个东西对他们来说太慢了。他们抛弃了第一个实现,转向了C语言。

那么这里的教训是什么?不要使用深奥的语言。Prolog和Lisp是之前就存在的东西。我可能会认为,理论上您可以采用那种语言(如Prolog),然后将其重新编译成有用的东西,比如C,对吧?但他们没有这样做。他们实际上切换到了C。他们为系统的实际实现发明了自己的编程语言E,我认为它基于C。我认为Lisp来自哪里?Lisp是Dennis Ritchie发明的吗?我不确定。我认为E可能基于Dennis Ritchie的早期语言。无论如何,在Prolog中做这件事是个坏主意。再次强调,那是80年代,人们在做很多实验性的事情。你知道第一个Postgres是用什么编程语言写的吗?我在课堂上说过。Postgres最初是用什么写的?Lisp。没错。所以他们必须编写自己的转换器(transpiler)将Lisp转换为C,然后实际编译并运行系统。在80年代,人们尝试各种疯狂的事情,比如在伯克利,人们试图构建可以直接在硬件上运行Lisp的CPU。所以那是一个错误。他们删除了所有Lisp内容,并用C重写了所有东西。这就是我们今天拥有的代码库。

EXODUS的集成流程

那么流程是怎样的呢?数据库系统实现者(试图在其系统中集成EXODUS或Volcano查询优化器的人)需要提供一个模型描述文件,以及实现您告诉优化器您的系统将支持的不同操作符的C函数。显然,系统内置了一些如扫描、连接等基本操作符。但如果您想要超越他们已有的基本关系代数构造或操作符之外的东西,您也需要提供这些。

然后,您将其输入优化器生成器,优化器生成器将对其进行处理,输出一堆C代码,接着调用GCC或您喜欢的任何编译器和链接器,生成一个共享对象文件,然后您将其链接到数据库系统中。这被称为数据库系统生成时,将作为构建数据库系统实际软件的编译过程的一部分完成。

当系统实际部署后,SQL查询到达,我们像以前一样运行解析器和绑定器,这会产生一个逻辑计划。然后我们将其输入查询优化器,优化器执行我们现在要讨论的所有操作,最后输出一个物理计划。

其理念是,与其从头编写查询优化器并让其深深嵌入数据库系统中,不如将其作为一个独立的共享对象链接进去。它为您提供功能,从而避免了每个数据系统都必须重新实现自己的查询优化器。尽管这是1987年写的,但今天仍然基本适用。

Calcite和Orca试图提供类似的东西。但使用Calcite全部功能的系统很少,使用Orca的人主要是编写Orca的Pivotal员工,所以并未被广泛采用。

模型描述文件内容

模型描述文件中包含什么?首先是操作符定义:您必须告诉它参数数量、操作符名称以及该操作符处理数据所需的任何额外属性和期望。其次是访问方法定义:例如有哪些类型的索引可用,输出数据是否已排序等。

然后是用于转换查询计划的规则,以及用于映射哪些操作符可以访问上述定义的哪些访问方法的额外规则。这本质上是元数据,以结构化形式告诉优化器您提供的C代码实现了什么、参数需要什么数据类型等等。可以将其想象成Python或Java等编程语言中注解出现之前的东西。

规则类型:转换规则与实现规则

现在我们来谈谈规则。他们明确区分了转换规则和实现规则。可以这样理解:在Starburst的上下文中,星型操作符是执行高级功能的高级操作符类,但会有规则可以将该星型转换为另一个星型,或转换为棒棒糖型和一堆其他星型。他们总是称之为规则或转换规则。在EXODUS和Volcano中,他们将区分转换规则和实现规则。

需要说明的是,论文有时可能相当令人困惑,而教科书关于下一章(周三阅读的Cascades)的内容稍微清理了这一点。部分原因我不让你们阅读原始的Cascades论文,是因为他们提到“我要进行转换”,但该转换基于实现规则,然后还有表达式(我们稍后介绍)。他们使用的术语数量过多,在查询优化器的概念中含义容易混淆。

因此,即使您阅读论文时看到“当我们应用此实现规则时,它进行转换”,这和他们所说的转换规则含义不同。

转换规则是将逻辑操作符映射到另一个逻辑操作符的方法。在模型描述文件的定义中,还会有额外的提示,可以警告此规则是否可能陷入无限循环。解释点试图说明,如果我进行这种交换性操作,将连接顺序从AB改为BA,我不希望再次应用同一规则将BA变回AB。所以他们只是在这里设置了一个小标志,表示:当您应用此规则时,不要尝试在刚刚生成的输出上再次应用它。

实现规则则是将一个或多个逻辑操作符映射到一个或多个物理操作符。这是EXODUS与Volcano对比的一个关键区别。这更符合Starburst的做法,尽管Starburst的星型在某些方面比这更复杂。他们在这里的意思是,在EXODUS中,他们实际上会建议工程师或实际构建数据库系统的实现者,如果您知道某些规则总是按特定顺序连续执行(例如,您总是知道要将过滤器下推到连接下方,然后紧接着进行其他转换),那么不要将它们实现为单独的规则,而是在代码中将它们组合成一个单一规则。这样,搜索过程更高效,因为它不会花费时间应用规则,然后在表中查找下一个要应用的规则,再继续应用。

Volcano不会这样做。Volcano会说,最好让系统自动学习这些规则应该一个接一个地组合,而不是尝试合并它们,因为那只会增加实现的复杂性,使您更难推理在应用程序中所做的更改。同样,Starburst声称他们也想要这样,希望所有规则都是独立的,但比Volcano要做的更复杂。

另一件他们也能做的事情是支持双向规则,意味着您可以进行逻辑到物理的转换,然后也可能反向进行,从物理回到逻辑。Volcano不会这样做。

EXODUS的搜索算法

EXODUS中的搜索算法相当简单。他们确定下一个要应用的规则的方式,基本上是重新实现了Prolog中用于确定下一个应用规则的运行时。他们将维护一个名为“open”的优先队列(这是80年代AI中的术语)。这个优先队列将包含所有可以下一步应用的转换,它只是对将存在于称为“mesh”的哈希表中的转换的引用列表。该哈希表跟踪所有已应用的转换,以及他们已经执行的所有逻辑和物理操作符。然后,转换规则基本上是对这些操作符的引用,系统在mesh中查找这些引用,然后执行转换;如果它们不存在,则继续应用它们。

这是一个非常简单的循环:当还有更多转换要执行时,从队列中选择第一个,根据mesh中存在的内容应用其所有转换规则。然后,执行转换规则后,执行实现规则,将第二步中生成的所有逻辑操作符立即转换为物理操作符,并进行成本分析,以确定实际上哪个是最值得追求的。

因此,他们将在执行逻辑转换后立即急切地考虑和评估所有物理操作符。Volcano也会做一点类似的事情。Cascades不会这样做。Cascades将在遍历过程中急切地应用转换或将事物转换为物理操作符,而不是立即爆炸式地扩大搜索空间。

他们要做的一件关键事情实际上也很有趣,那就是在每一轮开始时,他们将尝试估计进行此转换将获得多少改进,然后根据他们认为哪个将带来最大收益来设定优先级。他们称之为预期成本因子。

因此,“open”是一个优先队列,其中还有其他东西跟踪您想要应用它们的优先级。然后,当我进行转换时,我希望能够查看它们并确定优先级队列中的顺序将基于此预期成本因子,该因子表示我认为哪个将对整个查询计划的总成本带来最大收益,然后首先应用它。

可以这样思考:如果我当前查询计划的总成本是某个值C,那么转换的预期成本将是这个因子F,它表示应用此转换对我的查询计划的影响以及这将如何影响成本。其思想是,如果F小于1,那么您知道您认为它将为您带来更大的收益,并且越接近零越好。

对于中性规则,例如,交换性不一定等于1,但某些规则(如投影下推)在您的特定系统中可能无关紧要,那将被视为中性规则。

因此,与其总是去成本模型询问“进行此转换我将获得多少收益?”,他们将尝试学习该收益将是什么,并应用他们所谓的传播调整。

这里的理念是,我想跟踪最近N次应用此转换规则的情况以及我实际从中获得了多少收益。他们有不同的公式和不同的计算方式,其中一种可以是取最近K次转换或尝试的滑动平均值,或者可以是所有转换的平均值,但一旦开始跟踪所有历史记录,在80年代这会变得非常昂贵。他们基本上说这并没有太大区别,但他们认为这是一个好主意,即跟踪并学习当我应用此转换时,未来实际获得了多少收益,这可以指导您是否有意义再次应用相同的事情。

他们没有详细说明如何考虑诸如数据变化、如何使此信息失效,或者查询参数变化时如何解释等问题。当然,您可以想象对于单个查询,如果您只关注该查询的范围,那么这些问题就不再是问题,因为我在优化这个查询,我再次应用此转换规则,看看实际获得了多少收益。但如果跨多个查询、在更长的时间范围内进行,这就变得有问题了。

他的问题是,在上下文中,他们不是在谈论实际运行查询。他们不这样做。他们只是试图表示,这是我认为收益将是什么的期望。但此时,我只有一堆逻辑操作符。所以我认为,如果我将您转换为哈希连接,那么我将获得这个收益。我估计那个提升将是多少。然后当我实际执行时,我运行我的真实成本模型,它会告诉我实际获得了多少收益。所以这是对其成本的近似,因为逻辑操作符没有成本。据我所知,他们将其范围限定在单个查询的上下文中以使其工作。但您可以想象,您可能希望将其扩展到多个查询。正如我之前所说,这只是在查询本身内部。没有关于我是否应用了此更改来实际使查询运行更快的内容,因为这实际上很难做到,因为如果我应用了某些更改并获得了一些收益,但我的最终查询甚至没有包含该更改,那么我实际上无法运行查询并为此获得任何反馈。

另一件要讨论的事情是,当他们应用更改并获得这种传播调整时,您实际上并不将其应用于刚刚应用的转换规则,而是应用于该规则和前一个规则。因为可能存在这样的情况:您应用的第一件事实际上使情况变得更糟,但这为您设置了某种状态,然后您应用到第二件事,现在您走出了搜索的“低谷”,情况变得好多了。所以他们硬编码规定传播调整应用于最后两个规则。应该是三个还是四个?他们没说。这有点技巧性,方向是对的,但我不认为这是正确的方法。但再次强调,他们在80年代就思考了“我能从过去的经验中学习吗?”这个理念,而现在这在强化学习AI和其他事物中被视为理所当然。

EXODUS的一个重大缺陷

EXODUS的一个重大缺陷是,他们表示所有当前已生成的操作符(通过这些转换规则在mesh哈希表中)的方式,他们进行查找的条目或键将是逻辑操作符和通过实现规则转换到的物理操作符的组合。

这意味着,如果我有一个逻辑操作符可以转换为排序合并连接,而同一个逻辑操作符可以转换为哈希连接,我将在我的mesh哈希表中拥有两个不同的条目来表示它们。

这清楚吗?因为Volcano将做不同的事情。Cascades当然也会做非常不同的事情。所以,基本上,现在您在这个mesh哈希表中拥有所有这些重复的逻辑操作符,因为它们无法在从一个逻辑操作符产生多个物理操作符之间建立联系。

关于添加可能要求特定属性的额外操作符的所有逻辑,必须放入成本函数中。您可以有这样一个概念:操作符期望数据以某种方式出现,但如果您想推动查询计划确保满足或强制执行这些条件,那么该逻辑就必须存在于成本模型本身中。正如我所说,EXODUS应用逻辑转换规则后,立即应用所有实现规则,基本上爆炸式地扩大了搜索空间,即使实现规则中的某些转换可能实际上并不是好主意。

他们在论文中还谈到了其他事情(我认为是在Volcano论文或EXODUS论文中),因为又是80年代,他们想使用A搜索算法(如果您熟悉的话),但他们无法以A算法所需的方式计算实现规则的预期收益或改进的承诺。因为您不能有来回振荡的情况,它必须是一个清晰的阶跃函数。

过渡到Volcano

这现在将我们带入Volcano。Grayson Grffy从威斯康星大学毕业,然后我想他曾在科罗拉多大学任教,后来成为波特兰州立大学的教授。我不清楚具体细节。但他多年来一直在真正推动这个优化器生成器的理念,这是真正开创性的工作。

Volcano项目与EXODUS类似,旨在成为一个可扩展的数据库系统工具包。当我们在并行算法中讨论交换操作符时,我们有时会提到那是Volcano方法,这些都来自他为这个职业应用程序构建的同一个项目。

因此,与EXODUS一样,它旨在成为一个灵活的系统,您可以添加新的操作和等效规则。再次强调,他们试图在规划过程中将数据属性视为一等实体,他们将比EXODUS做得更好一点,但我们将在下节课看到Cascades正确地处理了所有极端情况。

他们还将采用与EXODUS中相同的规则编译流程:您用更高级别的元数据定义这些规则,同时为您尝试添加的不同操作或操作符提供C实现,然后他们将其编译成共享对象,您再将其链接到数据库系统中。

Volcano的不同之处

与EXODUS和Starburst不同,Volcano现在将首次采用自顶向下的搜索来生成查询计划,或称为后向链接。他们将基于分支定界搜索进行。

他们现在还要尝试做的一件大事(EXODUS也能做)是,他们希望能够生成所有这些不同的排列或应用规则并开始进行更改,然后不必重复并反复重新访问事物。因为EXODUS中维护的数据结构方式不允许他们跟踪事物。

原始源代码从未在学术界之外使用。我认为EXODUS在90年代被德克萨斯仪器公司用于一个原型面向对象数据库系统,但除此之外,我认为它没有走得更远。而Calcite据称(至少在他们论文中声称)是基于此实现的。

有趣的是,看看他们在Volcano中实际试图实现什么EXODUS无法做到的事情。我还要说,如果您听说过“第二系统综合症”这个术语,它来自IBM。基本上,第二系统综合症是指,如果您参与了一个构建一个新系统的项目,然后您说,好吧,让我们构建下一个,进行重写。当您构建第二个系统时,您试图纠正您在第一个系统中做错的所有事情。但然后您说,既然我已经在重写系统了,让我添加这个功能和那个功能,让我们做这个疯狂的事情,您开始在这个第二个系统中添加一堆新东西。然后第二个系统从未建成。所以我必须赞扬他,Volcano是他的第二个系统,他完成了它,遵循了他的目标,并且实现得相当不错。

Volcano的目标

因此,与之前一样,他们希望与现有的数据库系统协同工作,希望比EXODUS更具计算和存储效率,这样您就不会让这个mesh表爆炸式地包含所有您以前见过的可能事物,或者不必提前生成事物。

他们希望拥有可扩展的物理属性,超越排序和压缩,您希望能够更轻松地指定这些内容,并再次将它们作为一等属性来支持。他们希望能够允许您重写搜索以及在遍历过程中确定如何剪枝分支的能力。我认为除了他们实现的深度优先搜索算法之外,没有人超越这一点。论文谈到,如果我们将来能做到这一点就好了。

我们现在不会过多讨论这一点,因为我们将在学期后期介绍,但他们希望成本模型能够支持所谓的参数化查询,即查询计划中可能实际上没有谓词或WHERE子句中使用的所有常量值。例如,SELECT * FROM table WHERE id = 1,2,3。如果您将其声明为预处理语句,您会说SELECT * FROM table WHERE id = ? 或 $1。其理念是,在运行时,当应用程序实际调用该查询时,他们将以其预处理语句的形式调用,然后将值传递给该参数。理想情况下,您希望能够在被告知“准备语句”后立即生成查询计划,然后在稍后的运行时填入该值,而不是再次运行整个优化器。论文没有过多讨论他们如何支持这一点。实际上,如果您尝试适应查询计划的结构可能根据作为参数传递的那些值而发生很大变化,这将非常具有挑战性。我们将花一整天讨论如何处理这个问题,但这是另一个重大挑战。

我认为他们具有基本的能力来保留占位符,并且您实现成本模型时说,嘿,我不知道这个值是什么,让我只选择最坏情况或平均值,这就是他们所做的全部。

Volcano的实现

在实现本身中,他们现在将引入这个理念:他们有两个不同的代数,一个用于逻辑表达式,一个用于物理表达式。这回到了我之前所说的,他们在论文中重载了一堆术语来表示非常具体的事物。Cascades会更糟。但他们基本上引入了“表达式”这个术语。它基本上就是我们之前在Starburst的QGM(查询图模型)中看到的查询框,表示某个完整的SELECT操作及其FROM子句等。

所有规则将是独立的。与EXODUS中他们说“如果您知道这些规则应该一个接一个地应用,请在您的实现中将它们组合在一起”不同,在Volcano中,他们将建议或劝告您不要这样做,而让查询优化器或搜索引擎自行弄清楚这些规则应该一个接一个地应用,而不是您必须手动强制执行。我认为您仍然可以这样做,或者模式匹配不支持,但最好让优化器自动学习这一点,因为再次强调,您在说“将这两个规则组合在一起”时对数据所做的任何假设,将来可能会被打破或不成立,所以如果这些规则只是小的原子单元,我们会将它们组合在一起。

他们还讨论了他们内部关于是否继续采用EXODUS的方法(让我在系统生成时编译所有内容,将所有规则编译成C代码,然后编译成机器代码,因为这将实现更快的搜索)的辩论。与之相对的是使用解释器,这是Starburst开始做的,以便您可以在查询优化器运行时动态更改其行为,而无需重新编译和重新链接所有内容。这种灵活性很好。我认为当时他们说,至少在当时EXODUS论文中,唯一这样做的是Starburst,而Starburst使用的是解释。所以在这篇Volcano论文中,他们说,好吧,既然您看到了Starburst,他们做的是解释,而我们做的是EXODUS编译,我们认为拥有更快的搜索更好,所以他们将坚持编译方法。在Postgres中,您可以安装新规则,因为您只需将其插入到目录表(如pg_rules)中,并且这些东西知道如何在运行时查看。但他们并不试图以Volcano的方式进行完整的查询优化。

什么是表达式?

那么什么是表达式?再次强调,这只是他们用来定义您在查询优化器中试图推理的某个作用域查询的术语。对于逻辑表达式,它只是表示用户请求的查询的表示,定义为逻辑操作符及其之间的依赖关系。

物理操作符是相同的东西,是查询计划的表示,但通过物理操作符,您可以实际在数据库系统中运行此查询计划,假设它知道如何读取它们。

我想指出的关键一点是,在所有这些早期查询优化器中,它们都假设它们操作的是树。我们稍后会从德国人那里看到,您实际上想使用有向无环图,其中您可能希望一个操作符有两个父节点。而在所有这些树结构中,总是只有一个父节点。我想这样做的原因是,如果您想开始物化中间结果,比如CTE或嵌套查询,您可能有查询计划的不同部分需要将数据输入其中。您可以像Postgres那样作弊,基本上有一个引用来表示:我知道我的输入是在查询计划另一部分定义的子查询。但当涉及到进行更复杂的转换时(他们在这里没有做),我们会从德国人那里看到,您希望将事物表示为DAG。这只是现在的题外话。

操作符与属性

因此,这一切应该与之前看到的相同:您有这些逻辑操作符,它们具有不同的属性、模式、基数,还有一个属性函数来表示此事物是否提供该属性。对于物理操作符,同样的事情:它们有自己关心的属性类型,它们有一个成本函数,因为现在您可以说,如果我在查询计划中运行此物理操作符,预期的成本是多少。而在逻辑操作符中,没有成本:扫描的成本是多少?这取决于我是进行顺序扫描还是索引扫描,您无法在那里做到。

然后会有这个适用性函数的概念,它将表示此物理操作符是否将提供或满足来自其所源自的逻辑操作符定义的物理操作符。

现在,他们还将引入这些强制器操作符,它们类似于虚拟或占位符操作符,并不真正映射到我们实际执行的任何东西。这只是一种在我们的查询计划中设置屏障的方式,表示我们需要通过查询树的这部分向上馈送的数据提供我们关心的某些属性,比如排序顺序。再次强调,强制器基本上等同于Starburst中的粘合操作符,只是一种在我的查询计划中检查时跟踪的方式,或者通过该树中的流或边上来的数据是否将是我需要的形式。

因此,如果您将Volcano与数据库系统集成,您必须再次提供:这是我的强制器,以及它们将处理的属性。我认为,如果您只是说我需要一个可以保证这些属性的强制器,他们会为您合成该代码。因为强制器实际上不做任何事情,它不获取任何数据,它只是作为一个占位符存在,表示我需要数据以这种形式呈现。

Volcano中的规则

Volcano中的规则基本上与EXODUS中的相同,但用于具有某种匹配函数,然后查看查询计划的形式,如果匹配,则触发一个操作函数来执行转换,对吧?然后,为了避免必须为所有可能的逻辑计划或物理计划组合定义规则,您可以参数化这些匹配函数,例如,我有一个连接,我的哈希连接,然后在我下面可能是一个扫描操作符,我不关心是索引扫描还是顺序扫描,您不必为这些事物的所有可能组合定义不同的规则。

他们有这个称为辅助函数或支持函数的东西,我的理解是,这些只是在基本模式匹配完成对查询计划形式或结构的检查后,如果您想检查操作符本身内部的更多信息,那么您可以附加这些支持函数,然后在模式匹配后触发它们。因此,模式匹配是在这个高级方言中完成的(我认为论文没有说是什么DSL),但如果您实际上想通过过程代码明确检查某些内容,那么您提供这个额外的函数。

成本函数在实现中也抽象出来了。再次强调,这是您作为数据系统实现者必须提供的东西。可以这样想:在面向对象模型中,您会有一个抽象的成本类。然后,如果您重载或覆盖它,您必须提供如加运算符、等于运算符、小于、大于等。所以,没有任何内置的关于成本实际是什么的概念,它不是基于CPU或Volcano知道的任何其他东西。这是您必须提供的东西。再次强调,它不使用我们在EXODUS中看到的预期成本因子或传播调整,他们完全放弃了那个。我不认为有人提到它。

Volcano的搜索算法

那么搜索算法是什么?同样,规则的设置看起来基本相同,属性比EXODUS中的更细致,现在我们有了这些明确的强制器操作符,可以保证数据将以我们想要的形式出现。

搜索将使用自顶向下的方法进行后向链接。可以这样理解:这是一种面向目标的搜索,意味着我将从想要的答案开始,即查询本身的最终结果,然后我将向下遍历并开始填充将使我达到该点的物理操作符。

与EXODUS中他们有一个哈希表来跟踪在应用这些规则时生成的这些操作符一样,他们将拥有相同的东西。他们没有给出明确的名称,我认为在论文的某个地方他们称之为查找表。在Cascades的工作中,您会看到这被称为备忘录表,但它基本上应用了相同的理念。但Cascades中的备忘录表比这个更复杂。

现在,在我的搜索算法的每一轮中,当我在查询计划的树中的某个点检查逻辑操作符时,我会查看是否应用转换,该转换的结果是否已经存在于我的查找表中?如果是,那么我不想重新应用它;如果否,那么我应用它,然后填充它,以便下次如果我尝试再次应用它时,我不会浪费周期做同样的事情。

这确实允许我们能够重用我们在单个查询上下文中生成的成本计算,因为数据没有演变,不是在很长的时间范围内,我们可以重用我们已经产生的任何计算,而不是重新分析向下的不同子树。

而EXODUS无法做到这一点。

再次强调,自顶向下与自底向上,或者后向链接与前向链接,在高层面上,这两个术语基本相同。但为了澄清,后向链接基本上是说您将从根开始。它实际上是用逻辑来定义的:我有这些事实,我将扫描艺术家表,我将扫描出现表。然后我可以应用规则,对于这些基本操作或基本访问方法,我可以应用规则来不断生成不同的选择,然后在每一层我找出哪个是最好的,保留那些,然后我遍历并查看所有下一个规则,依此类推。所以我最终在顶部得到我的目标。

我们在System R中以前看到过这个。在后向链接中,理念是当您从根开始时,我希望我的答案是艺术家与出现表连接的查询输出。所以我首先向下走一层,说在最初我只有一个选择,比如从这个逻辑操作符进行新的转换,我可以产生一个物理操作符。然后在该分支内,该物理操作符现在向下说,好吧,访问艺术家的最佳方式是什么?向下走另一侧,获取访问出现表的最佳方式。现在在这一点上,我有了一个可以实际执行的完整查询计划。可能有很多我甚至没有考虑的事情。

我可以比前向链接更快地获得初始查询计划。它可能不是更好,但我肯定可以更快地获得初始计划。

他的问题是,目标是更快地获得计划吗?不一定。这取决于查询的简单程度,您可能能够更快地找到它。但然后,这个东西也会快速遍历它。关于Volcano的事情是,与EXODUS相比,它只会探索实际能为表达式做出贡献的计划部分的子查询。所以,就像它可能决定……我知道Cascades是什么,我知道Volcano是什么,界限变得有点模糊。所以我可能看到一些实际上是Cascades中而不是这个里面的东西。但请容忍我。理念是,当我向下遍历时,我可以为它们设置优先级,并说这个选择可能更好,让我只检查这个,甚至不用费心看另一个。这有点像EXODUS中的预期成本调整或优先级调整部分。我认为这实际上是Cascades,而不是这个。我所描述的是Cascades。是的,所以忽略我所说的,下节课再讲。

因此,在这种情况下,在Volcano中,他们实际上会探索所有这些。没有指导。Volcano中没有指导。谢谢,是的。有三个需要跟踪。

Volcano示例

让我们看看Volcano的这个例子。谢谢。同样,我们从最开始开始。这是我们之前的查询。我们想要获取所有出现在Mushu致敬专辑上的艺术家,然后按艺术家ID排序,对吧?所以那个红色的小块,那是我们需要强制执行的属性,对吧?从这一点开始,我们将应用可用的不同规则,然后向下展开搜索树,开始填充我们实际需要的部分。

一个简单的转换可能是,虽然这将来自逻辑查询计划,我们知道我们需要以某种方式对前两个表进行排序。然后我们需要对所有三个表一起排序。再次强调,因为这是PowerPoint,我没有展示所有可能的组合,您可以想象所有不同的逻辑连接顺序以及物理连接顺序也会出现在这里。

我从最顶部开始,说,好吧,我想在下面产生一个物理操作符,它将给我艺术家连接出现连接专辑的输出,并且该输出必须按艺术家ID排序。所以我会应用所有逻辑到物理的转换,将三路连接转换为连接实现:我可以在连接结果A1 A2上进行哈希连接,或者在连接结果A1 A2与A3上进行排序合并连接,或者我可以引入一个强制器操作符,它只是说,好吧,我仍然不知道您将如何连接这个,但我保证无论从我这里出来的数据都将按您想要排序的东西排序。

那么我们可以做一个……您知道,我们可以丢弃这个哈希连接,您实际上甚至不会为此生成物理操作符,因为它会违反这里的排序顺序。所以是的,也许我只是想展示它的存在,但您实际上不会扩展这个,因为它会说这不提供我所需排序顺序的数据。所以我们直接忽略哈希连接。但在这一点上,我作弊说,不是合并连接,而是排序合并连接,所以排序内置于操作符中。但这将保证我的输出。然后这个强制器,我不知道下面实际上是什么,但我知道它将保证我的数据实际上是排序的。

是的。所以这个艺术家ID。就像这里上面的那个东西。就像我没有这里的SQL查询,但上节课应该有。就像它是一个带有ORDER BY子句是artistId的SELECT查询。所以,再次强调,这是在PowerPoint中完成的,但您会……绑定器会知道您引用的是某个列,并能够将其映射到A1、A2、A3,或者艺术家、专辑、出现,或者无论什么,就像在顶部我知道我希望我的数据在馈送给我的某个表的某个列上排序。

还有很多规则的东西是不完整的。这是非常不完整的。这是PowerPoint,是的,这是PowerPoint动画,它们不是,是的。有更多的规则。

所以它的右侧我提到了我们连接所有表并排序,就像我们先对艺术家表排序。您的问题是,抱歉,这个在这里?所以我们排序然后……是的,所以您知道,也许应该是不同的颜色,也许是红色什么的。所以它不是物理操作符,因为它实际上不做任何事情。它是一个占位符,表示在向下遍历树时,

005:查询优化-CMU-15-799--(2025-年春季)-p05-P05-#05---Cascades-查询优化器-(CMU-Optimize!)

在本节课中,我们将深入学习Cascades查询优化器。Cascades是Goetz Graefe在20世纪90年代开发的第三个查询优化器,它建立在Volcano优化器的基础上,并引入了许多关键改进,如任务调度、承诺机制以及将强制属性(如排序)作为一等规则处理。我们将详细探讨其设计理念、核心组件和工作流程。

概述:Cascades优化器简介

Cascades查询优化器是Volcano优化器的演进版本,采用了一种更面向对象和统一的方法。与Volcano将逻辑转换和物理转换分为两个阶段不同,Cascades将优化过程分解为细粒度的任务,并通过一个集中的任务栈进行调度。它引入了“承诺”机制来指导规则应用的顺序,并将属性强制执行(如排序)完全整合到规则系统中,而不是作为特殊的虚拟操作符。这些设计使得Cascades更加灵活,能够更有效地探索查询计划空间。

核心概念澄清:Volcano与Cascades的对比

在深入Cascades之前,我们需要澄清Volcano优化器中的两个关键概念:优化阶段和强制操作符。这有助于我们理解Cascades所做的设计决策。

上一节我们介绍了Cascades的概况,本节中我们来看看它与前身Volcano的关键区别。

Volcano的优化阶段

Volcano虽然被视为一个统一的优化框架,但它仍然将逻辑转换和物理转换分为两个独立的阶段。

  • 生成阶段:这是一个自顶向下的搜索过程。优化器遍历查询计划树,对遇到的每个逻辑操作符应用所有可能的逻辑转换规则,从而穷举出所有可能的逻辑表达式变体。它使用一个哈希表(Memo表的早期版本)来记录已见过的逻辑表达式,避免重复探索。
  • 成本分析阶段:在生成所有逻辑可能性后,优化器再次进行自顶向下的搜索。这次,它尝试为查询树每一层的逻辑操作符选择成本最低的物理实现操作符。

Cascades则不同,它不会预先穷举所有逻辑表达式。相反,它根据规则和“承诺”值,按需地、受控地应用规则来扩展逻辑表达式。

Volcano中的强制操作符

在Volcano中,强制操作符是一种特殊的虚拟物理操作符,用于保证其输出数据满足上层查询计划所需的属性(例如特定的排序顺序)。

其工作流程是:在生成阶段,当需要某个属性时,就在计划树中插入一个强制操作符。在成本分析阶段,当将逻辑操作符转换为物理操作符时,如果下层输入无法提供所需属性,优化器就知道需要将这个强制操作符转换为一个具体的物理操作(如快速排序算法)来强制执行该属性。

Cascades摒弃了这种特殊的强制操作符。它将属性要求完全整合到规则系统中。规则在将逻辑表达式转换为物理表达式时,会检查所需的属性是否得到满足。这使属性强制执行成为规则系统中的一等公民,设计上更加统一和清晰。

Cascades的设计理念与核心组件 🧩

了解了与Volcano的区别后,我们现在聚焦于Cascades自身的设计。Cascades的核心思想是将优化过程分解为可调度的小任务,并统一处理逻辑和物理表达式。

关键设计思想

Cascades相较于Volcano有几个关键创新:

  1. 任务作为一等公民:优化过程被分解为独立的“任务”数据结构,而不是隐藏在规则触发机制中。这使得任务调度和并行执行成为可能。
  2. 统一的规则与操作符表示:逻辑操作符、物理操作符甚至谓词树都在同一个搜索空间和表示框架内。规则可以混合应用于逻辑和物理转换,提供了更大的灵活性。
  3. 承诺机制:这是一个与成本函数解耦的启发式值,用于估计应用某个规则可能带来的收益,从而动态调整任务执行的优先级,更快地导向有希望的查询计划。
  4. 规则化的属性强制执行:如前所述,属性要求通过规则来保证,而非特殊的操作符。

表达式与组

Cascades使用两个核心概念来组织查询计划:

  • 表达式:表示一个具有零个或多个输入的操作。它可以是逻辑表达式或物理表达式。系统需要能够判断两个表达式(无论是逻辑-逻辑、物理-物理还是逻辑-物理)是否等价。
  • :这是等价表达式的容器。一个组定义了一个逻辑输出(即它产生什么数据),并包含一组能产生该输出的等价逻辑表达式,以及另一组能产生该输出的等价物理表达式。组用花括号 {} 表示,作为查询计划树中的占位符,避免了像Exodus那样冗余存储所有可能的子计划组合。

公式/代码表示
一个组可以非正式地表示为:
Group(LogicalOutput) = { Set<LogicalExpr>, Set<PhysicalExpr> }
其中,每个表达式中的子节点引用的是其他组。

规则

规则是执行转换的基本单元,分为两类:

  • 转换规则:将一个逻辑表达式转换为另一个逻辑表达式(例如,交换连接顺序)。
  • 实现规则:将一个逻辑表达式转换为一个物理表达式(例如,将逻辑连接转换为哈希连接)。

每条规则由一个模式匹配函数和一个转换函数组成。规则模式中可以使用组作为通配符,以匹配更广泛的查询计划结构。

任务与承诺

  • 任务:是优化过程中的细粒度工作单元,例如“优化一个组”或“探索一个表达式”。任务被放入一个栈(任务队列)中,由优化器调度执行。
  • 承诺:是一个与成本解耦的启发式数值,用于评估应用某个规则可能带来的相对收益。承诺值高的任务会被优先调度。例如,如果知道表B(10,000行)远小于表A(1亿行),那么“将A JOIN B交换为B JOIN A”这条规则的承诺值就会设得很高,因为让小表作为外连接表通常是好的起点。

代码表示(概念性):

class Task:
    def execute(self): pass

class ApplyRuleTask(Task):
    def __init__(self, rule, group, promise):
        self.rule = rule
        self.group = group
        self.promise = promise # 承诺值指导调度优先级

Memo表

Memo表是一个全局数据结构,用于记录优化过程中的所有状态,以避免重复工作。它跟踪:

  • 所有已探索的组和表达式。
  • 每个组中当前找到的最佳物理表达式及其成本。
  • 已对哪些组应用过哪些规则(通过位图记录),防止循环应用。

Memo表是实现贝尔曼最优性原理的关键。该原理指出:一个全局最优计划必然由它的局部最优子计划构成。因此,如果在构建计划时,某个子组的成本已经使得总成本超过了当前找到的最佳全局成本,那么就可以剪枝掉这个分支,无需进一步探索。

Cascades优化流程详解 ⚙️

现在我们已经了解了所有核心组件,本节中我们来看看它们是如何协同工作来完成一次优化过程的。

Cascades的优化是一个受任务驱动的、自顶向下的深度优先搜索过程。整个过程从一个仅包含目标输出逻辑定义的根组开始。

以下是优化流程的核心步骤:

  1. 初始化:解析SQL查询,生成顶层的逻辑输出组(根组)。
  2. 任务调度循环
    • 优化器从任务栈中取出承诺值最高的任务执行。
    • 任务类型包括:
      • 优化组:为该组寻找最佳物理计划。
      • 探索组:对该组应用逻辑转换规则,生成新的逻辑表达式(可能创建新组)。
      • 优化表达式:将特定逻辑表达式转换为物理表达式并计算成本。
      • 探索表达式:对特定表达式应用逻辑规则。
  3. 规则应用与反馈:当任务应用一条规则后,可能会产生新的逻辑或物理表达式。这些新表达式会被加入相应的组。同时,可能产生新的优化或探索任务,并被压入任务栈。
  4. 成本计算与剪枝:每当为一个组找到新的物理表达式时,就计算其成本。Memo表会更新该组的最佳成本。如果当前探索路径的累计成本已超过全局最佳成本,则进行剪枝。
  5. 递归深入:当优化一个涉及子组的表达式时(如连接),会创建任务来优化其输入组,从而递归地深入查询树。
  6. 终止:当任务栈为空,或达到资源限制(如转换次数上限)时,优化过程终止。根组中成本最低的物理表达式即为最终查询计划。

示例流程(简化):
对于一个查询 (A JOIN B) JOIN C

  1. 从根组(输出为Join(AB, C))开始。
  2. 探索任务应用逻辑规则,生成逻辑表达式 Join( Join(A, B), C)
  3. 优化任务决定先深入探索如何获取A和B。它创建子组Group(A)Group(B)
  4. Group(A)中,应用实现规则,得到SeqScan(A)(成本10)和IndexScan(A)(成本15)。Memo记录最佳方案为SeqScan(A),成本10。
  5. 同理,Group(B)的最佳方案为SeqScan(B),成本20。
  6. 回到Group(AB),现在可以应用实现规则来计算HashJoin(SeqScan(A), SeqScan(B))的成本(例如成本=10+20+Join开销)。
  7. 同时,也可能应用逻辑转换规则生成Join(B, A),但由于Memo已记录子组成本,可以快速计算其物理实现成本。
  8. 优化器比较不同连接顺序和算法的成本,选出Group(AB)的最佳方案。
  9. 最后优化Group(ABC),考虑与C的连接,完成整个计划成本计算。

这个流程展示了Cascades如何按需、增量地探索计划空间,并通过Memo表和承诺值提高效率。

实际系统中的应用与变体 🏢

Cascades的设计理念影响深远,许多现代数据库系统都实现了自己的变体。本节中我们来看看几个重要的实例。

Microsoft SQL Server

SQL Server拥有最早且最著名的Cascades实现之一。其实践中包含了多项工程优化:

  • 多阶段优化:将优化过程分为简化/规范化、准备、探索(多阶段)等。每个阶段启用不同的规则集,类似于Starburst的分层策略,但仍在Cascades框架内。
  • 基于转换次数的终止:为避免系统负载影响优化质量,设置一个转换次数上限而非时间上限。
  • 记忆化表预热:使用启发式方法预填充Memo表,避免对明显最优的路径(如主键查找)进行重复探索。
  • 过程式代码:规则直接用C++编写,而非使用特定领域语言。

Orca (Greenplum)

Orca是一个独立于执行引擎的Cascades优化器服务。

  • 多线程搜索:是少数真正实现并行化搜索的Cascades变体之一,利用任务队列和共享Memo表实现。
  • 服务化架构:通过XML接口接收查询和元数据,返回物理计划,便于跨不同系统(如Greenplum和HAWQ)使用。

CockroachDB

CockroachDB的优化器采用Cascades框架,但有其特色:

  • 自定义DSL:定义了一种名为Optgen的领域特定语言来声明式地编写规则,然后编译成Go代码,以提高性能。
  • 回退机制:对于无法用DSL表达的复杂转换,允许使用过程式Go代码作为补充。

其他系统

如Snowflake、Databricks等系统也采用了受Cascades启发的优化器,并可能结合数据湖环境的特点,进行延迟物化或自适应优化。

总结

本节课我们一起深入学习了Cascades查询优化器。我们从回顾Volcano优化器的阶段和强制操作符开始,明确了Cascades的设计起点。然后,我们系统地探讨了Cascades的核心组件:表达式的结构、规则的类型、作为调度单元的任务、指导搜索方向的承诺机制以及记录全局状态的Memo表

接着,我们分析了Cascades的优化流程,这是一个由任务驱动、自顶向下、按需展开的搜索过程,通过成本计算和贝尔曼原理进行剪枝。最后,我们浏览了Cascades在Microsoft SQL Server、Orca、CockroachDB等实际系统中的实现和变体,了解了工业界如何根据自身需求调整和发展这一框架。

Cascades通过其统一、灵活、任务驱动的设计,为构建高效的查询优化器提供了一个强大的蓝图,其影响持续至今。

006:查询计划转换

概述

在本节课中,我们将学习查询优化器的核心组成部分之一:查询计划转换。我们将探讨如何通过应用一系列转换规则,将初始的查询计划重写为语义等价但执行效率可能更高的形式。理解这些转换是构建高效优化器的关键。


查询计划转换简介

上一节我们介绍了Cascades等优化器框架的整体架构。本节中,我们来看看如何在这些框架中实现具体的优化逻辑,即查询计划转换。

转换规则是优化器的“武器库”,它允许我们将一个查询计划改变为另一种形式,同时保证结果的正确性。转换的目标是最终获得一个执行成本更低的计划。有时,一个转换本身可能不会立即降低成本,但它能“解锁”后续一系列更有效的转换。

一个转换规则的核心在于,它基于我们对关系代数等价性、数据库模式(如约束、外键)以及数据本身(如选择性、大小)的了解。


访问路径选择

在考虑如何连接表之前,我们首先需要决定如何访问单个表(即基关系)。目标是选择成本最低的数据访问方法。

影响选择的因素包括:

  • 谓词的选择性。
  • 访问的列是否在索引中。
  • 表或索引的排序是否有助于后续操作。
  • 是否存在包含列(INCLUDE clause)的索引。
  • 数据的编码/压缩方式。

包含列索引示例

CREATE INDEX idx_food ON food(A, B) INCLUDE (C);

对于查询 SELECT B FROM food WHERE A=123 AND C='value',优化器可以仅通过扫描索引 idx_food 的叶子节点来获取所有需要的列(A, B, C),而无需回表查找原始元组,这被称为“覆盖索引扫描”。

以下是常见的访问方法分类:

  • 表扫描(Table Scan):顺序读取表的所有数据。在没有合适索引或表很小时,这是默认选择。
  • 索引查找(Index Seek):使用索引精确查找满足等值条件的元组。
  • 索引扫描(Index Scan):在索引上进行范围扫描。
  • 索引键查找(Index Key Lookup):仅通过索引检查某个键是否存在,不获取数据。

选择过程是一个“资格赛”:首先排除所有不适用于当前查询的访问方法(例如,谓词引用了未索引的列),然后在剩余的选项中估算成本并选择最佳者。

示例:对于查询 SELECT A, B FROM S WHERE S.A > 10 AND S.B = 20,假设表S上有两个索引:

  1. idx_a:索引键为 (A),包含列 (C, B)
  2. idx_b:索引键为 (B),包含列 (C, A)

优化器会考虑三种物理操作符:

  1. S 进行表扫描,然后过滤。
  2. idx_a 上进行索引扫描(范围扫描 A > 10),然后过滤 B = 20。由于 B 是包含列,可能无需回表。
  3. idx_b 上进行索引查找(等值查找 B = 20),然后过滤 A > 10。由于 A 是包含列,可能无需回表。

成本模型将最终决定哪个计划最好。

有时,组合多个索引访问可能更优。例如,如果 A > 10B = 20 各自的选择性都不高,但组合起来很高,那么先通过两个索引分别获取满足各自条件的行ID(ROWID),取交集,再根据交集后的ROWID批量回表获取完整数据,可能比单独使用一个索引再回表过滤更快。在逻辑上,这可以看作同一个表与自身的连接(自连接)。


连接排序与交换

决定了如何访问单个表后,接下来要处理表之间的连接。对于内连接,改变连接顺序(join ordering)是影响性能最重要的优化之一。目标是为查询生成所有(或部分)有潜力的连接顺序。

在基于转换的优化器(如Cascades)中,直接穷举所有连接顺序会产生巨大的搜索空间(复杂度约 O(3^n) 到 O(4^n))。为了避免重复探索和无效转换,我们需要管理转换规则的应用。

一种方法是为每个操作符维护一个位图(bit set),记录哪些转换规则已经应用于它,以及哪些规则被禁止再次应用。这可以避免陷入无限循环(例如,连续应用交换律又交换回来)。

以下是连接枚举中的基本转换规则:

  1. 交换律(Commutativity)(X JOIN Y) -> (Y JOIN X)
  2. 左结合律(Left Associativity)(X JOIN Y) JOIN Z -> X JOIN (Y JOIN Z)
  3. 右结合律(Right Associativity)X JOIN (Y JOIN Z) -> (X JOIN Y) JOIN Z
  4. 左右交换(Left-Right Exchange):在复杂连接树中交换左右子树的位置。

应用一条规则后,会在新生成的操作符上禁用可能导致循环的规则。例如,应用了交换律后,通常会在该连接操作符上禁用再次应用交换律。


谓词下推与上拉

这是另一个关键的转换。其核心思想是:尽可能早地过滤数据,减少后续操作(如连接)需要处理的数据量。

谓词下推(Pushdown):如果一个过滤谓词只引用单个表的列,那么可以将其下推到该表的扫描操作之后、连接操作之前执行。
谓词上拉(Pullup):有时,虽然谓词可以下推,但其本身计算代价高昂(例如调用一个复杂函数)。此时,将其保持在连接之后执行可能更好,因为连接可能已经过滤了大量数据。

优化器在绑定(Binding)阶段会将WHERE子句中的复合谓词拆分为独立的单元,以便独立地移动它们。

示例:对于查询 SELECT * FROM R JOIN S ON R.id = S.rid WHERE R.a > 10,谓词 R.a > 10 可以下推到 R 表的扫描之后,这样参与连接的就是过滤后的、更小的 R 数据集。


连接算法选择

除了逻辑顺序,我们还需要为每个连接操作选择具体的物理算法。选择取决于数据特性和谓词类型:

  • 哈希连接(Hash Join):适用于等值连接,且内存足以容纳构建侧(build side)数据。
  • 归并连接(Merge Join):适用于等值或范围连接,且两侧输入数据已在连接键上排序。
  • 嵌套循环连接(Nested Loop Join):通用但通常较慢,可作为保底选择,尤其适用于非等值连接或一侧数据量极小时。

优化器在选择算法时,可能还会根据成本估算设置运行时参数,例如哈希表的大小。


外连接转换为内连接

外连接(LEFT/RIGHT OUTER JOIN)的优化比内连接更复杂,因为不是所有连接顺序的交换都保持语义正确。

一个重要的优化机会是:将外连接转换为内连接。如果查询中有一个谓词会过滤掉内表(对于左外连接是右侧表)产生的所有NULL值行,那么该外连接在语义上就等价于一个内连接。

转换条件示例
原始查询:SELECT * FROM S LEFT OUTER JOIN R ON S.id = R.sid WHERE R.a > 10
如果 R 中不匹配的行会产生 NULL,那么 R.a > 10NULL 的判定结果为 UNKNOWN(假),这些行会被WHERE子句过滤掉。因此,这个左外连接可以安全地转换为内连接:S INNER JOIN R ON S.id = R.sid WHERE R.a > 10

完成转换后,所有适用于内连接的优化规则(如自由改变连接顺序)就都可以应用了,这常常能带来巨大的性能提升。


分组操作下推

对于包含连接和分组聚合(GROUP BY)的查询,有时将聚合操作下推到连接之前执行是有利的,这样可以显著减少连接需要处理的数据量。

完全下推:当分组和聚合只涉及连接中的一个表时,可以直接将整个GROUP BY操作下推到该表的扫描之后。
部分下推(Partial Pushdown):当聚合涉及多个表时,可能仍然可以下推一个“部分聚合”。例如,先按连接键进行分组和预聚合,连接完成后再进行最终的分组合并。

示例
查询:SELECT R.b, SUM(S.a) FROM R JOIN S ON R.id = S.rid GROUP BY R.b
如果 S.rid 是外键引用 R.id,那么可以先将 S 表按 S.rid 分组并预计算 SUM(S.a)。这样,连接操作就只需要处理每个 R.id 对应的一个聚合值,而不是 S 表中的所有原始行。


特殊模式优化:星型与雪花型模式

数据仓库中常见的星型(Star)和雪花型(Snowflake)模式有其特定的优化模式。如果优化器能识别出查询正在访问这类模式,可以应用特定的启发式规则,快速得到一个接近最优的计划,避免搜索整个空间。

识别与优化

  • 识别:存在一个巨大的事实表(Fact Table),并通过外键与多个较小的维度表(Dimension Table)连接。
  • 启发式规则:立即将连接树固定为左深树或右深树,并按照维度表的选择性(或大小)对连接顺序进行排序。对于列式存储系统,这通常意味着为所有维度表构建哈希表,然后顺序扫描事实表并进行探测,形成高效的流水线执行。

这种优化在Snowflake、Databricks等现代数据仓库系统中非常普遍。它相当于在成本搜索开始之前,用一个“好”的初始计划来引导和剪枝后续的搜索空间。


总结

本节课我们一起学习了查询优化中的核心——查询计划转换。我们涵盖了从基础的表访问路径选择,到复杂的连接排序、谓词移动、外连接转换、聚合下推,以及针对特定数据模式(如星型模式)的启发式优化。

理解这些转换规则是构建和调试查询优化器的基础。然而,正如课程中指出的,一个生产级优化器的规则集是通过多年处理真实查询、发现性能问题并逐一添加规则而积累起来的,这是一个持续迭代的过程。下一节课,我们将深入探讨另一种优化器实现范式:自底向上的连接枚举方法。

007:连接顺序优化 - 自底向上方法

在本节课中,我们将深入学习数据库查询优化中一个核心且具有挑战性的问题:如何为多表连接查询确定最优的连接顺序。我们将重点探讨一种自底向上的优化策略,它根据查询的逻辑复杂度自适应地选择不同的算法,以在可接受的时间内为各种规模的查询生成高质量的执行计划。

概述

连接顺序选择是一个NP难问题,穷举所有可能性对于大型查询是不现实的。因此,优化器需要智能的策略。关键思想是:根据查询的逻辑复杂度(而非数据量)自适应地选择最优的搜索算法。对于简单的小型查询,我们可以进行(近似)穷举搜索找到最优计划;对于中型查询,我们先将问题简化(如线性化),再进行深度搜索;对于大型复杂查询,则采用启发式或随机化算法,在有限时间内找到一个“足够好”的计划。

上一节我们介绍了查询优化中的转换规则和基础概念,本节中我们来看看如何具体实现连接顺序的枚举。

查询复杂度分类

连接顺序优化的首要步骤是评估查询的“逻辑复杂度”。这并非指待处理的数据量,而是指查询图的结构复杂性。查询图是一种表示方法,其中节点代表关系(表),边代表连接谓词。

以下是两种极端的结构,大多数实际查询介于两者之间:

  • 链式图:每个关系最多与两个其他关系相连,形成一个线性序列。例如,R1 JOIN R2 ON ... JOIN R3 ON ... JOIN R4 ON ...。这是最简单的情况,连接顺序基本被确定。
  • 团式图:每个关系都可以与所有其他关系相连。例如,四个表 R1, R2, R3, R4 之间两两都有连接条件。这是最复杂的情况,搜索空间巨大。

基于查询图的结构和动态规划表中需要管理的条目数量(这直接对应复杂度),优化器将查询分为三类:

  1. 小型查询:动态规划表条目 ≤ 10,000。例如,链式查询可包含多达1000个表,而团式查询最多只能有14个表。
  2. 中型查询:复杂度介于小型和大型之间。
  3. 大型查询:超出中型查询的复杂度上限。

自适应优化策略

基于上述分类,优化器采用不同的策略:

针对小型查询:动态规划与超图算法

对于小型查询,目标是找到最优的执行计划。这里采用一种基于超图的动态规划算法,它比传统的System R风格动态规划更高效。

核心概念

  • 超图:是普通图的扩展,其边(称为超边)可以连接任意数量的节点。在查询优化中,超边用于表示一组因连接谓词而紧密关联的关系。
  • 算法思想:该算法将查询图分解为多个子超图(即关系组)。优化时,先将每个子超图内部优化到最优,然后将这些子超图视为单元,通过超边进行连接组合。这利用了最优子结构原理:全局最优计划必然由局部最优的子计划构成。

公式/代码描述
算法递归地遍历超图,先优化子超图,再通过连接超边逐步构建更大的计划。它能够处理内连接、外连接甚至笛卡尔积,并能生成左深树和浓密树。

// 伪代码概念
function OptimizeHypergraph(Hypergraph H):
    for each connected subgraph S in H:
        local_plan = FindOptimalPlan(S) // 递归优化子图
        memoize(local_plan)
    // 然后基于超边组合子图计划,寻找全局最优
    return CombineSubgraphsViaHyperedges(H)

这个算法是后续策略的基础,由Thomas Neumann等人开发,并已在HyPer等数据库中实现。

针对中型查询:线性化与动态规划

对于中型查询,完全的最优搜索可能耗时过长。策略是:先将复杂的查询图简化为近似的链式结构,然后在这个简化问题上运行动态规划算法

这个过程称为基于搜索的线性化。关键步骤是获得一个初始的“良好”连接顺序。这里使用一个来自80年代的算法——IKKBZ算法,它能快速为左深树找到一个近似最优的连接顺序。

IKKBZ算法简述

  1. 为查询图中的每个关系节点计算一个rank = selectivity / cost。选择性越高、成本越低,秩越大,越应优先连接。
  2. 基于连接谓词构建一个前驱图,表示连接的顺序约束。
  3. 按照秩的降序尝试合并节点和边,如果顺序约束(前驱图)与秩顺序冲突,则将冲突的节点合并为一个组(合并其成本)。
  4. 最终产生一个线性的连接顺序。

获得这个初始顺序后,将其作为起点,在此约束下运行动态规划算法来探索浓密树等更优计划。这样,我们至少有一个良好的左深树计划作为保底,并有机会找到更优解。

针对大型查询:贪心算法与局部优化

对于超大型查询(如涉及上百个表),即使简化后的问题也难以处理。此时采用迭代改进的策略:

  1. 生成初始计划:使用一个快速的贪心算法(如GOO算法)生成一个初始的、可执行的查询计划。
  2. 识别瓶颈:分析初始计划,找出成本最高的子图(例如包含K个关系的连接部分)。
  3. 局部优化:对识别出的高成本子图,使用动态规划等更强力的算法进行局部重新优化,时间有限。
  4. 迭代:将优化后的子图替换回原计划,重复步骤2和3,直到时间耗尽。

GOO贪心算法示例
在查询图中,迭代地选择一对可连接的关系 (R_i, R_j) 进行合并,选择标准是使公式 cost = |R_i| * |R_j| * selectivity(R_i, R_j) 最小。合并后,更新图,直到所有关系合并为一个。这种方法可以生成左深或浓密的树。

与其他方法的对比

论文中将这种自适应策略与商业数据库(如Oracle, SQL Server)以及使用随机化算法的PostgreSQL进行了对比。

随机化算法(如遗传算法)在搜索空间巨大时作为一种备选方案,但缺点明显:

  • 不可预测性:虽然PostgreSQL使用确定性随机种子保证同一查询计划可重现,但不同查询或不同参数下可能产生差异巨大的计划。
  • 可能陷入局部最优:随机搜索可能永远找不到高质量的计划。
  • 解释性差:难以解释为何选择某个特定计划。

在实践中,自适应策略在优化时间(500毫秒内处理大型查询)和计划质量(接近最优的10%以内)上均表现优异。

总结

本节课我们一起学习了自底向上的连接顺序优化自适应策略。核心要点是:

  1. 分类处理:根据查询图的结构复杂度(小型、中型、大型),选择不同的优化算法。
  2. 算法工具箱:对于小型查询,使用基于超图的动态规划进行最优搜索;对于中型查询,先用IKKBZ算法线性化简化问题,再进行动态规划搜索;对于大型查询,采用贪心算法生成初始计划,然后迭代地对高成本子图进行局部优化。
  3. 实用哲学:面对NP难问题,一个强大的策略是先用一个快速、近似但“尚可”的算法获得一个良好的起点,然后在此基础上进行精细化改进,这比完全随机或盲目搜索要有效得多。

下一节课,我们将探讨自顶向下的连接顺序优化方法,以及如何在Cascades风格的优化器框架中实现这些思想。

008:自顶向下连接排序 - 转换法

概述

在本节课中,我们将学习一种自顶向下的查询优化方法,重点关注连接排序问题。我们将探讨如何通过图分区技术来指导搜索,并比较不同的剪枝策略,以实现高效的最优查询计划搜索。


回顾:自底向上与超图算法

上一节我们介绍了自底向上的动态规划连接枚举方法。本节中,我们来看看一种不同的思路——自顶向下的方法。

在自底向上的方法中,算法从最小的子查询开始,逐步构建更大的计划。而自顶向下的方法,例如 Volcano 和 Cascades 优化器,则是从完整的查询开始,通过应用转换规则递归地探索可能的计划。这种方法的一个关键优势是需求驱动的搜索:我们可以优先探索那些看起来最有希望的计划部分,而不是盲目枚举所有可能性。

此外,分支定界剪枝允许我们基于当前找到的最佳计划的成本,提前放弃那些成本显然更高的搜索路径。我们还可以利用部分计划信息,在计划完全构建之前就开始估算成本。

然而,自顶向下方法也面临挑战。与动态规划可以丢弃次优子计划不同,自顶向下搜索可能会多次访问相同的子表达式。因此,我们需要一个备忘录表来缓存已计算过的表达式结果,避免重复工作。另一个挑战是,如何在不引入笛卡尔积的情况下,高效地生成所有可能的连接顺序。这正是我们今天要讨论的论文所解决的问题。


分区法的基本思想

该论文的核心思想是将查询的连接图分割成越来越小的分区。

基本算法流程如下:

  1. 为查询图生成所有可能的分区方案。
  2. 进行深度优先搜索,选择一个分区方案,并递归地向下探索,为每个子分区确定最佳的连接顺序。
  3. 在分区时,需要确保不会产生孤立的、无法连接到其他分区的节点,以避免引入笛卡尔积。

这种方法与 Cascades 优化器的区别在于,它通过图分区来主动引导搜索过程,优先探索那些我们认为可能产生更好计划的分区,而不是像 System R 那样进行盲目的搜索。


算法“最优性”的定义

该论文声称其算法是“最优的”自顶向下分区算法。但这里的最优性并非指总能找到全局最优的查询计划(这是一个 NP 难问题),而是针对算法实现本身的效率而言。

在 2006 年的一篇论文之前,优化算法的最优性通常基于其需要考虑的连接顺序数量的下界来定义。而这篇论文采用了不同的定义:算法的开销在于,从一个连接顺序考虑切换到下一个连接顺序时,所产生的额外开销是线性的。换句话说,算法应该能够快速、增量式地生成下一个待考察的连接顺序,而不是预先枚举所有可能顺序或花费大量时间计算下一个选项。如果能以线性时间开销完成这一步,该算法就被认为是“最优”的。

实现这一目标的主要挑战在于,随着连接图规模和复杂度的增长,在遍历和分区过程中需要反复进行的图操作(如检查顶点是否在集合中、求集合交集、检查边是否存在等)会变得非常昂贵。


高效的图表示:位图

算法的实际实现方式会极大地影响其常数因子开销。论文讨论了两种图的表示方法:

  1. 边列表:每个边表示为一对节点。操作简单,但进行集合交、并等复杂操作时,需要分配和操作不定长的内存,开销较大。
  2. 位图:使用位图数组表示邻接矩阵。每个节点对应一个位图,位设置为 1 表示与该节点有边相连。

使用位图的优势在于,可以利用 CPU 的位运算指令(如 SIMD 指令)来高效地处理这些操作(例如,检查连接性、求交集),这比循环遍历列表要快得多。虽然在实际优化器中,成本模型的估算(如访问统计信息、计算选择性)可能比图操作更耗时,但对于大型查询图(例如涉及数千张表),位图表示的优势会非常明显。


分区策略详解

分区策略是算法的核心。以下是几种不同的方法:

1. 朴素的左深树分区(允许笛卡尔积)

这种方法非常简单,类似于 System R 的穷举枚举。

  • 遍历每个顶点,将其从图中移除,形成两个分区(该顶点和剩余子图)。
  • 递归地对剩余子图进行相同操作,直到得到左深树。
  • 问题:它不考虑图的连接性,移除一个顶点可能导致剩余子图中的其他顶点变得孤立,从而产生大量笛卡尔积。

2. 朴素的左深树分区(避免笛卡尔积)

在方法1的基础上增加一个检查步骤。

  • 在决定移除一个顶点并形成新分区之前,检查移除后剩余子图是否仍然连通
  • 如果移除会导致子图不连通(即产生孤立顶点),则忽略此分区方案,尝试下一个顶点。
  • 这通过避免无效分区减少了浪费的计算。

3. 朴素的 Bushy 计划分区

为了生成 Bushy 连接树,我们需要迭代顶点集的非空子集。

  • 算法迭代所有非空顶点子集,将图分割为该子集和其补集两部分。
  • 同样,需要增加连通性检查:移除该子集后,原图是否仍然连通?生成的新分区本身是否连通?
  • 检查通过后,递归地对两个新分区进行分割。

以上朴素方法的问题在于,它们没有利用图的结构信息来指导如何分割,更像是“随机尝试然后验证”,可能产生大量无效的尝试。


最小割分区算法

为了避免无效尝试,我们希望预先知道哪些分割点是“好”的。论文采用了来自网络理论的最小割算法,并为其适应查询优化环境进行了简化。

基本思想是:

  1. 选择一个随机顶点(例如 B)作为起点。
  2. 生成一个双连通树,记录从该顶点出发,哪些边被切割后会导致其下的子图与主图断开。
  3. 通过深度优先搜索这棵树,我们可以决定在哪里进行切割,并且能立即知道切割是否会引入笛卡尔积。
  4. 关键优化是惰性生成这棵树:不需要预先物化整棵树,而是在搜索过程中按需扩展,从而节省内存和计算。

这种方法就像一个“索引”,让我们能快速判断一个分割决策是否安全,而无需在每次尝试后都进行昂贵的全图连通性检查。


剪枝策略比较

论文中另一个非常有趣的部分是对不同分支定界剪枝策略的深入讨论。剪枝的目标是避免探索那些不可能产生更好计划的子树。

1. 累积成本定界

这是 Cascades 优化器中使用的方法。

  • 全局上界:记录迄今为止在整个搜索树中找到的最佳物理计划的成本。
  • 累积下界:在探索子树时,传递一个累积成本(当前路径上已确定的物理操作符成本之和)。
  • 剪枝条件:如果当前子树的累积下界已经超过全局上界,则可以剪掉该子树及其所有物理操作符实现,因为任何物理实现都只会增加成本。
  • 优点:基于真实的物理成本,剪枝准确高效。
  • 缺点:为了计算累积下界,在探索一个子树时,需要等待其子节点的物理成本确定,这导致了搜索的串行化,不利于并行化。

2. 预测成本定界

这种方法试图在到达物理操作符之前就进行剪枝。

  • 逻辑表达式成本预测:为逻辑操作符(如逻辑连接、逻辑扫描)估算一个近似的“成本”,通常基于其输出的预期元组数(基数)。
  • 选择逻辑表达式:在多个逻辑表达式中,选择预测成本最低的进行深入探索。
  • 优点:允许更早地剪枝,无需深入到底层物理操作符。
  • 缺点:预测成本(如将表访问成本设为零)是粗略的近似,可能不如真实物理成本准确,为了不错误剪枝可能包含真正好计划的路径,预测需要相对保守。

实验对比

论文通过合成查询实验对比了两种策略:

  • 剪枝效果:累积成本法由于基于真实成本,能剪掉更多不必要的分支。
  • CPU 时间:预测成本法在总 CPU 时间上表现更好,因为它允许更早的剪枝,减少了总探索量。而累积成本法由于串行依赖,即使探索的分支少,实际运行时间也可能更长。
  • 组合方法:同时使用两种策略,其效果主要由累积成本法主导,因此仍然面临串行化问题。

与超图算法的结合

论文最后提到,可以将这里讨论的最小割分区技术与之前介绍的超图算法结合起来。

超图算法能更紧凑地表示复杂的连接谓词(涉及多个表的谓词)。将其适配到自顶向下的框架中,并结合最小割分区和分支定界剪枝,可以得到一个支持更复杂查询(如外连接、非等值连接)且仍能保持高效并行潜力的优化算法。这为我们下周讨论并行查询优化打下了基础。


总结

本节课我们一起学习了基于分区策略的自顶向下连接排序算法。我们了解了如何通过最小割算法来智能地分割查询图,从而高效地引导搜索。我们还深入比较了累积成本定界和预测成本定界两种剪枝策略的优缺点,认识到累积成本法更精确但可能导致串行化,而预测成本法能更早剪枝但精度较低。最后,我们看到了将分区法与超图表示结合的可能性,这为构建更强大、支持更复杂查询的优化器指明了方向。自顶向下的方法以其统一、直观的框架,为查询优化提供了另一种强大的思路。

009:搜索并行化 - 自底向上方法

在本节课中,我们将学习如何并行化自底向上的查询优化搜索算法。我们将探讨并行化面临的挑战,并介绍几种不同的并行算法,包括DPsub、MPDP以及一种基于关系数据库的独特方法。我们还将讨论在GPU上运行优化器的可行性。

概述

查询优化器的搜索过程是NP难问题,计算复杂度呈指数级增长。为了加速这一过程,一个自然的想法是利用并行计算。然而,与数据库系统的并行执行不同,查询优化的并行化面临独特的挑战,主要是因为搜索空间内部存在复杂的依赖关系。本节课我们将深入探讨如何设计并行化的自底向上搜索算法。

并行化的挑战

上一节我们介绍了自底向上连接枚举的基本框架。本节中我们来看看将其并行化时面临的主要挑战。

与查询执行不同,查询优化的并行化更为复杂。大多数现代数据库系统的优化器仍然是单线程的,这既有历史原因,也有架构原因。在单机架构中,优化线程与执行线程共享计算资源,将宝贵资源用于查询规划可能得不偿失。即使在云原生架构中,单个查询的优化也通常是单线程的。

并行化的目标是实现搜索时间与核心数量成比例的加速。但挑战在于,连接枚举是一个“非串行多面体”问题。这意味着计算某一阶段的搜索树结果时,可能依赖于搜索树下方多个层级的结果。例如,为了确定上层的连接顺序,可能需要知道下层子图的最优连接计划。这种依赖关系限制了简单的并行化策略。

基本并行策略

尽管存在挑战,但并行化的基本思路与并行查询执行类似:将搜索空间进行划分,使得工作负载可以分配给多个工作线程,并尽量减少线程间的依赖和空闲等待时间。

核心思想是分区本地化。我们希望将搜索任务划分成多个分区,每个工作线程可以独立处理一个分区内的计算,而无需频繁等待其他线程的结果。

DPsub 算法

我们将首先介绍一个作为基础的“稻草人”算法——DPsub。它本质上是System R动态规划算法的变体,主要在生成子集的方式上略有不同。

DPsub算法的基本思想是,按照子图包含的顶点数量(即关系表数量)来逐步构建更大的子集。算法从大小为2的子集开始,逐步扩展到更大的子集。对于每个子集,算法尝试所有可能的方式将其分割为两个更小的子集,只要这两个子集在查询图中是相连的(以避免笛卡尔积),然后计算连接这两个子集的最佳方式,并与当前已知的最佳计划进行比较。

以下是算法核心循环的简化示意:

for subset_size in range(2, total_tables + 1):
    for subset in all_subsets_of_size(subset_size):
        # 这部分循环可以并行执行
        find_best_plan_for_subset(subset)

在这个算法中,对每个特定大小的子集进行最佳计划查找的循环是可以并行执行的,因为处理不同子集时不需要彼此协调信息。然而,算法在分割子图后需要检查连通性,这个分支判断在后续的GPU实现中会带来问题。

MPDP 算法

接下来我们看看MPDP算法,它是对DPsub的扩展,旨在实现更高效的并行化,特别是针对GPU架构。

MPDP算法结合了基于顶点的枚举基于边的枚举。基于顶点的枚举就是我们刚才在DPsub中看到的。而基于边的枚举则是通过考察连接对(边)来决定如何切割查询图。基于边的方法可以减少无效工作的生成(避免生成不连通的子图),但并行化更困难,因为需要先完成对边的“切片”才能探索不同的分区。

MPDP算法的一个关键特性是它采用了一种启发式方法来处理复杂的查询。当查询过于复杂时,算法会计算查询图中每个连接的成本(基于关系大小和选择性的启发式估计),然后选择成本最高的连接,将其涉及的关系合并成一个“复合节点”。这个过程会重复进行,直到形成多个大小为K的集群。然后,算法再对每个集群内部运行MPDP进行详尽的连接枚举优化。

这种启发式的思想是:优先优化那些会产生最大中间结果的数据集部分,因为这部分对最终查询性能的影响最大。这与我们之前看到的Goo等方法(优先合并最小的、影响最小的部分)思路相反。

GPU上的并行化

MPDP论文的一个重要贡献是探讨了在GPU上运行连接枚举算法。GPU拥有数千个计算核心,非常适合大规模并行计算,但有其特定的编程约束。

在GPU上实现的关键挑战是分支发散。GPU的线程以线程束(Warp)为单位执行,如果线程束内的线程执行不同的代码路径(例如由于if条件判断),性能会严重下降。因此,MPDP算法致力于消除所有条件分支,例如通过预先计算所有有效的图切割方式(基于边枚举),避免在运行时进行连通性检查。

另一个挑战是数据局部性。GPU线程在计算过程中不能频繁回调CPU获取信息(例如从目录中获取成本估算)。因此,所有需要的元数据(如关系基数、选择率)都必须预先打包并传输到GPU内存中,使整个搜索过程在GPU内部完成。

性能评估

根据论文中的实验结果,并行化算法的性能提升因工作负载而异。

在MusicBrainz数据集上,GPU版本的MPDP算法在连接表数量增加时,搜索时间显著低于CPU版本。然而,在Join Order Benchmark数据集上,GPU的优势并不明显。这是因为JOB的查询谓词限制性更强,可考虑的连接顺序选项较少,导致并行搜索的机会有限,无法充分发挥GPU的并行能力。同时,向GPU传输任务和返回结果的开销成为了一个固定成本。

在查询计划质量方面,论文比较了MPDP与其他算法(如遗传算法、IKKBZ近似算法等)的估计成本。结果表明,对于星型模式和雪花模式查询,MPDP生成的计划质量很高。但对于团状查询图,某些算法会超时,而MPDP虽然能完成,但计划质量的相对优势可能有所不同。

基于关系数据库的并行方法

最后,我们简要介绍一种来自IBM研究部门(2008年)的独特并行方法。该方法的核心思想是将连接枚举过程本身表达为数据库内的关系操作。

具体做法是:将所有可能的子查询计划存储在数据库的一个备忘录表中。然后,通过对此表进行自连接来生成更大的连接组合。工作负载被逻辑划分为不同大小的子图分区(P1为大小1的子图,P2为大小2,依此类推)。自底向上的枚举过程就转化为对这些分区进行自连接操作。

这种方法面临的挑战是如何将自连接产生的任务智能地分配给工作线程。简单的平均分配可能导致负载不均衡,因为许多任务组合可能是无效的(会产生笛卡尔积)。论文提出了几种任务分配策略:

  • 范围分配:将外层循环的迭代范围平均分给线程。
  • 外层循环轮询:每次外层循环迭代时,将内层循环的所有工作分配给一个线程(轮询)。
  • 内层循环轮询:在外层循环的一次迭代内,将内层循环的工作轮询分配给多个线程。

其中,对外层循环进行分配的策略对于星型模式等具有偏斜的工作负载效果最好,因为它能将涉及大事实表的工作均匀分散。

总结与展望

本节课我们一起学习了自底向上连接枚举搜索的并行化技术。我们了解到,由于搜索空间内部的依赖关系,并行化并非易事。我们探讨了DPsub、MPDP等算法,它们通过图分区、启发式聚类等技巧来划分工作负载。我们还讨论了在GPU上实现并行搜索的机遇与挑战,以及一种将枚举过程转化为数据库内操作的独特方法。

主要的收获是:并行化查询优化搜索是可行的,但需要精心设计以处理依赖关系、避免无效工作并保持所有工作线程的忙碌。是否使用专用GPU取决于成本效益分析;但随着集成显卡的普及,利用其并行能力可能成为一个有吸引力的低成本选择。

下节课,我们将转向自顶向下搜索方法的并行化,并讨论项目二的潜在选题。

010:并行化搜索 - 自上而下方法

概述

在本节课中,我们将学习如何在自上而下(转换式)查询优化器中实现并行化搜索。我们将探讨为何这种优化器比自底向上系统面临更大的搜索空间挑战,并介绍一种通过依赖图来调度任务、替代传统栈模型的并行化方法。


自上而下优化器的并行化挑战

上一节我们讨论了自底向上优化器中连接枚举的并行化。本节中,我们来看看在自上而下的转换式优化器中,如何应对更复杂的搜索空间并行化问题。

在自底向上优化器中,搜索被分层处理(例如,先应用启发式规则,再进行基于成本的连接排序)。而在自上而下的优化器(如Cascades风格)中,所有转换规则被统一应用于整个搜索空间,导致需要考虑的备选方案数量急剧增加。

例如,对于一个TPC-H Query 6(六表连接),仅考虑连接顺序的选项可能少于1100个。但如果考虑所有可能的逻辑转换,备选方案数量可能激增至约2.3亿个。因此,在这种环境下,并行化能带来更大的性能提升潜力。


传统栈调度模型的局限性

在原始的Cascades论文中,任务调度是通过一个后进先出(LIFO)栈来管理的。当一个转换规则被应用并生成新的子任务时,这些子任务被压入栈顶。调度线程从栈顶取出任务执行。

这种模型的优点是能隐式地处理任务依赖(子任务必须在父任务之前完成)。但其主要缺点是,栈结构只保留了顺序信息,而丢失了任务间细粒度的依赖关系。系统无法判断两个并排的任务是否可以并行执行,因为它们可能并不相互依赖。


基于依赖图的并行调度模型

为了克服栈模型的限制,论文提出用一个更通用的调度器来替代栈。该调度器维护一个依赖图,显式地跟踪所有任务及其依赖关系。

核心概念:任务与依赖

每个任务代表优化过程中的一个目标(例如,探索一个组ExploreGroup,优化一个组表达式OptimizeGroupExpression)。任务在执行过程中可以创建新的子任务。

依赖关系的关键保证是:

  • 父任务可以在子任务开始之前启动(因为它需要创建子任务)。
  • 父任务不能在子任务完成之前结束(因为它需要子任务的结果)。

调度器的核心职责是持续检查依赖图,找出所有可运行任务(即所有依赖项都已满足的任务),并将其分配给空闲的工作线程。

任务状态机

每个任务在其生命周期中遵循一个状态机:

  1. 可运行:任务被创建,依赖已满足。
  2. 运行中:任务被分配给工作线程并正在执行。
  3. 挂起/非活跃:任务需要等待其子任务完成,被放入队列。
  4. 完成:任务执行完毕,结果存入备忘录表,任务实例被丢弃。

任务的重入性

一个关键要求是任务必须是可重入的。这意味着任务可以在执行过程中被挂起(当它需要等待子任务结果时),并在之后从挂起点恢复执行,而不是从头开始。这通常通过内部状态机或类似Python yield的机制来实现。


并行执行流程示例

以下是一个简化的并行任务执行流程,展示了依赖如何产生和解决:

  1. 初始调用 OptimizeGroup(G0)
  2. G0 调用 ExploreGroup,进而可能并行触发多个任务:
    • ExploreExpression 在当前层级应用转换规则,生成新的逻辑表达式。
    • ExploreChildren 递归探索下层子组。
    • 这些任务在同一层级,彼此独立,可以并行执行
  3. 当获得一些逻辑表达式后,可以调用 ImplementExpression 任务来生成物理表达式。
  4. 进行成本计算时(例如在 OptimizeGroupExpression 中),任务需要向下层组请求最佳子计划成本。此时,该任务必须挂起,等待下层 OptimizeGroup 任务完成并返回结果。
  5. 下层任务完成后,上层任务被唤醒,整合成本信息,继续执行或创建更多任务。

通过这种方式,独立任务广泛并行,依赖任务则有序等待,从而充分利用了多核资源。


并行化效果与可扩展性

论文通过实验评估了并行化的效果:

  1. 并行机会:对于星型查询和链式查询,在初始构建阶段后,高达85%-90%的任务可以并行执行,这证实了自上而下优化器存在大量并行潜力。
  2. 加速比:在多核机器上运行不同规模查询的加速比。
    • 星型查询:随着连接表数增加(2, 4, 8...),并行机会多,接近线性加速比。
    • 链式查询/小查询:并行机会有限,超过2-4核后加速比提升不明显,甚至因线程协调和共享数据结构(如备忘录表)的争用而下降。

主要结论是:要实现良好的可扩展性,不仅需要任务级并行,还需要精心设计优化器内部的并发数据结构,以减少锁争用。


项目二介绍

本节课的最后,我们简要介绍本课程的最终项目(Project 2)。

项目目标

学生需要组成小组(最多3人),选择一个与课程主题相关的项目,进行设计、实现和评估。项目应围绕构建或增强查询优化器的某个方面展开。

项目阶段

  1. 项目提案:春假后的第一个周一(3月10日)提交。需要清晰阐述项目目标、实施方案、测试方法和评估计划。
  2. 中期汇报:约一个月后进行5分钟的课堂进度更新。
  3. 最终报告与演示:在课程最终考核日,进行10分钟的演示,并提交最终设计文档。

潜在项目方向(非 exhaustive)

以下是几个可能的研究方向,供参考:

  • 代价承诺的自适应学习:动态调整转换规则的“承诺”值,以指导搜索顺序。
  • 任意子查询去嵌套的转换规则实现:将自底向上的去嵌套算法实现为Cascades风格的转换规则。
  • 基于LLM的查询重写与验证:利用大语言模型重写SQL,并通过模糊测试确保语义等价性。
  • 跨优化器的通用转换规则库:定义一种中间表示,将不同开源优化器(如CockroachDB、Calcite)的转换规则统一并移植。
  • 优化器测试与基准测试框架:自动化生成查询、验证计划正确性、比较计划质量。
  • 物理计划提示注入:将优化器生成的物理计划,通过提示(如PG Hint Plan)的方式嵌回给数据库执行引擎。
  • 复杂谓词的机器学习表示:为更复杂的SQL谓词(包含析取、子查询等)构建嵌入表示,用于代价预测。

学生可以自由提出其他与查询优化相关的创新想法。


总结

本节课中,我们一起学习了在自上而下查询优化器中实现并行化搜索的核心思想。我们分析了传统栈调度模型的不足,并介绍了一种基于显式依赖图可重入任务的并行调度模型。该模型通过调度器动态管理任务依赖,使得大量独立任务得以并行执行,从而有效应对了转换式优化器庞大的搜索空间。最后,我们介绍了课程最终项目的要求和一些潜在的研究方向。理解这些并行化技术,对于构建高性能的现代查询优化器至关重要。

011:子查询解嵌套 (CMU Optimize!)

在本节课中,我们将学习查询优化中一个关键且复杂的主题:子查询的解嵌套。我们将探讨如何将嵌套的子查询重写为更高效的连接操作,并介绍从早期启发式方法到现代系统性算法的演进过程。

概述

查询优化器在生成高效执行计划时,需要处理各种复杂的查询结构,其中嵌套子查询是一个重大挑战。子查询允许在查询内部嵌入另一个查询,增加了逻辑表达的灵活性,但也给优化带来了困难。本节课我们将深入探讨子查询的类型、优化挑战以及系统性的解嵌套算法。

子查询类型与挑战

上一节我们介绍了查询优化器的搜索策略,本节中我们来看看一个具体的优化难题:子查询。子查询主要分为两类:非相关子查询和相关子查询。

  • 非相关子查询:内层查询的执行不依赖于外层查询的任何属性或数据。

    • 示例:SELECT name FROM students WHERE score = (SELECT MAX(score) FROM students)
    • 优化简单:内层查询可以像公共表表达式(CTE)一样只执行一次,物化为临时表,然后与外层查询进行连接。
  • 相关子查询:内层查询的执行依赖于外层查询的属性或数据。

    • 示例:SELECT name, major FROM students s1 WHERE score = (SELECT MAX(score) FROM students s2 WHERE s1.major = s2.major)
    • 优化复杂:最朴素的执行方式是为外层查询的每一行都执行一次内层查询,效率极低。

我们的高级目标是避免这种最坏情况,即愚蠢地为外层查询的每一行都执行内层查询。主要方法是通过重写查询,将相关子查询转换为连接操作。

绑定与语义歧义

在深入优化算法之前,我们需要理解查询绑定过程中的一个挑战:语义歧义。绑定是将SQL语句中的引用映射到数据库目录中对象的过程。对于子查询,不同的数据库系统可能对同一查询有不同的解释。

以下是几个展示歧义的查询示例:

  • 示例1SELECT (SELECT SUM(i) FROM integers) FROM integers i1
    • PostgreSQL/MySQL/SQL Server:先计算内层聚合(SUM),结果为6,然后与外层每一行连接,产生多行结果(如6, 6, 6...)。
    • Oracle:为外层每一行(i1.i)执行内层聚合(SUM(i1.i)),结果为每行自身的值(1, 2, 3...)。

  • 示例2SELECT (SELECT SUM(i1.i + i2.i) FROM integers i2) FROM integers i1

    • 此查询展示了更复杂的引用模式,不同数据库可能产生不同结果。
  • 示例3SELECT * FROM integers i1 WHERE i = (SELECT MAX(i) FROM integers)

    • 此查询需要先计算外层的MAX(i),再用于内层过滤。

这些例子表明,在优化器中处理子查询时,绑定和语义解释是首要的挑战。大多数系统(如SQLite)采用基于启发式的重写规则,而像DuckDB和Umbra/Hyper这样的系统则采用更系统性的方法。

早期启发式方法

自20世纪80年代早期以来,人们提出了许多基于启发式和规则的重写方案。这些方法本质上类似于我们在本学期早期看到的Starburst中的转换规则,即识别特定模式,然后安全地进行重写。

例如,SQLite的文档中列出了大约24条不同的规则,用于判断子查询何时可以解嵌套并转换为连接。另一个著名的技术是“Magic Sets”,它通过重写查询,在FROM子句中引入一个带有额外过滤条件的表(即Magic Set),从而减少内层查询需要扫描的数据量。

微软的SQL Server采用了一套更为复杂的启发式规则,并引入了APPLY运算符(类似于LATERAL JOIN)。其核心思想是,如果能够将这些APPLY运算符转换为内连接,那么就可以应用标准的连接优化技术。

然而,启发式方法存在局限性:

  1. 无法处理所有可能的嵌套场景。
  2. 规则需要人工编写和维护,工程上难以持续。
  3. 修改一条规则可能会破坏其他规则所依赖的假设。

从工程角度看,启发式方法可能解决了75%的问题,但并非可维护和可持续的解决方案。

系统性方法:依赖连接消除 (2015)

接下来,我们看看一种更系统性的方法,源自2015年的一篇论文(2025年有更新)。其核心思想是引入一个新的关系代数运算符:依赖连接

  • 依赖连接:这是一个扩展的关系代数运算符,用于表示运算符左右两侧之间存在依赖关系,对应于相关子查询。可以将其视为一个带有标记的交叉连接,用于跟踪依赖关系。
  • 算法目标:识别执行计划中的依赖连接,并通过重写将其转换为常规的内连接,从而消除嵌套执行。

该算法(2015版)是自底向上进行的:

  1. 从计划树底部开始,找到第一个依赖连接。
  2. 尝试将该依赖连接下推至尽可能低的位置。
  3. 在重写过程中,可能会引入去重扫描运算符,以确保语义正确性。
  4. 当下推到底部时,将依赖连接转换为交叉连接,进而与上层的过滤条件结合,形成内连接。
  5. 消除不必要的运算符(如去重扫描)。
  6. 重复此过程,直到消除所有依赖连接。

局限性:2015年的算法一次只处理一个依赖连接。当存在多层嵌套子查询(即依赖连接嵌套)时,它可能产生次优计划,甚至无法处理某些复杂情况,因为它没有全局视图。

系统性方法:整体解嵌套 (2025)

针对2015年算法的局限性,2025年的新论文提出了整体解嵌套算法。关键区别在于,新算法是自顶向下进行的,并且能同时考虑多个依赖连接。

该算法分为两个主要阶段:

  1. 分析与标记阶段

    • 遍历整个逻辑执行计划,识别出所有的依赖连接。
    • 构建并维护一个称为索引代数的辅助数据结构。它记录了:
      • 源操作符:哪些操作符产生了哪些属性。
      • 消费者:哪些操作符使用了这些属性。
    • 这个数据结构帮助优化器理解数据在操作符之间的流向和依赖关系。
  2. 自顶向下重写阶段

    • 从最顶层的依赖连接开始处理。
    • 在遍历计划树时,维护一个状态栈,跟踪当前处理的依赖连接、外部引用、等价类等信息。
    • 当遇到一个依赖连接时,利用索引代数和状态信息,判断是否可以安全地进行重写。
    • 重写可能涉及:
      • 利用等价类替换列引用,消除跨分支的依赖。
      • 将依赖连接转换为常规连接。
      • 将过滤条件下推或上拉。
    • 算法会递归地处理子节点,确保在消除高层依赖连接时,已妥善处理其下层的依赖关系。

关键优势:通过自顶向下的方式和全局的索引代数视图,该算法能够更智能地处理多层嵌套和复杂的依赖关系,避免2015年算法可能产生的次优解。此外,该算法可以与成本模型结合,在多种可行的重写路径中做出更优选择。

实现前提:该算法要求查询优化器能够将执行计划表示为有向无环图,而不仅仅是树,以便重用子计划并清晰表达数据流。Umbra/Hyper等系统支持这种表示。

总结

本节课我们一起学习了查询优化中关于子查询解嵌套的核心内容。

我们首先区分了非相关子查询和相关子查询,并明确了将相关子查询重写为连接操作的核心优化目标。接着,我们探讨了子查询绑定中可能出现的语义歧义问题,这是优化器需要面对的现实挑战。

然后,我们回顾了早期基于启发式规则的方法,虽然实用但存在覆盖不全和难以维护的缺点。之后,我们深入分析了两种系统性的解嵌套算法:2015年的自底向上“依赖连接消除”算法,以及2025年改进的自顶向下“整体解嵌套”算法。后者通过引入索引代数和有向无环图计划表示,能够更有效地处理复杂的多层嵌套子查询。

子查询优化是一个极具挑战性的领域,目前只有少数先进的数据库系统(如Umbra)完整实现了这些最新的系统性算法。理解这些原理对于构建或深度使用高性能数据库系统至关重要。

从下一节课开始,我们将进入查询优化的另一个核心模块:成本模型。我们将探讨优化器如何估算不同执行计划的代价,并理解当前成本模型存在的局限性,为后续学习自适应优化技术打下基础。

012:查询成本模型与统计信息

概述

在本节课中,我们将学习查询优化器的核心组成部分:成本模型与统计信息。我们将探讨数据库系统如何通过维护数据摘要(统计信息)来估算不同查询计划的执行成本,从而在众多候选计划中选择最优的一个。课程将涵盖统计信息的类型、维护方式,以及它们如何被用于成本估算。

成本模型简介

上一节我们讨论了如何构建查询优化器以及应用基本的转换算法。本节中,我们来看看如何通过成本估算来指导搜索过程。

查询成本模型是对执行特定查询计划所需物理资源消耗的估算。它用于比较不同计划或操作符的优劣,从而选择成本更低的计划。成本估算基于数据库的当前状态,包括硬件配置、表内容、数据分布等因素。

成本模型无法预测查询的实际执行时间(挂钟时间),因为这是一个非常困难的问题。它的核心目标是相对比较,即判断计划A是否优于计划B。

不同数据库系统的成本模型彼此不兼容。例如,不能将PostgreSQL的成本估算与MySQL的进行比较。

我们之所以需要成本模型,是因为实际运行所有候选查询计划或操作符来比较性能是极其昂贵的。

成本模型的组成部分

成本模型主要包含两个部分:物理成本和逻辑成本。

物理成本

物理成本估算查询计划实际执行时将消耗的硬件资源。主要包括:

  • CPU时间:处理数据所需的计算资源。
  • I/O成本:从磁盘或网络读取/写入数据的开销。
  • 内存消耗:构建数据结构(如哈希表)或暂存中间结果所需的内存。

物理成本与物理操作符的具体实现紧密相关。例如,哈希连接需要构建哈希表,我们可以估算其内存占用和查找效率。

为这些不同的硬件资源分配权重常数(例如,一次磁盘I/O相当于多少CPU周期)是一个棘手的问题。一些高端系统会通过运行基准测试自动确定这些值,而像PostgreSQL这样的系统则依赖管理员手动配置。

逻辑成本

逻辑成本与具体的物理实现算法无关,它关注逻辑操作符层面的数据特征。最重要的逻辑成本是基数估计,即估算流入一个操作符的数据量以及从该操作符流出的数据量。

例如,无论是使用嵌套循环连接还是哈希连接,连接谓词是相同的,因此通过该连接过滤或产生的元组数量在逻辑层面是相同的。逻辑成本可以帮助我们在搜索早期进行剪枝(如界限剪枝),或者决定优先优化哪些可能产生大量中间结果的操作符。

需要注意的是,虽然逻辑成本的计算公式本身可能不复杂,但获取基数估计值本身可能是成本模型中开销最大的部分,因为这需要查询统计信息目录并应用估算公式。

统计信息的维护

为了进行基数估计等逻辑成本计算,数据库系统需要维护描述数据特征的统计信息

统计信息是存储在系统目录(Catalog)中的数据结构,用于汇总数据库中数据的实际情况。它们就像普通的表一样被存储和管理。

以下是关于统计信息维护需要考虑的几个方面:

  • 准确性:我们需要多高的估算精度?能否量化数据结构的置信区间?
  • 维护效率:当数据变化(插入、更新、删除)时,更新统计信息的效率如何?
  • 内存开销:存储统计信息需要多少内存?统计信息本身不应占用过多资源。
  • 适用范围:该数据结构能支持哪些类型的查询谓词估算?
  • 构建与更新策略:何时以及如何构建和更新统计信息?

统计信息的存储与更新

统计信息通常作为元数据存储在系统目录表中(例如PostgreSQL的pg_statistic)。系统需要决定何时创建或更新它们。常见的策略包括:

  • 定期任务:例如Oracle在每晚自动运行统计信息收集作业。
  • 变更触发:当表中一定比例(如20%)的数据被修改后,触发统计信息更新。
  • 手动命令:管理员执行如ANALYZE(PostgreSQL)或UPDATE STATISTICS(SQL Server)的命令。
  • 附加操作:PostgreSQL的自动清理(AutoVacuum)进程在清理旧版本元组时,会顺便更新统计信息。

单列与多列统计信息

大多数系统会为表的每个列自动创建单列统计信息。然而,当查询谓词涉及多个列时,如果这些列的值存在相关性(例如“城市”和“邮政编码”),基于单列统计信息并假设列间独立的估算会产生很大误差。

为此,一些系统支持创建多列统计信息(或扩展统计信息)。例如,在PostgreSQL中,可以使用 CREATE STATISTICS 命令指定相关的列组。优化器在处理涉及这些列的联合谓词时,会使用多列统计信息来获得更准确的基数估计。

统计信息的数据结构

数据库系统使用多种数据结构来汇总数据,主要分为三类:直方图、草图(Sketch)和采样。

直方图

直方图是最经典、最常用的方法,用于近似表示列中值的分布。

等宽直方图:将值域范围划分为若干个宽度相等的桶,每个桶内记录值的总出现次数。估算时假设桶内值均匀分布。
等高直方图:调整每个桶的边界,使得每个桶内值的总出现次数大致相等。这是更常用的方法(PostgreSQL、SQL Server使用),通常比等宽直方图误差更小。
最频值直方图:在等高直方图的基础上,单独精确记录出现频率最高的N个值(高频项),其余值归入一个“其他”桶并用平均值估算。这有助于提高对常见查询的估算精度。

直方图的挑战在于,当数据频繁更新时,需要有效的维护和更新策略来保证其准确性。

草图

草图是一种概率数据结构,利用信息论原理,用极小的空间提供对特定问题的近似答案,但适用范围较窄。

Count-Min Sketch:可以看作一个“计数型布隆过滤器”。它使用多个哈希函数和计数器数组,用于估算某个特定值出现的频率(提供一个上界)。它易于合并,适合分布式场景。
HyperLogLog:用于估算数据集中不同值的数量(基数)。它通过哈希值中前导零的个数来概率性地估算基数。

草图结构通常非常节省空间且易于维护,但只能回答特定类型的问题(如“值V出现了多少次?”或“有多少个不同值?”),无法像直方图那样支持范围查询等多种谓词。

采样

采样的思想是直接对数据本身的一个随机子集运行一个“微型查询”,用这个样本上的结果来估算整个数据集的 selectivity。

维护样本表:在侧边维护一个小的、随机的数据样本。
实时采样:在优化时直接对基表进行随机读取(可能以读未提交模式)。

采样的优点是能够处理任意复杂的谓词,因为使用的是真实的查询执行引擎。缺点是维护样本有开销,且在数据更新时样本可能过时。目前,只有少数系统(如SQL Server)有限地使用采样进行基数估计。

实际系统案例

PostgreSQL的成本模型与统计信息

PostgreSQL主要使用等高直方图,并结合最频值列表。其物理成本权重(CPU vs 顺序I/O vs 随机I/O)依赖于一组需要管理员手动配置的魔法常数。文档明确指出,没有完美的方法来确定这些值,调整它们是有风险的。

高端系统的自动校准

像DB2这样的企业级系统,会在系统启动时自动运行一系列微基准测试,来测量当前硬件(CPU、内存、I/O、网络)的实际性能。这些测量结果被用作成本模型中的权重常数,使得成本模型能够自适应具体的部署环境。

无统计信息时的挑战与自适应优化

在现代数据湖或湖仓一体架构中,数据以文件(如Parquet)形式存放在对象存储(如S3)中。查询引擎可能在从未“见过”某些数据文件的情况下就被要求执行查询。此时,传统的统计信息可能不存在。

面对无统计信息的冷数据,系统通常采取以下策略:

  • 做出最坏情况假设:使用一些经验性的默认值(例如,假设连接的选择性为20%)。
  • 自适应执行:在查询执行过程中监控实际数据特征,如果发现初始估算严重错误,则动态调整后续的执行策略(甚至重优化)。
  • 反馈循环:记录查询执行过程中观察到的实际基数,并将其反馈给优化器,用于未来对相同或类似查询的优化。

总结

本节课中,我们一起学习了查询优化中成本模型与统计信息的基础知识。我们了解到:

  1. 成本模型通过估算物理和逻辑成本来比较查询计划。
  2. 统计信息(直方图、草图、采样)是数据库系统维护的数据摘要,用于支持基数估计等逻辑成本计算。
  3. 直方图是最通用和常用的结构,而草图在特定问题上更高效。采样则提供了另一种思路,但应用不广。
  4. 实际系统中,统计信息的维护策略和成本权重的确定方式各不相同,从手动配置到自动校准都有。
  5. 在现代云原生环境下,处理无统计信息的冷数据是一个重要挑战,催生了自适应查询执行等技术。

核心在于,我们构建的是一套基于近似的估算系统:用近似的数据摘要(统计信息)去估算近似的中间结果大小(基数),最终得到近似的成本。误差会层层累积,这也是查询优化器有时会选错计划的原因,也是自适应优化技术发展的动力。下一节课,我们将深入探讨如何利用这些统计信息进行具体的基数估计。

013:基数估计与代价模型 🎯

在本节课中,我们将学习查询优化器的核心组成部分——代价模型,并重点探讨如何利用代价模型来估计查询操作符的基数。基数估计是预测一个操作符(如扫描、连接或过滤)将产生多少行数据的过程,它是决定一个查询计划是否优于另一个的逻辑基础。

概述

查询优化器通过代价模型来评估不同查询计划的执行效率。代价模型的核心是基数估计,即预测每个操作符将处理或输出的数据量。无论使用何种连接算法或过滤顺序,基数估计都是一个逻辑代价,我们可以据此推断物理执行代价。

上一节我们介绍了数据库系统在目录中维护的数据结构(如直方图、HyperLogLog),这些结构用于汇总表数据。本节中,我们将学习如何利用这些统计信息来计算基数估计。

基数估计基础

基数估计是优化器预测操作符将生成的行数的方法。这是评估查询计划优劣的基石。

从高层次看,其工作原理是:对于任何操作(如带过滤的扫描或带连接条件的连接),我们都有一个选择性的概念,即输入元组满足谓词并成为输出一部分的概率。将选择性估计乘以输入行数,即可得到近似的基数。

今天我们将讨论基数估计的基础知识,然后分析你们阅读的来自德国研究者的综述论文,最后探讨 PostgreSQL 和 SQL Server 的具体实现。

核心操作符

要进行基数估计,我们需要支持三个核心操作符:

  1. 过滤条件(选择)
  2. 连接大小估计
  3. 不同值估计

以下是实现这些估计的基本方法:

  • 基本统计量:从表的统计模型(如直方图、草图)中推导出基数估计。许多技术可追溯到 1970 年代的 System R。
  • 函数定义
    • T(R):表 R 中的元组数。
    • V(R, A):表 R 中属性 A 的不同值数量(可使用 HyperLogLog 估算)。
    • SC(R, A):选择基数,定义为属性 A 的给定值的平均预期记录数除以总元组数。
  • 选择性计算:基于上述统计量计算谓词的选择性。

单谓词选择性估计

首先,我们来看如何为单个谓词计算选择性估计。

等值谓词

处理等值谓词是最简单的情况。

公式:对于查询 SELECT * FROM people WHERE age = 9,其选择性为直方图中键值 9 的计数除以输入总元组数。

示例

  • 精确直方图:若直方图显示 age=9 的计数为 4,总元组数为 45,则选择性 = 4 / 45 = 0.08。
  • 等高直方图:实际系统中通常存储等高直方图。对于 age=9,我们找到其所属的桶。假设该桶总计数为 9,包含 5 个不同的键(9,10,11,12,13)。我们假设桶内键值分布均匀,则 age=9 的近似计数 = 9 / 5。选择性 = (9 / 5) / 45 = 0.04。

核心假设:在等高直方图的桶内,我们假设键值的分布是均匀的。这个假设简化了计算,但可能不准确。采样可以部分解决此问题,但无法保证完全代表真实数据。

范围谓词

接下来,我们处理范围谓词,例如 WHERE age >= 7

计算方法

  1. 确定起始点(7)所在的桶。
  2. 包含起始点右侧所有桶的计数。
  3. 对于起始点所在的桶,再次应用均匀性假设,估算满足条件的键值比例。

示例:假设 age >= 7

  • [6,8] 计数为 12,包含键 {6,7,8}。满足 >=7 的键是 {7,8},占 2/3。因此,该桶的贡献计数 = 12 * (2/3) = 8。
  • 右侧桶的计数分别为 9 和 12。
  • 总满足计数 = 8 + 9 + 12 = 29。
  • 选择性 = 29 / 45 = 0.64。

另一个重要假设:我们假设桶内所有键值都是连续存在的,没有间隙。如果存在间隙,可能会导致高估选择性。

否定谓词

否定谓词的处理相对简单,它是等值谓词的逆运算。

公式:对于 WHERE age != 2,先计算 age = 2 的选择性 S,则 age != 2 的选择性为 1 - S

示例:若 age = 2 的选择性为 0.05,则 age != 2 的选择性为 0.95。

多谓词组合估计

现在,我们知道了如何计算单个谓词的选择性。但当同一个表上有多个谓词时,如何组合它们呢?

挑战在于,即使谓词在同一表上,其值域和分布也可能完全不同且相互独立(尽管实践中可能存在相关性,可通过多列统计来捕获)。

我们可以将选择性估计视为概率问题:一个元组满足谓词的概率。因此,可以利用概率论知识来组合它们。

合取(AND)

对于用 AND 连接的谓词,大多数系统采用独立性假设,即一个谓词的选择性不受另一个谓词所选值的影响。

公式:若两个谓词独立,则合取的选择性为各自选择性的乘积:S(P1 AND P2) = S(P1) * S(P2)

问题:当列之间存在相关性时,此假设会导致严重错误。

示例:一个汽车数据库,有 10 个品牌和 100 个型号。查询 WHERE make = ‘Honda’ AND model = ‘Accord’

  • 独立性假设:选择性 = (1/10) * (1/100) = 0.001。
  • 实际情况:Accord 只由 Honda 生产,存在强相关性。真实选择性应为 1/100 = 0.01。
  • 结果:估计值偏差了一个数量级。

析取(OR)

对于用 OR 连接的谓词,同样在独立性假设下进行计算。

公式S(P1 OR P2) = S(P1) + S(P2) - S(P1) * S(P2)

处理相关性:SQL Server 的指数回退法

独立性假设在实践中常导致低估基数。SQL Server 采用了一种启发式方法——指数回退法来缓解此问题。

方法

  1. 将多个合取谓词按其选择性排序(S1 最选择,S2 次之,依此类推)。
  2. 组合选择性时,对第二个及以后的谓词的选择性进行开方运算,以削弱其影响。

公式S_total = S1 * sqrt(S2) * sqrt(sqrt(S3)) * ...

原理:SQL Server 团队发现实际工作负载中,数据分布通常是正相关的。指数回退法是一种折中方案,旨在减少低估,同时保持较低的统计开销。这是一种经验性的“技巧”,但根据论文结果,SQL Server 的基数估计质量确实更高。

连接大小估计

连接大小估计是基数估计中最困难的部分,误差在这里会急剧放大。

我们引入一个包含性假设:对于内连接,假设一个关系中存在的连接键在另一个关系中也存在。这可能导致高估匹配数。

估计步骤

  1. 对齐直方图:连接的两个表可能具有不同的等高直方图桶范围。首先需要处理直方图,使其范围对齐。这个过程本身就会引入误差,因为需要根据均匀性假设重新分配桶内计数。
  2. 逐桶计算:对每个对齐后的桶对,计算预期的连接输出元组数。
  3. 汇总:将所有桶对的估计值相加,得到总的连接基数估计。

误差传播

连接估计的误差会随着查询计划树向上传播而放大。如果一个连接操作符的输入基数被低估,那么这个低估的估计值又会作为输入传递给上游的连接操作符,导致误差层层累积。

示例:连接三个表 A、B、C。

  • 首先估计基表基数(A、C 无谓词,B 有范围谓词)。
  • 计算第一个连接 A ⋈ B 的基数。公式可能取 max(|A|, |B|) 或其他简化形式。
  • 将第一个连接的输出基数作为输入,计算 (A ⋈ B) ⋈ C 的基数。
  • 底层的估计误差在每一步都会被放大,导致顶层的估计严重偏离。

实证研究与影响

德国研究者的论文通过实验揭示了基数估计误差的严重性及其对查询计划的影响。

实验设置

  • 基准:使用 IMDB 数据库的 JOB 工作负载。
  • 方法:比较多个数据库系统(PostgreSQL, Hyper, DB2, SQL Server 等)的基数估计值与真实值。
  • 度量:以连接数为横轴,展示估计值是高估还是低估。

关键发现

  1. 普遍低估:大多数系统都倾向于低估连接操作的基数,尤其是随着连接数的增加。
  2. 误差累积:呈现“阶梯函数”特征,连接数越多,低估越严重。
  3. 系统差异:SQL Server 的估计最接近真实值(部分归功于指数回退法等优化),PostgreSQL 的估计误差较大且随连接数增加而恶化。

对查询计划的影响

糟糕的基数估计会导致优化器选择次优的物理操作符。

PostgreSQL 示例

  • 问题:由于低估连接输出,优化器可能认为只有少量数据,从而选择嵌套循环连接而非哈希连接。当实际数据量很大时,嵌套循环连接性能极差。
  • 改进
    • 如果禁止优化器选择嵌套循环连接,性能更接近最优。
    • PostgreSQL 后来增加了哈希表溢出时动态重建的能力,这缓解了因低估导致哈希表大小不足的问题。
  • 结论:拥有更好的基数估计所带来的性能提升,可能比设计极其复杂、考虑微观操作的代价模型更大。论文强调,改进基数估计应优先于完善代价模型

实际系统实现

最后,我们简要看看 PostgreSQL 和 SQL Server 的具体实现。

PostgreSQL 的代价模型

PostgreSQL 的操作符代价是磁盘页面访问数和内存中元组处理数的加权和。权重(如随机 I/O 成本、顺序 I/O 成本、CPU 处理成本)需要人工或自动配置。

特点

  • 代码复杂,包含许多历史累积的微调。
  • 包含一些魔法常数(例如,某些 CPU 成本乘数为 0.5)。
  • 研究显示,如果为其注入真实的基数,即使使用简单的代价模型,其查询计划性能也能大幅提升。这再次证明了基数估计的关键性。

SQL Server 的优化

除了指数回退法,SQL Server 还采用了一些其他优化:

  1. 首次元组成本:不仅计算产生所有输出元组的总成本,还单独计算产生第一个输出元组的成本。这对于比较嵌套循环连接和索引嵌套循环连接很重要。
  2. 结果缓存:如果探测侧多次出现相同值,可以缓存上一次的结果以避免重复探测。
  3. 行目标:维护一个“行目标”属性,表示查询计划树中某部分实际需要的行数(例如,受 LIMIT 子句影响)。这可以影响下层操作符的选择(例如,只需要一行时,索引探测可能更优)。

总结

本节课我们一起学习了查询优化中基数估计的核心概念与方法。

我们了解到,基数估计是预测查询操作符输出行数的过程,是代价模型的基础。我们从单谓词(等值、范围、否定)的选择性计算开始,介绍了关键的均匀性假设连续性假设。接着,我们探讨了多谓词组合估计,引入了独立性假设,并看到了它因忽略列间相关性而导致的误差,以及 SQL Server 用指数回退法对此进行的改进。

然后,我们深入了更复杂的连接大小估计,指出了包含性假设直方图对齐带来的挑战,以及误差在查询计划树中向上传播的严重问题。通过实证研究,我们看到了糟糕的基数估计如何导致优化器选择次优的查询计划,从而严重影响性能。最后,我们对比了 PostgreSQL 和 SQL Server 的实现差异。

核心结论是:获得准确的基数估计比构建极其复杂的代价模型更重要。它是优化器做出正确决策的基石。许多现代优化技术,包括我们将在春假后学习的基于机器学习的估计方法,都致力于解决这一根本问题。

014:查询成本模型与基数估计进阶

概述

在本节课中,我们将深入学习查询优化中的基数估计问题。我们将基于一篇来自微软的论文,探讨当基数估计出错时,查询计划会受到何种具体影响,并比较行存储与列存储系统在应对基数估计错误时的差异。我们还将了解一些运行时优化技术,如位图过滤和自适应连接,以及它们如何在一定程度上弥补基数估计的不足。

背景回顾

上一节我们介绍了德国研究团队关于基数估计误差对查询计划质量影响的综述论文。本节中,我们将深入探讨微软团队对此问题的进一步研究。

基数估计是查询优化器用来预测查询计划中每个操作符将输出多少元组的机制。这非常具有挑战性,因为优化器需要基于从底层表中推导出的统计信息(如直方图或草图)来进行估算。当查询包含多个谓词或表达式时,优化器需要在这些已有估算的基础上进行叠加估算,这可能导致误差累积。

基数估计之所以重要,是因为它直接影响连接顺序的选择、索引扫描与顺序扫描的决策,以及其他一些优化策略。

微软论文的研究动机

微软团队认为德国论文的分析存在三个局限性:

  1. 工作负载范围有限:德国论文主要使用了连接顺序基准测试,该基准只包含简单的选择-投影-连接查询,而真实工作负载可能包含分组、外连接、CTE等更复杂的操作。
  2. 基数注入方式:德国论文在实验中为查询计划中的所有操作符都注入了真实的基数。微软团队认为,可能只需要对一部分关键操作符注入真实基数,就能获得大部分收益。
  3. 系统评估范围:德国论文只评估了PostgreSQL。微软团队希望在一个具有更先进优化器(如SQL Server的Cascades优化器)和更多运行时优化功能的系统上进行更全面的分析。

研究方法:在Cascades优化器中注入基数

SQL Server使用Cascades优化器。为了注入真实的基数估计,微软团队采用了一种巧妙的方法:

  1. 首先,为查询生成所有逻辑表达式,并填充备忘录表。
  2. 对备忘录表中的每个逻辑表达式及其子表达式递归计算哈希值,为每个子树生成唯一标识符。
  3. 将这些逻辑表达式反向解析回SQL查询。
  4. 在数据库上实际执行这些SQL查询,获取每个操作符的真实基数。
  5. 将哈希值与真实基数存储在目录中。在优化期间,当成本模型需要某个逻辑表达式的基数时,便从目录中查找预先计算好的真实值。

行存储系统的评估结果

微软团队在多种工作负载上进行了测试,包括标准基准测试(如TPC-H、TPC-DS)和真实的客户工作负载。

以下是主要发现:

  • 性能提升与回归:大多数查询在注入真实基数后性能得到提升(达到2倍或更多),但也存在少数查询性能下降(回归)的情况。回归表明成本模型本身可能存在缺陷。
  • 性能提升的来源:对于性能提升的查询,他们分析了是哪些操作符的改进贡献了主要收益。分类如下:
    • 索引操作符:决定使用索引扫描还是顺序扫描。
    • 扫描操作符:顺序扫描。
    • 非叶节点操作符:连接、聚合、排序等。
    • 其他操作符
  • 在连接顺序基准等测试中,避免不必要的索引扫描是性能提升的主要原因。而在更复杂的工作负载中,收益则来自上述多个方面的综合改进。

查询计划变化示例

通过具体查询示例,可以看到注入真实基数后查询计划的变化:

  1. 访问方法改变:当基数估计严重低估时,优化器可能将索引查找改为(聚集)索引扫描或顺序扫描,因为扫描大量数据时,顺序访问通常比随机I/O更高效。
  2. 连接算法与顺序改变:可能将索引嵌套循环连接改为哈希连接或合并连接,并可能调整连接顺序。
  3. 聚合下推:当连接输出远大于最终聚合结果时,优化器可能将部分聚合计算下推到连接操作之下,显著减少连接需要处理的数据量。这是一种PostgreSQL不具备的优化。

列存储系统的评估结果

接下来,我们来看看在列存储系统上的评估结果。SQL Server支持列存储索引,数据以字典编码压缩的列形式存储。

总体结论是:在列存储系统中,获得更准确的基数估计同样能带来与行存储系统相似的性能收益。

然而,也观察到一些差异:

  • 在列存储中,索引扫描本身较少,因此从“避免错误索引扫描”中获得的收益不如行存储明显。
  • 在某些工作负载中,虽然获得显著提速的查询比例很高,但少数严重回归的查询其绝对执行时间增长巨大,以至于拖累了整体工作负载的总执行时间。

关键操作符集分析

一个有趣的问题是:是否需要对所有操作符都注入真实基数?

微软团队定义了“关键操作符集”:只需对该集合中的操作符注入真实基数,就能获得与对所有操作符注入相同的最优查询计划;如果移除该集合中的任何操作符,查询计划就会变差。

通过实验发现:

  • 对于大多数查询,关键操作符集的中位数大小约为5个,远小于查询计划中操作符的总数。
  • 这意味着在实践中,可能只需要对少数关键位置进行更精确的统计,就能大幅改善查询计划质量。这为数据库系统指明了一个更实用的优化方向:优先保证关键表或关键操作的统计准确性。

运行时优化技术的影响

最后,我们探讨两种运行时优化技术是否能够掩盖基数估计不准确的问题。

位图过滤

位图过滤是一种侧向信息传递技术。在构建哈希连接时,同时构建一个布隆过滤器。在探测端扫描数据时,先用布隆过滤器快速过滤掉肯定不匹配的元组,避免不必要的哈希表探测。

实验表明:

  • 启用位图过滤后,即使使用系统估计的基数,也有约36%的查询获得2倍以上性能提升,且几乎没有回归。
  • 当同时使用真实基数和位图过滤时,性能有进一步小幅提升。
  • 结论:位图过滤本身能带来显著收益,部分弥补了基数估计的误差,但准确的基数估计与之结合效果更佳。

自适应连接

自适应连接是SQL Server的一项功能,它允许运行时在嵌套循环连接和哈希连接之间切换。优化器初始选择一个计划(通常基于小数据量假设选择嵌套循环连接),运行时如果发现探测到的元组数量超过阈值,则切换为哈希连接。

实验表明:

  • 仅使用自适应连接(基于估计基数)带来的收益有限。
  • 结合真实基数估计后,收益更明显。
  • 结论:自适应连接只能优化连接算法本身,无法纠正因基数估计错误导致的连接顺序、访问方法等更高层的决策错误。因此,它不能完全替代准确的基数估计。

总结

本节课我们一起深入学习了基数估计对查询计划质量的深远影响。通过微软的研究,我们了解到:

  1. 准确的基数估计对行存储和列存储系统都至关重要,能通过改进访问方法选择、连接顺序和聚合下推等来提升性能。
  2. 无需对所有操作符进行精确估计,只需关注一个较小的“关键操作符集”(约5个)即可获得大部分收益。
  3. 运行时优化技术(如位图过滤和自适应连接)能有效提升性能并部分掩盖基数估计错误,但它们不能完全替代一个基于准确估计的、良好的初始查询计划。
  4. 在实践中,数据库管理员需要权衡性能提升与潜在查询回归的风险,企业级数据库通常提供查询计划冻结等功能来管理此类风险。

这些发现为构建更健壮、高效的查询优化器提供了重要的指导和启示。

posted @ 2026-03-28 12:23  布客飞龙V  阅读(1)  评论(0)    收藏  举报