了解Apache Doris的存储层设计和存储结构

2,285 阅读13分钟

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文件命名规则为:rowset_id_{rowset\_id}\_{segment_id}.dat。

具体存储目录存储格式如下图所示: image.png

Segment v2文件结构

Segment的整体文件格式分为三部分:数据区、索引区、页脚,如下图所示: image.png

  • 数据区:用于存储各列的数据信息,这里的数据根据需要以页面形式加载

  • 索引区。Doris将每一列的索引数据统一存储在索引区。这里的数据将根据列的粒度来加载,所以它与列的数据信息分开存储。

  • 页脚信息

  • SegmentFooterPB:定义文件的元数据信息

  • 4字节的FooterPB内容的校验和

  • 4字节的FileFooterPB信息长度,用于阅读FileFooterPB

下面的分布介绍了各部分的存储格式的设计。

页脚信息

Footer信息段位于文件的末尾,它存储了文件的整体结构,包括数据字段的位置、索引字段的位置以及其他信息,包括SegmentFooterPB、CheckSum、Length、MAGIC CODE 4部分。

SegmentFooterPB数据结构如下: image.png

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中。具体结构如下图所示: image.png

与索引数据相对应的根页面地址被存储在OrdinalIndexMeta中。这里进行了一些优化。当数据只有一页时,这里的地址可以直接指向唯一的数据页;当不能放置一页时,则指向Ordinal Index类型的第二页 层次结构的索引页,索引数据中的每个数据项都对应着列数据页的偏移位置、大小和顺序行号信息。顺序索引的颗粒度与页的颗粒度相同,默认为64*1024字节。

列数据存储

数据页存储结构

DataPage主要分为两部分:数据部分和页脚部分。

数据部分存储的是当前页的列数据。当允许Null值时,Null值的Bitmap单独存储为Null值,Null值的行号通过bool类型以RLE格式编码记录。 image.png

Page Footer包含Page type类型,UncompressedSize未压缩的数据大小,FirstOrdinal RowId当前Page的第一行,NumValues是当前Page的行数,NullMapSize对应于NullBitmap的大小。

数据压缩

不同的字段类型使用不同的编码方式。默认情况下,不同类型采用的对应关系如下。

image.png

数据默认是以LZ4F格式压缩的。

短键索引

存储结构

短键索引前缀索引是一种基于键的排序(AGGREGATE KEY、UNIQ KEY和DUPLICATE KEY),根据给定的前缀列快速查询数据的索引方法。在这里,短键指数索引也采用了稀疏的索引结构。在数据写入过程中,每隔一定的行数就会产生一个索引项。对于索引的粒度来说,行数默认为1024行,可以进行配置。这个过程如下图所示。

image.png 其中,KeyBytes存储索引项的数据,OffsetBytes存储索引项在KeyBytes中的偏移。

索引生成规则

短键索引使用前36个字节作为这一行数据的前缀索引。当遇到VARCHAR类型时,前缀索引被简单截断。

应用案例

(1) 以下表结构的前缀索引是user_id(8Byte) + age(4Bytes) + message(前缀24Bytes)。 image.png (2) 下面的表结构的前缀索引是user_name(20Bytes)。即使没有达到36字节,因为遇到了VARCHAR,也会被直接截断,不会再继续下去。 image.png 当我们的查询条件是前缀索引的前缀时,查询速度就可以大大加快了。例如,在第一个例子中,我们执行以下查询。

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的索引存储结构如下图所示。

image.png

在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的存储结构如下图所示: image.png

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没有被命中时,意味着页面上没有这样的数据,这样可以减少扫描的页面数量。

案例:表的模式如下 image.png 这里的SQL如下。

SQL

SELECT * FROM table WHERE name = 'Zhang San'

由于名字的区分度很高,为了提高SQL的查询性能,在名字数据中添加了一个BloomFilter索引,PROPERTIES ( "bloom_filter_columns" = "name") 。在查询时,BloomFilter索引可以过滤掉大量的页面。

位图索引索引

Doris还提供BitmapIndex来加速数据查询。

存储结构

位图的存储格式如下

image.png

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。主要结构如下。 image.png 生成索引数据时,首先写入字典数据,地图结构的键值被写入DictColumn中。然后,键值对应于咆哮编码的rowid,以字节为单位将数据写入BitMapColumn。

应用案例

在查询数据时,位图索引可以用来优化差异化程度小、列心率小的数据列。例如,性别、婚姻、地理信息等。

案例:表的模式如下 image.png

这里的SQL如下。

SQL

SELECT * FROM table WHERE city in ("Beijing", "Shanghai")

由于城市的值比较小,在建立了数据字典和位图后,通过扫描位图可以快速找到匹配的行。而且经过位图压缩后,数据量本身就很小,通过扫描较少的数据就可以准确匹配整个列。

索引查询过程

在查询Segment中的数据时,根据所执行的查询条件,首先根据字段索引对数据进行过滤。然后读取数据,整个查询过程如下。

image.png

  1. 首先,将根据Segment中的行数构建一个row_bitmap,表示需要读取数据的记录。如果没有使用索引,所有的数据都需要被读取。

  2. 当根据前缀索引规则在查询条件中使用键时,短键索引将首先被过滤,短键索引中匹配的序号行数范围可以合并到row_bitmap中。

  3. 当查询条件中的列字段有BitMap Index索引时,将根据BitMap索引直接找到满足条件的序号行数,并得到与row_bitmap的交叉过滤。这里的过滤是准确的,删除查询条件后,这个字段不会被后续的索引过滤。

  4. 当查询条件中的列字段有BloomFilter索引且条件相等(eq,in,is)时,会被BloomFilter索引过滤,这里会经过所有的索引,过滤每个Page的BloomFilter,找出查询条件可以被All Pages击中。将索引信息中的序号行数范围与row_bitmap相交。

  5. 当查询条件中的列字段有ZoneMap索引时,将由ZoneMap索引进行过滤。这里,所有的索引也将被遍历,以找到查询条件能与ZoneMap相交的所有页面。将索引信息中的序号行数范围与row_bitmap相交。

  6. row_bitmap生成后,通过各Column的OrdinalIndex分批找到具体的数据页。

  7. 批量读取每一列的Column Data Page的数据。读取时,对于有空值的页面,根据空值位图判断当前行是否为空。如果是空值,可以直接填充。

总结

Doris目前采用了完整的列存储结构,并提供了丰富的索引来应对不同的查询场景,为Doris的高效写入和查询性能打下了坚实的基础。Doris存储层的设计非常灵活,未来还可以进一步增加新的索引和增强数据删除等功能。