【转】【Spark】Spark 数据倾斜优化方法

https://mp.weixin.qq.com/s?__biz=MzIzNzI1NzY3Nw==&mid=2247484221&idx=1&sn=7e20f08bfb490b91f0920aefb29ca271&chksm=e8ca159fdfbd9c89f610dd230e07f414521b4dd13018994ee9b873421d1e8efcdc535c810225&scene=21#wechat_redirect

大数据梅峰谷 2017-05-19

--------本节内容--------

1.前言

2.Spark数据倾斜

   2.1 数据倾斜现象

     2.1.1 OOM错误

     2.1.2 Spark执行缓慢

2.2 数据倾斜原理

    2.3 数据倾斜原因定位

       2.3.1 通过业务经验定位

       2.3.2 结合Spark原理定位

       2.3.3 查看数据倾斜Key的分布

3.数据倾斜解决方法

    3.1 使用broadcast避免shuffle

    3.2 采样倾斜key并分拆join操作

    3.3 改变并行度缓解数据倾斜

    3.4 两阶段聚合(局部+全局)

    3.5 Hive ETL预处理数据

    3.6 过滤少数导致倾斜的key

    3.7自定义Partitioner

4.参考资料

---------------------- 

 

 

1.前言

数据倾斜是一个非常普遍的问题,不管你的Spark应用于什么场景,碰到数据倾斜的现象近乎是100%,因为太多数业务数据都服从2/8原则。所以热点数据问题是一个常规现象,如何发现、理解和解决数据倾斜问题是我们必须掌握的技巧。并且,数据倾斜的调优是很多Spark程序调优的基础,这个问题没有解决,其他的调优手段显得都很多余。

 

2.数据倾斜

2.1 数据倾斜现象

数据倾斜有2个方面的现象

2.1.1 OOM错误

过多的数据在同一个task中执行,将会把executor撑爆,造成OOM,程序终止运行。报内存溢出的错误,这种错误已经非常严重了,程序直接没法运行起来,可想而知。OOM 现象(这几个图是网上看到的,我觉得很具有代表性)主要有:

1)任务日志显示某节点内存超过yarn的限制:xx G,被yarn杀掉。

2)打开webui(:4040/jobs),打开executor列表,会显示只有一个worker在工作

3)日志报错java.lang.OutOfMemroError:Java heap space,当然实际开发中,不能一看到这个错误,就认为是数据倾斜了,你的程序bug,偶然的数据异常等等,都有可能导致内存溢出。

4) 观察CPU,CPU被打到100%,或者使用率非常高

下图是阿里云集群监控图表中的cpu曲线,黑色的曲线是其中一台worker,靠前的两次100%就是两次问题任务执行,红色的是修改了部分问题之后重新跑了一遍,还是有部分倾斜,但是成功跑完了。最后一段是正常曲线。 

 

5)观察内存,内存爆表

 这里需要注意一点,Spark实际数据到了内存会变的比原来大很多

 

2.1.2 Spark程序执行慢

多数task执行速度较快,少数task执行时间非常长,执行速度非常慢,虽然能执行,但要花很长时间。这种情况也是不允许的,生产中对任务的执行时间是有要求的,不可能允许毫无止境的执行下去,而且也不允许运行时间不可估算。

上图,倒数第三列显示了每个task的运行时间。明显可以看到,有的task运行特别快,只需要几秒钟就可以运行完;而有的task运行特别慢,需要几分钟才能运行完,此时单从运行时间上看就已经能够确定发生数据倾斜了。此外,倒数第一列显示了每个task处理的数据量,明显可以看到,运行时间特别短的task只需要处理几百KB的数据即可,而运行时间特别长的task需要处理几千KB的数据,处理的数据量差了10倍。此时更加能够确定是发生了数据倾斜。此时通过WebUI是可以看到在哪个Stage发生了数据倾斜。

 

2.2 数据倾斜原理

数据倾斜,字面理解,就是数据分布不均衡的意思。表现在Spark分布式集群中,数据在各个节点上分布不均匀,从而某个节点要处理大量的数据,超出了该节点的处理能力。

