导语丨随着大数据组件从Storm + Druid/CK + HIVE 逐步升级为Flink + StarRocks + iceberg,如何更好利用StarRocks的各种特性给用户带来极致的性能体验也变的尤为重要。本文将详解StarRocks底层实现原理来帮助大家理解StarRocks是如何进行数据读取与计算,从而进一步帮助大家可以更好地基于StarRocks进行业务SQL的编写与优化。
作者:Deltaverse高级研发工程师 even
1、背景
虽然说Starrocks 有向量化、RBO、CBO等引擎内核优化手段,但是由于统计信息收集存在随机性与局部性以及用户查询的复杂性,不能100%保证选择的执行计划就一定是最优的执行计划,所以我们需要进一步理解其底层存储与计算原理来进一步从SQL层减少资源使用的同时提高执行效率,实现降本增效。
2、执行原理
Starrocks执行的本质就是FE将SQL语句进行语法解析生成执行计划树,然后将执行计划发送给BE,BE读取对应的数据,执行相应的节点操作。如下图即为一个最基本的执行计划树,大部分执行计划都是由一个或者多个该执行计划组合而成,其中箭头方向即为数据流向。为了更好地理解Starrocks执行原理,接下来,我将从底至上依次讲解数据读取、分组聚合以及JOIN等实现,帮助大家可以更好地理解自己SQL是如何执行的同时知道应该如何进行优化。
2.1 从数据存储理解如何在建表语句与Where条件中提高数据读取速度
对于数据的读取,在SQL中能做的就是尽可能让其使用索引并且尽可能少的扫描数据。通过理解Starrocks数据存储原理有助于用户更好地完成这个目标,让自己的SQL执行的更快。
2.1.1 数据分布
Starrocks 采用 Range+Hash 的数据分布方式。
Range: 即分区,按区间范围进行数据分布。比如: 按天、月分区,则会将数据按对应范围进行划分管理。
分区管理的优点:
-
在查询中作为Starrocks第一级过滤,进行分区裁剪,减少数据扫描量,加速查询。所以建议使用where条件最常用的字段,且类型必须为日期或者整数类型。
-
对于每个分区提供了副本数、分桶数、冷热策略和存储介质等存储策略,可以灵活的根据不同分区的数据特点来选择最合适的存储策略,节省数据存储成本。
Hash: 即分桶,通过哈希函数把数据映射到不同节点上。
在同一分区内,分桶键哈希值相同的数据形成 Tablet,分桶数即为该分区的Hash取模的底数,即分区分桶数 = 分区Tablet数。Tablet是数据实际存储的物理单元,也是Starrocks计算实例处理数据的基本单位。比如: 集群有3个BE,查询并行度设置为8,则Starrocks会创建24个计算实例进行数据处理与计算,但是如果查询的数据只存在于10个Tablet中,则就会出现最多10个计算实例在处理这10个Tablet数据,而还有14个计算实例没有数据处理的情况,导致数据倾斜。而对于MPP架构的Starrocks来说,最终的执行时间是由执行最慢的计算实例决定的,所以这样数据倾斜就会拖慢整个计算。从这个逻辑看就可以知道分桶的目的就是将数据打散,使数据尽量均匀地分布在集群的各个BE节点上,以便在查询时充分发挥集群多机多核的优势。
那么如何通过分桶来将数据打散呢?
首先分桶需要设置两个值
1.分桶键: 即按照这个字段/字段组合的值进行Hash取模。
-
由于需要将数据尽可能打散,让每个tablet中的数据量尽可能相当,所以建议选择高基数(即该字段的去重数比较大)且不存在单值倾斜的字段。高基数是因为假设我们使用低基数的字段,该字段去重数比较少,比如只有10个值,而我们分桶数设置成100,这样就会导致只有10个Tablet有数据,并不会打散数据。而单值倾斜的字段的意思是,这列有个值数据量非常大,这样也是不适合的,因为这样容易导致有个tablet非常大,从而导致数据倾斜。NULL值现象比较容易出现这种情况,即一个字段有大量的NULL值,这时候需要考虑这个NULL值合不合理,如果不合理则在ETL阶段就对其进行过滤,如果合理则可能需要考虑换一个字段作为分桶键或者通过多个字段组合作为分桶键了。对于多字段组合分桶键时建议不超过3个字段。
-
由于分桶是Starrocks第二级过滤,所以建议选择经常作为查询条件的列为分桶键,这样可以通过分桶键减少数据扫描量,提高查询效率。
2.分桶数: 即为该分区的Hash取模的底数。
其决定了分区的Tablet数,从而决定着查询并行度。那是不是这个值越大越好呢?这肯定不是的,主要原因是虽然Starrocks的数据都存储在BE,但是FE作为Starrocks的大脑,在生成执行计划时它需要知道哪些数据在哪个BE上,这样才能进行计算任务的分发。也正是由于这个原因,所以FE需要在内存中存储每个Tablet信息,这样如果分桶数太大时就会导致FE内存消耗巨大,产生性能瓶颈,从而影响集群稳定性。那应该设置多少分桶数比较合适呢?建议压缩后每个Tablet大小为1G左右。
可以按照以下公式进行估算:
估算公式: 分桶数=分区数据实际大小/5G;
例:假设按天进行分区,每天10亿数据,每条数据1kB; 则 分桶数 = 1kB* 1 000 000 000 / 5G = 200
说明: 除以5是由于Starrocks默认使用Lz4压缩,压缩比约为5。
2.1.2 数据存储
前面说了Tablet是数据实际存储的物理单元,那Tablet具体又是如何存储的呢?
以下为数据在BE中的存储结构:
Slot: 一共1024个槽位,用于通过一致性Hash算法解决分布式存储时BE扩缩容数据迁移问题。
|— Tablet: 实际存储的物理单元
| |— RowSet: Rowset是Tablet中一次数据变更的数据集合,每次变更会生成一个版本号 version
| |— Segment.dat: Rowset中的数据分段,也是Starrocks最终的数据存储文件
StarRocks的OLAP表的数据就是通过这四个层级组织存储结构的。
2.1.3 Starrocks数据文件
接下来我们就看看Starrocks数据是如何存储的呢?以及在快速定位到需要的数据,减少数据扫描上做了哪些工作与设计。
如上图即为Segment.dat文件的文件结构。其主要为3个大区域:
1.DataRegion: 用户数据。从图中可以看出Starrocks数据文件是列式存储的。
使用列式存储主要有以下优点:
-
由于同一列数据类型相同,且对于排序的列,相同值也聚集在一起,这样会有比较好的压缩效果。
-
对于OLAP查询时,只需要扫描需要的列,不用全部列扫描,减少数据扫描量,提高查询效率。
而每列数据又被分成多个列级数据块,即图中的Data Page。
2.Index Region: 索引区域。
其中又有两个区域:
1.Column x Index: 列级索引;从图中可以看到有三种列级索引: Boolm Filter、BitMap、Ordinal Index。但并不是所有列都有这三种索引。首先对于排序键的列,会生成其Ordinal Index。而其他列以及索引只有在建表语句中指定了该列使用Boolm Filter或者BitMap索引才会生成。
比如 : 在建表语句的列语句中使用: INDEX index_name (column_name) USING BITMAP 这样才会对这列生成BitMap索引。而Boolm Filter索引是在建表语句的PROPERTIES中指定: PROPERTIES("bloom_filter_columns" = "k1,k2");
或者也可以使用 Create Index 语句进行添加。
2.Short Key Index: 也称为排序键索引/前缀索引。用于快速查找排序键对应的数据。
3.Footer: 文件页脚。其中也有三个信息:
-
FileFooterPB: 其中还存有 ZoneMap Index;其中存储该数据文件所有简单列(即数值/时间类型的列)的最大最小值。用于快速判断查询的数据是否在该数据文件中。
-
PB Checksum & PB Length : 用于验证数据文件是否损坏。
-
MAGIC CODE:Starrocks数据文件魔数,Starrocks数据文件类型标识。
接下来我们就来看看Starrocks是如何通过这样的文件结构来快速查找到需要的数据。
示例:
比如需要从tb表查询每天test事件且platid为255的人数,其中分区字段为ds,按天分区,id为分桶字段,event为排序键。
SQL:
1.分区过滤: ds >= '20230101' AND ds < '20230102',FE计算出是需要读取的分区p20230101,获取到这个分区的所有分桶。
2.分桶过滤: 由于where条件中没有分桶字段id的过滤,所以需要扫描所有分桶(即tablet)。
3.通过tablet分布,为对应的BE生成计算任务,如果设置了副本数,则可以根据副本分布,尽可能使BE的任务均衡。
4.BE接收到扫描任务从对应的Tablet信息中找到对应的 Segment.dat 文件。
5.ZoneMap Index: 存有数值字段和时间字段的最大最小值。所以会先通过这个对ds和platid字段进行验证,如果where条件的范围不在这个Segment.dat 文件中则不用读取。
6.对于需要读取的Segment.dat 文件进行排序键过滤: event = 'test'
如下图即为Segment.dat 文件索引的数据结构:
前缀索引(Short Key Index):
表中每 1024 行数据构成一个逻辑数据块 (Data Block)。每个逻辑数据块在前缀索引表中存储一个索引项,索引项的内容为数据块中第一行数据的排序键所构成的前缀,长度不超过 36 字节,所以不建议排序键长度超过36字节,如果超过Starrocks存储时前缀索引中会进行截取。前缀索引是一种稀疏索引。使用表中某行数据的排序键所构成的前缀查找前缀索引表,可以确定该行数据所在逻辑数据块的起始行号。
列级索引(Column x Index - ordinal Index):
表中每列数据都有一个独立的行号索引。行号索引表中,该列的数据块和行号一一对应。每个行号索引项由对应数据块的起始行号、位置和长度信息构成。用某行数据的行号查找行号索引表,可以获取包含该行号对应的数据块所在的位置,读取目标数据块后,可以进一步查找数据。
列级数据块(Data page):
表中每列数据都按 64 KB 分块存储。数据块作为一个单位单独编码、压缩,也作为 I/O 单位,整体写回设备或者读出。
-
在前缀索引中找到 'test' 所在的前缀区域: 由于前缀索引中的前缀是根据排序键的前缀排序的,所以这里可以通过二分查找快速定位。这样便可以获取到'test' 所属的行号范围。
-
通过这个行号范围 从列级索引中找到对应的块地址: 由于行号有序,所以这里也可以通过二分查找快速找到。
-
解压、解码event 字段对应的数据块,然后读取字段值 与 'test' 进行比较。记录匹配值的行号。
7.其他值条件过滤: platid = 255; 根据排序键 过滤的行号 找到platid字段对应行号的数据 进行读取比较,得到值为255的行号,即最终需要的数据行。
8.由于最后只需要 ds和id字段,所以读取ds与id字段对应行号的数据即可。
整个过程最多只会扫描 event字段值为'test'所在范围的数据块 * 4 (event、platid、ds、id四个字段)。而且其中两次需要使用索引查询的地方也是二分查找,所以整个过程不会很耗时。
通过以上对Starrocks数据存储与扫描过程的讲解,除了对建表上有一些要求,其实在查询语句上也可以有一些优化帮助。
-
使用分区、分桶、排序键进行WHERE条件过滤,这样可以减少数据扫描量的同时快速定位到需要的数据。
-
WHERE 条件中不要对分区、分桶、排序键进行函数转换,这样分区、分桶、排序键的索引能力会失效。
-
对于可以使用数值与时间类型的字段,尽量使用数值与时间类型,不要使用字符串类型,这样可以在ZoneMap index中进行数据过滤。
-
在SELECT子句中具体要查什么字段就SELECT什么字段,不要SELECT多余字段,不然会扫描多余字段,特别不要SELECT *,这样就失去了列式存储只需要扫描部分字段的优势了。
2.2 分组聚合
了解完了Starrocks数据是如何存储的后,接下来我们来看看Starrocks是如何完成聚合操作的。在Olap分析中聚合操作基本无处不在,所以理解聚合是如何完成的对于Olap查询优化是至关重要的。
2.2.1 聚合实现的方式
在Olap分析中聚合的意思是指:将数据按对应字段值进行分组(即GROUP BY),字段值相同的为一组。然后对这组数据进行同一个操作(即聚合函数),比如统计、求和、求最大值等。最后每组数据输出一个结果的过程。
通过以上的阐述,可以简单的理解聚合的本质即:分组 + 聚合函数。接下来我们来看一下分组是如何实现的。对于分组的实现一般有两种:排序分组与Hash分组。
排序分组
排序分组即将所有数据按照分组列进行排序,这样相同组的数据就会处于相邻位置,只需要通过判断当前数据与上一个数据的分组列是否相同,相同则为同一组数据,不同则说明接下来是新的一组数据。
Hash分组
Hash分组即先通过Hash函数将数据根据分组列进行Hash分桶,这样就可以保证相同分组列的数据在一个桶中。最后对每个桶中相同分组列数据进行聚合操作即可。
由于排序分组的算法,其时间复杂度为O(NlgN),而Hash分组则为O(N),所以Starrocks采用的是Hash分组的方式进行分组。而由于聚合函数种类较多,不同聚合函数的实现也不同,这里就不一一阐述了。
2.2.2 分布式分组
一阶段聚合
前面说了Starrocks采用的是Hash分组的方式进行分组的。但是由于是分布式存储,相同分组列的数据可能在不同的节点上,这样就需要先将相同分组列的数据放到同一个节点上再进行分组聚合。如下即为Starrocks的一阶段分布式分组:读取数据后通过Exchange将数据通过分组列的值进行Hash分桶,把相同桶的数据传输到对应的BE节点上,然后再在该节点上进行Hash分组聚合。
二阶段聚合
虽然一阶段分组聚合可以很好解决分布式分组问题,但是从上图可以看出,所有数据需要通过Exchange进行一次全量的数据传输。为了提高整体的执行效率,Starrocks进行了优化。即使相同分组列的数据可能在不同的节点上,但是由于分析处理的数据数据量较大,在对应节点相同分组列的数据还是比较多的,所以其如下图实现了二阶段聚合进行一定的优化。
如上图,Starrocks在数据读取时会先进行一次预聚合,比如avg函数,会先计算出相同分组的sum值与count值,然后再通过Exchange将数据通过分组列的值进行Hash分桶。这样当数据量大时,每个Exchange上传输的数据量就只有当前节点分组数的数据量级,对于聚合度高的数据,会大大减少数据的传输量,提高执行效率。最后再对预聚合的结果进行分组再聚合,即可完成整个聚合的计算。
虽然二阶段聚合在大部分情况下是优于一阶段聚合的,但是如果数据的聚合度比较差,即预聚合基本没有什么效果的情况,二阶段聚合的执行效率与资源使用是比一阶段聚合差的,因为这种情况不但没有减少太多数据的网络传输,还会消耗大量资源进行效果比较差的预聚合。所以当聚合度比较差的场景不推荐使用二阶段聚合,如果在查询分析时发现有该情况,可以尝试通过设置new_planner_agg_stage参数来指定聚合阶段数。
分桶聚合
由于数据存储时有分桶特性,所以Starrocks针对包含分桶键的分组聚合也进行了优化,即分桶聚合。由于这种情况相同分组的数据都在同一个节点上,所以只需要在读取数据时直接进行分组聚合即可,不用在进行Hash分桶传输。
由于分桶聚合少了大量的数据传输,所以也是比较推荐在表的分桶设计时可以考虑将经常作为GROUP BY的键作为分桶键。
从前面对聚合实现的说明,其实可以看到聚合度是影响聚合执行效率的关键。由于Starrocks是通过Hash进行分组聚合的,这样就需要通过一个HashTable来存储数据,如果聚合后的分组数非常多的话,1. 进行Hash探测的效率会比较低;2. HashTable需要申请大量的内存。所以在进行分组聚合时我们应该尽量考虑如何提高聚合度,尽可能减少最终分组数,来提高查询效率。
2.2.3 聚合优化实践 -- 通过count group by 优化 count distinct数据倾斜问题
除了前面所说的聚合度会对分组聚合造成比较大的影响外,我们还要考虑一个点,即数据倾斜问题。
背景: 如下为最初的用户计算uv的SQL
执行12 min 21.09 sec;分析其profile发现出现严重的数据倾斜,在第二阶段中只有7个实例有大量数据的计算,其他实例并没有数据。这是为什么呢?
如下图 即为Starrocks执行该SQL的流程图:
我们从左往右看:
-
首先Starrocks会根据数据存储情况以及查询条件为每个instance生成ScanNode并分配对应的数据进行读取(其中instance数量为be数* 每个be的并行度)。
-
然后每个instance进行数据的读取与转换,根据group by的字段,这里即to_date(dteventtime),进行分组,然后每个分组分配一个hashSet来实现distinct去重。
-
处理完成后会根据group by的字段进行hash分组,将同一个hash值的结果发送到同一个Be的instance上。如上图,由于这里group by的字段为to_date(dteventtime),而我们只获取了一周的数据,所以只有7个分组,这样就导致了只有7个instance会获取到数据,其他instance并不会获取到数据,从而导致了严重的数据倾斜。
优化
从前面的分析看,整个查询的瓶颈是由于以下两个条件导致的
-
分组键数量较少导致了数据倾斜
-
上游输出至下游的聚合值为一个大hashSet,导致下游的聚合操作为多个大hashSet的去重操作,这样在数据倾斜的情况下处理效率就严重被拖,且容易造成OOM。
然后我们重新分析uv的计算,本质要做的就是两步:
-
按照指定维度对vopenid进行去重
-
按照指定维度统计去重后的vopenid数量
这样看就可以对SQL进行一定的改写:
1.按照指定维度对vopenid进行去重 :
现在这个SQL语法是有问题的,但是即使可以执行,理论上也会出现前面数据倾斜的情况。但是从这个SQL也比较容易看出,实际上我们就是要按照 指定维度与vopenid进行去重。
所以可以进一步改写SQL (这里使用distinct to_date(dteventtime) AS 日期,vopenid 其实也可以,但是效果和下面SQL相同)
现在看这个SQL就符合语法,且由于我们的分组键为指定维度与vopenid的组合,这样就可以增加分组数,从而解决原先少量分组键导致的数据倾斜问题。
2.按照指定维度统计去重后的vopenid数量
有了前面的改造,这里只要按照指定维度使用count()进行统计就可以得到uv了。所以整个SQL就变成了如下形式:
执行时间 2.95 sec
下图即为优化后的SQL执行流程图
从上图可以看到,首先在阶段1生成的是由 日期+vopenid 的分组键,这样分组数就是(日期数*vopenid数),这样就可以保证每个BE的instance尽可能均匀的分配到数据,大大提高了第二阶段的执行效率。然后第二阶段会收集上游每个instance的分组结果,完成全局的去重操作,并进行第一阶段的分组统计(即按日期统计当前instance数据中日期维度对应的uv)。最后根据日期分桶交由第三阶段计算全局的uv,这里可以看到实际第三阶段还是会存在数据倾斜的情况(即数据最后还是只会由7个instance进行处理),但是这里由于上游为统计后的数据,分组键与聚合值占用空间并不大(只有一个日期和一个统计值),所以不容易造成OOM,而且第三阶段的最终聚合也只要将相同分组的统计结果进行加和即可。假设这里我们有40个BE,每个BE并行度为8,则也只不过是320个加和操作,这比原先的320个大hashSet去重可快多了,所以不会成为整个执行的瓶颈。
2.3 JOIN
Join是Olap分析SQL中最常见的算子,并且其对整个查询的影响也是比较大的。接下来,我将通过解释Starrocks Join底层实现原理,让大家可以更好地理解并完成SQL中关于Join部分的优化。
2.3.1 分布式场景下的Join实现
在分布式的场景中,数据存储在不同的节点上,而分布式场景下的Join本质就是将相同值的多条数据合并为一条数据。而在分布式场景下,主流的Join方法主要有两种: Hash探测法与排序合并法。以下以两张表left join为例进行讲解,多表join本质就是多次的两张表join,而其他join方式本质与left join相差不大。
table1
table2
SQL:
Hash探测法:
Hash探测法的实现原理: 将table1的数据存储在HashTable中,将key的值进行hash作为存储索引,HashTable中存储的值为table1的value1。接下来遍历读取table2的数据,将其table2 key的值进行hash,如果HashTable该存储索引有数据则表示Join上,输出HashTable该位置的值以及table2中需要的值。其中构建成HashTable的表也称为构建表,即table1,而另一张表称为探测表,即table2
排序合并法:
排序合并法的实现原理: 首先将两张表根据key值进行排序,然后通过双流从头读取两张表的数据,如果key值相同,则输出 table1的value1与table2的value2,并读取table2的下一条数据。如果table1的key小于table2的key,则读取table1的下一条值。如 果table1的key大于table2的key,则继续读取table2的下一条数据,直到其中一张表的数据读取完。
2.3.2 Starrocks Join实现
由于Hash探测法的时间复杂度为O(N),而排序合并法由于需要进行排序,所以时间复杂度为O(NlgN),所以Starrocks通过Hash探测法进行Join的实现。Starrocks的构建表为右表。
首先接收右表所有chunk,拼接成一个大Chunk。Starrocks的数据都是拆分成一个个chunk进行传输,每个chunk存储着4096行数据。这时候右表的所有数据便按行顺序存储在数组中。从这里其实也可以看到,右表的大小会决定着内存使用的多少。
然后构建HashTable,当前Starrocks Join使用的HashTable本质是一个链式的Hash表。
如上图所示:
input: 为构建表join on条件列的值。
first: 为分桶,为链表表头,存储着构建表数据的行号
next: 索引数组,通过其构成所有链表,其中数组index为前一个输入的行号,而存储的是当前输入的行号,这里的输入即为构建表 join on条件列的值。0表示到达链表尾。
values: 存储着构建表join on条件列的值,其中index即为第几行。
Starrocks Join的步骤:
1.初始化:
接收右表数据作为构建表创建first、next、values数组,其中first的长度为构建表行数 * 2,next长度为 构建表行数 + 1,values长度为构建表行数 + 1。values长度为 构建表行数 + 1这个很好理解,因为它要存储每行的构建表join on条件列的值。
2.HashTable构建:
遍历input,即构建表的大chunk中join on条件列。读取值%桶数,如图中,读取第一个构建表的值 1,由于桶数=构建表行数*2 = 8,所以余数为1,将其行号放到first的index=1的位置。此时first为[0,1,0,0,0,0,0,0],这里的1表示的是构建表的第一行,不是表示 值为1。接下来第二个值5,同样hash分桶到index=5的位置,由于是第二行,所以first[5]=2。第三个值为9,hash分桶到index=1 的位置,此时发现first[1]已经有值了,这时候由于当前行号为3,所以next[3]=first[1],然后再让first[1]=当前行号3,并将first[1] 指向next[first[1]],即next[3],这样便完成了链表的连接。然后继续读下一个值11,hash分桶值为3,所以first[3]=当前行号4。这 样就完成了HashTable的构建,如上图所示。比如如果下一个值为9,则同理,先进行hash分桶,发现first[1]有值,然后当前行号 为5,所以next[5]=first[1]=3,并指向next[3];然后first[1]=当前行号5,并指向next[5]。
通过这种实现,可以发现,由于next中 存储的是行号,且index表示的也是行号,所以不会存在相同值,即不会有冲突情况,这样next中的元素个数肯定也不会超过构建 表行数 + 1。至于为什么first的长度为构建表行数 * 2呢?不是first+next的元素数量 = 构建表行数吗?这样理论上first也只要构建 表行数 + 1的长度应该就可以了。这个原因进行HashTable探测中应该可以找到原因。
3.HashTable探测:
读取左表,此时并不需要一次性读取左表全部数据,只需要读取一个chunk的数据,然后进行遍历,完成后接收下一个chunk即可,读取左表所需要的内存就是一个chunk所用的内存。然后按行读取左表的join on条件列的值。同样进行hash分桶,比如这时读取的值为9,这样hash分桶到index=1。然后first[1]=3,所以取values[3],发现也是值9,则两个值便join上了,这样就可以读取构建表第3行所需要的列与探测表第一行所需要的列进行输出。但是由于右表中可能存在重复数据,比如刚才举例的右表第5行值也为9的情况,所以实际上还有继续遍历该条链表,直到链尾,这样便完成了第一个值的探测join。同理,之后遍历探测表进行同样的操作,直到左表数据遍历完即完成了本次join。
通过探测,我们可以发现,每个探测表的值是需要遍历其所在桶的整条链表,所以为了缩短链表长度,first的长度=构建表行数* 2。
通过以上对于Starrocks Join原理的阐述,其实可以很容易得到一个结论: Starrocks的Join性能主要由右表数据量影响,且内存使用也主要由右表数据量决定。所以在进行Join时我们需要尽可能保证右表为小表。并且相关数据显示,HashTable 查询的主要性能影响因素是: Cache Miss 率 (理论上占 73%)。
2.3.3 Starrocks Join分发方式
除了HashTable Cache Miss 率是Join性能的主要影响因素外,Starrocks Join性能的主要影响因素还有一个,那便是join的分发方式。由于我们现在是在分布式场景中,所以数据是存储在不同的节点上的,而将相同key的数据收集到同一个节点,这便是join的分发方式。
Starrock提供了4种Join分发方式
1.Partition Shuffle Join
如上图,即为Partition Shuffle Join,通常大家所说的Shuffle Join实际就是该分发方式。Partition Shuffle Join是通过将两张表join on条件的值根据实例数进行hash分桶,相同分桶的数据交给对应实例进行处理。这样相同的key的数据便可以分发到同一个实例上。
优点:
-
数据可以比较好地通过hash进行打散,不容易数据倾斜
-
整个分发过程中基本不用额外的内存
缺点:
-
需要分发两张表的全部数据,分发数据量大,分发效率一般。
适用场景: 主要适用于两张大表Join的场景。
2.Broadcast Join
如上图,即为BroadCast Join。BroadCast Join是通过将右表的数据全量广播给所有实例,这样所有实例就都拥有右表的数据,如上图所示,假设有3个BE节点,每个BE节点的并行度是4,则一共有12个实例,所以Exchange就需要分发12份的Table2数据。通过该方式也可以完成相同key值的数据在同一节点的目标。
优点:
-
当右表数据量小的时候数据分发量小,可以实现快速分发
缺点:
-
分发的数据需要使用内存存储后进行分发,如果右表较大,需要使用大量内存
-
数据倾斜完全由左表数据分布决定
适用场景: 主要适用于小表 Join 大表的场景,且右表为小表,且集群计算节点数较少,并行度较少的场景。
3.Bucket Shuffle Join
如上图,即为Bucket Shuffle Join。从图结构看其基本与BroadCast Join一样,但是还是有一个关键不同的,其Exchange网络传输的数据量只有Table2的数据量,而BroadCast Join网络传输的数据量是Table2的数据量 * 实例数。
那么它是怎么实现只要传输Table2的数据量实现相同key数据在同一个节点上的呢?其实该分发方式需要一些特殊条件:
join的两张表分桶键相同且分桶数相同
join on的条件中包含分桶键的等值join
由于有这两个特定条件,所以只需要根据左表这些数据分桶分布情况,对右表的分桶列使用相同的hash算法,就可以使相同分桶值的数据在同一个节点上了。
优点:
只要分发Table2的数据量,分发数据量小
缺点:
-
需要特殊场景才能使用,即分桶要求
-
数据倾斜完全由左表数据分桶分布决定
-
需要两张表分桶键相同且分桶数相同,如果两张表数据量不相当,容易造成表分桶数不合理
适用场景: 两张表的分桶键以及分桶数相同,且分桶键数据分布比较均衡,且两张表数据量相当
4.Colocate Join
如上图,即为Colocate Join。大家可以看到,Colocate Join并没有Exchange节点进行网络数据传输的。而该Join分发方式也是一种特殊的Join分发方式,其需要满足以下几个条件:
-
join的两张表分桶键相同且分桶数相同
-
join on的条件中包含分桶键的等值join
-
两张表设置了相同的Colocation Group,即colocate_with属性相同
-
集群开启了Colocation Join参数
-
两张表的Colocation Group属于Stable状态,即相同分桶键的副本位置一致
由于两张表分桶键、分桶数相同且设置了相同的Colocation Group,这样Starrocks就会将这两张表相同分桶键的副本搬迁到相同的节点上,这样join on的条件中如果包含分桶键,则相同分桶键数据已经在相同节点上了,即可以实现数据的本地Join,不用进行数据的网络传输。
优点:
-
数据本地Join,效率高
缺点:
-
需要的条件比较苛刻
-
数据倾斜完全由左表数据分桶分布决定
-
两张表分桶键相同且分桶数相同,如果两张表数据量不相当,容易造成表分桶数不合理
-
由于需要保证两张表的所有数据副本分布一致,所以会产生大量数据搬迁任务,对集群造成较明显的影响
适用场景: 需要实现两张表快速join,且两张表数据相当,且数据量都不是很大的场景。
通过对Starrocks Join分发方式的讲解,大家应该基本理解了什么时候用哪种join是最优的,而如果由于Starrocks CBO统计信息不准确导致导致使用了不合理的Join分发方式,Starrocks也提供了join hint的方式进行Join分发方式的修改。
比如:
Partition Shuffle Join: join[shuffle]
BroadCast Join: join[broadcast]
Bucket Shuffle Join: join[bucket]
Colocate Join: 无
这里需要特别注意的是,使用了join hint的情况下,必须要保证右表为小表,因为当使用了join hint,Starrocks将不会通过CBO对join中的左右表根据数据量进行交换率优化。所以也不建议大家一上来就就使用join hint来制定join分发方式,而是应该在确定CBO无法解决分发方式的准确性时使用,比如存在严重的数据倾斜的情况。
3、分析工具
目前我们基本知道了Starrocks主要的几种节点的底层实现,以及对应的一些优化方法。那我们怎么知道自己的查询哪里有问题,整个查询瓶颈在哪里呢?
对于这个问题,Starrocks主要提供了两种工具进行分析定位:1. profile;2. explain。接下来,我将简单介绍一下这两种工具的使用。
3.1 profile 分析
3.1.1 使用
开启profile:set enable_profile = true;
执行SQL
3.1.2 分析
这里以非pipeline格式profile进行说明,set enable_pipeline = false;
profile中记录了整个执行过程中每个instance执行的详细信息,包括执行的实例数、执行的时间、每个执行节点输入输出以及各阶段的执行时间、数据量等信息。但是由于文件内容还是比较复杂的,这里就不进行详细说明了。但是我们可以通过其图形化页面(图形化页面暂未对外开放,需要进行申请使用)进行更直观的分析。
如上图,即为profile图形化页面,通过该页面可以直观的看出,该查询哪部分使用资源较多,以及对应执行时间、内存使用、输入输出的数据量等。通过这些信息可以方便地定位到查询瓶颈,以及Join是否是最优的,聚合使用几阶段合适等。
3.2 explain 分析
profile分析虽然可以直观地看出查询的瓶颈以及相关问题,但是从前面步骤也可以看出,其需要查询成功才可以使用。而由于我们的查询有时会出现OOM的情况,这样就不适合使用profile进行分析了。而针对这种情况,我们可以先使用explain进行简单的定位分析。通过explain 对应的SQL,Starrocks会输出该SQL生成的执行计划树。我们可以通过该执行计划中的ScanNode的partition来查看是否存在全表扫描问题,也可以看出Join的分发方式以及左右表。但是由于其未真正执行SQL,所以没有执行时的信息统计,所以难以直接判断Join是否是最优等问题。此时我们可以通过拆分SQL,对其子查询进行统计,然后来判断该执行计划是否最优,以及如何进行优化。
4、总结
本文通过讲解Starocks数据存储、聚合和Join的底层实现,并简单介绍了Starrocks explain与profile两种分析工具,旨在帮助大家在日常使用中可以更好理解并分析优化SQL,在减少资源使用的同时提高执行效率。为了降低用户的学习与使用成本,后面我们团队也会不断增强Starrocks CBO的准确性与RBO的能力,尽可能通过Starrocks内核自身选择最优的执行计划。
最后我们从底至上对该执行计划的优化点进行一个简单的总结。
数据读取[Scan]
1.合理的建表:使用最经常作为where条件的字段作为分区键,使用高基数的字段作为分桶键保证数据打散,并设置合理的分桶数:
合理分桶数 = 原数据量 / 5 / 1G (因为建议每个分桶压缩后的数据量在100M~1G,而Starrocks默认使用lz4压缩,压缩比为5)。而如果是在DeltaLH V4以及之后版本,也可以不用设置分桶数,Starrocks会自动根据数据量计算合适的分桶数。
2.扫描快:主要由where条件决定,尽量使用分区、分桶、排序键进行where条件限定,使其可以直接进行分区、分桶、排序键快速过滤。
读的少:主要由where条件与select决定,where条件尽量精准,以减少数据的扫描量。只select需要的字段,这样可以减少内存使用的同时加快执行效率。
数据转换[Project]
尽可能使用执行效率比较高的函数替换执行效率比较低的函数,比如使用to_date() 代替 date_format(dteventtime,'%Y-%m-%d')。
数据聚合[Aggregate]
尽量保证聚合度高。
尽量保证无数据倾斜。
Join
右表为小表
使用合适的分发方式
输出[Sort+Result Sink]
由于Starrocks最终输出都要收集到一个节点进行数据,所以尽量使用Limit来限制输出的数据量。
了解更多游戏数据技术干货及资讯动态可以关注公众号:腾讯游戏数据
腾讯游戏数据(海外品牌名称Deltaverse)沉淀20余年、700+款大型游戏的数据工作经验,基于AI大模型及数据科学能力,为全球游戏业务和企业提供专业智能的数据平台及定制化解决方案,覆盖治理、开发、分析全链路及业务全场景,助力实现高效增长。