01存储引擎
对于一个分析型数据库,最核心的三个组成部分就是存储引擎、查询引擎和查询优化器,这也是Doris极致性能的决定因素。在此之外,向量化执行引擎的加入,让CPU的能力得到了更充分的发挥,更进一步提升了Doris查询性能。
和大多数分析型数据库一样,Doris也是以列存格式存储数据的。数据按照列进行连续存储,因为类型相同,因此可以获得极高的压缩率,节省磁盘空间。Doris对不同列的数据类型还提供了不同的编码方式,如INT类型会使用BitShuffle的编码方式,而字符串类型会使用字典编码。更进一步,Doris还会自动根据列的值分布情况来切换编码类型。比如对于字符串类型,如果列的去重值比较多,则不再使用字典编码,而直接切换到Plain Text编码,以避免不必要的空间浪费。
从文件组织形式上,Doris的文件格式和Parquet比较类似。一个数据版本会被分割成最大空间为256MB一个的Segment,每个Segment对应一个物理文件。Segment通常分为Header、Data Region、Index Region、Footer几个部分。Data Region 用于按列存储数据,每一列又被分为多个Page,而Page是Doris的最小数据存取单元,如图1所示。
▲图1 Doris文件格式
Index Region负责存储数据的索引。Doris提供了丰富的索引结构来帮助加速数据的读取和过滤。索引的类型大体可以分为智能索引和二级索引两种。其中智能索引是在Doris数据写入时自动生成的,无须用户干预,包括前缀稀疏索引、Min Max索引等。而二级索引是用户可以选择性地在某些列上添加的辅助索引,需要用户自主选择是否创建,比如像Bloom Filter、Bitmap倒排索引等。
前缀稀疏索引是建立在排序结构上的一种索引。存储在文件中的数据是按照排序列有序存储的。Doris会在排序列数据上,每1024行创建一个稀疏索引项,如图2所示。索引的Key即当前这1024行中第一行的前缀排序列的值。当用户的查询条件包含这些排序列是,我们可以通过前缀稀疏索引快速的定位到起始行。
▲图2 Doris前缀稀疏索引和Min Max索引示例
Min Max索引是建立在Segment和Page级别的索引。对于Page中的每一列,Min Max索引都会记录这个Page中的最大值和最小值,同样,在Segment级别也会对每一列的最大值和最小值进行记录。这样当进行等值或范围查询时,可以通过Min Max索引快速过滤掉不需要读取的行。
Bloom Filter(布隆过滤器)是一种需要用户自主选择是否创建的索引。当对某一列创建Bloom Filter索引后,Doris会在page级别创建该列的Bloom Filter结构。Bloom Filter是一种使用固定空间的位图来快速判断一个值是否存在的数据结构,这种数据结构非常适合用于高基数列上的等值查询,比如UUID。
Bitmap也是一种需要用户自主选择是否创建的索引。Bitmap索引是一种基于位图的数据结构,其Key值是实际的列值,而Value值是key在数据文件中的offset 。通过Bitmap索引,Doris可以很快定位到列值对应的行号,进行快速取数。这种索引比较合适在基数较低的列上进行等值查询的场景,比如城市等。
除了存储方式和索引结构,Doris在读取逻辑上也有很多优化。比如延迟物化功能会先根据有索引的列,定位到一个数据范围,然后再根据有过滤条件的列进行进一步过滤来缩小数据范围,最后再读取其他需要读取的列。这种方式可以很大程度上减少不必要的数据读取,降低查询请求对I/O的资源消耗。
02查询引擎
Doris的查询引擎是基于MPP的火山模型,是从早期版本的Apache Impala演化而来的。在Doris中,一个SQL语句会先生成一个逻辑执行计划,然后根据数据的分布,形成一个物理执行计划。物理执行计划会有多个Fragment,而Fragment之间的数据传输则是由Exchange模块完成的。通过Exchange模块,Doris在执行整个查询的时候就有了数据重分布(Reshuffle)的能力,查询不再局限于数据的存储节点,从而能够更好地利用多节点资源进行并行数据处理。执行框架如图3所示。
▲图3 MPP框架执行流程示意图
逻辑执行计划的Agg阶段变成了物理执行计划中的先重分布然后汇总的两个步骤,这个过程和Hadoop是类似的,都是按照相同的Key进行数据重分布。
除了整体的执行框架通过并行设计来提高查询效率外,Doris 还对很多具体的查询算子进行了优化。比如图4种的聚合算子。
▲图4 聚合算子
在Doris中,聚合算子会被拆分成两级聚合。第一级聚合会在数据所在节点先进行一次本地聚合,以减少发送到第二层聚合时需要传输的数据量,而第二级聚合会将Key相同的数据汇聚到同一个节点,进行最终的聚合计算。
在此基础上,Doris还实现了自适应的聚合算法。首先我们要知道,聚合算子是一种阻塞型算子,需要等到全部数据处理完成后,才会将数据发送给上层节点。而自适应聚合算法的意思是,在第一级聚合算子中,如果发现数据的聚合效果很低,即使聚合后也无法有效降低需要传输的数据量,则会自动停止第一级聚合,而将算子转换为一个非阻塞的流式算子,直接将读取到的数据发送到上层节点,从而避免不必要的阻塞等待时间。
针对Join算子,Doris也进行了大量优化,其中Runtime Filter是很重要的一种优化方式。在两个表的Join操作中,我们通常将右表称为BuildTable,而将左表称为ProbeTable。在实现上,通常首先读取右表的数据,在内存中构建一个HashTable,然后开始读取左表的每一行数据,并在HashTable中进行连接匹配,返回符合连接条件的数据。通常来说,左表的数据量会大于右表的数据量。
而Runtime Filter的设计思路,是在右表构建HashTable的同时,为连接列生成一个过滤结构。之后把这个过滤结构推给左表。这样,左表就可以利用过滤结构,对数据进行过滤,从而减少Probe节点需要传输和比对的数据量。这种过滤结构被称为Runtime Filter。针对不同的数据,Doris也实现了不同类型的过滤器,例如In Predicate,Bloom Filter和Min Max。用户可以根据不同场景选择不同的过滤器。Runtime Filter实现逻辑示意图如图5所示。
▲图5 Runtime Filter实现逻辑示意图
Runtime Filter可以适用于大部分Join场景,包括节点的自动穿透,将Filter穿透下推到最底层的扫描节点,例如分布式Shuffle Join中,将多个节点产生的Filter进行合并后再下推数据读取节点等。
03查询优化器
除了查询执行层方面的优化,Doris 在查询优化器方面也做了大量工作。Doris中的查询优化器能够同时进行基于规则和基于代价的查询优化。在基于规则的查询优化方面,Doris包括但不限于以下优化规则。
1)常量折叠。常量折叠可以预先对常量表达式进行计算,计算后的结果有助于规划器进行分区分桶裁剪,以及执行层利用索引进行数据过滤等。例如将where event_dt>=cast(add_months(now(),-1) as date)折算成where event_dt >=’2022-02-20’[14] [15] (编写本节时是2022年3月20日晚上)。
2)子查询改写。将子查询改写为Join操作,从而利用Doris在Join上做的一系列优化来提升查询效率。例如select * from tb1 where col1 in (select col2 from tb2) a改写成select tb1.* from tb1 inner join tb2 on tb1.col1=tb2.col2。
3)提取公共表达式。提取公共表达式可以将SQL中的一些析取范式转换成和取范式,而和取范式通常对执行引擎是比较友好,可以将查询条件重组或者下推,减少数据扫描和读取的行数。例如将条件where (a>1 and b=2) or (a>1 and b=3) or (a>1 and b=4)转化成 where a>1 and b in (2,3,4),明显后者的判断速度比前者的快很多。
4)智能预过滤。智能预过滤可以将SQL中的析取范式转换成和取范式并提炼出公共条件部分。这些公共条件可以预先过滤部分数据,从而减少数据处理量。
5)谓词下推也是查询优化器常见的优化手段。Doris中的谓词下推不仅可以穿透算子,更能进一步地下推到存储引擎,利用数据索引进行数据的过滤,如图6所示。
▲图6 Doris中的谓词下推示意图
而在基于代价的查询优化方面,Doris主要针对Join算子进行了大量优化。
Join Reorder功能可以通过一些表的统计信息,自动的调整Join的顺序。而Join顺序的调整,会显著降低Join操作中生成的中间数据集的大小,从而加速查询的执行,如图7所示。
▲图7 Doris Join Reorder优化示意图
Colocation Join可以利用数据的分布情况,将原本需要Shuffle后才能进行Join的数据,在本地即可完成Join操作,从而避免了Shuffle时大量的网络数据传输。如图8所示。
▲图8 Doris Colocation Join示意图
Bucket Join是Colocation Join的通用版本。在Colocation Join中,用户需要在建表时就指定表的分布,以保证需要关联查询的若干个表有相同的数据分布。而Bucket Join会更智能地自动判断SQL中的关联条件和数据分布之间的关系,将原本需要同时Shuffle的左右两张表的数据的操作,变成仅将右表数据重分布到左表所在的节点,从而减少数据的移动量。如图9所示。
▲图9 Doris Bucket Join示意图
04向量化执行引擎
传统的数据库都是典型的迭代模型,执行计划里面的每个算子通过调用下一个算子的next()方法来获取数据,数据从最底层的数据块中一条一条的读取处理最终返回给客户,它的问题在于每个tuple(也叫元组,是一种常见的编程数据类型,和数组类似,但是元组的元素可以是不同的类型)都要调用一次函数,调用开销太大,而且因为CPU每次只处理一条数据,无法利用[18] [19] CPU技术升级带来的新特性,比如SIMD。向量化模型每次处理的是一批数据,这些数据会被保存在一种叫作向量的数据结构里面,然后因为每次处理的是一批数据,因此可以在每个Batch内部可以做各种优化。简单的说,向量化执行引擎 = 高效的向量数据结构(Vector)+ 批量化处理模型(nextBatch) + Batch内性能优化(例如SIMD等)。
原本向量化执行引擎只是一个概念,是ClickHouse将其变成了现实,率先在数据库产品中实现了向量化执行引擎并展示出强悍性能。通过向量化执行引擎原理的介绍,我们可以看出,向量化执行引擎非常适合基于列存储的OLAP数据库,可以极大的提高并行查询效率。在ClickHouse之后,OLAP数据库实现向量化执行引擎几乎已经成为标配。目前,除了Doris以外,polar-x、TDSQL都声称部分或者全部实现了向量化执行引擎功能。
Doris是在0.15版本引入向量化执行引擎功能的,并在1.0版本中逐渐成熟。根据Doris的演进计划,向量化执行引擎会逐步替换当前Doris的行式SQL执行引擎,以充分释放现代CPU的计算能力,实现更强悍的查询性能。
在绝大多数场景之中,用户只需要将session变量enable_vectorized_engine设置为true,则FE在进行查询规划时就会默认将SQL算子与SQL表达式转换为向量化的执行计划,从而提升SQL执行性能。
关于作者:王春波,资深大数据架构师,现就职于一家互联网公司,任高级数仓工程师,负责电商数仓项目;在银行业、零售行业深耕多年,参与和负责过多家银行、零售数据分析实施项目;“数据中台研习社”号主,《Doris实时数仓实战》《高效使用Greenplum:入门、进阶与数据中台》作者。