spark的外排:AppendOnlyMap與ExternalAppendOnlyMap

來源:互聯網
上載者:User

標籤:一個   err   erb   pen   素數   目標   檔案   cts   previous   

相信很多人和我一樣, 在控制台中總是可以看到會列印出如下的語句:

  INFO ExternalAppendOnlyMap: Thread 94 spilling in-memory map of 63.2 MB to disk (7 times so far)

 

經過查詢一下,摘抄入下:

AppendOnlyMap/ExternalAppendOnlyMap在spark被廣泛使用,如join中shuffle的reduce階段以及combineByKey操作等。

 

AppendOnlyMap
AppendOnlyMap是spark自己實現的Map,只能添加資料,不能remove。該Map是使用開放定址法中的二次探測法,不用內建的HashMap等應該是節省空間的,提高效能。
數組中a=keyi, a[i+1]=valuei, 即用兩個位置來儲存kv對。
growThreshold=LOAD_FACTOR * capacity, 當添加的元素超過該值時,數組會進行grow, capacity翻倍,同時所有的kv都會進行rehash重新分配位置。
主要方法:

1.apply
即調用Map(key)訪問value時。根據key的hash(具體是murmur3_32)找到位置,如果目標位置的key與要尋找的key不一樣,則使用二次探測法繼續尋找,直到找到為止。

2.update
找到對應key的value位置,將新value覆蓋原value。

3.changeValue
spark中用的比較多的方法

 

該方法最核心的其實是外部傳進來的updateFunc(hadValue, oldValue), updateFunc一般是當hadValue為false時createCombiner(v)作為新value, hadValue為true時mergeValue(oldValue,v),將v加到oldValue中。

iterator
一般是在RDD的compute中會調用該方法,作為RDD操作的Iterator, 即其下遊RDD可以為此為資料來源。主要也是實現hasNext和next方法,它們都調用了nextValue方法。
nextValue則從pos(初始化是0)開始遍曆,直到找到data(2*pos)!=null的,則將結果返回。
hasNext是判斷nextValue()!=null。
next是得到nextValue()的傳回值,且將pos+=1。

destructiveSortedIterator
轉化成一般的數組,並按key對kv進行排序。失去了Map的特性。主要用於外排時將Map排序輸出到disk中。
實現思路是:將原數組的值不斷向數組左端緊湊移動,且將原先佔用兩個位置的kv轉成(k,v)只佔一個位置,然後對數組按key進行kv排序。排序方法是KCComparator,即按key的hashcode進行排序。然後建立一個Iterator,其hasNext和next都是對新的數組進行相應遍曆操作。

spark早期版本採用的是AppendOnlyMap來實現shuffle reduce階段資料的彙總,當資料量不大時沒什麼問題,但當資料量很大時就會佔用大量記憶體,最後可能OOM。所以從spark 0.9開始就引入了ExternalAppendOnlyMap來代替AppendOnlyMap。

 

 

ExternalAppendOnlyMap
note:當spark.shuffle.spill=true時會啟用ExternalAppendOnlyMap,預設為true. 為false時就啟用AppendOnlyMap

 

 

 ExternalAppendOnlyMap也在記憶體維護了一個SizeTrackingAppendOnlyMap(繼承於AppendOnlyMap),當該Map元素數超過一定值時就spill到磁碟。最後ExternalAppendOnlyMap其實是維護了一個記憶體Map:currentMap以及多個diskMap:spillMaps。

 

 主要屬性和參數:

currentMap
SizeTrackingAppendOnlyMap,繼承於AppendOnlyMap。是ExternalAppendOnlyMap的記憶體Map。

spilledMaps
new ArrayBuffer[DiskMapIterator], 每個DiskMapIterator都指向了相應的spill到disk上的檔案資料。

maxMemoryThreshold
該值決定了用於該worker上同時啟動並執行任務的currentMap的大小之和,即num(running tasks) * size(各task的currentMap)。該值由spark.shuffle.memoryFraction和spark.shuffle.safetyFraction決定,具體計算方式如下:

