spark性能优化指南--高级篇

spark性能优化指南–高级篇

导读

本文非原创,是由阅读美团技术团队于2016年05月12日 作者: 李雪蕤 发表的文章《Spark性能优化指南——高级篇》,收获甚多,以本文作为学习笔记。

一、数据倾斜

  • 数据倾斜发生时的现象
    • 绝大多数task执行得都非常快,但个别task执行极慢。比如,总共有1000个task,997个task都在1分钟之内执行完了,但是剩余两三个task却要一两个小时。这种情况很常见
    • 原本能够正常执行的Spark作业,某天突然报出OOM(内存溢出)异常,观察异常栈,是我们写的业务代码造成的。这种情况比较少见
  • 数据倾斜发生的原理
    • 在进行shuffle的时候,必须将各个节点上相同的key拉取到某个节点上的一个task来进行处理,比如按照key进行聚合或join等操作。此时如果某个key对应的数据量特别大的话,就会发生数据倾斜。比如大部分key对应10条数据,但是个别key却对应了100万条数据,那么大部分task可能就只会分配到10条数据,然后1秒钟就运行完了;但是个别task可能分配到了100万数据,要运行一两个小时。因此,整个Spark作业的运行进度是由运行时间最长的那个task决定的
    • 因此出现数据倾斜的时候,Spark作业看起来会运行得非常缓慢,甚至可能因为某个task处理的数据量过大导致内存溢出
    • 数据倾斜只会发生在shuffle过程中。这里给大家罗列一些常用的并且可能会触发shuffle操作的算子:distinct、groupByKey、reduceByKey、aggregateByKey、join、cogroup、repartition等。出现数据倾斜时,可能就是你的代码中使用了这些算子中的某一个所导致的
  • 如何定位导致数据倾斜的代码
    1. 某个task执行特别慢的情况
      • 首先可以从SparkWebUI上看数据倾斜是发生在第几个stage
      • 知道是哪个stage后,根据stage划分原理,推算出来发生倾斜的stage对应代买中的哪一部分,这一部分肯定会有一个shuffle类算子

      以 spark 的WorkCount为例:

      val conf = new SparkConf()
      val sc = new SparkContext(conf)
       
      val lines = sc.textFile("hdfs://...")
      val words = lines.flatMap(_.split(" "))
      val pairs = words.map((_, 1))			 //以上代码为 stage-0
      val wordCounts = pairs.reduceByKey(_ + _)//触发shuffle
       										 //以下代码为 stage-1
      wordCounts.collect().foreach(println(_))
      

      从上代码可知,会触发shuffle的算子就是reduceByKey,以改算子为界限,前后划分为两个stage

    2. 某个task莫名其妙内存溢出的情况
      • 这种情况下去定位出问题的代码就比较容易了。我们建议直接看yarn-client模式下本地log的异常栈,或者是通过YARN查看yarn-cluster模式下的log中的异常栈。一般来说,通过异常栈信息就可以定位到你的代码中哪一行发生了内存溢出。然后在那行代码附近找找,一般也会有shuffle类算子,此时很可能就是这个算子导致了数据倾斜
      • 但是大家要注意的是,不能单纯靠偶然的内存溢出就判定发生了数据倾斜。因为自己编写的代码的bug,以及偶然出现的数据异常,也可能会导致内存溢出。因此还是要按照上面所讲的方法,通过Spark Web UI查看报错的那个stage的各个task的运行时间以及分配的数据量,才能确定是否是由于数据倾斜才导致了这次内存溢出
  • 查看导致数据倾斜的key的数据分布情况
    • 如果是Spark SQL中的group by、join语句导致的数据倾斜,就查询一下SQL中使用的表的key分布情况
    • 如果是对Spark RDD执行shuffle算子导致的数据倾斜,那么可以在Spark作业中加入查看key分布的代码,比如RDD.countByKey()。然后对统计出来的各个key出现的次数,collect/take到客户端打印一下,就可以看到key的分布情况

    举例来说,对于上面所说的单词计数程序,如果确定了是stage1的reduceByKey算子导致了数据倾斜,那么就应该看看进行reduceByKey操作的RDD中的key分布情况,在这个例子中指的就是pairs RDD。如下示例,我们可以先对pairs采样10%的样本数据,然后使用countByKey算子统计出每个key出现的次数,最后在客户端遍历和打印样本数据中各个key的出现次数。

    val sampledPairs = pairs.sample(false, 0.1)
    val sampledWordCounts = sampledPairs.countByKey()
    sampledWordCounts.foreach(println(_))
    
  • 数据倾斜的解决方案
    1. 解决方案一:使用Hive ETL预处理数据【预处理】
    2. 解决方案二:过滤少数导致倾斜的key【过滤非必须】
    3. 解决方案三:提高shuffle操作的并行度【提高并行度】
    4. 解决方案四:两阶段聚合(局部聚合+全局聚合)【分段聚合】
    // 方案四
    
    // 第一步,给RDD中的每个key都打上一个随机前缀。
    JavaPairRDD<String, Long> randomPrefixRdd = rdd.mapToPair(
            new PairFunction<Tuple2<Long,Long>, String, Long>() {
                private static final long serialVersionUID = 1L;
                @Override
                public Tuple2<String, Long> call(Tuple2<Long, Long> tuple)
                        throws Exception {
                    Random random = new Random();
                    int prefix = random.nextInt(10);
                    return new Tuple2<String, Long>(prefix + "_" + tuple._1, tuple._2);
                }
            });
      
    // 第二步,对打上随机前缀的key进行局部聚合。
    JavaPairRDD<String, Long> localAggrRdd = randomPrefixRdd.reduceByKey(
            new Function2<Long, Long, Long>() {
                private static final long serialVersionUID = 1L;
                @Override
                public Long call(Long v1, Long v2) throws Exception {
                    return v1 + v2;
                }
            });
      
    // 第三步,去除RDD中每个key的随机前缀。
    JavaPairRDD<Long, Long> removedRandomPrefixRdd = localAggrRdd.mapToPair(
            new PairFunction<Tuple2<String,Long>, Long, Long>() {
                private static final long serialVersionUID = 1L;
                @Override
                public Tuple2<Long, Long> call(Tuple2<String, Long> tuple)
                        throws Exception {
                    long originalKey = Long.valueOf(tuple._1.split("_")[1]);
                    return new Tuple2<Long, Long>(originalKey, tuple._2);
                }
            });
      
    // 第四步,对去除了随机前缀的RDD进行全局聚合。
    JavaPairRDD<Long, Long> globalAggrRdd = removedRandomPrefixRdd.reduceByKey(
            new Function2<Long, Long, Long>() {
                private static final long serialVersionUID = 1L;
                @Override
                public Long call(Long v1, Long v2) throws Exception {
                    return v1 + v2;
                }
            });
    
    1. 解决方案五:将reduce join 转为map join【广播变量】
