背景
随着大資料時代的到來,越來越多的資料流向了Hadoop生态圈,同時對于能夠快速的從TB甚至PB級别的資料中擷取有價值的資料對于一個産品和公司來說更加重要,在Hadoop生态圈的快速發展過程中,湧現了一批開源的資料分析引擎,例如Hive、Spark SQL、Impala、Presto等,同時也産生了多個高性能的列式存儲格式,例如RCFile、ORC、Parquet等,本文主要從實作的角度上對比分析ORC和Parquet兩種典型的列存格式,并對它們做了相應的對比測試。
列式存儲
由于OLAP查詢的特點,列式存儲可以提升其查詢性能,但是它是如何做到的呢?這就要從列式存儲的原理說起,從圖1中可以看到,相對于關系資料庫中通常使用的行式存儲,在使用列式存儲時每一列的所有元素都是順序存儲的。由此特點可以給查詢帶來如下的優化:
- 查詢的時候不需要掃描全部的資料,而隻需要讀取每次查詢涉及的列,這樣可以将I/O消耗降低N倍,另外可以儲存每一列的統計資訊(min、max、sum等),實作部分的謂詞下推。
- 由于每一列的成員都是同構的,可以針對不同的資料類型使用更高效的資料壓縮算法,進一步減小I/O。
- 由于每一列的成員的同構性,可以使用更加适合CPU pipeline的編碼方式,減小CPU的緩存失效。
圖1 行式存儲VS列式存儲
嵌套資料格式
通常我們使用關系資料庫存儲結構化資料,而關系資料庫支援的資料模型都是扁平式的,而遇到諸如List、Map和自定義Struct的時候就需要使用者自己解析,但是在大資料環境下,資料的來源多種多樣,例如埋點資料,很可能需要把程式中的某些對象内容作為輸出的一部分,而每一個對象都可能是嵌套的,是以如果能夠原生的支援這種資料,查詢的時候就不需要額外的解析便能獲得想要的結果。例如在Twitter,他們一個典型的日志對象(一條記錄)有87個字段,其中嵌套了7層,如下圖。
圖2 嵌套資料模型
随着嵌套格式的資料的需求日益增加,目前Hadoop生态圈中主流的查詢引擎都支援更豐富的資料類型,例如Hive、SparkSQL、Impala等都原生的支援諸如struct、map、array這樣的複雜資料類型,這樣促使各種存儲格式都需要支援嵌套資料格式。
Parquet存儲格式
Apache Parquet是Hadoop生态圈中一種新型列式存儲格式,它可以相容Hadoop生态圈中大多數計算架構(Mapreduce、Spark等),被多種查詢引擎支援(Hive、Impala、Drill等),并且它是語言和平台無關的。Parquet最初是由Twitter和Cloudera合作開發完成并開源,2015年5月從Apache的孵化器裡畢業成為Apache頂級項目。
Parquet最初的靈感來自Google于2010年發表的Dremel論文,文中介紹了一種支援嵌套結構的存儲格式,并且使用了列式存儲的方式提升查詢性能,在Dremel論文中還介紹了Google如何使用這種存儲格式實作并行查詢的,如果對此感興趣可以參考論文和開源實作Drill。
資料模型
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類型,如圖3。
圖3 Parquet的schema結構
可以看出在Schema中所有的基本類型字段都是葉子節點,在這個Schema中一共存在6個葉子節點,如果把這樣的Schema轉換成扁平式的關系模型,就可以了解為該表包含六個列。Parquet中沒有Map、Array這樣的複雜資料結構,但是可以通過repeated和group組合來實作的。由于一條記錄中某一列可能出現零次或者多次,需要标示出哪些列的值構成一條完整的記錄。這是由Striping/Assembly算法實作的。
由于Parquet支援的資料模型比較松散,可能一條記錄中存在比較深的嵌套關系,如果為每一條記錄都維護一個類似的樹狀結可能會占用較大的存儲空間,是以Dremel論文中提出了一種高效的對于嵌套資料格式的壓縮算法:Striping/Assembly算法。它的原理是每一個記錄中的每一個成員值有三部分組成:Value、Repetition level和Definition level。value記錄了該成員的原始值,可以根據特定類型的壓縮算法進行壓縮,兩個level值用于記錄該值在整個記錄中的位置。對于repeated類型的列,Repetition level值記錄了目前值屬于哪一條記錄以及它處于該記錄的什麼位置;對于repeated和optional類型的列,可能一條記錄中某一列是沒有值的,假設我們不記錄這樣的值就會導緻本該屬于下一條記錄的值被當做目前記錄的一部分,進而造成資料的錯誤,是以對于這種情況需要一個占位符标示這種情況。
通過Striping/Assembly算法,parquet可以使用較少的存儲空間表示複雜的嵌套格式,并且通常Repetition level和Definition level都是較小的整數值,可以通過RLE算法對其進行壓縮,進一步降低存儲空間。
檔案結構
Parquet檔案是以二進制方式存儲的,是不可以直接讀取和修改的,Parquet檔案是自解析的,檔案中包括該檔案的資料和中繼資料。在HDFS檔案系統和Parquet檔案中存在如下幾個概念:
- HDFS塊(Block):它是HDFS上的最小的副本機關,HDFS會把一個Block存儲在本地的一個檔案并且維護分散在不同的機器上的多個副本,通常情況下一個Block的大小為256M、512M等。
- HDFS檔案(File):一個HDFS的檔案,包括資料和中繼資料,資料分散存儲在多個Block中。
- 行組(Row Group):按照行将資料實體上劃分為多個單元,每一個行組包含一定的行數,在一個HDFS檔案中至少存儲一個行組,Parquet讀寫的時候會将整個行組緩存在記憶體中,是以如果每一個行組的大小是由記憶體大的小決定的。
- 列塊(Column Chunk):在一個行組中每一列儲存在一個列塊中,行組中的所有列連續的存儲在這個行組檔案中。不同的列塊可能使用不同的算法進行壓縮。
- 頁(Page):每一個列塊劃分為多個頁,一個頁是最小的編碼的機關,在同一個列塊的不同頁可能使用不同的編碼方式。
通常情況下,在存儲Parquet資料的時候會按照HDFS的Block大小設定行組的大小,由于一般情況下每一個Mapper任務處理資料的最小機關是一個Block,這樣可以把每一個行組由一個Mapper任務處理,增大任務執行并行度。Parquet檔案的格式如下圖所示。
圖4 Parquet檔案結構
上圖展示了一個Parquet檔案的結構,一個檔案中可以存儲多個行組,檔案的首位都是該檔案的Magic Code,用于校驗它是否是一個Parquet檔案,Footer length存儲了檔案中繼資料的大小,通過該值和檔案長度可以計算出中繼資料的偏移量,檔案的中繼資料中包括每一個行組的中繼資料資訊和目前檔案的Schema資訊。除了檔案中每一個行組的中繼資料,每一頁的開始都會存儲該頁的中繼資料,在Parquet中,有三種類型的頁:資料頁、字典頁和索引頁。資料頁用于存儲目前行組中該列的值,字典頁存儲該列值的編碼字典,每一個列塊中最多包含一個字典頁,索引頁用來存儲目前行組下該列的索引,目前Parquet中還不支援索引頁,但是在後面的版本中增加。
資料通路
說到列式存儲的優勢,Project下推是無疑最突出的,它意味着在擷取表中原始資料時隻需要掃描查詢中需要的列,由于每一列的所有值都是連續存儲的,避免掃描整個表檔案内容。
在Parquet中原生就支援Project下推,執行查詢的時候可以通過Configuration傳遞需要讀取的列的資訊,這些列必須是Schema的子集,Parquet每次會掃描一個Row Group的資料,然後一次性得将該Row Group裡所有需要的列的Cloumn Chunk都讀取到記憶體中,每次讀取一個Row Group的資料能夠大大降低随機讀的次數,除此之外,Parquet在讀取的時候會考慮列是否連續,如果某些需要的列是存儲位置是連續的,那麼一次讀操作就可以把多個列的資料讀取到記憶體。
在資料通路的過程中,Parquet還可以利用每一個row group生成的統計資訊進行謂詞下推,這部分資訊包括該Column Chunk的最大值、最小值和空值個數。通過這些統計值和該列的過濾條件可以判斷該Row Group是否需要掃描。另外Parquet未來還會增加諸如Bloom Filter和Index等優化資料,更加有效的完成謂詞下推。
ORC檔案格式
ORC檔案格式是一種Hadoop生态圈中的列式存儲格式,它的産生早在2013年初,最初産生自Apache Hive,用于降低Hadoop資料存儲空間和加速Hive查詢速度。和Parquet類似,它并不是一個單純的列式存儲格式,仍然是首先根據行組分割整個表,在每一個行組内進行按列存儲。ORC檔案是自描述的,它的中繼資料使用Protocol Buffers序列化,并且檔案中的資料盡可能的壓縮以降低存儲空間的消耗,目前也被Spark SQL、Presto等查詢引擎支援,但是Impala對于ORC目前沒有支援,仍然使用Parquet作為主要的列式存儲格式。2015年ORC項目被Apache項目基金會提升為Apache頂級項目。
資料模型
和Parquet不同,ORC原生是不支援嵌套資料格式的,而是通過對複雜資料類型特殊處理的方式實作嵌套格式的支援,例如對于如下的hive表:
CREATE TABLE `orcStructTable`(
`name` string,
`course` struct<course:string,score:int>,
`score` map<string,int>,
`work_locations` array<string>)
ORC格式會将其轉換成如下的樹狀結構:
圖5 ORC的schema結構
在ORC的結構中這個schema包含10個column,其中包含了複雜類型列和原始類型的列,前者包括LIST、STRUCT、MAP和UNION類型,後者包括BOOLEAN、整數、浮點數、字元串類型等,其中STRUCT的孩子節點包括它的成員變量,可能有多個孩子節點,MAP有兩個孩子節點,分别為key和value,LIST包含一個孩子節點,類型為該LIST的成員類型,UNION一般不怎麼用得到。每一個Schema樹的根節點為一個Struct類型,所有的column按照樹的中序周遊順序編号。
ORC隻需要存儲schema樹中葉子節點的值,而中間的非葉子節點隻是做一層代理,它們隻需要負責孩子節點值得讀取,隻有真正的葉子節點才會讀取資料,然後交由父節點封裝成對應的資料結構傳回。
檔案結構
和Parquet類似,ORC檔案也是以二進制方式存儲的,是以是不可以直接讀取,ORC檔案也是自解析的,它包含許多的中繼資料,這些中繼資料都是同構ProtoBuffer進行序列化的。ORC的檔案結構入圖6,其中涉及到如下的概念:
- ORC檔案:儲存在檔案系統上的普通二進制檔案,一個ORC檔案中可以包含多個stripe,每一個stripe包含多條記錄,這些記錄按照列進行獨立存儲,對應到Parquet中的row group的概念。
- 檔案級中繼資料:包括檔案的描述資訊PostScript、檔案meta資訊(包括整個檔案的統計資訊)、所有stripe的資訊和檔案schema資訊。
- stripe:一組行形成一個stripe,每次讀取檔案是以行組為機關的,一般為HDFS的塊大小,儲存了每一列的索引和資料。
- stripe中繼資料:儲存stripe的位置、每一個列的在該stripe的統計資訊以及所有的stream類型和位置。
- row group:索引的最小機關,一個stripe中包含多個row group,預設為10000個值組成。
- stream:一個stream表示檔案中一段有效的資料,包括索引和資料兩類。索引stream儲存每一個row group的位置和統計資訊,資料stream包括多種類型的資料,具體需要哪幾種是由該列類型和編碼方式決定。
圖6 ORC檔案結構
在ORC檔案中儲存了三個層級的統計資訊,分别為檔案級别、stripe級别和row group級别的,他們都可以用來根據Search ARGuments(謂詞下推條件)判斷是否可以跳過某些資料,在統計資訊中都包含成員數和是否有null值,并且對于不同類型的資料設定一些特定的統計資訊。
資料通路
讀取ORC檔案是從尾部開始的,第一次讀取16KB的大小,盡可能的将Postscript和Footer資料都讀入記憶體。檔案的最後一個位元組儲存着PostScript的長度,它的長度不會超過256位元組,PostScript中儲存着整個檔案的中繼資料資訊,它包括檔案的壓縮格式、檔案内部每一個壓縮塊的最大長度(每次配置設定記憶體的大小)、Footer長度,以及一些版本資訊。在Postscript和Footer之間存儲着整個檔案的統計資訊(上圖中未畫出),這部分的統計資訊包括每一個stripe中每一列的資訊,主要統計成員數、最大值、最小值、是否有空值等。
接下來讀取檔案的Footer資訊,它包含了每一個stripe的長度和偏移量,該檔案的schema資訊(将schema樹按照schema中的編号儲存在數組中)、整個檔案的統計資訊以及每一個row group的行數。
處理stripe時首先從Footer中擷取每一個stripe的其實位置和長度、每一個stripe的Footer資料(中繼資料,記錄了index和data的的長度),整個striper被分為index和data兩部分,stripe内部是按照row group進行分塊的(每一個row group中多少條記錄在檔案的Footer中存儲),row group内部按列存儲。每一個row group由多個stream儲存資料和索引資訊。每一個stream的資料會根據該列的類型使用特定的壓縮算法儲存。在ORC中存在如下幾種stream類型:
- PRESENT:每一個成員值在這個stream中保持一位(bit)用于标示該值是否為NULL,通過它可以隻記錄部位NULL的值
- DATA:該列的中屬于目前stripe的成員值。
- LENGTH:每一個成員的長度,這個是針對string類型的列才有的。
- DICTIONARY_DATA:對string類型資料編碼之後字典的内容。
- SECONDARY:存儲Decimal、timestamp類型的小數或者納秒數等。
- ROW_INDEX:儲存stripe中每一個row group的統計資訊和每一個row group起始位置資訊。
在初始化階段擷取全部的中繼資料之後,可以通過includes數組指定需要讀取的列編号,它是一個boolean數組,如果不指定則讀取全部的列,還可以通過傳遞SearchArgument參數指定過濾條件,根據中繼資料首先讀取每一個stripe中的index資訊,然後根據index中統計資訊以及SearchArgument參數确定需要讀取的row group編号,再根據includes資料決定需要從這些row group中讀取的列,通過這兩層的過濾需要讀取的資料隻是整個stripe多個小段的區間,然後ORC會盡可能合并多個離散的區間盡可能的減少I/O次數。然後再根據index中儲存的下一個row group的位置資訊調至該stripe中第一個需要讀取的row group中。
由于ORC中使用了更加精确的索引資訊,使得在讀取資料時可以指定從任意一行開始讀取,更細粒度的統計資訊使得讀取ORC檔案跳過整個row group,ORC預設會對任何一塊資料和索引資訊使用ZLIB壓縮,是以ORC檔案占用的存儲空間也更小,這點在後面的測試對比中也有所印證。
在新版本的ORC中也加入了對Bloom Filter的支援,它可以進一步提升謂詞下推的效率,在Hive 1.2.0版本以後也加入了對此的支援。
性能測試
為了對比測試兩種存儲格式,我選擇使用TPC-DS資料集并且對它進行改造以生成寬表、嵌套和多層嵌套的資料。使用最常用的Hive作為SQL引擎進行測試。
測試環境
- Hadoop叢集:實體測試叢集,四台DataNode/NodeManager機器,每個機器32core+128GB,測試時使用整個叢集的資源。
- Hive:Hive 1.2.1版本,使用hiveserver2啟動,本機MySql作為中繼資料庫,jdbc方式送出查詢SQL
- 資料集:100GB TPC-DS資料集,選取其中的Store_Sales為事實表的模型作為測試資料
- 查詢SQL:選擇TPC-DS中涉及到上述模型的10條SQL并對其進行改造。
測試場景和結果
整個測試設定了四種場景,每一種場景下對比測試資料占用的存儲空間的大小和相同查詢執行消耗的時間對比,除了場景一基于原始的TPC-DS資料集外,其餘的資料都需要進行資料導入,同時對比這幾個場景的資料導入時間。
場景一:一個事實表、多個次元表,複雜的join查詢。
基于原始的TPC-DS資料集。
Store_Sales表記錄數:287,997,024,表大小為:
- 原始Text格式,未壓縮 : 38.1 G
- ORC格式,預設壓縮(ZLIB),一共1800+個分區 : 11.5 G
- Parquet格式,預設壓縮(Snappy),一共1800+個分區 : 14.8 G
查詢測試結果:
場景二:次元表和事實表join之後生成的寬表,隻在一個表上做查詢。
整個測試設定了四種場景,每一種場景下對比測試資料占用的存儲空間的大小和相同查詢執行消耗的時間對比,除了場景一基于原始的TPC-DS資料集外,其餘的資料都需要進行資料導入,同時對比這幾個場景的資料導入時間。選取資料模型中的store_sales, household_demographics, customer_address, date_dim, store表生成一個扁平式寬表(store_sales_wide_table),基于這個表執行查詢,由于場景一種選擇的query大多數不能完全match到這個寬表,是以對場景1中的SQL進行部分改造。
store_sales_wide_table表記錄數:263,704,266,表大小為:
- 原始Text格式,未壓縮 : 149.0 G
- ORC格式,預設壓縮 : 10.6 G
- PARQUET格式,預設壓縮 : 12.5 G
查詢測試結果:
場景三:複雜的資料結構組成的寬表,struct、list、map等(1層)
整個測試設定了四種場景,每一種場景下對比測試資料占用的存儲空間的大小和相同查詢執行消耗的時間對比,除了場景一基于原始的TPC-DS資料集外,其餘的資料都需要進行資料導入,同時對比這幾個場景的資料導入時間。在場景二的基礎上,将次元表(除了store_sales表)轉換成一個struct或者map對象,源store_sales表中的字段保持不變。生成有一層嵌套的新表(store_sales_wide_table_one_nested),使用的查詢邏輯相同。
store_sales_wide_table_one_nested表記錄數:263,704,266,表大小為:
- 原始Text格式,未壓縮 : 245.3 G
- ORC格式,預設壓縮 : 10.9 G 比store_sales表還小?
- PARQUET格式,預設壓縮 : 29.8 G
查詢測試結果:
場景四:複雜的資料結構,多層嵌套。(3層)
整個測試設定了四種場景,每一種場景下對比測試資料占用的存儲空間的大小和相同查詢執行消耗的時間對比,除了場景一基于原始的TPC-DS資料集外,其餘的資料都需要進行資料導入,同時對比這幾個場景的資料導入時間。在場景三的基礎上,将部分次元表的struct内的字段再轉換成struct或者map對象,隻存在struct中嵌套map的情況,最深的嵌套為三層。生成一個多層嵌套的新表(store_sales_wide_table_more_nested),使用的查詢邏輯相同。
該場景中隻涉及一個多層嵌套的寬表,沒有任何分區字段,store_sales_wide_table_more_nested表記錄數:263,704,266,表大小為:
- 原始Text格式,未壓縮 : 222.7 G
- ORC格式,預設壓縮 : 10.9 G 比store_sales表還小?
- PARQUET格式,預設壓縮 : 23.1 G 比一層嵌套表store_sales_wide_table_one_nested要小?
查詢測試結果:
結果分析
從上述測試結果來看,星狀模型對于資料分析場景并不是很合适,多個表的join會大大拖慢查詢速度,并且不能很好的利用列式存儲帶來的性能提升,在使用寬表的情況下,列式存儲的性能提升明顯,ORC檔案格式在存儲空間上要遠優于Text格式,較之于PARQUET格式有一倍的存儲空間提升,在導資料(insert into table select 這樣的方式)方面ORC格式也要優于PARQUET,在最終的查詢性能上可以看到,無論是無嵌套的扁平式寬表,或是一層嵌套表,還是多層嵌套的寬表,兩者的查詢性能相差不多,較之于Text格式有2到3倍左右的提升。
另外,通過對比場景二和場景三的測試結果,可以發現扁平式的表結構要比嵌套式結構的查詢性能有所提升,是以如果選擇使用大寬表,則設計寬表的時候盡可能的将表設計的扁平化,減少嵌套資料。
通過這三種檔案存儲格式的測試對比,ORC檔案存儲格式無論是在空間存儲、導資料速度還是查詢速度上表現的都較好一些,并且ORC可以一定程度上支援ACID操作,社群的發展目前也是Hive中比較提倡使用的一種列式存儲格式,另外,本次測試主要針對的是Hive引擎,是以不排除存在Hive與ORC的敏感度比PARQUET要高的可能性。
總結
本文主要從資料模型、檔案格式和資料通路流程等幾個方面詳細介紹了Hadoop生态圈中的兩種列式存儲格式——Parquet和ORC,并通過大資料量的測試對兩者的存儲和查詢性能進行了對比。對于大資料場景下的資料分析需求,使用這兩種存儲格式總會帶來存儲和性能上的提升,但是在實際使用時還需要針對實際的資料進行選擇。另外由于不同開源産品可能對不同的存儲格式有特定的優化,是以選擇時還需要考慮查詢引擎的因素。