本文转发自技术世界,原文链接 http://www.jasongj.com/spark/skew/
Spark性能优化之道——解决Spark数据倾斜(Data Skew)的N种姿势
自定义Partitioner
原理
使用自定义的Partitioner(默认为HashPartitioner),将原本被分配到同一个Task的不同Key分配到不同Task。
案例
以上述数据集为例,继续将并发度设置为12,但是在groupByKey
算子上,使用自定义的Partitioner
(实现如下)
1 .groupByKey(new Partitioner() { 2 @Override 3 public int numPartitions() { 4 return 12; 5 } 6 7 @Override 8 public int getPartition(Object key) { 9 int id = Integer.parseInt(key.toString()); 10 if(id >= 9500000 && id <= 9500084 && ((id - 9500000) % 12) == 0) { 11 return (id - 9500000) / 12; 12 } else { 13 return id % 12; 14 } 15 } 16 })
由下图可见,使用自定义Partition后,耗时最长的Task 6处理约1000万条数据,用时15秒。并且各Task所处理的数据集大小相当。
总结
适用场景
大量不同的Key被分配到了相同的Task造成该Task数据量过大。
解决方案
使用自定义的Partitioner实现类代替默认的HashPartitioner,尽量将所有不同的Key均匀分配到不同的Task中。
优势
不影响原有的并行度设计。如果改变并行度,后续Stage的并行度也会默认改变,可能会影响后续Stage。
劣势
适用场景有限,只能将不同Key分散开,对于同一Key对应数据集非常大的场景不适用。效果与调整并行度类似,只能缓解数据倾斜而不能完全消除数据倾斜。而且需要根据数据特点自定义专用的Partitioner,不够灵活。
将Reduce side Join转变为Map side Join
原理
通过Spark的Broadcast机制,将Reduce侧Join转化为Map侧Join,避免Shuffle从而完全消除Shuffle带来的数据倾斜。
案例
通过如下SQL创建一张具有倾斜Key且总记录数为1.5亿的大表test。
1 INSERT OVERWRITE TABLE test 2 SELECT CAST(CASE WHEN id < 980000000 THEN (95000000 + (CAST (RAND() * 4 AS INT) + 1) * 48 ) 3 ELSE CAST(id/10 AS INT) END AS STRING), 4 name 5 FROM student_external 6 WHERE id BETWEEN 900000000 AND 1050000000;
使用如下SQL创建一张数据分布均匀且总记录数为50万的小表test_new。
1 INSERT OVERWRITE TABLE test_new 2 SELECT CAST(CAST(id/10 AS INT) AS STRING), 3 name 4 FROM student_delta_external 5 WHERE id BETWEEN 950000000 AND 950500000;
直接通过Spark Thrift Server提交如下SQL将表test与表test_new进行Join并将Join结果存于表test_join中。
1 INSERT OVERWRITE TABLE test_join 2 SELECT test_new.id, test_new.name 3 FROM test 4 JOIN test_new 5 ON test.id = test_new.id;
该SQL对应的DAG如下图所示。从该图可见,该执行过程总共分为三个Stage,前两个用于从Hive中读取数据,同时二者进行Shuffle,通过最后一个Stage进行Join并将结果写入表test_join中。
从下图可见,Join Stage各Task处理的数据倾斜严重,处理数据量最大的Task耗时7.1分钟,远高于其它无数据倾斜的Task约2秒的耗时。
接下来,尝试通过Broadcast实现Map侧Join。实现Map侧Join的方法,并非直接通过CACHE TABLE test_new
将小表test_new进行cache。现通过如下SQL进行Join。
1 CACHE TABLE test_new; 2 INSERT OVERWRITE TABLE test_join 3 SELECT test_new.id, test_new.name 4 FROM test 5 JOIN test_new 6 ON test.id = test_new.id;
通过如下DAG图可见,该操作仍分为三个Stage,且仍然有Shuffle存在,唯一不同的是,小表的读取不再直接扫描Hive表,而是扫描内存中缓存的表。
并且数据倾斜仍然存在。如下图所示,最慢的Task耗时为7.1分钟,远高于其它Task的约2秒。
正确的使用Broadcast实现Map侧Join的方式是,通过SET spark.sql.autoBroadcastJoinThreshold=104857600;
将Broadcast的阈值设置得足够大。
再次通过如下SQL进行Join。
1 SET spark.sql.autoBroadcastJoinThreshold=104857600; 2 INSERT OVERWRITE TABLE test_join 3 SELECT test_new.id, test_new.name 4 FROM test 5 JOIN test_new 6 ON test.id = test_new.id;
通过如下DAG图可见,该方案只包含一个Stage。
并且从下图可见,各Task耗时相当,无明显数据倾斜现象。并且总耗时为1.5分钟,远低于Reduce侧Join的7.3分钟。
总结
适用场景
参与Join的一边数据集足够小,可被加载进Driver并通过Broadcast方法广播到各个Executor中。
解决方案
在Java/Scala代码中将小数据集数据拉取到Driver,然后通过Broadcast方案将小数据集的数据广播到各Executor。或者在使用SQL前,将Broadcast的阈值调整得足够大,从而使用Broadcast生效。进而将Reduce侧Join替换为Map侧Join。
优势
避免了Shuffle,彻底消除了数据倾斜产生的条件,可极大提升性能。
劣势
要求参与Join的一侧数据集足够小,并且主要适用于Join的场景,不适合聚合的场景,适用条件有限。
为skew的key增加随机前/后缀
原理
为数据量特别大的Key增加随机前/后缀,使得原来Key相同的数据变为Key不相同的数据,从而使倾斜的数据集分散到不同的Task中,彻底解决数据倾斜问题。Join另一则的数据中,与倾斜Key对应的部分数据,与随机前缀集作笛卡尔乘积,从而保证无论数据倾斜侧倾斜Key如何加前缀,都能与之正常Join。
案例
通过如下SQL,将id为9亿到9.08亿共800万条数据的id转为9500048或者9500096,其它数据的id除以100取整。从而该数据集中,id为9500048和9500096的数据各400万,其它id对应的数据记录数均为100条。这些数据存于名为test的表中。
对于另外一张小表test_new,取出50万条数据,并将id(递增且唯一)除以100取整,使得所有id都对应100条数据。
1 INSERT OVERWRITE TABLE test 2 SELECT CAST(CASE WHEN id < 908000000 THEN (9500000 + (CAST (RAND() * 2 AS INT) + 1) * 48 ) 3 ELSE CAST(id/100 AS INT) END AS STRING), 4 name 5 FROM student_external 6 WHERE id BETWEEN 900000000 AND 1050000000; 7 8 INSERT OVERWRITE TABLE test_new 9 SELECT CAST(CAST(id/100 AS INT) AS STRING), 10 name 11 FROM student_delta_external 12 WHERE id BETWEEN 950000000 AND 950500000;
通过如下代码,读取test表对应的文件夹内的数据并转换为JavaPairRDD存于leftRDD中,同样读取test表对应的数据存于rightRDD中。通过RDD的join算子对leftRDD与rightRDD进行Join,并指定并行度为48。
1 public class SparkDataSkew{ 2 public static void main(String[] args) { 3 SparkConf sparkConf = new SparkConf(); 4 sparkConf.setAppName("DemoSparkDataFrameWithSkewedBigTableDirect"); 5 sparkConf.set("spark.default.parallelism", String.valueOf(parallelism)); 6 JavaSparkContext javaSparkContext = new JavaSparkContext(sparkConf); 7 8 JavaPairRDD<String, String> leftRDD = javaSparkContext.textFile("hdfs://hadoop1:8020/apps/hive/warehouse/default/test/") 9 .mapToPair((String row) -> { 10 String[] str = row.split(","); 11 return new Tuple2<String, String>(str[0], str[1]); 12 }); 13 14 JavaPairRDD<String, String> rightRDD = javaSparkContext.textFile("hdfs://hadoop1:8020/apps/hive/warehouse/default/test_new/") 15 .mapToPair((String row) -> { 16 String[] str = row.split(","); 17 return new Tuple2<String, String>(str[0], str[1]); 18 }); 19 20 leftRDD.join(rightRDD, parallelism) 21 .mapToPair((Tuple2<String, Tuple2<String, String>> tuple) -> new Tuple2<String, String>(tuple._1(), tuple._2()._2())) 22 .foreachPartition((Iterator<Tuple2<String, String>> iterator) -> { 23 AtomicInteger atomicInteger = new AtomicInteger(); 24 iterator.forEachRemaining((Tuple2<String, String> tuple) -> atomicInteger.incrementAndGet()); 25 }); 26 27 javaSparkContext.stop(); 28 javaSparkContext.close(); 29 } 30 }
从下图可看出,整个Join耗时1分54秒,其中Join Stage耗时1.7分钟。
通过分析Join Stage的所有Task可知,在其它Task所处理记录数为192.71万的同时Task 32的处理的记录数为992.72万,故它耗时为1.7分钟,远高于其它Task的约10秒。这与上文准备数据集时,将id为9500048为9500096对应的数据量设置非常大,其它id对应的数据集非常均匀相符合。
现通过如下操作,实现倾斜Key的分散处理
- 将leftRDD中倾斜的key(即9500048与9500096)对应的数据单独过滤出来,且加上1到24的随机前缀,并将前缀与原数据用逗号分隔(以方便之后去掉前缀)形成单独的leftSkewRDD
- 将rightRDD中倾斜key对应的数据抽取出来,并通过flatMap操作将该数据集中每条数据均转换为24条数据(每条分别加上1到24的随机前缀),形成单独的rightSkewRDD
- 将leftSkewRDD与rightSkewRDD进行Join,并将并行度设置为48,且在Join过程中将随机前缀去掉,得到倾斜数据集的Join结果skewedJoinRDD
- 将leftRDD中不包含倾斜Key的数据抽取出来作为单独的leftUnSkewRDD
- 对leftUnSkewRDD与原始的rightRDD进行Join,并行度也设置为48,得到Join结果unskewedJoinRDD
- 通过union算子将skewedJoinRDD与unskewedJoinRDD进行合并,从而得到完整的Join结果集
具体实现代码如下
1 public class SparkDataSkew{ 2 public static void main(String[] args) { 3 int parallelism = 48; 4 SparkConf sparkConf = new SparkConf(); 5 sparkConf.setAppName("SolveDataSkewWithRandomPrefix"); 6 sparkConf.set("spark.default.parallelism", parallelism + ""); 7 JavaSparkContext javaSparkContext = new JavaSparkContext(sparkConf); 8 9 JavaPairRDD<String, String> leftRDD = javaSparkContext.textFile("hdfs://hadoop1:8020/apps/hive/warehouse/default/test/") 10 .mapToPair((String row) -> { 11 String[] str = row.split(","); 12 return new Tuple2<String, String>(str[0], str[1]); 13 }); 14 15 JavaPairRDD<String, String> rightRDD = javaSparkContext.textFile("hdfs://hadoop1:8020/apps/hive/warehouse/default/test_new/") 16 .mapToPair((String row) -> { 17 String[] str = row.split(","); 18 return new Tuple2<String, String>(str[0], str[1]); 19 }); 20 21 String[] skewedKeyArray = new String[]{"9500048", "9500096"}; 22 Set<String> skewedKeySet = new HashSet<String>(); 23 List<String> addList = new ArrayList<String>(); 24 for(int i = 1; i <=24; i++) { 25 addList.add(i + ""); 26 } 27 for(String key : skewedKeyArray) { 28 skewedKeySet.add(key); 29 } 30 31 Broadcast<Set<String>> skewedKeys = javaSparkContext.broadcast(skewedKeySet); 32 Broadcast<List<String>> addListKeys = javaSparkContext.broadcast(addList); 33 34 JavaPairRDD<String, String> leftSkewRDD = leftRDD 35 .filter((Tuple2<String, String> tuple) -> skewedKeys.value().contains(tuple._1())) 36 .mapToPair((Tuple2<String, String> tuple) -> new Tuple2<String, String>((new Random().nextInt(24) + 1) + "," + tuple._1(), tuple._2())); 37 38 JavaPairRDD<String, String> rightSkewRDD = rightRDD.filter((Tuple2<String, String> tuple) -> skewedKeys.value().contains(tuple._1())) 39 .flatMapToPair((Tuple2<String, String> tuple) -> addListKeys.value().stream() 40 .map((String i) -> new Tuple2<String, String>( i + "," + tuple._1(), tuple._2())) 41 .collect(Collectors.toList()) 42 .iterator() 43 ); 44 45 JavaPairRDD<String, String> skewedJoinRDD = leftSkewRDD 46 .join(rightSkewRDD, parallelism) 47 .mapToPair((Tuple2<String, Tuple2<String, String>> tuple) -> new Tuple2<String, String>(tuple._1().split(",")[1], tuple._2()._2())); 48 49 JavaPairRDD<String, String> leftUnSkewRDD = leftRDD.filter((Tuple2<String, String> tuple) -> !skewedKeys.value().contains(tuple._1())); 50 JavaPairRDD<String, String> unskewedJoinRDD = leftUnSkewRDD.join(rightRDD, parallelism).mapToPair((Tuple2<String, Tuple2<String, String>> tuple) -> new Tuple2<String, String>(tuple._1(), tuple._2()._2())); 51 52 skewedJoinRDD.union(unskewedJoinRDD).foreachPartition((Iterator<Tuple2<String, String>> iterator) -> { 53 AtomicInteger atomicInteger = new AtomicInteger(); 54 iterator.forEachRemaining((Tuple2<String, String> tuple) -> atomicInteger.incrementAndGet()); 55 }); 56 57 javaSparkContext.stop(); 58 javaSparkContext.close(); 59 } 60 }
通过分析Join Stage的所有Task可知从下图可看出,整个Join耗时58秒,其中Join Stage耗时33秒。
- 由于Join分倾斜数据集Join和非倾斜数据集Join,而各Join的并行度均为48,故总的并行度为96
- 由于提交任务时,设置的Executor个数为4,每个Executor的core数为12,故可用Core数为48,所以前48个Task同时启动(其Launch时间相同),后48个Task的启动时间各不相同(等待前面的Task结束才开始)
- 由于倾斜Key被加上随机前缀,原本相同的Key变为不同的Key,被分散到不同的Task处理,故在所有Task中,未发现所处理数据集明显高于其它Task的情况
实际上,由于倾斜Key与非倾斜Key的操作完全独立,可并行进行。而本实验受限于可用总核数为48,可同时运行的总Task数为48,故而该方案只是将总耗时减少一半(效率提升一倍)。如果资源充足,可并发执行Task数增多,该方案的优势将更为明显。在实际项目中,该方案往往可提升数倍至10倍的效率。
总结
适用场景
两张表都比较大,无法使用Map则Join。其中一个RDD有少数几个Key的数据量过大,另外一个RDD的Key分布较为均匀。
解决方案
将有数据倾斜的RDD中倾斜Key对应的数据集单独抽取出来加上随机前缀,另外一个RDD每条数据分别与随机前缀结合形成新的RDD(相当于将其数据增到到原来的N倍,N即为随机前缀的总个数),然后将二者Join并去掉前缀。然后将不包含倾斜Key的剩余数据进行Join。最后将两次Join的结果集通过union合并,即可得到全部Join结果。
优势
相对于Map则Join,更能适应大数据集的Join。如果资源充足,倾斜部分数据集与非倾斜部分数据集可并行进行,效率提升明显。且只针对倾斜部分的数据做数据扩展,增加的资源消耗有限。
劣势
如果倾斜Key非常多,则另一侧数据膨胀非常大,此方案不适用。而且此时对倾斜Key与非倾斜Key分开处理,需要扫描数据集两遍,增加了开销。
大表随机添加N种随机前缀,小表扩大N倍
原理
如果出现数据倾斜的Key比较多,上一种方法将这些大量的倾斜Key分拆出来,意义不大。此时更适合直接对存在数据倾斜的数据集全部加上随机前缀,然后对另外一个不存在严重数据倾斜的数据集整体与随机前缀集作笛卡尔乘积(即将数据量扩大N倍)。
案例
这里给出示例代码,读者可参考上文中分拆出少数倾斜Key添加随机前缀的方法,自行测试。
1 public class SparkDataSkew { 2 public static void main(String[] args) { 3 SparkConf sparkConf = new SparkConf(); 4 sparkConf.setAppName("ResolveDataSkewWithNAndRandom"); 5 sparkConf.set("spark.default.parallelism", parallelism + ""); 6 JavaSparkContext javaSparkContext = new JavaSparkContext(sparkConf); 7 8 JavaPairRDD<String, String> leftRDD = javaSparkContext.textFile("hdfs://hadoop1:8020/apps/hive/warehouse/default/test/") 9 .mapToPair((String row) -> { 10 String[] str = row.split(","); 11 return new Tuple2<String, String>(str[0], str[1]); 12 }); 13 14 JavaPairRDD<String, String> rightRDD = javaSparkContext.textFile("hdfs://hadoop1:8020/apps/hive/warehouse/default/test_new/") 15 .mapToPair((String row) -> { 16 String[] str = row.split(","); 17 return new Tuple2<String, String>(str[0], str[1]); 18 }); 19 20 List<String> addList = new ArrayList<String>(); 21 for(int i = 1; i <=48; i++) { 22 addList.add(i + ""); 23 } 24 25 Broadcast<List<String>> addListKeys = javaSparkContext.broadcast(addList); 26 27 JavaPairRDD<String, String> leftRandomRDD = leftRDD.mapToPair((Tuple2<String, String> tuple) -> new Tuple2<String, String>(new Random().nextInt(48) + "," + tuple._1(), tuple._2())); 28 29 JavaPairRDD<String, String> rightNewRDD = rightRDD 30 .flatMapToPair((Tuple2<String, String> tuple) -> addListKeys.value().stream() 31 .map((String i) -> new Tuple2<String, String>( i + "," + tuple._1(), tuple._2())) 32 .collect(Collectors.toList()) 33 .iterator() 34 ); 35 36 JavaPairRDD<String, String> joinRDD = leftRandomRDD 37 .join(rightNewRDD, parallelism) 38 .mapToPair((Tuple2<String, Tuple2<String, String>> tuple) -> new Tuple2<String, String>(tuple._1().split(",")[1], tuple._2()._2())); 39 40 joinRDD.foreachPartition((Iterator<Tuple2<String, String>> iterator) -> { 41 AtomicInteger atomicInteger = new AtomicInteger(); 42 iterator.forEachRemaining((Tuple2<String, String> tuple) -> atomicInteger.incrementAndGet()); 43 }); 44 45 javaSparkContext.stop(); 46 javaSparkContext.close(); 47 } 48 }
总结
适用场景
一个数据集存在的倾斜Key比较多,另外一个数据集数据分布比较均匀。
优势
对大部分场景都适用,效果不错。
劣势
需要将一个数据集整体扩大N倍,会增加资源消耗。
总结
对于数据倾斜,并无一个统一的一劳永逸的方法。更多的时候,是结合数据特点(数据集大小,倾斜Key的多少等)综合使用上文所述的多种方法。