Spark Parquet詳解

HoLoong發表於2020-09-29

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的檔案格式主要由headerfooter、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 levelDefinition 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就直接讀取就好,畢竟都是一家人。。。。

參考

文中的很多概念、例子等都來自於下面兩篇分享,需要的同學可以移步那邊;

相關文章