Spark應用與調優筆記

一、Spark是如何在集羣上運行的

1. Spark的driver和executor並不是孤立存在的,cluster manager會將它們聯繫起來,集羣管理器負責維護一組運行Spark application的機器。集羣管理器也擁有自己的“driver”(即master節點,在yarn中是Resource Manager)和worker的抽象,核心區別在於集羣管理器管理的是物理機器,而不是進程。下圖展示了一個基本的集羣配置,圖左側的機器是集羣管理器的master節點,圓圈表示在每個物理節點上運行的進程,它們負責管理每個物理節點。因爲目前還沒有運行Spark application,所以這些進程只是集羣管理器的進程,並不是運行Spark應用程序的driver和executor:

當實際運行Spark應用程序時,會從集羣管理器那裏請求資源來運行driver和executor。在Spark應用程序執行過程中,集羣管理器如YARN將負責管理和運行執行應用程序的底層機器。接下來看看在運行應用程序時需要做的第一個選擇:選擇執行模式,有三種模式可供選擇:(1)stand-alone模式。(2)yarn-client模式。(3)yarn-cluster模式。

cluster模式是運行Spark應用程序的最常見方式。在集羣模式下,用戶將預編譯的JAR包、Python腳本或R語言腳本提交給集羣管理器。除executor進程外,集羣管理器還會在集羣內的某個worker節點上啓動driver進程(對於yarn-cluster模式,driver與application master在同一個節點上),這意味着集羣管理器負責維護所有與Spark應用程序相關的進程,如下圖所示:

client模式與cluster模式幾乎相同,只是Spark driver保留在提交application的客戶端機器上。這意味着客戶端機器負責維護Spark driver進程,並且集羣管理器維護executor進程。在下圖中,使用一臺集羣外的機器上提交Spark應用程序,這些機器通常被稱爲網關機器(gateway machines)或邊緣節點(edge nodes),可以看到Driver在集羣外部的計算機上運行,但executor位於集羣中的計算機上(黃點代表的YARN application master也在集羣中):

stand-alone模式與前兩種模式有很大不同:它不通過YARN等集羣資源管理器,而是Spark自己單獨維護Spark集羣的任務和狀態,一般不建議使用該模式運行生產級別的應用程序。

2. 對於Spark外部,接下來的例子假設一個集羣已經運行了四個節點,包括一個master節點和三個worker節點,且使用yarn-cluster模式。第一步是提交一個application,這是一個編譯好的JAR包或者庫,此時會向集羣管理器master節點發出請求,會爲Spark driver進程請求資源(會放在yarn集羣中,這裏提一下Linux服務器看來,yarn的NodeManager、container和Spark的executor等實際上就是平等的幾個進程,並不存在executor放在container或者NM裏這種說法),假設集羣管理器接受請求並將driver放置到集羣中的一個物理節點上(右側黃線,這裏的黃點是Resource Manager),之後提交application的客戶端進程退出(左側黃線),應用程序開始在集羣上運行,如下圖所示:

上面的提交過程需要在終端中運行以下命令:

./bin/spark-submit \
--class <main-class> \
--master <master-url> \
--deploy-mode cluster \
--conf <key>=<value> \
… # other options
<application-jar> \
[application-arguments]

現在driver進程已經被放到集羣上了(下圖右上角的橙框,它與yarn的AM在同一臺機子上),開始執行用戶代碼,此代碼必須包含一個初始化Spark集羣(如driver和若干executor)的SparkSession,SparkSession隨後將與集羣管理器的master節點(在yarn中是RM)通信(紅線),要求它在集羣上啓動Spark executor,集羣管理器隨後在集羣worker節點上啓動executor(黃線),executor的數量及其相關配置由用戶通過最開始spark-submit調用中的命令行參數設置,如下圖所示:

假如一切順利的話,集羣管理器就會啓動Spark executor,並將executor位置等相關信息發送給Spark driver。在所有程序都正確關聯之後,就成功構建了一個針對該application的“Spark集羣”。接下來Spark就可以開始順利地執行代碼了,如下圖所示,集羣的driver節點和executor節點相互通信、執行代碼、和移動數據,driver節點將任務安排到每個executor節點上,每個executor節點回應給driver節點這些任務的執行狀態,也可能回覆啓動成功或啓動失敗等:

Spark應用程序完成後,Spark driver會以成功或失敗的狀態退出,如下圖所示,然後集羣管理器會爲該Spark driver關閉該application對應的Spark集羣中的executor,此時可以向集羣管理器請求來獲知Spark應用程序是成功退出還是失敗退出:

3. 對於Spark內部,每個application由一個或多個Spark job組成,application內的一系列job是串行執行的(除非使用多線程並行啓動多個job),每當在程序中遇到一個action算子的時候,就會提交一個job任何Spark應用程序的第一步都是創建一個SparkSession,在交互模式中通常已經預先創建了,但在應用程序中用戶必須自己創建。一些老舊的代碼可能會使用new SparkContext這種方法創建,但是應該儘量避免使用這種方法,而是推薦使用SparkSession的構建器方法,該方法可以更穩定地實例化Spark和SQLContext,並確保沒有多線程切換導致的上下文衝突,因爲可能有多個庫試圖在相同的Spark應用程序中創建會話:

// Creating a SparkSession in Scala
import org.apache.spark.sql.SparkSession
val spark = SparkSession.builder().appName("Databricks Spark Example")
  .config("spark.sql.warehouse.dir", "/user/hive/warehouse")
  .getOrCreate()

在創建SparkSession後,就應該可以運行Spark代碼了。通過SparkSession可以相應地訪問所有低級的和老舊的Spark功能和配置。需要注意的是,SparkSession類是在Spark 2.X版本後才支持的,可能會發現較舊的代碼會直接爲結構化API創建SparkContext和SQLContext。

SparkSession中的SparkContext對象代表與Spark集羣的連接,可以通過它與一些Spark的低級API(如RDD)進行通信,在較早的示例和文檔中它通常以變量名sc存儲。通過SparkContext可以創建RDD、累加器和廣播變量,並且可以在集羣上運行代碼。大多數情況下,不需要顯式初始化SparkContext,應該通過SparkSession來訪問它。如果還是要指定SparkContext,可以通過getOrCreate方法來創建它:

import org.apache.spark.SparkContext
val sc = SparkContext.getOrCreate()

在下面這個例子中,將使用一個簡單的DataFrame執行三步操作:重新分區、執行逐個值的操作、然後執行聚合操作並收集最終結果:

# in Python
df1 = spark.range(2, 10000000, 2)
df2 = spark.range(2, 10000000, 4)
step1 = df1.repartition(5)
step12 = df2.repartition(6)
step2 = step1.selectExpr("id * 5 as id")
step3 = step2.join(step12, ["id"])
step4 = step3.selectExpr("sum(id)")
step4.collect() # 2500000000000

當運行這段代碼時,可以看到action觸發了一個完整的Spark作業。來看一下解釋計劃,可以在Spark UI中的上方SQL菜單上查看這些信息:

step4.explain()