数据倾斜原理:主要发生在shuffle阶段,因为这个阶段要发生大量的网络通讯,数据要重新布局。【shuffle的过程可以参考前面的博文Spark你妈喊你回家吃饭-13Spark计算引擎剖析,参考文献的第三篇,对shuffle的原理和调优也介绍的非常到位】,在进行shuffle的时候,Spark将各个节点上相同的key拉取到某个节点上的task来处理,比如执行join、groupByKey、reduceByKey、repartion、distinct、agregateByKey、cogroup等操作,此时,如果某个key对应的数据量特别大的话,task就会出现数据倾斜,执行速度非常慢,导致整体任务执行灌满,甚至失败。举个栗子(这个也是来源于互联网),80%的数据集中在某个节点上,该节点有不可承受之重。

2.3 数据倾斜原因定位

一个理想的分布式程序应该是将计算任务分摊到各个节点,尽量均衡的分摊下去。发现数据倾斜的时候,不要急于提高executor的资源,修改参数或是修改程序,首先要检查数据本身,是否存在异常数据,结合WebUI和日志确定和定位是否发生了数据倾斜,那么如何定位是不是发生了数据倾斜呢,这个既要靠经验(对业务的理解)也要靠方法(对Spark运行机制的掌握),方法论+经验论组合判断。

数据倾斜的罪魁祸首就是Key的不均与分析,那开始Shuffle之前,那个Key是怎么划分,怎么产生的? Key分布不均不就是key的划分不合理么,所以要搞清楚Key在Spark中时怎么划分出来的,Key出来之后,是根据什么方法将Key进行分类的,有几个reduce就会将Key分成几类

1)对于键值对类型的RDD,还好理解,执行带有ByKey操作触发shuffle时,用的key就是键值对RDD的Key。

2)对于不是键值对RDD的Key,系统自带的Hash函数产生Key,这个会比较均匀。

所以在在开发的时候,对于带有ByKey的算子使用,就要长点心了,不要掉坑里去了。

2.3.1 通过业务经验定位

分析业务数据,大多数业务的数据分布都服从2/8法则,所以定位的时候有以下几点给予参考:

1)正常数据:结合业务规则,分析热点数据的分布,找出hot key

2)空数据:key为null(空值)或是一些无意义的数据构成

3)异常数据:大量重复的测试数据或是对结果影响不大的异常数据。

单独把key拉出来观察数据分布,或者通过抽样的方式,分析key的分布

df.select("key").sample(false,0.1).(k=>(k,1)).reduceBykey(_+_).map(k=>(k._2,k._1)).sortByKey(false).take(100)

如果发现多数数据分布都较为平均,而个别数据比其他数据大上若干个数量级,则说明发生了数据倾斜。

 

2.3.2 结合Spark原理定位

主要是spark使用不当导致数据倾斜,例如parittion指定的个数不合理等,一些带byKey的算子滥用等,结合shuffle原理来判断是不是会数据倾斜,就要求对spark的机制理解的非常透彻,比如stage的划分,举个例子

-----------

val conf = new SparkConf() val sc = new SparkContext(conf) val lines = sc.textFile("hdfs://...") val words = lines.flatMap(_.split(" ")) val pairs = words.map((_, 1)) val wordCounts = pairs.reduceByKey(_ + _) wordCounts.collect().foreach(println(_))

-------------

这里我们就以Spark最基础的入门程序——单词计数来举例,如何用最简单的方法大致推算出一个stage对应的代码。如下示例,在整个代码中,只有一个reduceByKey是会发生shuffle的算子,因此就可以认为,以这个算子为界限,会划分出前后两个stage。

·stage0,主要是执行从textFile到map操作,以及执行shuffle write操作。shuffle write操作,我们可以简单理解为对pairs RDD中的数据进行分区操作,每个task处理的数据中,相同的key会写入同一个磁盘文件内。

