[譯]Spark編程指南(二)

彈性分布式數據集(RDDs)

Spark圍繞著彈性分布式數據集(RDD)這個概念,RDD是具有容錯機制的元素集合,可以并行操作。有兩種方式創建RDDs:并行化驅動程序中已存在的集合,或者引用外部存儲系統中的數據集,例如一個共享文件系統,HDFS,HBase,或者任何支持Hadoop輸入格式的數據源。

并行集合

在驅動程序中已存在的集合上調用SparkContextparallelize方法可創建并行集合。通過拷貝已存在集合中的元素來生成可并行操作的分布式數據集。下面是創建并行集合的例子:

val data = Array(1, 2, 3, 4, 5)
val distData = sc.parallelize(data)

一旦創建完成,分布式數據集(distData)就可以被并行操作。例如,調用distData.reduce((a, b) => a + b)可以累加數組的元素。稍后介紹分布式數據集上的操作。

并行集合的一個重要參數是分區(partitions)的數量,用于切分數據集。Spark會為集群上的每個分區運行一個任務。通常你會想要為集群上的每個CPU分配2-4個分區。一般情況下,Spark會嘗試根據集群自動設置分區的數量。當然,你可能想手動設置,通過傳遞parallelize方法的第二個參數(如sc.parallelize(data, 10))可以實現。注意:有些代碼為了向下兼容使用了術語slices(和partitions一個意思)。

外部數據集

Spark可以通過任何Hadoop支持的存儲源創建分布式數據集,包括你本地的文件系統,HDFS, Cassandra,HBase,Amazon S3等等。Spark支持文本文件,SequenceFiles和任意其它Hadoop輸入格式

