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来使用已经优化过的成熟代码。

  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值