·stage1,主要是执行从reduceByKey到collect操作,stage1的各个task一开始运行,就会首先执行shuffle read操作。执行shuffle read操作的task,会从stage0的各个task所在节点拉取属于自己处理的那些key,然后对同一个key进行全局性的聚合或join等操作,在这里就是对key的value值进行累加。stage1在执行完reduceByKey算子之后,就计算出了最终的wordCounts RDD,然后会执行collect算子,将所有数据拉取到Driver上,供我们遍历和打印输出。

通过对单词计数程序的分析,希望能够让大家了解最基本的stage划分的原理,以及stage划分后shuffle操作是如何在两个stage的边界处执行的。然后我们就知道如何快速定位出发生数据倾斜的stage对应代码的哪一个部分了。比如我们在Spark Web UI或者本地log中发现,stage1的某几个task执行得特别慢,判定stage1出现了数据倾斜,那么就可以回到代码中定位出stage1主要包括了reduceByKey这个shuffle类算子,此时基本就可以确定是由educeByKey算子导致的数据倾斜问题。比如某个单词出现了100万次,其他单词才出现10次,那么stage1的某个task就要处理100万数据,整个stage的速度就会被这个task拖慢。

2.3.3 查看数据倾斜Key的分布

知道了数据倾斜发生在哪里之后,通常需要分析一下那个执行了shuffle操作并且导致了数据倾斜的RDD/Hive表,查看一下其中key的分布情况。这主要是为之后选择哪一种技术方案提供依据。针对不同的key分布与不同的shuffle算子组合起来的各种情况,可能需要选择不同的技术方案来解决。

此时根据你执行操作的情况不同,可以有很多种查看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)Web UI,可以清晰看见哪些个Task运行的数据量大小;

2)Log,Log的一个好处是可以清晰的告诉是哪一行出现问题OOM,同时可以清晰的看到在具体哪个Stage出现了数据倾斜(数据倾斜一般是在Shuffle过程中产生的),从而定位具体Shuffle的代码;也有可能发现绝大多数Task非常快,但是个别Task非常慢;

3)代码走读,重点看join、groupByKey、reduceByKey等关键代码;

4)结合业务,对数据特征分布进行分析。

 

3.数据倾斜解决方法

3.1 使用broadcast避免shuffle

1)尽可能的将Reduce端放在Map端,就避免了shuffle,避免了shuffle就在很大情况下化解了数据倾斜的问题

2)什么叫MappedReduce,整个spark是Rdd的链式操作,我们的DAGScheduler根据不同得RDD类型的依赖关系划分成不同的stage,不同类型依赖关系就是宽依赖和窄依赖。宽依赖的时候把stage换分成更小的stage,我们想做的事是把宽依赖减掉,避免掉shuffle,把操作直接放在map端。从stage角度讲,后边stage都是前面前面stage都是后边stage的map的。对我们解决数据倾斜很有帮助。

3)使用broadcast避免shuffle

 MapReduce过程:RDD1和RDD2要进行join操作,spark根据key在reduce阶段划分了2个task,然后发生shuffle,数据根据key的情况将数据移动相应的executor所在节点,组成成RDD3,RDD3下有2个task,这2 task拿到shuffle后的数据,各自进行join操作,最后将结果合并。

 分析:分析发现,RDD1的数据非常大,RDD2的数据很小,可以将RDD2广播到RDD1,这样计算就发生在Map阶段了,有效避免了shuffle

4)举例说明

用broadcast + filter来代替join,这种优化是一种特定场景的神器,就是拿大的RDD A去join一个小的RDD B,比如有这样两个RDD:

·A的结构为(name, age, sex),表示全国人民的RDD,超大

·B的结果为(age, title),表示“年龄 -> 称号”的映射,比如60岁有称号“花甲之年”,70岁则是“古稀之年”,这个RDD显然很小,因为人的年龄范围在0~200岁之间,而且有的“年龄”还没有“称号”

现在我要从全国人民中找出这些有称号的人来。如果直接写成:

A.map{case (name, age, sex) => (age, (name, sex))} .join(B) .map{case (age, ((name, sex), title)) => (name, age, sex)}

