經典論文翻譯導讀之《A Bloat-Aware Design for Big Data Applications》

來源:互聯網
上載者:User
文章目錄
  • 3.2.1 設計上的轉變
  • 3.2.2 執行過程
  • 3.2.3 運行樣本

【譯者預讀】

世界上最窩囊的莫過於營運說可以給你8核16G記憶體的高配機器你卻只能說虛成4份再給我吧。。。為何,因為怕Java程式駕馭不了這麼大的記憶體。實踐發現JVM堆記憶體調到2G以上就要非常小心GC帶來的巨大開銷了。本篇論文從理論和實踐上摸索出了一條解決之道,其思路清晰、分析透徹,想享受Java便利又遠離GC困擾的,可供參考。

 

1簡介

在過去十年裡,在資料驅動商業智慧持續增長的需求下,各種大規模資料密集型應用開始繁榮興盛,它們經常要處理成噸的資料(TP或PB規模)。物件導向風格的語言,比如Java,經常被開發人員使用來實現這些應用,首要原因就是Java的快速開發週期和豐富社區資源。儘管使用這種語言讓開發變得簡單,它帶來的顯著性能問題卻如噩夢揮之不去——在託管式運行時系統中存在各種與生俱來的低效因素,外加在有限記憶體空間中處理大規模資料的衝擊,最終導致了驚人的記憶體膨脹和效能退化。

本片論文提出一個膨脹感知的設計範式,旨在開發高效、可伸縮的大資料應用,即使使用的是物件導向、需要GC的語言。我們首先會學習幾個典型的記憶體膨脹模式。這些模式總結自兩個廣泛使用的開源大資料應用的使用者抱怨郵件。然後我們將討論一種新的設計範例來消除膨脹。通過樣本和實驗,我們示範了使用此範例編程不會顯著增加開發成本。然後我們分別使用新設計原則和便利的物件導向設計原則實現了一些常用的資料處理任務以作對比。實驗結果顯示新設計範例極大的改進了效能——甚至在資料規模不那麼龐大時,我們都看到了2.5倍以上的效能提升,隨著資料集合的擴大,效能收益成比例上升。

2 對大資料應用的記憶體分析

本章節中,我們通過對Giraph和Hive這兩個流行的資料密集型應用的研究,來分析用Java對象來表述、處理資料對效能和延展性的衝擊。我們的分析會深入到最基本的問題——時間和空間:1 被對象頭和對象引用消耗的大量記憶體空間,導致很低的記憶體堆積密度,2 海量對象和引用導致悲催的GC效率。

2.1 堆積密度低

在Java運行時,每個對象需要一個頭空間,以支援類型管理和記憶體管理。數組對象需要附加空間來儲存它的長度。比如,在Oracle的64位HotSpot JVM中,常規對象、數組的頭空間分別佔據8和12位元組。在一個典型的大資料應用中,JVM堆中經常包含很多小對象(比如代表記錄ID的Integer),其中頭空間的開銷不容忽視。而且由於物件導向資料結構的大量使用,空間會越發低效。這些資料結構經常使用多級委託來達到它們的功能,導致大量空間被用來儲存指標而不是真實資料。為了衡量空間使用的性價比,我們使用了一個標準,名為堆積密度(Packing
Factor),它表示能儲存到一個固定大小記憶體中的真實資料的最大數量。我們的分析主要針對大資料應用情境,也就是海量資料分批流經一個固定大小的記憶體。
為了分析典型大資料應用的堆記憶體堆積密度,我們使用PageRank演算法(一個基於Giraph構建的應用程式)作為一個例子。PR(PageRank下面簡稱PR)是一個串連分析演算法,它在一個圖結構中為每個頂點分配權重,通過迭代的方式、基於每個頂點入度鄰居的權重來計算。這個演算法廣泛使用在搜尋引擎的頁面排名中。
我們在不同的開源大資料計算系統上運行了PR,包括Giraph、Spark和Mahout,使用一個6機組、180台機器的叢集。每個機器有2個quad-core Intel Xeon E5420處理器和16GB的RAM。實驗的資料集合(web graph dataset)共70GB,包含1,413,511,393個頂點。我們發現他們仨沒有誰能成功的處理這個資料集合,都崩潰在java.lang.OutOfMemoryError,資料是分區在每台機器上(少於500MB)的,實體記憶體足夠。

