引言:在多台机器上分布数据以及处理数据是Spark的核心能力,即我们所说的大规模的数据集处理。为了充分利用Spark特性,应该考虑一些调优技术。本文每一小节都是关于调优技术的,并给出了如何实现调优的必要步骤。
本文选自《Spark GraphX实战》。

1 用缓存和持久化来加速 Spark

  我们知道Spark 可以通过 RDD 实现计算链的原理 :转换函数包含在 RDD 链中,但仅在调用 action 函数后才会触发实际的求值过程,执行分布式运算,返回运算结果。要是在 同一 RDD 上重复调用 action 会发生什么?

RDD 持久化

  一般 RDD 不会保留运算结果,如果再次调用 action 函数,整个 RDD 链会重新 运算。有些情况下这不会有问题,但是对于许多机器学习任务和图处理任务,这就 是很大的问题了。通常需要多次迭代的算法,在同一个 RDD 上执行很多次,反复 地重新加载数据和重新计算会导致时间浪费。更糟糕的是,这些算法通常需要很长 的 RDD 链。
  看来我们需要另一种方式来充分利用集群可用内存来保存 RDD 的运算结果。 这就是 Spark 缓存(缓存也是 Spark 支持的一种持久化类型)。
  要在内存中缓存一个 RDD,可以调用 RDD 对象的 cache 函数。以下在 spark- shell 中执行的代码,会计算文件的总行数,输出文件内容 :

val filename = "..."
val rdd1 = sc.textFile(filename).cache
rdd1.count
rdd1.collect