你就可以想象,执行的时候超大的A被打散和分发到各个节点去。而且更要命的是,为了恢复一开始的(name, age, sex)的结构,又做了一次map,而这次map一样导致shuffle。两次shuffle,太疯狂了。但是如果这样写:

val b = sc.broadcast(B.collectAsMap) A.filter{case (name, age, sex) => b.values.contains(age)}

一次shuffle都没有,A老老实实待着不动,等着全量的B被分发过来。

5)优点和缺点

 优点:1)有效避免shuffle的发生,2)broadcast是进程级别,并且只读的,spark task是线程级别,所以很安全;3)spark sql中有小表,会自动进行broadcast,要配置点参数,配置信息的分布也可以broadcast。

 缺点:1)要广播出去的rdd数据要比较小,否则也会导致OOM,2)并且对GC也会有影响,因为广播变量是常住内存的,很容易变成老年代,不适合两个rdd数据量都非常大情况

场景:两个要shuffle的表,有一个rdd数据非常小很适合使用。那两个表都很大要进行join该怎么优化,1)优化key,2)采样

 

3.2 采样倾斜key并分拆join操作

适用场景:两个RDD/Hive表进行join的时候,如果数据量都比较大,那么此时可以看一下两个RDD/Hive表中的key分布情况。如果出现数据倾斜,是因为其中某一个RDD/Hive表中的少数几个key的数据量过大,而另一个RDD/Hive表中的所有key都分布比较均匀,那么采用这个解决方案是比较合适的。

方案实现思路:

1)统计热门Key

对包含少数几个数据量过大的key的那RDD,通过sample算子采样出一份样本来,然后统计一下每个key的数量,计算出来数据量最大的是哪几个key。

2)拆分热门Key的RDD

然后将这几个key对应的数据从原来的RDD中拆分出来,形成一个单独的RDD,并给每个key都打上n以内的随机数作为前缀,而不会导致倾斜的大部分key形成另外一个也会导致倾斜的RDD。

3)扩容被Join的RDD

因为前面是随机加了随机数,所以需要将要参与join的另一个RDD,也过滤出来那几个倾斜key对应的数据并形成一个单独的RDD,将每条数据膨胀成n条数据,这n条数据都按顺序附加一个0~n的前缀,不会导致倾斜的大部分key也形成另外一个RDD。

4)执行Join被拆分和被扩容的RDD

再将附加了随机前缀的独立RDD与另一个膨胀n倍的独立RDD进行join,此时就可以将原先相同的key打散成n份,分散到多个task中去进行join了。

5)执行非热门RDDjoin

另外两个普通的RDD就照常join即可。

6)合并热门RDD和非热门RDD join 后的结果

最后将两次join的结果使用union算子合并起来即可,就是最终的join结果。

 

实现原理:对于join导致的数据倾斜,如果只是某几个key导致了倾斜,可以将少数几个key分拆成独立RDD,并附加随机前缀打散成n份去进行join,此时这几个key对应的数据就不会集中在少数几个task上,而是分散到多个task进行join了。具体原理见下图。

方案优点:对于join导致的数据倾斜,如果只是某几个key导致了倾斜,采用该方式可以用最有效的方式打散key进行join。而且只需要针对少数倾斜key对应的数据进行扩容n倍,不需要对全量数据进行扩容。避免了占用过多内存。

方案缺点:如果导致倾斜的key特别多的话,比如成千上万个key都导致数据倾斜,那么这种方式也不适合。

举个栗子:代码demo如下

-----------------

// 首先从包含了少数几个导致数据倾斜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); 

}

});

 

//取出导致数据倾斜的Key出来,只取出了一个 

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);

-----------------

3.3 改变并行度缓解数据倾斜

原理

Spark在做Shuffle时,默认使用HashPartitioner(非Hash Shuffle)对数据进行分区。如果并行度设置的不合适,可能造成大量不相同的Key对应的数据被分配到了同一个Task上,造成该Task所处理的数据远大于其它Task,从而造成数据倾斜。