// 方案五

// 首先将数据量比较小的RDD的数据,collect到Driver中来。
List<Tuple2<Long, Row>> rdd1Data = rdd1.collect()
// 然后使用Spark的广播功能,将小RDD的数据转换成广播变量,这样每个Executor就只有一份RDD的数据。
// 可以尽可能节省内存空间,并且减少网络传输性能开销。
final Broadcast<List<Tuple2<Long, Row>>> rdd1DataBroadcast = sc.broadcast(rdd1Data);
  
// 对另外一个RDD执行map类操作,而不再是join类操作。
JavaPairRDD<String, Tuple2<String, Row>> joinedRdd = rdd2.mapToPair(
        new PairFunction<Tuple2<Long,String>, String, Tuple2<String, Row>>() {
            private static final long serialVersionUID = 1L;
            @Override
            public Tuple2<String, Tuple2<String, Row>> call(Tuple2<Long, String> tuple)
                    throws Exception {
                // 在算子函数中,通过广播变量,获取到本地Executor中的rdd1数据。
                List<Tuple2<Long, Row>> rdd1Data = rdd1DataBroadcast.value();
                // 可以将rdd1的数据转换为一个Map,便于后面进行join操作。
                Map<Long, Row> rdd1DataMap = new HashMap<Long, Row>();
                for(Tuple2<Long, Row> data : rdd1Data) {
                    rdd1DataMap.put(data._1, data._2);
                }
                // 获取当前RDD数据的key以及value。
                String key = tuple._1;
                String value = tuple._2;
                // 从rdd1数据Map中,根据key获取到可以join到的数据。
                Row rdd1Value = rdd1DataMap.get(key);
                return new Tuple2<String, String>(key, new Tuple2<String, Row>(value, rdd1Value));
            }
        });
  
