大数据篇--Spark调优

一、算子的合理选择

pom.xml 内容:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>com.huiq</groupId>
    <artifactId>HuiqTest</artifactId>
    <version>1.0-SNAPSHOT</version>

    <properties>
        <maven.compiler.source>1.8</maven.compiler.source>
        <maven.compiler.target>1.8</maven.compiler.target>
        <encoding>UTF-8</encoding>
        <scala.version>2.11.8</scala.version>
        <spark.version>2.4.0</spark.version>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.scala-lang</groupId>
            <artifactId>scala-library</artifactId>
            <version>${scala.version}</version>
        </dependency>

        <dependency>
            <groupId>org.apache.spark</groupId>
            <artifactId>spark-core_2.11</artifactId>
            <version>${spark.version}</version>
        </dependency>
    </dependencies>

    <build>
        <sourceDirectory>src/main/scala</sourceDirectory>
        <testSourceDirectory>src/test/scala</testSourceDirectory>
        <plugins>
            <plugin>
                <groupId>net.alchim31.maven</groupId>
                <artifactId>scala-maven-plugin</artifactId>
                <version>3.2.2</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>compile</goal>
                            <goal>testCompile</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>
1.map和mappartition:

  编写 Scala 程序模拟使用不同的算子将数据插入到数据中比较不同。

MapMappartitionApp:

package com.huiq.test

import org.apache.spark.rdd.RDD
import org.apache.spark.{SparkConf, SparkContext}

import scala.collection.mutable.ListBuffer

object MapMappartitionApp {

  def main(args: Array[String]): Unit = {
    val sparkConf = new SparkConf().setMaster("local[2]")
      .setAppName("MapMappartitionApp")

    val sc = new SparkContext(sparkConf)

    val students = new ListBuffer[String]()
    for (i<-1 to 100) {
      students += "stu: " + i
    }
    val stuRdd = sc.parallelize(students)

    // 需要把students存储到数据库中去
    myMap(stuRdd)

    sc.stop()
  }

  def myMap(rdd: RDD[String]): Unit = {
    rdd.map(x => {
      val connection = DBUtils.getConnection()
      println(connection + "------------>")

      // TODO... 保存数据到数据库中

      DBUtils.returnConnection(connection)
    }).foreach(println)
  }
}

  运行程序我们会发现使用 map 算子有多少个元素就会创建多少个 connection,这种性能肯定是不行的。

在这里插入图片描述
  换成 mapPartition 再运行程序:

在这里插入图片描述
总结map 是对 RDD 中的每个元素作用上一个函数(你假设有个 rdd 有100个分区,每个分区里有1万个元素,你算一下整个过程会开启多少个 connection?)。mapPartition 是将函数作用到 partition 之上的(如果遇到要写数据到数据库,一定要选择该模式)。

思考如果分区数量比较少导致一个分区中的数据量很大,这种场景下用 mapPartition 可能会有资源不够导致类似 OOM 的问题,遇到这种问题的时候可以手动调整 partition 的数量来解决,比如上面的代码可以设置成10个分区val stuRdd = sc.parallelize(students, 10)

2.foreach和foreachpartition:
package com.huiq.test

import org.apache.spark.rdd.RDD
import org.apache.spark.{SparkConf, SparkContext}

import scala.collection.mutable.ListBuffer

object ForeachForeachMappartitionApp {

  def main(args: Array[String]): Unit = {
    val sparkConf = new SparkConf().setMaster("local[2]")
      .setAppName("ForeachForeachMappartitionApp")

    val sc = new SparkContext(sparkConf)

    val students = new ListBuffer[String]()
    for (i<-1 to 100) {
      students += "stu: " + i
    }
    val stuRdd = sc.parallelize(students, 10)

    // 需要把students存储到数据库中去
//    myForeach(stuRdd)
    myForeachPartition(stuRdd)
    sc.stop()
  }

  def myForeach(rdd:RDD[String]): Unit = {
    rdd.foreach(x => {
      val connection = DBUtils.getConnection()
      println(connection + "------------>")

      // TODO... 保存数据到数据库中

      DBUtils.returnConnection(connection)
    })
  }

  def myForeachPartition(rdd:RDD[String]): Unit = {
    rdd.foreachPartition(x => {
      val connection = DBUtils.getConnection()
      println(connection + "------------>")

      // TODO... 保存数据到数据库中

      DBUtils.returnConnection(connection)
    })
  }
}

总结用法和 map/mappartition 是非常类似的,只不过是 action 和 transformation 的区别,写数据库一定要使用 xxxxPartition。