如果调整Shuffle时的并行度,使得原本被分配到同一Task的不同Key发配到不同Task上处理,则可降低原Task所需处理的数据量,从而缓解数据倾斜问题造成的短板效应。

案例

现有一张测试表,名为student_external,内有10.5亿条数据,每条数据有一个唯一的id值。现从中取出id取值为9亿到10.5亿的共1.5条数据,并通过一些处理,使得id为9亿到9.4亿间的所有数据对12取模后余数为8(即在Shuffle并行度为12时该数据集全部被HashPartition分配到第8个Task),其它数据集对其id除以100取整,从而使得id大于9.4亿的数据在Shuffle时可被均匀分配到所有Task中,而id小于9.4亿的数据全部分配到同一个Task中。处理过程如下:

--------------

INSERT OVERWRITE TABLE test

SELECT CASE WHEN id < 940000000 THEN (9500000  + (CAST (RAND() * 8 AS INTEGER)) * 12 )

ELSE CAST(id/100 AS INTEGER)

END,

name

FROM student_external

WHERE id BETWEEN 900000000 AND 1050000000;

--------------

通过上述处理,一份可能造成后续数据倾斜的测试数据即以准备好。接下来,使用Spark读取该测试数据,并通过groupByKey(12)对id分组处理,且Shuffle并行度为12。代码如下

--------------------

public class SparkDataSkew {

public static void main(String[] args) {

SparkSession sparkSession = SparkSession.builder()

.appName("SparkDataSkewTunning")

.config("hive.metastore.uris", "thrift://hadoop1:9083")

.enableHiveSupport()

.getOrCreate();

 

Dataset<Row> dataframe = sparkSession.sql( "select * from test");

dataframe.toJavaRDD()

.mapToPair((Row row) -> new Tuple2<Integer, String>(row.getInt(0),row.getString(1)))

.groupByKey(12)

.mapToPair((Tuple2<Integer, Iterable<String>> tuple) -> {

int id = tuple._1();

AtomicInteger atomicInteger = new AtomicInteger(0);

tuple._2().forEach((String name) -> atomicInteger.incrementAndGet());

return new Tuple2<Integer, Integer>(id, atomicInteger.get());

}).count();

 

sparkSession.stop();

sparkSession.close();

}

}

-----------------

本次实验所使用集群节点数为4,每个节点可被Yarn使用的CPU核数为16,内存为16GB。使用如下方式提交上述应用,将启动4个Executor,每个Executor可使用核数为12(该配置并非生产环境下的最优配置,仅用于本文实验),可用内存为12GB。

spark-submit --queue ambari --num-executors 4 --executor-cores 12 --executor-memory 12g --class com.jasongj.spark.driver.SparkDataSkew --master yarn --deploy-mode client SparkExample-with-dependencies-1.0.jar

GroupBy Stage的Task状态如下图所示,Task 8处理的记录数为4500万,远大于(9倍于)其它11个Task处理的500万记录。而Task 8所耗费的时间为38秒,远高于其它11个Task的平均时间(16秒)。整个Stage的时间也为38秒,该时间主要由最慢的Task 8决定。

在这种情况下,可以通过调整Shuffle并行度,使得原来被分配到同一个Task(即该例中的Task 8)的不同Key分配到不同Task,从而降低Task 8所需处理的数据量,缓解数据倾斜。

通过groupByKey(48)将Shuffle并行度调整为48,重新提交到Spark。新的Job的GroupBy Stage所有Task状态如下图所示。

在这种场景下,调整并行度,并不意味着一定要增加并行度,也可能是减小并行度。如果通过groupByKey(11)将Shuffle并行度调整为11,重新提交到Spark。新Job的GroupBy Stage的所有Task状态如下图所示。

从上图可见,处理记录数最多的Task 6所处理的记录数约为1045万,耗时为23秒。处理记录数最少的Task 1处理的记录数约为545万,耗时12秒。

适用场景

大量不同的Key被分配到了相同的Task造成该Task数据量过大。

解决方案

调整并行度。一般是增大并行度,但有时如本例减小并行度也可达到效果。

