一文講透大數據列存標準格式:Parquet

語言: CN / TW / HK

導讀:

今天介紹一種大數據時代有名的列式存儲文件格式:Parquet,被廣泛用於 Spark、Hadoop 數據存儲。Parquet 中文直譯是鑲木地板,意思是結構緊湊,空間佔用率高。

1

概念

大規模分析型數據處理在互聯網乃至其他行業中應用 都已 越來越廣泛,尤其是當前已經可以用廉價的存儲來收集、保存海量的業務數據情況下。如何讓分析師和工程師便捷的利用這些數據也變得越來越重要。列式存儲(Column-oriented Storage)是大數據場景面向分析型數據的主流存儲方式。與行式存儲相比,列存由於可以只提取部分數據列、同列同質數據擁有更好的編碼及壓縮方式,因此在 OLAP 場景下能提供更好的 IO 性能。

Apache Parquet 是由 Twitter 和 Cloudera 最先發起併合作開發的列存項目,也是 2010 年 Google 發表的 Dremel 論文中描述的內部列存格式的開源實現。和一些傳統的列式存儲(C-Store、MonetDB 等)系統相比,Dremel/Parquet 最大的貢獻是支持嵌套格式數據(Nested Data)的列式存儲。嵌套格式可以很自然的描述互聯網和科學計算等領域的數據,Dremel/Parquet “原生”的支持嵌套格式數據減少了規則化、重新組合這些大規模數據的代價。

Parquet 的設計與計算框架、數據模型以及編程語言無關,可以與任意項目集成,因此應用廣泛。目前已經是 Hadoop 大數據生態圈列式存儲的事實標準。

2

原理

2.1 行存 VS 列存

例如,下圖是擁有 A/B/C 3 個字段的簡單示意表:

在面向行的存儲中,每列的數據依次排成一行,如下所示:

而在面向列的存儲中,相同列的數據存儲在一起:

顯而易見,行存適用於數據整行讀取場景,而列存適用於只讀取部分列數據(如統計分析等)場景。

2.2 數據模型

(1)schema 協議

想要深入的瞭解 Parquet 存儲格式首先需要理解它的數據模型。Parquet 採用了一個類似 Google Protobuf 的協議來描述存儲數據的 schema。下面是 Parquet 數據 schema 的一個簡單示例:

message AddressBook {
required string owner;
repeated string ownerPhoneNumbers;
repeated group contacts {
required string name;
optional string phoneNumber;
}
}

schema 的最上層是 message,裏面可以包含一系列字段。每個字段都擁有3個屬性:重複性(repetition)、類型(type)以及名稱(name)。字段類型可以是一個 group 或者原子類型(如 int/boolean/string 等),group 可以用來表示數據的嵌套結構。字段的重複性有三種情況:

  • required:有且只有一次

  • optional:0或1次

  • repeated:0或多次

這個模型非常的簡潔。一些複雜的數據類型如:Map/List/Set 也可以用重複的字段(repeated fields) + groups 來表達,因此也就不用再單獨定義這些類型。

採用 repeated field 表達 List 或者 Set 的示例:

採用 repeated group(包含 key 和 value,其中 key 是 required) 來表達 Map 的示例:

(2)列式存儲格式

試想一下,為了使數據能夠按列存儲,對於一條記錄(Record),首先要將其按列(Column)進行拆分。對於扁平(Flat)結構數據,拆分比較直觀,一個字段即對應一列,而嵌套格式數據會複雜些。Dremel/Parquet 中,提出以樹狀層級的形式組織 schema 中的字段(Field),樹的葉子結點對應一個原子類型字段,這樣這個模型能同時覆蓋扁平結構和嵌套結構數據(扁平結構只是嵌套結構的一種特例)。嵌套字段的完整路徑使用簡單的點分符號表示,如 contacts. name。

AddressBook 例子以樹狀結構展示的樣式:

列存連續的存儲一個字段的值,以便進行高效的編碼壓縮及快速的讀取。Dremel 中行存 vs 列存的圖示:

(3)Repetition and Definition Levels

對於嵌套格式列存,除了按列拆分進行連續的存儲,還需要能夠“無損”的保留嵌套格式的結構化信息,以便正確的重建記錄。

只有字段值不能表達清楚記錄的結構。給定一個重複字段的兩個值,我們不知道此值是在什麼“級別”被重複的(比如,這些值是來自兩個不同的記錄,還是相同的記錄中兩個重複的值)。同樣的,給出一個缺失的可選字段,我們不知道整個路徑有多少字段被顯示定義了。

Dremel 提出了 Repetition Level(重複級別)和 Definition Level(定義級別)兩個概念,用以解決這個問題。並實現了記錄中任意一個字段的恢復都不需要依賴其它字段,且可以對任意字段子集按原始嵌套格式進行重建。

Repetition levels:用以表示在該字段路徑上哪個節點進行了重複 (at what repeated field in the field’s path the value has repeated)。

一個重複字段存儲的列值,有可能來自不同記錄,也可能由同一記錄的不同層級節點重複導致。如上圖中的 Code 字段,他在 r1 記錄中出現了 3 次,分別是字段 Name 和 Language 重複導致的,其中 Language 先重複了 2 次,Name 字段再重複了 1 次。

Repetition Levels 採用數字代表重複節點的層級。根據樹形層次結構,根結點為 0、下一層級為 1… 依次類推。根結點的重複暗含了記錄的重複,也即 r=0 代表新記錄的開始。required 和 optional 字段不需要 repetition level,只有可重複的字段需要。因此,上述 Code 字段的 repetition levels 範圍為 0-2。當我們從上往下掃描 r1 記錄,首先遇到 Code 的值是“en-us”,由於它之前沒有該字段路徑相關的字段出現,因此 r=0;其次遇到“en”,是 Language 層級重複導致的,r=2;最後遇到“en-gb”,是 Name 層級重複導致的,因此 r=1。所以,Code 字段的 repetition levels 在 r1 記錄中是“0,2,1”。

需要注意的是,r1 記錄中的第二個重複 Name,由於其不包含 Code 字段,為了區分“en-gb”值是來自記錄中的第三個 Name 而不是第二個,我們需要在“en”和“en-gb”之間插入一個值“null”。由於它是 Name 級重複的,因此它的 r=1。另外還需要注意一些隱含信息,比如 Code 是 required 字段類型,因此一旦 Code 出現未定義,則隱含表明其上級 Language 也肯定未定義。

Definition Levels:用以表示該字段路徑上有多少可選的字段實際進行了定義(how many fields in p that could be undefined (because they are optional or repeated) are actually present)。

光有 Repetition Levels 尚無法完全保留嵌套結構信息,考慮上述圖中 r1 記錄的 Backward 字段。由於 r1 中未定義 Backward 字段,因此我們插入一個“null”並設置 r=0。但 Backward 的上級 Links 字段在 r1 中顯式的進行了定義,null 和 r=0 無法再表達出這一層信息。因此需要額外再添加 Definition Levels 定義記錄可選字段出現的個數,Backward 的路徑上出現 1 個可選字段 Links,因此它的 d=1。

有了 Definition Levels 我們就可以清楚的知道該值出現在字段路徑的第幾層,對未定義字段的 null 和字段實際的值為 null 也能進行區分。只有 optional 和 repeated 字段需要 Definition Levels 定義,因為 required 字段已經隱含了字段肯定被定義(這可以減少 Definition Levels 需要描述的數字,並在一定程度上節省後續的存儲空間)。另外一些其他的隱含信息:如果 Definition Levels 小於路徑中 optional + repeated 字段的數量,則該字段的值肯定為 null;Definition Levels 的值為 0 隱含了 Repeated Levels 也為 0(路徑中沒有 optional/repeated 字段或整個路徑未定義)。

(4)striping and assembly 算法

現在把 Repetition Levels 和 Definition Levels 兩個概念一起考慮。還是沿用上述 AddressBook 例子。下表顯示了 AddressBook 中每個字段的最大重複和定義級別,並解釋了為什麼它們小於列的深度:

假設這是兩條真實的 AddressBook 數據:

AddressBook {
owner: "Julien Le Dem",
ownerPhoneNumbers: "555 123 4567",
ownerPhoneNumbers: "555 666 1337",
contacts: {
name: "Dmitriy Ryaboy",
phoneNumber: "555 987 6543",
},
contacts: {
name: "Chris Aniszczyk"
}
}
AddressBook {
owner: "A. Nonymous"
}

我們採用 contacts.phoneNumber 字段來演示一下拆解和重組記錄的 striping and assembly 算法。

僅針對 contacts.phoneNumber 字段投影后,數據具有如下結構:

AddressBook {
contacts: {
phoneNumber: "555 987 6543"
}
contacts: {
}
}
AddressBook {
}

計算可得該該字段對應的數據如下(R =重複級別,D =定義級別):

因此我們最終存儲的記錄數據如下:

contacts.phoneNumber: “555 987 6543”
new record: R = 0
value is defined: D = maximum (2)
contacts.phoneNumber: null
repeated contacts: R = 1
only defined up to contacts: D = 1
contacts: null
new record: R = 0
only defined up to AddressBook: D = 0

使用圖表展示(注意其中的 null 值並不會實際存儲,原因如上所説只要 Definition Levels 小於其 max 值即隱含該字段值為 null):

在重組該記錄時,我們重複讀取該字段的值:

R=0, D=2, Value = “555 987 6543”:
R = 0 means a new record. We recreate the nested records from the root until the definition level (here 2)
D = 2 which is the maximum. The value is defined and is inserted.
R=1, D=1:
R = 1 means a new entry in the contacts list at level 1.
D = 1 means contacts is defined but not phoneNumber, so we just create an empty contacts.
R=0, D=0:
R = 0 means a new record. we create the nested records from the root until the definition level
D = 0 => contacts is actually null, so we only have an empty AddressBook

3

實現

Parquet 工程具體的實現。

3.1 Parquet 文件存儲格式中的術語

  • Block (hdfs block):即指 HDFS Block,Parquet 的設計與 HDFS 完全兼容。Block 是 HDFS 文件存儲的基本單位,HDFS 會維護一個 Block 的多個副本。在 Hadoop 1.x 版本中 Block 默認大小 64M,Hadoop 2.x 版本中默認大小為 128M。

  • File:HDFS 文件,保存了該文件的元數據信息,但可以不包含實際數據(由 Block 保存)。

  • Row group:按照行將數據劃分為多個邏輯水平分區。一個 Row group(行組)由每個列的一個列塊(Column Chunk)組成。

  • Column chunk:一個列的列塊,分佈在行組當中,並在文件中保證是連續的。

  • Page:一個列塊切分成多個 Pages(頁面),概念上講,頁面是 Parquet 中最小的基礎單元(就壓縮和編碼方面而言)。一個列塊中可以有多個類型的頁面。

3.2 並行化執行的基本單元

  • MapReduce - File/Row Group(一個任務對應一個文件或一個行組)

  • IO - Column chunk(任務中的 IO 以列塊為單位進行讀取)

  • Encoding/Compression - Page(編碼格式和壓縮一次以一個頁面為單位進行)

3.3 Parquet 文件格式

Parquet 文件格式是自解析的,採用 thrift 格式定義的文件 schema 以及其他元數據信息一起存儲在文件的末尾。

文件存儲格式示例:

4-byte magic number "PAR1"
<Column 1 Chunk 1 + Column Metadata>
<Column 2 Chunk 1 + Column Metadata>
...
<Column N Chunk 1 + Column Metadata>
<Column 1 Chunk 2 + Column Metadata>
<Column 2 Chunk 2 + Column Metadata>
...
<Column N Chunk 2 + Column Metadata>
...
<Column 1 Chunk M + Column Metadata>
<Column 2 Chunk M + Column Metadata>
...
<Column N Chunk M + Column Metadata>
File Metadata
4-byte length in bytes of file metadata
4-byte magic number "PAR1"

