Parquet調研報告
1. 概述
1.1 簡介
Apache Parquet是Hadoop生態圈中一種新型列式存儲格式,它可以兼容Hadoop生態圈中大多數計算框架(Hadoop、Spark等),被多種查詢引擎支持(Hive、Impala、Drill等),并且它是語言和平臺無關的。Parquet最初是由Twitter和Cloudera(由于Impala的緣故)合作開發完成并開源,2015年5月從Apache的孵化器里畢業成為Apache頂級項目,最新的版本是1.8.1。
Parquet是語言無關的,而且不與任何一種數據處理框架綁定在一起,適配多種語言和組件,能夠與Parquet配合的組件有:
查詢引擎: Hive, Impala, Pig, Presto, Drill, Tajo, HAWQ, IBM Big SQL
計算框架: MapReduce, Spark, Cascading, Crunch, Scalding, Kite
數據模型: Avro, Thrift, Protocol Buffers, POJOs
那么Parquet是如何與這些組件協作的呢?這個可以通過下圖來說明。數據從內存到Parquet文件或者反過來的過程主要由以下三個部分組成:
- 存儲格式(storage format)
parquet-format項目定義了Parquet內部的數據類型、存儲格式等。
- 對象模型轉換器(object model converters)
這部分功能由parquet-mr項目來實現,主要完成外部對象模型與Parquet內部數據類型的映射。
- 對象模型(object models)
對象模型可以簡單理解為內存中的數據表示,Avro, Thrift, Protocol Buffers, Hive SerDe, Pig Tuple, Spark SQL InternalRow等這些都是對象模型。Parquet也提供了一個example object model 幫助大家理解。
例如parquet-mr項目里的parquet-pig項目就是負責把內存中的Pig Tuple序列化并按列存儲成Parquet格式,以及反過來把Parquet文件的數據反序列化成Pig Tuple。
這里需要注意的是Avro, Thrift, Protocol Buffers都有他們自己的存儲格式,但是Parquet并沒有使用他們,而是使用了自己在parquet-format項目里定義的存儲格式。所以如果你的應用使用了Avro等對象模型,這些數據序列化到磁盤還是使用的parquet-mr定義的轉換器把他們轉換成Parquet自己的存儲格式。
1.2 列式存儲
列式存儲,顧名思義就是按照列進行存儲數據,把某一列的數據連續的存儲,每一行中的不同列的值離散分布。列式存儲技術并不新鮮,在關系數據庫中都已經在使用,尤其是在針對OLAP場景下的數據存儲,由于OLAP場景下的數據大部分情況下都是批量導入,基本上不需要支持單條記錄的增刪改操作,而查詢的時候大多數都是只使用部分列進行過濾、聚合,對少數列進行計算(基本不需要select * from xx之類的查詢)。
example:
以下這張表有A、B、C三個字段:
A | B | C |
---|---|---|
A1 | B1 | C1 |
A2 | B2 | C2 |
A3 | B3 | C3 |
行存儲:
A1 | B1 | C1 | A2 | B2 | C2 | A3 | B3 | C3 |
---|
列存儲
A1 | A2 | A3 | B1 | B2 | B3 | C1 | C2 | C3 |
---|
列式存儲可以大大提升這類查詢的性能,較之于行是存儲,列式存儲能夠帶來這些優化:
- 查詢的時候不需要掃描全部的數據,而只需要讀取每次查詢涉及的列,這樣可以將I/O消耗降低N倍,另外可以保存每一列的統計信息(min、max、sum等),實現部分的謂詞下推。
- 由于每一列的成員都是同構的,可以針對不同的數據類型使用更高效的數據壓縮算法,進一步減小I/O。
- 由于每一列的成員的同構性,可以使用更加適合CPU pipeline的編碼方式,減小CPU的緩存失效。
2. Parquet詳解
2.1 數據模型
理解Parquet首先要理解這個列存儲格式的數據模型。我們以一個下面這樣的schema和數據為例來說明這個問題。
message AddressBook {
required string owner;
repeated string ownerPhoneNumbers;
repeated group contacts {
required string name;
optional string phoneNumber;
}
}
這個schema中每條記錄表示一個人的AddressBook。有且只有一個owner,owner可以有0個或者多個ownerPhoneNumbers,owner可以有0個或者多個contacts。每個contact有且只有一個name,這個contact的phoneNumber可有可無。
每個schema的結構是這樣的:根叫做message,message包含多個fields。每個field包含三個屬性:repetition, type, name。repetition可以是以下三種:required(出現1次),optional(出現0次或者1次),repeated(出現0次或者多次)。type可以是一個group或者一個primitive類型。
Parquet格式的數據類型不需要復雜的Map, List, Set等,而是使用repeated fields 和 groups來表示。例如List和Set可以被表示成一個repeated field,Map可以表示成一個包含有key-value 對的repeated group
,而且key是required的。
List(或Set)可以用repeated field來表示:
Map可以用包含key-value對且key是required的repeated group來表示:
2.2 列存儲格式
列存儲通過將相同基本類型(primitive type)的值存儲在一起來提供高效的編碼和解碼。為了用列存儲來存儲如上嵌套的數據結構,我們需要將該schema用某種方式映射到一系列的列使我們能夠將記錄寫到列中并且能讀取成原來的嵌套的數據結構。
在Parquet格式的存儲中,一個schema的樹結構有幾個葉子節點(葉子節點都是primitive type),實際的存儲中就會有多少column。
上面的schema的樹結構如圖所示:
上面這個schema的數據存儲實際上有四個column,如下圖所示:
只有字段值不能表達清楚記錄的結構。給出一個repeated field的兩個值,我們不知道此值是按什么‘深度’被重復的(比如,這些值是來自兩個不同的記錄,還是相同的記錄中兩個重復的值)。同樣的,給出一個缺失的可選字段,我們不知道整個路徑有多少字段被顯示定義了。因此我們將介紹repetition level 和 definition level的概念。
example:
兩條嵌套的記錄和它們的schema:
將上圖的兩條記錄用列存儲表示:
上面的例子主要是想讓大家對嵌套結構的列式存儲有個直觀的印象,包括repetition level 和 definition level的應用,接下來詳細介紹repetition level 和 definition level。
2.3 Definition levels
Definition level指明該列的路徑上多少個可選field被定義了。
嵌套數據類型的特點是有些field(optional field 和 repeated field)可以是空的,也就是沒有定義。如果一個field是定義的,那么它的所有的父節點都是被定義的。從根節點開始遍歷,當某一個field的路徑上的節點開始是空的時候我們記錄下當前的深度作為這個field的Definition Level。如果一個field的definition Level等于這個field的最大definition Level就說明這個field是有數據的。對于required類型的field必須是有定義的,所以這個Definition Level是不需要的。在關系型數據中,optional類型的field被編碼成0表示空和1表示非空(或者反之)。
注:definition Level是該路徑上有定義的repeated field 和 optional field的個數,不包括required field,因為required field是必須有定義的。
再舉個簡單的例子:
message ExampleDefinitionLevel {
optional group a {
required group b {
optional string c;
}
}
}
因為b是required field,所以第3行c的definition level為1而不是2(因為b是required field,所有不需計算在內);第4行c的definition level為2而不是3(理由同上).
2.4 Repetition levels
Repetition level指明該值在路徑中哪個repeated field重復。
Repetition level是針對repeted field的。注意在圖2中的Code字段。可以看到它在r1出現了3次。‘en-us’、‘en’在第一個Name中,而‘en-gb’在第三個Name中。結合了圖2你肯定能理解我上一句話并知道‘en-us’、‘en’、‘en-gb’出現在r1中的具體位置,但是不看圖的話呢?怎么用文字,或者說是一種定義、一種屬性、一個數值,詮釋清楚它們出現的位置?這就是重復深度這個概念的作用,它能用一個數字告訴我們在路徑中的什么重復字段,此值重復了,以此來確定此值的位置(注意,這里的重復,特指在某個repeated類型的字段下“重復”出現的“重復”)。我們用深度0表示一個紀錄的開頭(虛擬的根節點),深度的計算忽略非重復字段(標簽不是repeated的字段都不算在深度里)。所以在Name.Language.Code這個路徑中,包含兩個重復字段,Name和Language,如果在Name處重復,重復深度為1(虛擬的根節點是0,下一級就是1),在Language處重復就是2,不可能在Code處重復,它是required類型,表示有且僅有一個;同樣的,在路徑Links.Forward中,Links是optional的,不參與深度計算(不可能重復),Forward是repeated的,因此只有在Forward處重復時重復深度為1。現在我們從上至下掃描紀錄r1。當我們遇到’en-us’,我們沒看到任何重復字段,也就是說,重復深度是0。當我們遇到‘en’,字段Language重復了(在‘en-us’的路徑里已經出現過一個Language),所以重復深度是2.最終,當我們遇到’en-gb‘,Name重復了(Name在前面‘en-us’和‘en’的路徑里已經出現過一次,而此Name后Language只出現過一次,沒有重復),所以重復深度是1。因此,r1中Code的值的重復深度是0、2、1.
要注意第二個Name在r1中沒有包含任何Code值。為了確定‘en-gb’出現在第三個Name而不是第二個,我們添加一個NULL值在‘en’和‘en-gb’之間(如圖3所示)。
2.5 Striping and assembly
下面用AddressBook的例子來說明Striping和assembly的過程。
對于每個column的最大的Repetion Level和 Definition Level下圖所示。
下面這樣兩條record:
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這一列為例,"555 987 6543"這個contacts.phoneNumber的Definition Level是最大Definition Level=2。而如果一個contact沒有phoneNumber,那么它的Definition Level就是1。如果連contact都沒有,那么它的Definition Level就是0。
下面我們拿掉其他三個column只看contacts.phoneNumber這個column,把上面的兩條record簡化成下面的樣子:
AddressBook {
contacts: {
phoneNumber: "555 987 6543"
}
contacts: {
}
}
AddressBook {
}
這兩條記錄的序列化過程如下圖所示:
如果我們要把這個column寫到磁盤上,磁盤上會寫入這樣的數據:
注意:NULL實際上不會被存儲,如果一個column value的Definition Level小于該column最大Definition Level的話,那么就表示這是一個空值。
下面是從磁盤上讀取數據并反序列化成AddressBook對象的過程:
-
讀取第一個三元組R=0, D=2, Value=”555 987 6543”
R=0 表示是一個新的record,要根據schema創建一個新的nested record直到Definition Level=2。
D=2 說明Definition Level=Max Definition Level,那么這個Value就是contacts.phoneNumber這一列的值,賦值操作contacts.phoneNumber=”555 987 6543”。
-
讀取第二個三元組 R=1, D=1
R=1 表示不是一個新的record,是上一個record中一個新的contacts。
D=1 表示contacts定義了,但是contacts的下一個級別也就是phoneNumber沒有被定義,所以創建一個空的contacts。
-
讀取第三個三元組 R=0, D=0
R=0 表示一個新的record,根據schema創建一個新的nested record直到Definition Level=0,也就是創建一個AddressBook根節點。
可以看出在Parquet列式存儲中,對于一個schema的所有葉子節點會被當成column存儲,而且葉子節點一定是primitive類型的數據。對于這樣一個primitive類型的數據會衍生出三個sub columns (R, D, Value),也就是從邏輯上看除了數據本身以外會存儲大量的Definition Level和Repetition Level。那么這些Definition Level和Repetition Level是否會帶來額外的存儲開銷呢?實際上這部分額外的存儲開銷是可以忽略的。因為對于一個schema來說level都是有上限的,而且非repeated類型的field不需要Repetition Level,required類型的field不需要Definition Level,也可以縮短這個上限。例如對于Twitter的7層嵌套的schema來說,只需要3個bits就可以表示這兩個Level了。
對于存儲關系型的record,record中的元素都是非空的(NOT NULL in SQL)。Repetion Level和Definition Level都是0,所以這兩個sub column就完全不需要存儲了。所以在存儲非嵌套類型的時候,Parquet格式也是一樣高效的。
2.6 文件格式
-
行組(Row Group):按照行將數據物理上劃分為多個單元,每一個行組包含一定的行數。一個行組包含這個行組對應的區間內的所有列的列塊。
官方建議:
更大的行組意味著更大的列塊,使得能夠做更大的序列IO。我們建議設置更大的行組(512MB-1GB)。因為一次可能需要讀取整個行組,所以我們想讓一個行組剛好在一個HDFS塊中。因此,HDFS塊的大小也需要被設得更大。一個最優的讀設置是:1GB的行組,1GB的HDFS塊,1個HDFS塊放一個HDFS文件。
列塊(Column Chunk):在一個行組中每一列保存在一個列塊中,行組中的所有列連續的存儲在這個行組文件中。不同的列塊可能使用不同的算法進行壓縮。一個列塊由多個頁組成。
頁(Page):每一個列塊劃分為多個頁,頁是壓縮和編碼的單元,對數據模型來說頁是透明的。在同一個列塊的不同頁可能使用不同的編碼方式。官方建議一個頁為8KB。
上圖展示了一個Parquet文件的結構,一個文件中可以存儲多個行組,文件的首位都是該文件的Magic Code,用于校驗它是否是一個Parquet文件,Footer length存儲了文件元數據的大小,通過該值和文件長度可以計算出元數據的偏移量,文件的元數據中包括每一個行組的元數據信息和當前文件的Schema信息。除了文件中每一個行組的元數據,每一頁的開始都會存儲該頁的元數據,在Parquet中,有三種類型的頁:數據頁、字典頁和索引頁。數據頁用于存儲當前行組中該列的值,字典頁存儲該列值的編碼字典,每一個列塊中最多包含一個字典頁,索引頁用來存儲當前行組下該列的索引,目前Parquet中還不支持索引頁,但是在后面的版本中增加。
2.7 映射下推(Project PushDown)
說到列式存儲的優勢,映射下推是最突出的,它意味著在獲取表中原始數據時只需要掃描查詢中需要的列,由于每一列的所有值都是連續存儲的,所以分區取出每一列的所有值就可以實現TableScan算子,而避免掃描整個表文件內容。
在Parquet中原生就支持映射下推,執行查詢的時候可以通過Configuration傳遞需要讀取的列的信息,這些列必須是Schema的子集,映射每次會掃描一個Row Group的數據,然后一次性得將該Row Group里所有需要的列的Cloumn Chunk都讀取到內存中,每次讀取一個Row Group的數據能夠大大降低隨機讀的次數,除此之外,Parquet在讀取的時候會考慮列是否連續,如果某些需要的列是存儲位置是連續的,那么一次讀操作就可以把多個列的數據讀取到內存。
2.8 謂詞下推(Predicate PushDown)
在數據庫之類的查詢系統中最常用的優化手段就是謂詞下推了,通過將一些過濾條件盡可能的在最底層執行可以減少每一層交互的數據量,從而提升性能,例如”select count(1) from A Join B on A.id = B.id where A.a > 10 and B.b < 100″SQL查詢中,在處理Join操作之前需要首先對A和B執行TableScan操作,然后再進行Join,再執行過濾,最后計算聚合函數返回,但是如果把過濾條件A.a > 10和B.b < 100分別移到A表的TableScan和B表的TableScan的時候執行,可以大大降低Join操作的輸入數據。
無論是行式存儲還是列式存儲,都可以在將過濾條件在讀取一條記錄之后執行以判斷該記錄是否需要返回給調用者,在Parquet做了更進一步的優化,優化的方法時對每一個Row Group的每一個Column Chunk在存儲的時候都計算對應的統計信息,包括該Column Chunk的最大值、最小值和空值個數。通過這些統計值和該列的過濾條件可以判斷該Row Group是否需要掃描。另外Parquet未來還會增加諸如Bloom Filter和Index等優化數據,更加有效的完成謂詞下推。
3. 性能
3.1 壓縮
上圖是展示了使用不同格式存儲TPC-H和TPC-DS數據集中兩個表數據的文件大小對比,可以看出Parquet較之于其他的二進制文件存儲格式能夠更有效的利用存儲空間,而新版本的Parquet(2.0版本)使用了更加高效的頁存儲方式,進一步的提升存儲空間。
3.2 查詢
上圖展示了Twitter在Impala中使用不同格式文件執行TPC-DS基準測試的結果,測試結果可以看出Parquet較之于其他的行式存儲格式有較明顯的性能提升。
上圖展示了criteo公司在Hive中使用ORC和Parquet兩種列式存儲格式執行TPC-DS基準測試的結果,測試結果可以看出在數據存儲方面,兩種存儲格式在都是用snappy壓縮的情況下量中存儲格式占用的空間相差并不大,查詢的結果顯示Parquet格式稍好于ORC格式,兩者在功能上也都有優缺點,Parquet原生支持嵌套式數據結構,而ORC對此支持的較差,這種復雜的Schema查詢也相對較差;而Parquet不支持數據的修改和ACID,但是ORC對此提供支持,但是在OLAP環境下很少會對單條數據修改,更多的則是批量導入。
4. 總結
本文介紹了一種支持嵌套數據模型對的列式存儲格式Parquet,作為大數據系統中OLAP查詢的優化方案,它已經被多種查詢引擎原生支持,并且部分高性能引擎將其作為默認的文件存儲格式。通過數據編碼和壓縮,以及映射下推和謂詞下推功能,Parquet的性能也較之其它文件格式有所提升,可以預見,隨著數據模型的豐富和Ad hoc查詢的需求,Parquet將會被更廣泛的使用。
5. 參考
- Google論文:Dremel: Interactive Analysis of Web-Scale Datasets
- Twitter博文:Dremel made simple with Parquet
- http://www.importnew.com/2617.html
- http://www.2cto.com/database/201605/509506.html
- http://www.infoq.com/cn/articles/in-depth-analysis-of-parquet-column-storage-format/
歡迎關注公眾號: FullStackPlan 獲取更多干貨哦~