Spark系列文章二——Spark数据倾斜及调优

目录

摘要  

一、数据倾斜现象。

二、数据倾斜的原理

2.1、原理详解

2.2、举例说明

2.3、常见的shuffle分发数据规则及其优缺点(扩展)

三、数据倾斜代码定位

3.1、某个Task执行特别慢的情况

3.2、某个Task莫名其妙的内存溢出

四、数据倾斜的解决方案

4.1、使用Hive ETL进行预处理数据

4.2、过滤不需要的导致数据倾斜的数据

4.3、增加shuffle的并行度

4.4、两阶段聚合(局部聚合+全局聚合)

4.5、将reduce join转化为 Map join

4.6、采样倾斜key并拆分join操作

4.7、使用随机前缀+扩容RDD进行join

4.8、根据实际业务,融合贯通


摘要  

        相信大家在使用Spark进行大数据计算的时候,都遇到过一个问题——数据倾斜。当发生数据倾斜的时候,spark程序就会发生运行效率低下,甚至发生内存溢出异常,导致任务失败。为了防止这种情况,开发人员就需要使用技术手段进行数据调优,保证Spark运行效率。
        本文将从spark数据倾斜的现象、原理、倾斜代码定位及调优四个方面来详细描述Spark数据倾斜及调优,希望小伙伴们看完本文之后会有所收获。

一、数据倾斜现象。

数据倾斜的体现主要表现在以下两方面。

1、任务中极大多数Task正常执行,极个别Task执行极慢(常见)

        例如:一个Stage有100个Task,执行任务时候,99个Task在10秒左右执行完成,只有1个Task执行时间长达 1个多小时。

2、发生内存溢出(少见)

        例如:可以正常运行的任务,突然某一天发生内存溢出的异常报错。观察异常栈,发现是我们代码造成的。(不过这种情况很少见)

二、数据倾斜的原理

出现数据倾斜的原因只有一个,那就是shuffle算子造成的。

2.1、原理详解

spark任务在进行shuffle计算的时候,必须将各个节点上相同的key拉到某个节点的一个Task上进行处理,比如按照key来进行聚合或者join操作。如果某个Key的数据量特别大的时候,就会发生数据倾斜。例如一个任务中,大部分key对应的数据只有10条,只有一个key对应的数据达到1000W条,那么大部分key分配到的数据只有10条,1秒钟可能就执行完了,但是这个key分配到1000W数据,要运行3/4个小时。因为整个Spark运行时间由运行时间最长的那个Task决定,那这种情况就会导致其他Task一直处于等待状态,资源闲置。

因此出现数据倾斜的时候,Spark任务运行会非常缓慢,甚至出现某个Task资源不足而触发OOM异常,导致整个任务运行失败。

2.2、举例说明

 上图就是一个特别清晰地例子:hello这个key一共出现了7次,这些数据会被分发到同一个task处理,而world和you这两个key各出现1次,所以另外两个task各处理1条数据即可。此时第一个Task处理数据的时间可能是其他Task的7  倍。从而导致整个Stage的运行时间延长。

2.3、常见的shuffle分发数据规则及其优缺点(扩展)

1、哈希散列

        优点:相同key的数据绝对会在同一个task

        缺点:容易发生数据倾斜

2、轮询机制

        优点:数据均匀的发送到task上,不会出现数据倾斜

        缺点:无法保证相同key的数据在同一个Task

3、范围分发(Hbase的table默认规则)

        优点:相邻的数据会分发到同一个Task

        缺点:范围划分的中间值确认不好会导致数据倾斜。

        Hbase解决数据倾斜的方式:每个region分裂的时候,按照从中间切割的方式。

4、随机分发

        优点:绝对不会数据倾斜

        缺点:相同的数据也会均匀的分发到各个Task

5、自定义

        优点:灵活

        缺点:分区细节太多,需要自己指定

三、数据倾斜代码定位