优势

实现简单,可在需要Shuffle的操作算子上直接设置并行度或者使用spark.default.parallelism设置。如果是Spark SQL,还可通过SET spark.sql.shuffle.partitions=[num_tasks]设置并行度。可用最小的代价解决问题。一般如果出现数据倾斜,都可以通过这种方法先试验几次,如果问题未解决,再尝试其它方法。

劣势

适用场景少,只能将分配到同一Task的不同Key分散开,但对于同一Key倾斜严重的情况该方法并不适用。并且该方法一般只能缓解数据倾斜,没有彻底消除问题。从实践经验来看,其效果一般。

3.4 两阶段聚合(局部+全局)

方案适用场景:对RDD执行reduceByKey等聚合类shuffle算子或者在Spark SQL中使用group by语句进行分组聚合时,比较适用这种方案。

方案实现思路:这个方案的核心实现思路就是进行两阶段聚合。第一次是局部聚合,先给每个key都打上一个随机数,比如10以内的随机数,此时原先一样的key就变成不一样的了,比如(hello, 1) (hello, 1) (hello, 1) (hello, 1),就会变成(1_hello, 1) (1_hello, 1) (2_hello, 1) (2_hello, 1)。接着对打上随机数后的数据,执行reduceByKey等聚合操作,进行局部聚合,那么局部聚合结果,就会变成了(1_hello, 2) (2_hello, 2)。然后将各个key的前缀给去掉,就会变成(hello,2)(hello,2),再次进行全局聚合操作,就可以得到最终结果了,比如(hello, 4),和3.2【采样倾斜key并分拆join操作】的方法类似。

方案实现原理:将原本相同的key通过附加随机前缀的方式,变成多个不同的key,就可以让原本被一个task处理的数据分散到多个task上去做局部聚合,进而解决单个task处理数据量过多的问题。接着去除掉随机前缀,再次进行全局聚合,就可以得到最终的结果。具体原理见下图。

方案优点:对于聚合类的shuffle操作导致的数据倾斜,效果是非常不错的。通常都可以解决掉数据倾斜,或者至少是大幅度缓解数据倾斜,将Spark作业的性能提升数倍以上。

方案缺点:仅仅适用于聚合类的shuffle操作,适用范围相对较窄。如果是join类的shuffle操作,还得用其他的解决方案。

 

3.5 Hive ETL预处理数据

适用场景:导致数据倾斜的是Hive表。如果该Hive表中的数据本身很不均匀(比如某个key对应了100万数据,其他key才对应了10条数据),而且业务场景需要频繁使用Spark对Hive表执行某个分析操作,那么比较适合使用这种技术方案。

实现思路:此时可以评估一下,是否可以通过Hive来进行数据预处理(即通过Hive ETL预先对数据按照key进行聚合,或者是预先和其他表进行join),然后在Spark作业中针对的数据源就不是原来的Hive表了,而是预处理后的Hive表。此时由于数据已经预先进行过聚合或join操作了,那么在Spark作业中也就不需要使用原先的shuffle类算子执行这类操作了。

实现原理:这种方案从根源上解决了数据倾斜,因为彻底避免了在Spark中执行shuffle类算子,那么肯定就不会有数据倾斜的问题了。但是这里也要提醒一下大家,这种方式属于治标不治本。因为毕竟数据本身就存在分布不均匀的问题,所以Hive ETL中进行group by或者join等shuffle操作时,还是会出现数据倾斜,导致Hive ETL的速度很慢。我们只是把数据倾斜的发生提前到了Hive ETL中,避免Spark程序发生数据倾斜而已。

优点:实现起来简单便捷,效果还非常好,完全规避掉了数据倾斜,Spark作业的性能会大幅度提升。

缺点:治标不治本,Hive ETL中还是会发生数据倾斜。

实践经验:在一些Java系统与Spark结合使用的项目中,会出现Java代码频繁调用Spark作业的场景,而且对Spark作业的执行性能要求很高,就比较适合使用这种方案。将数据倾斜提前到上游的Hive ETL,每天仅执行一次,只有那一次是比较慢的,而之后每次Java调用Spark作业时,执行速度都会很快,能够提供更好的用户体验。

