Spark - Parquet
概述
Apache Parquet屬於Hadoop生態圈的一種新型列式存儲格式,既然屬於Hadoop生態圈,因此也兼容大多圈內計算框架(Hadoop、Spark),另外Parquet是平臺、語言無關的,這使得它的適用性很廣,只要相關語言有對應支持的類庫就可以用;
Parquet的優劣對比:
- 支持嵌套結構,這點對比同樣是列式存儲的OCR具備一定優勢;
- 適用於OLAP場景,對比CSV等行式存儲結構,列示存儲支持映射下推和謂詞下推,減少磁盤IO;
- 同樣的壓縮方式下,列式存儲因爲每一列都是同構的,因此可以使用更高效的壓縮方法;
下面主要介紹Parquet如何實現自身的相關優勢,絕不僅僅是使用了列式存儲就完了,而是在數據模型、存儲格式、架構設計等方面都有突破;
列式存儲 vs 行式存儲
區別在於數據在內存中是以行爲順序存儲還是列爲順序,首先沒有哪種方式更優,主要考慮實際業務場景下的數據量、常用操作等;
數據壓縮
例如兩個學生對象分別在行式和列式下的存儲情況,假設學生對象具備姓名-string、年齡-int、平均分-double等信息:
行式存儲:
姓名 | 年齡 | 平均分 | 姓名 | 年齡 | 平均分 |
---|---|---|---|---|---|
張三 | 15 | 82.5 | 李四 | 16 | 77.0 |
列式存儲:
姓名 | 姓名 | 年齡 | 年齡 | 平均分 | 平均分 |
---|---|---|---|---|---|
張三 | 李四 | 15 | 16 | 82.5 | 77.0 |
乍一看似乎沒有什麼區別,事實上如何不進行壓縮的化,兩種存儲方式實際存儲的數據量都是一致的,那麼確實沒有區別,但是實際上現在常用的數據存儲方式都有進行不同程度的壓縮,下面我們考慮靈活進行壓縮的情況下二者的差異:
行式存儲是按照行來劃分最小單元,也就是說壓縮對象是某一行的數據,此處就是針對(張三、15、82.5)這個數據組進行壓縮,問題是該組中數據格式並不一致且佔用內存空間大小不同,也就沒法進行特定的壓縮手段;
列式存儲則不同,它的存儲單元是某一列數據,比如(張三、李四)或者(15,16),那麼就可以針對某一列進行特定的壓縮,比如對於姓名列,假設我們值到最長的姓名長度那麼就可以針對性進行壓縮,同樣對於年齡列,一般最大不超過120,那麼就可以使用tiny int來進行壓縮等等,此處利用的就是列式存儲的同構性;
注意:此處的壓縮指的不是類似gzip這種通用的壓縮手段,事實上任何一種格式都可以進行gzip壓縮,這裏討論的壓縮是在此之外能夠進一步針對存儲數據應用更加高效的壓縮算法以減少IO操作;
謂詞下推
與上述數據壓縮類似,謂詞下推也是列式存儲特有的優勢之一,繼續使用上面的例子:
行式存儲:
姓名 | 年齡 | 平均分 | 姓名 | 年齡 | 平均分 |
---|---|---|---|---|---|
張三 | 15 | 82.5 | 李四 | 16 | 77.0 |
列式存儲:
姓名 | 姓名 | 年齡 | 年齡 | 平均分 | 平均分 |
---|---|---|---|---|---|
張三 | 李四 | 15 | 16 | 82.5 | 77.0 |
假設上述數據中每個數據值佔用空間大小都是1,因此二者在未壓縮下佔用都是6;
我們有在大規模數據進行如下的查詢語句:
SELECT 姓名,年齡 FROM info WHERE 年齡>=16;
這是一個很常見的根據某個過濾條件查詢某個表中的某些列,下面我們考慮該查詢分別在行式和列式存儲下的執行過程:
- 行式存儲:
- 查詢結果和過濾中使用到了姓名、年齡,針對全部數據;
- 由於行式是按行存儲,而此處是針對全部數據行的查詢,因此需要遍歷所有數據並對比其年齡數據,確定是否返回姓名、年齡;
- 列式存儲:
- 過濾中使用了年齡,因此把年齡列取出來進行判斷,判斷結果是李四滿足要求;
- 按照上述判斷結果把姓名列取出來,取出其中對應位置的姓名數據,與上述年齡數據一起返回;
- 可以看到此時由於未涉及平均分,因此平均分列沒有被操作過;
事實上謂詞下推的使用主要依賴於在大規模數據處理分析的場景中,針對數據中某些列做過濾、計算、查詢的情況確實更多,這一點有相關經驗的同學應該感觸很多,因此這裏只能說列式存儲更加適用於該場景;
統計信息
這部分直接用例子來理解,還是上面的例子都是有一點點改動,爲了支持一些頻繁的統計信息查詢,針對年齡列增加了最大和最小兩個統計信息,這樣如果用戶查詢年齡列的最大最小值就不需要計算,直接返回即可,存儲格式如下:
行式存儲:
姓名 | 年齡 | 平均分 | 姓名 | 年齡 | 平均分 | 年齡最大 | 年齡最小 |
---|---|---|---|---|---|---|---|
張三 | 15 | 82.5 | 李四 | 16 | 77.0 | 16 | 15 |
列式存儲:
姓名 | 姓名 | 年齡 | 年齡 | 年齡最大 | 年齡最小 | 平均分 | 平均分 |
---|---|---|---|---|---|---|---|
張三 | 李四 | 15 | 16 | 16 | 15 | 82.5 | 77.0 |
在統計信息存放位置上,由於統計信息通常是針對某一列的,因此列式存儲直接放到對應列的最後方或者最前方即可,行式存儲需要單獨存放;
針對統計信息的耗時主要體現在數據插入刪除時的維護更新上:
- 行式存儲:插入刪除每條數據都需要將年齡與最大最小值進行比較並判斷是否需要更新,如果是插入數據,那麼更新只需要分別於最大最小進行對比即可,如果是刪除數據,那麼如果刪除的恰恰是最大最小值,就還需要從現有數據中遍歷查找最大最小值來,這就需要遍歷所有數據;
- 列式存儲:插入有統計信息的對應列時才需要進行比較,此處如果是插入姓名列,那就沒有比較的必要,只有年齡列會進行此操作,同樣對於年齡列進行刪除操作後的更新時,只需要針對該列進行遍歷即可,這在數據維度很大的情況下可以縮小N(N爲數據列數)倍的查詢範圍;
數據架構
這部分主要分析Parquet使用的數據模型,以及其如何對嵌套類型的支持(需要分析repetition level和definition level);
數據模型這部分主要分析的是列式存儲如何處理不同行不同列之間存儲上的歧義問題,假設上述例子中增加一個興趣列,該列對應行可以沒有數據,也可以有多個數據(也就是說對於張三和李四,可以沒有任何興趣,也可以有多個,這種情況對於行式存儲不是問題,但是對於列式存儲存在一個數據對應關係的歧義問題),假設興趣列存儲如下:
興趣 | 興趣 |
---|---|
羽毛球 | 籃球 |
事實上我們並不確定羽毛球和籃球到底都是張三的、都是李四的、還是二人一人一個,這是由興趣列的特殊性決定的,這在Parquet數據模型中稱這一列爲repeated的;
數據模型
上述例子的數據格式用parquet來描述如下:
message Student{
required string name;
optinal int age;
required double score;
repeated group hobbies{
required string hobby_name;
repeated string home_page;
}
}
這裏將興趣列複雜了一些以展示parquet對嵌套的支持:
- Student作爲整個schema的頂點,也是結構樹的根節點,由message關鍵字標識;
- name作爲必須有一個值的列,用required標識,類型爲string;
- age作爲可選項,可以有一個值也可以沒有,用optinal標識,類型爲string;
- score作爲必須有一個值的列,用required標識,類型爲double;
- hobbies作爲可以沒有也可以有多個的列,用repeated標識,類型爲group,也就是嵌套類型;
- hobby_name屬於hobbies中元素的屬性,必須有一個,類型爲string;
- home_page屬於hobbies中元素的屬性,可以有一個也可以沒有,類型爲string;
可以看到Parquet的schema結構中沒有對於List、Map等類型的支持,事實上List通過repeated支持,而Map則是通過group類型支持,舉例說明:
通過repeated支持List:
[15,16,18,14]
==>
repeated int ages;
通過repeated+group支持List[Map]:
{'name':'李四','age':15}
==>
repeated group Peoples{
required string name;
optinal int age;
}
存儲格式
從schema樹結構到列存儲;
還是上述例子,看下schema的樹形結構:
矩形表示是一個葉子節點,葉子節點都是基本類型,Group不是葉子,葉子節點中顏色最淺的是optinal,中間的是required,最深的是repeated;
首先上述結構對應的列式存儲總共有5列(等於葉子節點的數量):
Column | Type |
---|---|
Name | string |
Age | int |
Score | double |
hobbies.hobby_name | string |
hobbies.page_home | string |
Definition level & Repeatition level
解決上述歧義問題是通過定義等級和重複等級來完成的,下面依次介紹這兩個比較難以直觀理解的概念;
Definition level 定義等級
Definition level指的是截至當前位置爲止,從根節點一路到此的路徑上有多少可選的節點被定義了,因爲是可選的,因此required類型不統計在內;
如果一個節點被定義了,那麼說明到達它的路徑上的所有節點都是被定義的,如果一個節點的定義等級等於這個節點處的最大定義等級,那麼說明它是有數據的,否則它的定義等級應該更小纔對;
一個簡單例子講解定義等級:
message ExampleDefinitionLevel{
optinal group a{
required group b{
optinal string c;
}
}
}
Value | Definition level | 說明 |
---|---|---|
a:null | 0 | a往上只有根節點,因此它最大定義等級爲1,但是它爲null,所以它的定義等級爲0; |
a:{b:null} | 不可能 | b是required的,因此它不可能爲null; |
a:{b:{c:null}} | 1 | c處最大定義等級爲2,因爲b是required的不參與統計,但是c爲null,所以它的定義等級爲1; |
a:{b:{c:"foo"}} | 2 | c有數據,因此它的定義等級就等於它的最大定義等級,即2; |
到此,定義等級的計算公式如下:當前樹深度 - 路徑上類型爲required的個數 - 1(如果自身爲null);
Repetition level 重複等級
針對repeated類型field,如果一個field重複了,那麼它的重複等級等於根節點到達它的路徑上的repeated節點的個數;
注意:這個重複指的是同一個父節點下的同一類field出現多個,如果是不同父節點,那也是不算重複的;
同樣以簡單例子進行分析:
message ExampleRepetitionLevel{
repeated group a{
required group b{
repeated group c{
required string d;
repeated string e;
}
}
}
}
Value | Repetition level | 說明 |
---|---|---|
a:null | 0 | 根本沒有重複這回事。。。。 |
a:a1 | 0 | 對於a1,雖然不是null,但是field目前只有一個a1,也沒有重複; |
a:a1 a:a2 |
1 | 對於a2,前面有個a1此時節點a重複出現了,它的重複等級爲1,因爲它上面也沒有其他repeated節點了; |
a1:{b:null} | 0 | 對於b,a1看不到a2,因此沒有重複; |
a1:{b:null} a2:{b:null} |
1 | 對於a2的b,a2在a1後面,所以算出現重複,b自身不重複且爲null; |
a1:{b:{c:c1}} a2:{b:{c:c2}} |
1 | 對於c2,雖然看着好像之前有個c1,但是由於他們分屬不同的父節點,因此c沒有重複,但是對於a2與a1依然是重複的,所以重複等級爲1; |
a1:{b:{c:c1}} a1:{b:{c:c2}} |
2 | 對於c2,他們都是從a1到b,父節點都是b,那麼此時field c重複了,c路徑上還有一個a爲repeated,因此重複等級爲2; |
這裏可能還是比較難以理解,下面通過之前的張三李四的例子,來更加真切的感受下在這個例子上的定義等級和重複等級;
張三李四的定義、重複等級
Schema以及數據內容如下:
message Student{
required string name;
optinal int age;
required double score;
repeated group hobbies{
required string hobby_name;
repeated string home_page;
}
}
Student 1:
Name 張三
Age 15
Score 70
hobbies
hobby_name 籃球
page_home nba.com
hobbies
hobby_name 足球
Student 2:
Name 李四
Score 75
name列最好理解,首先它是required的,所以既不符合定義等級,也不符合重複等級的要求,又是第一層的節點,因此全部都是0;
name | 定義等級 | 重複等級 |
---|---|---|
張三 | 0 | 0 |
李四 | 0 | 0 |
score列所處層級、類型與name列一致,也全部都是0,這裏就不列出來了;
age列同樣處於第一層,但是它是optinal的,因此滿足定義等級的要求,只有張三有age,定義等級爲1,路徑上只有它自己滿足,重複等級爲0;
age | 定義等級 | 重複等級 |
---|---|---|
15 | 1 | 0 |
hobby_name列處於hobbies group中,類型是required,籃球、足球定義等級都是1(自身爲required不納入統計),父節點hobbies爲repeated,納入統計,籃球重複等級爲0,此時張三的數據中還沒有出現過hobby_name或者hobbies,而足球的父節點hobbies重複了,而hobbies路徑上重複節點數爲1,因此它的重複等級爲1;
hobbies.hobby_name | 定義等級 | 重複等級 |
---|---|---|
籃球 | 1 | 0 |
足球 | 1 | 1 |
home_page列只在張三的第一個hobbies中有,首先重複等級爲0,這點與籃球是一個原因,而定義等級爲2,因爲它是repeated,路徑上它的父節點也是repeated的;
hobbies.home_page | 定義等級 | 重複等級 |
---|---|---|
nba.com | 2 | 0 |
到此對兩個雖然簡單,但是也包含了Parquet的三種類型、嵌套group等結構的例子進行了列式存儲分析,對此有個基本概念就行,其實就是兩個等級的定義問題;
文件格式
Parquet的文件格式主要由header、footer、Row group、Column、Page組成,這種形式也是爲了支持在hadoop等分佈式大數據框架下的數據存儲,因此這部分看起來總讓人想起hadoop的分區。。。。。。
結合下面的官方格式展示圖:
可以看到圖中分爲左右兩部分:
- 左邊:
- 最外層表示一個Parquet文件;
- 首先是Magic Number,用於校驗Parquet文件,並且也可以用於表示文件開始和結束位;
- 一個File對應多個Row group;
- 一個Row group對應多個Column;
- 一個Column對應多個Page;
- Page是最小邏輯存儲單元,其中包含頭信息、重複等級和定義等級以及對應的數據值;
- 右邊:
- Footer中包含重要的元數據;
- 文件元數據包含版本、架構、額外的k/v對等;
- Row group元數據包括其下屬各個Column的元數據;
- Column的元數據包含數據類型、路徑、編碼、偏移量、壓縮/未壓縮大小、額外的k/v對等;
文件格式的設定一方面是針對Hadoop等分佈式結構的適應,另一方面也是對其嵌套支持、高效壓縮等特性的支持,所以覺得從這方面理解會更容易一些,比如:
- 嵌套支持:從上一章節知道列式存儲支持嵌套中Repetition level和Definition level是很重要的,這二者都存放於Row group的元數據中;
- 高效壓縮:注意到每個Column都有一個type元數據,那麼壓縮算法可以通過這個屬性來進行對應壓縮,另外元數據中的額外k/v對可以用於存放對應列的統計信息;
Python導入導出Parquet格式文件
最後給出Python使用Pandas和pyspark兩種方式對Parquet文件的操作Demo吧,實際使用上由於相關庫的封裝,對於調用者來說除了導入導出的API略有不同,其他操作是完全一致的;
Pandas:
import pandas as pd
pd.read_parquet('parquet_file_path', engine='pyarrow')
上述代碼需要注意的是要單獨安裝pyarrow庫,否則會報錯,pandas是基於pyarrow對parquet進行支持的;
PS:這裏沒有安裝pyarrow,也沒有指定engine的話,報錯信息中說可以安裝pyarrow或者fastparquet,但是我這裏試過fastparquet加載我的parquet文件會失敗,我的parquet是spark上直接導出的,不知道是不是兩個庫對parquet支持上有差異還是因爲啥,pyarrow就可以。。。。
pyspark:
from pyspark import SparkContext
from pyspark.sql.session import SparkSession
ss = SparkSession(sc)
ss.read.parquet('parquet_file_path') # 默認讀取的是hdfs的file
pyspark就直接讀取就好,畢竟都是一家人。。。。
參考
文中的很多概念、例子等都來自於下面兩篇分享,需要的同學可以移步那邊;