3.1、某个Task执行特别慢的情况

        针对某个Task执行特别慢的情况,我们首先需要做的就是,先确定数据倾斜发生在哪一个Stage。

        如果是本地测试(spark-client)提交,那么可以直接找到本地log,查看当前任务执行到哪个stage,如果是生产(spark-cluster)提交,那么可以在Spark Web ui 查看当前任务执行到哪个stage。不管本地测试还是生产提交,度可以再Spark Web UI 查看当前Stage为各个Task分配的任务量。

        找到问题发生在哪个stage之后,我们可以根据spark的Stage划分原理,推算出来这个stage在代码中的位置,这个位置附近肯定会有shuffle操作。这个推断的前提是得熟悉Spark的源码,在这里推荐一个相对简单的方法:只要看到spark中shuffle算子或者spark sql中会导致shuffle的语句(例如 group by),那么我们就可以认为,以这个算子为界限,划分出了2 个stage。

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

针对上述代码进行stage分析

1 stage0 ,主要是执行从 textFile map 操作,以及执行 shuffle write 操作。 shuffle write 操 作,我们可以简单理解为对pairs RDD 中的数据进行分区操作,每个 task 处理的数据中,相同的 key 会写入 同一个磁盘文件内。
2 stage1 ,主要是执行从 reduceByKey collect 操作, stage1 的各个 task 一开始运行,就会首先执行 shuffle read操作。执行 shuffle read 操作的 task ,会从 stage0 的各个 task 所在节点拉取属于自己处理的那些key ,然后对同一个 key 进行全局性的聚合或 join 等操作,在这里就是对 key value 值进行累加。stage1在执行完 reduceByKey 算子之后,就计算出了最终的 wordCounts RDD ,然后会执行 collect 算子,将所有数据拉取到Driver 上,供我们遍历和打印输出。

总结:Spark的应用程序的运行会分成多个Stage进行。每个Stage会并行运行很多的同种类型的Task,在正常和理想情况下,每个Task处理的数量一样,因为也应该处理消耗时间一样。但是由于多个stage之间存在shuffle过程,如果shuffle过程中,上一个stage分发给下一个stage的数据不均匀,则容易出现数据倾斜。

3.2、某个Task莫名其妙的内存溢出

        出现这个问题,定位可以直接根据报错找到相应代码的行号,在这行附近,肯定有shuffle算子。此时,很可能就是这个算子导致了数据倾斜的发生。

注意:出现这个异常,不一定是数据倾斜,首先查看自己的业务代码是否有问题,然后还有可能是偶发性的数据问题(突然某一天某个Key的数据量暴增)

四、数据倾斜的解决方案

4.1、使用Hive ETL进行预处理数据

方案适用场景:业务场景中需要spark处理hive中的表,但是hive中的数据本身就有数据倾斜的问题存在。例如,hive表中某个key对应的数据有100W条,而大部分key对应的数据只有10条左右。针对这种现象,就比较适合这种技术方案。

方案实现思路:先评估,是否可以通过Hive来处理该表数据(通过HQL事先进行按照key对数据进行聚合。或者跟其他表进行join。)然后spark处理的不是原先的hive表,而是预处理过之后新的hive表。因为数据已经事先进行了聚合或者join操作了,所以spark也不用再进行shuffle类算子操作。 

方案实现原理:在hive中进行聚合或者join操作,就是从根源上解决了数据倾斜,因为避免了spark的shuffle算子,所以自然而然就不会产生数据倾斜。

方案优点:通过简单便捷的Hive ETL避免了数据倾斜,Spark性能大幅度提高。

方案缺点:治标不治本,Hive中还是有可能发生数据倾斜。

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

4.2、过滤不需要的导致数据倾斜的数据

将少数不影响计算结果且导致数据倾斜的数据过滤。

方案适用场景如果发现导致倾斜的 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给过滤掉。

4.3、增加shuffle的并行度

方案适用场景如果我们必须要对数据倾斜迎难而上,那么建议优先使用这种方案,因为这是处理数据倾斜最简单的一种方案。

方案实现思路在对 RDD 执行 shuffle 算子时,给 shuffle 算子传入一个参数,比如reduceByKey(1000),该参数就设置了这个 shuffle 算子执行时 shuffle read task 的数量。对于 Spark SQL 中的 shuffle 类语句,比如 group byjoin 等,需要设置一个参数,即spark.sql.shuffle.partitions,该参数代表了 shuffle read task 的并行度,该值默认是 200,对于很多场景来说都有点过小。

