作者:贺凯,StarRocks Committer
导读:欢迎来到 StarRocks 技术内幕系列文章,我们将为你全方位揭晓 StarRocks 背后的技术原理和实践细节,助你逐步上手这款明星开源数据库产品。
#01
Join 背景
—
上图列举了常见的 Join 类型:
Inner Join:输出左表和右表的交集,根据连接条件产生一对多的结果行。
Join 的执行效率通常分成两部分来优化,一是提高单机上 Join 算子的效率,二是规划一个合理的 Join 计划,尽可能地减少 Join 的输入/执行成本。本文主要集中在后者的介绍上,那么接下来就从 Join 优化的难点开始讲起。
难点一,Join 的实现方式多。
在多表 Join 的场景下,选择度高的 Join 先执行,会提高整个 SQL 的效率,但是怎么判断出 Join 的执行顺序呢?这却是十分困难的。
在分布式系统中,会通过 Re-Shuffle 或者广播数据的方式,将需要的数据发送到目的端参与计算,分布式数据库中 Join 也是如此。但这也带来了另外一个问题,一个单机数据库上最优的执行计划,因为没有考虑数据的分布 & 网络传输的开销,放在分布式数据库上未必是最优的执行计划。分布式数据库在规划 Join 的执行计划和执行方式时,需要考虑数据的分布和网络成本。
StarRocks 目前 Join 的算法主要是一个 Hash Join,默认使用右表去构建 Hash 表,在这个前提下,我们总结了五个优化方向:
1. 不同 Join 类型的算子,性能是不同的,尽可能使用性能高的 Join 类型,避免使用性能差的 Join 类型。根据 Join 输出的数据量,大致上的性能排序:Semi-Join/Anti-Join > Inner Join > Outer Join > Full Outer Join > Cross Join。
2. Hash Join 实现时,使用小表做 Hash 表,远比用一个大表做 Hash 表高效。
3. 多表 Join 时,优先执行选择度高的 Join,能大幅减少后续 Join 的开销。
4. 尽可能减少参与 Join 的数据量。
#02
Join 逻辑优化
—
第一个优化规则紧贴着前面所说的第一个优化原则,也就是把低效率的 Join 类型转为高效的 Join 类型,主要包括以下三个转换规则。
-- 转换前Select * From t1, t2 Where t1.v1 = t2.v1;-- 转换后, Where t1.v1 = t2.v1是连接关系谓词Select * From t1 Inner Join t2 On t1.v1 = t2.v1;
转换规则二:Outer Join 转换为 Inner Join
当满足以下约束时,可以将 Outer Join 转为 Inner Join:
1. Left / Right Outer Join 上存在一个 Right / Left 表的相关谓词;
-- 转换前Select * From t1 Left Outer Join t2 On t1.v1 = t2.v1 Where t2.v1 > 0;-- 转换后, t2.v1 > 0 是一个 t2 表上的严格谓词Select * From t1 Inner Join t2 On t1.v1 = t2.v1 Where t2.v1 > 0;
需要注意的是,在 Outer Join 中,需要根据 On 子句的连接谓词进行补 Null 操作,而不是过滤,所以该转换规则不适用 On 子句中的连接谓词。例如:
Select * From t1 Left Outer Join t2 On t1.v1 = t2.v1 And t2.v1 > 1;-- 显然,上面的SQL和下面SQL的语义并不等价Select * From t1 Inner Join t2 On t1.v1 = t2.v1 And t2.v1 > 1;
-- 转换前Select * From t1 Full Outer Join t2 On t1.v1 = t2.v1 Where t1.v1 > 0;-- 转换后, t1.v1 > 0 是一个左表上的谓词,且是一个严格谓词Select * From t1 Left Outer Join t2 On t1.v1 = t2.v1 Where t1.v1 > 0;
谓词下推是一个 Join 上非常重要,也是很常用的一个优化规则,其主要目的是提前过滤 Join 的输入,从而提升 Join 的性能。
对于 Where 子句,当满足以下约束时,我们可以进行谓词下推,并且伴随着谓词下推,我们可以做 Join 类型转换:
1. 任意 Join 类型;
Select *From t1 Left Outer Join t2 On t1.v1 = t2.v1Left Outer Join t3 On t2.v2 = t3.v2Where t1.v1 = 1 And t2.v1 = 2 And t3.v2 = 3;
其谓词下推的流程如下。
连接谓词只能 bind 到 [Right/Left] 输入上。
Select *From t1 Left Outer Join t2 On t1.v1 = t2.v1 And t1.v1 = 1 And t2.v1 = 2Left Outer Join t3 On t2.v2 = t3.v2 And t3.v2 = 3;
其 On 连接谓词下推的流程如下。
第一步,下推 t1 Left Join t2 Left Join t3 上可以 bind 到右表的连接谓词 (t3.v2 = 3),此时无法将 Left Outer Join 转换为 Inner Join。
第二步,下推 t1 Left Join t2 上可以 bind 到右表的连接谓词 (t2.v1 = 2)。由于t1.v1 = 1是 bind 到左表的,下推以后会过滤 t1 的数据,所以该行为与 Left Outer Join 语义不符,无法下推该谓词。
在之前的谓词下推的规则中,只能下推满足合取语义的谓词,例如 t1.v1 = 1 And t2.v1 = 2 And t3.v2 = 3 中,三个子谓词都是通过合取谓词连接,而无法下推析取语义的谓词,例如t1.v1 = 1 Or t2.v1 = 2 Or t3.v2 = 3。
-- 谓词提取前Select *From t1 Join t2 On t1.v1 = t2.v1Where (t2.v1 = 2 AND t1.v2 = 3) OR (t2.v1 > 5 AND t1.v2 = 4)-- 利用(t2.v1 = 2 AND t1.v2 = 3) OR (t2.v1 > 5 AND t1.v2 = 4)进行列值推导,推导出(t2.v1 >= 2),(t1.v2 IN (3, 4))两个谓词Select *From t1 Join t2 On t1.v1 = t2.v1Where (t2.v1 = 2 AND t1.v2 = 3) OR (t2.v1 > 5 AND t1.v2 = 4)AND t2.v1 >= 2 AND t1.v2 IN (3, 4);
在谓词上,除了上述的谓词提取,还有另一个重要的优化,叫等价推导。等价推导主要利用了 Join 的连接关系,从左表/右表列的取值范围,推导出右表/左表对应列的取值范围。例如:
-- 原始SQLSelect *From t1 Join t2 On t1.v1 = t2.v1Where (t2.v1 = 2 AND t1.v2 = 3) OR (t2.v1 > 5 AND t1.v2 = 4)-- 利用(t2.v1 = 2 AND t1.v2 = 3) OR (t2.v1 > 5 AND t1.v2 = 4)进行列值推导,推导出(t2.v1 >= 2),(t1.v2 IN (3, 4))两个谓词Select *From t1 Join t2 On t1.v1 = t2.v1Where (t2.v1 = 2 AND t1.v2 = 3) OR (t2.v1 > 5 AND t1.v2 = 4)AND t2.v1 >= 2 AND t1.v2 IN (3, 4);-- 利用连接谓词(t1.v1 = t2.v1)和(t2.v1 >= 2)进行等价推导,推导出(t1.v1 >= 2)谓词Select *From t1 Join t2 On t1.v1 = t2.v1Where (t2.v1 = 2 AND t1.v2 = 3) OR (t2.v1 > 5 AND t1.v2 = 4)AND t2.v1 >= 2 AND t1.v2 IN (3, 4) AND t1.v1 >= 2;
几乎没有约束,可以从左表的谓词推导出右表,反之亦可。
在 Inner Join 上和 Where 谓词相同,没有条件约束;
除 Inner Join 外,仅支持 Semi Join 和 Outer Join,且仅支持与 Join 方向相反的单向推导。例如,Left Outer Join 可以从左表的谓词推导出右表的谓词,Right Outer Join 可以从右表的谓词推导出左表的谓词。
除了谓词可以下推,Join 上也支持 Limit 的下推。当 SQL 是一个 Outer Join 或 Cross Join 时,可以将 Limit 下推到输出行数稳定的孩子上。其中,Left Outer Join 输出行数至少和左孩子一致,那么 Limit 可以下推到左表上,Right Outer Join 反之。
-- 下推前Select *From t1 Left Outer Join t2 On t1.v1 = t2.v1Limit 100;-- 下推后Select *From (Select * From t1 Limit 100) t Left Outer Join t2 On t.v1 = t2.v1Limit 100;
-- 下推前Select *From t1 Join t2Limit 100;-- 下推后Select *From (Select * From t1 Limit 100) x1 Join(Select * From t2 Limit 100)Limit 100;
#03
Join Reorder
—
Join Reorder 用于推断多表 Join 的执行顺序,数据库需要尽可能地先执行一个高选择度的 Join,这样就能减少后续 Join 的输入数据,从而提升性能。
StarRocks 的 Join Reorder,主要是在一个连续的 Inner Join 或者 Cross Join 上工作。以下图为例,StarRocks 会将一组连续的 Inner / Cross Join 叫做一个 Multi Join Node,而 Multi Join Node 就是一个Join Reorder 的单位,即下推存在两个 Multi Join Node,StarRocks 将分别对着两个 Multi Join Node 进行 Join Reorder 推导。
目前业界实现 JoinReorder 的算法有很多种,或者基于不同模型的,例如:
Genetic:GreenPlum
......
其中 StarRocks 实现了 Left-Deep、Exhaustive、Greedy、DPsub,接下来会着重介绍一下 StarRocks 中 Exhaustive、Greedy 的实现。
穷举算法通常包括两个规则,通过这两个规则基本上覆盖 Join 的全排列组合。
规则一:Join 的交换律。
A Join B 转为 B Join A,转换过程中需要注意 Join 类型的变化,比如 Left Outer Join 交换后变为 Right Outer Join。
规则二:Join 的结合律。
(A Join B) Join C 转为 A Join(B Join C)。结合律上 StarRocks 又分为两种,一种是 Inner / Cross Join 的结合律,另一种是 Semi Join 的结合律。
StarRocks 在贪心算法上主要参考多序列贪心算法,其次做了一个小改进,就是对于贪心算法每层产生的结果,StarRocks 都会保留 10 个最优解(可能不是全局最优),以此往后迭代,最终计算出 10 个贪心最优的 Plan。
当然,由于贪心算法的局限性,这样的优化只是提高了计算出全局最优解的概率,并不能保证一定得到全局最优的 Plan。
StarRocks 使用这些 Join Reorder 的算法推导出 N 个 Plan,最终会根据 Cost Model 的算法,估算出每个 Join 的 Cost,整个 Cost 的计算公式如下:
Join Cost: CPU * (Row(L) + Row(R)) + Memory * Row(R)
其中 Row(L)、Row(R) 分别表示 Join 左右孩子的输出行数,公式主要是考虑 CPU 开销,以及 Hash Join 右表做 Hash 表内存的开销,下图详细展示了 StarRocks 中 Join 的输出行数的计算方式。
此外,由于不同算法探索 Join Reorder 的空间不同,StarRocks 按照算法的空间复杂度和耗时做了基本的测试,具体如下。
基于上述耗时的结论,StarRocks 对各个算法的执行做了简单的限制。当在 4 表以内的 Join Reorder 使用穷举算法;4~10 表时会分别使用左深、贪心、动态规划算法产生 1 个、10 个、1 个计划,并且在此基础上会使用 Join 交换律探索更多的 Plan;当 10 表以上时,StarRocks 就只使用贪心和左深产生的 11个 Plan 为基础进行 Reorder;另外,在 StarRocks 没有统计信息时,基于 Cost 的贪心和动规都无法很好地工作,所以只会使用左深产生的 1 个 Plan 为基础 Reorder。
#04
分布式 Join 规划
—
Select * From A Join B on A.a = B.b
Replicate Join:StarRocks 的实验性功能,当每一台 A 表的机器上都存在一份完整的 B 表数据时,直接在本地进行 Join 操作,该 Join 的约束条件比较严格,基本上意味着 B 表的副本数需要和整个集群的机器数保持一致,所以实践意义并不理想。
Select * From A Join B on A.a = B.b
Select * From A Join B on A.a = B.b Join C on A.a = C.c
这里简单举几个 StarRocks 基于 Shuffle Join 和 Broadcast Join 生成的分布式 Plan:
#05
总结
—
本文讲述了 StarRocks 对 Join 查询优化的实践和探索,所有的优化都是紧贴提到的优化原则。当然,用户在自行优化 SQL 时,也完全可以参考如下 5 点,以及 StarRocks 提供的功能进行优化。
1. 不同 Join 类型的算子,性能是不同的,尽可能使用性能高的 Join 类型,避免使用性能差的 Join 类型。根据 Join 输出的数据量,大致的性能排序为:Semi-Join/Anti-Join > Inner Join > Outer Join > Full Outer Join > Cross Join。
2. Hash Join 的实现时,使用小表做 Hash 表,远比用一个大表做 Hash 表高效。
3. 多表 Join 时,优先执行选择度高的 Join,能大幅减少后续 Join 的开销。
4. 尽可能减少参与 Join 的数据量。
5. 尽可能减少分布式 Join 产生的网络成本。
StarRocks 在支持了那么多优化后,也有了更多的心得和更多的规划,比如:
支持更多调度方式,可能优化网络成本开销。
读到这里,好学的你是不是又产生了一些新思考与启发?
扫描下方用户群二维码加入 StarRocks 社区一起自由交流!
关于 StarRocks
面世两年多来,StarRocks 一直专注打造世界顶级的新一代极速全场景 MPP 数据库,帮助企业建立“极速统一”的数据分析新范式,助力企业全面数字化经营。
2021 年 9 月,StarRocks 源代码开放,在 GitHub 上的星数已超过 3400 个。StarRocks 的全球社区飞速成长,至今已有超百位贡献者,社群用户突破 7000 人,吸引几十家国内外行业头部企业参与共建。
StarRocks 技术内幕:
👇 阅读原文了解 StarRocks 产品详细信息