如果不调用 cache 函数,当 count 和 collect 这两个 action 函数被调用时, 会导致执行从存储系统中读文件两次。调用了 cache 函数,第一个 action 函数(count 函数)会把它的运算结果保留在内存中,在执行第二个 action 函数(collection 函数)时,会直接在使用缓存的数据上继续运算,而不需要重新计算整个 RDD 链。 即使通过转换缓存的 RDD,生成新的 RDD,缓存的数据仍然可用。下面的代码会找出所有的注释行(以 # 开始的行数据)。

val rdd2 =rdd1.filter(_.startsWith("#"))
rdd2.collect

  因为 rdd2 源于已缓存的 rdd1,rdd1 已经把它的运算结果缓存在内存中了, 所以 rdd2 也就不需要重新从存储系统中读取数据。

注意:cache 方法作为一个标志表示 RDD 应当缓存,但并不是立即缓存。 缓存发生在当前 RDD 在下一次要被计算的时候。

持久化等级

  如上所述,缓存是其中一种持久化类型。下表列出了 Spark 支持的所有持久 化等级。

        

Spark 支持输出的小文件自动合并 spark小文件太多有什么影响_GraphX


  每个持久化等级都定义在单例对象 StorageLevel 中。例如,调用 rdd.persist(StorageLevel.MEMORY_AND_DISK)方法会把 RDD 设置成内存和磁盘缓 存。 cache 方法内部也是调用 rdd.persist(StorageLevel.MEMORY_ONLY)。

注意 :其他的持久化等级,如 MEMORY_ONLY2、MEMORY_AND_ DISK2 等,也是可用的。它们会复制 RDD到集群的其他节点上,以便 提供容错能力。这些内容超出了本书范围,感兴趣的读者可以看看 Petar Zec’ evic’ 和 Marko
Bonac’ i(Manning, 2016)的书 Spark in Action,这本书更 深入地介绍了 Spark 容错方面的内容。

图的持久化

  无论什么时候,通过 Graph 对象调用一些函数如 mapVertices 或 aggregateMessages, 这些操作都是基于下层的 RDD 实现的。
  Graph 对象提供了基于顶点 RDD 和边 RDD 方便的缓存和持久化方法。

在合适的时机反持久化

  虽然看起来缓存是一个应该被到处使用的好东西,但是用得太多也会让人过度依赖它。
  当缓存越来越多的 RDD 后,可用的内存就会减少。最终 Spark 会把分区数据从 内存中逐出(使用最少最近使用算法,LRU)。同时,缓存过多的 Java 对象,JVM 垃圾回收高耗是不可避免的。这就是为什么当缓存不再被使用时很有必要调用 un- persist 方法。对迭代算法而言,在循环中常用下面的方法调用模式 :

  • 调用 Graph 的 cache 或 persist 方法。
  • 调用 Graph 的 action 函数,触发 Graph 下面的 RDD 被缓存……
  • 执行算法主体的其余部分。
  • 在循环体的最后部分,反持久化,即释放缓存。

提示 :用Pregel API的好处是,它已经在内部做了缓存和释放缓存的操作。

何时不用缓存

  不能盲目地在内存中缓存 RDD。要考虑数据集会被访问多少次以及每次访问时 重计算和缓存的代价对比,重计算也可能比增加内存的方式付出的代价小。
  毫无疑问,如果仅仅读一次数据集,缓存 RDD 就毫无意义,这还会让作业运 行得更慢,特别是用了有序列化的持久化等级。

2.checkpointing

  图算法中一个常用的模式是用每个迭代过程中运算后的新数据更新图。这意味 着,实际构成图的顶点 RDD 亦或边 RDD 的链会变得越来越长。

定义 :当 RDD 由逐级继承的祖先 RDD 链形成时,我们说从 RDD 到 根 RDD 的路径是其谱系。

  下面清单所示的示例是一个简单的算法,可生成一个新顶点集并更新图。这个 算法迭代的次数由变量 iterations 控制。

        

Spark 支持输出的小文件自动合并 spark小文件太多有什么影响_Spark 支持输出的小文件自动合并_02


  上述代码每一次调用 joinVertices 都会增加一个新 RDD 到顶点 RDD 链中。 显然我们需要使用缓存来确保在每次迭代中避免重新计算 RDD 链,但这并不能改变一个事实,那就是有一个不断增长的子 RDD 到父 RDD 的对象引用列表。

  这样的后果是,如果运行迭代次数过多,运行的代码中最终会爆出 Stack- OverflowError 栈溢出错误。通常迭代 500次就会出现栈溢出。

  而由 RDD 提供并且被 Graph 继承的一个特性 :checkpointing,能解决长 RDD 谱系问题。下面清单中的代码示范了如何使用 checkpointing,这样就可以持续输出 顶点,更新结果图。

              

Spark 支持输出的小文件自动合并 spark小文件太多有什么影响_GraphX_03


  一个标记为 checkpointing 的 RDD 会把 RDD 保存到一个 checkpoint 目录,然 后指向父 RDD 的连接被切断,即切断了 lineage 谱系。一个标记为 checkpointing 的 Graph 会导致下面的顶点 RDD 和边 RDD 做 checkpoint。

  调用 SparkContext.setCheckpointDir 来设置 checkpoint 目录,指定一个 共享存储系统的文件路径,如 HDFS。

  如前面的代码清单所示,必须在调用 RDD 任何方法之前调用 checkpoint,这 是因为 checkpointing 是一个相当耗时的过程(毕竟需要把图写入磁盘文件),通常 需要不断地 checkpoint 避免栈溢出错误,一般可以每 100 次迭代做一次 checkpoint。

注意 :一个加速 checkpointing 的选择是 checkpoint 到 Tachyon(已 更名为 Alluxio),而不是checkpoint 到标准的文件系 统。Alluxio,来自 AMPLab,是一个“以内存为中心的有容错能力的分布式文件系统,它能让Spark 这类集群框架加速访问共享在内存中的文件”。

3 通过序列化降低内存压力

  内存压力(内存不够用)往往是 Spark 应用性能差和容易出故障的主要原因 之一。这些问题通常表现为频繁的、耗时的 JVM 垃圾回收和“内存不足”的错 误。checkpointing 在这里也不能缓解内存压力。遇到这种问题,首先要考虑序列化 Graph 对象。

定义 :数据序列化,Data serialization,是把 JVM 里表示的对象实 例转换(序列化)成字节流 ;把字节流通过网络传输到另一个 JVM 进程 中 ;在另一个 JVM 进程中,字节流可以被“反序列化”为一个对象实例。Spark用序列化的方式,可以在网络间传输对象,也可以把序列化后的字节流缓存在内存中。

  要用序列化,可以选用 persist 中下面的 StorageLevels :

  • StorageLevel.MEMORY_ONLY_SER
  • StorageLevel.MEMORY_AND_DISK_SER

序列化节省了空间,同时序列化和反序列化也会增加 CPU 的开销。

使用 Kryo 序列化

  Spark 默认使用 JavaSerializer 来序列化对象,这是一个低效的 Java 序列化框架,一个更好的选择是选用 Kryo。Kryo 是一个开源的 Java 序列化框架,提供了 快速高效的序列化能力。
  Spark 中使用 Kryo 序列 化,只需要设置 spark.serializer 参数为 org. apache.spark.serializer.KryoSerializer,如这样设置命令行参数 :

spark-shell --conf "spark.serializer=org.apache.spark.serializer.KryoSerializer"

  要是每次都这样设置参数,会很烦琐。可以在 $Spark_HOME/conf/spark-
defaults.conf 这个配置文件中,用标准的属性文件语法(用 Tab 分隔作为一行),把 spark.serializer 等参数及其对应的值写入这个配置文件,如下所示 :

spark.serializer org.apache.spark.serializer.KryoSerializer

  为保证性能最佳,Kryo 要求注册要序列化的类,如果不注册,类名也会被序列 化在对象字节码里,这样对性能有较大影响。幸运的是,Spark 对其框架里用到的 类做了自动注册 ;但是,如果应用程序代码里有自定义的类,恰好这些自定义类也 要用 Kryo 序列化,那就需要调用 SparkConf.registerKryoClasses 函数来手 动注册。下面的清单展示了如何注册 Person 这个自定义类。

         

Spark 支持输出的小文件自动合并 spark小文件太多有什么影响_RDD_04

检查 RDD 大小

  在应用程序调优时,常常需要知道 RDD 的大小。这就很棘手,因为文件或数 据库中对象的大小和 JVM 中对象占用多少内存没有太大关系。
  一个小技巧是,先将 RDD 缓存到内存中,然后到 Spark UI 中的 Storage 选项卡, 这里记录着 RDD 的大小。要衡量配置了序列化的效果,用这个方法也可以。