3.reducebykey和groupbykey:

  下面我们使用两种不同的方式去计算单词的个数[2]:

val words = Array("one", "two", "two", "three", "three", "three")
 
val wordPairsRDD = sc.parallelize(words).map(word => (word, 1))
 
val wordCountsWithReduce = wordPairsRDD.reduceByKey(_ + _)
 
val wordCountsWithGroup = wordPairsRDD.groupByKey().map(t => (t._1, t._2.sum))

  上面得到的 wordCountsWithReduce 和 wordCountsWithGroup 是完全一样的,但是,它们的内部运算过程是不同的。

  当采用 reduceByKey 时,Spark 可以在每个分区移动数据之前将待输出数据与一个共用的 key 结合。借助下图可以理解在 reduceByKey 里究竟发生了什么。 注意在数据对被搬移前同一机器上同样的key是怎样被组合的(reduceByKey 中的 lamdba 函数)。然后 lamdba 函数在每个区上被再次调用来将所有值 reduce 成一个最终结果。整个过程如下:
在这里插入图片描述
  当采用 groupByKey 时,由于它不接收函数,spark 只能先将所有的键值对(key-value pair)都移动,这样的后果是集群节点之间的开销很大。整个过程如下:
在这里插入图片描述
总结reduceByKey 会先在 map 端做一个本地的聚合,然后将聚合的数据进行 shuffle 操作;groupByKey 将所有的 key 都经过了 shuffle。所以在 Spark 中少用 groupByKey 而去选择 reduceByKey。

4.collect:

  查看源码中的定义:collect算子执行结果的数据全部放到一个数组中。

在这里插入图片描述
  collect 是 Action 操作里边的一个算子,这个方法可以将 RDD 类型的数据转化为数组,同时会从远程集群是拉取数据到 driver 端。最后将大量数据汇集到一个 driver 节点上,并且像这样val arr = data.collect(),将数据用数组存放,占用了 jvm 堆内存,可想而知,是有多么轻松就会内存溢出(OOM)。所以 collect 一定要慎用,你可能在测试环境测试不出来,一上生产环境全是坑,我们组内的小伙伴有的就这么干,结果上生产一堆问题。

  如何规避:若需要遍历 RDD 中元素,大可不必使用 collect,可以使用 foreach 语句;若需要打印 RDD 中元素,可用 take 语句,返回数据集前 n 个元素,data.take(1000).foreach(println),这点官方文档里有说明;若需要查看其中内容,可用 saveAsTextFile 方法。

  补充:collectPartitions:同样属于 Action 的一种操作,同样也会将数据汇集到 Driver 节点上,与 collect 区别并不是很大,唯一的区别是:collectPartitions 产生数据类型不同于 collect,collect 是将所有RDD汇集到一个数组里,而 collectPartitions 是将各个分区内所有元素存储到一个数组里,再将这些数组汇集到 driver 端产生一个数组;collect 产生一维数组,而 collectPartitions 产生二维数组。

5.coalesce 和 repartition:

  我们常认为 coalesce 不产生 shuffle 会比 repartition 产生 shuffle 效率高,而实际情况往往要根据具体问题具体分析,coalesce 效率不一定高,有时还有大坑,大家要慎用。 coalesce 与 repartition 他们两个都是 RDD 的分区进行重新划分,repartition 只是 coalesce 接口中 shuffle 为 true 的实现

在这里插入图片描述
假设源 RDD 有 N 个分区,需要重新划分成 M 个分区:

  • 如果 N<M。一般情况下 N 个分区有数据分布不均匀的状况,利用 HashPartitioner 函数将数据重新分区为 M 个,这时需要将 shuffle 设置为 true(repartition 实现,coalesce 也实现不了)。
  • 如果 N>M 并且 N 和 M 相差不多,(假如 N 是1000,M 是100)那么就可以将 N 个分区中的若干个分区合并成一个新的分区,最终合并为 M 个分区,这时可以将 shuff 设置为 false(coalesce 实现),如果 M>N 时,coalesce 是无效的,不进行 shuffle 过程,父 RDD 和子 RDD 之间是窄依赖关系,无法使文件数(partiton)变多。 总之如果 shuffle 为 false 时,如果传入的参数大于现有的分区数目,RDD 的分区数不变,也就是说不经过 shuffle,是无法将 RDD 的分区数变多的
  • 如果 N>M 并且两者相差悬殊,这时你要看 executor 数与要生成的 partition 关系,如果 executor数 <= 要生成 partition 数,coalesce 效率高,反之如果用 coalesce 会导致(executor 数 - 要生成 partiton 数)个 excutor 空跑从而降低效率。如果在 M 为 1 的时候,为了使 coalesce 之前的操作有更好的并行度,可以将 shuffle 设置为 true。
