ITPub博客

首页 > 数据库 > 数据库开发技术 > 大数据项目——解决数据倾斜的案例

大数据项目——解决数据倾斜的案例

原创 数据库开发技术 作者:安瑞哥是码农 时间:2022-10-11 20:51:21 0 删除 编辑

这篇文章欠了好久,今天该还了。

很多小伙伴在面试大数据开发过程中总能被问到数据倾斜问题,但是很多人的回答一般都只会流于表面,说一些泛泛的概念,根本原因在于:对于大部分入行不久的小伙伴来说,根本就碰不上需要你花大精力去解决所谓的数据倾斜问题。

我之前的视频内容有详细说过对于数据倾斜的解决办法,但是对于很多人,估计也就听一乐,大家对其中的原理和具体解决方案依然云里雾里,尤其对倾斜数据进行的【加盐】和【减盐】操作,不知所云。

那么这篇文章,我就用一个具体的例子来演示【加盐】和【减盐】是如何解决数据倾斜问题的。

还是用之前那个大数据项目,我提供给大家的数据集,该案例的需求如下:

统计出每个上网的目的IP(target_ip),都是通过哪些客户端IP(client_ip)登录的,以及每个客户端IP的登录次数分别是多少?

需求很简单,一个SQL就可以搞定的事情,但是在现实场景中,大概率会出现某几个热门target_ip被特别多的客户端进行访问,其访问数量远超其他的target_ip,这个时候就出现了所谓的【数据倾斜】问题,如果数据量太大,你用普通SQL要么查询出来很慢,要么可能直接卡死或者OOM。


为了方便演示本次数据倾斜出现的问题,我用RDD作为本次数据处理的数据模型,因为如果你通过SQL语法本身是无法处理该问题的,这个时候,算子的优势就发挥出来了。


为了展示出对数据倾斜优化前后的对比,我通过优化前和优化的代码来做一个直观的比较。



|优化之前

对于本次的需求,正常的做法都会通过target_ip进行分组聚合,然后将所有访问当前target_ip的client_ip进行聚合统计,最终得出结果,优化前的spark代码如下:

package com.anryg.bigdata.test.data_skew

import java.util

import org.apache.spark.{SparkConf, SparkContext}

/**
  * @DESC: 一个数据倾斜的例子
  * @Auther: Anryg
  * @Date: 2022/10/10 17:00
  */

object DataSkew01 {

    def main(args: Array[String]): Unit = {
        val conf = new SparkConf().setAppName("DataSkewTest01")/*.setMaster("local[*]")*/
        val spark = new SparkContext(conf)

        val rawRDD = spark.textFile(args(0))//读取数据源

        val filteredRDD = rawRDD.filter(line => { /**筛选满足需要的数据,已到达数据倾斜的目的*/
            val array = line.split(",")
            val target_ip = array(3)
            target_ip.equals("106.38.176.185") || target_ip.equals("106.38.176.117") || target_ip.equals("106.38.176.118") || target_ip.equals("106.38.176.116")
        })


        val reducedRDD = filteredRDD.map(line => {/**根据目的ip进行汇总,将访问同一个目的ip的所有客户端ip进行汇总*/
            val array = line.split(",")
            val target_ip = array(3)
            val client_ip = array(0)
            val index = client_ip.lastIndexOf(".")
            val subClientIP = client_ip.substring(0, index) //为了让后续聚合后的value数据量尽可能的少,只取ip的前段部分
            (target_ip,Array(subClientIP))
        }).reduceByKey(_++_,4)//将Array中的元素进行合并,然后将分区调整为已知的4个


        val targetRDD = reducedRDD.map(kv => {/**将访问同一个目的ip的客户端,再次根据客户端ip进行进一步统计*/
            val map = new util.HashMap[String,Int]()
            val target_ip = kv._1
            val clientIPArray = kv._2
            clientIPArray.foreach(clientIP => {
                if (map.containsKey(clientIP)) {
                    val sum = map.get(clientIP) + 1
                    map.put(clientIP,sum)
                }
                else map.put(clientIP,1)
            })
            (target_ip,map)
        })

        targetRDD.foreach(println(_))
        targetRDD.saveAsTextFile("/tmp/DataSkew01") //结果数据保存目录
    }
}

