Trino Cost-Based Optimizer 介绍

1,493 阅读8分钟

Trino 中的 CBO 在业界的标准 benchmark 中取得了不俗的成绩。CBO 基于以下几个因素来决策:

  • 查询的形态(shape of the query)

  • 过滤条件(Filters)

  • 表统计数据(Table Statistics)

本文会介绍 Trino 中的表统计数据,以及从中可以推导出什么信息。

背景

在进入 Trino 如何分析统计数据前,先建立一个场景。假设一个数据科学家想要通过历史订单信息知道哪位顾客花了最多的钱(可能是为了提供一些折扣)。他们也许会执行下面的查询:

SELECT c.custkey, sum(l.price)
FROM customer c, orders o, lineitem l
WHERE c.custkey = o.custkey AND l.orderkey = o.orderkey
GROUP BY c.custkey ORDER BY sum(l.price) DESC;

Trino 需要为这个查询创建一个执行计划。首先,将查询转换为最简单的计划 - 此时会为 FROM customer c, orders o, lineitem l 创建 CROSS JOINS,为 WHERE c.custkey = o.custkey AND l.orderkey = o.orderkey 创建 FILTER。初始的计划非常 naïve - CROSS JOINS 会产生海量的中间数据。对于 Trino 而言执行这样的计划毫无意义。取而代之的是,Trino 会将计划进行转换,如下所示。(注意:为了简洁性,只画出了部分查询计划,聚合以及排序没有画出)。

image

新的计划比带有 CROSS JOINS 的好上不少。但如果我们考虑代价的话,可以做的更好一些。

先不深入数据库内部是如何实现 JOIN 操作的,让我们先认为选择哪张表作为 JOIN 操作的右表会有很大的影响(简单解释:在 JOIN 计算过程中,右表需要被保存在内存中)。正因为如此,下面的计划可以产生相同的结果,但是会有不同的执行时间以及内存需求。

image

CPU 时间,内存需求以及网络带宽是单个查询以及并发查询执行耗时中的 3 个维度。在 Trino 中,这些维度使用代价来描述。

数据科学家知道大多数顾客下了一个订单,每个订单中至少会有一个商品(许多订单中包含了许多商品),因此lineitem是最大的表,orders次之而customer最小。当 JOINcustomer 和 orders 时,将 orders 作为 JOIN 的右表不是一个好主意。但是,Planner 是如何知道的呢?Planner 肯定不可能仅通过表名就得到这个信息,这就是表统计数据的用武之地了。

表统计数据(Table statistics)

Trino 拥有基于 Connector 的架构。Connector 可以提供表、列统计数据:

  • 表行数

  • 列不重复值数量(NDV)

  • 列中NULL值比例

  • 列中的最大/最小值

  • 列的平均数据大小

当然,如果一些信息有遗漏 - 比如,一个 varchar 列中的平均文本长度未知 - Connector 仍然能够为 Cost-Based Optimizer 提供其它可用的信息。

在数据科学家的例子中,数据规模可以呈现为:

image

以此为前提,Trino’s Cost-Based Optimizer 可以得到拥有完全不同 JOIN 顺序的计划。

image

过滤统计信息(Filter statistics)

正如我们所看到的,了解查询中涉及表的大小是为 JOIN 重新排序的基础。然而,仅仅知道大小是不够的。回到我们的例子,数据科学家可能希望深入了解他们之前查询的结果,以了解哪些客户在特定项目上重复购买并花费了最多的钱(显然,这一定是一些消耗品或手机)。为此,他们将使用与原始查询几乎相同的查询,只是增加了一个条件。

SELECT c.custkey, sum(l.price)
FROM customer c, orders o, lineitem l
WHERE c.custkey = o.custkey AND l.orderkey = o.orderkey
  AND l.item = 106170                              --- additional condition
GROUP BY c.custkey ORDER BY sum(l.price) DESC;

附加的 FILTER 可能会在 JOIN 之后或之前应用。显然,尽早过滤是最佳策略,但这也意味着 JOIN 中涉及的数据实际大小将有所不同。在数据科学家的例子中,连接顺序确实会有所不同。

image

内部实现

执行时间及代价(Execution Time and Cost)

从外部视角来看,只有 3 件真正重要的事:

  • 执行时间

  • 执行代价(以美元计算)

  • 同时运行多个并发查询的能力

执行时间通常被称为“墙上时间(wall time)”,以强调我们对“CPU时间”或所涉及的机器/节点/线程数量并不真正感兴趣,墙上时间是最终的判断。如果在每次运行查询时数据科学家们不被强迫喝咖啡/吃午饭,那就太好了。另一方面,CFO 会有兴趣将集群成本保持在尽可能低的水平(当然,以不影响员工的效率为前提)。最后,系统管理员需要确保所有集群用户都能同时使用。也就是说,该集群可以一次处理多个查询,拥有足够的吞吐量,以至于每个用户观察到的“墙上时间”都是令人满意的。

