
深入浅出openGauss的执行器基础
火山模型
执行器各个算子解耦合的基础。对于每个算子来说,只有三步:
- 向自己的孩子拿一个 tuple。即调用孩子节点的 Next 函数;
- 执行计算;
- 向上层返回一个 tuple。即当前节点 Next 函数的返回结果。
所以整个执行器的内核可以用下面这个伪代码来表达。
这种模型的好处是:
- 设计简单,算子解耦,互相不依赖;
- 内存使用量小,没有物化的情况下,每次只消耗一个 tuple 的内存。
Tuple 数据结构设计
两个算子之间的传递的都是 tuple。所以 Tuple 数据结构是整个执行器的核心,也是执行器和存储引擎交互的数据结构。
先看下几个具体数据结构之间的关系(附上关键的变量,非全部)
以上图为例,最下层的 seqscan,会调用存储引擎的 heap_getnext 函数(astore)。
以上就是存储引擎和执行引擎之间 tuple 怎么交互的。总结下就是存储引擎会把实际数据所在的地址传给上层,最后执行引擎拿到的结构体为TupleTableSlot。
所以,执行引擎只关心 TupleTableSlot 这一个结构体即可。
一个很自然的问题就是,如果算子之间的 Tuple 都是深拷贝传递,对于较大的 tuple 来说(包含 varchar 类型),性能很差。因此,PG 中的 tuple 分了4类(详见头文件tuptable.h):
- 第一类 tuple 是 Disk buffer page 上的 physical tuple,就是前文的 HeapTuple。Buffer 一定要 pin 住。这种 Tuple 可以直接根据头地址进行访问。
- 内存中的组装的tuple,格式和文件上 tuple 完全一样,也是进行过压缩。这种也算是 physical tuple,可以直接用地址。
- Minimal physical tuple,也是内存中的,唯一区别在于没有系统列(xmin、xmax 等)。
- virtual tuple,只记录每一个属性数据的地址,并没有深拷贝,而是直接通过地址来访问。现在约定的是,当一个算子向上层吐一个 tuple,直到它下次被调用时,该tuple所在的内存不会被释放。
对于查询来说,第一类和第四类最为常见。2、3两类会在物化的时候使用,比如 CTEScan、HashJoin 建立 hash 表的时候,相当于深拷贝。性能比较敏感的场合,尽量避免2、3类 tuple的使用。
slot 的创建一般通过 ExecAllocTableSlot、ExecSetSlotDescriptor 两个函数来分配内存和初始化信息。在执行器初始化阶段,每个算子会分配相应的 slot。
以上图为例,
- SeqScan 算子有一个 tupleTableSlot,是一个 physical tuple,指向的是 Buffer 中的地址。
向上层返回自己的 tuple slot; - HashJoin 一个一个拿到 内表的 tuple slot,需要建立 hash 表,所以创建了一个 minimal physical tuple,复制内表的 tuple 内容;
- Hash表建立后,HashJoin 算子然后一个一个拿到外表的 tuple slot,做 join 计算。
HashJoin 自己有一个 tuple slot,如果碰到匹配,则把自己的 tuple slot 设置成 virtual tuple,其中的 tts_values 指向的是孩子节点的 tuple 中的地址。
再向上返回。
其中,外表的内容指向的是 Buffer 上的physical tuple, 内表的内容指向的是 hash 表中的 minimal physical tuple; - 当 HashJoin 被再次调用时,它会重置 tuple slot。因为是 virtual tuple,所以没做任何事情。然后 HashJoin 会调用 SeqScan 拿下一条tuple;
- SeqScan 被再次调用时,也会重置 tuple slot。
因为是 physical tuple,它需要释放之前的 Buffer。
(当然,如果一直是同一个 Buffer 不会反复 pin/unpin,这是存储引擎的优化)。
条件计算
Expr 和 Var
执行器每个算子会对底下传上来的 tuple 进行计算和过滤。比如 NestLoop 需要计算内外表传上来的 tuple 是否满足 join 条件。
这时候需要引出第二个重要的概念,表达式的抽象。个人理解,任何对数据的获取操作都可以认为是表达式。
Var/Const 也是表达式的一种。Var 表示直接从 tuple 中获取数据,Const 表示的是直接获取一个常数。
每一个表达式会对应一个 ExprContext,ExprContext 中会记录计算所需要的所有数据,一般是孩子节点返回的 tuple。
表达式本身,在执行器中用 ExprState 来表示。里面重点是表达式的计算函数
总结下,ExprState 结构体表示表达式计算的逻辑,ExprContext结构体表示的是表达式计算要用到的数据。
从 OpExpr 可以看出,ExprState 本身也是一棵树。一直递归调用 ExecEvalExpr 来获取最终的结果。
需要注意的是,执行树中除了叶子节点上的扫描节点,其它节点的数据都来源于孩子节点。
所以,这些计算节点上的 Var,不能直接指向某个 table,而是需要指向的是内表还是外表的 tuple slot。
因此,在优化器最后的阶段,set_plan_refs 函数中,会把中间节点的 Var 的 varno 改写成特定的值。
而 Var 的表达式处理函数 ExecEvalScalarVar 也是根据这个信息决定去找 ExprContext 中的 哪个 tuple slot。
表达式如下:
示例1 filter
以 SeqScan 为例,在优化器阶段, SeqScan 上会有一个 qual,表示过滤条件。在执行器阶段会生成一个对应的 ExprState,用于计算。
示例2 join
以 Nestloop 为例,优化器结束的时候,join 节点会有一个 joinqual 表示 join 条件。
示例3 index scan & index only scan
之前很多人搞不清楚这里面 index cond/ filter 是什么关系。但是,通过执行器源码很容易得知它们的用处。先看 IndexOnlyScan
总结:
- Index Cond 是用来做 btree 扫描的 key,定位到第一个 IndexTuple。存储引擎中用
- 之后索引扫描会顺着 btree 的链表扫描所有的叶子页面,对叶子页面上的每一个 tuple 用 ScanKey 检查是否满足条件,满足再返回
- Filter 是在 执行器层用,针对 HeapTuple 再做一次过滤
- IndexOnlyScan 和 Index Scan的唯一区别是,IndexOnlyScan 的HeapTuple是根据IndexTuple直接构建的,不需要回表,其它逻辑是一样。
- 所以理论上讲 IndexOnlyScan 不应该出现 filter,上述场景可能有改进空间
文章转载自公众号:openGauss
