Apache Doris 学习笔记: Backend存储层字符串编码与解码

字符串编码/解码

字符串编码主要逻辑位于BinaryDictPageBuilder以及其上下游的ColumnWriter和BitshufflePageBuilder。

它们在backend的storage_engine中负责把内存中的字符串数据(CHAR/VARCHAR,在EncodingInfoResolver中确定)进行编码压缩,打包成page并写入文件。

graph LR A[BetaRowsetWriter] -->B[SegmentWriter] B --> C[ColumnWriter] C -->D[BinaryDictPageBuilder] D -->E[BitshufflePageBuilder]
BetaRowsetWriter::flush_single_memtable(MemTable* memtable, int64_t* flush_size) [MemTable::Iterator->ContiguousRow(多个)]

· memtable: 用于把数据缓存在内存中,并利用类似跳表的结构有序地维护数据。在写满后异步生成一个segement。

· ContiguousRow: 利用MemTable::Iterator来取得memtable中的一行数据。含有schema和void*形式的数据。
Status SegmentWriter::append_row(const RowType& row) [ContiguousRow->RowCursorCell(每列一个)]

· RowCursorCell: 内部持有一个void类型的指针, void*的第一位是bool型的is_null,随后跟着数据(slice里面的uint8*和length)。 
ColumnWriter::append(const CellType& cell) [RowCursorCell -> void*]
BinaryDictPageBuilder::add(const uint8_t* vals, size_t* count) [void* -> Slice]

· Slice: 对uint8指针的封装,含有指针和长度(byte数),同时还含有一些功能性的函数。它主要来存储各种数据。

· OwnedSlice: 对Slice的封装,它将赋值运算的实现改为swap,以此来实现类似move的高效传递数据。
BitshufflePageBuilder::add(const uint8_t* vals, size_t* count) [UINT32]

· faststring: 和slice一样含有一个uint8和length,类似std::string的字符串类,通过预先申请32byte空间来提高一些操作的速度。支持转换到slice。

ScalarColumnWriter

一个page由若干slice组成body,然后和footer、next指针组成。

ScalarColumnWriter的写入过程中会产生一个page构成的链表,最后再统一write,如果是dict编码则在最后再写入一个DICTIONARY_PAGE(PLAIN_ENCODING类型)。

graph LR A[DataPage1] -->B[DataPage2] B --> C[DataPage3] C -->D[DataPage...] D -->E[DictionaryPage]

ScalarColumnWriter::finish_current_page()

从page_builder获取finish出来经过编码的page数据,然后加入自己的vector body。
如果自身是nullable的,则再往body里加入一个_null_bitmap_builder产生的page数据。

然后造一个当前page的footer,footer格式如下:

  • type: 表示page的类型,这里设置为DATA_PAGE,表示是存储数据的page。

  • uncompressed_size: page的大小,是body里所有slice的size求和。

  • first_ordinal: 该page第一行数据的row_id,每个ColumnWriter独立计数。

  • num_values: 元素个数。

  • nullmap_size: nullmap的size。

ScalarColumnWriter::write_data()

把当前的page都写入文件。

对于每个data_page,通过PageIO::write_page把body和footer写入writeable_block,然后返回一个page_pointer(page在文件中的偏移量和长度),最后把page_pointer加入ordinal_index。

在写完data_page后写入dictionary_page(字典编码时)。

把BinaryDictPageBuilder的hash_map通过PLAIN_ENCODING做成一个page,然后同样做好footer。

经过PageIO::compress_and_write_page压缩并写入wblock。(LZ4F)

然后把page_pointer写入ColumnWriterOptions.ColumnMetaPB.dict_page。


BinaryDictPageBuilder

对字符串做字典编码并传递给下层的bitshuffle_page_builder。

输入 :

通过ColumnWriter的append_data调用BinaryDictPageBuilder的add来传入uint8_t* ptr形式的数据(slice)。

输出 :

通过ColumnWriter的finish_current_page调用BinaryDictPageBuilder的finish来获取一个OwnedSlice格式的编码完的page数据。

示例

原始数据:

aaaaa,
aaaab,
bbbbb,
aaaaa,
bbbbb,
aaaaa

字典编码:

value_code dict
0,1,2,0,2,0 aaaaa->0,aaaab->1,bbbbb->2

bitshuffle:

源码中value_code为UINT32,有32位,这里为了简单起见改为4位。

0000,
0001,
0010,
0000,
0010,
0000

对数据按列重新排列:

000000,000000,001010,010000

lz4压缩(以扫描窗口大小为4举例):

0000(4,4)(8,4)00101(5,4)000

持有根据_encoding_type的值分为两种类型的PageBuilder,它们会产生不同编码类型的page。

DICT_ENCODING负责产生data_page,字典编码+bitshuffle+lz4压缩。

PLAIN_ENCODING负责产生dictionary_page,lz4f压缩(在write时)。

DICT_ENCODING

add(const uint8_t* vals, size_t* count) 添加一个字符串
这里会做一次字典编码,然后追加到BitshufflePageBuilder的_data(faststring)末尾。

详细过程 :

先进行一些合法性检查(例如是否已经finish过了,是否添加了空串)。

然后将输入的指针用reinterpret_cast强制转换为Slice指针(由void*转换为slice)。

如果是page的第一行数据,则要拷贝存入_first_value。

然后对Slice指针的的每个Slice进行遍历,在这里进行字典编码,获得每个Slice的value_code。

字典编码采用The Parallel Hashmap中的flat_hash_map。

相比普通的hash_map,flat_hash_map扩容的时候迭代器会失效,但是它有着更小的内存占用和在小数据规模下更快的性能。

phmap::flat_hash_map<Slice, uint32_t, HashOfSlice> _dictionary;