6.cache 和 persist:

  cache 调用的是 persist()persist 调用的是 persist(StorageLevel.MEMORY_ONLY),我们从源码中就可以看出来:

在这里插入图片描述
  StorageLevel 有很多种,我们可以看源码:

在这里插入图片描述

  • useDisk:使用硬盘(外存)
  • useMemory:使用内存
  • useOffHeap:使用堆外内存,这是Java虚拟机里面的概念,堆外内存意味着把内存对象分配在Java虚拟机的堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机)。这样做的结果就是能保持一个较小的堆,以减少垃圾收集对应用的影响。
  • deserialized:反序列化,其逆过程序列化(Serialization)是 java 提供的一种机制,将对象表示成一连串的字节;而反序列化就表示将字节恢复为对象的过程。
  • serialization:序列化是对象永久化的一种机制,可以将对象及其属性保存起来,并能在反序列化后直接恢复这个对象。序列化方式存储对象可以节省磁盘或内存的空间,一般
    序列化:反序列化 = 1:3
  • replication:备份数(在多个节点上备份)

  理解了这5个参数,StorageLevel 的12种缓存级别就不难理解了。

  另外还注意到有一种特殊的缓存级别:val OFF_HEAP = new StorageLevel(false, false, true, false),使用了堆外内存,StorageLevel 类的源码中有一段代码可以看出这个的特殊性,它不能和其它几个参数共存。

  如何选择合适的存储策略可以参考官网:RDD Persistence

在这里插入图片描述
总结cachepersist 都是用于将一个 RDD 进行缓存的,这样在之后使用的过程中就不需要重新计算了,可以大大节省程序运行时间。cache 只有一个默认的缓存级别 MEMORY_ONLY ,而 persist 可以根据情况设置其它的缓存级别。
 

二、合理的序列化整合Spark使用为性能提速

调优可参考官网(这部分讲的挺重要):Tuning Spark

在这里插入图片描述

1.序列化概述:

在这里插入图片描述
  默认情况下,Spark内部是使用Java的序列化机制,ObjectOutputStream / ObjectInputStream,对象输入输出流机制,来进行序列化。这种默认序列化机制的好处在于,处理起来比较方便,也不需要我们手动去做什么事情,只是你在算子里面使用的变量,必须是实现Serializable接口的,可序列化即可。但是缺点在于,默认的序列化机制的效率不高,序列化的速度比较慢,序列化以后的数据,占用的内存空间相对还是比较大。

  Spark支持使用Kryo序列化机制。Kryo序列化机制,比默认的Java序列化机制,速度要快,序列化后的数据要更小,大概是Java序列化机制的1/10。所以Kryo序列化优化以后,可以让网络传输的数据变少,在集群中耗费的内存资源大大减少。Kryo之所以没有被作为默认的序列化类库的原因,主要是因为Kryo要求,如果要达到它的最佳性能的话,那么就一定要注册你自定义的类(比如,你的算子函数中使用到了外部自定义类型的对象变量,这时就要求必须注册你的类,否则Kryo达不到最佳性能)。

  Kryo序列化机制,一旦启用以后,会生效的几个地方:

  • 算子函数中使用到的外部变量,使用 Kryo 以后:优化网络传输的性能,可以优化集群中内存的占用和消耗
  • 持久化RDD,优化内存的占用和消耗,持久化RDD占用的内存越少,task 执行的时候,创建的对象,就不至于频繁的占满内存,频繁发生GC。
  • shuffle过程中会进行数据的抓取聚合,在进行stage间的task的shuffle操作时,节点与节点之间的task会互相大量通过网络拉取和传输文件,此时,这些数据既然通过网络传输,也是可能要序列化的,就会使用Kryo。
2.序列化性能测试:
Scala 代码
(1)Java序列化性能测试:

  编写测试代码SerializationApp.scala:

package com.huiq.test

import org.apache.spark.storage.StorageLevel
import org.apache.spark.{SparkConf, SparkContext}

import scala.collection.mutable.ArrayBuffer
import scala.util.Random

object SerializationApp {

