RDD, Resilient Distributed Dataset,彈性分散式資料集, 是Spark的核心概念。
對於RDD的原理性的知識,可以參閱Resilient Distributed Datasets: A Fault-Tolerant Abstraction for In-Memory Cluster Computing 和 An Architecture for Fast and General Data Processing on Large Clusters 這兩篇論文。
這篇文章用來記錄一部分Spark對RDD實現的細節。
首先翻譯一下RDD這個虛類的註釋
RDD是一個分散式彈性資料集, RDD是Spark的基本抽象,代表了一個不可變的、分割槽的、可以用於平行計算的資料集。這個類包括了所有RDD共有的
基本操作,比如map, filter, persist。另外
- org.apache.spark.rdd.PairRDDFunctions包括了只能用於key-value對型別的RDD的操作,
比如groupByKey和join。- org.apache.spark.rdd.DoubleRDDFunctions包括了只能用於Double型別RDD的操作,
- org.apache.spark.rdd.SequenceFileRDDFunctions包括了能被儲存為SequenceFile的RDD支援的操作。
通過隱式轉換,只要RDD的型別正確,相關的操作就自動可用。在內部,每個RDD都由五個主要屬性來表徵:
- 分割槽表(A list of partitions)
- 一個用於計算每個split的函式
- 對其它RDD的依賴
- 可選: 用於鍵值對型別的RDD使用的Partitioner
- 可選: 計算每個split時優先使用的location(+ 資料本地化, preferred locations) (比如一個HDFS檔案的block的位置)。
Spark裡所有的排程和執行都是依據這些方法,以此來允許每個RDD實現自己的方式來計算自己。使用者可以覆蓋這些方法來實現自己的RDD(比如,從一個新的儲存系統中讀取資料)。
新參考Spark paper來檢視關於RDD內部機制的更多細節。
RDD的5個主要屬性對應的程式碼主要為:
-
- 分割槽
protected def getPartitions: Array[Partition]
以及final def partitions: Array[Partition]
- 計算每個partition
def compute(split: Partition, context: TaskContext): Iterator[T]
- 對其它RDD的依賴 建構函式中的
deps: Seq[Dependency[_]]
以及protected def getDependencies: Seq[Dependency[_]] = deps
以及final def dependencies: Seq[Dependency[T]]
- kv型別RDD的partitioner
@transient val partitioner: Option[Partitioner] = None
- preferred location
protected def getPreferredLoations(split: Partition): Seeq[String] = Nil
以及final def preferredLocations(split: Partition): Seq[String]
- 分割槽
其中的這些final方法: partitions, dependencies, preferedLocations都是考慮了checkpoint的結果。可見,checkpoint機制會對這些屬性有所改變。
以下是對於這個註釋的內容的思考:
1. RDD把定語去掉了,就是資料集;但是Spark作為一個分散式計算的框架,“資料集的轉換”與“資料集”都是不可缺少的。Spark並沒有把transformation這個概念抽象成一個基類,在我們寫rdd.filter(func1).map(func2)這樣的語句的時候,得到的最終結果是一個RDD,而scheduler使用的也只是這個RDD,因此,func1和func2這樣的轉換操作,作為一種元資訊,肯定被RDD記錄,作為RDD的屬性。具體的講,轉換操作的資訊會被記錄在RDD的第二個屬性“一個用於計算每個split的函式”中。所以,RDD不僅是彈性分散式資料集,也包括了資料集之間進行轉換所需要的函式。
2. RDD的第三個屬性“對其它RDD的依賴”,提供了以下資訊:
a. 對這個RDD的父RDD的引用
b. 這個RDD的每個partition跟父RDD的partition的對映關係。
假設有RDD X和RDD Y, X可以轉換為Y, 即 X -> Y。這是一個鏈式的構造,要獲得Y,需要X和->。 ->即是轉換操作,被記錄於第二個屬性,那麼X在何處呢?X即是Dependency, 是RDD的第三個屬性。也就是說第二和第三個屬性,使得RDD成為一個鏈式結構, X -> Y -> Z,知道Z,就可以上溯到作為源頭的X,就能從X計算出Z來。這個就是為什麼我們在最後一個RDD上呼叫action, Spark就可以開始執行,而不再需要提供其它的RDD。
下面看一下Spark對於以上兩點具體的實現。
轉換邏輯的儲存
以常用的map操作為例 X -> Y, -> 在這裡就是map。
/** * Return a new RDD by applying a function to all elements of this RDD. */ def map[U: ClassTag](f: T => U): RDD[U] = withScope { val cleanF = sc.clean(f) new MapPartitionsRDD[U, T](this, (context, pid, iter) => iter.map(cleanF)) }
作為map引數的f和map的語義一起指明瞭從當前RDD到MapPartitionsRDD轉換的邏輯。而這個邏輯,作為引數被傳遞給MapPartitionsRDD,即 (context, pid, iter) => iter.map(cleanF))。下面看一下MapPartitionsRDD是如何儲存這個邏輯的。
private[spark] class MapPartitionsRDD[U: ClassTag, T: ClassTag]( prev: RDD[T], f: (TaskContext, Int, Iterator[T]) => Iterator[U], // (TaskContext, partition index, iterator) preservesPartitioning: Boolean = false) extends RDD[U](prev) { override val partitioner = if (preservesPartitioning) firstParent[T].partitioner else None override def getPartitions: Array[Partition] = firstParent[T].partitions override def compute(split: Partition, context: TaskContext): Iterator[U] = f(context, split.index, firstParent[T].iterator(split, context)) }
注意它的compute方法,首先,它呼叫了f, f就是我們在RDD的map方法中傳給MapPartitionsRDD構造器的函式。也就是說MapPartitionsRDD儲存了從父RDD轉換的邏輯, 即 ->
另外,注意compute方法中的 firstParent[T].iterator(split, context)。firstParent即是在map函式中傳進來的this, 也就是MapPartitionsRDD的父RDD, 即X。
-> 和 X就這樣被儲存在了Y, 即MapPartitionsRDD中。
關於Iterator
當compute方法被呼叫時,實際上會呼叫firstParent.iterator.map(cleanF)。那麼此時,父RDD的迭代器會進行迭代和map計算嗎?
答案是否,而且,可以看出Spark的RDD間的轉換和Scala的迭代器間的轉換是類似的,它們都可以認為是惰性的,即在x -> y中,儲存了x和->,只有在需要計算時才會計算。
下面是scala.collection.Iterator的map方法的程式碼
def map[B](f: A => B): Iterator[B] = new AbstractIterator[B] { def hasNext = self.hasNext def next() = f(self.next()) }
在這裡被返回的Iterator相當於y, 而呼叫map的Iterator相當於x。y持有對x的引用"self", 也持有轉換的函式f,這就使得x -> y的鏈是完備的,因此Iterator上的map, filter等操作也構成了一個鏈式結構。
由於Iterator的這種特性,使得RDD的計算過程構成一個由函式組成的管道,在不對中間RDD進行persist的操作時,初始RDD的每個元素經過所有轉換函式的處理後,再開始處理第二個元素;而不是所有元素都經過第一個函式處理後,形成一個資料集,這個資料集再進行轉換。
比如,有三個RDD, X -> Y -> Z,都是使用的map進行轉換,所使用的函式依次為f和g。
那麼Z的compute方法的呼叫過程就成為了X.iterator.map(f).map(g)。
依據Iterator的特點, Z的迭代器的hasNext方法會返回X.iterator.hasNext.hasNext, Z的迭代器的next方法會返回g(f(X.iterator.next))。
因此,在一系列轉過程中的中間的RDD如果沒有被persist, 是不會作為一個資料集存在的。
另外,需要注意
trait Iterator[+A] extends TraversableOnce[A]
注意這個TraversableOnce的含義。所以,在自己實現RDD時,需要確保compute方法被呼叫時,它所使用的父RDD的迭代器沒有在其它地方被使用過,不然一個已經被迭代過的迭代器再次被使用時,可能不會返回所有元素,或者乾脆就不能繼續迭代了(俺就曾經在compute里加了條日誌,記了下iteartor.size(), 就悲劇了)。
父子關係的儲存
先看下RDD的主構造器
abstract class RDD[T: ClassTag]( @transient private var _sc: SparkContext, @transient private var deps: Seq[Dependency[_]] ) extends Serializable with Logging {
RDD的這個構造器展示了Dependency對於RDD定義的重要作用。 Dependency包含了這個RDD對其父RDD的依賴,這個依賴不僅包括其父RDD是什麼,還包括子RDD的分割槽和父RDD的
分割槽之間的對應關係。
需要注意到,deps是一個Seq,
這說明單個的Dependency可能不足以描述父子RDD之間的依賴關係,得通過一系列的Dependency才能描述此關係。結合Dependency的定義,每個
Dependency只包含了一個父RDD的資訊,但是一個RDD可能依賴多個RDD,所以這裡用Seq[Dependency[_]]
是有必要的 。
如果使用
class NarrowDependency[T](parent: RDD[T], deps: List[List[Int]]) extends Dependency[T]{
override def _rdd: RDD[T] = parent
def getDependency(partition: Int) = deps(partition)
}
這種定義。在Dependency中提供子RDD的每個分割槽所依賴的父RDD的分割槽,那麼NarrowDependency和ShuffleDependency就都可以用這一種方式來定義。
但是,Spark中卻把NarrowDependency和ShuffleDependency分開定義,是為了區分什麼呢?
- 或許是在NarrowDependency的定義中是定義的每個父RDD的分割槽被哪一個子RDD的分割槽依賴。
- 或許是在ShuffleDependency中不僅要提供子RDD的每個分割槽的依賴,還要提供父RDD的每個分割槽被哪些子RDD的分割槽依賴,這樣進行shuffle時,才好由父RDD
的分割槽計算出對於不同子RDD分割槽的資料。
let us see see.
ShuffleDependency
之所以不像俺想的那樣,是因為ShuffleDependency包括了與shuffle有關的更多的資訊,這些資訊包括:
- partitioner 決定父RDD的每個record進入哪個子RDD分割槽。同時,它包含了reduce的個數的資訊。
- aggeragator 可選,對value進行聚合
- mapSideCombine 是否要在map側呼叫aggeragator,這是一個布林型別值
- keyOrdering 可選,決定key的順序,用來對key排序。
- serializer ?可選,或許是用來對key-value做序列化的,現在不能確定
以上是建構函式裡的資訊,此外ShuffleDependency的方法也提供了一些資訊:
*
shuffleId 還不確定有什麼用
*
shuffleHandle 提供與shuffle有關的資訊。目前只看到它的一個實現:
BaseShuffleHandler,構造器為(shuffleId, numTasks,
Dependency:[ShuffleDependency])
不確定其具體作用
這些資訊被shuffle過程使用,具體怎麼用,得看shuffle的實現。
NarrowDependency
而NarrowDependency包括的情況更少,因為如果用List[List[Int]]來表示NarrowDependency的話,會把NarrowDependency的範圍括大,比如多對多的關係也能用這種形式來表示。
Spark的實現裡,NarrowDependency是個abstract
class
,由不同的子類來應對具體的NarrowDependency的情況,每種情況用不同的方法來表示窄依賴。在NarrowDependency同
一個檔案裡,有兩種NarrowDepdency的子類。在其它的RDD實現中,還有會其它的NarrowDependency,比如CoalescedRDD在一個匿名內部類裡實現了自己的NarrowDependency。
- OneToOneDependency 這種情況父RDD的分割槽跟子RDD的分割槽是一致的,每個子RDD分割槽依賴於同樣索引號的父RDD的分割槽
- RangeDependency 子RDD的一個分割槽依賴於父RDD的某個連續的分割槽段,比如0-3, 4-5這種。
其實現為:
class OneToOneDependency[T](rdd: RDD[T]) extends NarrowDependency[T](rdd) {
override def getParents(partitionId: Int): List[Int] = List(partitionId)
}
可見,父RDD的index為partitionId的分割槽被同樣index的子RDD的分割槽依賴,父子RDD的分割槽是一對一的關係
class RangeDependency[T](rdd: RDD[T], inStart: Int, outStart: Int, length: Int)
extends NarrowDependency[T](rdd) {
override def getParents(partitionId: Int): List[Int] = {
if (partitionId >= outStart && partitionId < outStart + length) {
List(partitionId - outStart + inStart)
} else {
Nil
}
}
}
它述描了子RDD的一些分割槽對父RDD的一些分割槽依賴關係,在父子RDD對應的分割槽間是OneToOne的關係,但這種關係只對父子RDD的一個區間有效。比如,
子RDD從index為2開始的分割槽,以OneToOne的關係依賴於父RDD從index為8開始的分割槽,這種依賴關係對於連續的3個分割槽有效,即(子2依賴父8),
(子3依賴父9),
(子4依賴父10)
在UnionRDD中會使用RangeDependency
總結:
RDD儲存了DAG Scheduler進行排程所需的資訊(比如可以在RDD鏈中尋找ShuffleDependency來劃分Stage),也儲存了生成目標RDD所需要的計算邏輯。也就是說RDD對於Spark這個框架,在某種程度上相當於後設資料。可以看到,在driver往executor傳送的作為task的位元組陣列中就包括了RDD。
在ShuffleMapTask中,反序列化後的taskBinary為:
val (rdd, dep) = ser.deserialize[(RDD[_], ShuffleDependency[_, _, _])]( //返回結果是(RDD, ShuffleDependency) ByteBuffer.wrap(taskBinary.value), Thread.currentThread.getContextClassLoader)
在ResultTask中,反序列化後的taskBinary為:
val (rdd, func) = ser.deserialize[(RDD[T], (TaskContext, Iterator[T]) => U)](
ByteBuffer.wrap(taskBinary.value), Thread.currentThread.getContextClassLoader)
可以看到,RDD始終是作為計算邏輯的主要攜帶者被傳給executor。
而RDD能做到這些,就是因為它儲存了所需的資訊在自己的定義中, 前邊分析了一部分其實現的細節。RDD這個類的實現有很長很長的程式碼,也有更多有意思的細節需要進一步看一下。