文本文件RDDs可使用SparkContexttextFile方法創建。這個方法需要文件的URI(一個本地機器上的路徑,或者一個hdfs://, s3n://等),文件內容讀取后是所有行的集合。下面是一個示例:

scala> val distFile = sc.textFile("data.txt")
distFile: org.apache.spark.rdd.RDD[String] = data.txt MapPartitionsRDD[10] at textFile at <console>:26

一旦創建完成,distFile就可以進行數據集操作。例如,可使用mapreduce操作累積所有行的大小,代碼是distFile.map(s => s.length).reduce((a, b) => a + b)

用Spark讀取文件時需要注意的是:

  • 如果使用本地文件系統的路徑,文件必須在worker節點上可以訪問。可以拷貝本地文件到所有woker節點,也可以使用網絡共享文件系統。
  • Spark中所有基于文件的輸入方法,包括textFile,都支持在目錄,壓縮文件和通配符。例如,可以使用textFile("/my/directory")textFile("/my/directory/*.txt")textFile("/my/directory/*.gz")
  • textFile方法還包含一個可選參數,用于控制文件的分區數量。默認情況下,Spark會為文件的每個block創建一個分區(HDFS默認的block大小是128MB),不過你可以通過可選參數設置更大的分區值。需要注意的是不能比blocks的數量還少。

除了文本文件,Spark的Scala API還支持多種數據格式:

  • SparkContext.wholeTextFiles可以讀取包含多個小文本文件的目錄,并且把每個文件作為(filename, content)返回。相比之下,textFile會把每個文件的每一行作為一條記錄返回。
  • 對于SequenceFiles,使用SparkContextsequenceFile[K, V]方法,KV是文件中的鍵值類型。KV應該是Hadoop的Writable接口的子類,如IntWritableText。此外,Spark允許為一些常見的Writables指定原生類型;例如,sequenceFile[Int, String]會自動讀取IntWritables和Texts。
  • 對于其它Hadoop輸入格式,可以使用SparkContext.hadoopRDD方法,以任意JobConf和輸入格式類,key類和value類作為參數。和使用輸入源設置Hadoop作業一樣設置上述參數。對于基于新MapReduce API(org.apache.hadoop.mapreduce)的輸入格式,可以使用SparkContext.newAPIHadoopRDD
  • RDD.saveAsObjectFileSparkContext.objectFile支持將RDD保存到由序列化的Java對象組成的簡單格式。雖然這不如專業格式Avro效率高,卻提供了一種保存RDD的簡單方式。

RDD操作

RDD支持兩種操作:transformations(從一個已存在的數據集創建新的數據集)和actions(在數據集上進行計算并將結果返回給驅動程序)。例如,map是一個transformation,用于將數據集中的每個元素傳遞給一個函數并且返回一個新的RDD作為結果。reduce是一個action,它會用某個函數將RDD的所有元素聚合然后將最終結果返回給驅動程序(有一個reduceByKey返回分布式數據集)。

Spark的所有transformation都是lazy的,不會立刻計算結果。相反,只是記錄應用到基礎數據集(如文件)的transformation。只有action要返回結果到驅動程序時才會計算transformation。這樣設計是為了Spark更高效。例如,map創建的數據集會在reduce中使用,之后會返回reduce的結果給驅動程序,而map創建的那個更大的數據集。

默認情況下,轉換得到的RDD,每次要對其執行action的時候重新計算。可以使用persist(或cache)方法將RDD保存到內存中,Spark會將RDD的元素存儲到集群中,下次查詢的時候會更快。Spark也支持將RDD存儲到磁盤上,或者跨多個節點復制。

基礎

RDD的基本操作,如下:

val lines = sc.textFile("data.txt")
val lineLengths = lines.map(s => s.length)
val totalLength = lineLengths.reduce((a, b) => a + b)

第一行從外部文件定義了一個基本RDD。這個數據集沒有加載到內存,也沒有做其它操作,lines僅僅是指向文件的指針。第二行定義了lineLengths作為map transformation的結果。lineLengths不是立即計算的,因為是懶加載的。最后,執行reduce action。這時候Spark會將計算分解成多個任務運行在獨立的機器上,每個機器會執行一部分map和局部的reduce,然后返回結果到驅動程序。

如果之后還想用lineLengths,在reduce之前執行以下語句:

lineLengths.persist()

這樣可以在第一次計算時將lineLengths保存到內存中。

函數傳遞到Spark

Spark的API很依賴給驅動程序傳遞函數來在集群上運行。有兩種推薦方式:

  • 匿名函數,用于短代碼。
  • 在全局單例對象中的靜態方法。例如,定義了object MyFunctions,然后傳遞MyFunctions.func1,如下:
object MyFunctions {
  def func1(s: String): String = { ... }
}

myRdd.map(MyFunctions.func1)

也可以在類的實例中(相對于單例對象)傳遞方法的引用,這需要傳遞包含方法的對象。例如:

class MyClass {
  def func1(s: String): String = { ... }
  def doStuff(rdd: RDD[String]): RDD[String] = { rdd.map(func1) }
}

如果創建了MyClass的實例并且調用doStuff,其中的map會引用實例的func1方法,所以整個對象都需要發送到集群。類似于rdd.map(x => this.func1(x))這種寫法。

類似地,訪問外部對象的字段也會引用整個對象:

class MyClass {
  val field = "Hello"
  def doStuff(rdd: RDD[String]): RDD[String] = { rdd.map(x => field + x) }
}

rdd.map(x => this.field + x)寫法等價。為了避免這個問題,最簡單的方法是拷貝field到本地變量,不進行外部訪問:

def doStuff(rdd: RDD[String]): RDD[String] = {
  val field_ = this.field
  rdd.map(x => field_ + x)
}

理解閉包

在集群上執行代碼時,理解變量和方法的作用范圍和生命周期是Spark的一個難點。RDD操作在作用范圍之外修改變量是經常出現的問題。下面是一個foreach()增加計數的例子,相同的問題也會出現在其它操作當中。

示例

看下面RDD元素求和的示例,代碼的行為會根據是否在同一個JVM上執行而有所不同。常見的例子是在local模式(--master = local[n])下運行與部署在集群(spark-submit提交給YARN)上運行進行對比。

var counter = 0
var rdd = sc.parallelize(data)

// Wrong: Don't do this!!
rdd.foreach(x => counter += x)

println("Counter value: " + counter)

本地模式 vs. 集群模式

上面代碼的行為是未定義的,可能無法按照預期工作。為執行作業,Spark會把RDD操作分拆成任務,每個任務由一個executor執行。執行之前,Spark會計算任務的閉包。閉包就是變量和方法(上面例子里是foreach()),它們對于在RDD上執行計算的executor是可以見的。這個閉包會被序列化并發送到每個executor

發送到每個executor的閉包變量是一個拷貝,在foreach函數中引用counter時,已經不是驅動節點上的counter了。驅動節點的內存中仍然有counter,但是對于所有executor已經不可見了!所有executor只能看到序列化閉包中的拷貝。counter最后的值還是0,因為所有的操作都在序列化閉包內的counter上執行。

本地模式中,在某些情況下,foreach函數會和驅動程序在同一個JVM上執行,這樣可以引用到原始的counter`并更新這個變量。

要保證有明確定義的行為,需要使用Accumulator
。當對變量的操作跨集群中的多個工作節點時,Accumulator提供一種安全更新變量的機制。后面介紹Accumulator時會詳細說明。

通常來說,閉包—構建像循環或局部定義的方法,不應該用于改變全局狀態。對于改變閉包外對象的行為,Spark沒有定義也不提供保證。有些代碼用本地模式執行,但那不是最常用的,這樣的代碼在分布式模式中不會按照預期執行。如果需要全局聚合,使用Accumulator

打印RDD的元素

另外一個問題是用rdd.foreach(println)rdd.map(println)打印RDD的元素。在一臺機器上,可以保證正常打印所有RDD的元素。但是在集群模式中,executor使用的是自己的stdout,不是驅動節點上的,所以在驅動節點的stdout是看不到打印結果的!要在驅動節點上打印所有元素,可以使用collect()方法將所有元素放到驅動節點:rdd.collect().foreach(println)。這樣做可能會導致驅動節點內存耗盡,因為collect()方法將整個RDD都放到一臺機器上了;如果只想打印部分元素,使用take()是一種安全的方式:rdd.take(100).foreach(println)。

操作鍵值對

RDD的大部分操作都可以處理任意對象類型,不過有幾個特殊操作只能操作鍵值對類型的RDD。最常用的就是分布式"shuffle"操作,如針對key進行分組或聚合元素。

在Scala中,這些操作對于包含Tuple2對象(語言內置的元組,可用(a, b)創建)的RDD自動可用。鍵值對操作在PairRDDFunctions類中可用,能夠自動處理包含元組的RDD。

例如,下面代碼在鍵值對上使用reduceByKey操作,計算每一行在文件中出現的次數:

val lines = sc.textFile("data.txt")
val pairs = lines.map(s => (s, 1))
val counts = pairs.reduceByKey((a, b) => a + b)

也可使用counts.sortByKey(),按字母序排序,使用counts.collect()將結果當做對象數據放到驅動程序中。

注意:當在鍵值對操作中使用自定義對象作為key時,必須保證有自定義的equals()方法以及與之匹配的hashCode()方法。更多細節,請參見Object.hashCode() documentation

Transformations

下面列出了常用的transformations。更多細節,請參見RDD API doc(Scala)和pair RDD functions doc(Scala)。

Transformation 描述
map(func) 通過將源數據的每個元素傳遞給func生成新的分布式數據集。
filter(func) 選擇func返回true的源數據元素生成新的分布式數據集。
flatMap(func) 和map類似,但是每個輸入項可對應0或多個輸出項(func應該返回Seq而不是單一項)。
mapPartitions(func) 和map類似,但是在RDD的每個分區上獨立執行,當運行在類型T的RDD上時,func必須是Iterator<T> => Iterator<U>類型。
mapPartitionsWithIndex(func) 和mapPartitions類似,但是還需要給func提供一個代表分區所以的整數值,當運行在類型T的RDD上時,func必須是(Int, Iterator<T>) => Iterator<U>類型。
sample(withReplacement, fraction, seed) 抽樣一小部分數據,withReplacement可選,使用給定的隨機數種子。
union(otherDataset) 返回新的數據集,包含源數據集和參數數據集元素的union。
intersection(otherDataset) 返回新的數據集,包含源數據集和參數數據集元素的intersection。
distinct([numTasks])) 返回新的數據集,包含源數據集中的不同元素。
groupByKey([numTasks]) 當在(K, V)數據集上調用時,返回(K, Iterable<V>)數據集。
注意:如果是為了執行聚合(如求和或求均值)進行分組,使用reduceByKeyaggregateByKey會獲得更好的性能。
注意:默認地,輸出的并行度取決于父RDD的分區數量。可以傳遞可選參數numTasks設置不同的任務數量。
reduceByKey(func, [numTasks]) 當在(K, V)數據集上調用時,返回(K, V)數據集,每個key的值使用給定的函數func進行聚合,func必須是(V,V) => V類型。像groupByKey一樣,reduce任務數量可通過第二個可選參數進行配置。
aggregateByKey(zeroValue)(seqOp, combOp, [numTasks]) 當在(K, V)數據集上調用時,返回(K, U)數據集,每個key的值使用給定的combine函數和"zero"值進行聚合。允許聚合值類型與輸入值類型不同,同時避免不必要的分配。像groupByKey一樣,任務數量可通過第二個可選參數進行配置。
sortByKey([ascending], [numTasks]) 當在(K, V)數據集上調用時,其中K是可排序的,返回按照key排序的(K, V)數據集,布爾參數ascending可指定升序或降序。
join(otherDataset, [numTasks]) 當在(K, V)和(K, W)數據集上調用時,返回(K, (V, W))數據集。支持外連接,leftOuterJoinrightOuterJoinfullOuterJoin
cogroup(otherDataset, [numTasks]) 當在(K, V)和(K, W)數據集上調用時,返回(K, (Iterable<V>, Iterable<W>)) 數據集。這個操作也叫做groupWith
cartesian(otherDataset) 當在類型T和U的數據集上調用時,返回(T, U)數據集。用于過濾大數據集后更有效地執行操作。
pipe(command, [envVars]) 通過shell命令將RDD以管道的方式處理每個分區,如Perl或bash腳本。RDD元素會被寫入進程的stdin并且作為字符串類型的RDD返回,按行輸出到stdout。
coalesce(numPartitions) 將RDD的分區數量減少到numPartitions。
repartition(numPartitions) 隨機Reshuffle RDD中的數據來創建更多或更少的分區并進行平衡。總是在網絡上shuffle所有數據。
repartitionAndSortWithinPartitions(partitioner) 根據給定的partitioner對RDD重新分區,在每個結果分區中,根據key進行排序。這個方法比repartition更高效,并且可以對每個分區進行排序,因為它會將排序放到shuffle machinery。

Actions

下面的表列出了常用的actions。更多細節,請參見RDD API doc(Scala)和pair RDD functions doc(Scala)。

Action 描述
reduce(func) 用func函數(需要兩個參數,返回一個值)聚合數據集的元素。func函數是可交換可結合的,這樣可以正確進行并行計算。
collect() 將數據集元素作為數據返回到驅動程序中。這個方法通常用于過濾器或其它操作返回的足夠小的數據子集。
count() 返回數據集中元素的數量。
first() 返回數據集中的第一個元素。(take(1)類似)
take(n) 返回一個數組,包含數據集中的前n個元素。
takeSample(withReplacement, num, [seed]) 返回num個隨機抽樣的元素組成的數組,withReplacement可選,可指定隨機數生成器的種子。
takeOrdered(n, [ordering] 返回RDD的前n個元素,使用自然順序或者自定義比較器。
saveAsTextFile(path) 將數據集作為文本文件(或文本文件集合)寫入到本地文件系統的指定目錄,HDFS,或者任何其它Hadoop支持的文件系統。Spark會對每個元素調用toString將其轉換成文件中的一行文本。
saveAsSequenceFile(path)
(Java and Scala)
將數據集作為Hadoop SequenceFile寫入到本地文件系統的指定目錄,HDFS,或者任何其它Hadoop支持的文件系統。在實現了Hadoop Writable接口的鍵值對類型的RDD上可用。在Scala中,對于可隱式轉換為Writable的類型也可用(Spark包含對基本類似的轉換,如Int,Double,String等)
saveAsObjectFile(path)
(Java and Scala)
使用Java序列化將數據集的元素寫入一種簡單格式,可使用SparkContext.objectFile()加載。
countByKey() 只在(K, V)類型的RDD上可用。返回hashmap (K, Int),Int只每個key的數量。
foreach(func) 在數據集的每個元素上執行func函數。這個方法通常用于更新Accumulator或者與外部存儲系統交互。
注意:修改foreach()外部Accumulator以外的變量可能會導致未定義的行為。前面閉包里面說過。

Spark RDD API也暴露了一些action的異步版本,如foreachAsync,立刻返回FutureAction給調用者,不會阻塞在action的計算上。這類方法用于管理或等待action的異步執行。通常需要在executor和機器之間拷貝數據,shuffle是一個復雜耗時的操作。

Shuffle操作

Spark中的一些操作會觸發被稱為shuffle的事件。shuffle是Spark重新分配數據的機制,讓數據在分區間有不同的分組。

背景

想要理解shuffle的細節,可參見reduceByKey操作。reduceByKey操作生成了一個新RDD,單個key的所有值都放到了元組(包含了key和這個key相關的所有值執行reduce函數后的結果)中。面臨的問題是,單個key的所有值不是一定放在同一個分區或者同一臺機器中,但是這些值需要一起計算結果。

在Spark中,數據通常不會為特定操作跨分區分布在需要的位置上。在計算時。單個任務在單個分區上執行—這樣,為了組織單個reduceByKey的reduce任務需要的所有數據,Spark需要執行一個all-to-all操作。需要從所有分區上找出所有key的所有值,然后將每個key的值跨分區集合起來結算處最后的結果—這就是shuffle

雖然shuffle之后每個分區的元素集合是確定的,分區的順序也是確定,但是元素是無序的。如果想要在shuffle之后得到有序數據,可使用:

  • mapPartitions,使用.sorted給每個分區排序
  • repartitionAndSortWithinPartitions,在重新分區的同時高效地排序
  • sortBy,生成全局排序的RDD
    會進行shuffle的操作包括repartition操作,如repartitioncoalesce'ByKey操作(除了計數),如groupByKeyreduceByKeyjoin操作,如cogroupjoin

性能影響

Shuffle是非常耗時的操作,因為需要磁盤I/O,數據序列化,以及網絡I/O。為了shuffle組織數據,Spark生成了任務集合,map任務集合負責祖師數據,reduce任務集合負責聚合數據。這個命名方式來自MapReduce,和Spark的mapreduce操作沒有直接關系。

單個map任務的結果一直放在內存中直到放不下為止。然后,這些結果會根據目標分區進行排序并寫到單個文件中。reduce任務會讀取相關的已排序的塊。

一些shuffle操作會消耗大量堆內存,因為它們在轉換前后使用內存數據結構來組織記錄。特別地,reduceByKeyaggregateByKey在map時創建這些數據結構,'ByKey操作在reduce時生成這些結構。當數據不適合放在內存中時,Spark會將這些表拆分到磁盤中,這樣會導致額外的磁盤I/O開銷以及增加GC。

shuffle也會在磁盤上生成大量中間文件。Spark 1.3,這些文件會保留到對應的RDD不再使用并且已經被回收。這樣做的話,在重新計算時shuffle文件不需要重新創建。如果應用程序一直保留這些RDD的引用或者GC不頻繁,那么shuffle文件可能會很長時間之后才會回收。這就意味著長時間運行的Spark作業可能會消耗大量磁盤空間。在配置Spark Context時,spark.local.dir配置參數用于指定臨時存儲目錄。

shuffle的行為可通過很多配置參數進行調整。具體參見Spark Configuration Guide中的‘Shuffle Behavior’。

RDD持久化

Spark最重要的功能之一就是在內存中跨操作持久化(或緩存)數據集。當持久化RDD時,每個節點會將其要計算的分區存儲到內存中,并且在數據集上進行其它action操作時重用內存中的數據。這樣之后的action操作可以執行得更快(通常超過10x)。緩存是迭代算法和快速交互的重要工具。

可使用persist()cache()方法將RDD標記為持久化。第一在action中進行計算時,持久化的RDD會保存到節點內存中。Spark的緩存是具有容錯機制的—如果RDD的任意分區丟失了,會使用最初創建它的transformations自動重新計算。

另外,每個持久化的RDD可使用不同的存儲級別進行存儲,例如,持久化數據集到磁盤,作為序列化的Java對象持久化到內存,跨節點復制。這些等級通過給persist()傳遞StorageLevel對象(Scala)進行設置。cache()方法使用默認存儲等級,即torageLevel.MEMORY_ONLY(在內存中存儲反序列化對象)。存儲等級如下:

存儲等級 描述
MEMORY_ONLY 在JVM中以反序列化的Java對象存儲RDD。如果RDD無法完整存儲到內存,一些分區就不會緩存,每次需要的時候重新計算。這是默認級別。
MEMORY_AND_DISK 在JVM中以反序列化的Java對象存儲RDD。如果RDD無法完整存儲到內存,無法存儲到內存的分區會放到磁盤上,需要的時候從磁盤讀取。
MEMORY_ONLY_SER
(Java and Scala)
以序列化的Java對象存儲RDD。通常這種方式比反序列化對象更節省空間,尤其是使用fast serializer,但是在讀取時需要消耗更多CPU。
MEMORY_AND_DISK_SER
(Java and Scala)
和MEMORY_ONLY_SER類似,但是無法存到內存的分區會放到磁盤,不會在需要時從新計算。
DISK_ONLY 只將RDD分區存儲到磁盤。
MEMORY_ONLY_2, MEMORY_AND_DISK_2等 和前面的等級一樣,不過每個分區會復制到兩個集群節點上。
OFF_HEAP (experimental) MEMORY_ONLY_SER類似,但是將數據存儲到off-heap memory。需要啟用off-heap內存。

注意:在Python中,使用Pickle庫保存的對象永遠都是序列化的,所以是否選擇序列化等級都沒關系。Python可用的存儲等級包括MEMORY_ONLYMEMORY_ONLY_2MEMORY_AND_DISKMEMORY_AND_DISK_2DISK_ONLYDISK_ONLY_2

Spark會自動持久化shuffle操作的中間數據(如reduceByKey),甚至不需要用戶調用persist。這樣做是為了防止shuffle期間如果有節點出錯了需要重新計算整個輸入。如果想要重用RDD,建議用戶手動調用persist

如何選擇存儲等級

Spark的存儲等級提供了在內存使用和CPU效率之間的不同權衡方案。推薦按照下面方法選擇存儲等級:

  • 如果RDD內存適合使用默認存儲等級(MEMORY_ONLY),那就選擇默認存儲等級。這種方式是CPU效率最高的,能夠讓RDD上的操作盡可能快遞執行。
  • 如果不適合,使用MEMORY_ONLY_SER并且選擇一個快的序列化庫讓對象存儲更節省空間,但是仍然可以合理地快速訪問。
  • 除非計算數據集非常耗時,或者它們過濾掉了大量數據,否則不要將數據集放到磁盤。不然的話,重新計算分區可能和從磁盤讀取是一樣快的。
  • 如果想要快速進行錯誤恢復,使用復制存儲等級(例如,如果使用Spark服務web應用請求)。所有存儲等級通過重新計算丟失數據提供全容錯機制,但是復制存儲等級可以讓任務繼續在RDD上執行,不需要等著丟失分區重新計算完成。

刪除數據

Spark會自動監控每個節點上的緩存使用情況,使用LRU將老數據分區清理掉。如果想要手動刪除RDD,使用RDD.unpersist()方法。


最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容