http://blog.csdn.net/yu616568/article/details/50993491
Apache Parquet是Hadoop生態圈中一種新型列式存儲格式,它可以兼容hadoop生態圈中大多數計算框架(Hadoop、Spark等),被多種查詢引擎支持(Hive、Impala、Drill等),并且它是語言和平臺無關的。Parquet最初是由Twitter和Cloudera(由于Impala的緣故)合作開發完成并開源,2015年5月從Apache的孵化器里畢業成為Apache頂級項目,最新的版本是1.8.1。
Parquet是什么
Parquet的靈感來自于2010年Google發表的Dremel論文,文中介紹了一種支持嵌套結構的存儲格式,并且使用了列式存儲的方式提升查詢性能,在Dremel論文中還介紹了Google如何使用這種存儲格式實現并行查詢的,如果對此感興趣可以參考論文和開源實現Apache Drill。
嵌套數據模型
在接觸大數據之前,我們簡單的將數據劃分為結構化數據和非結構化數據,通常我們使用關系數據庫存儲結構化數據,而關系數據庫中使用數據模型都是扁平式的,遇到諸如List、Map和自定義Struct的時候就需要用戶在應用層解析。但是在大數據環境下,通常數據的來源是服務端的埋點數據,很可能需要把程序中的某些對象內容作為輸出的一部分,而每一個對象都可能是嵌套的,所以如果能夠原生的支持這種數據,這樣在查詢的時候就不需要額外的解析便能獲得想要的結果。例如在Twitter,在他們的生產環境中一個典型的日志對象(一條記錄)有87個字段,其中嵌套了7層,如下圖:
另外,隨著嵌套格式的數據的需求日益增加,目前Hadoop生態圈中主流的查詢引擎都支持更豐富的數據類型,例如hive、SparkSQL、Impala等都原生的支持諸如struct、map、array這樣的復雜數據類型,這樣也就使得諸如Parquet這種原生支持嵌套數據類型的存儲格式也變得至關重要,性能也會更好。
列式存儲
列式存儲,顧名思義就是按照列進行存儲數據,把某一列的數據連續的存儲,每一行中的不同列的值離散分布。列式存儲技術并不新鮮,在關系數據庫中都已經在使用,尤其是在針對OLAP場景下的數據存儲,由于OLAP場景下的數據大部分情況下都是批量導入,基本上不需要支持單條記錄的增刪改操作,而查詢的時候大多數都是只使用部分列進行過濾、聚合,對少數列進行計算(基本不需要select * from xx之類的查詢)。列式存儲可以大大提升這類查詢的性能,較之于行是存儲,列式存儲能夠帶來這些優化:
由于每一列中的數據類型相同,所以可以針對不同類型的列使用不同的編碼和壓縮方式,這樣可以大大降低數據存儲空間。
讀取數據的時候可以把映射(Project)下推,只需要讀取需要的列,這樣可以大大減少每次查詢的I/O數據量,更甚至可以支持謂詞下推,跳過不滿足條件的列。
由于每一列的數據類型相同,可以使用更加適合CPU pipeline的編碼方式,減小CPU的緩存失效。
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操作,由于映射下推和謂詞下推的使用,可以減少一大部分不必要的數據掃描,尤其是表結構比較龐大的時候更加明顯,由此也能夠帶來更好的查詢性能。
[圖片上傳中。。。(9)]
上圖是展示了使用不同格式存儲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