// 这里得提示一下。
// 上面的做法,仅仅适用于rdd1中的key没有重复,全部是唯一的场景。
// 如果rdd1中有多个相同的key,那么就得用flatMap类的操作,在进行join的时候不能用map,而是得遍历rdd1所有数据进行join。
// rdd2中每条数据都可能会返回多条join后的数据。
  1. 解决方案六:采样倾斜key并分拆join操作
    • 思路:对包含少数几个数据量过大的key的那个RDD,通过sample算子采样出一份样本来,然后统计一下每个key的数量,计算出来数据量最大的是哪几个key。 然后将这几个key对应的数据从原来的RDD中拆分出来,形成一个单独的RDD,并给每个key都打上n以内的随机数作为前缀,而不会导致倾斜的大部分key形成另外一个RDD。 接着将需要join的另一个RDD,也过滤出来那几个倾斜key对应的数据并形成一个单独的RDD,将每条数据膨胀成n条数据,这n条数据都按顺序附加一个0~n的前缀,不会导致倾斜的大部分key也形成另外一个RDD。 再将附加了随机前缀的独立RDD与另一个膨胀n倍的独立RDD进行join,此时就可以将原先相同的key打散成n份,分散到多个task中去进行join了。 而另外两个普通的RDD就照常join即可。 最后将两次join的结果使用union算子合并起来即可,就是最终的join结果
    // 首先从包含了少数几个导致数据倾斜key的rdd1中,采样10%的样本数据。
    JavaPairRDD<Long, String> sampledRDD = rdd1.sample(false, 0.1);
      
    // 对样本数据RDD统计出每个key的出现次数,并按出现次数降序排序。
    // 对降序排序后的数据,取出top 1或者top 100的数据,也就是key最多的前n个数据。
    // 具体取出多少个数据量最多的key,由大家自己决定,我们这里就取1个作为示范。
    JavaPairRDD<Long, Long> mappedSampledRDD = sampledRDD.mapToPair(
            new PairFunction<Tuple2<Long,String>, Long, Long>() {
                private static final long serialVersionUID = 1L;
                @Override
                public Tuple2<Long, Long> call(Tuple2<Long, String> tuple)
                        throws Exception {
                    return new Tuple2<Long, Long>(tuple._1, 1L);
                }     
            });
    JavaPairRDD<Long, Long> countedSampledRDD = mappedSampledRDD.reduceByKey(
            new Function2<Long, Long, Long>() {
                private static final long serialVersionUID = 1L;
                @Override
                public Long call(Long v1, Long v2) throws Exception {
                    return v1 + v2;
                }
            });
    JavaPairRDD<Long, Long> reversedSampledRDD = countedSampledRDD.mapToPair( 
            new PairFunction<Tuple2<Long,Long>, Long, Long>() {
                private static final long serialVersionUID = 1L;
                @Override
                public Tuple2<Long, Long> call(Tuple2<Long, Long> tuple)
                        throws Exception {
                    return new Tuple2<Long, Long>(tuple._2, tuple._1);
                }
            });
    final Long skewedUserid = reversedSampledRDD.sortByKey(false).take(1).get(0)._2;
      
    // 从rdd1中分拆出导致数据倾斜的key,形成独立的RDD。
    JavaPairRDD<Long, String> skewedRDD = rdd1.filter(
            new Function<Tuple2<Long,String>, Boolean>() {
                private static final long serialVersionUID = 1L;
                @Override
                public Boolean call(Tuple2<Long, String> tuple) throws Exception {
                    return tuple._1.equals(skewedUserid);
                }
            });
    // 从rdd1中分拆出不导致数据倾斜的普通key,形成独立的RDD。
    JavaPairRDD<Long, String> commonRDD = rdd1.filter(
            new Function<Tuple2<Long,String>, Boolean>() {
                private static final long serialVersionUID = 1L;
                @Override
                public Boolean call(Tuple2<Long, String> tuple) throws Exception {
                    return !tuple._1.equals(skewedUserid);
                } 
            });
      
    // rdd2,就是那个所有key的分布相对较为均匀的rdd。
    // 这里将rdd2中,前面获取到的key对应的数据,过滤出来,分拆成单独的rdd,并对rdd中的数据使用flatMap算子都扩容100倍。
    // 对扩容的每条数据,都打上0~100的前缀。
    JavaPairRDD<String, Row> skewedRdd2 = rdd2.filter(
             new Function<Tuple2<Long,Row>, Boolean>() {
                private static final long serialVersionUID = 1L;
                @Override
                public Boolean call(Tuple2<Long, Row> tuple) throws Exception {
                    return tuple._1.equals(skewedUserid);
                }
            }).flatMapToPair(new PairFlatMapFunction<Tuple2<Long,Row>, String, Row>() {
                private static final long serialVersionUID = 1L;
                @Override
                public Iterable<Tuple2<String, Row>> call(
                        Tuple2<Long, Row> tuple) throws Exception {
                    Random random = new Random();
                    List<Tuple2<String, Row>> list = new ArrayList<Tuple2<String, Row>>();
                    for(int i = 0; i < 100; i++) {
                        list.add(new Tuple2<String, Row>(i + "_" + tuple._1, tuple._2));
                    }
                    return list;
                }
                  
            });
     
    // 将rdd1中分拆出来的导致倾斜的key的独立rdd,每条数据都打上100以内的随机前缀。
    // 然后将这个rdd1中分拆出来的独立rdd,与上面rdd2中分拆出来的独立rdd,进行join。
    JavaPairRDD<Long, Tuple2<String, Row>> joinedRDD1 = skewedRDD.mapToPair(
            new PairFunction<Tuple2<Long,String>, String, String>() {
                private static final long serialVersionUID = 1L;
                @Override
                public Tuple2<String, String> call(Tuple2<Long, String> tuple)
                        throws Exception {
                    Random random = new Random();
                    int prefix = random.nextInt(100);
                    return new Tuple2<String, String>(prefix + "_" + tuple._1, tuple._2);
                }
            })
            .join(skewedUserid2infoRDD)
            .mapToPair(new PairFunction<Tuple2<String,Tuple2<String,Row>>, Long, Tuple2<String, Row>>() {
                            private static final long serialVersionUID = 1L;
                            @Override
                            public Tuple2<Long, Tuple2<String, Row>> call(
                                Tuple2<String, Tuple2<String, Row>> tuple)
                                throws Exception {
                                long key = Long.valueOf(tuple._1.split("_")[1]);
                                return new Tuple2<Long, Tuple2<String, Row>>(key, tuple._2);
                            }
                        });
     
    // 将rdd1中分拆出来的包含普通key的独立rdd,直接与rdd2进行join。
    JavaPairRDD<Long, Tuple2<String, Row>> joinedRDD2 = commonRDD.join(rdd2);
     
    // 将倾斜key join后的结果与普通key join后的结果,uinon起来。
    // 就是最终的join结果。
    JavaPairRDD<Long, Tuple2<String, Row>> joinedRDD = joinedRDD1.union(joinedRDD2);
    
  2. 解决方案七:使用随机前缀和扩容RDD进行join
    • 思路: 该方案的实现思路基本和“解决方案六”类似,首先查看RDD/Hive表中的数据分布情况,找到那个造成数据倾斜的RDD/Hive表,比如有多个key都对应了超过1万条数据。然后将该RDD的每条数据都打上一个n以内的随机前缀。同时对另外一个正常的RDD进行扩容,将每条数据都扩容成n条数据,扩容出来的每条数据都依次打上一个0~n的前缀。最后将两个处理后的RDD进行join即可
    // 首先将其中一个key分布相对较为均匀的RDD膨胀100倍。
    JavaPairRDD<String, Row> expandedRDD = rdd1.flatMapToPair(
            new PairFlatMapFunction<Tuple2<Long,Row>, String, Row>() {
                private static final long serialVersionUID = 1L;
                @Override
                public Iterable<Tuple2<String, Row>> call(Tuple2<Long, Row> tuple)
                        throws Exception {
                    List<Tuple2<String, Row>> list = new ArrayList<Tuple2<String, Row>>();
                    for(int i = 0; i < 100; i++) {
                        list.add(new Tuple2<String, Row>(0 + "_" + tuple._1, tuple._2));
                    }
                    return list;
                }
            });
      
    // 其次,将另一个有数据倾斜key的RDD,每条数据都打上100以内的随机前缀。
    JavaPairRDD<String, String> mappedRDD = rdd2.mapToPair(
            new PairFunction<Tuple2<Long,String>, String, String>() {
                private static final long serialVersionUID = 1L;
                @Override
                public Tuple2<String, String> call(Tuple2<Long, String> tuple)
                        throws Exception {
                    Random random = new Random();
                    int prefix = random.nextInt(100);
                    return new Tuple2<String, String>(prefix + "_" + tuple._1, tuple._2);
                }
            });
      
    // 将两个处理后的RDD进行join即可。
    JavaPairRDD<String, Tuple2<String, Row>> joinedRDD = mappedRDD.join(expandedRDD);
    
  3. 解决方案八:以上多种方案组合使用

