1. 程式人生 > 其它 >Apache Doris 學習筆記: 字串編碼與解碼

Apache Doris 學習筆記: 字串編碼與解碼

字串編碼/解碼

字串編碼主要邏輯位於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返回。