在x86架构下HashOfSlice内部默认采用CityHash64作为具体实现。

然后将value_code添加进BitshufflePageBuilder(第二层编码)。

在过程中同时在新增value_code时把string追加到一个vector,这样顺序存储value_code对应的string,之后用于生成dictionary_page。

在有许多重复输入的字符串时,这里的字典编码由一定压缩效果。

理论上这里可以根据value_code最大值去缩小存储类型的长度,从32位改为更小的位数。这样就能有效缩小存储空间,也能提高编码/解码效率。

不过之后还有一层编码压缩,所以这里的冗余部分在之后会有更高的压缩率。

DICT_ENCODING退化机制

当dictionary_page大于option_->dict_page_size(1024*1024)时,会触发退化机制。

此时会立刻finish当前page,在这之后的data_page的编码模式都会被reset成PLAIN_ENCODING(之前的page保持不变)。

finish()

返回一个自身page数据的OwnedSlice,会先经过BitshufflePageBuilder的finish(里面通过bitshuffle::compress_lz4进行一次lz4压缩)。

然后把这个slice转换成faststring,并在header写入编码类型(_encoding_type)。

在finish之后想要继续添加数据必须先执行reset重置自身。

get_dictionary_page()

返回字典,用于解码。
会在ColumnWriter执行write_data的时候被调用。多个page公用一个dictionary_page。

BinaryDictPageDecoder

与BinaryDictPageBuilder对应的解码器。

同样在_encoding_type=DICT_ENCODING时会持有BitShufflePageDecoder类型的_data_page_decoder,和PLAIN_ENCODING编码类型的_dict_decoder。

_data_page_decoder用来解码被Bitshuffle编码压缩的数据body,_dict_decoder用来解码字典。

然后通过_dict_decoder->string_at_index来把value_code转换回字符串slice并传递给作为输出的ColumnBlock。

最后通过ColumnBlock持有的Mempool申请一块连续内存,按顺序分配给每个Slice分配一个向上取到8的整倍数((mem_size + 7) & ~7)内存空间来做到内存对齐。

PLAIN_ENCODING

通过BinaryPlainPageBuilder生产BinaryPlainPage。直接append,不进行任何编码。

具体存储格式为|str_1|str_2|str_3....|str_n|offset_1|offset_2|offset_3....|offset_n|element_number|

str为字符串(不定长),offset和element_number为uint32(固定占4位)。

BinaryPlainPageDecoder

string_at_index(size_t idx)

根据id直接计算出偏移位置,然后获取对应value_code的字符串。

解码步骤

  1. 求出_offsets_pos(通过element_number计算offset部分的开头)
  2. 通过index求找出对应的offset,
  3. 通过offset找到str
  4. 通过对offset序列进行一次差分得到str的length序列。

BitshufflePageBuilder

生产BitShufflePage(以Slice的形式)。
BitShufflePage内部数据由Header和Element data两部分组成。

Header(16bytes 4个uint32_t):

num_elements,page内的元素个数

compressed_size,page压缩后的大小(Header和压缩后的Element data)

padded_num_elements,填充空元素的个数(BitShuffle库需要输入元素个数是8的倍数,所以需要在序列末尾添加空元素)

elem_size_bytes,每个元素占的空间大小。

Element data:

经过字典编码+BitShuffle编码和lz4压缩后的数据。
DictEncodingDataPage
_encoding_type
num_elements
compressed_size
padded_num_elements
elem_size_bytes
ElementData

add(const uint8_t* vals, size_t* count)

直接给slice追加数据,做了一些容量限制(这里写满一个page会返回到上层执行finish进行落盘)。

一个BitshufflePageBuilder可以写16384个value_code(page_size/sizeof(uint32),64×1024/4)。

finish()

首先记录first_value和last_value,之后在这部分进行bitshuffle排列和lz4压缩。

首先做了一些resize调整以满足bitshuffle条件,然后调用bitshuffle的compress_lz4把data数据压缩存储到faststring类型的buffer。

Bitshuffle本身没有压缩效果,只是把元素按位从高到低以列的顺序重新排列。它的作用是通过编码提高其他压缩算法的压缩率(特别是LZF和LZ4)。

最后更新buffer的header然后通过build返回OwnedSlice数据。

在进行之前的字典编码后再进行Bitshuffle很可能会让数据出现较长的全0前缀。

BitShufflePageDecoder

BitShufflePageDecoder在init(二段构造)时进行一系列检查数据合法性的操作,检查完后在_decode()进行lz4解码。

具体流程 :

首先检测init是否已经被调用过,防止重复解码。

然后检测输入数据长度是否小于header的长度,header长度是固定的,如果小于这个长度显然数据不合法。

然后解析出header部分的数据,通过header中的数据去做数据合法性检测:		

1、校验data size是否和header中的_compressed_size一致。

2、检测header中_num_element_after_padding是否和通过_num_elements重新计算出的实际数值一致。

3、检测_size_of_element符合要求,除了UNSIGNED_INT类型外的数据元素size必须和decoder的SIZE_OF_TYPE相同。UNSIGNED_INT类型则允许从数据定义的size比decoder定义的size小(允许类型提升到UNSIGNED_INT)。

然后执行_decode()开始解码,把解码的数据存储到faststring类型的_decoded。

next_batch(size_t* n, ColumnBlockView* dst)

读取一批数据,因为在二段构造后解码实际已经完成,所以可以直接取出数据到ColumnBlockView。

跟其他层的next_batch或者类似功能的函数一样,输入需要提取元素的个数n,然后对n和剩余元素取min,最后将成功读取的元素个数更新回n返回。

posted @ 2021-08-31 14:08  BiteTheDDDDt  阅读(1025)  评论(0编辑  收藏  举报