深入Parquet文件格式設計原理及實現(xiàn)細節(jié)
引言
思考半天決定講一個大家既熟悉又陌生的話題:Parquet文件。相信每個做大數(shù)據(jù)的工程師肯定都接觸過Parquet文件,都知道它是一種列式存儲格式,在面對OLAP查詢時可以減少讀取的數(shù)據(jù)量,提高查詢性能。但是對于它的格式具體是如何設計的,以及更重要的:為什么這樣設計,可能就沒有那么清楚了。
這篇文章會帶你深入Parquet文件的原理和實現(xiàn)細節(jié),并試圖說明這些設計背后的意義。
Parquet解決什么問題
要理解一個系統(tǒng),首先第一個要提出的問題就是
這個系統(tǒng)為了解決什么問題?
也就是“這個系統(tǒng)提供了什么功能”。這是理解任何一個系統(tǒng)都需要關注的主線。只要心中有這條主線,就不會陷入各種細節(jié)的泥沼,而迷失了方向。
對于Parquet文件來說,這條主線在Twitter宣布Parquet開源的文章中就講得非常清楚
Parquet is an open-source columnar storage format for Hadoop.
...
Not all data people store in Hadoop is a simple table — complex nested structures abound. For example, one of Twitter’s common internal datasets has a schema nested seven levels deep, with over 80 leaf nodes.
也就是說,Twitter想在Hadoop上面設計一種新的列式存儲格式,這種格式可以保存包含嵌套結構的數(shù)據(jù)。所以Parquet文件格式試圖解決的問題,用一句話來說,就是列式存儲一個類型包含嵌套結構的數(shù)據(jù)集。
什么是“包含嵌套結構的數(shù)據(jù)集”呢?舉個例子
假設我們要存儲1000個用戶的電話簿信息,其中每個用戶的電話簿用下面的這個結構來表示
{ "owner": "Lei Li", "ownerPhoneNumbers": ["13354127165", "18819972777"], "contacts": [ { "name": "Meimei Han", "phoneNumber": "18561628306" }, { "name": "Lucy", "phoneNumber": "14550091758" } ] }
可以看到其中 ownerPhoneNumbers
字段是一個數(shù)組,而contacts
字段更是一個對象的數(shù)組。所以這個類型就不能用簡單的二維表來存儲,因為它包含了嵌套結構。
如果把這個包含嵌套結構的類型稱為AddressBook
,那么Parquet文件的目標就是以面向列的方式保存AddressBook對象所構成的數(shù)據(jù)集。
接下來再來講如何“以面向列的方式保存”。對于一個二維表的數(shù)據(jù),相信大家可以很容易地想象出怎樣列式地存儲這些數(shù)據(jù),例如
name | age | phoneNumber |
Lei Li | 16 | 13354127165 |
Meimei Han | 14 | 18561628306 |
Lucy | 15 | 14550091758 |
把它列式存儲就變成了
"Lei Li"
"Meimei Han"
"Lucy"
16
14
15
"13354127165"
"18561628306"
"14550091758"
但如果數(shù)據(jù)是一個包含數(shù)組和對象的復雜嵌套結構呢?可能就不是這么直觀了。
在Parquet里面,保存嵌套結構的方式是把所有字段打平以后順序存儲。
什么意思呢?以電話簿的例子來說,真正有數(shù)據(jù)的其實只有4列:
- owner
- ownerPhoneNumbers
- contacts.name
- contacts.phoneNumber
所以只需要把原始數(shù)據(jù)看做是一個4列的表即可。舉個例子:
假設有2條AddressBook記錄
{ "owner": "Lei Li", "ownerPhoneNumbers": ["13354127165", "18819972777"], "contacts": [ { "name": "Meimei Han", "phoneNumber": "18561628306" }, { "name": "Lucy", "phoneNumber": "14550091758" } ] }, { "owner": "Meimei Han", "ownerPhoneNumbers": ["15130245254"], "contacts": [ { "name": "Lily" }, { "name": "Lucy", "phoneNumber": "14550091758" } ] }
以列式保存之后,就會變成這樣
"Lei Li"
"Meimei Han"
"13354127165"
"18819972777"
"15130245254"
"Meimei Han"
"Lucy"
"Lily"
"Lucy"
"18561628306"
"14550091758"
"14550091758"
聰明的朋友肯定很快就發(fā)現(xiàn)了,因為原始結構里有個數(shù)組,長度是不定的,如果只是把數(shù)據(jù)按順序存放,那就無法區(qū)分record之間的邊界,也就不知道每個值究竟屬于哪條record了。所以簡單地打平是不可行的。
為了解決這個問題,Parquet的設計者引入了兩個新的概念:repetition level和definition level。這兩個值會保存額外的信息,可以用來重構出數(shù)據(jù)原本的結構。
關于repetition level和definition level具體是如何工作的,我會放到最后來講。這里只需要記住,Parquet文件對每個value,都同時保存了它們的repetition level和definition level,以便確定這個value屬于哪條record。
Parquet具體是怎么存放數(shù)據(jù)
接下來我們會深入Parquet文件的內部,講講Parquet具體是怎么存放數(shù)據(jù)的。
首先放一張Parquet文件的整體結構圖
其實Parquet還有一張更常見的結構圖,官方也經常引用,但我覺得層次不清晰,反而更讓人費解,所以就自己畫了上面這張圖。
看過Parquet的整體結構圖之后,可能你已經被這些概念搞迷糊了:Header,Row Group,Column Chunk,Page,F(xiàn)ooter……沒關系,還是回到我們的主線——列式存儲一個包含嵌套結構的數(shù)據(jù)集,我會把解決這個問題的思路自上而下地拆解,自然而然地就能產生這些概念。
Row Group
首先,因為我們要存儲的對象是一個數(shù)據(jù)集,而這個數(shù)據(jù)集往往包含上億條record,所以我們會進行一次水平切分,把這些record切成多個“分片”,每個分片被稱為Row Group。為什么要進行水平切分?雖然Parquet的官方文檔沒有解釋,但我認為主要和HDFS有關。因為HDFS存儲數(shù)據(jù)的單位是Block,默認為128m。如果不對數(shù)據(jù)進行水平切分,只要數(shù)據(jù)量足夠大(超過128m),一條record的數(shù)據(jù)就會跨越多個Block,會增加很多IO開銷。Parquet的官方文檔也建議,把HDFS的block size設置為1g,同時把Parquet的parquet.block.size也設置為1g,目的就是使一個Row Group正好存放在一個HDFS Block里面。
Column Chunk
在水平切分之后,就輪到列式存儲標志性的垂直切分了。切分方式和上文提到的一致,會把一個嵌套結構打平以后拆分成多列,其中每一列的數(shù)據(jù)所構成的分片就被稱為Column Chunk。最后再把這些Column Chunk順序地保存。
Page
把數(shù)據(jù)拆解到Column Chunk級別之后,其結構已經相當簡單了。對Column Chunk,Parquet會進行最后一次水平切分,分解成為一個個的Page。每個Page的默認大小為1m。這次的水平切分又是為了什么?盡管Parquet的官方文檔又一次地沒有解釋,我認為主要是為了讓數(shù)據(jù)讀取的粒度足夠小,便于單條數(shù)據(jù)或小批量數(shù)據(jù)的查詢。因為Page是Parquet文件最小的讀取單位,同時也是壓縮的單位,如果沒有Page這一級別,壓縮就只能對整個Column Chunk進行壓縮,而Column Chunk如果整個被壓縮,就無法從中間讀取數(shù)據(jù),只能把Column Chunk整個讀出來之后解壓,才能讀到其中的數(shù)據(jù)。
Header, Index和Footer
最后聊聊Data以外的Metadata部分,主要是:Header,Index和Footer。
Header
Header的內容很少,只有4個字節(jié),本質是一個magic number,用來指示文件類型。這個magic number目前有兩種變體,分別是“PAR1”和“PARE”。其中“PAR1”代表的是普通的Parquet文件,“PARE”代表的是加密過的Parquet文件。
Index
Index是Parquet文件的索引塊,主要為了支持“謂詞下推”(Predicate Pushdown)功能。謂詞下推是一種優(yōu)化查詢性能的技術,簡單地來說就是把查詢條件發(fā)給存儲層,讓存儲層可以做初步的過濾,把肯定不滿足查詢條件的數(shù)據(jù)排除掉,從而減少數(shù)據(jù)的讀取和傳輸量。舉個例子,對于csv文件,因為不支持謂詞下推,Spark只能把整個文件的數(shù)據(jù)全部讀出來以后,再用where條件對數(shù)據(jù)進行過濾。而如果是Parquet文件,因為自帶Max-Min索引,Spark就可以根據(jù)每個Page的max和min值,選擇是否要跳過這個Page,不用讀取這部分數(shù)據(jù),也就減少了IO的開銷。
目前Parquet的索引有兩種,一種是Max-Min統(tǒng)計信息,一種是BloomFilter。其中Max-Min索引是對每個Page都記錄它所含數(shù)據(jù)的最大值和最小值,這樣某個Page是否不滿足查詢條件就可以通過這個Page的max和min值來判斷。BloomFilter索引則是對Max-Min索引的補充,針對value比較稀疏,max-min范圍比較大的列,用Max-Min索引的效果就不太好,BloomFilter可以克服這一點,同時也可以用于單條數(shù)據(jù)的查詢。
Footer
Footer是Parquet元數(shù)據(jù)的大本營,包含了諸如schema,Block的offset和size,Column Chunk的offset和size等所有重要的元數(shù)據(jù)。另外Footer還承擔了整個文件入口的職責,讀取Parquet文件的第一步就是讀取Footer信息,轉換成元數(shù)據(jù)之后,再根據(jù)這些元數(shù)據(jù)跳轉到對應的block和column,讀取真正所要的數(shù)據(jù)。
關于Footer還有一個問題,就是為什么Parquet要把元數(shù)據(jù)放在文件的末尾而不是開頭?這主要是為了讓文件寫入的操作可以在一趟(one pass)內完成。因為很多元數(shù)據(jù)的信息需要把文件基本寫完以后才知道(例如總行數(shù),各個Block的offset等),如果要寫在文件開頭,就必須seek回文件的初始位置,大部分文件系統(tǒng)并不支持這種寫入操作(例如HDFS)。而如果寫在文件末尾,那么整個寫入過程就不需要任何回退。
Parquet如何把嵌套結構編碼進列式存儲
講完了Parquet的整體結構之后,我們還剩下最后一個問題,也就是我之前埋下的伏筆:Parquet如何把嵌套結構編碼進列式存儲。在上文中我提到了Parquet是通過repetition level和definition level來解決這個問題,接下來就會詳細地講解一下這是怎么實現(xiàn)的。
還是上文用到的例子
{ "owner": "Lei Li", "ownerPhoneNumbers": ["13354127165", "18819972777"], "contacts": [ { "name": "Meimei Han", "phoneNumber": "18561628306" }, { "name": "Lucy", "phoneNumber": "14550091758" } ] }, { "owner": "Meimei Han", "ownerPhoneNumbers": ["15130245254"], "contacts": [ { "name": "Lily" }, { "name": "Lucy", "phoneNumber": "14550091758" } ] }
注意其中的第三列contacts.name,它有4個值”Meimei Han”,“Lucy”,“Lily”,“Lucy”,其中前兩個屬于前一條record,后兩個屬于后一條record。Parquet是如何表達這個信息的呢?它是用repetition level這個值來表達的。
repetition level主要用來表達數(shù)組類型字段的長度,但它并不直接記錄長度,而是通過記錄嵌套層級的變化來間接地表達長度,即如果嵌套層級不變,那么說明數(shù)組還在延續(xù),如果嵌套層級變了,說明前一個數(shù)組結束了。如果在某個值上嵌套層級由0提高到了1,則這個值的repetition level就是0。如果在某個值的位置嵌套層級不變,則這個值的repetition level就是它的嵌套層級。對于上文中的例子,對應的repetition level就是
Value | Repetition Level |
Meimei Han | 0 |
Lucy | 1 |
Lily | 0 |
Lucy | 1 |
還不是很明白?換個更典型的例子
[["a", "b"], ["c", "d", "e"]]
它對應的repetition level會被編碼成
Value | Repetition Level |
a | 0 |
b | 2 |
c | 1 |
d | 2 |
e | 2 |
因為這個數(shù)組的嵌套層級是2,而”a”是從level 0到level 2的邊界,所以它的repetition level是0,”c”是從level 1到level 2的邊界,所以它的repetition level是1,其他字母的嵌套層級沒有發(fā)生變化,所以它們的repetition level就是2。
總結一下,repetition level主要用來表達數(shù)組的長度。
講完了repetition level,再來講講definition level。與repetition level類似的,definition level主要用來表達null的位置。因為Parquet文件里不會顯式地存儲null,所以通過definition level來判斷某個值是否是null。例如對于下面這個例子
AddressBook { contacts: { phoneNumber: "555 987 6543" } contacts: { } } AddressBook { }
對應的definition level是這樣編碼的
Value | Definition Level |
555 987 6543 | 2 |
NULL | 1 |
NULL | 0 |
可以看到,凡是definition level小于嵌套層級的,都表達了這個值是null。而definition level具體的值則表達null出現(xiàn)在哪一個嵌套層級。
Parquet最難理解的部分到此就結束了。你或許會有疑問,如果對每個值都保存repetition level和definition level,那么這部分的數(shù)據(jù)量肯定不小(兩個int32整數(shù),共8個字節(jié)),搞不好比本來要存的數(shù)據(jù)還要大,是不是本末倒置了?顯然Parquet也考慮到了這個問題,所以有很多的優(yōu)化措施,例如“對非數(shù)組類型的值不保存repetition level”,“對必填字段不保存definition level”等,真正存儲這兩個level時,也使用的是bit-packing + RLE編碼,盡可能地對這部分數(shù)據(jù)進行了壓縮。篇幅有限,就不在這里展開了。
最后聊聊Parquet格式的演進
Parquet格式最初由Twitter和Cloudera提出,作為RCFile格式的替代者,和早一個月提出的ORC格式類似。聯(lián)想到ORC是由Facebook和Hortonworks提出的,這兩者的競爭關系不言自明。(有機會可以再來寫寫ORC格式)
Parquet在2013年宣布開源后,2014年被Cloudera捐給Apache基金會,進入孵化流程,并于2015年畢業(yè)成為頂級項目。Parquet的框架在進入Apache基金會之前已經基本成型,此后變化得也不快,主要新增了幾個功能:
- Column Index
- BloomFilter
- 模塊化加密
這些改動主要是為了加強對謂詞下推的支持,但也有個副作用:文件體積變得更大了。
以上就是深入Parquet文件原理實現(xiàn)細節(jié)及設計意義的詳細內容,更多關于Parquet文件原理設計的資料請關注腳本之家其它相關文章!
相關文章
簡單了解JAVA SimpleDateFormat yyyy和YYYY的區(qū)別
這篇文章主要介紹了簡單了解JAVA SimpleDateFormat yyyy和YYYY的區(qū)別,文中通過示例代碼介紹的非常詳細,對大家的學習或者工作具有一定的參考學習價值,需要的朋友可以參考下2020-03-03Java通過Freemarker模板實現(xiàn)生成Word文件
FreeMarker是一款模板引擎: 即一種基于模板和要改變的數(shù)據(jù), 并用來生成輸出文本的通用工具。本文將根據(jù)Freemarker模板實現(xiàn)生成Word文件,需要的可以參考一下2022-09-09Java實戰(zhàn)之圖書管理系統(tǒng)的實現(xiàn)
這篇文章主要介紹了如何利用Java語言編寫一個圖書管理系統(tǒng),文中采用的技術有Springboot、SpringMVC、MyBatis、ThymeLeaf 等,需要的可以參考一下2022-03-03