一文聊透Apache Hudi的索引設計與應用

Hudi索引在數據讀和寫的過程中都有應用。讀的過程主要是查詢引擎利用MetaDataTable使用索引進行Data Skipping以提高查找速度;寫的過程主要應用在upsert寫上,即利用索引查找該紀錄是新增(I)還是更新(U),以提高寫入過程中紀錄的打標(tag)速度。

MetaDataTable

目前使能了"hoodie.metadata.enable"後,會在.hoodie目錄下生成一張名爲metadata的mor表,利用該表可以顯著提升源表的讀寫性能。

該表目前包含三個分區:files, column_stats, bloom_filters,分區下文件的格式爲hfile,採用該格式的目的主要是爲了提高metadata表的點查能力。

其中files分區紀錄了源表各個分區內的所有文件列表,這樣hudi在生成源表的文件系統視圖時就不必再依賴文件系統的list files操作(在雲存儲場景list files操作更有可能是性能瓶頸);TimeLine Server和上述設計類似,也是通過時間線服務器來避免對提交元數據進行list以生成hudi active timeline。

其中column_stats分區紀錄了源表中各個分區內所有文件的統計信息,主要是每個文件中各個列的最大值,最小值,紀錄數據,空值數量等。只有在開啓了"hoodie.metadata.index.column.stats.enable"參數後纔會使能column_stats分區,默認源表中所有列的統計信息都會紀錄,也可以通過"hoodie.metadata.index.column.stats.column.list"參數單獨設置。Hudi表每次提交時都會更新column_stats分區內各文件統計信息(這部分統計信息在提交前的文件寫入階段便已經統計好)。

其中bloom_filters分區紀錄了源表中各個分區內所有文件的bloom_filter信息,只有在開啓了"hoodie.metadata.index.bloom.filter.enable"參數後纔會使能bloom_filters分區,默認紀錄源表中record key的bloomfilter, 也可以通過"hoodie.metadata.index.bloom.filter.column.list"參數單獨設置。

需要注意bloom_filter信息不僅僅存儲在metadata表中(存在該表中是爲了讀取加速,減少從各個base文件中提取bloomfilter的IO開銷)。Hudi表在開啓了"hoodie.populate.meta.fields"參數後(默認開啓),在完成一個parquet文件寫入時,會在parquet文件的footerMetadata中填充bloomfilter相關參數, 其中"hoodie_bloom_filter_type_code"參數爲過濾器類型,設置爲默認的DYNAMIC_V0(可根據record key數量動態擴容);"org.apache.hudi.bloomfilter"參數爲過濾器bitmap序列化結果;"hoodie_min_record_key"參數爲當前文件record_key最小值;"hoodie_max_record_key"參數爲當前文件record_key最大值。Hudi表提交時其Metadata表bloom_filters分區內的bloom_filter信息便提取自parquet文件footerMetadata的"org.apache.hudi.bloomfilter".

寫入

對flink寫入而言就是通過bucket_idx進行打標(僅支持分區內去重打標)或者bucket_assigner算子使用flink state進行打標(支持分區內以及全局去重打標,可通過參數控制,如果要進行全局去重需要使能index.global.enabled且不使能changelog.enabled),目前flink僅支持這兩種方式,具體可參考hoodieStreamWrite方法。

對於upsert寫入場景,flink state會隨着寫入數據量的增大而線性增大,導致越寫越慢(打標過程變慢)的現象;而bucket_idx由於沒有數據查找過程(通過紀錄的record key直接哈希得到對應的filegroup進行打標),因此寫入速度不會隨數據量增大而線性增大。

如果應用場景需要對分區表進行全局去重,則只能使用flink state。如果上層業務允許,我們也可以通過變更表結構,將分區鍵加入到主鍵中作爲主鍵的一部分來實現分區間的天然去重。

圖2. 1 flink寫入打標過程

對於metadata表而言,flink可以通過使能參數"hoodie.metadata.index.column.stats.enable"生成column_stats,flink可以在讀優化查詢時使用到列統計信息進行data skipping。

對於metadata表而言,flink可以通過使能參數"hoodie.metadata.index.bloom.filter.enable"生成bloom_filters,但是flink

目前不支持在讀時使用bloomfilter進行data skipping,也不支持在寫時通過bloomfilter進行打標。

Spark

對spark寫入而言就是對每條紀錄調用index.tagLocation進行打標的過程。Spark目前支持SimpleIndex, GlobalSimpleIndex, BloomIndex, BucketIndex, HbaseIndex進行寫入打標。