实践经验:在美团·点评的交互式用户行为分析系统中使用了这种方案,该系统主要是允许用户通过Java Web系统提交数据分析统计任务,后端通过Java提交Spark作业进行数据分析统计。要求Spark作业速度必须要快,尽量在10分钟以内,否则速度太慢,用户体验会很差。所以我们将有些Spark作业的shuffle操作提前到了Hive ETL中,从而让Spark直接使用预处理的Hive中间表,尽可能地减少Spark的shuffle操作,大幅度提升了性能,将部分作业的性能提升了6倍以上。

 

3.6 过滤少数导致倾斜的key

适用场景:如果发现导致倾斜的key就少数几个,而且对计算本身的影响并不大的话,那么很适合使用这种方案。比如99%的key就对应10条数据,但是只有一个key对应了100万数据,从而导致了数据倾斜。

实现思路:如果我们判断那少数几个数据量特别多的key,对作业的执行和计算结果不是特别重要的话,那么干脆就直接过滤掉那少数几个key。比如,在Spark SQL中可以使用where子句过滤掉这些key或者在Spark Core中对RDD执行filter算子过滤掉这些key。如果需要每次作业执行时,动态判定哪些key的数据量最多然后再进行过滤,那么可以使用sample算子对RDD进行采样,然后计算出每个key的数量,取数据量最多的key过滤掉即可。

实现原理:将导致数据倾斜的key给过滤掉之后,这些key就不会参与计算了,自然不可能产生数据倾斜。

优点:实现简单,而且效果也很好,可以完全规避掉数据倾斜。

缺点:适用场景不多,大多数情况下,导致倾斜的key还是很多的,并不是只有少数几个。

实践经验:在项目中我们也采用过这种方案解决数据倾斜。有一次发现某一天Spark作业在运行的时候突然OOM了,追查之后发现,是Hive表中的某一个key在那天数据异常,导致数据量暴增。因此就采取每次执行前先进行采样,计算出样本中数据量最大的几个key之后,直接在程序中将那些key给过滤掉。

3.7 自定义partitioner

原理

使用自定义的Partitioner(默认为HashPartitioner),将原本被分配到同一个Task的不同Key分配到不同Task。

案例

以上述3.3 例子继续,将并发度设置为12,但是在groupByKey算子上,使用自定义的Partitioner(实现如下)

------------------------

.groupByKey(new Partitioner() {

@Override

public int numPartitions() {

return 12;

}

 

@Override

public int getPartition(Object key) {

int id = Integer.parseInt(key.toString());

if(id >= 9500000 && id <= 9500084 && ((id - 9500000) % 12) == 0) {

return (id - 9500000) / 12;

} else {

return id % 12;

}

}

})

------------------------

由下图可见,使用自定义Partition后,耗时最长的Task 6处理约1000万条数据,用时15秒。并且各Task所处理的数据集大小相当。

适用场景

大量不同的Key被分配到了相同的Task造成该Task数据量过大。

解决方案

使用自定义的Partitioner实现类代替默认的HashPartitioner,尽量将所有不同的Key均匀分配到不同的Task中。

优势

不影响原有的并行度设计。如果改变并行度,后续Stage的并行度也会默认改变,可能会影响后续Stage。

劣势

适用场景有限,只能将不同Key分散开,对于同一Key对应数据集非常大的场景不适用。效果与调整并行度类似,只能缓解数据倾斜而不能完全消除数据倾斜。而且需要根据数据特点自定义专用的Partitioner,不够灵活。

 

4.参考资料

1.https://yq.aliyun.com/articles/62541spark 数据倾斜的一些表现

2.https://www.iteblog.com/archives/1671.html Spark性能优化:数据倾斜调优

3.https://www.iteblog.com/archives/1672.html shuffle调优

4.http://www.jasongj.com/spark/skew/ 解决Spark数据倾斜(Data Skew)的N种姿势

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值