在前几章节中介绍了Trino,并进行了初步安装和使用,现在我们将讨论Trino的架构。我们将深入探讨相关概念,以便您了解Trino的查询执行模型、查询计划和基于成本的优化。
在本章中,我们首先讨论Trino的高级架构组件。了解Trino的工作方式是非常重要的,特别是如果您打算自己安装和管理Trino集群,正如第5章所讨论的那样。 在本章的后半部分,我们将深入探讨这些组件,讨论Trino的查询执行模型。如果您需要诊断或调优性能较慢的查询(在第8章中讨论),或者计划为Trino开源项目做出贡献,这将非常重要。
集群中的协调器和工作节点
当您首次安装Trino时(如第2章所讨论的),您只使用了单台机器来运行所有内容。对于所需的可扩展性和性能,这种部署方式并不适合。
Trino是一个分布式的SQL查询引擎,类似于大规模并行处理(MPP)风格的数据库和查询引擎。它不依赖于运行Trino的服务器的垂直扩展,而是能够在水平方向上将所有处理任务分布到一个服务器集群中。这意味着您可以添加更多的节点以获得更多的处理能力。
借助这种架构,Trino查询引擎能够并行处理大量数据的SQL查询任务,这些数据分布在一个由多台计算机或节点组成的集群中。Trino在每个节点上作为单个服务器进程运行。多个运行Trino的节点组成了一个Trino集群,并通过彼此协作来完成任务。
图4-1显示了由一个协调器和多个工作节点组成的Trino集群的高级概述。Trino用户使用客户端(如使用JDBC驱动程序或Trino CLI的工具)连接到协调器。然后,协调器与工作节点合作,工作节点访问数据源。
协调器是一个Trino服务器,负责处理传入的查询并管理工作节点来执行这些查询。 工作节点是负责执行任务和处理数据的Trino服务器。
发现服务运行在协调器上,允许工作节点注册并参与集群。 所有客户端、协调器和工作节点之间的通信和数据传输都使用基于HTTP/HTTPS的REST交互。
图4-2显示了集群内部的通信方式,包括协调器与工作节点之间的通信,以及一个工作节点与另一个工作节点之间的通信。协调器与工作节点进行交互,分配任务、更新状态,并获取顶层结果集以返回给用户。工作节点之间相互通信,从上游任务(运行在其他工作节点上)获取数据。工作节点从数据源检索结果集。
协调器
Trino的协调器是负责接收用户的SQL语句,解析这些语句,计划查询并管理工作节点的服务器。它是Trino安装的核心和客户端连接的节点。用户可以通过Trino CLI、使用JDBC或ODBC驱动程序的应用程序、Trino Python客户端或任何其他语言的客户端库与协调器进行交互。协调器接受来自客户端的SQL语句,例如SELECT查询,以便执行。
每个Trino安装都必须有一个协调器和一个或多个工作节点。对于开发或测试目的,可以配置单个Trino实例来执行这两个角色。 协调器跟踪每个工作节点的活动并协调查询的执行。协调器创建一个涉及一系列阶段的查询的逻辑模型。
一旦协调器接收到SQL语句,它负责解析、分析、计划和调度在Trino工作节点上执行查询。该语句被转换为一系列在工作节点集群上运行的相互连接的任务。当工作节点处理数据时,结果由协调器检索并在输出缓冲区中提供给客户端。当客户端完全读取输出缓冲区时,协调器代表客户端向工作节点请求更多数据。工作节点则与数据源进行交互,从中获取数据。因此,在查询执行完成之前,客户端不断请求数据,工作节点从数据源中提供数据。 协调器使用基于HTTP的协议与工作节点和客户端进行通信。图4-3显示了客户端、协调器和工作节点之间的通信。
发现服务
Trino使用发现服务来查找集群中的所有节点。每个Trino实例在启动时向发现服务注册,并定期发送心跳信号。这使得协调器能够拥有一个最新的可用工作节点列表,并将该列表用于调度查询执行。
如果一个工作节点未能发送心跳信号,发现服务将触发故障检测器,该工作节点将不再适合执行进一步的任务。 Trino协调器运行发现服务。它与Trino共享HTTP服务器,因此使用相同的端口。因此,发现服务的工作节点配置指向协调器的主机名和端口。
工作节点
Trino工作节点是Trino安装中的服务器。它负责执行协调器分配的任务,包括从数据源检索数据和处理数据。工作节点通过使用连接器从数据源获取数据,然后彼此交换中间数据。最终的结果数据传递给协调器。协调器负责从工作节点收集结果,并将最终结果提供给客户端。
在安装过程中,工作节点被配置为知道集群的发现服务的主机名或IP地址。当一个工作节点启动时,它向发现服务注册自己,使得协调器可以将任务分配给它进行执行。 工作节点使用基于HTTP的协议与其他工作节点和协调器进行通信。
图4-4展示了多个工作节点如何从数据源中检索数据并协同处理数据,直到一个工作节点可以将数据提供给协调器。
连接器架构
连接器架构是Trino中存储和计算分离的核心。连接器为Trino提供了访问任意数据源的接口。
每个连接器在底层数据源上提供了基于表的抽象。只要数据可以使用Trino可用的数据类型表示为表、列和行,就可以创建连接器,并且查询引擎可以使用该数据进行查询处理。
Trino提供了一个服务提供者接口(SPI),定义了连接器必须为特定功能实现的功能。通过在连接器中实现SPI,Trino可以在内部使用标准操作连接到任何数据源并对任何数据源执行操作。连接器负责处理特定数据源的相关细节。
每个连接器实现了API的三个部分:
- 获取表/视图/模式元数据的操作
- 生成逻辑数据分区的操作,以便Trino可以并行读写
- 将数据源转换为查询引擎所期望的内存格式的数据源和接收器
让我们通过一个示例来澄清SPI。Trino中的任何支持从底层数据源读取数据的连接器都需要实现listTables SPI。因此,Trino可以使用相同的方法来询问任何连接器以检查模式中可用的表列表。Trino不需要知道某些连接器必须从信息模式获取数据,其他连接器必须查询元数据存储,还有其他连接器必须通过数据源的API请求该信息。对于核心Trino引擎来说,这些细节是无关紧要的。连接器负责处理这些细节。这种方法清晰地将核心查询引擎的关注点与任何底层数据源的具体细节分离开来。这种简单而强大的方法为代码的可读性、扩展性和维护性带来了巨大的好处。
Trino提供了许多连接器,用于访问诸如HDFS/Hive、Iceberg、Delta Lake、MySQL、PostgreSQL、MS SQL Server、Kafka、Cassandra、Redis等系统。在第6章和第7章中,您将了解到一些连接器。支持的连接器列表不断增长。有关支持的连接器的最新列表,请参阅Trino文档。
Trino的SPI还使您能够创建自己的自定义连接器。如果您需要访问没有兼容连接器的数据源,这可能是必要的。如果您最终创建了一个连接器,我们强烈建议您更多地了解Trino开源社区,使用我们的帮助,并贡献您的连接器。请参阅“Trino资源”了解更多信息。如果您的组织中有一个独特或专有的数据源,可能还需要自定义连接器。这就是允许Trino用户使用SQL查询任何数据源的功能-真正的SQL-on-Anything。
图4-5显示了Trino SPI如何包括用于协调器的元数据、数据统计和数据位置的分离接口,以及用于工作节点的数据流接口。
Trino连接器是在每个服务器启动时加载的插件。它们通过目录属性文件中的特定参数进行配置,并从插件目录加载。我们将在第6章中更详细地探讨这一点。
目录、模式和表
Trino集群使用之前描述的基于连接器的架构来处理所有查询。每个目录配置使用一个连接器来访问特定的数据源。数据源在目录中公开一个或多个模式。每个模式包含提供数据的表,表的行使用不同的数据类型作为列。更多详细信息,请参阅第8章的“目录”、“模式”和“表”部分。
查询执行模型
现在您已经了解到Trino在实际部署中涉及一个拥有协调器和多个工作节点的集群,我们可以看一下实际的SQL查询语句是如何处理的。
理解执行模型为您提供了在特定查询中优化Trino性能所需的基础知识。
回顾一下,协调器从终端用户、CLI或使用ODBC或JDBC驱动程序或其他客户端库的应用程序接收SQL语句。然后,协调器触发工作节点从数据源获取所有数据,创建结果数据集,并将其提供给客户端。
让我们首先更详细地了解协调器内部发生的情况。当将SQL语句提交给协调器时,它以文本格式接收该语句。协调器将该文本进行解析和分析,然后使用Trino内部的查询计划数据结构创建执行计划。该流程如图4-6所示。查询计划广泛地表示了按照SQL语句处理数据和返回结果所需的步骤。
如图4-7所示,查询计划的生成使用元数据SPI和数据统计SPI来创建。因此,协调器使用SPI直接连接到数据源来收集关于表和其他元数据的信息。
协调器使用元数据SPI获取有关表、列和类型的信息。这些信息用于验证查询的语义是否有效,并对原始查询中的表达式进行类型检查和安全检查。
统计信息SPI用于获取行数和表大小的信息,在规划过程中执行基于成本的查询优化。
数据位置SPI在创建分布式查询计划时发挥作用。它用于生成表内容的逻辑拆分。拆分是工作分配和并行处理的最小单位。
分布式查询计划是简单查询计划的扩展,由一个或多个阶段组成。简单查询计划被拆分为计划片段。阶段是计划片段的运行时实现,它包含了阶段计划片段描述的所有任务。
协调器将计划分解,以便在并行处理的工作节点上处理,以加快整个查询的速度。拥有多个阶段会导致创建阶段之间的依赖树。阶段的数量取决于查询的复杂性。例如,查询的表、返回的列、JOIN语句、WHERE条件、GROUP BY操作和其他SQL语句都会影响创建的阶段数量。
图4-8展示了在集群中协调器上如何将逻辑查询计划转换为分布式查询计划。
分布式查询计划定义了阶段以及查询在Trino集群上的执行方式。协调器使用它来进一步计划和安排任务在工作节点之间的分配。一个阶段包含一个或多个任务。通常涉及许多任务,每个任务处理数据的一部分。
协调器将阶段中的任务分配给集群中的工作节点,如图4-9所示。
任务处理的数据单位称为split。split是对底层数据的一个片段的描述,可以由工作节点检索和处理。它是并行性和工作分配的单位。
由连接器执行的数据操作取决于底层数据源。例如,Hive连接器以文件路径、偏移量和长度的形式描述split,指示需要处理的文件的哪个部分。
源阶段的任务以页面的形式生成数据,页面是列格式的行的集合。这些页面流向其他中间下游阶段。页面通过交换操作符在阶段之间传输,交换操作符从上游阶段的任务中读取数据。
源任务使用数据源SPI和连接器的帮助从底层数据源提取数据。这些数据以页面的形式呈现给Trino,并通过引擎流动。操作符根据其语义处理和生成页面。例如,过滤器丢弃行,投影操作生成带有新派生列的页面,等等。
任务中的操作符序列称为流水线。流水线的最后一个操作符通常将其输出页面放入任务的输出缓冲区。下游任务中的交换操作符从上游任务的输出缓冲区消耗页面。所有这些操作在不同的工作节点上并行进行,如图4-10所示。
因此,任务是计划片段在分配给工作节点时的运行实例。任务创建后,它为每个split实例化一个驱动程序。每个驱动程序是操作符流水线的一个实例化,用于对split中的数据进行处理。
任务可能使用一个或多个驱动程序,这取决于Trino的配置和环境,如图4-11所示。一旦所有驱动程序完成并将数据传递给下一个split,驱动程序和任务完成其工作并被销毁。
运算符处理输入数据以生成下游运算符的输出数据。示例运算符包括表扫描、过滤器、连接和聚合等。一系列这些运算符形成一个运算符流水线。例如,您可以有一个流水线,首先扫描和读取数据,然后对数据进行过滤,最后进行部分聚合。
为了处理查询,协调器使用连接器的元数据创建split列表。使用split列表,协调器开始在工作节点上调度任务以获取split中的数据。在查询执行过程中,协调器跟踪所有可用于处理的split以及运行在工作节点上并处理split的任务的位置。
当任务完成处理并生成更多的用于下游处理的split时,协调器继续调度任务,直到没有剩余的split可供处理。一旦工作节点上的所有split都被处理完毕,所有数据都可用,协调器就可以将结果提供给客户端。
查询计划
在深入了解Trino查询规划器和基于成本的优化工作之前,让我们先设定一个阶段,以特定的背景为我们的考虑提供框架。我们提供一个示例查询作为背景,帮助您理解查询规划的过程。 示例4-1使用TPC-H数据集(请参阅“Trino TPC-H和TPC-DS Connectors”)对每个国家的订单价值进行求和,并列出前五个国家:
SELECT
(SELECT name FROM region r WHERE regionkey = n.regionkey) AS region_name,
n.name AS nation_name,
sum(totalprice) orders_sum
FROM nation n, orders o, customer c
WHERE n.nationkey = c.nationkey
AND c.custkey = o.custkey
GROUP BY n.nationkey, regionkey, n.name
ORDER BY orders_sum DESC
LIMIT 5;
让我们尝试理解查询中使用的SQL结构及其目的:
- 在FROM子句中使用了三个表的SELECT查询,隐式定义了nation、orders和customer表之间的CROSS JOIN(交叉连接)关系。
- 使用WHERE条件从nation、orders和customer表中保留匹配的行。
- 使用GROUP BY进行聚合,对每个国家的订单值进行聚合。
- 使用子查询(SELECT name FROM region WHERE regionkey = n.regionkey)从region表中获取区域名称;请注意,这个查询是相关的,就好像它应该独立地对包含结果集的每一行执行。
- 使用排序定义ORDER BY orders_sum DESC,在返回结果之前对结果进行排序。
- 定义了限制条件为五行,仅返回订单总额最高的国家,并过滤掉其他所有行。
解析和分析
在查询被执行之前,需要对其进行解析和分析。有关SQL和构建查询的相关语法规则的详细信息可以在第8章和第9章中找到。当解析查询时,Trino会验证其是否符合这些语法规则。接下来,Trino会对查询进行分析,包括以下步骤:
- 识别查询中使用的表
表在目录和模式中进行组织,因此多个表可以具有相同的名称。例如,TPC-H数据集在不同模式中提供了各种大小的orders表,如sf10.orders、sf100.orders等。
- 识别查询中使用的列
限定的列引用orders.totalprice明确指的是orders表中的totalprice列。通常情况下,SQL查询只通过列名进行引用,例如totalprice(如示例4-1中所示)。Trino分析器可以确定列来自哪个表。
- 识别对ROW值中字段的引用
解引用表达式c.bonus可能是指名为c的表中的bonus列,也可能是指名为c的列中的具有命名字段的行类型(具有命名字段的结构)。Trino的分析器的任务是确定哪种情况适用,如果存在歧义,则以带有表限定的列引用为准。分析需要遵循SQL语言的作用域和可见性规则。收集的信息,如标识符消歧义,稍后在计划过程中使用,这样计划器就不需要再次了解查询语言的作用域规则。
正如您所见,查询分析器具有复杂的、跨领域的职责。它的角色非常技术性,在查询正确无误时对用户来说是不可见的。只有当查询违反SQL语言规则、超出用户权限或由于其他原因不正确时,分析器才会显露出来。
一旦查询被分析并且查询中的所有标识符被处理和解析,Trino就会进入下一个阶段,即查询计划。
初始化查询计划
查询计划定义了一个生成查询结果的程序。回想一下,SQL是一种声明性语言:用户编写SQL查询来指定他们从系统中获取的数据。与命令式程序不同,用户不会指定如何处理数据以获得结果,这部分由查询规划器和优化器来确定处理数据以获得所需结果的步骤顺序。
这些步骤序列通常被称为查询计划。从理论上讲,产生相同查询结果的查询计划的数量是指数级的。计划的性能差异巨大,这就是Trino规划器和优化器试图确定最佳计划的地方。始终产生相同结果的计划被称为等价计划。
让我们考虑前面在示例4-1中显示的查询。对于这个查询,最直接的查询计划是最接近查询的SQL语法结构的计划。这个计划显示在示例4-2中。为了讨论的目的,该列表应该是不言自明的。您只需要知道该计划是一棵树,并且它的执行从叶节点开始,沿着树结构向上进行。
- Limit[5]
- Sort[orders_sum DESC]
- LateralJoin[2]
- Aggregate[by nationkey...; orders_sum := sum(totalprice)]
- Filter[c.nationkey = n.nationkey AND c.custkey = o.custkey]
- CrossJoin
- CrossJoin
- TableScan[nation]
- TableScan[orders]
- TableScan[customer]
- EnforceSingleRow[region_name := r.name]
- Filter[r.regionkey = n.regionkey]
- TableScan[region]
查询计划的每个元素可以以直观、命令式的方式实现。例如,TableScan访问其底层存储中的表,并返回包含表中所有行的结果集。Filter接收行并对每行应用过滤条件,仅保留满足条件的行。CrossJoin在两个从其子节点接收到的数据集上操作。它生成这些数据集中行的所有组合,可能会将其中一个数据集存储在内存中,这样就不必多次访问底层存储。
现在让我们考虑一下这个查询计划的计算复杂性。在不了解实际实现的所有细节的情况下,我们无法完全推断出复杂性。然而,我们可以假设查询计划节点的复杂性的下界是它所生成的数据集的大小。因此,我们使用大欧米伽符号来描述复杂性,它描述了渐近下界。如果N、O、C和R分别表示nation、orders、customer和region表中的行数,则我们可以观察到以下情况:
TableScan [orders] 读取orders表,返回O行,因此其复杂度为Ω(O)。类似地,另外两个TableScan操作分别返回N行和C行,因此它们的复杂度分别为Ω(N)和Ω(C)。
位于TableScan [nation]和TableScan [orders]上方的CrossJoin组合了nation和orders表的数据,因此其复杂度为Ω(N × O)。
上述CrossJoin将前面产生的N × O行与TableScan [customer]组合,因此其复杂度为Ω(N × O × C)。
底部的TableScan [region]复杂度为Ω(R)。然而,由于LateralJoin,它被调用了N次,其中N是从聚合返回的行数。因此,总体而言,此操作的计算成本为Ω(R × N)。
Sort操作需要对N行进行排序,因此它所需的时间不能少于N × log(N)的比例。
暂时忽略其他操作,因为它们的成本不会比我们迄今分析的成本更高,前面计划的总成本至少为Ω[N + O + C + (N × O) + (N × O × C) + (R × N) + (N × log(N))]。在不了解相对表大小的情况下,这可以简化为Ω[(N × O × C) + (R × N) + (N × log(N))]。假设region是最小的表,nation是第二小的表,我们可以忽略结果的第二部分和第三部分,并得到简化后的结果Ω(N × O × C)。
足够的代数公式了。现在是时候看看在实践中这意味着什么!让我们来考虑一个拥有2亿个国家的1亿个客户,并总共下了10亿个订单的流行购物网站的例子。这两个表的CrossJoin需要生成20万亿(20,000,000,000,000,000,000)行数据。对于一个相当强大的100节点集群,每个节点每秒处理100万行数据,计算我们查询的中间数据需要超过63个世纪的时间。
当然,Trino根本不会尝试执行这样一个简单的计划。但是这个初始计划作为两个世界之间的桥梁:SQL语言的世界和其语义规则的世界以及查询优化的世界。查询优化的作用是将初始计划转化和演化为一个等价的计划,该计划可以尽快执行,至少在合理的时间范围内,考虑到Trino集群的有限资源。让我们来谈谈查询优化如何努力实现这个目标。
优化规则
在本节中,您将了解Trino实施的许多重要优化规则中的一些。
谓词下推
谓词下推可能是最重要且最容易理解的优化技术。它的作用是尽可能将过滤条件移至数据源附近。这样,在查询执行过程中尽早减少数据量。在我们的案例中,它将一个Filter转换为一个更简单的Filter,并在相同的CrossJoin条件之上添加一个InnerJoin,得到了示例4-3中所示的计划。为了易读性,省略了未发生变化的计划部分。
- Aggregate[by nationkey...; orders_sum := sum(totalprice)]
- Filter[c.nationkey = n.nationkey AND c.custkey = o.custkey] // original filter
- CrossJoin
- CrossJoin
- TableScan[nation]
- TableScan[orders]
- TableScan[customer]
...
- Aggregate[by nationkey...; orders_sum := sum(totalprice)]
- Filter[c.nationkey = n.nationkey] // transformed simpler filter
- InnerJoin[o.custkey = c.custkey] // added inner join
- CrossJoin
- TableScan[nation]
- TableScan[orders]
- TableScan[customer]
...
现在,原本存在的“较大”的连接被转换为基于相等条件的InnerJoin。暂且不深入细节,我们假设这样的连接可以在分布式系统中高效实现,其计算复杂度等于所产生的行数。这意味着谓词下推将一个“至少”Ω(N × O × C)的CrossJoin替换为一个“确切”Θ(N × O)的Join。
然而,谓词下推无法改进国家表和订单表之间的CrossJoin,因为这两个表之间没有直接的连接条件。这就是交叉连接消除发挥作用的地方。
交叉连接消除
在没有基于成本的优化器的情况下,Trino按照查询文本中出现的顺序连接包含在SELECT查询中的表。这种顺序连接的一个重要例外是当要连接的表没有连接条件时,就会产生交叉连接。在几乎所有实际情况下,交叉连接是不需要的,所有乘法行后来都会被过滤掉,但是交叉连接本身需要执行的工作太多了,可能永远无法完成。
交叉连接消除会重新排序要连接的表,以最小化交叉连接的数量,理想情况下将其减少到零。在没有关于相对表大小的信息的情况下,除了交叉连接消除之外,表连接的顺序保持不变,因此用户仍然有控制权。交叉连接消除对我们示例查询的影响可以在示例4-4中看到。现在两个连接都是内连接,将连接的整体计算成本降低到Θ(C + O) = Θ(O)。查询计划的其他部分自初始计划以来没有变化,因此整体查询计算成本至少为Ω[O + (R × N) + (N × log(N))] - 当然,O部分表示订单表中的行数是主导因素。
- Aggregate[by nationkey...; orders_sum := sum(totalprice)]
- Filter[c.nationkey = n.nationkey] // filter on nationkey first
- InnerJoin[o.custkey = c.custkey] // then inner join custkey
- CrossJoin
- TableScan[nation]
- TableScan[orders]
- TableScan[customer]
...
- Aggregate[by nationkey...; orders_sum := sum(totalprice)]
- InnerJoin[c.custkey = o.custkey] // reordered to custkey first
- InnerJoin[n.nationkey = c.nationkey] // then nationkey
- TableScan[nation]
- TableScan[customer]
- TableScan[orders]
Top N
通常情况下,当查询中有LIMIT子句时,它之前会有一个ORDER BY子句。没有排序的情况下,SQL不能保证返回哪些结果行。我们的查询中也出现了ORDER BY紧跟LIMIT的组合。
当执行这样的查询时,Trino可以对产生的所有行进行排序,然后仅保留其中的前几行。这种方法的计算复杂度为Θ(row_count × log(row_count)),内存占用为Θ(row_count)。然而,这种方法并不是最优的,将整个结果集排序只为了保留一个更小的排序结果子集是很浪费的。因此,一种优化规则将ORDER BY紧跟LIMIT的操作合并为一个TopN计划节点。在查询执行过程中,TopN使用堆数据结构来保留所需数量的行,在以流式方式读取输入数据时更新堆。这将计算复杂度降低为Θ(row_count × log(limit)),内存占用降低为Θ(limit)。整体查询计算成本现在为Ω[O + (R × N) + N]。
部分聚合
Trino在连接过程中不需要传递来自订单表的所有行,因为我们对单个订单不感兴趣。我们的示例查询计算了每个国家的totalprice的总和,因此可以在之前对行进行预聚合,如示例4-5所示。通过对数据进行聚合,我们减少了传入下游连接的数据量。这些结果并不完整,因此被称为预聚合。但数据量可能会减少,从而显著提高查询性能。
- Aggregate[by nationkey...; orders_sum := sum(totalprice)]
- InnerJoin[c.custkey = o.custkey]
- InnerJoin[n.nationkey = c.nationkey]
- TableScan[nation]
- TableScan[customer]
- Aggregate[by custkey; totalprice := sum(totalprice)]
- TableScan[orders]
为了提高并行性,这种类型的预聚合以不同的方式实现,称为部分聚合。在这里,我们呈现的是简化的计划,但在实际的执行计划中,与最终的聚合操作不同,部分聚合会以不同的方式表示。
实现规则
到目前为止,我们介绍的规则都是优化规则,旨在减少查询处理时间、查询的内存占用或网络传输的数据量。然而,即使在我们的示例查询中,初始计划中包含了一个未实现的操作:侧向连接(lateral join)。在接下来的部分,我们将看看Trino如何处理这类操作。
侧向连接解耦
侧向连接可以被实现为一个for-each循环,遍历数据集中的所有行,并对每一行执行另一个查询。这样的实现是可能的,但这不是Trino处理类似我们示例的情况的方式。相反,Trino会解耦子查询,提取所有相关条件,并形成一个常规的左连接。从SQL的角度来看,这对应于对查询的转换:
SELECT
(SELECT name FROM region r WHERE regionkey = n.regionkey)
AS region_name,
n.name AS nation_name
FROM nation n
转为
SELECT
r.name AS region_name,
n.name AS nation_name
FROM nation n LEFT OUTER JOIN region r ON r.regionkey = n.regionkey
尽管我们可以交替使用这些结构,但熟悉SQL语义的细心读者立即意识到它们并不完全等同。第一个查询如果区域表中的重复条目具有相同的区域键(regionkey),则会失败,而第二个查询则不会失败。相反,它会产生更多的结果行。因此,为了保持原始查询语义,侧向连接解耦使用了两个额外的组件。首先,它为所有源行“编号”,以便可以区分它们。其次,在连接之后,它检查是否存在重复的行,如示例4-6所示。如果检测到重复,查询处理将失败。
- TopN[5; orders_sum DESC]
- MarkDistinct & Check
- LeftJoin[n.regionkey = r.regionkey]
- AssignUniqueId
- Aggregate[by nationkey...; orders_sum := sum(totalprice)]
- ...
- TableScan[region]
半连接(IN)解耦
子查询不仅可以用于提取信息(就像我们刚才在侧向连接示例中看到的),还可以通过使用IN谓词来过滤行。实际上,IN谓词可以在过滤器(WHERE子句)或投影(SELECT子句)中使用。当您在投影中使用IN时,它就不像EXISTS那样是一个简单的布尔值运算符。相反,IN谓词可以计算为true、false或null。
让我们考虑一个设计用于查找客户和商品供应商来自同一国家的订单的查询,如示例4-7所示。这样的订单可能很有意义。例如,我们可能希望通过直接从供应商发货给客户,绕过我们自己的配送中心,从而节省运费或减少运输对环境的影响。
SELECT DISTINCT o.orderkey
FROM lineitem l
JOIN orders o ON o.orderkey = l.orderkey
JOIN customer c ON o.custkey = c.custkey
WHERE c.nationkey IN (
-- subquery invoked multiple times
SELECT s.nationkey
FROM part p
JOIN partsupp ps ON p.partkey = ps.partkey
JOIN supplier s ON ps.suppkey = s.suppkey
WHERE p.partkey = l.partkey
);
与侧向连接一样,这可以通过对外部查询的行进行循环来实现,其中子查询以多次调用的方式检索出该商品的所有供应商的国家。
Trino并不采用这种方法,而是将子查询解耦——子查询在去除相关条件的情况下被评估一次,然后通过使用相关条件与外部查询重新连接。棘手的部分是确保连接不会产生多个结果行(因此使用了去重聚合),并且转换正确保留了IN谓词的三值逻辑。
在这种情况下,去重聚合使用与连接相同的分区方式,因此可以以流式处理的方式执行,而无需在网络上进行数据交换,并且具有最小的内存占用。
基于成本的优化器
在“查询规划”中,您了解了Trino规划器如何将文本形式的查询转换为可执行且优化的查询计划。在“优化规则”中,您了解了各种优化规则,并了解了它们在执行时对查询性能的重要性。在“实现规则”中,您还了解到了如果没有这些规则,查询计划将无法执行。
我们从接收用户的查询文本开始,一直走到最终的执行计划准备就绪。在这个过程中,我们看到了一些关键的计划转换,它们使计划的执行速度提高了数个数量级,或者使计划能够被执行。
现在让我们更仔细地看一下计划转换,这些转换不仅基于查询的形状,而且更重要的是基于正在查询的数据的形状做出决策。这就是Trino先进的基于成本的优化器(CBO)所做的工作。
成本的概念
在之前的内容中,我们使用了一个示例查询作为我们的工作模型。为了方便起见和帮助理解,让我们再次使用类似的方法。正如您在示例4-8中所看到的,删除了与本节无关的某些查询子句。这样可以让您专注于查询规划器的基于成本的决策。
SELECT
n.name AS nation_name,
avg(extendedprice) as avg_price
FROM nation n, orders o, customer c, lineitem l
WHERE n.nationkey = c.nationkey
AND c.custkey = o.custkey
AND o.orderkey = l.orderkey
GROUP BY n.nationkey, n.name
ORDER BY nation_name;
在没有基于成本的决策的情况下,查询规划器规则会优化该查询的初始计划,生成一个计划,如示例4-9所示。该计划仅由SQL查询的词法结构决定。优化器仅使用了句法信息,因此有时被称为句法优化器。这个名称是用来幽默地强调优化的简单性。由于查询计划仅基于查询本身,您可以通过调整查询中表的句法顺序来手动调优或优化查询。
- Aggregate[by nationkey...; orders_sum := sum(totalprice)]
- InnerJoin[o.orderkey = l.orderkey]
- InnerJoin[c.custkey = o.custkey]
- InnerJoin[n.nationkey = c.nationkey]
- TableScan[nation]
- TableScan[customer]
- TableScan[orders]
- TableScan[lineitem]
现在假设查询被以不同的方式编写,只改变了WHERE条件的顺序:
SELECT
n.name AS nation_name,
avg(extendedprice) as avg_price
FROM nation n, orders o, customer c, lineitem l
WHERE c.custkey = o.custkey
AND o.orderkey = l.orderkey
AND n.nationkey = c.nationkey
GROUP BY n.nationkey, n.name;
由于查询中WHERE条件的顺序不同,计划最终得到了不同的连接顺序:
- Aggregate[by nationkey...; orders_sum := sum(totalprice)]
- InnerJoin[n.nationkey = c.nationkey]
- InnerJoin[o.orderkey = l.orderkey]
- InnerJoin[c.custkey = o.custkey]
- TableScan[customer]
- TableScan[orders]
- TableScan[lineitem]
- TableScan[nation]
一个简单的条件顺序更改会影响查询计划,从而影响查询的性能,这对于SQL分析师来说是不方便的。要创建高效的查询,需要对Trino处理查询的方式有内部了解。查询的作者不应该需要拥有这种知识才能获得Trino的最佳性能。此外,与Trino一起使用的工具(如Apache Superset、Tableau、Qlick或Metabase)通常支持许多不同的数据库和查询引擎,并且不会为Trino编写优化的查询。
基于成本的优化器确保这两个查询变体产生相同的最佳查询计划,供Trino的执行引擎处理。
从时间复杂度的角度来看,无论是将nation表与customer表连接,还是反过来将customer表与nation表连接都是一样的。两个表都需要进行处理,在哈希连接实现的情况下,总运行时间与输出行数成正比。然而,时间复杂度并不是唯一重要的因素。这通常适用于处理数据的程序,但对于大型数据库系统来说尤其如此。Trino还需要关注内存使用和网络流量。为了推断连接的内存和网络使用情况,Trino需要更好地了解连接的实现方式。
CPU时间、内存需求和网络带宽使用是查询执行时间的三个维度,无论是在单个查询还是并发工作负载中。这些维度构成了Trino中的成本。
Join的成本
在使用等号(=)条件连接两个表时,Trino实现了一种称为哈希连接的扩展算法。连接的其中一个表被称为构建侧(build side)。该表用于构建具有连接条件列作为键的查找哈希表。另一个连接的表被称为探测侧(probe side)。一旦查找哈希表准备好,就会处理来自探测侧的行,并且可以在常数时间内使用哈希表找到与构建侧匹配的行。默认情况下,Trino使用三级哈希来尽可能并行化处理:
-
两个连接的表基于连接条件列的哈希值在工作节点之间进行分布。应该匹配的行在连接条件列上具有相同的值,因此它们被分配给同一个节点。这样可以通过在此阶段使用的节点数量减少问题的规模。这种节点级数据分配是哈希的第一级。
-
在节点级别上,构建侧进一步分散到构建侧的工作线程中,再次使用哈希函数。构建哈希表是一个CPU密集型过程,使用多个线程来完成工作可以极大提高吞吐量。
-
每个工作线程最终产生最终查找哈希表的一个分区。每个分区本身就是一个哈希表。这些分区组合成一个两级查找哈希表,以避免将探测侧也分散到多个线程中。探测侧仍然在多个线程中处理,但是线程按批次分配工作,这比使用哈希函数对数据进行分区更快。
正如你所看到的,构建侧被保留在内存中以实现快速的内存数据处理。当然,这也会带来与构建侧大小成比例的内存占用。这意味着构建侧必须适应节点上可用的内存。这也意味着其他操作和其他查询可用的内存较少。这是与连接相关的内存成本。还有网络成本。在先前描述的算法中,两个连接的表都通过网络传输以实现节点级数据分配。
基于成本的优化器可以选择哪个表应该成为构建表,从而控制连接的内存成本。在某些条件下,优化器还可以避免将其中一个表发送到网络,从而减少网络带宽的使用(降低网络成本)。为了完成其工作,基于成本的优化器需要知道连接的表的大小,这通过表统计信息提供。
表统计信息
在“基于连接器的架构”中,您学习了连接器的作用。每个表都由一个连接器提供。除了表的模式信息和实际数据访问权限之外,连接器还可以提供表和列的统计信息:
- 表中的行数
- 列中的不同值数量
- 列中空值的比例
- 列中的最小值和最大值
- 列的平均数据大小
当然,如果某些信息缺失,例如不知道varchar列的平均文本长度,连接器仍然可以提供其他信息,而成本优化器将使用可用的信息。
通过估计连接表中的行数,以及可选的列的平均数据大小,成本优化器已经具备了足够的知识来确定我们示例查询中表的最优顺序。CBO可以从最大的表(lineitem)开始,然后依次连接其他表:orders,然后customer,最后nation:
- Aggregate[by nationkey...; orders_sum := sum(totalprice)]
- InnerJoin[l.orderkey = o.orderkey]
- InnerJoin[o.custkey = c.custkey]
- InnerJoin[c.nationkey = n.nationkey]
- TableScan[lineitem]
- TableScan[orders]
- TableScan[customer]
- TableScan[nation]
这样的计划是不错的,并且应该被考虑,因为每个连接都将较小的关系作为构建侧,但它并不一定是最优的。如果您运行示例查询,并使用提供表统计信息的连接器,您可以通过会话属性启用CBO:
SET SESSION join_reordering_strategy = 'AUTOMATIC';
利用连接器提供的表统计信息,Trino可能会提出不同的计划:
- Aggregate[by nationkey...; orders_sum := sum(totalprice)]
- InnerJoin[l.orderkey = o.orderkey]
- TableScan[lineitem]
- InnerJoin[o.custkey = c.custkey]
- TableScan[orders]
- InnerJoin[c.nationkey = n.nationkey]
- TableScan[customer]
- TableScan[nation]
选择这个计划是因为它避免了将最大的表(lineitem)多次通过网络发送。该表只在节点之间分散一次。 最终的计划取决于加入表的实际大小和集群中节点的数量,因此如果您自己尝试,可能会得到不同于此处显示的计划。
谨慎的读者会注意到,加入顺序仅基于加入条件、表之间的连接和表的数据大小(包括行数和每个列的平均数据大小)进行选择。其他统计数据对于优化更复杂的查询计划非常重要,这些计划包含了表扫描和连接之间的中间操作,例如过滤器、聚合和非内连接。
过滤器统计信息
正如您刚才看到的,了解参与查询的表的大小对于正确重新排序查询计划中的连接表非常重要。然而,仅仅知道表的大小是不够的。考虑到我们的示例查询的修改,用户添加了另一个条件,如 l.partkey = 638,以便在数据集中深入了解有关特定物品的订单信息:
SELECT
n.name AS nation_name,
avg(extendedprice) as avg_price
FROM nation n, orders o, customer c, lineitem l
WHERE n.nationkey = c.nationkey
AND c.custkey = o.custkey
AND o.orderkey = l.orderkey
AND l.partkey = 638
GROUP BY n.nationkey, n.name
ORDER BY nation_name;
在添加条件之前,lineitem是最大的表,并且查询计划旨在优化处理该表。但是现在,经过筛选的lineitem成为了最小的连接关系之一。 观察查询计划可以发现,经过筛选的lineitem表现在已经足够小。CBO将该表放置在连接操作的构建侧,以便它作为其他表的筛选器:
- Aggregate[by nationkey...; orders_sum := sum(totalprice)]
- InnerJoin[l.orderkey = o.orderkey]
- InnerJoin[o.custkey = c.custkey]
- TableScan[customer]
- InnerJoin[c.nationkey = n.nationkey]
- TableScan[orders]
- Filter[partkey = 638]
- TableScan[lineitem]
- TableScan[nation]
为了估算经过筛选的lineitem表中的行数,CBO再次使用连接器提供的统计信息:列中的不同值数量和列中NULL值的比例。对于partkey = 638条件,没有NULL值满足该条件,因此优化器知道行数会按partkey列中的NULL值比例减少。此外,如果假设列中的值大致均匀分布,可以推导出最终的行数:
filtered rows = unfiltered rows * (1 - null fraction)
/ number of distinct values
显然,该公式仅在值的分布是均匀的情况下才正确。然而,优化器并不需要知道准确的行数;它只需要知道一个估算值,因此一般来说稍微偏差一些并不是问题。当然,如果某个项的购买频率远高于其他项,比如Starburst糖果,估算可能会相差太远,优化器可能会选择一个糟糕的计划。目前,当出现这种情况时,您需要禁用CBO。 将来,连接器将能够提供有关数据分布的信息来处理这类情况。例如,如果数据的直方图可用,则CBO可以更准确地估算筛选后的行数。
分区表的表统计信息
特殊类型的过滤表值得特别提及:分区表。数据可以组织成分区表,可以通过Hive连接器访问Hive/HDFS数据仓库,或者通过Iceberg或Delta Lake表格式和连接器访问现代数据湖(lakehouse);请参阅“Hive Connector for Distributed Storage Data Sources”和“Modern Distributed Storage Management and Analytics”。当数据通过分区键进行过滤时,查询执行过程中只读取匹配的分区。此外,由于表统计信息存储在每个分区上,CBO仅获取读取的分区的统计信息,因此更准确。
当然,每个连接器都可以为过滤关系提供此类改进的统计信息。这里我们只提到Hive连接器提供统计信息的方式。
连接枚举(Join Enumeration)
到目前为止,我们已经讨论了CBO如何利用数据统计信息来为执行查询制定最优计划。特别是,它选择了最佳的连接顺序,这对查询性能有着重大影响,原因有两个主要方面:
- 哈希连接实现:
哈希连接的实现是非对称的。仔细选择哪个输入作为构建侧,哪个输入作为探测侧非常重要。
- 分布式连接类型:
仔细选择是广播还是重新分配数据到连接输入非常重要。
广播连接与分布式连接
在前面的部分中,您了解了哈希连接的实现方式以及构建和探测方的重要性。由于Trino是一个分布式系统,连接操作可以在一个工作节点集群上并行执行,每个工作节点处理连接的一部分。为了进行分布式连接,数据可能需要在网络上进行分布,而不同的策略可以选择,其效率取决于数据的形状。
广播连接策略(Broadcast Join Strategy)
在广播连接策略中,连接的构建端(build side)被广播到所有并行执行连接的工作节点。换句话说,每个连接操作都会获得构建端数据的完整副本,如图4-12所示。这种方式在语义上只有在探测端(probe side)保持分布式且不重复的情况下才是正确的,否则会产生重复的结果。
广播连接策略在构建端较小且可以进行成本有效的数据传输时具有优势。当探测端非常大时,该策略的优势更为明显,因为它避免了在分布式连接中需要重新分配数据的情况。
分布式连接策略
在分布式连接策略中,构建端和探测端的输入数据都被重新分布到集群中的各个节点上,从而使工作节点能够并行执行连接操作。与广播连接不同,数据在网络上传输的方式是每个工作节点接收到数据集的唯一部分,而不是数据的副本。数据的重新分布必须使用分区算法,以确保匹配的连接键值被发送到同一个节点。例如,假设我们在特定节点上有以下连接键的数据集:
Probe: {4, 5, 6, 7, 9, 10, 11, 14}
Build: {4, 6, 9, 10, 17}
考虑一个简单的分区算法:
if joinkey mod 3 == 0 then send to Worker 1
if joinkey mod 3 == 1 then send to Worker 2
if joinkey mod 3 == 2 then send to Worker 3
这个分区算法在Worker 1上的结果如下:
Probe:{6, 9}
Build:{6, 9}
Worker 2处理不同的探测数据和构建数据:
Probe: {4, 7, 10}
Build: {4, 10}
最后,Worker 3处理不同的子集:
Probe:{5, 11, 14}
Build: {17}
通过对数据进行分区,CBO保证了在处理过程中可以并行计算连接操作,而无需在处理过程中共享信息。分布式连接的优势在于它允许Trino计算连接操作,其中两个连接方都非常大,并且单台计算机的内存无法容纳整个探测方的数据。缺点是需要通过网络传输额外的数据。
在广播连接和分布式连接策略之间进行决策时必须进行成本估算。每种策略都有权衡,我们必须考虑数据统计信息以确定最佳策略。此外,这也需要在连接重新排序过程中进行决策。根据连接顺序和应用过滤器的位置,数据形状会发生变化。这可能会导致在一种连接顺序方案中,两个数据集之间的分布式连接效果最佳,而在另一种方案中,广播连接效果更好。连接枚举算法将考虑这一点。
与表统计数据一起工作
为了在Trino中利用CBO,您的数据必须具有统计信息。如果没有数据统计信息,CBO无法做出很多决策;它需要数据统计信息来估算不同计划的行数和成本。
由于Trino不存储数据,生成Trino的统计信息取决于连接器的实现。截至撰写本文时,Hive、Delta Lake和Iceberg等对象存储系统的连接器以及包括PostgreSQL在内的多个RDBMS连接器向Trino提供数据统计信息。我们预计随着时间的推移,会有更多的连接器支持统计信息收集,您应该继续参考Trino文档以获取最新信息。
表统计信息的收集和维护取决于底层数据源。让我们以Hive连接器为例,介绍一些收集统计信息的方式:
- 使用Trino的ANALYZE命令收集统计信息。
- 在向表中写入数据时,使Trino收集统计信息。
- 使用Hive的ANALYZE命令收集统计信息。
重要的是要注意,Trino和Hive连接器将统计信息存储在Hive元存储中,这与Hive用于存储统计信息的位置相同。其他连接器使用与连接的数据源相同的元数据存储方式,例如Iceberg表格式中的元数据文件或某些关系数据库中的信息模式。因此,如果在Hive和Trino之间共享相同的表,则它们会互相覆盖统计信息。在确定如何管理统计信息收集时,这是您应考虑的因素之一。
Trino的ANALYZE命令
Trino提供了一个ANALYZE命令,用于收集连接器(例如Hive连接器)的统计信息。运行时,Trino使用其执行引擎计算列级统计信息,并将统计信息存储在Hive元数据存储中。其语法如下:
ANALYZE table_name [ WITH ( property_name = expression [, ...] ) ]
例如,如果您想收集并存储来自flights表的统计信息,可以运行以下命令:
ANALYZE datalake.ontime.flights;
在分区的情况下,如果我们只想分析特定的分区,可以使用WITH子句,如下所示:
ANALYZE datalake.ontime.flights WITH (partitions = ARRAY[ARRAY['01-01-2019']])
当您有多个分区键,并且希望每个键都成为下一个数组中的元素时,需要使用嵌套数组。如果您有多个要分析的分区,顶层数组将被使用。在Trino中,指定分区非常有用。例如,您可能有某种类型的ETL过程会创建新的分区。随着新数据的到来,统计信息可能会变得过时,因为它们不包含新数据。但是,通过更新新分区的统计信息,您无需重新分析所有先前的数据。
将数据写入磁盘时收集统计信息
如果您的表的数据始终通过Trino进行写入,可以在写入操作期间收集统计信息。例如,如果运行CREATE TABLE AS或INSERT SELECT查询,Trino会在将数据写入磁盘(例如HDFS或S3)时收集统计信息,然后将统计信息存储在Hive元数据存储中。
这是一个有用的功能,因为它不需要您运行手动的ANALYZE步骤。统计信息永远不会过时。但是,为了使此功能正常工作并达到预期的效果,表中的数据必须始终由Trino进行写入。
这个过程的开销经过了广泛的基准测试和测试,显示对性能影响微乎其微。要启用此功能,您可以通过使用Hive连接器将以下属性添加到您的目录属性文件中:
hive.collect-column-statistics-on-write=true
Hive分析命令
在Trino之外,您仍然可以使用Hive的ANALYZE命令来收集Trino的统计信息。统计信息的计算由Hive执行引擎而不是Trino执行引擎执行,因此结果可能会有所不同,并且使用Hive生成的统计信息与使用Trino生成的统计信息时,Trino的行为可能会有所不同。通常建议使用Trino来收集统计信息。但是,有时可能有使用Hive的原因,例如如果数据是作为更复杂的流程的一部分进行传输,并且与其他可能需要使用统计信息的工具共享。要使用Hive收集统计信息,可以运行以下命令:
hive> ANALYZE TABLE datalake.ontime.flights COMPUTE STATISTICS;
hive> ANALYZE TABLE datalake.ontime.flights COMPUTE STATISTICS FOR COLUMNS;
有关Hive ANALYZE命令的详细信息,请参考官方的Hive文档。
显示表的统计信息
一旦您收集了统计信息,通常可以通过查看它们来了解更多信息。您可能想要这样做来确认已收集到统计信息,或者您可能正在调试性能问题并希望查看使用的统计信息。 Trino提供了一个SHOW STATS命令:
SHOW STATS FOR datalake.ontime.flights;
或者,如果您想查看数据子集的统计信息,可以提供一个过滤条件。例如:
SHOW STATS FOR (SELECT * FROM datalake.ontime.flights WHERE year > 2010);