image

只针对上述维度中的一个进行优化是可能的。例如,我们可以只有一个单节点集群,CFO 会很高兴(但员工会去别的地方)。相反,我们可能拥有千个节点的集群,即使公司负担不起。用户会(最初)很高兴,直到公司破产。最终,我们还是需要权衡这些因素,意味着查询需要尽可能快地执行,同时使用尽可能少的资源。

在 Trino 中,这些因素使用代价的概念来建模,它覆盖了 CPU 代价、内存需求和网络带宽使用等属性。探索查询执行计划的空间,计算代价并进行比较。选择总代价最小的计划来执行。这种方法巧妙地平衡了集群用户、管理员和 CFO 的需求。

查询计划中每个操作的代价计算方式因操作的类型而异,同时考虑到操作中涉及的统计信息。现在,让我们看看统计信息来自哪里。

统计信息(Statistics)

In our Data Scientist’s example, the row counts for tables were taken directly from table statistics, i.e. provided by a connector. But where did “~3K rows” come from? Let’s dive into some nitty-gritty details.

在数据科学家的例子中,表的行数直接来自表的统计信息,即由 Connector 提供。但是“~3K 行”是从哪里来的呢?让我们深入了解一些细节。

一个查询执行计划由一系列作为“构建元素”的操作组成,包括:

  • 表扫描(读取表;运行时它会和一个 Filter 合并在一起)

  • 过滤(SQL 中的 WHERE条件,或者由 Planner 推导出来的任何条件)

  • 投影 (即,计算输出表达式)

  • 关联

  • 聚合 (事实上对聚合而言也有一些不同的“构建元素”,这里不展开)

  • 排序 (SQL’s ORDER BY )

  • LIMIT (SQL’s LIMIT )

  • 排序和 LIMIT 的组合 (SQL’s ORDER BY .. LIMIT .. 值得单独处理)

  • 以及更多!

下面讨论如何计算最普遍的“构建元素”的统计数据。

Table Scan 统计信息

image

如“表统计数据”部分所述,定义表的 Connector 负责提供表统计数据。此外,Connector 将被告知要应用于从表中读取数据的任何过滤条件。这也许很重要,例如在 Hive 分区表的情况下,统计信息是按分区存储的。如果过滤条件排除了一些(或许多)分区,统计信息将考虑较小的数据集(剩余分区),并且会更准确。

回忆一下,Connector 可以提供以下的表/列统计数据:

  • 表行数

  • 列不重复值数量(NDV)

  • 列中NULL值比例

  • 列中的最大/最小值

  • 列的平均数据大小

Filter 统计信息

image

当考虑过滤操作时,分析过滤的条件并计算得到以下预估:

  • 数据行通过过滤条件的概率是多少。由此,得出过滤后的预期行数

  • 过滤条件中涉及列的 NULL 值的比例(对于大多数条件,是 0%)

  • 过滤条件中涉及列的不重复值数量(NDV)

  • number of distinct values for columns that were not part of the filtering condition, if their original number of distinct values was more than the expected number of data rows that pass the filter

例如,像l.item = 106170这样的条件,我们可以观察到:

  • 没有l.item为NULL的行满足条件

  • 过滤操作后只有一个不同的值l.item(106170)

  • 平均而言,预期通过过滤的数据行数将等于number_of_input_rows * fraction_of_non_nulls / distinct_values。(当然,这假设用户最常下钻他们真正拥有的数据,这是一个相当合理的假设,也是安全的)。

Projection 统计信息

image

投影(l.item – 1 AS iid)类似于过滤条件,除了它们不会影响操作后的预期行数。

对于投影,计算以下类型的列统计信息:

  • 投影产生的不同值的数量(NDV)

  • 投影产生的NULL值的比例

  • 投影产生的最大/最小值

当然,如果iid只返回给用户,那么这些统计信息就没有用了。但是,如果稍后会在过滤或关联操作中使用,这些统计信息对正确估算满足过滤条件或关联操作后的行数很重要。

Conclusion

总而言之,Trino 的基于代价的优化器在概念上是一件非常简单的事情。考虑可替代的查询计划,选择最佳计划并执行之。然而,细节并不那么简单。幸运的是,要使用 Trino,不需要知道所有这些细节。当然,任何有技术倾向、喜欢在数据库内部漫游的人都可以来研究 Trino 代码

启用 Trino CBO 非常简单:

  • set optimizer.join-reordering-strategy=AUTOMATIC and join-distribution-type=AUTOMATIC in your config.properties ,

  • analyze your tables,

  • 没有第三步了!

今天带 Trino CBO 去兜风,让我们知道你的 Trino 体验!