1. 程式人生 > >深入分析Parquet列式儲存格式

深入分析Parquet列式儲存格式

Parquet是面向分析型業務的列式儲存格式,由Twitter和Cloudera合作開發,2015年5月從Apache的孵化器裡畢業成為Apache頂級專案,最新的版本是1.8.0。

列式儲存

列式儲存和行式儲存相比有哪些優勢呢?

  1. 可以跳過不符合條件的資料,只讀取需要的資料,降低IO資料量。
  2. 壓縮編碼可以降低磁碟儲存空間。由於同一列的資料型別是一樣的,可以使用更高效的壓縮編碼(例如Run Length Encoding和Delta Encoding)進一步節約儲存空間。
  3. 只讀取需要的列,支援向量運算,能夠獲取更好的掃描效能。

當時Twitter的日增資料量達到壓縮之後的100TB+,儲存在HDFS上,工程師會使用多種計算框架(例如MapReduce, Hive, Pig等)對這些資料做分析和挖掘;日誌結構是複雜的巢狀資料型別,例如一個典型的日誌的schema有87列,嵌套了7層。所以需要設計一種列式儲存格式,既能支援關係型資料(簡單資料型別),又能支援複雜的巢狀型別的資料,同時能夠適配多種資料處理框架。

關係型資料的列式儲存,可以將每一列的值直接排列下來,不用引入其他的概念,也不會丟失資料。關係型資料的列式儲存比較好理解,而巢狀型別資料的列儲存則會遇到一些麻煩。如圖1所示,我們把巢狀資料型別的一行叫做一個記錄(record),巢狀資料型別的特點是一個record中的column除了可以是Int, Long, String這樣的原語(primitive)型別以外,還可以是List, Map, Set這樣的複雜型別。在行式儲存中一行的多列是連續的寫在一起的,在列式儲存中資料按列分開儲存,例如可以只讀取A.B.C這一列的資料而不去讀A.E和A.B.D,那麼如何根據讀取出來的各個列的資料重構出一行記錄呢?

圖1 行式儲存和列式儲存

 

Google的Dremel系統解決了這個問題,核心思想是使用“record shredding and assembly algorithm”來表示複雜的巢狀資料型別,同時輔以按列的高效壓縮和編碼技術,實現降低儲存空間,提高IO效率,降低上層應用延遲。Parquet就是基於Dremel的資料模型和演算法實現的。

 

 

Google的Dremel系統解決了這個問題,核心思想是使用“record shredding and assembly algorithm”來表示複雜的巢狀資料型別,同時輔以按列的高效壓縮和編碼技術,實現降低儲存空間,提高IO效率,降低上層應用延遲。Parquet就是基於Dremel的資料模型和演算法實現的。

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是如何與這些元件協作的呢?這個可以通過圖2來說明。資料從記憶體到Parquet檔案或者反過來的過程主要由以下三個部分組成:

1, 儲存格式(storage format)

parquet-format專案定義了Parquet內部的資料型別、儲存格式等。

2, 物件模型轉換器(object model converters)

這部分功能由parquet-mr專案來實現,主要完成外部物件模型與Parquet內部資料型別的對映。

3, 物件模型(object models)

物件模型可以簡單理解為記憶體中的資料表示,Avro, Thrift, Protocol Buffers, Hive SerDe, Pig Tuple, Spark SQL InternalRow等這些都是物件模型。Parquet也提供了一個example object model 幫助大家理解。

例如parquet-mr專案裡的parquet-pig專案就是負責把記憶體中的Pig Tuple序列化並按列儲存成Parquet格式,以及反過來把Parquet檔案的資料反序列化成Pig Tuple。

這裡需要注意的是Avro, Thrift, Protocol Buffers都有他們自己的儲存格式,但是Parquet並沒有使用他們,而是使用了自己在parquet-format專案裡定義的儲存格式。所以如果你的應用使用了Avro等物件模型,這些資料序列化到磁碟還是使用的parquet-mr定義的轉換器把他們轉換成Parquet自己的儲存格式。

圖2 Parquet專案的結構

Parquet資料模型

理解Parquet首先要理解這個列儲存格式的資料模型。我們以一個下面這樣的schema和資料為例來說明這個問題。