val maxMemoryThreshold = {     val memoryFraction = sparkConf.getDouble("spark.shuffle.memoryFraction", 0.3)   val safetyFraction = sparkConf.getDouble("spark.shuffle.safetyFraction", 0.8)   (Runtime.getRuntime.maxMemory * memoryFraction * safetyFraction).toLong //即worker的記憶體*0.24}

 

insert
插入kv對的主要方法。
shouldSpill是剩餘空間是否足夠讓currentMap進行擴容,夠的話進行大小翻倍,不夠的話則將currentMap spill到disk中。
這裡需要判斷是否需要進行shouldSpill判斷,具體判斷邏輯如下:

        numPairsInMemory > trackMemoryThreshold && currentMap.atGrowThreshold


numPairsInMemory為已經添加的kv數,trackMemoryThreshold為固定值1000。也就是前1000個元素是可以直接往currentMap放而不會發生spill。
由於currentMap初始時可容納kv的個數為64,則在numPairsInMemory > trackMemoryThreshold前currentMap還是會發生幾次grow。當numPairsInMemory > trackMemoryThreshold時,則currentMap本次到達growThreshold時就要進行shouldSpill的判斷。

  • 當這個結果是false時,則未達到需要進行shouldSpill判斷的條件,則直接currentMap.changeValue(key, update)將kv更新到currentMap中。
  • 當這個結果是true時,則需要進行shouldSpill到disk判斷。


shouldSpill判斷的具體步驟為:根據maxMemoryThreshold以及目前正在啟動並執行其他task的currentMap大小 來判斷是否有足夠記憶體來讓currentMap的大小翻倍。

  val threadId = Thread.currentThread().getId  val previouslyOccupiedMemory = shuffleMemoryMap.get(threadId)  val availableMemory = maxMemoryThreshold -  (shuffleMemoryMap.values.sum - previouslyOccupiedMemory.getOrElse(0L))  // Assume map growth factor is 2x  shouldSpill = availableMemory < mapSize * 2

 

  • shouldSpill=false:讓 shuffleMemoryMap(threadId) = mapSize * 2, 即讓當前任何佔用2倍的空間。 currentMap的擴容會發生之後的currentMap.changeValue裡。
  • shouldSpill=true: 進行spill操作。



spill
將currentMap寫到disk上。具體步驟為:
1、通過currentMap.destructiveSortedIterator(KCComparator)將currentMap變成按key的hashCode進行排序的數組,並封裝成相應的Iterator。
2、遍曆1得到的Iterator,將kv write到DiskBlockObjectWriter中,但寫入量objectsWritten達到serializerBatchSize(批量寫到檔案的記錄數,由spark.shuffle.spill.batchSize控制,預設是10000,太小的話則在寫檔案時效能變低)時進行writer.flush()將之前的資料寫到檔案中,並將spill到磁碟的大小記錄到batchSizes中,batchSizes記錄了每次spill時的資料大小,便於之後的讀取(因為批量寫到磁碟時經過了壓縮序列化,所以讀取時要讀取與寫時等量的資料才可以正常的解壓還原序列化,所以batchSizes十分重要)
3、不斷重複2直到將currentMap的資料全部寫到檔案中。
4、產生一個DiskMapIterator(用於讀取檔案資料),將加到spillMaps中。這裡會將batchSizes放到DiskMapIterator並於從檔案讀取資料。
4、reset工作:

  • 產生新的currentMap。
  • shuffleMemoryMap(Thread.currentThread().getId)=0即將使用的currentMap容量清0。
  • numPairsInMemory重設為0.



iterator
一般是在RDD的compute中會調用該方法,作為RDD操作的Iterator, 即其下遊RDD可以為此為資料來源。

  • 當spillMaps為空白,即只有currentMap,從未spill到disk時,直接調用currentMap.iterator()
  • 當spillMaps不空時,則要進行外排過程ExternalIterator(和Hadoop的reduce的sort階段以及hbase的memStore、storeFile遍曆類似)




ExternalIterator
外排的主要思想:各個Iterator已經按key.hashcode排好序,利用一個優先隊列儲存各個Iterator, hasNext是看優先隊列是否有元素,next則是返回當前最小hashcode的最小key對應的所有value合并成的combine,即(minKey,minCombiner)。
具體實現如下:
1、各個Iterator: 由currentMap.destructiveSortedIterator形成的Iterator以及spillMaps中的DiskMapIterator
2、優先隊列為mergeHeap = new mutable.PriorityQueue[StreamBuffer],StreamBuffer的主要方法如下:

 

private case class StreamBuffer(iterator: Iterator[(K, C)], pairs: ArrayBuffer[(K, C)])  extends Comparable[StreamBuffer] {  def isEmpty = pairs.length == 0  // Invalid if there are no more pairs in this stream  def minKeyHash = {    assert(pairs.length > 0)    pairs.head._1.hashCode()  }  override def compareTo(other: StreamBuffer): Int = {    // descending order because mutable.PriorityQueue dequeues the max, not the min    if (other.minKeyHash < minKeyHash) -1 else if (other.minKeyHash == minKeyHash) 0 else 1  }}}

 

  


StreamBuffer存的是某個Iterator,以及該Iterator按某個key.hashCode彙總的結果。其compareTo決定了其在mergeHeap的位置。StreamBuffer的key.hashCode都是一樣的,這樣minKeyHash可以從其儲存的資料集中隨便取一個就行。這裡會讓hashCode相同的兩個key同時存到這個StreamBuffer中,也就是key不相同,這裡會有問題嗎,後面的講到的mergeIfKeyExists會進行key是否相同的判斷。


3、將各個Iterator轉成StreamBuffer, 這個過程需要獲得各個Iterator最小的keyHash對應的所有kv對,具體實現是getMorePairs方法。

private def getMorePairs(it: Iterator[(K, C)]): ArrayBuffer[(K, C)] = {  val kcPairs = new ArrayBuffer[(K, C)]  if (it.hasNext) {    var kc = it.next()    kcPairs += kc    val minHash = kc._1.hashCode()    while (it.hasNext && kc._1.hashCode() == minHash) {      kc = it.next()      kcPairs += kc    }  }  kcPairs}

 


該方法十分簡單,就是獲得第一個key.hashCode即最小的minHash(因為Iterator已經按key.hashCode排好序),然後獲得和minHash相同的所有kv對。
4、hasNext:mergeHeap優先隊列是否為空白
5、next: 外排的核心邏輯。
a、mergeHeap.dequeue()將隊列頂最小的StreamBuffer出隊列並加到mergedBuffers(mergedBuffers為了記錄出隊的StreamBuffer,便於下一輪繼續加入)中,得到minHash,以及(minKey, minCombiner)。
b、然後要去剩下的StreamBuffer上獲得和minHash相同的kv對,並與(minKey, minCombiner)進行合并。從隊列頂不斷dequeue與minHash相同的StreamBuffer並加到mergedBuffers中,每取到一個StreamBuffer則進行value合并,合并具體調用mergeIfKeyExists。

private def mergeIfKeyExists(key: K, baseCombiner: C, buffer: StreamBuffer): C = {  var i = 0  while (i < buffer.pairs.length) {    val (k, c) = buffer.pairs(i)    if (k == key) {      buffer.pairs.remove(i)      //baseCombiner即b中的minCombiner。這裡mergeCombiners的原因是在currentMap中的updateFunc時產生的是Combiner      return mergeCombiners(baseCombiner, c)    }    i += 1  }  baseCombiner}

 

 


這裡只有與minKey相同的kv才會被選取與minCombiner進行合并且從對應的StreamBuffer中移除,否則仍保留。
c、遍曆mergedBuffers即dequeue的各StreamBuffer判斷其是否還有kv對,沒有的話則重新調用getMorePairs獲得下一波kv對。 然後將StreamBuffer再次enqueue到mergeHeap中進行重新排序。當然如果某個StreamBuffer還是沒kv對,則說明對應的Iterator已經遍曆完,不需要再加到mergeHeap中。
d、返回(minKey,minCombiner)

DiskMapIterator
從disk檔案讀取資料形成Iterator。
hasNext:是否讀到檔案末尾
next: 先調用nextBatchStream()將batchSizes.remove(0)即當前要讀的資料量的資料讀到bufferedStream中,然後每次next都從該緩衝中獲得kv對,當緩衝中資料取完時又調用nextBatchStream()重新從檔案批量讀取下塊資料

 

spark的外排:AppendOnlyMap與ExternalAppendOnlyMap

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在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.