字典编码在Apache Doris中的应用
文章目录
字典编码简介
字典编码是指将某一列数据的值替换成一些编码后的值,在进行存储和处理时转而使用这些编码值,同时额外维护一个从编码值到原始值的映射。理想情况下,字典编码应该能够进行快速编解码,并且能够提升数据处理的速度。
在实现字典编码时,有几个值得考虑的问题:
- 编码值的长度:
- 固定长度
- 根据要编码值的值域大小动态变化
- 字典的构建时机:
- 一次性构建:对于一批数据,对其构建字典后该字典不再改变,后续的新数据必须构建新的字典
- 增量构建:将新的数据合并到原始字典中
- 字典的粒度:
- 块级:对一个表的一部分维持字典
- 表级:对一张表维持字典
- 多表级:对多张表或整个库维持字典
- 偏序关系:码字的偏序关系是否需要和原始值的偏序关系一致
- 编码和解码的时机:在数据从导入到内存到进行处理的流程当中的什么时候进行编码/解码操作
Apache Doris中字典编码的相关存储结构
在存储文件中的位置
我们首先来看字典编码相关数据结构在存储文件中的位置。目前doris使用segment_v2结构存储数据,一个Segment
主要包括数据区,索引区和Footer三部分,其中Footer部分负责存储该segment的一些元数据,其结构由SegmentFooterPB.proto
所描述。在SegmentFooterPB
中有一个类型为ColumnMetaPB
的Columns
字段用于存储table schema。
|
|
可以看到ColumnMetaPB
中有一个类型为EncodingTypePB
的字段encoding
,有一个类型为PagePointerPB
的字段dict_page
存储字典页的指针。encoding
用于存储当前segment的编码类型,继续看EncodingTypePB
的定义我们可以看到目前doris支持的编码种类,和字典编码相关的有DICT_ENCODING
和PLAIN_ENCODING
。
|
|
字典编码存储结构
在Apache Doris中,使用字典编码的数据页的存储会用到两种模式
PLAIN_ENCODING
模式:页面布局为header+embedded BinaryPlainPage,即将数据项在页头依次顺序排放,各个数据的offsets从页末尾开始依次逆序排放。DICT_ENCODING
模式:布局为header+embedded codeword page,即数据页中存放(压缩后的)码字。此时编码信息存放在dict_page
中,目前dict_page
只支持按BinaryPlainPage格式存放。
这里字典编码的编码信息最多只会存放在一个页中,当编码信息页的大小超过一个阈值后,之后添加的数据页会自动从DICT_ENCODING
模式fall back到PLAIN_ENCODING
模式,且之前已经编码过的数据页不受影响。这个字典信息页大小的阈值由PageBuilderOptions
中的dict_page_size
字段所决定,目前其值为固定的大小1024 * 1024
即1M。
Doris所有类型支持的编码方式在EncodingInfoResolver
进行了注册,我可以看到其中可以使用字典编码的类型有OLAP_FIELD_TYPE_CHAR
, OLAP_FIELD_TYPE_VARCHAR
, OLAP_FIELD_TYPE_STRING
, OLAP_FIELD_TYPE_JSONB
。并且在不开启optimize_value_seek
选项的情况下,这些类型的字段默认使用字典编码进行存储。
编码流程
写路径
我们从DeltaWriter
开始,通过考察一个WriteRequest
的写路径来看在什么地方会应用字典编码的编码过程。
- 对每个
WriteRequest
(一个WriteRequest
会写入到一个Tablet
,一个Tablet
就是一个表的物理储存结构)会创建一个对应的DeltaWriter
完成写入 - 通过调用
DeltaWriter
的append()/write()
方法不断写入增量数据,完成一个Tablet
的写入。 DeltaWriter
首先先写入到内存中的MemTable
(使用SkipList
存储数据)中,写满后,则提交一个MemtableFlushTask
异步任务到线程池,这个异步任务负责将Memtable
写入成为磁盘上的当前数据变更对应的Rowset
中的一个Segment
,然后重置当前的MemTable
处理后续的写入。
其中MemtableFlushTask
异步任务的调用链如下:
MemtableFlushTask::run()
->FlushToken::_flush_memtable()
->Memtable::flush()
->MemTable::_do_flush()
:通过MemTable::_collect_vskiplist_results()
将跳表中的数据收集到_output_mutable_block
中再转为Block
(包含了一次磁盘写入的数据和元数据)以供后续写入- ->
BetaRowsetWriter::flush_single_memtable()
:创建SegmentWriter
,并添加刚刚构造的Block
。SegmentWriter
继续分别通过ShortKeyIndexBuilder
,PrimaryKeyIndexBuilder
构造并写入对应的索引数据, 通过ColumnWriter
写入列数据 - 不同类型的
ColumnWriter
(ScalarColumnWriter
/StructColumnWriter
/ArrayColumnWriter
/MapColumnWriter
)通过各种Builder
生成并写入列数据和对应的各类索引数据(BloomFilterIndex
/BitMapIndex
/ZoneMapIndex
/InvertedIndex
/OrdinalIndex
) - 上述各类
xxxBuilder
最终通过PageIO::write_page()/compress_and_write_page()
->FileWriter::appendv()
将数据写入到不同类型的存储介质(local file/s3/hdfs等)上
我们所关心的字典编码会在ScalarColumnWriter
下的BinaryDictPageBuilder
进行处理。
ScalarColumnWriter
在ScalarColumnWriter::init()
函数中会获取使用的压缩算法和使用的编码类型,并通过编码类型获取相应的xxxPageBuilder
进行数据页的构建。当使用DICT_ENCODING
编码类型时,这会获取到BinaryDictPageBuilder
。
由于各种索引(BloomFilterIndex
/BitMapIndex
/ZoneMapIndex
/InvertedIndex
/OrdinalIndex
)的目的是根据查询来缩小最终进行scan的范围,所以这些索引当中依然存储的是关于原始值的相关信息。
上游通过ScalarColumnWriter::append_data()
向ScalarColumnWriter
输入数据,ScalarColumnWriter
会将上游输入的数据组织成ScalarColumnWriter::Page
构成的链表最后再一次性地写入。其中,一个Page
包括数据部分和footer部分。其中数据可能部分有以下三种情况:
- 包含一个
OwnedSlice
且数据页已压缩 - 包含一个
OwnedSlice
,数据页未压缩且没有nullmap - 包含一个
OwnedSlice
,数据页未压缩且包含一个nullmap页
在ScalarColumnWriter::append_data()
->ScalarColumnWriter::append_data_in_current_page()
中会将写入继续下发到下游的xxxPageBuilder
中,这其中就包括BinaryDictPageBuilder
。而当某个数据页写满后(_page_builder->is_page_full()
),会取出对应的数据页更新ZoneMap和BloomFilter的信息,并构造相应的nullmap页(如果该列nullable),然后构造一个新的Page
同时设置其footer部分,然后尝试对其进行压缩,最终将该Page
加入到链表中。
上游最终通过调用ScalarColumnWriter::write_data()
中将上述Page
链表中的数据全部写入到存储介质。对于需要字典编码的,会通过_page_builder->get_dictionary_page()
获取到字典编码信息页,设置其footer部分后压缩并写入到存储介质,最后更新其ColomnMetaPB
的dict_page
字段。
BinaryDictPageBuilder
BinaryDictPageBuilder
通过内存中的phmap::flat_hash_map
来维持字典值到字典id的映射,并通过下游的_data_page_builder
和_dict_builder
来构建和输出数据页和字典编码信息页。
_data_page_builder
: 类型为BitshufflePageBuilder
,负责构建和输出由数据编码后的编码值组成的页,其会对存储的编码值使用压缩算法进一步压缩。_dict_builder
: 类型为BinaryPlainPageBuilder
,负责构建和输出保存字典应映射信息的页,即ColumnMetaPB
中dict_page
字段所指向的页。
BinaryDictPageBuilder
的编码方式为将任意长度的字节序列映射为一个uint32_t
,编码值从0开始依次递增,0表示空串的编码,1是第一个有效编码值。
字典编码的实际编码过程发生在ScalarColumnWriter::append_data_in_current_page()
->BinaryDictPageBuilder::add()
中,对于每个输入的数据块,若为空,则返回_empty_code
;否则如果该值存储在与哈希表中则直接返回相应的编码值;否则,将该值加入到哈希表中并赋予新的编码值,同时将该新的编码映射加入到_dict_builder
中。最后通过_data_page_builder
将编码值写入到数据页中。
解码流程
读初始化
NewOlapScanner::init()
->Tablet::capture_rs_readers()
->BetaRowset::create_reader()
:NewOlapScanner
初始化Tablet
对象,之后根据需要的版本信息找到对应的Rowset
并创建RowsetReader
,然后初始化TabletReader
的读参数,包括查询条件,遍历范围等。NewOlapScanner::open()
->BlockReader::init()/VerticalBlockReader::init()
:- 根据读参数初始化初始化查询条件,各种filters等
- 初始化
RowsetReader
并添加到VCollectIterator
中 - 根据数据表数据模型的不同(
Aggregate
/Unique
/Duplicate
)设置数据读取函数(_direct_next_block
/_unique_key_next_block
/_agg_key_next_block
)
BetaRowsetReader::init()
:- ->
BetaRowsetReader::get_segment_iterators()
:- 初始化
key_ranges
,delete_handler
等 - ->
SegmentLoader::load_segment()
: 将Rowset
下的Segment
导入到内存中并创建对应的迭代器SegmentIterator
- 初始化
- 将多个
SegmentIterator
合并为一个new_merge_iterator/new_union_iterator
并对其进行初始化
- ->
读路径
接下来我们来看和字典解码相关的读路径。
BlockReader/next_block_with_aggregation::next_block_with_aggregation()
: 调用实际注册的读取函数,都通过VCollectIterator::next()
读一行数据,然后根据数据模型不同进行处理VCollectIterator::next()
: 从其管理的RowsetIterator
中读出一行BetaRowsetReader::next_block()
->RowwiseIterator::next_batch()
->SegmentIterator::next_batch()
->SegmentIterator::_next_batch_internal()
:- ->
SegmentIterator::_init()
: 初始化BitmapIndexIterator/InvertedIndexIterator
等 - ->
SegmentIterator::_read_columns_by_index()
:- 通过
BitmapRangeIterator::next_range()
得出当前读取的行号范围 - 先读出有predict的列的上述范围的数据,然后再进行精确过滤。再读取无条件的列中的数据。
- 通过
- ->
- 上述过程中
SegmentIterator
会通过SegmentIterator::_seek_columns()
移动指针,通过SegmentIterator::next_batch()
获取数据,而这些方法会调用对应类型ColumnIterator
的_read_data_page()
方法取读取页数据 ColumnIterator::_read_data_page()
->ColumnReader::read_page()
->PageIO::read_and_decompress_page()
: 最终完从存储介质上的读取并Parse出对应的页数据
FileColumnIterator
字典信息页的读取发生在FileColumnIterator::_read_data_page()
中,为了优化内存使用,字典信息页会在第一次被使用时才被读取到内存中。
上层对FileColumnIterator
的各种seek_xxx()/next_batch()/read()
读调用都需要通过FileColumnIterator::_read_data_page()
进行页的读取,它继而使用对应的ColumnReader
读取页数据并Parse到内存中。对于字典编码的页面,如果此时字典信息页还没有读入到内存中,则会读取字典信息页然后使用BinaryPlainPageDecoder
对其进行解码,并将字典的值存放到一个StringRef
数组中用于后续解码,由于字典码值从0开始且依次递增,所以直接通过数组下标访问就可以得到码值对应的原始值。之后它通过BinaryDictPageDecoder::set_dict_decoder()
将该数组传递到该页的BinaryDictPageDecoder
中。
BinaryDictPageDecoder
BinaryDictPageDecoder
负责对编码类型为DICT_ENCODING
和PLAIN_ENCODING
的数据页进行解码。
- 对于编码类型为
DICT_ENCODING
的数据页,使用BitShufflePageDecoder
对页数据进行解压缩,通过BinaryPlainPageDecoder
对字典信息页进行解码 - 对于编码类型为
PLAIN_ENCODING
的数据页,使用BinaryPlainPageDecoder
根据页尾的offsets取出页中的数据。
上层读取数据时,BinaryDictPageDecoder
通过_data_page_decoder
从数据页中读取数据,并通过Column::insert_many_dict_data()
将数据与字典dict
提供给上层,不同的类型的Column
再根据需要特化实际的处理过程。
查询执行过程中的处理
上面说到BinaryDictPageDecoder
值负责提供码值和字典,上层会根据需要进行特化。例如对于ColumnString
来说,其会在ColumnString::insert_many_dict_data()
中完成解码的过程,即根据每个位置的码字拷贝出对应的字符串并修改offsets
数组。显然,这种方式并充分发挥字典编码的优势,特别是对于string类型的列而言,其在一个查询中主要是作为聚合键的一部分,或者在条件中进行等值比较,在这种情况下,使用直接码值进行处理显然要比直接处理原值更高效。所以Doris针对低基数的string列使用了ColumnDictioinary
来进行优化。它会在ColumnDictionary::insert_many_dict_data()
中直接拷贝码值并保存字典。之后,对于等值比较,它将等值比较中的常量转化为字典中对应的码值。对于range类型的条件过滤,它会首先将字典的码值变换为可以反应原始值偏序关系的码值,然后在应用range predicate时就可以直接比较对应的码值。对于不完全使用字典编码的,即含有plain encoding编码类型的页的,ColumnDictionary
会转化成PredicateColumn
继续后续的处理。
参考资料
文章作者 bobh
上次更新 2023-05-11