可以看到,为了简化数据处理过程,我第一步用filter筛选了特定的4个target_ip,因为我通过统计知道,其中一个target_ip出现的次数是另外3个的数百倍,方便演示数据倾斜的效果。

整个逻辑其实很简单,一开始读取数据源,因为数据集约3.1G,在HDFS中分布在了25个block中(单个block大小为128MB),因此数据读取到spark中之后就为25个Partitions,如图所示:

且因为数据分布平均,因此几乎不存在数据倾斜问题:

但是当运行reduceByKey出现新的stage之后会发现,数据倾斜出现了,只有4个分区(准确的说是只有4个key),由于106.38.176.185这个key是其他3个key的几百倍(别问我怎么知道的,我事先统计过):

可以看到其中一个分区(106.38.176.185这个key所在的分区),聚集的数据量比其他分区要大太多。

当然,你可能会说,这也不大啊,还不到1MB呢,但你要知道,我这里只是举个栗子,如果在生产上,你把数据量放大百倍、千倍再看,效果就不一样了,那妥妥的严重数据倾斜,这样的话,倾斜严重的那个分区一定会处理效率非常的低,或者干脆OOM而导致任务失败。


|优化之后
那么针对这种情况,就需要将大分区的数据进行打散,如何打散?即加盐。
而因为同一个key的数据最终还是要进行聚合统计,所以加盐之后,还需要对其进行减盐操作,否则计算出来的结果就不对了,只不过在这【一加】和【一减】的过程中,会用各种策略去减少单个key的value大小,而且为了让分区之间的数据量尽可能的保持小幅波动,在实践中需要进行2次减盐操作,具体流程如下图:


优化后的参考代码如下:
package com.anryg.bigdata.test.data_skew

import java.util

import org.apache.spark.{SparkConf, SparkContext}

import scala.util.Random

/**
  * @DESC: 一个数据倾斜的例子
  * @Auther: Anryg
  * @Date: 2022/10/10 17:00
  */

object DataSkew02 {

    def main(args: Array[String]): Unit = {
        val conf = new SparkConf().setAppName("DataSkewTest02")/*.setMaster("local[*]")*/
        val spark = new SparkContext(conf)

        val rawRDD = spark.textFile(args(0)) //读取原始数据源

        val filteredRDD = rawRDD.filter(line => { /**筛选满足需要的数据,已到达数据倾斜的目的*/
            val array = line.split(",")
            val target_ip = array(3)
            target_ip.equals("106.38.176.185") || target_ip.equals("106.38.176.117") || target_ip.equals("106.38.176.118") || target_ip.equals("106.38.176.116")
        })

        val reducedRDD_01 = filteredRDD.map(line => {/**解决倾斜第一步:加盐操作将原本1个分区的数据扩大到100个分区*/
            val array = line.split(",")
            val target_ip = array(3)
            val client_ip = array(0)
            val index = client_ip.lastIndexOf(".")
            val subClientIP = client_ip.substring(0, index)//为了让后续聚合后的value数据量尽可能的少,只取ip的前段部分
            if (target_ip.equals("106.38.176.185")){/**针对特定倾斜的key进行加盐操作*/
                val saltNum = 99 //将原来的1个key增加到100个key
                val salt = new Random().nextInt(saltNum)
                (target_ip + "-" + salt,Array(subClientIP))
            }
            else (target_ip,Array(subClientIP))
        }).reduceByKey(_++_,103)//将Array中的元素进行合并,并确定分区数量

        val targetRDD_01 = reducedRDD_01.map(kv => {/**第二步:将各个分区中的数据进行初步统计,减少单个分区中value的大小*/
            val map = new util.HashMap[String,Int]()
            val target_ip = kv._1
            val clientIPArray = kv._2
            clientIPArray.foreach(clientIP => {//对clientIP进行统计
                if (map.containsKey(clientIP)) {
                    val sum = map.get(clientIP) + 1
                    map.put(clientIP,sum)
                }
                else map.put(clientIP,1)
            })
            (target_ip,map)
        })

        val reducedRDD_02 = targetRDD_01.map(kv => {/**第3步:对倾斜的数据进行减盐操作,将分区数从100减到10*/
            val targetIPWithSalt01 = kv._1
            val clientIPMap = kv._2
            if (targetIPWithSalt01.startsWith("106.38.176.185")){
                val targetIP = targetIPWithSalt01.split("-")(0)
                val saltNum = 9 //将原来的100个分区减少到10个分区
                val salt = new Random().nextInt(saltNum)
                (targetIP + "-" + salt,clientIPMap)
            }
            else kv
        }).reduceByKey((map1,map2) => { /**合并2个map中的元素,key相同则value值相加*/
            val map3 = new util.HashMap[String,Int](map1)
            map2.forEach((key,value) => {
                map3.merge(key, value, (v1,v2) => v1 + v2) //将map1和map2中的结果merge到map3中,相同的key,则value相加
            })
            map3
        },13)//调整分区数量

        val finalRDD = reducedRDD_02.map(kv => {/**第4步:继续减盐,将原本10个分区数的数据恢复到1个*/
            val targetIPWithSalt01 = kv._1
            val clientIPMap = kv._2
            if (targetIPWithSalt01.startsWith("106.38.176.185")){
                val targetIP = targetIPWithSalt01.split("-")(0)
                (targetIP,clientIPMap)//彻底将盐去掉
            }
            else kv
        }).reduceByKey((map1,map2) => { /**合并2个map中的元素,key相同则value值相加*/
            val map3 = new util.HashMap[String,Int](map1)
            map2.forEach((key,value) => {
                map3.merge(key, value, (v1,v2) => v1 + v2)
            })
            map3
        },4)//调整分区数量

        finalRDD.saveAsTextFile("/tmp/DataSkew02")
    }
}


