这是我参与「第四届青训营 」笔记创作活动的第3天
前言
数据库查询优化器的搜索技术,基本上分为了基于动态规划的bottom-up search(详见文章 System-R)和基于Cascade/Volcano的top-down search两个流派。
Top-down Optimizer 和 Bottom-up Optimizer 正是基于两种不同search engine的优化器。读者需要注意的是Cascade/Volcano的精髓之一亦是动态规划,那么相较于最早出现的bottom-up search,基于Cascade/Volcano的top-down search有何不同呢?
Volcano Optimizer
Volcano Optimizer 是一种基于成本的优化算法,其目的是基于一些假设和工程算法的实现, 在获得成本较优的执行方案的同时,可以通过剪枝和缓存中间结果(动态规划)的方法降低计算消耗。 这一章在介绍 Volcano Optimizer 的同时也将会引入很多对 Calcite 当中概念的讨论。
成本最优假设
成本最优假设是理解 Volcano Optimizer 实现的要点之一。这一假设认为, 在最优的方案当中,取局部的结构来看其方案也是最优的。
成本最优假设利用了贪心算法的思想,在计算的过程中, 如果一个方案是由几个局部区域组合而成,那么在计算总成本时, 我们只考虑每个局部目前已知的最优方案和成本即可。
换句话说,在 Volcano 优化算法下,下图所表示的关系代数的计算成本,大体上正比于各个部分计算成本的和。 这一假设不仅应用于单个 Operator 节点之间,也应用于子树之间和被规则匹配的区域内外。
Cumulative Cost
对于成本最优假设的另一种更直观的描述是,如果关系代数局部的某个输入的计算成本上升, 那么这一子树的整体成本趋向于上升,反之则会下降。也即是在上图右侧有
上述假设对于大部分关系代数算子都是有效的,但是并非百分之一百准确。 对于部分反例的处理方法将会在后文进一步介绍。
动态规划算法与等价集合
由于引入了成本最优假设,在优化过程中我们就可以对任意子树目前已知的最优方案和最优成本进行缓存。 此后在计算的过程中,如果需要利用这一子树,可以直接使用之前缓存的结果。这里应用了动态规划算法的思想。
要实现这一算法,只需要建立缓存结果到子树双向映射即可。在 Calcite 的实现当中,一颗子树使用其根结点作为代表。 某一棵子树所有可能的变换方案组成的集合被称为等价集合(Equivalent Set), 等价集合将会维护自身元素当中具有最优成本的方案。
等价集合在 Calcite 当中对应的是
RelSet
类。
对每一颗子树都枚举其等价集合的内容会十分耗费空间。其实,对于某一棵以 A 为根结点的子树来说, 我们只关心 A 本身和包含 A 了的匹配内的节点。对于 A 和包含 A 的匹配之外的部分, 我们可以直接链接到子树对应的等价集合当中。基于成本最优假设,在计算方案成本的时候, 我们还可以直接从这些部分的等价集合中选取最佳方案。
假设从 A 起,可以应用两种不同的变换规则,如下图所示:
则除了 A 本身和规则匹配到的部分,其他部分的计算就可以通过递推的方式实现 具体来说,对应上述三种情况,会计算等价集合的元素:
- A 节点结合 B、C 节点等价集合的最优元素和成本
- A、C 转化后的节点结合 B、E、F 节点对应等价集合当中的最优元素和成本
- A、B 转换后的节点结合 C、D、E 节点对应等价集合当中的最优元素和成本
A 所代表的子树对应的最优方案也即是从上述三种方案中选取。
通过链接到子树优化方案的技巧,我们的算法缩减了状态空间、节省了计算量和储存空间。 下图展示了链接到子方案的大致思路。
当然,在关系代数表示中不相邻的部分也可能具有重复的结构,Calcite 在实现的过程中考虑了这种情况, 将会在合适的时候对等价集合进行合并操作,也会出现树中两个不同子树的根指向了同一个等价集合的现象。
要实现树结构的相等计算和查询计算也比较复杂,Calcite 采用了最简单的递归将子树的内容打印成字符串的方法进行 Hash 和比较。 因此在使用 Calcite 时要注意正确实现 RelNode
类的 getDigest
方法。保证将节点的各种属性包含在内, 防止不同的节点被认为等价。
在计算结束后要得到最后的执行方案,只需从根节点开始将原始关系代数当中的结构替换成最优方案当中的即可。
Top-down Vs. Bottom-up
两种search engine孰优孰略已经被讨论了很久而没有定论,实际上两者覆盖的范畴并不完全相同。
Top-down win:
-
Volcano的算法中,包含了logical transformation的过程,因此可以去做等价变化,这不仅限于join ordering,各种类型的query transformation(例如group by placement, filter pushdown...)理论上都可以在top-down的search过程中完成,但bottom-up主要还是针对join ordering,描述等价变换比较困难。
-
Top-down的search方法,有一个最大的优点,就是可以做branch-and-bound pruning,由于每递归到一个logical expression,总是带有目标的属性的,因此可以只针对满足这个属性的目标来枚举,而bottom-up枚举时,父节点的情况并不清楚,因此当前节点需要枚举各种可能的物理输出属性,没法只针对一个"branch",此外由于向下有cost limit这个参数,在深度优先的递归时,一但cost limit无法满足要求就可以立即终止,形成了bound pruning。
Bottom-up win:
- 由于覆盖的search space更大,top-down搜索花费的时间和空间overhead更大,这也是为什么更多大数据生态的系统或者数仓系统会使用这种优化器,由于查询更复杂且涉及的数据量更大,它牺牲了较多的优化时间来换取更优的查询计划。在像SQL Server这样要处理事务型workload的系统中,优化分为了多个阶段,这样可以基于走一些fast path或者基于某些规则提前结束搜索。
综上所述,自底向上的算法最为直观:当我们试图计算节点 A 的最优方案时, 其子树上每个节点对应的等价集合和最优方案都已经计算完成了,我们只需要在 A 节点上不断寻找可以应用的规则,并利用已经计算好的子树成本计算出母树的成本,就可以得到最优方案。 事实上,包括 SQL Server 在内的一些成熟的数据库系统都采用这种方法。
然而这种方案存在一些难以解决的问题:
- 不方便应用剪枝技巧,在查询中可能会遇到在父亲节点的某一种方案成本很高,后续完全无需考虑的情况, 尽管如此,需要被利用的子计算都已经完成了,这部分计算因此不可避免
- 难以实现启发式计算和限制计算层数。由于程序要不断递归到最后才能得到比较好的方案, 因此即使计算量比较大也无法提前得到一个可行的方案并停止运行
因此,Volcano Optimizer 采取了自顶向下的计算方法,在计算开始, 每棵子树先按照原先的样子计算成本并作为初始结果。在不断应用规则的过程中,如果出现一种新的结构被加入到当前的等价集合中, 且这种等价集合具有更优的成本,这时需要向上冒泡到所有依赖这一子集合的父亲等价集合, 更新集合里每个元素的成本并得到新的最优成本和方案。
值得注意的是,在向上冒泡的过程中需要遍历父亲集合内的每一个方案,这是因为不同方案对于 Input 成本变化的敏感性不同,不能假设之前的最优方案仍然是最优的。
自顶向下的方法尽管解决了一些问题,但是也带来了对关系代数节点操作十分繁琐、 要不断维护父子等价集合的关系等问题,实现相对比较复杂。
总结
书山有路勤为径,学海无涯苦作舟。