方案实现原理增加 shuffle read task 的数量,可以让原本分配给一个 task 的多个 key 分配给多个task,从而让每个task处理比原来更少的数据。举例来说,如果原本有 5 key,每个 key 对应 10 条数据,这 5 key 都是分配给一个 task 的,那么这个 task 就要处理 50 条数据。而增加了 shuffle read task 以后,每个 task 就分配到一个 key,即每个 task 就处理 10 条数据,那么自然每个 task 的执行时间都会变短了。具体原理如下图所示。

方案优点实现起来比较简单,可以有效缓解和减轻数据倾斜的影响。

方案缺点只是缓解了数据倾斜而已,没有彻底根除问题,根据实践经验来看,其效果有限。

方案实践经验:该方案通常无法彻底解决数据倾斜,因为如果出现一些极端情况,比如某个 key 对应的数据量有100 万,那么无论你的 task 数量增加到多少,这个对应着 100 万数据的 key 肯定还是会分配到一个 task 中去处理,因此注定还是会发生数据倾斜的。所以这种方案只能说是在发现数据倾斜时尝试使用的第一种手段,尝试去用嘴简单的方法缓解数据倾斜而已,或者是和其他方案结合起来使用。

4.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)

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

在导致倾斜的常见场景中,针对于 聚合操作 产生的 倾斜来说,这可以认为是一种通用的解决方案:
rdd.map(x.key => random + x.key).reduceByKey().map(random + x.key =>
x.key).reduceByKey()
// 第一步,给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;
                }
            });

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

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

4.5、将reduce join转化为 Map join

方案适用场景在对 RDD 使用 join 类操作,或者是在 Spark SQL 中使用 join 语句时,而且 join 操作中的一个RDD 或表的数据量比较小(比如几百M或者一两G),比较适用此方案。

方案实现思路不使用 join 算子进行连接操作,而使用 Broadcast 变量与 map 类算子实现 join 操作,进而完全规避掉 shuffle 类的操作,彻底避免数据倾斜的发生和出现。将较小 RDD 中的数据直接通过collect 算子拉取到Driver 端的内存中来,然后对其创建一个 Broadcast 变量;接着对另外一个 RDD 执行 map 类算子,在算子函数内,从 Broadcast 变量中获取较小 RDD 的全量数据,与当前 RDD 的每一条数据按照连接 key 进行比对,如果连接 key 相同的话,那么就将两个 RDD 的数据用你需要的方式连 接起来。

方案实现原理普通的 join 是会走 shuffle 过程的,而一旦 shuffle,就相当于会将相同 key 的数据拉取到一个shuffle read task 中再进行 join,此时就是 reduce join。但是如果一个 RDD 是比较小的,则可以采用广播小 RDD 全量数据 + map 算子来实现与 join 同样的效果,也就是 map join,此时就不会发生 shuffle 操作,也就不会发生数据倾斜。具体原理如下图所示。

// 首先将数据量比较小的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后的数据。

方案优点对 join 操作导致的数据倾斜,效果非常好,因为根本就不会发生 shuffle,也就根本不会发生数据倾斜。

方案缺点适用场景较少,因为这个方案只适用于一个大表和一个小表的情况。毕竟我们需要将小表进行广播,此时会比较消耗内存资源,driver 和每个 Executor 内存中都会驻留一份小 RDD 的全量数据。如果我们广播出去的RDD 数据比较大,比如 10G 以上,那么就可能发生内存溢出了。因此并不适合两个都是大表的情况。

4.6、采样倾斜key并拆分join操作

如果导致倾斜的key是最终数据结果的一部分,不能丢弃,那么就应该把这些导致倾斜的key, 拿出来单独处理

1、第一个任务,由于已经没有了导致倾斜的key,所以这个普通任务没有数据倾斜

2 、第二个任务,拿出来的 key 单独形成一个任务,单独处理。
3、最后需要做一个操作:把两部分结果给union 起来

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

方案实现思路:总结为以下几步