加盐的目的在于将大分区中的数据进行打散,将原来其中在一个key中的数据,给先打散成100份,然后再第一次聚合,这样就把这个key中的value数量进行有效的减少。

然后,再把这100份数据又平均分成10份,在每一份内部再次聚合,再次减少value的数量,最后再把这10份数据汇总成1份,再聚合,得到最终结果,就这样通过一步步减少value的数量来达到每个分区中数据量减少的目的。

来看下实际运行情况:


可以看到,读取原始数据之后,分区数从之前的4个(对比优化前代码)变成了103个,因为有103个key,打开分区内部看下数据分布:


可以看出,经过加盐操作之后数据分布变得平均了很多,分区之间的数据不再相差巨大。

再来看第一次减盐之后的分区数据分布情况:

也是一样,相对都比较平均。再看最后一次减盐操作后,数据在分区之间的输入和输出分布情况:

优化后
对比下面优化前的输入和输出数据量,是不是改善了非常多。

优化前


|结果比对
最后的结果,当然必须是一样的,我验证后是一毛一样的,不信,你亲自跑着试一下看看。




|最后

别以为这样一篇看似简单的案例实现起来很容易,因为既要考虑到场景的普适性、业务的易理解性,还要考虑到满足数据倾斜的条件,是需要提前对数据做很多准备工作的。


因此,我希望你读完这篇文章后,如果对数据倾斜的解决方案感兴趣,请务必要动手实践起来,因为只有实践起来,你才会发现解决这类问题其中的奥秘,否则,永远只能停留在概念上的纸上谈兵。


如果,你对本篇文章有任何疑问,欢迎找我交流,或者在讨论群里发表你的看法。


(PS:文章参考代码已同步到GitHub,欢迎下载)



我这有一个高质量的大数据学习讨论群,不怎么扯淡、偶尔活跃气氛、大部分时间都在讨论技术,你在群里的几乎任何大数据相关的问题,都可以得到解答,要不要进来试试呢?拉你进群啊...


原文作者:安瑞哥是码农;如有侵权,请联系管理员删除。

请登录后发表评论 登录
全部评论

注册时间:2022-02-16

  • 博文量
    228
  • 访问量
    115433