Doris是一个基于MPP架构的交互式SQL数据仓库,主要用于解决近实时报表和多维分析。Doris的高效导入和查询与它的存储结构的精巧设计是分不开的。
本文主要通过解读Doris BE模块的代码,分析Doris BE模块存储层的实现原理,阐述和解密了Doris高效写入和查询能力背后的核心技术。其中包括Doris列存储设计、索引设计、数据读写过程、压缩过程、Tablet和Rowset的版本管理、数据备份等功能。
本文介绍了Segment V2版本的存储层结构,包括有序存储、稀疏索引、前缀索引、位图索引、BloomFilter等丰富的功能,可以为各种复杂场景提供极速的查询能力。
设计目标
-
大量导入,少量更新
-
绝大多数的读取请求
-
宽表场景,读取大量的行,少量的列
-
非交易型场景
-
良好的可扩展性
保存文件格式
存储目录结构
存储层对存储数据的管理是通过storage_root_path路径配置的,这个路径可以是多个。下一层的存储目录是按照桶来组织的。具体的片断存储在bucket目录中,子目录根据tablet_id来命名。
片断文件存储在 tablet_id 目录中,根据 SchemaHash 管理。可以有多个Segment文件,一般根据大小划分,默认为256MB。其中,segment v2文件命名规则为:{segment_id}.dat。
具体存储目录存储格式如下图所示:
Segment v2文件结构
Segment的整体文件格式分为三部分:数据区、索引区、页脚,如下图所示:
-
数据区:用于存储各列的数据信息,这里的数据根据需要以页面形式加载
-
索引区。Doris将每一列的索引数据统一存储在索引区。这里的数据将根据列的粒度来加载,所以它与列的数据信息分开存储。
-
页脚信息
-
SegmentFooterPB:定义文件的元数据信息
-
4字节的FooterPB内容的校验和
-
4字节的FileFooterPB信息长度,用于阅读FileFooterPB
下面的分布介绍了各部分的存储格式的设计。
页脚信息
Footer信息段位于文件的末尾,它存储了文件的整体结构,包括数据字段的位置、索引字段的位置以及其他信息,包括SegmentFooterPB、CheckSum、Length、MAGIC CODE 4部分。
SegmentFooterPB数据结构如下:
SegmentFooterPB采用PB格式存储,主要包括列的元信息、索引的元信息、段的短键索引信息和总行数。
列的元信息
- ColumnId:当前列在模式中的序列号
- UniqueId:全局唯一的ID
- Type:该列的类型信息
- Length:该列的长度信息
- Encoding:编码格式
- Compression(压缩)。压缩格式
- Dict PagePointer。字典信息
列索引的元信息
-
OrdinalIndex:存储该列的稀疏索引元信息。
-
ZoneMapIndex:存储ZoneMap索引的元信息,包括最大值,最小值,是否有空值,是否没有非空值。SegmentZoneMap存储全局ZoneMap信息,PageZoneMaps存储每个页面的统计信息。
-
BitMapIndex:存储BitMap索引的元信息,包括BitMap类型、字典数据BitMap数据。
-
BloomFilterIndex:存储BloomFilter索引信息。存储BloomFilter的索引信息。
为了防止索引本身的数据量过大,ZoneMapIndex、BitMapIndex和BloomFilterIndex采用两级Page管理。与IndexColumnMeta的结构相对应,当一个Page可以放下时,当前Page直接存储索引数据,即采用一级结构;当一个Page不能放下时,索引数据被写入一个新的Page中,根Page存储数据页的地址信息。
顺序索引
顺序索引(Ordinal Index)索引按行号提供列数据页的物理地址。顺序索引可以按行对齐列存储的数据,这可以理解为一级索引。当在其他索引中寻找数据时,序数索引被用来寻找数据页的位置。因此,这里先介绍一下Ordinal Index索引。
在一个段中,数据总是按照键的排序顺序(AGGREGATE KEY、UNIQ KEY和DUPLICATE KEY)来存储,也就是说,键的排序决定了数据存储的物理结构。列数据的物理结构顺序是确定的。写入数据时,列数据页由Ordinal索引管理。Ordinal索引记录了每个Column Data Page的第一个数据项的位置偏移、大小和行号信息。也就是Ordinal。这样一来,每一列都有能力按行信息快速扫描。顺序索引采用的稀疏索引结构就像一个图书目录,记录每一章对应的页码。
存储结构
顺序索引元信息存储在SegmentFooterPB中每一列的OrdinalIndexMeta中。具体结构如下图所示:
与索引数据相对应的根页面地址被存储在OrdinalIndexMeta中。这里进行了一些优化。当数据只有一页时,这里的地址可以直接指向唯一的数据页;当不能放置一页时,则指向Ordinal Index类型的第二页 层次结构的索引页,索引数据中的每个数据项都对应着列数据页的偏移位置、大小和顺序行号信息。顺序索引的颗粒度与页的颗粒度相同,默认为64*1024字节。
列数据存储
数据页存储结构
DataPage主要分为两部分:数据部分和页脚部分。
数据部分存储的是当前页的列数据。当允许Null值时,Null值的Bitmap单独存储为Null值,Null值的行号通过bool类型以RLE格式编码记录。
Page Footer包含Page type类型,UncompressedSize未压缩的数据大小,FirstOrdinal RowId当前Page的第一行,NumValues是当前Page的行数,NullMapSize对应于NullBitmap的大小。
数据压缩
不同的字段类型使用不同的编码方式。默认情况下,不同类型采用的对应关系如下。
数据默认是以LZ4F格式压缩的。
短键索引
存储结构
短键索引前缀索引是一种基于键的排序(AGGREGATE KEY、UNIQ KEY和DUPLICATE KEY),根据给定的前缀列快速查询数据的索引方法。在这里,短键指数索引也采用了稀疏的索引结构。在数据写入过程中,每隔一定的行数就会产生一个索引项。对于索引的粒度来说,行数默认为1024行,可以进行配置。这个过程如下图所示。
其中,KeyBytes存储索引项的数据,OffsetBytes存储索引项在KeyBytes中的偏移。
索引生成规则
短键索引使用前36个字节作为这一行数据的前缀索引。当遇到VARCHAR类型时,前缀索引被简单截断。
应用案例
(1) 以下表结构的前缀索引是user_id(8Byte) + age(4Bytes) + message(前缀24Bytes)。
(2) 下面的表结构的前缀索引是user_name(20Bytes)。即使没有达到36字节,因为遇到了VARCHAR,也会被直接截断,不会再继续下去。
当我们的查询条件是前缀索引的前缀时,查询速度就可以大大加快了。例如,在第一个例子中,我们执行以下查询。
SQL
SELECT * FROM table WHERE user_id=1829239 and age=20;
这个查询的效率会比下面的查询高很多。
SQL
SELECT * FROM table WHERE age=20;
因此,在建表时,选择正确的列序可以大大提高查询效率。
ZoneMap索引
ZoneMap索引存储了Segment和每个Page所对应的每一列的统计数据。这些统计数据可以帮助加快查询速度,减少扫描的数据量。这些统计数据包括Min的最大值,Max的最小值,HashNull的空值,以及HasNotNull的不全空信息。
存储结构
ZoneMap的索引存储结构如下图所示。
在SegmentFootPB结构中,每一列索引元数据ColumnIndexMeta存储了当前列的ZoneMapIndex索引数据信息。ZoneMapIndex有两个部分,SegmentZoneMap和PageZoneMaps。SegmentZoneMap存储的是当前段的全局ZoneMap索引信息,PageZoneMaps存储的是每个数据页的ZoneMap索引信息。
PageZoneMaps对应于存储在索引数据中的Page信息的IndexedColumnMeta结构。目前,实现中没有压缩,编码方法也是Plain。IndexedColumnMeta中的OrdinalIndexPage指向索引数据的根页面的偏移量和大小。第二层的Page优化也是在这里完成的。当只有一个DataPage时,OrdinalIndexMeta直接指向这个DataPage;当有多个DataPages时,OrdinalIndexMeta首先指向OrdinalIndexPage,OrdinalIndexPage它是一个二级Page结构,其中的数据项是索引数据DataPage的地址偏移、大小和序号信息。
索引生成规则
Doris默认为关键列打开ZoneMap索引;当表的模型为DUPULCATE时,所有字段都启用ZoneMap索引。当列数据被写入页面时,数据被自动比较,当前段的ZoneMap和当前页面的ZoneMap的索引信息被持续维护。
应用案例
在数据查询过程中,根据范围条件过滤的字段将根据ZoneMap的统计数据选择扫描的数据范围。例如,在案例1中,对年龄字段进行过滤。查询语句如下。
SQL
SELECT * FROM table WHERE age > 20 and age < 1000
如果没有命中短键索引,将使用ZoneMap索引,根据条件语句中年龄的查询条件,找到应该扫描的普通数据范围,减少需要扫描的页数。
BloomFilter
当某些字段不能使用短键索引,且字段具有较高的区分度时,Doris提供BloomFilter索引。
存储结构
BloomFilter的存储结构如下图所示:
BloomFilterIndex信息存储了BloomFilter产生的Hash策略、Hash算法以及相应的数据页信息。哈希算法采用HASH_MURMUR3,哈希策略采用BlockSplitBloomFilter块实现策略,预期误报率fpp默认配置为0.05。
BloomFilter索引数据对应的数据页的存储方式与ZoneMapIndex类似,并对二级页面进行了优化,这里不做详细介绍。
索引生成规则
BloomFilter是按Page粒度生成的。当数据被写入一个完整的Page时,Doris会根据Hash策略同时生成这个Page的BloomFilter索引数据。目前,该过滤器不支持tinyint/hll/float/double类型,其他类型已经支持。使用时,需要在PROPERTIES中指定bloom_filter_columns 要被BloomFilter索引的字段。
应用案例
当查询数据时,查询条件是在设置了bloom过滤器的字段中进行过滤。当bloom filter没有被命中时,意味着页面上没有这样的数据,这样可以减少扫描的页面数量。
案例:表的模式如下
这里的SQL如下。
SQL
SELECT * FROM table WHERE name = 'Zhang San'
由于名字的区分度很高,为了提高SQL的查询性能,在名字数据中添加了一个BloomFilter索引,PROPERTIES ( "bloom_filter_columns" = "name") 。在查询时,BloomFilter索引可以过滤掉大量的页面。
位图索引索引
Doris还提供BitmapIndex来加速数据查询。
存储结构
位图的存储格式如下
BitmapIndex的元信息也存储在SegmentFootPB中。BitmapIndex包括三个部分,BitMap类型,字典信息DictColumn,以及位图索引数据信息BitMapColumn。其中DictColumn和BitMapColumn对应于IndexedColumnData结构,分别存储字典数据和索引数据的Page地址偏移和大小。二级页面的优化也是在这里完成的,不再详细说明。
与其他索引存储结构不同的是,DictColumn的字典数据是经过LZ4F压缩的,在记录二级Page偏移量时,会存储Data Page的第一个值。
索引生成规则
创建BitMap时,需要通过CREATE INDEX创建。Bitmap的索引是整个Segment中Column字段的索引,而不是为每个Page生成一个单独的副本。写入数据时,需要维护一个map结构来记录每个键值对应的行号,Roaring bitmap用于编码rowid。主要结构如下。
生成索引数据时,首先写入字典数据,地图结构的键值被写入DictColumn中。然后,键值对应于咆哮编码的rowid,以字节为单位将数据写入BitMapColumn。
应用案例
在查询数据时,位图索引可以用来优化差异化程度小、列心率小的数据列。例如,性别、婚姻、地理信息等。
案例:表的模式如下
这里的SQL如下。
SQL
SELECT * FROM table WHERE city in ("Beijing", "Shanghai")
由于城市的值比较小,在建立了数据字典和位图后,通过扫描位图可以快速找到匹配的行。而且经过位图压缩后,数据量本身就很小,通过扫描较少的数据就可以准确匹配整个列。
索引查询过程
在查询Segment中的数据时,根据所执行的查询条件,首先根据字段索引对数据进行过滤。然后读取数据,整个查询过程如下。
-
首先,将根据Segment中的行数构建一个row_bitmap,表示需要读取数据的记录。如果没有使用索引,所有的数据都需要被读取。
-
当根据前缀索引规则在查询条件中使用键时,短键索引将首先被过滤,短键索引中匹配的序号行数范围可以合并到row_bitmap中。
-
当查询条件中的列字段有BitMap Index索引时,将根据BitMap索引直接找到满足条件的序号行数,并得到与row_bitmap的交叉过滤。这里的过滤是准确的,删除查询条件后,这个字段不会被后续的索引过滤。
-
当查询条件中的列字段有BloomFilter索引且条件相等(eq,in,is)时,会被BloomFilter索引过滤,这里会经过所有的索引,过滤每个Page的BloomFilter,找出查询条件可以被All Pages击中。将索引信息中的序号行数范围与row_bitmap相交。
-
当查询条件中的列字段有ZoneMap索引时,将由ZoneMap索引进行过滤。这里,所有的索引也将被遍历,以找到查询条件能与ZoneMap相交的所有页面。将索引信息中的序号行数范围与row_bitmap相交。
-
row_bitmap生成后,通过各Column的OrdinalIndex分批找到具体的数据页。
-
批量读取每一列的Column Data Page的数据。读取时,对于有空值的页面,根据空值位图判断当前行是否为空。如果是空值,可以直接填充。
总结
Doris目前采用了完整的列存储结构,并提供了丰富的索引来应对不同的查询场景,为Doris的高效写入和查询性能打下了坚实的基础。Doris存储层的设计非常灵活,未来还可以进一步增加新的索引和增强数据删除等功能。