我們發現很多開發人員遇到了類似的問題。比如,我們在Giraph的使用者郵件中看到很多OutOfMemoryError的抱怨。為了定位瓶頸,我們決定用PR來做一個定量分析。Giraph包含一個PR演算法的例子,部分資料結構的表述如下:

1234567891011121314151617 public
abstract
class
EdgeListVertex< I
extends
WritableComparable,
V extends
Writable, E extends
Writable, M extends
Writable> extends
MutableVertex<I, V, E, M> {     private
I vertexId = null;
    private
V vertexValue = null;
    /** indices of its outgoing edges */    private
List<I> destEdgeIndexList;     /** values of its outgoing edges */    private
List<E> destEdgeValueList;     /** incoming messages from the previous iteration */    private
List<M> msgList;     ......
    /** return the edge indices starting from 0 */    public
List<I> getEdegeIndexes(){     ...
    
}

在Giraph中處理的圖是帶標記的(頂點和連線都有值),連線是有向的。EdgeListVertex類代表了圖中一個頂點。在它的欄位裡,vertexId和vertexValue儲存了ID和頂點值。欄位destEdgeIndexList和destEdgeValueList,引用的分別是出度連線的ID列表和值列表。msgList包含上一次迭代發來的訊息。圖1以一個EdgeListVertex對象為根頂點展示了此圖結構。


在Giraph的PR實現中,I,V,E和M的真實類型是LongWritable、DoubleWritable、FloadWritable和DoubleWritable。圖中每個連線權值相同,因此destEdgeValueList引用的list一直是空的。假設每個頂點平均下來有m個出度連線和n個訊息。得到表1,其展示了一個頂點資料結構在Oracle64位HotSpot虛擬機器上的記憶體消耗統計。表中每行針對一個類,展示了它在這種資料結構中需要的對象數量、這些對象的頭空間使用的位元組數量、參考型別的欄位使用的位元組數量。一個頂點的空間額外開銷就是16(m+n)+148(也就是頭size和指標size的總和)
另一方面,圖2展現了一個理論上的記憶體布局,其中僅僅儲存必需資訊(不需要使用對象)。在這種情境下,一個頂點需要m+1個long值(頂點ID),n個double值(訊息),和兩個32位的int值(出度數量和訊息數量),這些消耗總共8(m+n+1)+16=8(m+n)+24位元組的記憶體。這比表1中對象頭和指標的記憶體消耗的一半還小。很明顯,在基於對象的表述中,空間額外開銷超過了200%(相比必需開銷)。

 

2.2 當對象和引用的數量達到一定規模

在一個JVM裡,GC線程會周期的遍曆堆中所有存活的對象,以鑒別和回收死對象。假設存活對象的數量是n,對象組成的圖結構中連線的總數量是e,一個追蹤記憶體回收演算法的計算複雜度就是O(n+e)。在大資料應用中,對象圖往往包含超大規模的、隔離的對象子圖,有些代表資料項目,有些代表為處理資料項目而建立的資料結構。因此記憶體中資料對象的規模龐大,n和e都比常規的Java應用大好幾個數量級。

我們使用一個在Hive的使用者郵件的異常例子來分析這個問題:

FATAL org.apache.hadoop.mapred.TaskTracker:
Error running child : java.lang.OutOfMemoryError: Java heap space
org.apache.hadoop.io.Text.setCapacity(Text.java:240)
at org.apache.hadoop.io.Text.set(Text.java:204)
at org.apache.hadoop.io.Text.set(Text.java:194)
at org.apache.hadoop.io.Text.<init>(Text.java:86)
……
at org.apache.hadoop.hive.ql.exec.persistence.Row
Container.next(RowContainer.java:263)
org.apache.hadoop.hive.ql.exec.persistence.Row Container.next(RowContainer.java:74)
at org.apache.hadoop.hive.ql.exec.CommonJoinOperator. checkAndGenObject(CommonJoinOperator.java:823)
at org.apache.hadoop.hive.ql.exec.JoinOperator. endGroup(JoinOperator.java:263)
at org.apache.hadoop.hive.ql.exec.ExecReducer. reduce(ExecReducer.java:198)
……
at org.apache.hadoop.hive.ql.exec.persistence.Row
Container.nextBlock(RowContainer.java:397)
at org.apache.hadoop.mapred.Child.main(Child.java:170)

我們檢查了Hive的源碼,發現堆棧頂端的Text.setCapacity()並不是問題根源。在Hive的join實現裡,JoinOperator持有來自RowContainer中一個輸入分支的所有Row對象。若大量Row對象被儲存在RowContainer中,單次GC都會變得十分昂貴。在堆棧裡,Row對象的總size超過了堆上限,導致了記憶體溢出。

即使在記憶體溢出沒有觸發的情境中,大規模數量的ROW對象也會導致效能退化。假設Row對象數量為n,那GC遍曆複雜度至少是O(n)。對Hive來說,n會隨著輸入資料成比例的增長,這能輕易的導致大量GC開銷。下面也是一個類似的例子,來自StackOverflow的使用者報告,儘管表現不太一樣,根本原因是同一個:

“我寫了個Hive查詢,它select大約30個列、大概400,000條記錄、插入到另一個表。我的SQL裡有個內串連。查詢失敗,因為一個Java GC overhead limit exceeded異常。”

事實上,在Hive郵件清單或者StackOverflow網站上經常能看到關於GC開銷太大的抱怨。更糟的是,從開發人員角度來說做不了什麼最佳化,因為低效的根源在於Hive的內在設計。Hive中所有資料處理相關的介面都需要使用Java對象來表述資料項目。為了操作Row裡包含的資料,我們需要將其封裝為一個Row對象,遵循介面設計。如果我們希望完全的解決這個效能問題,那就需要重新設計和實現所有相關的介面,任何使用者都無法承受這種顛覆。這個例子促使我們在設計層面尋求解決方案,不能再受限於傳統物件導向的條條框框。

3 膨脹感知的設計範例

上述效能問題的根本原因在於這兩個大資料應用都是完全遵循常規物件導向原則而設計和實現的:萬物皆對象。對象被使用來表述資料處理器和需要被處理的資料項目。建立資料處理器對象可能不會有顯著的效能衝擊,而對資料項目使用對象表述則會導致大規模的瓶頸,妨礙應用處理大型資料集合。值得一提的是,典型的大資料應用會重複執行類似的資料處理任務,一組相關的資料項目經常有類似的行為模式和生命週期,因此我們能輕易的在一個大型緩衝塊裡集中管理它們,這樣GC就不需要遍曆每個單獨的資料項目來檢查它是否已死。比如,在Giraph的例子裡,所有頂點對象在有相同的生命週期;在Hive裡的Row也是一樣。所以我們很自然的想到,應該分配一大塊記憶體地區,將所有資料項目的真實內容(data位元組,而不是對象)放置其中,集中管理它們,對JVM來說這一片記憶體地區整體才是一個對象,如果資料項目不再需要只需回收這一個整體對象。

基於這個觀察,我們提出一個膨脹感知的設計範例,旨在開發高效的大資料應用。這個範例包含下列兩個重要的組成部分:1 將小資料記錄合并、組織為幾個大對象(比如byte緩衝),而不是一條記錄一個對象,2 通過直接的緩衝訪問操作資料(在位元組層面而不是對象層面)。設計範例的核心是限制對象的數量,而不是讓它與輸入資料成比例增長。注意這些指導方針應該在早期設計階段就考慮明確,才能使得後期API和實現都遵從這些原則。我們構建了一個大資料處理架構Hyracks,它嚴格遵守此設計範例。後續將使用Hyracks運行一些樣本來詮釋這些設計原則。

3.1 資料存放區設計:合并小對象

如章節2所述,用JavaObject Storage Service資料會導致記憶體和CPU的各種開銷。所以,我們提出將一組資料項目集中儲存在一個Java記憶體page中。不像系統層面的記憶體page是用於處理虛擬記憶體,我們說的Java記憶體page是JVM堆中一個固定長度的連續的記憶體塊。為了簡化描述,後文中我們將使用page來表示Java記憶體page。在Hyracks裡,每個page被表述為一個對象,類型為java.nio.ByteBuffer。將記錄布置到page裡能減少對象的數量,以前等於資料項目的總量,現在等於page總量。因此,系統的堆積密度能更接近於理想狀態。注意將資料項目組合到一個二進位page只是很多方法的一種,我們後續會考慮更多的小對象合并方案。

將記錄放置到page方式很多,Hyracks系統使用的是”基於slot的記錄管理“[36],其被廣泛使用在現有的DBMS實現裡。再次以PR演算法為例,圖3展現了4個頂點儲存在一個page中。很容易看出每個頂點是按圖2中的緊湊布局儲存的,我們使用 4個slot(佔據4位元組)在page末尾來儲存每個頂點的offset(4個頂點,所以4個offset)。這些offset將被用來快速定位元據項、支援可變長度記錄。注意資料記錄的格式對開發人員是不可見的,所以他們能仍然聚焦在進階資料管理任務,不用關心位元組格式。由於page是固定大小的,經常有小的殘留空間造成浪費、不能被重用。背景介紹完畢,現在來計算這個設計的堆積密度,我們假設每個page平均擁有p條記錄,殘留空間有r個位元組。每個頂點表述的額外開銷包含3個部分:存offset的slot(4個位元組)、分攤的殘留空間(也就是r/p)、分攤的page對象開銷(也就是java.nio.ByteBuffer這個對象的額外開銷)。page對象有8位元組頭空間(在Oracle
64位HotSpot JVM)和一個引用(8位元組)指向一個內部位元組數組,此數組頭空間佔據12位元組。所以page對象額外開銷被分攤後為28/p。結合章節2.1,我們得到一個頂點總共需要 (8m+8n)+24+4+(r+28)/p 個位元組,其中(8m+8n)+24被用來儲存必需資料,4+(r+28)/p 是開銷。由於r是殘留空間的size,所以我們得到r ≤ 8m + 8n + 24,因此一個頂點的空間額外開銷限制在4+(8m+8n+52)/p。在Hyracks裡,我們使用32KB為page的size,p的大小區間是100到200(真實資料實驗中看到的)。為了計算最大可能的開銷,考慮最壞的情境,殘留空間等於頂點大小。一個頂點的size在
(32768 − 200 ∗ 4)/(200 + 1)=159 到 (32768 − 100 ∗ 4)/(100 + 1)=320 之間,所以159 ≤ r ≤ 320。這樣一個頂點的空間額外開銷就是4位元組(因為至少需要4位元組為offset的slot)到 (4 + (320 + 28)/100)=7  之間。因此相對於真實資料的size,總體的額外開銷率為2-4%,遠低於基於對象表述的200%(章節2.1論證的)。

3.2 資料處理器設計:訪問緩衝

實現基於buffer的記憶體管理後,就需要支援基於buffer的資料處理編程,我們提出了一個基於訪問器的編程範式。以前我們總是在堆中建立資料結構,其中包含各種資料項目,並表述它們的邏輯關係,現在,我們改為定義一個包含多種訪問器的結構,每個訪問器可以訪問不同類型的資料。同樣的,我們僅僅需要很少的訪問器結構就可以處理所有資料,顯著的減少堆對象。在本章節,我們要首先做個思維轉變,將以前物件導向設計的資料結構轉變為對應的訪問器結構,然後通過一些樣本來描述執行過程。

3.2.1 設計上的轉變

假設以前我們會根據物件導向原則,為資料項目設計一種資料結構,類型為D,現在我們將D換成一個訪問器類——Da。開發人員可以指定某個類型是否為資料項目類。轉變步驟如下:
Step1:假設f是D類型裡的一個欄位(field),類型為F,我們在Da裡添加一個對應欄位fa,類型為Fa,讓Fa作為F的訪問器類。D中的非資料項目類型裡只需直接拷貝到Da裡(非資料項目不重要,量不大,不管它,主要針對page裡存的資料項目內容)。

Step2: 添加一個public方法 set(byte[] data,int start,int length)到Da裡。這個方法用來將訪問器綁定到page中一個指定的位元組範圍,以訪問類型D的某個資料項目。這個可以做成熱執行個體化或者lazy執行個體化,熱執行個體化將會遞迴的為所有成員訪問器fa綁定到各自的二進位地區,lazy執行個體化中直到成員訪問器真正需要被使用時才去綁定。

Step3:對D中的每個方法M,我們在Da裡建立一個對應的方法Ma。然後將M的資料項目型別參數和傳回值全換成對應的資料訪問器類型,訪問器作為參數或傳回值可以用來訪問它綁定位元組範圍中的資料項目。

從常規物件導向的設計轉變到上述設計,應該在早期開發階段就實施,否則後期改造成本太大。我們未來也將嘗試通過編譯器實現自動的設計轉變。

3.2.2 執行過程

在運行時,我們可以將關聯的訪問器理解為一個圖結構,每個訪問器圖可以分批處理進階記錄。圖中每個節點是一個欄位(field)的訪問器對象,每個連線表示一個”成員“關係(如D類的對象中包含一個欄位f,那Da和fa之間用連線表示其從屬關係)。一個訪問器圖與它對應的堆資料結構的骨架類似,但它不會在內部儲存任何資料。我們讓page“流經”訪問器圖,訪問器依次綁定到page中各資料項目的位元組範圍繼而處理該資料項目。對單個線程來說,訪問器圖的數量與資料結構類型的數量相同,同個資料結構的不同執行個體能被相同的訪問器圖處理。

若使用熱執行個體化,執行任務時建立的訪問器對象的數量等於所有訪問器圖的節點總和。如果用lazy執行個體化,建立的訪問器對象數量能顯著降低,因為一個成員訪問器能經常被幾個相同類型的不同資料項目重用。在一些情境裡還需要附加的建立訪問器對象。比如在資料項目類中有一個compare方法,它會比較兩個資料項目參數,轉變後的compare方法需要兩個訪問器對象參數在運行時執行對比。不管用什麼方法實現訪問器,訪問器對象的數量一定是可以在編譯時間確定的,不會隨著資料集合的基數成比例增長。

【譯者注】

訪問器章節看似複雜其實原理十分簡單,就好像你做一個學生資訊管理系統,必然有學校、班級、學生3種對象。假設有1所學校、10個班級、600個學生,若用物件導向原則,當資料需要批量處理或常駐記憶體時,就會建立611個對象;若使用此文範例,則只會有4個對象,1個學校訪問器,一個班級訪問器,一個學生訪問器,還有個Page對象裡麵包含611個資料項目的縝密位元組數組。把合適的訪問器移到page中合適的位置上,就能訪問此位置對應的資料項目,比如302班、某位同學。所以訪問器對象的數量只和模型種類有關,在編譯時間期就能確定(而不是在運行時隨著資料項目增長而增長)。而且從文中看出,在單個線程裡一個訪問器的理想狀態是單例狀態,即一種訪問器類型只建立一個對象,串列的依次將它移動到合適的位移,一個個的訪問資料項目。
但是某些時候不是單例,比如班級裡原本只有一個數組訪問器成員,ListAccessor<學生> students,現在要分為兩個數組——ListAccessor<學生> maleStudent、ListAccessor<學生> femaleStudents,男女同學兩撥,而訪問器類型相同,卻在訪問器圖結構中有兩個成員對象(若是熱執行個體化模式那就是倆,若是lazy模式那就是1個,可以重複使用,每次使用只需set新位移量)。再比如Da類中有些方法,需要將當前對象和其他Da對象做處理,那也要附加的建立出其他Da對象。

至於為何叫訪問器“圖”(Accessor Graph)也不難理解,任何資料結構都不是孤立的,必然包含成員變數,成員變數可能不是個簡單的原生類型(比如int、double),也許是另一種資料結構對象的引用。這種成員關係遞迴反覆,就形成了一張圖的結構。所以各資料結構的訪問器也需組織成一個類似的圖結構,當“根”訪問器set到“根”位移後,其成員訪問器也需遞迴的set到各成員的位移上。

3.2.3 運行樣本

根據3個步驟,我們將章節2.1的頂點例子轉變為如下形式:

12345678910111213141516171819202122232425262728 public
abstract
class
EdgeListVertexAccessor< 
I extends
WritableComparableAccessor, V extends
WritableAccessor, E extends
WritableAccessor, M extends
WritableAccessor> extends
MutableVertexAccessor<I, V, E, M> {
    private
I vertexId = null;
    private
V vertexValue = null;
    /** by S1: indices of its outgoing edges */    private
ListAccessor<I> destEdgeIndexList = new                           ArrayListAccessor<I>();
    /** by S1: values of its outgoing edges */    private
ListAccessor<E> destEdgeValueList = new                            ArrayListAccessor<E>();
    /** by S1: incoming messages from the previous iteration */    private
ListAccessor<M> msgList = new
ArrayListAccessor<M>();     ......
   /** by S2:
    * binds the accessor to a binary region *of a vertex
    */    public
void set(byte[] data,
int start,
int length){     /* This may in turn call the set method
    of its member objects. */ ......
    }
    /** by S3: replacing the return type*/    public
ListAccessor<I> getEdegeIndexes(){     }
    ...
}

 

在上述程式碼片段裡,我們高亮顯示改動過的代碼、注釋描述了轉變步驟。圖4中是運行時期的一個堆快照。真實資料被布置在page中,一個訪問器圖被用來處理所有頂點,每次一個。對每個頂點,set方法會將訪問器圖綁定到它的位元組地區。

4 實驗

【譯者注】文章後續用各種演算法為例嘗試了一系列對比實驗,這裡不再細化翻譯了,貼幾張實驗結果的效能對比圖,以供感受,詳情請參考原文。


 

【譯者總結】

文章的精髓一言以蔽之——別建立對象,用固態byte數組。用byte數組,既減少記憶體空間佔用,又避免海量對象的GC遍曆,思路貌似和分布式小檔案儲存體系統也有點類似(把小檔案組裝成固定chunk,避免海量小檔案的中繼資料浪費和檢索開銷)。但是說起來容易做起來難,我們用Java就是為了享受設計模式的快感、整齊劃一的風格、物件導向的便利,改成byte數組,那和用C有什麼分別?即使文中期望用訪問器圖等各種手段來彌補這種缺憾,也難以挽回放棄物件導向而損失的優越感。 

然而這個世界有一個二八原則,即影響你系統80%效能的往往是那20%的代碼,甚至更少。譯者認為,魚和熊掌不可兼得,只能求其中者。我們在大架構、領域模型、ER設計上依然貫徹物件導向原則,但是可以像文中強調的那樣,針對資料穿梭的那條急流,做出妥協。

 

 

-- 掃描加關注,號: importnew --


原文連結:
asterix.ics.uci.edu 翻譯: ImportNew.com
- 儲曉穎
譯文連結: http://www.importnew.com/5061.html
[ 轉載請保留原文出處、譯者、譯文連結和上面的二維碼圖片。]

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.