== Physical Plan ==
*HashAggregate(keys=[],functions=[sum(id#15L)])
+-Exchange SinglePartition
  +-*HashAggregate(keys=[],functions=[partial_sum(id#15L)])
    +-*Project [id#15L]
      +-*SortMergeJoin [id#15L],[id#10L],Inner
      :-*Sort [id#15L ASC NULLS FIRST],false,0
        :+-Exchange hashpartitioning(id#15L,200)
        :+-*Project [(id#7L * 5) AS id#15L]
        :+-Exchange RoundRobinPartitioning(5)
        :+-*Range (2,10000000,step=2,splits=8)
      +-*Sort [id#10L ASC NULLS FIRST],false,0
        +-Exchange hashpartitioning(id#10L,200)
          +-Exchange RoundRobinPartitioning(6)
            +-*Range (2,10000000,step=4,splits=8)

當調用collect(或任何action)時將執行Spark job,它們由stage和task組成。如果正在本機上運行以查看Spark UI,可在瀏覽器上訪問localhost:4040。

4. 一般來說,一個action應該觸發一個Spark job,調用action總是會返回結果,每個job被分解成一系列stage,其數量取決於需要進行多少次shuffle(即寬依賴)。Spark中的階段(stage)代表可以一起執行的task組,用來在多臺機器上執行相同的操作。一般來說Spark會嘗試將盡可能多的工作(即job內部儘可能多的transformation操作)加入同一個stage,但在遇到shuffle寬依賴操作之後會啓動新的stage。

一次shuffle操作意味着一次對數據的物理重分區,例如對DataFrame進行排序,或對從文件中加載的數據按key進行group by(這要求將具有相同key的記錄發送到同一節點),這種repartition需要跨executor的協調來移動數據。Spark在每次shuffle之後開始一個新stage,並按照順序執行各stage以計算最終結果。在上面的例子代碼中,會出現以下幾個stage和task:

(1)第一個stage,有8個task。

(2)第二個stage,有8個task。

(3)第三個stage,有6個task。

(4)第四個stage,有5個task。

(5)第五個stage,有200個task。

(6)第六個stage,有1個task。

前兩個stage是執行的range操作,它將創建DataFrame,默認情況下當使用range創建DataFrame時,它有8個分區。下一步是repartition,通過對數據的shuffle操作來改變分區的數量,這些DataFrame被shuffle成6個分區和5個分區,對應於stage 3和stage 4中的task數量。

stage 3和stage 4在每個DataFrame上執行,並且stage 4的末尾爲join操作(需要shuffle過程),這時有了200個task,這是因爲spark.sql.shuffle.partitions的默認值是200,這意味着在執行過程中執行一個shuffle操作時,默認輸出200個shuffle分區,可以通過改變這個值來改變輸出分區的數量。分區數量是一個非常重要的參數,它應該根據集羣中core的數量來設置,以下爲設置方法:

spark.conf.set("spark.sql.shuffle.partitions", 50)

對於分區數量的設置,一個經驗法則是分區數量應該大於集羣上executor的數量,這可能取決於worker負載相關的多個因素。如果在本機上運行代碼,則應該將分區數量設置得較低。對於可能有更多executor core可以使用的集羣來說,就應該設置的更多。無論分區數量設置成多少,整個stage都是並行執行的,系統可以分別對這些分區並行執行聚合操作,將這些局部結果發送到一個彙總節點,然後再在這些局部結果上執行最終的聚合操作獲得最終結果,再把該結果返回給driver。

Spark中的stage由若干task組成,每個task都對應於一組數據和一組將在單個executor上運行的transformation操作。如果數據集中只有一個大分區,將只有一個task;如果有1000個小分區,將有1000個可以並行執行的task。task是應用於每個分區的計算單位,將數據劃分爲更多分區意味着可以並行執行更多分區。雖然可以通過增加分區數量來增加並行性,但不是萬能的。

Spark中task和stage的一些重要執行細節也值得關注。第一個執行細節是Spark會自動以pipeline的方式一併完成連續的stage和task,例如map操作接着另一個map操作。另外一個執行細節是,對於所有的shuffle操作Spark會將數據寫入磁盤,並可以在多個job中重複使用它

5. 使Spark成爲一個著名內存計算工具的很重要一點就是,與MapReduce不同,Spark在將數據寫入內存或磁盤之前執行儘可能多的操作(stage時才寫入磁盤,一個stage內的中間結果都在內存中)。Spark執行的關鍵優化之一是流水線(pipelining),它在RDD級別或其以下級別上執行。通過流水線技術,一系列有數據依賴關係的操作,如果不需要任何跨節點的數據移動,就可以將這一系列操作合併爲一個單獨的stage

例如編寫一個基於RDD的程序,首先執行map操作,然後是filter操作,然後接着另一個map操作,這些操作不需要在節點間移動數據,所以就可以將它們合併爲同一個stage的task,即讀取每個輸入記錄,然後經由第一個map操作,再執行filter操作,然後再執行map操作。通過pipeline優化的計算要比每步完成後將中間結果寫入內存或磁盤要快得多。對於執行select、filter和select序列操作的DataFrame或SQL計算,也會同樣地執行流水線操作。

實際上流水線優化對用戶來說是透明的,Spark引擎會自動的完成這項工作。但是如果通過Spark UI或其日誌文件檢查應用程序,將看到Spark系統將多個RDD或DataFrame操作通過流水線優化合併爲一個執行stage。

偶爾可以觀察到的第二個屬性是shuffle數據的持久化。當Spark需要運行某些需要跨節點移動數據的操作時,例如reduceByKey操作,其中每個鍵對應的輸入數據需要先從多個節點獲取併合並在一起,此時處理引擎不再執行流水線操作,而是執行跨網絡的shuffle操作(此時開始跨stage,shuffle寬依賴是兩個stage的分界點)。

在Spark執行shuffle操作時,總是首先讓前一stage的task將要發送的數據寫入到本地磁盤的shuffle文件上,然後下一stage執行reduceByKey的task將從每個shuffle文件中獲取相應的記錄並執行某些計算任務。將shuffle文件持久化到磁盤上允許Spark稍晚些執行reduce階段的某些任務,例如沒有足夠多的executor同時執行分配的task,由於數據已經持久化到磁盤上,可以稍晚些執行某些task,另外在錯誤發生時,也允許計算引擎僅重新執行reduce task而不必重新啓動所有的輸入task

shuffle操作數據持久化有一個附帶作用,即在已經執行了shuffle操作的數據上運行新的job並不會重新運行shuffle操作“源”一側的task(即上一stage生產shuffle數據的task)。由於shuffle文件早已寫入磁盤,因此Spark知道可以直接使用這些已經生成好的shuffle文件來運行job的後一個stage,而不需要重跑之前的stage。在Spark UI和日誌中,可以看到標記爲“skipped”的預shuffle stage,這種自動優化可以節省在同一數據上運行多個job所花費的時間,當然如果想要獲得更好的性能,也可以使用DataFrame或RDD的cache()方法自己設置緩存,這樣可以精確控制哪些數據需要保存,並且控制保存到哪裏。

二、開發Spark應用程序

6. 可以使用sbt或Apache Maven來構建應用程序,這是兩個基於JVM的程序構建工具。如果使用sbt構建Scala應用程序,需要修改build.sbt文件來配置軟件包依賴信息。在build.sbt裏面包括以下幾個關鍵的信息需要設置:

(1)項目元數據(包名稱,包版本信息等)。

(2)在哪裏解決依賴關係。

(3)構建庫所需要的包依賴關係。

以下是Scala的built.sbt文件示例,注意必須指定Scala版本以及Spark版本:

name : = "example"
organization : = "com.databricks"
version : = "0.1-SNAPSHOT"
scalaVersion : = "2.11.8"
// Spark相關信息
val sparkVersion = "2.2.0"
// 包含Spark軟件包
resolvers += "bintray-spark-packages" at
"https: //dl.bintray.com/spark-packages/maven/"
resolvers += "Typesafe Simple Repository" at
"http: //repo.typesafe.com/typesafe/simple/maven-releases/"
resolvers += "MavenRepository" at
"https: //mvnrepository.com/"
libraryDependencies ++= Seq(
// spark內核
"org.apache.spark" %% "spark-core" % sparkVersion,
"org.apache.spark" %% "spark-sql" % sparkVersion,
// 這裏忽略文件其餘部分
)

現在已經定義了構建文件,可以將代碼添加到項目中,然後把源代碼放在Scala和Java目錄中,在源代碼文件中加入如下內容,包括初始化SparkSession、運行應用程序、然後退出:

object DataFrameExample extends Serializable {
    def main(args: Array[String]) = {
        val pathToDataFolder = args(0)
        // 創建SparkSession
        // 顯式配置
        val spark = SparkSession.builder().appName("Spark Example")
            .config("spark.sql.warehouse.dir", "/user/hive/warehouse")
            .getOrCreate()
        // 註冊用戶自定義函數
        spark.udf.register("myUDF",someUDF(_: String): String)
        val df = spark.read.json(pathToDataFolder + "data.json")
        val manipulated = df.groupBy(expr("myUDF(group)")).sum().collect()
            .foreach(x => println(x))
    }
}

注意這裏需要定義一個main,當使用spark-submit命令行將它提交給集羣時,它纔可以執行。現在來build它,可以使用sbt assemble命令來構建一個包含所有依賴項的“uber-jar”或“fat-jar”。對於某些部署來說這可能是簡單的方式,但是在某些其他情況下可能造成依賴衝突。更輕量級的方法是運行sbt package,它將把所有依賴關係收集到target文件夾中,但不會將它們全部打包到一個大的JAR中。可以將這個JAR包作爲spark-submmit的參數以在集羣上執行應用程序:

$SPARK_HOME/bin/spark-submit \
--class com.databricks.example.DataFrameExample \
--master local \
target/scala-2.11/example_2.11-0.1-SNAPSHOT.jar "hello"

7. 編寫PySpark應用程序與編寫普通的Python應用程序沒有區別,與編寫命令行應用程序非常相似。Spark沒有build的概念,所以要運行應用程序只需在集羣上執行python腳本即可。爲了便於代碼重用,通常將多個Python文件打包成包含Spark代碼的egg文件或ZIP文件。爲了包含這些文件,可以通過spark-submit的--py-files參數來添加要與application一起分發的.py,.zip或.egg文件。在運行代碼的時候,需要在Python中創建一個“Scala / Java main class”,指定一個文件作爲構建SparkSession的可執行腳本,這也是spark-submit命令所需要的一個主要參數:

# in Python
from __future__ import print_function
if __name__ == '__main__':
from pyspark.sql import SparkSession
spark = SparkSession.builder \
    .master("local") \
    .appName("Word Count") \
    .config("spark.some.config.option","some-value") \
    .getOrCreate()
print(spark.range(5000).where("id > 500").selectExpr("sum(id)").collect())

在Python中開發時,建議使用pip指定PySpark作爲依賴項,可以運行命令pip install pyspark來首先安裝它,然後像使用其他Python包的方式來使用它。在編寫代碼之後,就可以提交它並在集羣上執行了,需要調用spark-submit執行應用程序:

$SPARK_HOME/bin/spark-submit --master local pyspark_template/main.py

8. 編寫Java Spark應用程序就像編寫Scala應用程序一樣,關鍵區別就是如何指定依賴關係,這裏使用Maven來指定依賴關係,在這種情況下將使用以下格式,在Maven中必須添加Spark依賴包的repository標籤,以便指定從該地址獲取依賴關係:

<dependencies>
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-core_2.11</artifactId>
            <version>2.1.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-sql_2.11</artifactId>
            <version>2.1.0</version>
        </dependency>
        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-mllib_2.11</artifactId>
            <version>2.1.0</version>
        </dependency>
        <dependency>
            <groupId>graphframes</groupId>
            <artifactId>graphframes</artifactId>
            <version>0.4.0-spark2.1-s_2.11</version>
        </dependency>
    </dependencies>
    <repositories>
        <!-- list of other repositories -->
        <repository>
            <id>SparkPackagesRepo</id>
            <url>http://dl.bintray.com/spark-packages/maven</url>
        </repository>
</repositories>
<build>
        <plugins>
            <plugin>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.3</version>
                <configuration>
                    <source>1.8</source>
                    <target>1.8</target>
                </configuration>
            </plugin>
            <plugin>
                <!-- Build an executable JAR -->
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-jar-plugin</artifactId>
                <version>3.0.2</version>
                <configuration>
                    <archive>
                        <manifest>
                            <addClasspath>true</addClasspath>
                            <classpathPrefix>lib/</classpathPrefix>
                            <mainClass>main.example.SimpleExample</mainClass>
                        </manifest>
                    </archive>
                </configuration>
            </plugin>
        </plugins>
</build>

然後,只需遵循相關的Java示例來實際構建和執行代碼。現在創建一個簡單的例子來指定一個main類:

import org.apache.spark.sql.SparkSession;
public class SimpleExample {
    public static void main(String[] args) {
        SparkSession spark = SparkSession
            .builder()
            .getOrCreate();
        spark.range(1, 2000).count();
    }
}

然後通過使用mvn package命令來打包。

9. Spark的examples目錄中還包含幾個示例應用程序,可以使用SparkPi等類作爲測試類來嘗試一下各參數選項的作用:

./bin/spark-submit \
--class org.apache.spark.examples.SparkPi \
--master spark://207.184.161.138:7077 \
--executor-memory 20G \
--total-executor-cores 100 \
replace/with/path/to/examples.jar \
1000

在Spark根目錄下的/conf目錄中包含了一些屬性配置模板,可以將這些屬性設置爲應用程序的默認參數,或者在運行時指定它們。可以使用環境變量配置每個節點的屬性,例如可以通過conf / spark-env.sh腳本來配置每個節點的IP地址,另外可以通過log4j.properties配置日誌記錄屬性。SparkConf對象管理着所有的應用配置,首先需要使用import語句並創建一個SparkConf對象,如下面示例所示,在創建SparkConf對象之後,SparkConf對象將不可以改寫:

import org.apache.spark.SparkConf
val conf = new SparkConf().setMaster("local[2]").setAppName("DefinitiveGuide")
    .set("some.conf", "to.some.value")

上面的示例將本地集羣配置爲具有兩個線程,並指定了Spark UI中顯示的應用程序名稱,可以訪問Spark UI(driver的4040端口)來檢查配置是否正確,這些配置顯示在“Environment”菜單下,只有通過spark-defaults.conf、SparkConf或命令行明確指定的配置參數纔會出現在這裏,其他屬性會默認使用默認配置。

在給定的Spark應用程序中,如果多個並行job是從不同的線程提交的,它們可以同時運行。Spark job的意思是一個action以及執行該action所需要啓動的task。Spark的調度是完全線程安全的,使應用程序支持多個請求(例如來自多個用戶的查詢請求)。默認情況下,Spark遵循FIFO方式調度job,如果位於隊列頭的作業不需要使用整個集羣資源,後面的job可以立即開始,但如果隊列頭的job工作量很大,位於隊列後部的job可能會被延遲啓動。

也可以配置job之間公平共享集羣資源。Spark以輪詢(round-robin)方式調度各job並分配task,以便所有job獲得大致相等的集羣資源份額。這意味着當一個大job正在運行並佔用集羣資源時,新提交的小job可以立即獲得計算資源並被啓動,無需等待大job的結束再開始,所以可以獲得較短的響應時間,此模式最適合多用戶情況。

要啓用公平調度,需要在配置SparkContext時將spark.scheduler.mode屬性設置爲FAIR。公平調度程序還支持將job分組到job池中,併爲每個池設置不同的調度策略或優先級。可以爲更重要的作業創建高優先級作業池,也可以將每個用戶的工作組合在一起配置一個job池,併爲每個用戶分配相同的調度資源,而不管他們的併發作業有多少,這種方法類似Hadoop 的Fair Scheduler。

默認情況下,新提交的job會進入默認池,也可以通過SparkContext設置job要提交到的job池,即設置spark.scheduler.pool屬性,這可以按如下方法完成,假定sc是SparkContext:

sc.setLocalProperty("spark.scheduler.pool", "pool1")

設置此本地屬性後,此線程提交的所有job將使用此job池名稱。該設置是按線程進行配置的,以便讓代表同一用戶的線程可以配置多個job。如果想清除該線程關聯的job池,請將該屬性設置爲null。

三、部署Spark

10. Spark的Standaone集羣管理器是專門爲Apache Spark工作構建的輕量級平臺。Standaone集羣管理器允許在同一個物理集羣上運行多個Spark應用程序,它還提供了簡單的操作界面,也可以擴展到大規模Spark工作。Standaone模式的主要缺點是它的功能相對其他的集羣管理器來說比較有限,特別是集羣只能運行Spark job。不過如果只想快速讓Spark集羣運行,並且沒有使用YARN或Mesos的經驗,那麼Standaone模式是最合適的。

啓動Standaone集羣首先需要操控集羣包含的物理節點,也就是要啓動它們,確保可以通過網絡互相通信,並在這些機器上安裝正確版本的Spark軟件。之後有兩種方法可以啓動集羣:手動或使用內置的啓動腳本。這裏首先手動啓動一個集羣,第一步是使用以下命令在某臺機器上啓動主進程:

$SPARK_HOME/sbin/start-master.sh

當運行這個命令時,cluster manager主進程將在該機器上啓動,然後會在命令行打印出一個URI即spark:// HOST:PORT。可以在application初始化時將此URI用作SparkSession的主參數,還可以在master節點的Web UI上找到此URI,默認情況下的Web UI地址是http:// master-ip-address:8080。另外,可以登錄到每臺計算節點並使用該URI運行以下腳本來啓動worker節點,注意master節點必須在網絡上可訪問,並且master節點的指定端口也必須打開:

$SPARK_HOME/sbin/start-slave.sh <master-spark-URI>

只要在另一臺worker節點上運行它,就啓動了一個worker進程,這樣你就構建了一個包括一個master節點和一個worker節點的Spark集羣。

上面這個創建過程是手動的,幸運的是有腳本可以自動化這個過程。爲此,需要在Spark目錄中創建一個名爲conf/slaves的文件,該文件需要包含啓動worker進程的所有計算機主機名,每行一個。如果這個文件不存在,那麼集羣將會以local模式啓動。實際啓動集羣時,master節點將通過SSH訪問每個worker節點,默認情況下SSH並行運行,並要求配置無密碼(使用私鑰)訪問環境。如果沒有無密碼設置,則需要設置環境變量SPARK_SSH_FOREGROUND來順序地爲每個worker節點提供訪問密碼。設置slaves文件後,可以使用以下shell腳本啓動或停止集羣,這些腳本基於Hadoop的部署腳本,並且可在$ SPARK_HOME / sbin中找到:

#在執行腳本的機器上啓動master實例
$ SPARK_HOME/sbin/start-master.sh
#在conf / slaves文件中指定的每臺機器上啓動一個slave實例
$ SPARK_HOME / sbin/ start-slaves.sh
#按照配置文件,在指定機器上啓動一個master實例和在指定多臺機器上啓動多個slave實例。
$ SPARK_HOME / sbin/ start-all.sh
#停止通過bin / start-master.sh腳本啓動的master實例
$ SPARK_HOME / sbin/ stop-master.sh
#停止conf / slaves文件中指定的機器上的slave實例
$ SPARK_HOME / sbin/stop-slaves.sh
#停止配置文件指定機器上的master實例和slave實例
$SPARK_HOME/sbin/stop-all.sh

11. 將application提交給YARN時,與其他部署方式的核心區別在於,--master需要指定的是yarn而不是master節點的IP(在Standalone模式中需要master節點IP)。Spark將使用環境變量HADOOP_CONF_DIR或YARN_CONF_DIR來查找YARN配置文件,在將這些環境變量設置爲Hadoop的安裝目錄後,就可以運行spark-submit來提交應用程序。

有兩種部署模式可用於在YARN上啓動Spark:yarn-cluster模式將spark driver作爲由YARN集羣管理的進程(driver與AM在同一個節點上),客戶端在創建應用程序後退出;在yarn-client模式下,driver將運行在客戶端進程中,因此YARN只負責將executor的資源授予application,而不是維護master節點。另外值得注意的是,yarn-cluster模式下,Spark不一定在執行spark-submit命令的同一臺機器上運行,因此庫和外部jar必須手動配置或通過--jars命令行參數配置

如果想使用Spark從HDFS進行讀寫,則需要在Spark的classpath中包含兩個Hadoop配置文件:hdfs-site.xml(配置HDFS客戶端)和core-site.xml(設置默認的文件系統名稱)。這些配置文件常見的位置在/ etc / hadoop / conf中。爲了讓這些文件對Spark可見,要將$SPARK_HOME/spark-env.sh中的HADOOP_CONF_DIR設置爲包含配置Hadoop文件的位置,或者在啓動application時將其設置爲環境變量。

如果多個用戶需要共享集羣並運行不同的Spark應用程序,則根據集羣管理器的不同,可以使用不同的選項來管理和分配計算資源。最簡單的調度方式是資源靜態劃分,通過這種方法每個application被分配到一定量的資源,並在整個程序運行期間一直佔用這些資源。使用spark-submit命令可以設置一些選項來控制特定application的資源分配。另外可以打開動態分配功能,這樣可以根據當前未執行task的數量進行動態擴展和縮減計算資源。另外如果希望用戶能夠以細粒度的方式共享內存和計算資源,則可以在單個Spark application內進行線程調度來並行處理多個請求。

如果在同一個集羣上同時運行多個Spark application,Spark提供了一種可以根據工作負載動態調整應用程序佔用的資源的機制。也就是說在application不再使用資源時將資源返回給集羣,並在有資源需要時再次請求使用。如果多個application共享Spark集羣中的資源,資源的動態分配就尤爲重要。此功能在默認情況下處於禁用狀態,但是在主流集羣管理器上都支持。

使用動態分配功能有兩個要求:首先必須將spark.dynamicAllocation.enabled屬性設置爲true;其次要在每個worker節點上設置外部Shuffle服務,並將spark.shuffle.service.enabled屬性設置爲true。外部shuffle服務的目的是爲了允許終止執行進程而不刪除由它們輸出的shuffle文件。Spark會在某節點的本地磁盤上存儲shuffle輸出塊,以便它們可供所有進程使用,也就是說可以終止任意executor進程,而其他進程仍然可以訪問該被終止進程產生的shuffle輸出文件。

四、監控與調試

12. 在發生錯誤的時候,需要監控Spark job的執行情況以瞭解問題所在,下面闡述可以監控的組件,並概述一些監控選項,如下圖所示:

(1)Spark application和job。通過Spark UI和Spark日誌是最方便獲取監控報告的方式,這些報告包括Spark application的運行狀態信息,例如RDD transformation和查詢計劃的執行信息等。

(2)JVM。Spark在JVM上運行executor,因此下一個監視層次是監控虛擬機以更好地理解代碼的運行方式。JVM提供一些監視工具,如用於跟蹤堆棧的jstack,用於創建堆轉儲(heap-dumps)的jmap,用於報告時序統計信息的jstat,以及用於可視化JVM屬性的jconsole。

(3)操作系統/主機。JVM運行在操作系統上,監視這些機器的運行狀態也很重要,包括諸如CPU、網絡、I/O等。這些信息通常在集羣級監控方案中也會報告,但是可以使用更專業的工具包括dstat,iostat和iotop。

(4)集羣。一些流行的集羣級監控工具包括Ganglia和Prometheus。

當監控一個Spark application時,最需要注意的是Driver進程,application的所有狀態都會在driver進程上有所反映,如果只能監控一臺機器或一臺JVM,那首選就是driver節點。當然瞭解Executor的狀態對於監控Spark job也非常重要,Spark提供一個基於Dropwizard Metrics Library的可配置指標監控系統,它的配置文件一般在$SPARK_HOME/conf/metrics.properties中指定,可以通過更改spark.metrics.conf配置屬性來自定義配置文件位置,這些監控指標可以輸出到包括Ganglia等多種不同的監控系統。

要更改Spark的日誌級別,只需運行以下命令:

spark.sparkContext.setLogLevel("INFO")

13. Spark UI提供了一種可視化的的方式,在Spark和JVM級別來監視運行中的application以及Spark工作負載的性能指標。每個運行的SparkContext都將啓動一個Web UI,默認情況下在端口4040,例如在本地模式下運行Spark時,通過訪問http:// localhost:4040即可在本地計算機上查看WebUI。如果運行多個application,它們將各自啓動一個Web UI並累加端口號(4041,4042,…),集羣管理器如yarn還會從它自己的UI鏈接到每個application的Spark UI。UI中的可監控菜單如下所示:

(1)Jobs菜單對應Spark job。

(2)Stages菜單對應各個stage(及其相關task)。

(3)Storage菜單包含當前在Spark application中緩存的信息和數據。

(4)Environment菜單包含有關Spark application的配置等相關信息。

(5)Executors菜單提供application中每個executor的詳細信息。

(6)SQL菜單對應提交的結構化API查詢(包括SQL和DataFrame)。

接下來通過下面的例子來監控一個給定查詢的狀態信息。打開一個新的Spark shell,運行下面的代碼:

#in Python
spark.read\
  .option("header", "true")\
  .csv("/data/retail-data/all/online-retail-dataset.csv")\
  .repartition(2)\
  .selectExpr("instr(Description, 'GLASS') >= 1 as is_glass")\
  .groupBy("is_glass")\
  .count()\
  .collect()

輸出結果爲三行不同的值。上面代碼啓動了一個SQL查詢,所以選擇Spark UI中的SQL菜單,之後應該看到類似下圖顯示的信息:

最先看到的是關於此查詢的彙總統計信息:

Submitted Time:2017/04/08 16:24:41
Duration:2 s
Succeeded Jobs:2

首先來看看代表Spark各stage聯繫的有向無環圖(DAG),每個藍色框代表Spark任務的一個stage,所有這些stage都代表一個Spark job。來仔細看看每個stage,以便能夠更好地理解每個stage發生的事情,下圖顯示了第一個stage的情況:

標記爲WholeStateCodegen的頂部框,代表對CSV文件的完整掃描。下方的藍框代表一次強制執行的shuffle過程,因爲調用了repartition()函數,它將尚未指定分區數的原始數據集劃分爲兩個分區。

下一個stage是投影操作(選擇/添加/過濾列)和聚合操作,需要注意的是,在下圖中輸出行數是6,這等於輸出行的數量與執行聚合操作時分區數量的乘積(3*2),這是因爲Spark在對數據進行shuffle以準備最後stage之前,會對每個分區執行聚合操作(該種情況下是基於hash的聚合):

最後一個stage聚合第二個stage所有分區的聚合結果,將來自兩個分區的最後三行結果合併,並作爲總查詢的結果輸出,如下圖所示:

進一步看看job的執行情況。在job菜單上的“Succeeded Jobs”中,如下圖所示,該job分爲三個stage,與SQL菜單上看到的三個stage相對應:

這些stage中單擊其中一個的標籤將顯示某個stage的詳細信息。在這個例子中有三個stage,分別有8個、2個和200個task。在深入分析細節之前,先了解一下爲什麼會出現這種情況。第一個stage有8個task,是因爲輸入的CSV文件是可拆分的,Spark對輸入數據均勻分佈到機器的不同內核上進行並行處理。第二個stage有2個task,是因爲代碼顯式調用了repartition()操作,並將數據移動到2個分區中。最後一個stage有200個task,是因爲默認的shuffle分區是200個。

接下來查看下一級的細節,點擊第一個stage後,其中包含8個task,如下圖所示:

Spark提供了大量關於該job在運行時所做task的詳細信息。在上圖頂部,SummaryMetrics部分顯示了關於各種指標的統計概要,要關注是否存在數據傾斜,在本例中各指標分佈比較均勻,沒有大的波動。在底部的表格中,可以檢查每個task對應executor的運行情況,這可以幫助判斷某個executor是否嚴重過載。Spark還提供了一組更詳細的性能指標(雖然大多數普通用戶可能不需要這些信息),在上圖中點擊ShowAdditional Metrics,然後根據想要查看的內容,選擇一些metric標準。

其餘的Spark UI菜單,包括Storage、Environment和Executors的功能都很容易理解。Storage菜單顯示有關集羣上緩存的RDD/DataFrame信息,可以幫助查看某些數據是否已經從緩存中取出。Environment菜單顯示有關運行環境的信息,包括有關Scala和Java的信息以及各種Spark集羣相關屬性。

14. 除了Spark UI之外,還可以通過REST API查詢Spark的狀態和性能指標(可訪問http://localhost:4040/api/v1),利用REST API也是在Spark之上構建可視化監視工具的一種方式。通過Web UI獲得的大多數信息也可以通過REST API獲得,只是它不包含SQL的相關信息。如果要根據Spark UI中提供的信息構建自己的監控平臺,REST API將是一個有用的工具。

通常情況下Spark UI僅在SparkContext運行時可用,爲了在application崩潰或結束後繼續通過Spark UI來排查問題,Spark提供了History Server工具,它允許重現Spark UI和REST API,但是前提是application配置爲保存事件日誌(event log)。使用History Server首先需要配置application將event log存儲到特定位置,這需要啓用spark.eventLog.enabled和配置spark.eventLog.dir來指定event log的存放位置。一旦存儲了event,就可以將History Server作爲獨立應用程序運行,並且會根據這些日誌自動重建Web UI。

15. 接下來看一些Spark運行過程中的常見問題和可行的解決方法:

(1)Spark job未啓動。這是經常遇到的,尤其是在部署一個新Spark集羣后或者遷移到一個新的硬件執行環境之後。表現形式爲除了driver節點,Spark UI不顯示集羣中其他executor節點的信息;或者Spark UI報告疑似不正確的信息。

這通常是由於集羣或application的資源需求配置不正確。在設置集羣的過程中,可能會錯誤地執行了某些配置,導致driver節點無法與executor節點進行通信,這可能是因爲沒有正確打開某個指定的IP或端口,這很可能是集羣和主機的配置問題。另一種可能是,application爲每個executor進程請求了過多的資源,以至於大於集羣管理器當前的空閒資源,在這種情況下driver進程將永遠等待executor進程啓動

因此解決方法爲:確保節點可以在指定的端口上相互通信,最好打開worker節點的所有端口,除非有嚴格的安全限制;同時確保Spark資源配置正確,並且確保集羣管理器已針對Spark進行了正確的配置。先嚐試運行一個簡單的application看看是否正常工作,可能每個executor進程被配置了太大的內存,超過了集羣管理器的內存資源配額。因此,先通過Spark UI檢查內存資源是否足夠,並且檢查spark-submit的內存配置

(2)執行前錯誤。這種錯誤可能發生在舊的程序可以運行,而在舊程序基礎上添加了一些代碼導致程序無法工作,表現形式爲命令不執行,並輸出了大量錯誤消息,或者通過Spark UI看不到任何job、stage或task的運行。

因此解決方法爲:在檢查並確認Spark UI的Environment菜單顯示的配置正確後,仔細檢查代碼,例如提供錯誤的輸入文件路徑或字段名稱等是常見的代碼錯誤;反覆仔細檢查以確認集羣的網絡連接正常,檢查driver節點、executor節點、以及存儲系統之間的網絡連接情況;也可能是庫或classpath的路徑配置問題,導致加載了外部庫的錯誤版本,試着一步步刪減代碼以縮小錯誤的範圍和定位錯誤,直到最後找到一個能夠重現問題的小代碼片段。

(3)執行期間錯誤。這可能是普通計劃作業在執行一定時間之後遇到的錯誤,也可能是一個交互式作業在用戶提交某個查詢之後產生的錯誤。表現形式爲一個Spark job在集羣上成功運行,但下一個job失敗;多步驟查詢中的某個步驟失敗;一個已經成功運行過的程序在第二次運行時失敗了;或難以解析錯誤信息。

因此解決方法爲:

a.檢查數據是否存在和輸入數據的格式是否正確,輸入數據可能會隨時間而改變,這有可能對應用程序產生意想不到的後果,或者是因爲查詢中引用的列名稱拼寫錯誤、引用的列、視圖或表不存在;

b.仔細讀stack trace錯誤跟蹤日誌,嘗試找到某些組件錯誤的線索(例如,錯誤發生在哪個運算符和哪個stage);

c.確保格式正確的輸入數據集來隔離問題,排除數據的問題之後,也可以嘗試刪除部分代碼邏輯,逐步縮減代碼,直到找到一個可以產生錯誤的較小代碼版本來定位問題;

d.如果job運行一段時間然後失敗,可能是由於輸入數據本身存在問題,可能特定行的數據模式(schema)不正確。例如,有時模式指定數據不應該包含空值,但實際數據確實包含空值,這可能會導致某些transformation操作失敗;

e.自己的代碼在處理數據時也可能會崩潰。在這種情況下Spark會顯示代碼拋出的異常,將在Spark UI上看到標記爲“FAILED”的task,並且還可以通過查看日誌來了解在失敗的時候正在做什麼task。可以在代碼中添加更多日誌打印以確定哪個正在處理的數據記錄有問題,這點會很有幫助。

(4)緩慢task或落後者(Straggler。此問題在優化程序時非常常見,這可能是由於數據沒有被均勻分佈在集羣各節點上導致數據傾斜,或者是由於某臺計算節點比其他計算節點速度慢(例如硬件問題)。表現形式爲stage中只剩下少數task未完成,這些任務運行了很長時間;或者在Spark UI中可以觀察到這些緩慢的task始終在相同的數據集上發生;或各stage都有這些緩慢task;擴大Spark集羣規模並沒有太大的效果,有些task仍然比其他task耗時更長;或者某些executor進程讀取和寫入的數據量比其他executor大得多。

因此解決方法爲:

a.最常見的原因是數據不均勻地分佈到DataFrame或RDD分區上(數據傾斜)。發生這種情況時,一些executor節點可能需要比其他executor節點更多的工作量,例如使用groupByKey後對應其中一個鍵的數據比其他鍵多得多。在這種情況下,當查看Spark UI時,會看到某些節點shuffle的數據比其他大得多。

b.嘗試增加分區數以減少每個分區被分配到的數據量。

c.嘗試通過另一種列組合來重新分區。例如當使用ID列進行分區時,如果ID是傾斜分佈的,那麼就容易產生緩慢task,或者當使用存在許多空值的列進行分區時,許多對應空值列的行都被集中分配到一臺節點上,也會造成緩慢task,在後一種情況下首先篩選出空值可能會有所幫助。

d.在代碼邏輯本身與調參合理的前提下,儘可能分配給executor進程更多的內存。

e.觀察有緩慢task的executor節點,並確定該節點在其他job上也總是執行緩慢task,這說明集羣中可能存在一個不健康的executor節點,例如是一個磁盤空間不足的節點。

f.對join操作或聚合(aggregation)操作的邏輯進行優化。通常情況下,聚合操作要將大量數據存入內存以處理對某個key的聚合操作,從而導致該executor比其他執行器要完成更多的工作。

g.檢查UDF是否在其對象分配或業務邏輯中有資源浪費不夠優化的情況,如果可能,嘗試將它們轉換爲DataFrame代碼。

h.打開推測執行(speculation)功能,這將爲緩慢task在另外一臺節點上重新運行一個task副本。如果緩慢問題是由於硬件節點的原因,推測執行功能將會有所幫助,因爲task會被遷移到更快的節點上運行。然而推測執行會消耗額外的資源,另外對於一些使用最終一致性的存儲系統,如果寫操作不是冪等的,則可能會產生重複冗餘的輸出數據。

i.由於Dataset執行大量的對象實例化並將記錄轉換爲UDF中的Java對象,這可能會導致大量GC。如果使用Dataset,需要查看Spark UI中的GC日誌和指標,以確定它們是否是導致緩慢task的原因。

(5)緩慢的聚合操作。表現形式爲在執行groupby操作時產生緩慢task,或聚合操作之後的job也執行的非常緩慢。因此解決方法如下:

a.在聚合操作之前增加分區數量可能有助於減少每個task中處理的不同key的數量;

b.增加executor的內存也可以緩解此問題。如果一個key擁有大量數據,這將允許executor進程更少地與磁盤交互數據並更快完成任務,儘管它可能仍然比處理其他key的executor進程要慢得多。

c.如果發現聚合操作之後的task也很慢,這意味着數據集在聚合操作之後可能仍然不均衡。嘗試調用repartition並對數據進行隨機重新分區;

d.確保涉及的所有過濾操作和select操作在聚合操作之前完成,這樣可以保證只對需要執行聚合操作的數據進行處理,避免處理無關數據。Spark的查詢優化器將自動爲結構化API執行此操作。

e.確保空值被正確地表示,建議使用Spark的null關鍵字,不要用””或”EMPTY”之類的含義空值表示。Spark優化器通常會在job執行初期來跳過對null空值的處理,但它無法爲用戶自定義的空值形式進行此優化。

f.一些聚合操作本身也比其他聚合操作慢。例如collect_list和collect_set是非常慢的聚合函數,因爲它們必須將所有匹配的對象返回給driver進程,所以在代碼中應該儘量避免使用這些聚合操作。

(6)緩慢join操作。Join和聚合都需要數據shuffle操作,所以它們在問題的表現形式和對問題的處理方式上類似。表現形式爲join操作的stage需要很長時間,可能是一項task或許多task這樣,或者join操作之前和之後的stage都很正常。

因此解決方案爲:

a.許多join操作類型可以轉化到其他更合適的join操作類型。

b.試驗不同的join操作順序是提高join操作性能的一個方法,如果其中一些join操作會過濾掉大量數據的話(如inner join),應該先執行這些join操作提早過濾數據

c.在執行join操作前對數據集進行分區,這對於減少在集羣之間的數據移動非常有用,特別是在多個join操作中使用相同的數據集時,嘗試不同的數據分區方法對提高join操作性能是值得一試的。但要注意數據分區是以數據的shuffle操作開銷爲代價的。

d.數據傾斜也可能導致join操作速度變慢。對於數據傾斜一般可以對key增加隨機數後綴進行skew join,或者用臨時表對數據傾斜部分進行預處理和過濾,當然增加executor節點的數量可能會有一定效果(先優化代碼本身,再調參)。

e.確保涉及的所有過濾操作和select操作在join操作之前完成,這樣可以保證只對需要執行聚合操作的數據進行處理,在join之前減少了需要處理的數據量。

f.與聚合操作一樣,使用null表示空值,而不是使用像“”或“EMPTY”這樣的語義空值表示

g.如果用戶自己知道要執行join操作的一個數據表很小,則可以強制採用廣播這個小數據表進行map join的方法實現join操作

(7)緩慢的讀寫操作。緩慢的I / O可能很難診斷原因,尤其是對於網絡文件系統。表現形式爲從HDFS或外部存儲系統上讀取數據緩慢,或者往網絡文件系統或blob存儲上寫入數據緩慢。

因此解決方法爲:

a.開啓推測執行(將spark.speculation設置爲true)有助於解決緩慢讀寫的問題。推測執行功能啓動執行相同操作的task副本,如果第一個task只是一些暫時性問題,推測執行可以很好地解決讀寫操作慢的問題。推測執行與支持數據一致性的文件系統兼容良好。但是,如果使用支持最終一致性的雲存儲系統例如Amazon S3,它可能會導致重複的數據寫入,因此要檢查使用的存儲系統連接器是否支持。

b.確保網絡連接狀態良好。Spark集羣可能因爲沒有足夠的網絡帶寬而導致讀寫存儲系統緩慢。

c. 如果在相同節點上運行Spark和HDFS,確保Spark與文件系統的節點主機名相同。Spark將考慮數據局部性(locality)進行task調度並優先在數據本地節點運行對應task(減少數據的網絡傳輸,就在數據所在節點跑任務),用戶可在Spark UI的“locality”列中看到該調度情況。

(8)driver的OutOfMemoryError錯誤或者無響應。這通常是一個非常嚴重的問題,它會使Spark application崩潰,這通常是由於driver進程收集了過多的數據,從而導致內存不足。表現形式爲driver端日誌裏有很多Full GC等內容,Spark UI響應慢,OOM報錯等。

因此解決方法爲:

a.代碼可能使用了諸如collect()之類的操作將過大的數據集收集到driver節點。儘量避免使用collect()等低性能代碼邏輯

b.可能使用了broadcast join但是廣播的數據太大。設置Spark的最大廣播連接數與廣播數據大小閾值可以更好地控制廣播數據的大小。

c.application長時間運行導致driver進程生成大量對象,並且無法釋放它們。Java的jmap工具可以打印堆內存維護對象數量的直方圖,這樣可以查看哪些對象正在佔用driver進程JVM的內存。但是要注意運行jmap將需要暫停該JVM。

d.在優化代碼邏輯與合理調參的前提下,可以適當增加driver進程的內存分配,讓它可以處理更多的數據。

e.由於兩者之間的數據轉換需要佔用JVM中的大量內存,因此如果使用其他語言如Python,JVM可能會發生內存不足的問題,查看該問題是否特定於選擇的語言,並將更少的數據帶回driver節點,或者將數據寫入文件而不是將其作爲內存中的對象返回

f.如果與其他用戶(例如,通過SQL JDBC服務器和某些Notebook環境)共享SparkContext,不要讓其他用戶在driver中執行可能導致分配大量內存的操作(例如在代碼中創建過大的數組,或者加載過大的數據集)。

(9)Executor的OutOfMemoryError錯誤或Executor無響應。表現形式爲在executor的錯誤日誌中發現OutOfMemoryErrors或GC信息、executor崩潰或無響應、某些節點上的緩慢task始終無法恢復等。

因此解決方案爲:

a.在合理優化代碼邏輯本身和調參的前提下,嘗試增加executor進程的可用內存和executor節點的數量。

b.嘗試通過相關的Python配置來增加PySpark worker節點的大小。

c.在executor的日誌中查找GC的錯誤消息。一些正在運行的任務,特別使用了UDF的application,可能會創建大量需要GC的對象,可以對數據進行重新分區以增加並行度,減少每個task處理的記錄數量,並確保所有executor獲得大體相同的數據量。

d.確保使用null代表空值,而不應該使用“”或“EMPTY”等來表示空值,避免因這種情況出現數據傾斜。

e.在使用RDD或Dataset時,由於對象實例化可能會導致產生executor的OOM錯誤,因此儘可能避免使用UDF,而儘可能使用Spark的結構化操作。

f.使用Java監視工具(如jmap)獲取executor的對象佔用內存情況的直方圖,查看哪類對象佔用的空間最多。

g.如果executor進程被放置在同時有其他組件和軟件運行的物理節點上,例如key-value存儲,則嘗試將Spark job與其他組件和軟件負載隔離。

(10)結果返回意外的空值。表現形式爲transformation操作後意外得到空值,或以前可以運行的生產job不再正確運行,或不再產生正確的結果。

因此解決方法爲:

a.在處理過程中數據格式可能已經更改,但是業務邏輯並沒有及時調整。也就是說以前的代碼不再有效,可根據業務情況調整代碼邏輯。

b.使用accumulator對記錄或某些類型的記錄進行計數,以及在跳過記錄時的解析或處理錯誤數。因爲它會幫助發現這樣的問題,即用戶認爲正在解析某種格式的數據,但實際上不是。大多數情況下,用戶在將原始數據解析爲某種格式時會將累加器放在其UDF中,並在那裏執行計數,這可以計算有效的和無效的記錄,並基於此信息調試程序。

c. 確保transformation操作解析成了正確的查詢計劃。Spark SQL有時會執行隱式強制類型轉換,可能會導致錯誤結果。例如SQL表達式SELECT 5 *“23”的結果爲115,因爲字符串“25”以整數形式轉換爲值25,但表達式SELECT 5 *“”的結果爲null,因爲將空字符串轉換爲整數會返回null。確保中間結果數據集具有正確模式(通過對數據使用printSchema檢查),並在最終查詢計劃中檢查所有CAST。

(11)磁盤空間不足錯誤。表現形式爲job失敗並顯示“no space left on disk”錯誤。

因此解決方法爲:

a.最簡單的方法是增加更多的磁盤空間。在雲環境中可以擴大集羣規模(增加計算節點數量),或者掛載額外的存儲。

b.如果集羣的存儲空間有限,由於數據傾斜某些節點的存儲可能會首先耗盡,因此對數據重新分區可能對此有所幫助。

c.還可以嘗試調整存儲配置。例如,有些配置決定日誌在被徹底刪除之前應該保留多長時間,可以縮短日誌的保留時間,以儘快釋放日誌佔用的磁盤空間

d.嘗試在相關機器上手動刪除一些有問題的舊日誌文件或舊shuffle文件,當然這只是個治標不治本的方法。

(12)序列化錯誤。表現形式爲job失敗並顯示序列化錯誤。

因此解決方法爲:

a.使用結構化API時,這種情況非常罕見。當處理無法序列化爲UDF或函數的代碼或數據時,或當處理某些無法序列化的奇怪數據類型時,就會出現該錯誤。如果正在使用Kryo序列化,要確認確實註冊了用到的類,以便它們能夠被序列化。

b.在Java或Scala類中創建UDF時,不要在UDF中引用封閉對象的任何字段,因爲這會導致Spark序列化整個封閉對象,這可能會產生錯誤。正確的做法是,將相關字段複製到與封閉對象相同作用域內的局部變量,然後再使用它們

與調試任何複雜的軟件一樣,建議基於調試原則一步一步地來找出錯誤所在:添加日誌記錄語句來確定程序在哪裏崩潰,以及確定每個stage處理的數據模式,使用隔離方法儘可能地將問題定位到最小的代碼範圍,並從那裏開始找問題。對於並行計算特有的數據傾斜問題,可以使用Spark UI快速瞭解每個task的負載。

五、性能調優

16. 如果Spark集羣網絡狀況很好,這將使Spark job運行的更快,因爲依賴於網絡性能的shuffle操作往往是Spark job中開銷最大的一個步驟。但是用戶通常難以優化已有的網絡環境,因此這裏更多地討論通過代碼優化或配置優化來提高Spark應用程序性能。對於Spark job,可以從多個方面進行優化,以下是一些優化方向:

(1)代碼級設計選擇(例如,選擇RDD還是DataFrame);(2)靜息數據;(3)join操作;(4)聚合操作;(5)處理中的數據;(6)application屬性。(7)executor節點的JVM。(8)worker節點。(9)集羣部署屬性。

當需要在結構化API中創建自定義transformation操作時,事情會變得更加複雜,比如RDD transformation或UDF。在這種情況下,因爲實際的執行方式,R和Python並不一定是最好的選擇。在定義跨語言函數時,嚴格保證類型和操作實現也更加困難。主要用Python來編寫應用程序,然後在必要的情況下用Scala編寫部分代碼,或者用Scala編寫UDF是一種很好的策略,它在整體可用性、可維護性和性能之間取得良好的平衡。

在所有語言環境下,DataFrame,Dataset和SQL在速度上都是相同的但是在定義UDF時,如果使用Python或R編寫的話會影響性能,用Java和Scala編寫的話會好一些。如果想純粹地優化性能,那麼應該嘗試使用DataFrame和SQL。雖然所有的DataFrame,SQL和Dataset代碼都可以編譯成RDD,但Spark的優化引擎會比手動編寫出的RDD代碼“更好”,並且這種避免手動編碼對工作量的節約可是相當可觀的。此外SparkSQL引擎發佈新的版本時,用戶自己新添加的優化操作可能都會失效了。

如果想使用RDD,推薦使用Scala或Java編寫,如果這不可行,建議將應用程序中調用RDD的次數限制到最低。這是因爲當Python運行RDD代碼時,大量數據將被序列化到Python進程或將從Python進程序列化出來。處理大規模數據時這個開銷非常大,並且也會降低穩定性

用戶可能會使用Kryo對自定義數據類型進行序列化,因爲它比Java序列化更緊湊,效率更高。但是使用Kryo進行序列化需要首先在程序中註冊將要序列化的類,這是非常不方便的。可以通過將spark.serializer設置爲org.apache.spark.serializer.KryoSerializer來使用Kryo序列化,還需要通過spark.kryo.classesToRegister顯式地註冊Kryo序列化器的類。要註冊自定義的類,要使用剛創建的SparkConf並傳入類的名稱:

conf.registerKryoClasses(Array(classOf[MyClass1], classOf[MyClass2]))

可以利用調度池(SchedulerPools)來優化Spark job的並行度,或利用動態分配或設置max-executor–cores來優化Spark application的並行度,還可以將spark.scheduler.mode設置爲FAIR,也可以設置--max-executor-cores指定應用程序需要的最大執行核心數量,指定此值可確保application不會佔滿集羣上的所有資源。還可以用集羣管理器更改默認值,方法是將配置spark.cores.max參數。

17. 有許多不同的文件格式,優化Spark job的最簡單方法之一,是在存儲數據時選擇最高效的存儲格式。一般而言,應該始終提倡結構化的二進制類型來存儲數據,儘管像CSV這樣的文件看起來結構良好,但它們解析速度很慢,而且通常存在邊界案例(EdgeCase)和某些痛點,例如在掃描大量文件時,不恰當地忽略換行符通常會導致很多麻煩。一般來說最有效的文件格式是Parquet,Parquet將數據存儲在具有列存儲格式的二進制文件中,並且還跟蹤有關每個文件的一些統計信息,以便快速跳過查詢那些不需要的數據。另外Spark支持Parquet數據源,使其與Spark可以很好地集成。

無論選擇哪種文件格式,都應確保它是“可拆分”(Splittable)的,這意味着不同的task可以並行讀取文件的不同部分,如果沒有使用可拆分的文件類型(比如JSON文件),會需要在單個機器上讀取整個文件,這樣大大降低了並行性。

文件的可分割能力主要取決於壓縮格式。ZIP文件或TAR歸檔文件不能被拆分,這意味着即使在ZIP中有10個文件,並且擁有10個core,也只有一個core可以讀取該數據,因爲無法並行訪問ZIP文件。相比之下,如果是gzip、bzip2或lz4壓縮文件通常是可拆分的。對於自己的輸入數據,使其可拆分的最簡單方法是將其作爲單獨的文件上傳,理想情況下每個文件不超過幾百兆。

表分區是指將文件基於關鍵字存儲在分開的目錄中,Hive支持表分區,許多Spark的內置數據源也支持。正確地對數據進行分區,可以使Spark在查詢特定範圍的數據時跳過許多不相關的文件。例如如果用戶在查詢中經常按“date”或“customerId”進行過濾,那就按這些列對數據進行分區,這將大大減少在大多數查詢中讀取的數據量,從而顯着提高速度。然而分區的一個缺點是,如果分割粒度太細可能會導致很多小文件,當列出所有小文件時這會產生巨大開銷。

分桶允許Spark根據可能執行的join操作或聚合操作對數據進行“預先分區”,這可以提高性能和穩定性,因爲分桶技術可以幫助數據在分區間持續分佈,而不是傾斜到一兩個分區。例如,如果在讀取後頻繁地根據某一列進行join操作,則可以使用分桶來確保數據根據這一列的值進行了良好的分區,這可以減少在join操作之前的shuffle操作,並有助於加快數據訪問。分桶通常與分區協同工作。

除了將數據分桶和分區之外,還需要考慮文件的數量和存儲文件的大小。如果有很多小文件,獲得文件列表和找到每個文件的開銷將非常大。例如,如果正在從HDFS讀取數據,每個數據塊大小(默認情況)最大爲128 MB,這意味着如果有30個文件,每個文件的實際內容大小爲5 MB,儘管2個文件塊就可以存儲下這些文件內容(內容總共150 MB),但是也需要請求30個塊。

擁有大量小文件將使調度程序更難以找到數據並啓動讀取任務,這可能會增加job的網絡開銷和調度開銷。可以減少文件數量讓每個文件更大,則可以減輕scheduler的開銷,但也會使task運行更長時間。在這種情況下,可以啓動比輸入文件個數更多的task來增加並行性,Spark會將每個文件分割並分配個多個task,前提是使用的是可拆分格式。一般來說建議調整文件大小,使每個文件至少包含幾十M的數據。要控制每個文件中有多少條記錄,可以爲寫入操作指定maxRecordsPerFile選項。

另一個在共享集羣環境中很重要的優化考慮是數據局部性(Data Locality)。如果在運行Spark的相同集羣機器上運行HDFS,則Spark將會嘗試調度與每個輸入數據塊在物理上更近的task,可以在Spark UI中看到標記爲“local”的數據讀取task。

18. Spark包含一個基於成本的查詢優化器,它在使用結構化API時根據輸入數據的屬性來計劃查詢。但是,基於成本的優化器需要收集(並維護)關於數據表的統計信息。這些統計信息包含兩類:表級和列級的統計信息。統計信息收集僅適用於表,不適用於任意的DataFrame或RDD。要收集表級統計信息,可以運行以下命令:

ANALYZE TABLE table_name COMPUTE STATISTICS

要收集列級統計信息,可以命名特定列:

ANALYZE TABLE table_name COMPUTE STATISTICS FOR
COLUMNS column_name1, column_name2, ...

列級別的統計信息收集速度較慢,這兩種統計信息可以幫助優化join操作、聚合操作、過濾操作,以及其他一些潛在的操作等(例如自動選擇何時進行broadcast join)。

配置Spark的外部Shuffle服務一般情況下可以提高性能,因爲它允許節點讀取來自遠程機器的Shuffle數據,即使這些機器上的Executor正忙於進行其他工作(例如GC),但這是以複雜性和維護爲代價的,在實際部署中這種策略可能並不值得。除了配置這個外部服務外,還有一些Shuffle配置比如每個executor的併發連接數。此外Shuffle的分區數量也很重要。如果分區太少那麼只有少量節點在工作,這可能會造成數據傾斜。但是如果有太多的分區,那麼啓動每一個task需要的開銷可能會佔據所有的資源。爲了更好地平衡,Shuffle中爲每個輸出分區設置至少幾十M的數據

19. 在執行Spark job的過程中,如果佔用過多的內存或GC運行過於頻繁,或者在JVM中創建了大量對象,而GC機制沒有對這些對象及時回收的情況下,就會產生內存壓力過大的情況。緩解此問題的一個策略是確保儘可能使用結構化API,這不僅會提高執行Spark job的效率,而且還會大大降低內存壓力,因爲結構化API不會生成JVM對象,SparkSQL只是在其內部格式上執行計算

GC調優的第一步是統計GC發生的頻率和時間,可以使用spark.executor.extraJavaOptions配置參數添加-verbose:gc –XX:+ PrintGCDetails –XX:+ PrintGCTimeStamps到Spark的JVM選項來完成此操作。下次運行Spark job時,每次發生GC時都會在worker節點的gclog日誌中打印相關信息。爲了進一步對GC調優,首先需要了解JVM中有關內存管理的一些基本信息:

(1)Java堆空間分爲兩個區域:新生代(Young)和老年代(Old),新生代用於保存短壽命的對象,而老年代用於保存壽命較長的對象。

(2)新生代被進一步劃分爲三個區域:Eden,Survivor1,以及Survivor2。

以下是對GC過程的簡單描述:

(1)當Eden區域滿了時,在Eden上運行一個小型垃圾回收系統,Eden和Survivor1區域中活躍的對象被複制到Survivor2區域中。

(2)Survivor1區域與Survivor2區域交換。

(3)如果一個對象足夠舊,或者如果Survivor2區域已滿,則該對象將移至老年代(Old)區域。

(4)最後,當老年代(Old)區域接近裝滿時,將調用Full GC。這涉及遍歷堆中的所有對象,刪除未引用的對象,以及移動其他對象以填充未使用的空間,期間暫停所有其他應用線程(Stop the world),所以它通常是最慢的GC操作

Spark中GC調優的目標是確保只有長壽命的數據對象存儲在老年代(Old)區域中,並且新生代(Young)區域的大小足以存儲所有短期對象,這將有助於避免Full GC處理在任務執行過程中創建的臨時對象。以下是可能有用的一些步驟:

(1)收集GC統計信息以確定它是否過於頻繁運行。如果在task完成之前多次調用Full GC,則意味着沒有足夠的內存可用於執行task,因此應該減少Spark用於緩存的內存大小(spark.storage.memoryFraction)。

(2)如果有很多小型GC但大型GC很少時,爲Eden區域分配更多內存空間將會有所幫助,可以將Eden區域的大小設置爲略大於每個task可能需要的內存總量,如果Eden區域的大小確定爲E,則可以使用選項-Xmn=4/3*E來設置Young區域的大小。(比例係數4/3也是爲了給Survivor區域騰出空間)例如,如果task正在從HDFS中讀取數據,則可以使用從HDFS中讀取的數據塊大小來估計該task使用的內存量,一般解壓縮之後塊的大小通常是壓縮塊大小的兩倍或三倍。所以如果想要三到四個task的工作空間,而HDFS塊大小爲128 MB,可以估計Eden區域的大小爲43128 MB。

(3)試着通過–XX:+UseG1GC選項來使用G1垃圾回收器替代傳統CMS垃圾回收器。如果垃圾回收成爲瓶頸,而且無法通過調整Young和Old區域大小的方法來降低它的開銷的話,那麼使用G1 GC可能會提高性能。注意對於較大executor的堆內存大小(HeapSize),使用-XX:G1HeapRegionSize增加G1區域大小非常重要。

監測GC的頻率和所花費的時間如何隨新設置的變化而發生變化。GC調優的效果取決於應用程序和可用內存量,設置Full GC的頻率可以幫助減少開銷,可以通過在application配置中設置spark.executor.extraJavaOptions來指定executor的GC選項

20. 如果需要加速某一個特定stage,應該做的第一件事就是增加並行度(在代碼邏輯性能合理的前提下)。通常如果一個stage需要處理大量的數據,建議分配集羣中每個CPU core至少有兩到三個task,可以通過spark.default.parallelism屬性來設置它,並同時根據集羣中的core數量來調整spark.sql.shuffle.partitions

提高性能的另一個常見手段是將filter儘可能移動到Spark application的最開始。有時這些過濾條件可以被放置在數據的源頭,這意味着可以避免讀取和處理與最終結果無關的數據,啓用分區和分桶也有助於實現此目的。儘可能早的過濾掉大量數據,這樣Spark job會運行的更快

重新分區的調用可能會導致Shuffle操作,但是通過平衡集羣中的數據可以從整體上優化job的執行,所以這個代價是值得的。一般來說應該試着Shuffle儘可能少的數據,因此如果要減少DataFrame或RDD中整個分區的數量,可以先嚐試使用coalesce方法(絕大部分情況下應該用repartition(),coalsece慎用可能會OOM等報錯),該方法不會執行數據Shuffle,而是將同一節點上的多個分區合併到一個分區中。repartition方法會跨網絡Shuffle數據以實現負載均衡,重新分區在執行join操作之前或者調用cache方法之前調用會產生非常好的效果。因此,重新分區是有開銷的,但它可以提高程序的整體性能和並行度。

如果job仍然緩慢或不穩定,可能需要嘗試在RDD級別上執行自定義分區,需要定義一個自定義分區函數,該函數將整個集羣中的數據組織到比DataFrame級別更高的精度級別。這種方法通常是不需要的,但它是提升性能的一個選擇。一般來說,儘量避免使用UDF是一個很好的優化策略。UDF有昂貴開銷,因爲它們強制將數據表示爲JVM中的對象,有時在查詢中要對一個記錄執行多次此操作,應該儘可能多地使用結構化API來執行操作

21. 在重複使用相同數據集的程序中,最有用的優化之一是緩存。緩存將DataFrame、數據表或RDD放入集羣中executor的臨時存儲區(內存或磁盤),這將使後續讀取更快。但緩存並不總是一件好事,這是因爲緩存數據會導致序列化,反序列化和存儲開銷,例如如果只打算在稍後的transformation操作中只處理這個數據集一次,緩存它只會降低速度。可以使用不同的存儲級別(StorageLevel)來緩存數據,指定要使用的存儲類型。

緩存cache()是一種惰性的操作,這意味着只有在訪問它們時纔會對其進行緩存。RDD API和結構化API在實際執行緩存方面有所不同,當緩存RDD時是緩存實際的物理數據(即比特位),當再次訪問此數據時Spark會返回正確的數據,這是通過RDD引用完成的。但是在結構化API中,緩存是基於物理計劃完成的,這意味着高效地將物理計劃存儲爲鍵而不是對象引用,並在執行結構化job之前執行查詢。這可能會導致混亂(緩存訪問衝突),因爲有時可能期望訪問原始數據,但由於其他人已經緩存了數據,該用戶實際上正在訪問它們的緩存版本,在使用此功能時要記住這一特性。

下圖的例子提供了一個簡單的過程說明,從一個CSV文件加載一個初始的DataFrame,然後使用transformation操作再從它派生出一些新的DataFrame,可以通過添加一行代碼來緩存它們,以避免重複計算原始的DataFrame(包括加載和解析CSV文件的重複計算):

現在來看看代碼,這裏還沒有使用緩存:

# in Python
# 原來的加載代碼並不會緩存DataFrame
DF1 = spark.read.format("csv")\
    .option("inferSchema", "true")\
    .option("header", "true")\
    .load("/data/flight-data/csv/2015-summary.csv")
DF2 = DF1.groupBy("DEST_COUNTRY_NAME").count().collect()
DF3 = DF1.groupBy("ORIGIN_COUNTRY_NAME").count().collect()
DF4 = DF1.groupBy("count").count().collect()

可以看到這裏“惰性”創建的DataFrame(DF1)以及另外三個訪問DF1中數據的DataFrame,所有下游的DataFrame都共享該父DataFrame(DF1),並在執行上述代碼時重複相同的工作。在這種情況下,它只是讀取和解析原始CSV數據,但這可能是一個相當繁重的過程。幸運的是緩存可以幫助加快速度,當緩存DataFrame時Spark會在第一次計算數據時將數據保存在內存或磁盤中,然後當任何其他查詢出現時,它們只會引用存儲在內存中的DataFrame而不是原始文件。可以使用DataFrame的cache()方法執行此操作:

DF1.cache()
DF1.count()

這裏使用count()這樣的action方法來觸發實際緩存數據的操作,這是因爲緩存本身是惰性的,僅在首次對DataFrame執行action操作時纔會真正地緩存數據。現在數據被緩存了,上面DF2到DF4的執行時間減少了一半以上,這對於迭代機器學習程序也很有用,因爲它們通常需要多次訪問相同的數據。Spark中的cache()默認將數據置於內存中,如果集羣的內存已滿則只緩存數據集的一部分。對於更多控制,還有一個persist方法,它通過StorageLevel對象指定緩存數據的位置,按內存、磁盤或兩者混合來緩存。

22. 對於Spark而言,等值連接(equi-join)是最容易優化的,因此應儘可能優先選擇equi-join。除此之外其他簡單的優化包括,通過改變join順序來讓過濾發生在inner join中,這可以極大的加快執行速度。另外,使用broadcast join可幫助Spark在創建查詢計劃時做出智能的計劃決策,避免笛卡爾連接或甚至避免full outer join通常是提高穩定性和性能優化的簡單方法。在做join操作之前使用前面的analyze語句收集數據表統計信息將有助於Spark做出智能的連接決策。此外,適當將數據分桶還可以幫助Spark在執行join時避免大規模的shuffle

大多數情況下,除了在聚合之前過濾數據之外,可以優化特定聚合操作的方法並不多。但是如果使用的是RDD,則精確地控制這些聚合的執行方式會非常有用(例如,用reduceByKey而不是groupByKey)。與此同時,這些策略還可以提高代碼的速度和穩定性。

廣播連接(Broadcast Join)和廣播變量(Broadcast Variable)也是很好的優化選擇。基本思路是,如果某一大塊數據被程序中的多個UDF訪問,則可以將其廣播讓每個節點上存儲一個只讀副本,並避免在每項job中重新發送此數據,例如把查找表(Lookup Table)或機器學習模型作爲廣播變量可能很有用,也可以通過使用SparkContext創建廣播變量來廣播任意對象,然後只需在程序中引用這些變量。

總結來說,在Spark性能優化中需要優先考慮的主要因素包括:

(1)儘可能地通過分區和高效的二進制格式來讀取較少的數據;

(2)確保使用分區的集羣具有足夠的並行度,並通過分區方法減少數據傾斜;

(3)儘可能多地使用諸如結構化API之類的高級API來使用已經優化過的成熟代碼。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章