SimpleIndex通過在每個分區內進行InputRecordRdd left outer join ExistingRecordRdd的方式判斷輸入紀錄是否已經存儲在當前分區內;GlobalSimpleIndex和SimpleIndex類似,只不過left outer join該表內所有已存在數據而不是當前分區已存在數據。

BloomIndex通過column_stat_idx和bloom_filter_idx進行數據打標過濾:首先通過column_stat_idx(可以從metadata表中獲取,也可從parquet footer metadata中獲取,通過"hoodie.bloom.index.use.metadata"參數控制)的min,max值過濾掉紀錄肯定不存在的文件(在record key遞增且數據經過clustering的情況下可以過濾出大量文件)以獲得紀錄可能存在的文件。然後在紀錄可能存在的文件中依次使用每個文件對應的bloomfilter(可以從metadata表中獲取,也可從parquet footer metadata中獲取,通過"hoodie.bloom.index.use.metadata"參數控制)判斷該紀錄是否一定不存在。最後得到每個文件可能包含的紀錄列表,由於bloomfilter的誤判特性,需要將這些紀錄在文件中進行精準匹配查找以得到實際需要更新的紀錄及其對應的location.

圖2. 2 spark寫入使用BloomIndex打標過程

BucketIndex和flink的bucket打標類似,通過hash(record_key) mod bucket_num的方式得到紀錄實際應該插入的文件位置,如果該文件不存在則爲插入,存在則爲更新。

HbaseIndex通過外部hbase服務存儲record key,因此打標過程需要和hbase服務進行交互,由於使用hbase存儲,因此該索引天然是全局的。

讀取

flink讀取目前支持使用column_stats進行data skipping.建表時需要使能"metadata.enabled","hoodie.metadata.index.column.stats.enable","read.data.skipping.enabled",這三個參數。

Spark

spark讀取目前支持使用column_stats進行data skipping.建表時需要使能" hoodie.metadata.enabled","hoodie.metadata.index.column.stats.enable","read.data.skipping.enabled",這三個參數。

總結

寫入打標:

column_stat_idx bloom_filter_idx bucket_idx flink_state Simple Hbase_idx
Spark Y Y Y N flink only Y Y
Flink N N Y Y N spark only N

MetaDataTable表索引分區構建:

file_idx column_stat_idx bloom_filter_idx
Spark Y Y Y
Flink Y Y Y

讀取data skipping:

column_stat_idx bloom_filter_idx bucket_idx
Spark Y N N
Flink Y N N

社區進展/規劃

Column Stats Index

RFC-27 Data skipping(column_stats) index to improve query performance

狀態:COMPLETED

簡述:列統計索引的rfc設計

原理:列統計索引存儲在metadata table中,使用hfile存儲索引數據

圖5. 1 hfile layout

HFile最大的優勢是數據按照key進行了排序,因此點查速度很快。

圖5. 2 column stats index storage format

由於HFile的前綴搜索速度很快,因此上述佈局(一個列的統計信息在相鄰的data block中)可以快速拿到一個列在各個文件中的統計信息。

https://cwiki.apache.org/confluence/display/HUDI/RFC-27+Data+skipping+index+to+improve+query+performance

https://github.com/apache/hudi/blob/master/rfc/rfc-27/rfc-27.md

RFC-58 Integrate column stats index with all query engines

狀態:UNDER REVIEW

簡述:集成列統計索引到presto/trino/hive

原理:基於RFC-27 metadata table中的column_stats index來實現上述引擎的data skipping,當前有兩種可能的實現:基於列域(column domain, 域是一個列可能包含值的一個集合)的實現和基於hudiExpression的實現。

圖5. 3 HudiExpression sketch

https://github.com/apache/hudi/pull/6345/files?short_path=e681037#diff-e6810379013465743bdbdab398ba78e45381edfd64399c125c16f21752f36728

https://github.com/apache/hudi/pull/6345

Bucket Index

RFC-29 Hash(bucket) Index

狀態:COMPLETED

簡述:bucket index的rfc設計

原理:對主鍵做hash後取桶個數的模(hash(pk) mod bucket_num), 即數據在寫入時就按照主鍵進行了clustering,後續upsert可以直接通過hash找到對應的桶。

圖5. 4 hash process

圖5. 5 bucket-filegroup mapping

目前一個桶和一個filegroup一一對應,數據文件的前綴會加上bucketId。

https://cwiki.apache.org/confluence/display/HUDI/RFC+-+29%3A+Hash+Index