整個文件(表)有 N 個列,劃分成了 M 個行組,每個行組都有所有列的一個 Chunk 和其元數據信息。文件的元數據信息存儲在數據之後,包含了所有列塊元數據信息的起始位置。讀取的時候首先從文件末尾讀取文件元數據信息,再在其中找到感興趣的 Column Chunk 信息,並依次讀取。文件元數據信息放在文件最後是為了方便數據依序一次性寫入。

具體的存儲格式展示圖:

3.4 元數據信息

Parquet 總共有 3 種類型的元數據:文件元數據、列(塊)元數據和 page header 元數據。所有元數據都採用 thrift 協議存儲。具體信息如下所示:

3.5 Parquet 數據類型

在實現層級上,Parquet 只保留了最精簡的部分數據類型,以方便存儲和讀寫。在其上有邏輯類型(Logical Types)以供擴展,比如:邏輯類型 strings 就映射為帶有 UTF8 標識的二進制 byte arrays 進行存儲。

Types:

BOOLEAN: 1 bit boolean
INT32: 32 bit signed ints
INT64: 64 bit signed ints
INT96: 96 bit signed ints
FLOAT: IEEE 32-bit floating point values
DOUBLE: IEEE 64-bit floating point values
BYTE_ARRAY: arbitrarily long byte arrays.

邏輯類型的更多説明請參考:

https://github.com/apache/parquet-format/blob/master/LogicalTypes.md

3.6 Encoding

數據編碼的實現大部分和原理部分所闡述的一致,這裏不再重複説明,更多細節可參考: https://github.com/apache/parquet-format/blob/master/Encodings.md

3.7 Column chunks 存儲

Column chunks 由一個個 Pages 組成,Reader 在讀取的時候可以根據 page header 信息跳過不感興趣的頁面。page header 中還存儲着頁面數據編碼和壓縮的信息。

3.8 錯誤情況處理

如果文件元數據損壞,則整個文件將丟失。如果列元數據損壞,則該列塊將丟失(但其他行組中該列的列塊還可以使用)。如果 page header 損壞,則該列塊中的剩餘頁面都將丟失。如果頁面中的數據損壞,則該頁面將丟失。較小的文件行組配置,可以更有效地抵抗損壞。

3.9 推薦配置

行組大小(Row group size): 更大的行組允許更大的列塊,這使得可以執行更大的順序 IO。不過更大的行組需要更大的寫緩存。Parquet 建議使用較大的行組(512MB-1GB)。此外由於可能需要讀取整個行組,因此最好一個行組能完全適配一個 HDFS Block。因此,HDFS 塊大小也需要相應的設置更大。一個較優的讀取配置為:行組大小 1GB,HDFS 塊大小 1GB,每個 HDFS 文件對應 1 個 HDFS 塊。

數據頁大小(Data page size): 數據頁應視為不可分割的,因此較小的數據頁可實現更細粒度的讀取(例如單行查找)。但較大的頁面可以減少空間的開銷(減少 page header 數量)和潛在的較少的解析開銷(處理 headers)。Parquet 建議的頁面大小為 8KB。

參考資料:

[1]. Dremel: Interactive Analysis of WebScale Datasets

[2]. Dremel made simple with Parquet

[3]. 經典論文翻譯導讀之《Dremel: Interactive Analysis of WebScale Datasets》

[4]. 處理海量數據:列式存儲綜述(存儲篇)

[5]. https://parquet.apache.org/documentation/latest/

作者簡介

大鯨,網易數帆有數實時數倉開發工程師,曾從事搜索系統、實時計算平台等相關工作。

獲取最新動態

最新的推文無法在第一時間看到?

以前的推文還需要複雜漫長的翻閲?

進入“網易有數”公眾號介紹頁,點擊右上角

“設為星標”

置頂公眾號,從此消息不迷路

設為星標,最新推文不迷路

分享,點贊,在看,安排一下?