  def main(args: Array[String]): Unit = {
    val sparkConf = new SparkConf()
//      .setMaster("local[2]")
//      .setAppName("SerializationApp")

    val sc = new SparkContext(sparkConf)

    val flag = sc.getConf.getInt("spark.flag", 0)

    val infos = new ArrayBuffer[Info]()
    val names = Array[String]("Huiq", "Zhangsan", "Lisi")
    val genders = Array[String]("male", "female")
    val addresses = Array[String]("beijing", "shanghai", "tianjin", "chengdu")

    for (i<-1 to 1000000) {
      val name = names(Random.nextInt(3))
      val age = Random.nextInt(100)
      val gender = genders(Random.nextInt(2))
      val address = addresses(Random.nextInt(4))

      infos += Info(name, age, gender, address)
    }

    val rdd = sc.parallelize(infos)

    if (flag == 0) {
      rdd.persist(StorageLevel.MEMORY_ONLY)
    } else {
      rdd.persist(StorageLevel.MEMORY_ONLY_SER)
    }

    println(rdd.count())

    Thread.sleep(1000 * 60)

    sc.stop()
  }

  case class Info(name:String, age:Int, gender:String, address:String)
}

  生成jar包并上传到装有Spark集群的服务器上:

在这里插入图片描述
  执行命令(Java方式采用MEMORY_ONLY的缓存策略):

spark-submit --class com.huiq.test.SerializationApp --master local[2] /mnt/huiq/HuiqTest-1.0-SNAPSHOT.jar

在这里插入图片描述
  执行命令(Java方式采用MEMORY_ONLY_SER方式缓存策略):

spark-submit --class com.huiq.test.SerializationApp --master local[2] --conf spark.flag=1 /mnt/huiq/HuiqTest-1.0-SNAPSHOT.jar

在这里插入图片描述
  缓存策略如何选择可参考官网:Which Storage Level to Choose?

(2)Kryo序列化性能测试:

  修改 SerializationApp.scala 为:

package com.huiq.test

import org.apache.spark.storage.StorageLevel
import org.apache.spark.{SparkConf, SparkContext}

import scala.collection.mutable.ArrayBuffer
import scala.util.Random

object SerializationApp {

  def main(args: Array[String]): Unit = {
    val sparkConf = new SparkConf()
//      .set("spark.serializer", "org.apache.spark.serializer.KryoSerializer")

    // 使用Kryo要先注册
//    sparkConf.registerKryoClasses(Array(classOf[Info]))

    val sc = new SparkContext(sparkConf)

    val infos = new ArrayBuffer[Info]()
    val names = Array[String]("Huiq", "Zhangsan", "Lisi")
    val genders = Array[String]("male", "female")
    val addresses = Array[String]("beijing", "shanghai", "tianjin", "chengdu")

    for (i<-1 to 1000000) {
      val name = names(Random.nextInt(3))
      val age = Random.nextInt(100)
      val gender = genders(Random.nextInt(2))
      val address = addresses(Random.nextInt(4))

      infos += Info(name, age, gender, address)
    }

    val rdd = sc.parallelize(infos)

    rdd.persist(StorageLevel.MEMORY_ONLY_SER)

    println(rdd.count())

    Thread.sleep(1000 * 60)

    sc.stop()
  }

  case class Info(name:String, age:Int, gender:String, address:String)
}

  执行命令:

spark-submit --class com.huiq.test.SerializationApp --master local[2] --conf spark.serializer=org.apache.spark.serializer.KryoSerializer /mnt/huiq/HuiqTest-1.0-SNAPSHOT.jar

  Kryo没有注册(代码sparkConf.registerKryoClasses(Array(classOf[Info]))注释掉)采用MEMORY_ONLY_SER方式缓存策略:
在这里插入图片描述
  Kryo 注册后(代码sparkConf.registerKryoClasses(Array(classOf[Info]))打开)采用MEMORY_ONLY_SER方式缓存策略:
在这里插入图片描述

Java 代码

参考:
浅谈Spark Kryo serialization
利用Kryo序列化库是你提升Spark性能要做的第一件事
Spark Configuration

  User.java:

import java.io.Serializable;

public class User implements Serializable {
    private String name;

    public User() {
    }