RFC-42 Consistent hashing index for dynamic bucket numbers

狀態:ONGOING

簡述:bucket index一致性哈希實現的rfc設計

原理:RFC-29實現的bucket index不支持動態修改桶個數,由此導致數據傾斜和一個file group size過大,採用一致性哈希可以在不改變大多數桶的情況下完成桶的分裂/合併,以儘可能小的減小動態調整桶數量時對讀寫的影響。

圖5. 6 一致性哈希算法

通過Hash(v) % 0xFFFF得到一個範圍hash值,然後通過一個range mapping layer將哈希值和桶關聯起來,可以看到如果bucket#2過大,可以將其對應的範圍0x5000-0xA000進行split分成兩個桶,僅需要在這個範圍內進行重新分桶/文件重寫即可。

圖5. 7 算法複雜度對比

https://github.com/apache/hudi/blob/master/rfc/rfc-42/rfc-42.md

https://github.com/apache/hudi/pull/4958

https://github.com/apache/hudi/pull/6737

Bloom Index

RFC-37 Metadata based Bloom Index

狀態:COMPLETED

簡述:bloom index的rfc設計

原理:將base文件內的bloom filter提取到metadata table中以減少IO,提升查找速度

圖5. 8 bloom filter location in metadata table

圖5. 9 bloom index storage format

Key的生成方式爲:

key = base64_encode(concat(hash64(partition name), hash128(file name)))

因此一個分區內的文件天然的在HFile的相鄰data block中,採用base64編碼可以減少key的磁盤存儲空間。

https://github.com/apache/hudi/blob/master/rfc/rfc-37/rfc-37.md

Record-level Index

RFC-08 Record-level index to speed up UUID-based upserts and deletes

狀態:ONGOING

簡述:記錄級(主鍵)索引的rfc設計

原理:爲每條記錄生成recordKey <-> partition, fileId的映射索引,以加速upsert的打標過程。

圖5. 10 行級索引實現

每條記錄被哈希到對應的bucket中,每一個bucket中包含多個HFile文件,每個HFile文件的data block中包含recordKey <-> partition, fileId的映射。

https://cwiki.apache.org/confluence/display/HUDI/RFC-08++Record+level+indexing+mechanisms+for+Hudi+datasets

https://issues.apache.org/jira/browse/HUDI-53

https://github.com/apache/hudi/pull/5581

Secondary Index

RFC-52 Secondary index to improve query performance

狀態:UNDER REVIEW

簡述:二級(非主鍵/輔助)索引的rfc設計

原理:二級索引可以精確匹配數據行(記錄級別索引只能定位到fileGroup),即提供一個column value -> row 的映射,如果查詢謂詞包含二級索引列就可以根據上述映射關係快速定位目標行。

圖5. 11 二級索引架構

圖5. 12 使用Lucene index進行謂詞過濾

如上圖所示:先通過row group統計信息進行首次過濾以加載指定page頁,然後通過lucene索引文件(倒排索引,key爲列值,value爲row id集合)過濾出指定的行(以row id標識),合併各謂詞的row id,加載各個列的page頁並進行row id對齊,取出目標行。Lucene index只是二級索引框架下的一種可能實現。

https://github.com/apache/hudi/pull/5370

Function Index

RFC-63 Index on Function

狀態:UNDER REVIEW

簡述:函數索引的rfc設計

原理:通過sql或者hudi配置定義一個在某列上的函數作爲函數索引,將其記錄到表屬性中,在數據寫入時索引函數可以作爲排序域,由此每個數據文件對應於索引函數值都有一個較小的min-max以進行有效的文件過濾,同時metadata table中也會維護文件級別的索引函數值對應的列統計信息。數據文件中不會新增索引函數值對應的列。

圖5. 13 帶timestamp的hudi表

如上圖所示,一個場景需要過濾出每天1點到2點的數據,由於把timestamp直接轉成小時將不會保序,就沒法直接使用timestamp的min,max進行文件過濾,如果我們對timestamp列做一個HOUR(timestamp)的函數索引,然後將每個文件對應的函數索引min,max值記錄到metadata table中,就可以快速的使用上述索引值進行文件過濾。

https://github.com/apache/hudi/pull/5370

Support data skipping for MOR

Hudi當前還不支持針對MOR表中log文件的索引,社區目前正在討論中:

https://issues.apache.org/jira/browse/HUDI-3866

https://cwiki.apache.org/confluence/display/HUDI/RFC+-+06+%3A+Add+indexing+support+to+the+log+file

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章