目录
摘要
一、数据倾斜现象。
二、数据倾斜的原理
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算子造成的。
spark任务在进行shuffle计算的时候,必须将各个节点上相同的key拉到某个节点的一个Task上进行处理,比如按照key来进行聚合或者join操作。如果某个Key的数据量特别大的时候,就会发生数据倾斜。例如一个任务中,大部分key对应的数据只有10条,只有一个key对应的数据达到1000W条,那么大部分key分配到的数据只有10条,1秒钟可能就执行完了,但是这个key分配到1000W数据,要运行3/4个小时。因为整个Spark运行时间由运行时间最长的那个Task决定,那这种情况就会导致其他Task一直处于等待状态,资源闲置。
因此出现数据倾斜的时候,Spark任务运行会非常缓慢,甚至出现某个Task资源不足而触发OOM异常,导致整个任务运行失败。
上图就是一个特别清晰地例子:hello这个key一共出现了7次,这些数据会被分发到同一个task处理,而world和you这两个key各出现1次,所以另外两个task各处理1条数据即可。此时第一个Task处理数据的时间可能是其他Task的7 倍。从而导致整个Stage的运行时间延长。
1、哈希散列
优点:相同key的数据绝对会在同一个task
缺点:容易发生数据倾斜
2、轮询机制
优点:数据均匀的发送到task上,不会出现数据倾斜
缺点:无法保证相同key的数据在同一个Task
3、范围分发(Hbase的table默认规则)
优点:相邻的数据会分发到同一个Task
缺点:范围划分的中间值确认不好会导致数据倾斜。
Hbase解决数据倾斜的方式:每个region分裂的时候,按照从中间切割的方式。
4、随机分发
优点:绝对不会数据倾斜
缺点:相同的数据也会均匀的分发到各个Task
5、自定义
优点:灵活
缺点:分区细节太多,需要自己指定
针对某个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。
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的数据不均匀,则容易出现数据倾斜。
出现这个问题,定位可以直接根据报错找到相应代码的行号,在这行附近,肯定有shuffle算子。此时,很可能就是这个算子导致了数据倾斜的发生。
注意:出现这个异常,不一定是数据倾斜,首先查看自己的业务代码是否有问题,然后还有可能是偶发性的数据问题(突然某一天某个Key的数据量暴增)
方案适用场景:业务场景中需要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 作业时,执行速度都会很快,能够提供更好的用户体验。
将少数不影响计算结果且导致数据倾斜的数据过滤。
方案适用场景:如果发现导致倾斜的 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给过滤掉。
方案适用场景:如果我们必须要对数据倾斜迎难而上,那么建议优先使用这种方案,因为这是处理数据倾斜最简单的一种方案。
方案实现思路:在对 RDD 执行 shuffle 算子时,给 shuffle 算子传入一个参数,比如reduceByKey(1000),该参数就设置了这个 shuffle 算子执行时 shuffle read task 的数量。对于 Spark SQL 中的 shuffle 类语句,比如 group by、join 等,需要设置一个参数,即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 中去处理,因此注定还是会发生数据倾斜的。所以这种方案只能说是在发现数据倾斜时尝试使用的第一种手段,尝试去用嘴简单的方法缓解数据倾斜而已,或者是和其他方案结合起来使用。
聚合类数据倾斜通用的解决方案
方案适用场景:对 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 randomPrefixRdd = rdd.mapToPair(
new PairFunction, String, Long>(){
private static final long serialVersionUID = 1L;
@Override
public Tuple2 call(Tuple2 tuple) throws Exception {
Random random = new Random();
int prefix = random.nextInt(10);
return new Tuple2(prefix + "_" + tuple._1, tuple._2);
}
});
// 第二步,对打上随机前缀的key进行局部聚合。
JavaPairRDD localAggrRdd = randomPrefixRdd.reduceByKey(
new Function2() {
private static final long serialVersionUID = 1L;
@Override public Long call(Long v1, Long v2) throws Exception {
return v1 + v2;
}
});
// 第三步,去除RDD中每个key的随机前缀。
JavaPairRDD removedRandomPrefixRdd = localAggrRdd.mapToPair(
new PairFunction, Long, Long>() {
private static final long serialVersionUID = 1L;
@Override
public Tuple2 call(Tuple2 tuple) throws Exception {
long originalKey = Long.valueOf(tuple._1.split("_")[1]);
return new Tuple2(originalKey, tuple._2);
}
});
// 第四步,对去除了随机前缀的RDD进行全局聚合。
JavaPairRDD globalAggrRdd = removedRandomPrefixRdd.reduceByKey(
new Function2() {
private static final long serialVersionUID = 1L;
@Override
public Long call(Long v1, Long v2) throws Exception {
return v1 + v2;
}
});
方案优点:对于聚合类的 shuffle 操作导致的数据倾斜,效果是非常不错的。通常都可以解决掉数据倾斜,或者至少是大幅度缓解数据倾斜,将 Spark 作业的性能提升数倍以上。
方案缺点:仅仅适用于聚合类的 shuffle 操作,适用范围相对较窄。如果是 join 类的 shuffle 操作,还得用其他的解决方案。
方案适用场景:在对 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> rdd1Data = rdd1.collect()
// 然后使用Spark的广播功能,将小RDD的数据转换成广播变量,这样每个Executor就只有一份RDD的数 据。
// 可以尽可能节省内存空间,并且减少网络传输性能开销。
final Broadcast>> rdd1DataBroadcast = sc.broadcast(rdd1Data);
// 对另外一个RDD执行map类操作,而不再是join类操作。
JavaPairRDD> joinedRdd = rdd2.mapToPair(
new PairFunction, String, Tuple2>() {
private static final long serialVersionUID = 1L;
@Override
public Tuple2> call(Tuple2 tuple)throws Exception {
// 在算子函数中,通过广播变量,获取到本地Executor中的rdd1数据。
List> rdd1Data = rdd1DataBroadcast.value();
// 可以将rdd1的数据转换为一个Map,便于后面进行join操作。
Map rdd1DataMap = new HashMap();
for(Tuple2 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(key, new Tuple2 (
value, rdd1Value));
}
});
// 这里得提示一下:上面的做法,仅仅适用于rdd1中的key没有重复,全部是唯一的场景。
// 如果rdd1中有多个相同的key,那么就得用flatMap类的操作,在进行join的时候不能用map,而是得遍历rdd1所有数据进行join。 // rdd2中每条数据都可能会返回多条join后的数据。
方案优点:对 join 操作导致的数据倾斜,效果非常好,因为根本就不会发生 shuffle,也就根本不会发生数据倾斜。
方案缺点:适用场景较少,因为这个方案只适用于一个大表和一个小表的情况。毕竟我们需要将小表进行广播,此时会比较消耗内存资源,driver 和每个 Executor 内存中都会驻留一份小 RDD 的全量数据。如果我们广播出去的RDD 数据比较大,比如 10G 以上,那么就可能发生内存溢出了。因此并不适合两个都是大表的情况。
如果导致倾斜的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 sampledRDD = rdd1.sample(false, 0.1);
// 对样本数据RDD统计出每个key的出现次数,并按出现次数降序排序。
// 对降序排序后的数据,取出top 1或者top 100的数据,也就是key最多的前n个数据。
// 具体取出多少个数据量最多的key,由大家自己决定,我们这里就取1个作为示范。
JavaPairRDD mappedSampledRDD = sampledRDD.mapToPair(
new PairFunction, Long, Long>() {
private static final long serialVersionUID = 1L;
@Override
public Tuple2 call(Tuple2 tuple) throws Exception {
return new Tuple2(tuple._1, 1L);
}
});
JavaPairRDD countedSampledRDD = mappedSampledRDD.reduceByKey(
new Function2() {
private static final long serialVersionUID = 1L;
@Override public Long call(Long v1, Long v2) throws Exception {
return v1 + v2;
}
});
JavaPairRDD reversedSampledRDD = countedSampledRDD.mapToPair(
new PairFunction, Long, Long>() {
private static final long serialVersionUID = 1L;
@Override
public Tuple2 call(Tuple2 tuple) throws Exception {
return new Tuple2(tuple._2, tuple._1);
}
});
final Long skewedUserid = reversedSampledRDD.sortByKey(false).take(1).get(0)._2;
// 从rdd1中分拆出导致数据倾斜的key,形成独立的RDD。
JavaPairRDD skewedRDD = rdd1.filter( new Function, Boolean>() {
private static final long serialVersionUID = 1L;
@Override
public Boolean call(Tuple2 tuple) throws Exception {
return tuple._1.equals(skewedUserid);
}
});
// 从rdd1中分拆出不导致数据倾斜的普通key,形成独立的RDD。
JavaPairRDD commonRDD = rdd1.filter(
new Function, Boolean>() {
private static final long serialVersionUID = 1L;
@Override
public Boolean call(Tuple2 tuple) throws Exception {
return !tuple._1.equals(skewedUserid);
}
});
// rdd2,就是那个所有key的分布相对较为均匀的rdd。
// 这里将rdd2中,前面获取到的key对应的数据,过滤出来,分拆成单独的rdd,并对rdd中的数据使用flatMap算子都扩容100倍。
// 对扩容的每条数据,都打上0~100的前缀。
JavaPairRDD skewedRdd2 = rdd2.filter( new Function, Boolean>() {
private static final long serialVersionUID = 1L;
@Override
public Boolean call(Tuple2 tuple) throws Exception {
return tuple._1.equals(skewedUserid);
}
}).flatMapToPair(
new PairFlatMapFunction, String, Row>() {
private static final long serialVersionUID = 1L;
@Override
public Iterable> call( Tuple2 tuple) throws Exception {
Random random = new Random();
List> list = new ArrayList>();
for(int i = 0; i < 100; i++) {
list.add(new Tuple2(i + "_" + tuple._1, tuple._2));
}
return list;
}
});
// 将rdd1中分拆出来的导致倾斜的key的独立rdd,每条数据都打上100以内的随机前缀。
// 然后将这个rdd1中分拆出来的独立rdd,与上面rdd2中分拆出来的独立rdd,进行join。
JavaPairRDD> joinedRDD1 = skewedRDD.mapToPair(
new PairFunction, String, String>() {
private static final long serialVersionUID = 1L;
@Override public Tuple2 call(Tuple2 tuple) throws Exception {
Random random = new Random();
int prefix = random.nextInt(100);
return new Tuple2(
prefix + "_" + tuple._1, tuple._2);
}
})
.join(skewedUserid2infoRDD)
.mapToPair(new PairFunction>, Long, Tuple2>() {
private static final long serialVersionUID = 1L;
@Override
public Tuple2> call( Tuple2> tuple) throws Exception {
long key = Long.valueOf(tuple._1.split("_")[1]);
return new Tuple2>(key, tuple._2);
}
});
// 将rdd1中分拆出来的包含普通key的独立rdd,直接与rdd2进行join。
JavaPairRDD> joinedRDD2 = commonRDD.join(rdd2);
// 将倾斜key join后的结果与普通key join后的结果,uinon起来。
// 就是最终的join结果。
JavaPairRDD> joinedRDD = joinedRDD1.union(joinedRDD2);
方案适用场景:如果在进行 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 expandedRDD = rdd1.flatMapToPair(
new PairFlatMapFunction, String, Row>() {
private static final long serialVersionUID = 1L;
@Override
public Iterable> call(Tuple2 tuple) throws Exception {
List> list = new ArrayList> ();
for(int i = 0; i < 100; i++) {
list.add(new Tuple2(i + "_" + tuple._1, tuple._2));
}
return list;
}
});
// 其次,将另一个有数据倾斜key的RDD,每条数据都打上100以内的随机前缀。
JavaPairRDD mappedRDD = rdd2.mapToPair(
new PairFunction, String, String>() {
private static final long serialVersionUID = 1L;
@Override
public Tuple2 call(Tuple2 tuple) throws Exception {
Random random = new Random();
int prefix = random.nextInt(100);
return new Tuple2(prefix + "_" + tuple._1, tuple._2);
}
});
// 将两个处理后的RDD进行join即可。
JavaPairRDD> joinedRDD = mappedRDD.join(expandedRDD);