    public User(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

  SerializationApp.java:

import org.apache.spark.SparkConf;
import org.apache.spark.api.java.JavaRDD;
import org.apache.spark.api.java.JavaSparkContext;
import org.apache.spark.storage.StorageLevel;

import java.util.ArrayList;
import java.util.List;

import org.apache.log4j.Level;
import org.apache.log4j.Logger;

public class SerializationApp {

    public static void main(String[] args) {
        Logger.getLogger("org.apache.spark").setLevel(Level.WARN);
        
        try {
            long start = System.currentTimeMillis();

            SparkConf conf = new SparkConf();
//            conf.setMaster("local");
            conf.setAppName("xiaoqiangTest");

            JavaSparkContext sc = new JavaSparkContext(conf);

            List<User> keysList = new ArrayList<>();
            for (int i = 0; i <= 10000000; i++) {
                User user = new User();
                user.setName("zhangsan");
                keysList.add(user);
            }

            JavaRDD<User> rdd1 = sc.parallelize(keysList);

            rdd1.persist(StorageLevel.MEMORY_ONLY());

            System.out.println(rdd1.count());

            Thread.sleep(1000 * 60);

            System.out.println("完成用时:" + (System.currentTimeMillis() - start) / 1000.0);
            sc.stop();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
(1)Java序列化性能测试:

  执行命令(Java方式采用 MEMORY_ONLY 的缓存策略):

spark-submit --class com.huiq.test.SerializationApp --master yarn --deploy-mode client /mnt/huiq/HuiqTest-1.0-SNAPSHOT.jar

在这里插入图片描述
  Java 方式采用 MEMORY_ONLY_SER 方式缓存策略):rdd1.persist(StorageLevel.MEMORY_ONLY_SER());

在这里插入图片描述

(2)Kryo序列化性能测试:

  添加 MyRegistrator.java:

import org.apache.spark.serializer.KryoRegistrator;

import com.esotericsoftware.kryo.Kryo;

public class MyRegistrator implements KryoRegistrator{

    public void registerClasses(Kryo arg0) {
        // TODO Auto-generated method stub
        arg0.register(User.class);
        // 可以写多个,如还可添加如下
        // arg0.register(Student.class);
    }
}

  修改 SerializationApp.java 为:

    public static void main(String[] args) {
        try {
            long start = System.currentTimeMillis();

            SparkConf conf = new SparkConf();
//            conf.setMaster("local");
            conf.setAppName("xiaoqiangTest");
            conf.set("spark.serializer", "org.apache.spark.serializer.KryoSerializer");
            conf.set("spark.kryo.registrator", MyRegistrator.class.getName());

            JavaSparkContext sc = new JavaSparkContext(conf);

            List<User> keysList = new ArrayList<>();
            for (int i = 0; i <= 10000000; i++) {
                User user = new User();
                user.setName("zhangsan");
                keysList.add(user);
            }

            JavaRDD<User> rdd1 = sc.parallelize(keysList);

            rdd1.persist(StorageLevel.MEMORY_ONLY_SER());

            System.out.println(rdd1.count());

            Thread.sleep(1000 * 60);

            System.out.println("完成用时:" + (System.currentTimeMillis() - start) / 1000.0);
            sc.stop();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

在这里插入图片描述
  如果想结合看控制台打印日志的话需要去掉这行代码 Logger.getLogger("org.apache.spark").setLevel(Level.WARN);,并且修改代码如下:

            JavaRDD<User> rdd1 = sc.parallelize(keysList);
            System.out.println("num-->"+rdd1.getNumPartitions());
            JavaRDD<User> rdd2 = rdd1.coalesce(1);
            System.out.println("num-->"+rdd2.getNumPartitions());
            rdd2.persist(StorageLevel.MEMORY_ONLY_SER());

            System.out.println(rdd2.count());

            Thread.sleep(1000 * 60);
            rdd2.unpersist();

在这里插入图片描述
在这里插入图片描述
  将 conf.set("spark.kryo.registrator", MyRegistrator.class.getName()); 注释掉后为 343.3MB。

在这里插入图片描述
  当然,如果想进一步的节省内存、硬盘的空间,减少网络传输的数据量,可以配合的使用Spark支持的压缩方式(目前默认是lz4),广播变量、shuffle过程中的数据都默认使用压缩功能。(注意,RDD默认是不压缩的)

Property NameDefaultMeaning
spark.io.compression.codeclz4The codec used to compress internal data such as RDD partitions, broadcast variables and shuffle outputs. By default, Spark provides three codecs: lz4, lzf, and snappy.
spark.broadcast.compresstrueWhether to compress broadcast variables before sending them. Generally a good idea.
spark.shuffle.compresstrueWhether to compress map output files. Generally a good idea.
spark.shuffle.spill.compresstrueWhether to compress data spilled during shuffles.
spark.rdd.compressfalseWhether to compress serialized RDD partitions (e.g. for StorageLevel.MEMORY_ONLY_SER in Java and Scala or StorageLevel.MEMORY_ONLY in Python). Can save substantial space at the cost of some extra CPU time.

  RDD 持久化操作时使用压缩机制(注意,只有序列化后的RDD才能使用压缩机制)

// SparkConf 增加下面的配置
conf.set("spark.rdd.compress", "true");

  效果很显著,大小直接干到了 KB 级别!

在这里插入图片描述

三、RDD复用及RDD的持久化

  通常来说,开发一个 Spark 作业时,首先是基于某个数据源(比如 Hive 表或 HDFS 文件)创建一个初始的RDD;接着对这个RDD执行某个算子操作,然后得到下一个RDD;以此类推,循环往复,直到计算出最终我们需要的结果。在这个过程中,多个RDD会通过不同的算子操作(比如 map、reduce 等)串起来,这个“RDD串”,就是 RDD lineage,也就是 “RDD的血缘关系链”。我们在开发过程中要注意:对于同一份数据,只应该创建一个RDD,不能创建多个RDD来代表同一份数据。

  除了要避免在开发过程中对一份完全相同的数据创建多个RDD之外,在对不同的数据执行算子操作时还要尽可能地复用一个RDD。比如说,有一个RDD的数据格式是 key-value 类型的,另一个是单value类型的,这两个RDD的value数据是完全一样的。那么此时我们可以只使用key-value类型的那个RDD,因为其中已经包含了另一个的数据。对于类似这种多个RDD的数据有重叠或者包含的情况,我们应该尽量复用一个RDD,这样可以尽可能地减少RDD的数量,从而尽可能减少算子执行的次数。

  Spark中有 tranformationaction 两类算子,tranformation 算子具有lazy特性,只有action算子才会触发job的开始,从而去执行action算子之前定义的tranformation算子,从hdfs中读取数据等,计算完成之后,Spark会将内存中的数据清除,这样处理的好处是避免了OOM问题,但不好之处在于每次job都会从头执行一边,比如从hdfs上读取文件等,如果文件数据量很大,这个过程就会很耗性能。这个问题就涉及到要讲的RDD持久化特性,合理的使用RDD持久化对Spark的性能会有很大提升。

  对于要多次计算和使用的公共RDD,一定要进行持久化。持久化就是说,将RDD的数据缓存到内存中/磁盘中,以后无论对这个RDD做多少次计算,那么都是直接取这个RDD持久化的数据,比如从内存或者磁盘中,直接提取一份数据使用,不会再重复占用资源进行计算。

  Spark非常重要的一个功能特性就是可以将RDD持久化在内存中。当对RDD执行持久化操作时,每个节点都会将自己操作的RDD的partition持久化到内存中,并且在之后对该RDD的反复使用中,直接使用内存缓存的partition。这样的话,对于针对一个RDD反复执行多个操作的场景,就只要对RDD计算一次即可,后面直接使用该RDD,而不需要反复计算多次该RDD。

  巧妙使用RDD持久化,甚至在某些场景下,可以将spark应用程序的性能提升10倍。对于迭代式算法和快速交互式应用来说,RDD持久化,是非常重要的。

  要持久化一个RDD,只要调用其 cache() 或者 persist() 方法即可。在该RDD第一次被计算出来时,就会直接缓存在每个节点中。而且Spark的持久化机制还是自动容错的,如果数据在内存中丢失,那么Spark会自动向父级依赖查找数据,直到找到数据为止,最坏的情况是找到hdfs重新获取数据重新计算,自动把结果补到原来持久化的位置。

  cache()persist() 的区别在于,cache()persist() 的一种简化方式,cache() 的底层就是调用的 persist() 的无参版本,同时就是调用 persist(StorageLevel.MEMORY_ONLY),将数据持久化到内存中。

  如果清楚地记得哪个 rdd 或者 dataSet 进行了缓存的操作,那么可以直接使用 unpersist() 方法。如果思路不够清晰,或者程序比较长,写着写着就忘记了哪些数据进行缓存过了,这里提供一个清除所有缓存在 spark 环境里面的数据的操作:sc.catalog().clearCache()(Java、Scala、Python都适用)。还有一种是 getPersistentRDDs 的方式,但我 Java 没有调通,可参考:spark:清空程序运行时的所有(cache)缓存块

  Spark 自己也会在 shuffle 操作时,进行数据的持久化,比如写入磁盘,主要是为了在节点失败时,避免需要重新计算整个过程。

1. 如何选择一种最合适的持久化策略
  • 默认情况下,性能最高的当然是MEMORY_ONLY,但前提是你的内存必须足够足够大,可以绰绰有余地存放下整个RDD的所有数据。因为不进行序列化与反序列化操作,就避免了这部分的性能开销;对这个RDD的后续算子操作,都是基于纯内存中的数据的操作,不需要从磁盘文件中读取数据,性能也很高;而且不需要复制一份数据副本,并远程传送到其他节点上。但是这里必须要注意的是,在实际的生产环境中,恐怕能够直接用这种策略的场景还是有限的,如果RDD中数据比较多时(比如几十亿),直接用这种持久化级别,会导致JVM的OOM内存溢出异常。

  • 如果使用MEMORY_ONLY级别时发生了内存溢出,那么建议尝试使用MEMORY_ONLY_SER级别。该级别会将RDD数据序列化后再保存在内存中,此时每个partition仅仅是一个字节数组而已,大大减少了对象数量,并降低了内存占用。这种级别比MEMORY_ONLY多出来的性能开销,主要就是序列化与反序列化的开销。但是后续算子可以基于纯内存进行操作,因此性能总体还是比较高的。此外,可能发生的问题同上,如果RDD中的数据量过多的话,还是可能会导致OOM内存溢出的异常。

  • 如果纯内存的级别都无法使用,那么建议使用MEMORY_AND_DISK_SER策略,而不是MEMORY_AND_DISK策略。因为既然到了这一步,就说明RDD的数据量很大,内存无法完全放下。序列化后的数据比较少,可以节省内存和磁盘的空间开销。同时该策略会优先尽量尝试将数据缓存在内存中,内存缓存不下才会写入磁盘。

  • 通常不建议使用DISK_ONLY和后缀为_2的级别:因为完全基于磁盘文件进行数据的读写,会导致性能急剧降低,有时还不如重新计算一次所有RDD。后缀为_2的级别,必须将所有数据都复制一份副本,并发送到其他节点上,数据复制以及网络传输会导致较大的性能开销,除非是要求作业的高可用性,否则不建议使用。

2. RDD 的 CheckPoint

  Checkpoint 的主要作用是斩断 RDD 的依赖链,并且将数据存储在可靠的存储引擎中,例如支持分布式存储和副本机制的 HDFS。

  斩断依赖链是一个非常重要的操作,接下来以HDFS的 NameNode 的原理来举例说明

  HDFS的 NameNode 中主要职责就是维护两个文件,一个叫做 edits,另外一个叫做 fsimage 。edits中主要存放 EditLog,FsImage 保存了当前系统中所有目录和文件的信息。这个 FsImage其实就是一个Checkpoint 。

  HDFS 的 NameNode 维护这两个文件的主要过程是:首先,会由 fsimage文件记录当前系统某个时间点的完整数据,自此之后的数据并不是时刻写入fsimage ,而是将操作记录存储在 edits 文件中。其次,在一定的触发条件下, edits 会将自身合并进入 fsimage。最后生成新的 fsimage 文件, edits重置,重新记录这次 fsimage 以后的操作日志。

  如果不合并 edits 进入 fsimage 会怎样?会导致 edits 中记录的日志过长,容易出错。

  所以当 Spark 的一个 Job 执行流程过长的时候,也需要这样的一个斩断依赖链的过程,使得接下来的计算轻装上阵。

  Cache 可以把 RDD 计算出来然后放在内存中,但是 RDD 的依赖链(相当于 NameNode 中的 Edits 日志)是不能丢掉的,因为这种缓存是不可靠的,如果出现了一些错误(例如 Executor 宕机),这个 RDD 的容错就只能通过回溯依赖链,重放计算出来。

  但是 Checkpoint 把结果保存在 HDFS 这类存储中,就是可靠的了,所以可以斩断依赖,如果出错了,则通过复制HDFS中的文件来实现容错。

  所以他们的区别主要在以下两点:

  • checkpoint 可以保存数据到 HDFS 这类可靠的存储上, Persist 和 Cache 只能保存在本地的磁盘和内存中。Checkpoint 可以斩断 RDD 的依赖链,而 Persist 和 Cache 不行;
  • 因为 CheckpointRDD 没有向上的依赖链,所以程序结束后依然存在,不会被删除。而 Cache 和 Persist 会在程序结束后立刻被清除。
3. RDD 的缓存和 Checkpoint 的区别

生命周期:

  • persist,程序结束完了之后就被释放掉或者 unpersist 释放掉;
  • checkpoint,程序结束后不会被释放掉。

存储位置:

  • persist,存储到本地的内存或者磁盘当中;
  • checkpoint,存到更为可靠的 HDFS 中。

依赖关系:

  • persist,不会摆脱依赖;
  • checkpoint,斩断依赖链。

  Persist是轻量化保存RDD数据,可存储在内存和硬盘,是分散存储,设计上是不安全的(保留血缘关系)

  CheckPoint是重量级保存RDD数据,是集中存储,只能存储在硬盘上(本地\HDFS),设计上是安全的(不保留血缘关系)

  对 checkpoint 在使用的时候进行优化,在调用 checkpoint 操作之前,可以先来做一个 cache 操作,缓存对应 rdd 的结果数据,后续就可以直接从 cache 中获取到 rdd 的数据写入到指定 checkpoint 目录中。一个 RDD 缓存并 checkpoint 后,如果一旦发现缓存丢失,就会优先查看 checkpoint 数据存不存在,如果有,就会使用 checkpoint 数据,而不用重新计算。也即是说,checkpoint 可以视为 cache 的保障机制,如果 cache 失败,就使用 checkpoint 的数据。

  Persist 与 checkpoint 也有区别。前者虽然可以将 RDD 的 partition 持久化到磁盘,一旦 driver program 执行结束,也就是 executor 所在进程 ,被 cache 到磁盘上的 RDD 也会被清空。而 checkpoint 将 RDD 持久化到 HDFS 或本地文件夹,如果不被手动 remove 掉,是一直存在的,也就是说可以被下一个 driver program 使用,而 cached RDD 不能被其他 dirver program 使用。

  性能对比:Cache性能更好,因为是分散存储,各个Executor并行执行,效率高;CheckPoint比较慢,因为是集中存储涉及到网络IO,但是存储到HDFS更加安全(多副本机制)
 

四、广播变量broadcast

  先看一段Spark程序代码,简单的读取数据并过滤:

val sc = new SparkContext(conf)
val list = List('hello')
val dataRDD = sc.textFile('./test.txt')

//读取变量
dataRDD.filter{x => x
     .contains(list)}.foreach{println}

  我们再来看下修改后的程序:

val sc = new SparkContext(conf)
val list = List('hello')

//定义broadcast变量
val broadcastVal = sc.broadcast(list)
val dataRDD = sc.textFile('./test.txt')

//broadcast变量读取
dataRDD.filter{x => x
     .contains(broadcastVal.value)}.foreach{println}

  有时在开发过程中,会遇到需要在算子函数中使用外部变量的场景(尤其是大变量,比如100M以上的大集合),那么此时就应该使用 Spark 的广播(Broadcast)功能来提升性能。

  在算子函数中使用到外部变量时,默认情况下,Spark 会将该变量复制多个副本,通过网络传输到 task 中,此时每个 task 都有一个变量副本。如果变量本身比较大的话(比如100M,甚至1G),那么大量的变量副本在网络中传输的性能开销,以及在各个节点的 Executor 中占用过多内存导致的频繁GC,都会极大地影响性能。

  因此对于上述情况,如果使用的外部变量比较大,建议使用Spark的广播功能,对该变量进行广播。广播后的变量,会保证每个 Executor 的内存中,只驻留一份变量副本,而 Executor 中的 task 执行时共享该 Executor 中的那份变量副本。这样的话,可以大大减少变量副本的数量,从而减少网络传输的性能开销,并减少对 Executor 内存的占用开销,降低 GC 的频率。

  每个 Executor 会对应自己的 BlockManager,BlockManager 是负责管理某个 Executor 对应的内存和磁盘上的数据。广播变量初始的时候就在 Drvier 上有一份副本,task 在运行的时候,想要使用广播变量中的数据,此时首先会在自己本地的 Executor 对应的 BlockManager 中,尝试获取变量副本。如果本地没有,那么就从 Driver 远程拉取变量副本,并保存在本地的 BlockManager 中,此后这个 executor 上的 task 都会直接使用本地的 BlockManager 中的副本。executor 的 BlockManager 除了从 driver 上拉取,也可能从其他节点的 BlockManager 上拉取变量副本,距离越近越好。

  根据在实际企业中的生产环境举例来说:总共有50个 executor,1000个 task,一个map大小为10M。
默认情况下,1000个task,1000份副本,共有10G的数据进行网络传输,在集群中,耗费10G的内存资源。如果使用了广播变量,50个execurtor就只有50个副本,有500M的数据进行网络传输,而且不一定都是从Driver传输到每个节点,还可能是就近从最近的节点的 executor 的 bockmanager 上拉取变量副本,网络传输速度大大增加,只有500M的内存消耗。

还可参考:
原创肝文!硬核Spark源码剖析第二期:广播变量Broadcast
原创肝文!2万字硬核Spark源码精讲手册(文末附pdf领取)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小强签名设计

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值