二、shuffle原理及相关参数

  1. ShuffleManager发展概述
    • 在Spark的源码中,负责shuffle过程的执行、计算和处理的组件主要就是ShuffleManager,也即shuffle管理器。而随着Spark的版本的发展,ShuffleManager也在不断迭代,变得越来越先进
    • 在Spark 1.2以前,默认的shuffle计算引擎是HashShuffleManager。该ShuffleManager而HashShuffleManager有着一个非常严重的弊端,就是会产生大量的中间磁盘文件,进而由大量的磁盘IO操作影响了性能
    • 因此在Spark 1.2以后的版本中,默认的ShuffleManager改成了SortShuffleManager。SortShuffleManager相较于HashShuffleManager来说,有了一定的改进。主要就在于,每个Task在进行shuffle操作时,虽然也会产生较多的临时磁盘文件,但是最后会将所有的临时文件合并(merge)成一个磁盘文件,因此每个Task就只有一个磁盘文件。在下一个stage的shuffle read task拉取自己的数据时,只要根据索引读取每个磁盘文件中的部分数据即可
  2. HashShuffleManager运行原理
    • shuffle write阶段,主要就是在一个stage结束计算之后,为了下一个stage可以执行shuffle类的算子(比如reduceByKey),而将每个task处理的数据按key进行“分类”。所谓“分类”,就是对相同的key执行hash算法,从而将相同key都写入同一个磁盘文件中,而每一个磁盘文件都只属于下游stage的一个task。在将数据写入磁盘之前,会先将数据写入内存缓冲中,当内存缓冲填满之后,才会溢写到磁盘文件中去
    • 那么**每个执行shuffle write的task,要为下一个stage创建多少个磁盘文件呢?很简单,下一个stage的task有多少个,当前stage的每个task就要创建多少份磁盘文件。**比如下一个stage总共有100个task,那么当前stage的每个task都要创建100份磁盘文件。如果当前stage有50个task,总共有10个Executor,每个Executor执行5个Task,那么每个Executor上总共就要创建500个磁盘文件,所有Executor上会创建5000个磁盘文件。由此可见,未经优化的shuffle write操作所产生的磁盘文件的数量是极其惊人的
    • shuffle read,通常就是一个stage刚开始时要做的事情。此时该stage的每一个task就需要将上一个stage的计算结果中的所有相同key,从各个节点上通过网络都拉取到自己所在的节点上,然后进行key的聚合或连接等操作。由于shuffle write的过程中,task给下游stage的每个task都创建了一个磁盘文件,因此shuffle read的过程中,每个task只要从上游stage的所有task所在节点上,拉取属于自己的那一个磁盘文件即可
    • shuffle read的拉取过程是一边拉取一边进行聚合的。每个shuffle read task都会有一个自己的buffer缓冲,每次都只能拉取与buffer缓冲相同大小的数据,然后通过内存中的一个Map进行聚合等操作。聚合完一批数据后,再拉取下一批数据,并放到buffer缓冲中进行聚合操作。以此类推,直到最后将所有数据到拉取完,并得到最终的结果
  3. 优化后的HashShuffleManager
    • spark.shuffle.consolidateFiles。该参数默认值为false,将其设置为true即可开启优化机制。通常来说,如果我们使用HashShuffleManager,那么都建议开启这个选项
    • 开启consolidate机制之后,在shuffle write过程中,task就不是为下游stage的每个task创建一个磁盘文件了。此时会出现shuffleFileGroup的概念,每个shuffleFileGroup会对应一批磁盘文件,磁盘文件的数量与下游stage的task数量是相同的。一个Executor上有多少个CPU core,就可以并行执行多少个task。而第一批并行执行的每个task都会创建一个shuffleFileGroup,并将数据写入对应的磁盘文件内
    • 当Executor的CPU core执行完一批task,接着执行下一批task时,下一批task就会复用之前已有的shuffleFileGroup,包括其中的磁盘文件。也就是说,此时task会将数据写入已有的磁盘文件中,而不会写入新的磁盘文件中。因此,consolidate机制允许不同的task复用同一批磁盘文件,这样就可以有效将多个task的磁盘文件进行一定程度上的合并,从而大幅度减少磁盘文件的数量,进而提升shuffle write的性能
    • 假设第二个stage有100个task,第一个stage有50个task,总共还是有10个Executor,每个Executor执行5个task。那么原本使用未经优化的HashShuffleManager时,每个Executor会产生500个磁盘文件,所有Executor会产生5000个磁盘文件的。但是此时经过优化之后,每个Executor创建的磁盘文件的数量的计算公式为:CPU core的数量 * 下一个stage的task数量。也就是说,每个Executor此时只会创建100个磁盘文件,所有Executor只会创建1000个磁盘文件
  4. SortShuffleManager运行原理
    • ortShuffleManager的运行机制主要分成两种,一种是普通运行机制,另一种是bypass运行机制。当shuffle read task的数量小于等于spark.shuffle.sort.bypassMergeThreshold参数的值时(默认为200),就会启用bypass机制
    • 普通运行机制
      • 在该模式下,数据会先写入一个内存数据结构中,此时根据不同的shuffle算子,可能选用不同的数据结构。如果是reduceByKey这种聚合类的shuffle算子,那么会选用Map数据结构,一边通过Map进行聚合,一边写入内存;如果是join这种普通的shuffle算子,那么会选用Array数据结构,直接写入内存。接着,每写一条数据进入内存数据结构之后,就会判断一下,是否达到了某个临界阈值。如果达到临界阈值的话,那么就会尝试将内存数据结构中的数据溢写到磁盘,然后清空内存数据结构
      • 在溢写到磁盘文件之前,会先根据key对内存数据结构中已有的数据进行排序。排序过后,会分批将数据写入磁盘文件。默认的batch数量是10000条,也就是说,排序好的数据,会以每批1万条数据的形式分批写入磁盘文件。写入磁盘文件是通过Java的BufferedOutputStream实现的。BufferedOutputStream是Java的缓冲输出流,首先会将数据缓冲在内存中,当内存缓冲满溢之后再一次写入磁盘文件中,这样可以减少磁盘IO次数,提升性能
      • 一个task将所有数据写入内存数据结构的过程中,会发生多次磁盘溢写操作,也就会产生多个临时文件。最后会将之前所有的临时磁盘文件都进行合并,这就是merge过程,此时会将之前所有临时磁盘文件中的数据读取出来,然后依次写入最终的磁盘文件之中。此外,由于一个task就只对应一个磁盘文件,也就意味着该task为下游stage的task准备的数据都在这一个文件中,因此还会单独写一份索引文件,其中标识了下游各个task的数据在文件中的start offset与end offset
      • SortShuffleManager由于有一个磁盘文件merge的过程,因此大大减少了文件数量。比如第一个stage有50个task,总共有10个Executor,每个Executor执行5个task,而第二个stage有100个task。由于每个task最终只有一个磁盘文件,因此此时每个Executor上只有5个磁盘文件,所有Executor只有50个磁盘文件
    • bypass运行机制
      • bypass运行机制的触发条件shuffle map task数量小于spark.shuffle.sort.bypassMergeThreshold参数的值,不是聚合类的shuffle算子(比如:reduceByKey)
      • 此时task会为每个下游task都创建一个临时磁盘文件,并将数据按key进行hash然后根据key的hash值,将key写入对应的磁盘文件之中。当然,写入磁盘文件时也是先写入内存缓冲,缓冲写满之后再溢写到磁盘文件的。最后,同样会将所有临时磁盘文件都合并成一个磁盘文件,并创建一个单独的索引文件
      • 该过程的磁盘写机制其实跟未经优化的HashShuffleManager是一模一样的,因为都要创建数量惊人的磁盘文件,只是在最后会做一个磁盘文件的合并而已。因此少量的最终磁盘文件,也让该机制相对未经优化的HashShuffleManager来说,shuffle read的性能会更好
      • 而该机制与普通SortShuffleManager运行机制的不同在于:第一,磁盘写机制不同;第二,不会进行排序。也就是说,启用该机制的最大好处在于,shuffle write过程中,不需要进行数据的排序操作,也就节省掉了这部分的性能开销
  5. shuffle相关参数调优
    • spark.shuffle.file.buffer
      • 默认值:48m
      • 参数说明:该参数用于设置shuffle read task的buffer缓冲大小,而这个buffer缓冲决定了每次能够拉取多少数据。
      • 调优建议:如果作业可用的内存资源较为充足的话,可以适当增加这个参数的大小(比如96m),从而减少拉取数据的次数,也就可以减少网络传输的次数,进而提升性能。在实践中发现,合理调节该参数,性能会有1%~5%的提升
    • spark.shuffle.io.maxRetries
      • 默认值:3
      • 参数说明:shuffle read task从shuffle write task所在节点拉取属于自己的数据时,如果因为网络异常导致拉取失败,是会自动进行重试的。该参数就代表了可以重试的最大次数。如果在指定次数之内拉取还是没有成功,就可能会导致作业执行失败
      • 调优建议:对于那些包含了特别耗时的shuffle操作的作业,建议增加重试最大次数(比如60次),以避免由于JVM的full gc或者网络不稳定等因素导致的数据拉取失败。在实践中发现,对于针对超大数据量(数十亿~上百亿)的shuffle过程,调节该参数可以大幅度提升稳定性
    • spark.shuffle.io.retryWait
      • 默认值:5s
      • 参数说明:具体解释同上,该参数代表了每次重试拉取数据的等待间隔,默认是5s
      • 调优建议:建议加大间隔时长(比如60s),以增加shuffle操作的稳定性
    • spark.shuffle.memoryFraction
      • 默认值:0.2
      • 参数说明:该参数代表了Executor内存中,分配给shuffle read task进行聚合操作的内存比例,默认是20%
      • 调优建议:在资源参数调优中讲解过这个参数。如果内存充足,而且很少使用持久化操作,建议调高这个比例,给shuffle read的聚合操作更多内存,以避免由于内存不足导致聚合过程中频繁读写磁盘。在实践中发现,合理调节该参数可以将性能提升10%左右
    • spark.shuffle.manager
      • 默认值:sort
      • 参数说明:该参数用于设置ShuffleManager的类型。Spark 1.5以后,有三个可选项:hash、sort和tungsten-sort。HashShuffleManager是Spark 1.2以前的默认选项,但是Spark 1.2以及之后的版本默认都是SortShuffleManager了。tungsten-sort与sort类似,但是使用了tungsten计划中的堆外内存管理机制,内存使用效率更高
      • 调优建议:由于SortShuffleManager默认会对数据进行排序,因此如果你的业务逻辑中需要该排序机制的话,则使用默认的SortShuffleManager就可以;而如果你的业务逻辑不需要对数据进行排序,那么建议参考后面的几个参数调优,通过bypass机制或优化的HashShuffleManager来避免排序操作,同时提供较好的磁盘读写性能。这里要注意的是,tungsten-sort要慎用,因为之前发现了一些相应的bug
    • spark.shuffle.sort.bypassMergeThreshold
      • 默认值:200
      • 参数说明:当ShuffleManager为SortShuffleManager时,如果shuffle read task的数量小于这个阈值(默认是200),则shuffle write过程中不会进行排序操作,而是直接按照未经优化的HashShuffleManager的方式去写数据,但是最后会将每个task产生的所有临时磁盘文件都合并成一个文件,并会创建单独的索引文件
      • 调优建议:当你使用SortShuffleManager时,如果的确不需要排序操作,那么建议将这个参数调大一些,大于shuffle read task的数量。那么此时就会自动启用bypass机制,map-side就不会进行排序了,减少了排序的性能开销。但是这种方式下,依然会产生大量的磁盘文件,因此shuffle write性能有待提高
    • spark.shuffle.consolidateFiles
      • 默认值:false
      • 参数说明:如果使用HashShuffleManager,该参数有效。如果设置为true,那么就会开启consolidate机制,会大幅度合并shuffle write的输出文件,对于shuffle read task数量特别多的情况下,这种方法可以极大地减少磁盘IO开销,提升性能
      • 调优建议:如果的确不需要SortShuffleManager的排序机制,那么除了使用bypass机制,还可以尝试将spark.shffle.manager参数手动指定为hash,使用HashShuffleManager,同时开启consolidate机制。在实践中尝试过,发现其性能比开启了bypass机制的SortShuffleManager要高出10%~30%

推荐阅读

本文所有摘抄知识全部来自该文章,推荐读者有时间去阅读该文章,会有意想不到的收获

// 学习链接:Spark性能优化指南——高级篇
https://tech.meituan.com/2016/05/12/spark-tuning-pro.html

你可能感兴趣的:(spark,spark)