一個Parquet文件是
由一個header以及一個或多個block塊組成,以一個footer結尾。
header中只包含一個4個字節的數字PAR1用來識別整個Parquet文件格式。
文件中所有的metadata都存在於footer中。
footer中的metadata包含了格式的版本信息,schema信息、key-value paris以及所有block中的metadata信息。
footer中最后兩個字段為一個以4個字節長度的footer的metadata,以及同header中包含的一樣的PAR1。
讀取一個Parquet文件時,需要完全讀取Footer的meatadata,
Parquet格式文件不需要讀取sync markers這樣的標記分割查找,因為所有block的邊界都存儲於footer的metadata中(因為metadata的寫入是在所有blocks塊寫入完成之后的,所以吸入操作包含的所有block的位置信息都是存在於內存直到文件close)
這里注意,不像sequence files以及Avro數據格式文件的header以及sync markers是用來分割blocks。Parquet格式文件不需要sync markers,因此block的邊界存儲與footer的meatada中。
在Parquet文件中,每一個block都具有一組Row group,她們是由一組Column chunk組成的列數據。
繼續往下,每一個column chunk中又包含了它具有的pages。
每個page就包含了來自於相同列的值.
Parquet同時使用更緊湊形式的編碼,當寫入Parquet文件時,它會自動基於column的類型適配一個合適的編碼,比如,一個boolean形式的值將會被用於run-length encoding。
另一方面,Parquet文件對於每個page支持標准的壓縮算法比如支持Snappy,gzip以及LZO壓縮格式,也支持不壓縮。
Parquet格式的數據類型:
參考: 《Hadoop:The Definitive Guide, 4th Edition》
一、Parquet的組成
Parquet僅僅是一種存儲格式,它是語言、平台無關的,並且不需要和任何一種數據處理框架綁定,
目前能夠和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-format項目由java實現,它定義了所有Parquet元數據對象,Parquet的元數據是使用Apache Thrift進行序列化並存儲在Parquet文件的尾部。
- parquet-format項目由java實現,它包括多個模塊,包括實現了讀寫Parquet文件的功能,並且提供一些和其它組件適配的工具,例如Hadoop Input/Output Formats、Hive Serde(目前Hive已經自帶Parquet了)、Pig loaders等。
- parquet-compatibility項目,包含不同編程語言之間(JAVA和C/C++)讀寫文件的測試代碼。
- parquet-cpp項目,它是用於用於讀寫Parquet文件的C++庫。
下圖展示了Parquet各個組件的層次以及從上到下交互的方式。
- 數據存儲層定義了Parquet的文件格式,其中元數據在parquet-format中定義,包括Parquet原始類型定義、Page類型、編碼類型、壓縮類型等等。
- 對象轉換層完成其他對象模型與Parquet內部數據模型的映射和轉換,Parquet的編碼方式使用的是striping and assembly算法。
- 對象模型層定義了如何讀取Parquet文件的內容,這一層轉換包括Avro、Thrift、PB等序列化格式、Hive serde等的適配。並且為了幫助大家理解和使用,Parquet提供了org.apache.parquet.example包實現了java對象和Parquet文件的轉換。
數據模型
Parquet支持嵌套的數據模型,類似於Protocol Buffers,
每一個數據模型的schema包含多個字段,每一個字段又可以包含多個字段,
每一個字段有三個屬性:重復數、數據類型和字段名,
重復數可以是以下三種:required(出現1次),repeated(出現0次或多次),optional(出現0次或1次)。
每一個字段的數據類型可以分成兩種:group(復雜類型)和primitive(基本類型)。
例如Dremel中提供的Document的schema示例,它的定義如下:
message Document { required int64 DocId; optional group Links { repeated int64 Backward; repeated int64 Forward; } repeated group Name { repeated group Language { required string Code; optional string Country; } optional string Url; } }
可以把這個Schema轉換成樹狀結構,根節點可以理解為repeated類型,如下圖:
可以看出在Schema中所有的基本類型字段都是葉子節點,在這個Schema中一共存在6個葉子節點,如果把這樣的Schema轉換成扁平式的關系模型,就可以理解為該表包含六個列。
Parquet中沒有Map、Array這樣的復雜數據結構,但是可以通過repeated和group組合來實現這樣的需求。
在這個包含6個字段的表中有以下幾個字段和每一條記錄中它們可能出現的次數:
DocId int64 只能出現一次 Links.Backward int64 可能出現任意多次,但是如果出現0次則需要使用NULL標識 Links.Forward int64 同上 Name.Language.Code string 同上 Name.Language.Country string 同上 Name.Url string 同上
由於在一個表中可能存在出現任意多次的列,對於這些列需要標示出現多次或者等於NULL的情況,它是由Striping/Assembly算法實現的。
Striping/Assembly算法
上文介紹了Parquet的數據模型,在Document中存在多個非required列,由於Parquet一條記錄的數據分散的存儲在不同的列中,如何組合不同的列值組成一條記錄是由Striping/Assembly算法決定的,
在該算法中列的每一個值都包含三部分:value、repetition level和definition level。
Repetition Levels
為了支持repeated類型的節點,在寫入的時候該值等於它和前面的值在哪一層節點是不共享的。在讀取的時候根據該值可以推導出哪一層上需要創建一個新的節點,例如對於這樣的一個schema和兩條記錄。
message nested { repeated group leve1 { repeated string leve2; } } r1:[[a,b,c,] , [d,e,f,g]] r2:[[h] , [i,j]]
計算repetition level值的過程如下:
- value=a是一條記錄的開始,和前面的值(已經沒有值了)在根節點(第0層)上是不共享的,所以repeated level=0.
- value=b它和前面的值共享了level1這個節點,但是level2這個節點上是不共享的,所以repeated level=2.
- 同理value=c, repeated level=2.
- value=d和前面的值共享了根節點(屬於相同記錄),但是在level1這個節點上是不共享的,所以repeated level=1.
- value=h和前面的值不屬於同一條記錄,也就是不共享任何節點,所以repeated level=0.
根據以上的分析每一個value需要記錄的repeated level值如下:
在讀取的時候,順序的讀取每一個值,然后根據它的repeated level創建對象,
當讀取value=a時repeated level=0,表示需要創建一個新的根節點(新記錄),
value=b時repeated level=2,表示需要創建一個新的level2節點,
value=d時repeated level=1,表示需要創建一個新的level1節點,
當所有列讀取完成之后可以創建一條新的記錄。
本例中當讀取文件構建每條記錄的結果如下:
可以看出
repeated level=0表示一條記錄的開始,
並且repeated level的值只是針對路徑上的repeated類型的節點,因此在計算該值的時候可以忽略非repeated類型的節點,
在寫入的時候
將其理解為該節點和路徑上的哪一個repeated節點是不共享的,
讀取的時候將
其理解為需要在哪一層創建一個新的repeated節點,
這樣的話每一列最大的repeated level值就等於路徑上的repeated節點的個數(不包括根節點)。
減小repeated level的好處能夠使得在存儲使用更加緊湊的編碼方式,節省存儲空間。
Definition Levels
有了repeated level我們就可以構造出一個記錄了,為什么還需要definition levels呢?
由於repeated和optional類型的存在,可能一條記錄中某一列是沒有值的,
假設我們不記錄這樣的值就會導致本該屬於下一條記錄的值被當做當前記錄的一部分,從而造成數據的錯誤,
因此對於這種情況需要一個占位符標示這種情況。
definition level的值
僅僅對於空值是有效的,
表示在該值的路徑上第幾層開始是未定義的,對於非空的值它是沒有意義的,因為非空值在葉子節點是定義的,所有的父節點也肯定是定義的,因此它總是等於該列最大的definition levels。
例如下面的schema。
message ExampleDefinitionLevel { optional group a { optional group b { optional string c; } } }
它包含一個列a.b.c,這個列的的每一個節點都是optional類型的,當c被定義時a和b肯定都是已定義的,
當c未定義時我們就需要標示出在從哪一層開始時未定義的,如下面的值:
由於definition level只需要考慮未定義的值,而對於repeated類型的節點,只要父節點是已定義的,該節點就必須定義
(例如Document中的DocId,每一條記錄都該列都必須有值,同樣對於Language節點,只要它定義了Code必須有值),
所以計算definition level的值時可以忽略路徑上的required節點,這樣可以減小definition level的最大值,優化存儲。
一個完整的例子
本節我們使用Dremel論文中給的Document示例和給定的兩個值r1和r2展示計算repeated level和definition level的過程,
這里把未定義的值記錄為NULL,使用R表示repeated level,D表示definition level。
首先看DocuId這一列,對於r1,DocId=10,由於它是記錄的開始並且是已定義的,所以R=0,D=0,同樣r2中的DocId=20,R=0,D=0。
對於Links.Forward這一列,在r1中,它是未定義的但是Links是已定義的,並且是該記錄中的第一個值,所以R=0,D=1,在r1中該列有兩個值,value1=10,R=0(記錄中該列的第一個值),D=2(該列的最大definition level)。
對於Name.Url這一列,r1中它有三個值,分別為url1=’http://A‘,它是r1中該列的第一個值並且是定義的,所以R=0,D=2;value2=’http://B‘,和上一個值value1在Name這一層是不相同的,所以R=1,D=2;value3=NULL,和上一個值value2在Name這一層是不相同的,所以R=1,但它是未定義的,而Name這一層是定義的,所以D=1。r2中該列只有一個值value3=’http://C‘,R=0,D=2.
最后看一下Name.Language.Code這一列,r1中有4個值,value1=’en-us’,它是r1中的第一個值並且是已定義的,所以R=0,D=2(由於Code是required類型,這一列repeated level的最大值等於2);value2=’en’,它和value1在Language這個節點是不共享的,所以R=2,D=2;value3=NULL,它是未定義的,但是它和前一個值在Name這個節點是不共享的,在Name這個節點是已定義的,所以R=1,D=1;value4=’en-gb’,它和前一個值在Name這一層不共享,所以R=1,D=2。在r2中該列有一個值,它是未定義的,但是Name這一層是已定義的,所以R=0,D=1.
Parquet文件格式
Parquet文件是
以二進制方式存儲的,所以是不可以直接讀取的,
文件中包括該文件的數據和元數據,因此Parquet格式文件是自解析的。
在HDFS文件系統和Parquet文件中存在如下幾個概念。
- HDFS塊(Block):它是HDFS上的最小的副本單位,HDFS會把一個Block存儲在本地的一個文件並且維護分散在不同的機器上的多個副本,通常情況下一個Block的大小為256M、512M等。
- HDFS文件(File):一個HDFS的文件,包括數據和元數據,數據分散存儲在多個Block中。
- 行組(Row Group):按照行將數據物理上划分為多個單元,每一個行組包含一定的行數,在一個HDFS文件中至少存儲一個行組,Parquet讀寫的時候會將整個行組緩存在內存中,所以如果每一個行組的大小是由內存大的小決定的,例如記錄占用空間比較小的Schema可以在每一個行組中存儲更多的行。
- 列塊(Column Chunk):在一個行組中每一列保存在一個列塊中,行組中的所有列連續的存儲在這個行組文件中。一個列塊中的值都是相同類型的,不同的列塊可能使用不同的算法進行壓縮。
- 頁(Page):每一個列塊划分為多個頁,一個頁是最小的編碼的單位,在同一個列塊的不同頁可能使用不同的編碼方式。
文件格式
通常情況下,在存儲Parquet數據的時候會按照Block大小設置行組的大小,由於一般情況下每一個Mapper任務處理數據的最小單位是一個Block,這樣可以把每一個行組由一個Mapper任務處理,增大任務執行並行度。
Parquet文件的格式如下圖所示
上圖展示了一個Parquet文件的內容,
一個文件中
可以存儲多個行組,
文件的首位都是該文件的Magic Code,用於校驗它是否是一個Parquet文件,
Footer length了文件元數據的大小,通過該值和文件長度可以計算出元數據的偏移量,
文件的元數據中包括每一個行組的元數據信息和該文件存儲數據的Schema信息。
除了文件中每一個行組的元數據,每一頁的開始都會存儲該頁的元數據,
在Parquet中,有三種類型的頁:數據頁、字典頁和索引頁。
數據頁用於存儲當前行組中該列的值,
字典頁存儲該列值的編碼字典,每一個列塊中最多包含一個字典頁,
索引頁用來存儲當前行組下該列的索引,
目前Parquet中還不支持索引頁,但是在后面的版本中增加。
在執行MR任務的時候可能存在多個Mapper任務的輸入是同一個Parquet文件的情況,
每一個Mapper通過InputSplit標示處理的文件范圍,如果多個InputSplit跨越了一個Row Group,Parquet能夠保證一個Row Group只會被一個Mapper任務處理。
映射下推(Project PushDown)
說到列式存儲的優勢,映射下推是最突出的,它意味着在獲取表中原始數據時只需要掃描查詢中需要的列,由於每一列的所有值都是連續存儲的,所以分區取出每一列的所有值就可以實現TableScan算子,而避免掃描整個表文件內容。
在Parquet中原生就支持映射下推,執行查詢的時候可以通過Configuration傳遞需要讀取的列的信息,這些列必須是Schema的子集,映射每次會掃描一個Row Group的數據,然后一次性得將該Row Group里所有需要的列的Cloumn Chunk都讀取到內存中,每次讀取一個Row Group的數據能夠大大降低隨機讀的次數,除此之外,Parquet在讀取的時候會考慮列是否連續,如果某些需要的列是存儲位置是連續的,那么一次讀操作就可以把多個列的數據讀取到內存。
謂詞下推(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等優化數據,更加有效的完成謂詞下推。
在使用Parquet的時候可以通過如下兩種策略提升查詢性能:
1、類似於關系數據庫的主鍵,對需要頻繁過濾的列設置為有序的,這樣在導入數據的時候會根據該列的順序存儲數據,這樣可以最大化的利用最大值、最小值實現謂詞下推。
2、減小行組大小和頁大小,這樣增加跳過整個行組的可能性,但是此時需要權衡由於壓縮和編碼效率下降帶來的I/O負載。
性能
相比傳統的行式存儲,Hadoop生態圈近年來也涌現出諸如RC、ORC、Parquet的列式存儲格式,它們的性能優勢主要體現在兩個方面:
1、更高的壓縮比,由於相同類型的數據更容易針對不同類型的列使用高效的編碼和壓縮方式。
2、更小的I/O操作,由於映射下推和謂詞下推的使用,可以減少一大部分不必要的數據掃描,尤其是表結構比較龐大的時候更加明顯,由此也能夠帶來更好的查詢性能
上圖是展示了使用不同格式存儲TPC-H和TPC-DS數據集中兩個表數據的文件大小對比,可以看出Parquet較之於其他的二進制文件存儲格式能夠更有效的利用存儲空間,而新版本的Parquet(2.0版本)使用了更加高效的頁存儲方式,進一步的提升存儲空間
上圖展示了Twitter在Impala中使用不同格式文件執行TPC-DS基准測試的結果,測試結果可以看出Parquet較之於其他的行式存儲格式有較明顯的性能提升。
上圖展示了criteo公司在Hive中使用ORC和Parquet兩種列式存儲格式執行TPC-DS基准測試的結果,測試結果可以看出在數據存儲方面,兩種存儲格式在都是用snappy壓縮的情況下量中存儲格式占用的空間相差並不大,查詢的結果顯示Parquet格式稍好於ORC格式,兩者在功能上也都有優缺點,Parquet原生支持嵌套式數據結構,而ORC對此支持的較差,這種復雜的Schema查詢也相對較差;而Parquet不支持數據的修改和ACID,但是ORC對此提供支持,但是在OLAP環境下很少會對單條數據修改,更多的則是批量導入。
項目發展
自從2012年由Twitter和Cloudera共同研發Parquet開始,該項目一直處於高速發展之中,並且在項目之初就將其貢獻給開源社區,2013年,Criteo公司加入開發並且向Hive社區提交了向hive集成Parquet的patch(HIVE-5783),在Hive 0.13版本之后正式加入了Parquet的支持;之后越來越多的查詢引擎對此進行支持,也進一步帶動了Parquet的發展。
目前Parquet正處於向2.0版本邁進的階段,在新的版本中實現了新的Page存儲格式,針對不同的類型優化編碼算法,另外豐富了支持的原始類型,增加了Decimal、Timestamp等類型的支持,增加更加豐富的統計信息,例如Bloon Filter,能夠盡可能得將謂詞下推在元數據層完成。
總結
本文介紹了一種支持嵌套數據模型對的列式存儲系統Parquet,作為大數據系統中OLAP查詢的優化方案,它已經被多種查詢引擎原生支持,並且部分高性能引擎將其作為默認的文件存儲格式。通過數據編碼和壓縮,以及映射下推和謂詞下推功能,Parquet的性能也較之其它文件格式有所提升,可以預見,隨着數據模型的豐富和Ad hoc查詢的需求,Parquet將會被更廣泛的使用。
參考
- Dremel: Interactive Analysis of Web-Scale Datasets
- Dremel made simple with Parquet
- Parquet: Columnar storage for the people
- Efficient Data Storage for Analytics with Apache Parquet 2.0
- 深入分析Parquet列式存儲格式
- Apache Parquet Document
- http://blog.csdn.net/yu616568/article/details/50993491
- http://blog.csdn.net/yu616568/article/details/51188479