下面来看看更复杂的情况,比如,当调度器进行流水线执行(pipelining),或把多个 RDD 合并到一个步骤中时。当RDD 不需要混洗数据就可以从父节点计算出来时,调度器就会自动进行流水线执行。上一篇博文结尾处输出的谱系图使用不同缩进等级来展示 RDD 是否会在物理步骤中进行流水线执行。在物理执行时,执行计划输出的缩进等级与其父节点相同的 RDD 会与其父节点在同一个步骤中进行流水线执行。例如,当计算 counts 时,尽管有很多级父 RDD,但从缩进来看总共只有两级。这表明物理执行只需要两个步骤。由于执行序列中有几个连续的筛选和映射操作,所以这个例子中才出现了流水线执行。下图展示了计算 counts 这个RDD 时的两个执行步骤。
除了流水线执行的优化,当一个 RDD 已经缓存在集群内存或磁盘上时,Spark 的内部调度器也会自动截短 RDD 谱系图。在这种情况下,Spark 会“短路”求值,直接基于缓存下来的 RDD 进行计算。还有一种截短 RDD 谱系图的情况发生在当 RDD 已经在之前的数据混洗中作为副产品物化出来时,哪怕该 RDD 并没有被显式调用 persist() 方法。这种内部优化是基于 Spark 数据混洗操作的输出均被写入磁盘的特性,同时也充分利用了 RDD 图的某些部分会被多次计算的事实。
一个物理步骤会启动很多任务,每个任务都是在不同的数据分区上做同样的事情。任务内部的流程是一样的,如下所述。
(1) 从数据存储(如果该 RDD 是一个输入 RDD)或已有 RDD(如果该步骤是基于已经缓存的数据)或数据混洗的输出中获取输入数据。
(2) 执行必要的操作来计算出这些操作所代表的 RDD。例如,对输入数据执行 filter() 和map() 函数,或者进行分组或归约操作。
(3) 把输出写到一个数据混洗文件中,写入外部存储,或者是发回驱动器程序(如果最终RDD 调用的是类似 count() 这样的行动操作)。
归纳一下,Spark 执行时有下面所列的这些流程。
• 用户代码定义RDD的有向无环图
RDD 上的操作会创建出新的 RDD,并引用它们的父节点,这样就创建出了一个图。
• 行动操作把有向无环图强制转译为执行计划
当你调用 RDD 的一个行动操作时,这个 RDD 就必须被计算出来。这也要求计算出该RDD 的父节点。Spark 调度器提交一个作业来计算所有必要的 RDD。这个作业会包含一个或多个步骤,每个步骤其实也就是一波并行执行的计算任务。一个步骤对应有向无环图中的一个或多个 RDD,一个步骤对应多个 RDD 是因为发生了流水线执行。
• 任务于集群中调度并执行
步骤是按顺序处理的,任务则独立地启动来计算出 RDD 的一部分。一旦作业的最后一个步骤结束,一个行动操作也就执行完毕了。
在一个给定的 Spark 应用中,由于需要创建一系列新的 RDD,因此上述阶段会连续发生很多次。
三、查找信息
Spark 在应用执行时记录详细的进度信息和性能指标。这些内容可以在两个地方找到:Spark 的网页用户界面以及驱动器进程和执行器进程生成的日志文件中。
1、Spark网页用户界面
Spark 内建的网页用户界面是了解 Spark 应用的行为和性能表现的第一站。默认情况下,它在驱动器程序所在机器的 4040 端口上。
关于Spark网页用户界面推荐一个博客,写得很详细[看图说话] 基于Spark UI性能优化与调试——初级篇。还有一个转载后的博客spark的UI界面。
import org.apache.spark.SparkConf import org.apache.spark.SparkContext object Spark_8 { def main(args: Array[String]): Unit = { // 创建一个conf对象 val conf = new SparkConf() conf.set("spark.app.name", "My Spark App") conf.set("spark.master", "local[4]") // conf.set("spark.ui.port", "36000") // 重载默认端口配置 // 使用这个配置对象创建一个SparkContext val sc = new SparkContext(conf) sc.setLogLevel("WARN") // 设置日志显示级别 val input = sc.textFile("words.txt") // 读取输入文件 // 切分为单词并且删掉空行 如果大于0的话删除不掉空行 val tokenized = input.map(line=>line.split(" ")).filter(words=>words.size>1) val counts = tokenized.map(words=>(words(0),1)).reduceByKey((a,b)=>a+b) // 提取出日志等级并进行计数 // 缓存RDD counts.cache() println(input.toDebugString) // 通过toDebugString查看RDD的谱系 println("====================================================") println(tokenized.toDebugString) println("====================================================") println(counts.toDebugString) // countRDD已经缓存 第一次求值运行仍然需要两个步骤 counts.collect().foreach(println) println("====================================================") // 该次求值只有一个步骤 counts.collect().foreach(println) Thread.sleep(60000) // 为了访问 http://localhost:4040 线程睡眠 } }
在本地模式程序运行下里面的日志信息包含了Spark网页用户界面的URL。
19/04/21 20:23:12 INFO MemoryStore: MemoryStore started with capacity 884.7 MB 19/04/21 20:23:12 INFO SparkEnv: Registering OutputCommitCoordinator 19/04/21 20:23:12 INFO Utils: Successfully started service 'SparkUI' on port 4040. 19/04/21 20:23:12 INFO SparkUI: Bound SparkUI to 0.0.0.0, and started at http://192.168.11.1:4040 19/04/21 20:23:12 INFO Executor: Starting executor ID driver on host localhost
注意,如果DAG Visualization这儿没有图片显示的话,那说明是浏览器的问题。
我换了一个火狐浏览器,就能出来图片了。
2、驱动器进程和执行器进程的日志
在某些情况下,用户需要深入研读驱动器进程和执行器进程所生成的日志来获取更多信息。日志会更详细地记录各种异常事件,例如内部的警告以及用户代码输出的详细异常信息。这些数据对于寻找错误原因很有用。
四、关键性能考量
1、并行度
并行度会从两方面影响程序的性能。首先,当并行度过低时,Spark 集群会出现资源闲置的情况。比如,假设你的应用有 1000 个可使用的计算核心,但所运行的步骤只有 30 个任务,你就应该提高并行度来充分利用更多的计算核心。而当并行度过高时,每个分区产生的间接开销累计起来就会更大。评判并行度是否过高的标准包括任务是否是几乎在瞬间(毫秒级)完成的,或者是否观察到任务没有读写任何数据。
Spark 提供了两种方法来对操作的并行度进行调优。第一种方法是在数据混洗操作时,使用参数的方式为混洗后的 RDD 指定并行度。第二种方法是对于任何已有的 RDD,可以进行重新分区来获取更多或者更少的分区数。重新分区操作通过 repartition() 实现,该操作会把 RDD 随机打乱并分成设定的分区数目。如果你确定要减少 RDD 分区,可以使用coalesce() 操作。由于没有打乱数据,该操作比 repartition() 更为高效。如果你认为当前的并行度过高或者过低,可以利用这些方法对数据分布进行重新调整。
举个例子,假设我们从 S3 上读取了大量数据,然后马上进行 filter() 操作筛选掉数据集中的绝大部分数据。默认情况下, filter() 返回的 RDD 的分区数和其父节点一样,这样可能会产生很多空的分区或者只有很少数据的分区。在这样的情况下,可以通过合并得到分区更少的 RDD 来提高应用性能。
2、序列化格式
当 Spark 需要通过网络传输数据,或是将数据溢写到磁盘上时,Spark 需要把数据序列化为二进制格式。序列化会在数据进行混洗操作时发生,此时有可能需要通过网络传输大量数据。默认情况下,Spark 会使用 Java 内建的序列化库。Spark 也支持使用第三方序列化库 Kryo(https://github.com/EsotericSoftware/kryo),可以提供比 Java 的序列化工具更短的序列化时间和更高压缩比的二进制表示,但不能直接序列化全部类型的对象。几乎所有的应用都在迁移到 Kryo 后获得了更好的性能。
3、内存管理
在各个执行器进程中,内存有以下所列几种用途。
• RDD存储
• 数据混洗与聚合的缓存区
• 用户代码
对于默认缓存策略的另一个改进是缓存序列化后的对象而非直接缓存。我们可以通过MEMORY_ONLY_SER 或者 MEMORY_AND_DISK_SER 的存储等级来实现这一点。缓存序列化后的对象会使缓存过程变慢,因为序列化对象也会消耗一些代价,不过这可以显著减少 JVM 的垃圾回收时间,因为很多独立的记录现在可以作为单个序列化的缓存而存储。
4、硬件供给
提供给 Spark 的硬件资源会显著影响应用的完成时间。影响集群规模的主要参数包括分配给每个执行器节点的内存大小、每个执行器节点占用的核心数、执行器节点总数,以及用来存储临时数据的本地磁盘数量。
这篇博文主要来自《Spark快速大数据分析》这本书里面的第八章,内容有删减,还有本书的一些代码的实验结果。