前言相信很多接触MapReduce的朋友对数据倾斜这四个字并不陌生那么究竟什么是数据倾斜又该怎样解决这种该死的情况呢一、什么是数据倾斜1.1 数据分布概念在弄清什么是数据倾斜之前我想让大家看看数据分布的概念正常的数据分布理论上都是倾斜的就是我们所说的20-80原理80%的财富集中在20%的人手中80%的用户只使用20%的功能20%的用户贡献了80%的访问量。不同的数据字段可能的数据倾斜一般有两种情况一种是唯一值非常少极少数值有非常多的记录值唯一值少于几千一种是唯一值比较多这个字段的某些值有远远多于其他值的记录数但是它的占比也小于百分之一或千分之一1.2 数据倾斜定义数据倾斜在MapReduce编程模型中十分常见用最通俗易懂的话来说数据倾斜无非就是大量的相同key被partition分配到一个分区里造成了一个人累死其他人闲死的情况。这种情况是我们不能接受的这也违背了并行计算的初衷。首先一个节点要承受着巨大的压力而其他节点计算完毕后要一直等待这个忙碌的节点也拖累了整体的计算时间可以说效率是十分低下的。1.3 数据倾斜现象数据倾斜发生时的现象绝大多数task执行得都非常快但个别task执行的极慢。原本能正常执行的Spark作业某天突然爆出OOM内存溢出异常。观察异常栈是我们写的业务代码造成的。1.4 数据倾斜原理数据倾斜发生的原理在进行shuffle的时候必须将各个节点上相同的Key拉取到某个节点上的一个task来进行处理比如按照key进行聚合或者join操作。如果某个key对应的数据量特别大的话会发生数据倾斜。比如大部分key对应的10条数据但个别key却对应了100万条数据那么大部分task会只分配到10条数据而个别task可能会分配了100万数据。整个spark作业的运行进度是由运行时间最长的那个task决定的。因此出现数据倾斜的时候spark作业看起来会运行得非常缓慢甚至可能因为某个task处理的数据量过大导致OOM。二、基础解决方案2.1 增加JVM内存这适用于第一种情况唯一值非常少极少数值有非常多的记录值唯一值少于几千。这种情况下往往只能通过硬件的手段来进行调优增加jvm内存可以显著的提高运行效率。2.2 增加Reduce的个数这适用于第二种情况唯一值比较多这个字段的某些值有远远多于其他值的记录数但是它的占比也小于百分之一或千分之一。我们知道这种情况下最容易造成的结果就是大量相同key被partition到一个分区从而一个reduce执行了大量的工作。而如果我们增加了reduce的个数这种情况相对来说会减轻很多毕竟计算的节点多了就算工作量还是不均匀的那也要小很多。2.3 自定义分区这需要用户自己继承partition类指定分区策略这种方式效果比较显著。2.4 重新设计Key有一种方案是在map阶段时给key加上一个随机数有了随机数的key就不会被大量的分配到同一节点小几率待到reduce后再把随机数去掉即可。2.5 使用Combiner合并Combiner是在map阶段reduce之前的一个中间阶段在这个阶段可以选择性的把大量的相同key数据先进行一个合并可以看做是local reduce然后再交给reduce来处理。这样做的好处很多即减轻了map端向reduce端发送的数据量减轻了网络带宽也减轻了map端和reduce端中间的shuffle阶段的数据拉取数量本地化磁盘IO速率推荐使用这种方法。三、如何定位数据倾斜3.1 识别可能触发Shuffle的算子如何定位发生数据倾斜的代码数据倾斜只会发生在shuffle中下面是常用的可能会触发shuffle操作的算子distinct、groupByKey、reduceByKey、aggregateByKey、join、cogroup、repartition等。出现数据倾斜时可能就是代码中使用了这些算子的原因。3.2 通过Spark UI定位Stage通过观察spark UI的节目定位数据倾斜发生在第几个stage中。如果是用yarn-client模式提交那么本地是可以直接看到log的可以在log中找到当前运行到了第几个stage如果用yarn-cluster模式提交可以通过Spark Web UI来查看当前运行到了第几个stage。此外无论是使用了yarn-client模式还是yarn-cluster模式我们都可以在Spark Web UI上深入看一下当前这个stage各个task分配的数据量从而进一步确定是不是task分配的数据不均匀导致了数据倾斜。3.3 根据Stage划分算法定位代码根据之前学的stage的划分算法定位到极有可能发生数据倾斜的代码。这是没有发生倾斜的例子若41ms为1h即表示发生倾斜。也可查看属于第几个stage。3.4 查看Key分布情况查看导致数据倾斜的key的分布情况如果是Spark SQL中的group by、join语句导致的数据倾斜那么就查询一下SQL中使用的表的key分布情况。如果是对Spark RDD执行shuffle算子导致的数据倾斜那么可以在Spark作业中加入查看key分布的代码比如RDD.countByKey()。然后对统计出来的各个key出现的次数collect/take到客户端打印一下就可以看到key的分布情况。四、详细解决方案4.1 使用Hive ETL预处理数据方案使用场景导致数据倾斜的是Hive表。如果该Hive表中的数据本身很不均匀而且业务场景需要频繁的使用Spark对Hive表执行某个分析操作那么比较适合使用这种技术方案。思路此时可以评估是否可以通过Hive来进行数据预处理。即通过Hive ETL预先对数据按照Key进行聚合或者是预先和其他表进行join然后再Spark作业中针对的数据源就是预处理后的Hive表。此时由于数据已经预先进行过聚合或者join操作了那么在Spark作业中也就不需要使用原先的shuffle类算子执行这类操作了。原理从根源上解决了数据倾斜因为彻底避免了在Spark中执行shuffle类算子。但是因为毕竟数据本身就存在分布不均匀的问题所以在Hive ETL中进行groupBy或者join等shuffle操作时还是会发生数据倾斜导致Hive ETL速度很慢。只是避免了Spark程序发生数据倾斜。经验在一些Java系统与Spark结合使用的项目中会出现Java代码频繁调用Spark作业的场景而且对Spark作业的执行性能要求很高就比较适合使用这种方案。将数据倾斜提前到上游的Hive ETL每天仅执行一次只有那一次是比较慢的而之后每次Java调用Spark作业时执行速度都会很快能够提供更好的用户体验。4.2 过滤少数导致倾斜的Key方案使用场景若发现导致倾斜的key就少数几个并且对计算本身的影响并不大。比如99%的key对应10条数据但只有一个key对应100万数据。思路若判断少数几个数据量特别多的key对作业的执行和计算结果不是那么特别重要可以直接过滤掉那几个key。如在Spark SQL中就可以使用where子句过滤掉这些key或者在Spark Core中对RDD执行filter算子过滤掉这些key。如果需要每次作业执行时动态判定哪些key的数据量最多然后过滤可以使用sample算子对RDD进行采样然后计算每个key的数量取数据量最多的key过滤即可。缺点适用场景不多大多数情况下导致倾斜的key还是很多的并不是只有少数几个。4.3 提高Shuffle操作的并行度方案使用场景若我们必须要面对数据倾斜问题要这么使用。思路在对RDD执行shuffle算子时给shuffle算子传入一个参数如reduceByKey1000该参数设置了这个shuffle算子执行时shuffle read task的数量。对于Spark SQL中的shuffle类语句如groupBy、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的执行时间都会变短了。实现起来比较简单可以有效缓解和减轻数据倾斜的影响。只是缓解了数据倾斜而已没有彻底根除问题根据实践经验来看其效果有限。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)。Java实现示例import org.apache.spark.SparkConf; import org.apache.spark.api.java.JavaPairRDD; import org.apache.spark.api.java.JavaRDD; import org.apache.spark.api.java.JavaSparkContext; import scala.Tuple2; import java.util.Arrays; import java.util.List; import java.util.concurrent.ThreadLocalRandom; public class AggWordCount11 { public static void main(String[] args) { // 1. 配置 Spark 环境 SparkConf conf new SparkConf() .setMaster(local) .setAppName(AggWordCount11); // 2. 使用 try-with-resources 确保 SparkContext 正确关闭防止资源泄漏 try (JavaSparkContext sc new JavaSparkContext(conf)) { // 3. 准备测试数据 ListString list Arrays.asList( you jump, i jump, you jump, jump jump, jump jump, jump jump ); JavaRDDString listRDD sc.parallelize(list); // 4. FlatMap: 拆分单词 // 注意原代码使用 \t 分割但数据中是空格 。这里修正为空格以符合数据实际内容。 // 如果原意确实是测试 Tab 分割且数据有误请改回 \t但通常 WordCount 使用空格或正则 \\s JavaRDDString flatMapRDD listRDD.flatMap(line - Arrays.asList(line.split( )).iterator()); // 5. MapToPair: 转换为 (word, 1) JavaPairRDDString, Integer wordPairRDD flatMapRDD.mapToPair(word - new Tuple2(word, 1)); // 6. 第一阶段加盐并局部聚合 // 使用 ThreadLocalRandom 提高并发下的随机数生成性能避免多线程竞争 final int saltFactor 4; JavaPairRDDString, Integer localAggRDD wordPairRDD // 加盐key 变为 randomPrefix_word .mapToPair(tuple - { int prefix ThreadLocalRandom.current().nextInt(saltFactor); return new Tuple2(prefix _ tuple._1, tuple._2); }) // 局部聚合在加盐后的 Key 上进行 ReduceByKey .reduceByKey(Integer::sum); // 7. 去盐去除前缀恢复原始 Key JavaPairRDDString, Integer cleanKeyRDD localAggRDD.mapToPair(tuple - { // 使用 split(_, 2) 确保只分割第一个下划线防止单词本身包含下划线时出错 String originalKey tuple._1.split(_, 2); return new Tuple2(originalKey, tuple._2); }); // 8. 第二阶段全局聚合 JavaPairRDDString, Integer globalResultRDD cleanKeyRDD.reduceByKey(Integer::sum); // 9. 输出结果 globalResultRDD.foreach(tuple - System.out.println(tuple._1 tuple._2) ); } catch (Exception e) { e.printStackTrace(); } } }Scala实现示例import org.apache.spark.SparkConf import org.apache.spark.SparkContext object AggWordCount { def main(args: Array[String]): Unit { val conf new SparkConf().setMaster(local).setAppName(AggWordCount) val sc new SparkContext(conf) val list Array(you jump,jump jump,jump jump,jump jump,jump jump,jump jump,jump jump,jump jump) val listRDD sc.parallelize(list, 1) listRDD.flatMap { line gt; line.split(\t) } .map { word gt; (word,1) } .map(word gt; { val prefix (new util.Random).nextInt(4) (prefix_word._1,word._2) }) .reduceByKey(__) .map(word gt; { val key word._1.split(_)(1) (key,word._2) }) .reduceByKey(__) .foreach(result gt; println(result._1 : result._2)) } }方案优点对于聚合类的shuffle操作导致的数据倾斜效果是非常不错的。通常都可以解决掉数据倾斜或者至少是大幅度缓解数据倾斜将Spark作业的性能提升数倍以上。方案缺点仅仅适用于聚合类的shuffle操作适用范围相对较窄。如果是join类的shuffle操作还得用其他的解决方案。五、将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操作也就不会发生数据倾斜。方案优点对join操作导致的数据倾斜效果非常好因为根本就不会发生shuffle也就根本不会发生数据倾斜。方案缺点适用场景较少因为这个方案只适用于一个大表和一个小表的情况。毕竟我们需要将小表进行广播此时会比较消耗内存资源driver和每个Executor内存中都会驻留一份小RDD的全量数据。如果我们广播出去的RDD数据比较大比如10G以上那么就可能发生内存溢出了。因此并不适合两个都是大表的情况。Java实现示例package org.xtwy.sparkcore; import org.apache.spark.SparkConf; import org.apache.spark.api.java.JavaPairRDD; import org.apache.spark.api.java.JavaRDD; import org.apache.spark.api.java.JavaSparkContext; import org.apache.spark.broadcast.Broadcast; import scala.Tuple2; import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.stream.Collectors; public class MapjoinTest { public static void main(String[] args) { // 1. 配置 Spark 环境 SparkConf conf new SparkConf() .setMaster(local) .setAppName(MapjoinTest); // 2. 创建 SparkContext使用 try-with-resources 确保资源释放 try (JavaSparkContext sc new JavaSparkContext(conf)) { // 3. 准备测试数据 ListTuple2String, String list1 Arrays.asList( new Tuple2(001, 令狐冲), new Tuple2(002, 任盈盈) ); ListTuple2String, String list2 Arrays.asList( new Tuple2(001, 一班), new Tuple2(002, 二班) ); // 4. 并行化数据 JavaRDDTuple2String, String list1RDD sc.parallelize(list1); JavaRDDTuple2String, String list2RDD sc.parallelize(list2); // 5. 收集小表数据并广播 // 注意collect() 会将所有数据加载到 Driver 内存仅适用于小数据集 ListTuple2String, String rdd1DataList list1RDD.collect(); // 优化点在 Driver 端直接转换为 Map减少 Executor 端的计算压力 // 如果必须在 Executor 端转换也应在 mapToPair 外部构建一次而不是每条数据构建一次 final MapString, String smallTableMap new HashMap(); for (Tuple2String, String tuple : rdd1DataList) { smallTableMap.put(tuple._1, tuple._2); } // 广播 Map 对象比广播 List 更高效因为减少了网络传输序列化的大小和 Executor 端的解析开销 final BroadcastMapString, String broadcastMap sc.broadcast(smallTableMap); // 6. 执行 Map-side Join JavaPairRDDString, Tuple2String, String resultRDD list2RDD.mapToPair(tuple - { // 获取广播变量中的 Map MapString, String map broadcastMap.value(); String key tuple._1; String valueFromBigTable tuple._2; // 从 Map 中获取小表对应的值 String valueFromSmallTable map.get(key); // 返回结果: Key - (大表Value, 小表Value) return new Tuple2(key, new Tuple2(valueFromBigTable, valueFromSmallTable)); }); // 7. 输出结果 resultRDD.foreach(tuple - { String key tuple._1; String bigTableVal tuple._2._1; String smallTableVal tuple._2._2; // 防止小表中没有对应 Key 导致 NullPointerException 或显示 null if (smallTableVal ! null) { System.out.println(key bigTableVal 姓名 smallTableVal); } else { // 可选处理未匹配到的情况 // System.out.println(key bigTableVal 姓名 [无匹配]); } }); } catch (Exception e) { e.printStackTrace(); } } }六、采样倾斜key并分拆join操作方案使用场景两个RDD/Hive表进行join的时候如果数据量都比较大无法采用上第五点解决方案那么此时可以看一下两个RDD/Hive表中key的分布情况若出现数据倾斜是因为其中某一个RDD/Hive表中的少数几个key的数据量过大而另一个中的所有key都分布比较均匀那么采用这个解决方案是比较合适的。实现思路对包含少数几个数据量过大的key的那个RDD通过sample算子采样出一份样本来然后统计一下每个key的数据量计算出数据量最大的是哪几个key。然后将这几个key对应数据从原来的RDD中拆分出来形成一个单独的RDD并给每个key打上n以内的随机数作为前缀而不会导致倾斜的大部分key形成另外一个RDD。接着将需要join的另一个RDD也就是过滤出来的那几个倾斜key对应的数据并形成一个单独的RDD将每条数据膨胀成n条数据这n条数据都按顺序附加一个0~n的前缀不会导致倾斜的大部分key也形成另外一个RDD。此时一共生成了四个RDD两个key有倾斜的RDD两个正常RDD再将附加了随机前缀的独立RDD与另一个膨胀n倍的独立RDD进行join此时就可以将原先相同的key打散成n份分散到多个task中去进行join。而另外两个普通的RDD就照常join即可。最后将两次join的结果使用union算子合并起来即可。原理对于join导致的数据倾斜如果只是某几个key导致了倾斜可以将少数几个key拆分为独立RDD并附加随机前缀打散成n份去进行join此时这几个key对应的数据就不会集中在少数几个task上而是分散到多个task进行join。优点对于join导致的数据倾斜如果只是某几个key导致了倾斜此方法可以用最有效的方式打散key进行join且只需要针对少数倾斜的key对应的数据进行扩容n倍不需要对全量数据进行扩容避免占用过多内存。缺点若key特别多则不合适。七、使用随机前缀和扩容RDD进行join方案使用场景若在进行join操作时RDD中有大量的key导致数据倾斜的时候。思路首先查看RDD/Hive表中的数据分布情况找到造成数据倾斜的RDD/Hive表比如有多个key都对应了超过万条数据。然后将该RDD的每条数据都打上一个n以内的随机前缀。同时对另外一个正常的RDD进行扩容将每条数据都扩容成n条数据扩容出来的每条数据都依次打上一个0~n的前缀。最后将两个处理后的RDD进行join即可。原理将原先一样的key通过附加前缀变成不一样的key然后就可以将这些处理后的不同的key分散到多个task中去处理而不是让一个task去处理大量相同的key。此方法与方法六的区别在于有大量倾斜key的情况没法将部分key拆分出来单独处理因此只能对整个RDD进行数据扩容对资源要求很高。缺点更多的是缓解数据倾斜而不是彻底避免而且需要对整个RDD进行扩容对内存资源要求较高。Java实现示例import org.apache.spark.SparkConf; import org.apache.spark.api.java.JavaPairRDD; import org.apache.spark.api.java.JavaSparkContext; import scala.Tuple2; import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Random; public class ExtendRDDTest { public static void main(String[] args) { // 1. 配置 Spark 环境 SparkConf conf new SparkConf() .setMaster(local) .setAppName(ExtendRDDTest); // 2. 创建 SparkContext使用 try-with-resources 确保资源自动关闭 try (JavaSparkContext sc new JavaSparkContext(conf)) { // 3. 准备测试数据 ListTuple2String, String list1 Arrays.asList( new Tuple2(001, 令狐冲), new Tuple2(002, 任盈盈) ); ListTuple2String, String list2 Arrays.asList( new Tuple2(001, 一班), new Tuple2(002, 二班) ); // 4. 并行化数据 // 注意list1 是“Key分布均匀”的数据源但在本例中作为被扩容方通常是小表或维度表 // list2 是“Key分布不均匀”的数据源作为加随机前缀方通常是大表或事实表 JavaPairRDDString, String rddUniform sc.parallelize(list1).mapToPair(v - v); JavaPairRDDString, String rddSkewed sc.parallelize(list2).mapToPair(v - v); // 5. 对分布均匀的 RDD 进行扩容加盐的反向操作将一条数据复制 N 份Key 加上 0~N-1 的前缀 // 这里假设扩容倍数为 100 final int saltFactor 100; JavaPairRDDString, String extendedRdd rddUniform.flatMapToPair(tuple - { ListTuple2String, String list new ArrayList(saltFactor); for (int i 0; i saltFactor; i) { // Key 变为: i_originalKey list.add(new Tuple2(i _ tuple._1, tuple._2)); } return list.iterator(); // 返回 Iterator 比 Iterable 更节省内存 }); // 6. 对分布不均匀的 RDD 添加随机前缀加盐 // 使用 ThreadLocalRandom 或复用 Random 实例以避免频繁创建对象开销 JavaPairRDDString, String mappedRdd rddSkewed.mapToPair(tuple - { // 在实际生产环境中建议将 Random 实例提取为静态成员或使用 ThreadLocalRandom int prefix new Random().nextInt(saltFactor); return new Tuple2(prefix _ tuple._1, tuple._2); }); // 7. 执行 Join 操作 // Join 后的 Key 格式为: prefix_originalKey // Value 格式为: Tuple2来自mappedRdd的值, 来自extendedRdd的值 JavaPairRDDString, Tuple2String, String joinedRdd mappedRdd.join(extendedRdd); // 8. 输出结果并还原原始 Key joinedRdd.foreach(tuple - { String saltedKey tuple._1; String valueFromSkewed tuple._2._1; // 来自 list2 (班级) String valueFromUniform tuple._2._2; // 来自 list1 (姓名) // 还原原始 Key: 去掉前缀和 _ String originalKey saltedKey.split(_, 2)[1]; System.out.println(originalKey valueFromUniform - valueFromSkewed); }); } catch (Exception e) { e.printStackTrace(); } } }八、总结数据倾斜是分布式计算中常见的问题特别是在Spark和MapReduce等大数据处理框架中。本文从概念、定位到解决方案全面介绍了数据倾斜的处理方法核心要点回顾理解数据倾斜数据倾斜的本质是数据分布不均匀导致部分任务负载过重影响整体计算效率。定位方法通过识别Shuffle算子、分析Spark UI、查看Key分布等方式准确定位问题所在。基础解决方案包括增加JVM内存、调整Reduce数量、自定义分区、重新设计Key和使用Combiner等。高级解决方案预处理使用Hive ETL提前处理数据过滤移除少数导致倾斜的Key并行度优化提高Shuffle操作的并行度两阶段聚合通过局部聚合全局聚合缓解倾斜Join优化将Reduce Join转为Map Join采样分拆对倾斜Key进行采样并分拆处理随机前缀扩容使用随机前缀和RDD扩容处理大量倾斜Key选择策略建议在实际应用中应根据具体情况选择合适的解决方案小表Join大表优先考虑Map Join方案少数Key倾斜采用采样分拆或过滤方案大量Key倾斜考虑随机前缀扩容方案聚合操作倾斜使用两阶段聚合方案频繁作业考虑Hive ETL预处理方案最佳实践预防为主在数据设计和ETL阶段就考虑数据分布问题监控预警建立数据倾斜监控机制及时发现和处理问题渐进优化从简单方案开始尝试逐步采用复杂方案资源平衡在解决倾斜问题时注意内存、CPU和网络资源的平衡代码规范遵循Spark最佳实践避免不必要的Shuffle操作数据倾斜问题的解决需要结合业务特点、数据特征和集群资源综合考虑。通过本文介绍的方法可以有效识别、定位和解决数据倾斜问题提升Spark作业的执行效率和稳定性。