message AddressBook {
 required string owner;
 repeated string ownerPhoneNumbers;
 repeated group contacts {
   required string name;
   optional string phoneNumber;
 }
}

 這個schema中每條記錄表示一個人的AddressBook。有且只有一個owner,owner可以有0個或者多個ownerPhoneNumbers,owner可以有0個或者多個contacts。每個contact有且只有一個name,這個contact的phoneNumber可有可無。這個schema可以用圖3的樹結構來表示。

每個schema的結構是這樣的:根叫做message,message包含多個fields。每個field包含三個屬性:repetition, type, name。repetition可以是以下三種:required(出現1次),optional(出現0次或者1次),repeated(出現0次或者多次)。type可以是一個group或者一個primitive型別。

Parquet格式的資料型別沒有複雜的Map, List, Set等,而是使用repeated fields 和 groups來表示。例如List和Set可以被表示成一個repeated field,Map可以表示成一個包含有key-value 對的repeated field,而且key是required的。

圖3 AddressBook的樹結構表示

Parquet檔案的儲存格式

那麼如何把記憶體中每個AddressBook物件按照列式儲存格式儲存下來呢?

在Parquet格式的儲存中,一個schema的樹結構有幾個葉子節點,實際的儲存中就會有多少column。例如上面這個schema的資料儲存實際上有四個column,如圖4所示。

圖4 AddressBook實際儲存的列 

Parquet檔案在磁碟上的分佈情況如圖5所示。所有的資料被水平切分成Row group,一個Row group包含這個Row group對應的區間內的所有列的column chunk。一個column chunk負責儲存某一列的資料,這些資料是這一列的Repetition levels, Definition levels和values(詳見後文)。一個column chunk是由Page組成的,Page是壓縮和編碼的單元,對資料模型來說是透明的。一個Parquet檔案最後是Footer,儲存了檔案的元資料資訊和統計資訊。Row group是資料讀寫時候的快取單元,所以推薦設定較大的Row group從而帶來較大的並行度,當然也需要較大的記憶體空間作為代價。一般情況下推薦配置一個Row group大小1G,一個HDFS塊大小1G,一個HDFS檔案只含有一個塊。

  

圖5 Parquet檔案格式在磁碟的分佈

拿我們的這個schema為例,在任何一個Row group內,會順序儲存四個column chunk。這四個column都是string型別。這個時候Parquet就需要把記憶體中的AddressBook物件對映到四個string型別的column中。如果讀取磁碟上的4個column要能夠恢復出AddressBook物件。這就用到了我們前面提到的 “record shredding and assembly algorithm”。

Striping/Assembly演算法

對於巢狀資料型別,我們除了儲存資料的value之外還需要兩個變數Repetition Level(R), Definition Level(D) 才能儲存其完整的資訊用於序列化和反序列化巢狀資料型別。Repetition Level和 Definition Level可以說是為了支援巢狀型別而設計的,但是它同樣適用於簡單資料型別。在Parquet中我們只需定義和儲存schema的葉子節點所在列的Repetition Level和Definition Level。

Definition Level

definition level的設計目的是,因為repeated和optional型別的存在,一條記錄的中某些列是沒有值的,如果不記錄這樣的值,就會導致本該屬於下一條記錄的值被當做當前記錄的一部分,從而導致資料錯誤,因此,對於這種情況,需要一個佔位符來表示。