1 、对包含少数几个数据量过大的 key 的那个 RDD ,通过 sample 算子采样出一份样本来,然后统计一下每个 key 的数量,计算出来数据量最大的是哪几个 key
2 、然后将这几个 key 对应的数据从原来的 RDD 中拆分出来,形成一个单独的 RDD ,并给每个 key 都打上 n 以内的随机数作为前缀,而不会导致倾斜的大部分 key 形成另外一个 RDD
3 、接着将需要 join 的另一个 RDD ,也过滤出来那几个倾斜 key 对应的数据并形成一个单独的 RDD ,将每条数据膨胀成 n 条数据,这 n 条数据都按顺序附加一个 0~n 的前缀,不会导致倾斜的大部分 key 也形成另外一个 RDD
4 、再将附加了随机前缀的独立 RDD 与另一个膨胀 n 倍的独立 RDD 进行 join ,此时就可以将原先相同的key 打散成 n 份,分散到多个 task 中去进行 join 了。
5 、而另外两个普通的 RDD 就照常 join 即可。
6 、最后将两次 join 的结果使用 union 算子合并起来即可,就是最终的 join 结果。

方案实现原理对于 join 导致的数据倾斜,如果只是某几个 key 导致了倾斜,可以将少数几个 key 分拆成独立 RDD,并附加随机前缀打散成 n 份去进行 join,此时这几个 key 对应的数据就不会集中在少数几个 task 上,而是分散到多个 task 进行 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);
方案优点对于 join 导致的数据倾斜,如果只是某几个 key 导致了倾斜,采用该方式可以用最有效的 方式打散 key 进行 join 。而且只需要针对少数倾斜 key 对应的数据进行扩容 n 倍,不需要对全量数据进 行扩容。避免了占用过多内存。
方案缺点如果导致倾斜的 key 特别多的话,比如成千上万个 key 都导致数据倾斜,那么这种方式也不 适合。

4.7、使用随机前缀+扩容RDD进行join

方案适用场景如果在进行 join 操作时,RDD 中有大量的 key 导致数据倾斜,那么进行分拆 key 也没 什么意义,此时就只能使用最后一种方案来解决问题了。

方案实现思路:总结为以下几步:

1 、该方案的实现思路基本和 解决方案六 类似,首先查看 RDD/Hive 表中的数据分布情况,找到那个造成数据倾斜的RDD/Hive 表,比如有多个 key 都对应了超过 1 万条数据。
2 、然后将该 RDD 的每条数据都打上一个 n 以内的随机前缀。
3 、同时对另外一个正常的 RDD 进行扩容,将每条数据都扩容成 n 条数据,扩容出来的每条数据都依次打上一个 0~n的前缀。
4 、最后将两个处理后的 RDD 进行 join 即可。

方案实现原理将原先一样的 key 通过附加随机前缀变成不一样的 key,然后就可以将这些处理后的 “不同key” 分散到多个 task 中去处理,而不是让一个 task 处理大量的相同 key。该方案与解决方案六” 的不同之处就在于,上一种方案是尽量只对少数倾斜 key 对应的数据进行特殊处理,由于处理过程需要扩容 RDD,因此上一种方案扩容RDD 后对内存的占用并不大;而这一种方案是针对有大量倾斜 key 的情况,没法将部分 key 拆分出来进行单独处理,因此只能对整个 RDD 进行数据扩容,对内存资源要求很高。

方案优点对 join 类型的数据倾斜基本都可以处理,而且效果也相对比较显著,性能提升效果非常不错。

方案缺点该方案更多的是缓解数据倾斜,而不是彻底避免数据倾斜。而且需要对整个 RDD 进行扩容,对内存资源要求很高。

方案实践方案:曾经开发一个数据需求的时候,发现一个 join 导致了数据倾斜。优化之前,作业的执行时间大约是 60 分钟左右;使用该方案优化之后,执行时间缩短到 10 分钟左右,性能提升了 6 倍。

// 首先将其中一个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>(i + "_" + 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);

4.8、根据实际业务,融合贯通

        在实践中发现,很多情况下,如果只是处理较为简单的数据倾斜场景,那么使用上述方案中的某一种基本就可以解决。但是如果要处理一个较为复杂的数据倾斜场景,那么可能需要将多种方案组合起来使用。比如说,我们针对出现了多个数据倾斜环节的 Spark 作业,可以先运用解决方案一和二,预处理一部分数据,并过滤一部分数据来缓解;其次可以对某些 shuffle 操作提升并行度,优化其性能;最后还可以针对不同的聚合或 join 操作,选择一种方案来优化其性能。大家需要对这些方案的思路和原理都透彻理解之后,在实践中根据各种不同的情况,灵活运用多种方案,来解决自己的数据倾斜问题。
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值