AddressBook {
 owner: "Julien Le Dem",
 ownerPhoneNumbers: "555 123 4567"
AddressBook {
 owner: "A. Nonymous"
}
AddressBook {
 owner: "A. Tom"
 ownerPhoneNumbers: "521 133 4362"
}

# 沒有defintion level,Tom的電話號變成Nonymous的了
Julien Le Dem|A. Nonymous|A. Tom
555 123 4567|521 133 4362
# 加入defintion level之後
Julien Le Dem|A. Nonymous|A. Tom
555 123 4567|佔位|521 133 4362

從根節點開始遍歷,當某一個field的路徑上的節點開始是空的時候我們記錄下當前的深度作為這個field的Definition Level。如果一個field的Definition Level等於這個field的最大Definition Level就說明這個field是有資料的。對於required型別的field必須是有定義的,所以這個Definition Level是不需要的。在關係型資料中,optional型別的field被編碼成0表示空和1表示非空(或者反之)。

Repetition Level

repetition level的設計目標是為了支援repeated型別的節點:

  • 在寫入時該值等於它和前面的值從哪一層節點開始是不共享的。
  • 在讀取的時候根據該值可以推匯出哪一層上需要建立一個新的節點。
    例子:對於這樣的schema和兩條記錄:

message nested {
repeated group leve1 {
repeated string leve2;
}
}

r1:[[a,b,c,] , [d,e,f,g]]
r2:[[h] , [i,j]]

計算一下各個值的repetition level。
repetition level計算過程:

  • value=a是一條記錄的開始,和前面的值在根結點上是不共享的,因此repetition level=0
  • value=b和前面的值共享了level1這個節點,但是在level2這個節點上不共享,因此repetition level=2
  • 同理,value=c的repetition value=2
  • value=d和前面的值共享了根節點,在level1這個節點是不共享的,因此repetition level=1
  • 同理,value=e,f,g都和自己前面的佔共享了level1,沒有共享level2,因此repetition level=2
  • value=h屬於另一條記錄,和前面不共享任何節點,因此,repetition level=0
  • value=i跟前面的結點共享了根,但是沒有共享level1節點,因此repetition level=1
  • value-j跟前面的節點共享了level1,但是沒有共享level2,因此repetition level=2

在讀取時,會順序讀取每個值,然後根據它的repetition level建立物件

  • 當讀取value=a時,repeatition level=0,表示需要建立一個新的根節點,
  • 當讀取value=b時,repeatition level=2,表示需要建立level2節點
  • 當讀取value=c時,repeatition level=2,表示需要建立level2節點
  • 當讀取value=d時,repeatition level=1,表示需要建立level1節點
  • 剩下的節點依此類推

幾點規律:

  • repetition level=0表示一條記錄的開始
  • repetition level的值只是針對路徑上repeated型別的節點,因此在計算時可以忽略非repeated型別的節點
  • 在寫入的時候將其理解為該節點和路徑上的哪一個repeated節點是不共享的
  • 讀取的時候將其理解為需要在哪一層建立一個新的repeated節點

下面用AddressBook的例子來說明Striping和assembly的過程。

對於每個column的最大的Repetion Level和 Definition Level如圖6所示。

 圖6 AddressBook的Max Definition Level和Max Repetition Level

最大值計算:我們從圖三樹結構中可以看到,整個樹可以分為3層,根節點AddressBook為0層,owner,ownerPhoneNumbers,concacts為1層,name和phoneNumber為2層。
先看owner這列,owner是required型別的,所以definition level對它沒意義,為0,owner列作為記錄開始列所以max repetition level為0。
ownerPhoneNumbers為repeated型別,有值時,max definition level最大,為1,max repeated level從根節點遍歷,經過1個repeated節點,所以最大值為1
name列為required型別,父節點concacts為repeated型別,當concacts有值時,name肯定有值,max definition level=1,從根節點遍歷,經過1個repeated節點,max repeated level=1
phoneNumber列為optional型別,父節點為repeated型別,當concacts有值時,phoneNumber有值時,max defintion level=2,max repeated level和name相同,為1

下面這樣兩條record:

AddressBook {
 owner: "Julien Le Dem",
 ownerPhoneNumbers: "555 123 4567",
 ownerPhoneNumbers: "555 666 1337",
 contacts: {
   name: "Dmitriy Ryaboy",
   phoneNumber: "555 987 6543",
 },
 contacts: {
   name: "Chris Aniszczyk"
 }
}
AddressBook {
 owner: "A. Nonymous"
}

以contacts.phoneNumber這一列為例,"555 987 6543"這個contacts.phoneNumber的Definition Level是最大Definition Level=2。而如果一個contact沒有phoneNumber,那麼它的Definition Level就是1。如果連contact都沒有,那麼它的Definition Level就是0。

下面我們拿掉其他三個column只看contacts.phoneNumber這個column,把上面的兩條record簡化成下面的樣子:

AddressBook {
 contacts: {
   phoneNumber: "555 987 6543"
 }
 contacts: {
 }
}
AddressBook {
}

這兩條記錄的序列化過程如圖7所示:

圖7 一條記錄的序列化過程 

如果我們要把這個column寫到磁碟上,磁碟上會寫入這樣的資料(圖8):

  

圖8 一條記錄的磁碟儲存

注意:NULL實際上不會被儲存,如果一個column value的Definition Level小於該column最大Definition Level的話,那麼就表示這是一個空值。

下面是從磁碟上讀取資料並反序列化成AddressBook物件的過程:

1,讀取第一個三元組R=0, D=2, Value=”555 987 6543”

R=0 表示是一個新的record,要根據schema建立一個新的nested record直到Definition Level=2。

D=2 說明Definition Level=Max Definition Level,那麼這個Value就是contacts.phoneNumber這一列的值,賦值操作contacts.phoneNumber=”555 987 6543”。

2,讀取第二個三元組 R=1, D=1

R=1 表示不是一個新的record,是上一個record中一個新的contacts。

D=1 表示contacts定義了,但是contacts的下一個級別也就是phoneNumber沒有被定義,所以建立一個空的contacts。

3,讀取第三個三元組 R=0, D=0

R=0 表示一個新的record,根據schema建立一個新的nested record直到Definition Level=0,也就是建立一個AddressBook根節點。

可以看出在Parquet列式儲存中,對於一個schema的所有葉子節點會被當成column儲存,而且葉子節點一定是primitive型別的資料。對於這樣一個primitive型別的資料會衍生出三個sub columns (R, D, Value),也就是從邏輯上看除了資料本身以外會儲存大量的Definition Level和Repetition Level。那麼這些Definition Level和Repetition Level是否會帶來額外的儲存開銷呢?實際上這部分額外的儲存開銷是可以忽略的。因為對於一個schema來說level都是有上限的,而且非repeated型別的field不需要Repetition Level,required型別的field不需要Definition Level,也可以縮短這個上限。例如對於Twitter的7層巢狀的schema來說,只需要3個bits就可以表示這兩個Level了。

對於儲存關係型的record,record中的元素都是非空的(NOT NULL in SQL)。Repetion Level和Definition Level都是0,所以這兩個sub column就完全不需要儲存了。所以在儲存非巢狀型別的時候,Parquet格式也是一樣高效的。

上面演示了一個column的寫入和重構,那麼在不同column之間是怎麼跳轉的呢,這裡用到了有限狀態機的知識,詳細介紹可以參考Dremel

資料壓縮演算法

列式儲存給資料壓縮也提供了更大的發揮空間,除了我們常見的snappy, gzip等壓縮方法以外,由於列式儲存同一列的資料型別是一致的,所以可以使用更多的壓縮演算法。

壓縮演算法

使用場景

Run Length Encoding

重複資料

Delta Encoding

有序資料集,例如timestamp,自動生成的ID,以及監控的各種metrics

Dictionary Encoding

小規模的資料集合,例如IP地址

Prefix Encoding

Delta Encoding for strings

效能

Parquet列式儲存帶來的效能上的提高在業內已經得到了充分的認可,特別是當你們的表非常寬(column非常多)的時候,Parquet無論在資源利用率還是效能上都優勢明顯。具體的效能指標詳見參考文件。

Spark已經將Parquet設為預設的檔案儲存格式,Cloudera投入了很多工程師到Impala+Parquet相關開發中,Hive/Pig都原生支援Parquet。Parquet現在為Twitter至少節省了1/3的儲存空間,同時節省了大量的表掃描和反序列化的時間。這兩方面直接反應就是節約成本和提高效能。

如果說HDFS是大資料時代檔案系統的事實標準的話,Parquet就是大資料時代儲存格式的事實標準。

參考文件

  1. http://parquet.apache.org/
  2. https://blog.twitter.com/2013/dremel-made-simple-with-parquet
  3. http://blog.cloudera.com/blog/2015/04/using-apache-parquet-at-appnexus/
  4. http://blog.cloudera.com/blog/2014/05/using-impala-at-scale-at-allstate/
  5. Dremel: Interactive Analysis of Web-Scale Datasets
  6. Dremel made simple with Parquet
  7. Parquet: Columnar storage for the people
  8. Efficient Data Storage for Analytics with Apache Parquet 2.0
  9. 深入分析Parquet列式儲存格式
  10. Apache Parquet Document
  11. http://blog.csdn.net/yu616568/article/details/50993491
  12. http://blog.csdn.net/yu616568/article/details/51188479