ITPub博客

首页 > 数据库 > 数据库开发技术 > 一道关于spark窗口函数的面试题

一道关于spark窗口函数的面试题

原创 数据库开发技术 作者:安瑞哥是码农 时间:2022-08-25 16:53:25 0 删除 编辑

昨天讨论群里有个小伙伴问了个大数据开发的面试题,题目是这样的:

利用临近点均值法,将缺失值填充:
要求用scala编写spark的处理程序,临近点定义为缺失处的上下n个元素,n<=4。要考虑大数据量情况下,不出现内存溢出。
输入:DataFrame(自己造测试数据,行数为2000万以上,列数至少要有2列以上,每一列随机产生一些空数据);
输出:经过缺失值填充后的DataFrame;

首先,这其实是一道很务实的题目,因为我们在实际项目中,在对数据进行ETL的过程中,其中有一步重要的操作就是【空值填充】,这么做的目的在于方便后续的数据处理程序处理数据时,代码中不用考虑过多的数据异常问题,可以少些一些诸如if else的判断。

对于这样的一道题,如果你在实际工作处理过类似的问题,就能很快回答出来,但是如果你压根就没有做过的话,那你大概率是懵逼的。

因为这道题很实用,所以也激发了我把它做出来的冲动,一来检验我还没有忘光的基础知识,二来也可以给正在准备大数据面试的小伙伴一些参考。


|解题思路

对于一般的数据处理思路,我们要么是针对每一条数据去应用我们的数据处理逻辑,要么是以一个分区的粒度为单位进行数据处理,典型的算子分别为map或者mapPartition。


但是对于这个题目来说,好像这两种方法都失效了,因为它要求的是,当处理到当前行数据时,如果当前行数据的某个字段值为空,那么需要根据上下n行(n=4)数据中该字段值的平均值来进行填充。


这个时候,传统的解题思路好像不奏效了。


咋整?


当我们在处理每一行数据时,同时要知道与当前行前后存在一定逻辑关系的其他行数据信息时,此时就需要要用到spark的窗口函数。


而这题的条件已经非常明显的在告诉你,在每次处理一个行时(当某个字段为空情况下),需要知道该行前后n行的数据情况,于是用窗口函数是没跑了。


再来看其他条件:考虑大数据量,不出现内存溢出,然后说数据量有2000万以上,至少2列以上数据。


我们来算一下200万数据量,3列的情况会有多大,假如我们用最浪费内存的string类型来存储所有列的数据,我们知道一个字符占用1个byte,就算1个字段有10个字符组成,也就是10byte大小,那么3列加起来,一行数据也就30byte,就算读到内存中之后,用容器进行存放,给它算到一行占用50byte,够多了吧。


即便是这样,2000万行数据我们就算按照50byte每行计算,也就不到1G的大小,虽然在数据处理过程中会通过窗口函数进行聚合操作,但是也就前后8行而已(n=8),这点资源对于spark不是小case吗?



|上代码

解题思路分析清楚了,那么接下来就是show you the code时间。

import org.apache.spark.SparkConf
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.expressions.Window
import org.apache.spark.sql.types.FloatType
object SparkWindowAgg1 {
    def main(args: Array[String]): Unit = {
        val sparkConf = new SparkConf().setAppName("一道面试题").setMaster("local")

        val spark = SparkSession.builder().config(sparkConf).getOrCreate()

        import spark.implicits._  /**引入隐式转换对象*/
        import org.apache.spark.sql.functions._  /**引入spark内置函数*/

        val df1 = Seq((1, "a", 7), (2, "a", 0), (3, "a", 6), (4, "b", 2), (9,"c", 8), (7, "d", 5), (5, "b",0), (6, "b", 1))
         .toDF("id", "category", "score") //定义数据源,这里只是举个栗子

        val window = Window.rowsBetween(-4, 4) /**定义窗口对象,确定应用的数据窗口*/
        val df2 = df1.withColumn("score", when (column("score") === 0, avg($"score") over window)
                .otherwise(column("score")))/**通过对空值字段进行基于窗口期数据求均值,并对空值进行替换*/
        df2.show()
    }
}

可以看到整个代码非常的简洁,代码量也非常的少,你可以看到,在用Window整个类的时候,我并没有用平时大家喜欢用的partitionBy函数以及orderBy函数,因为题目没有这么要求对不对?

而且,不用这两个函数,省去了数据分组(可能引发shuffle),和排序的时间,效率可以进一步提升,其他部分你看看代码中的注释。

来,看下运行结果:

不知道你有没有发现,这个结果虽然出来了,但是如果你仔细观察会发现,这个均值并不十分准确,我们要求的是前4行与后4后的和除以参与运算的行数,对应到这个程序中第一个要补全的结果,应该是:7.0+6.0+2.0+8.0+5.0/5 ,结果应该是5.6才对,但这里,它很明显把当前行也参与算进去了。

如果是在面试中,现场让你撸代码,你能写成这样,我肯定是会让你过的,但是如果这个是在实际工作中,为了确保精确,咱最好还是来优化一下。

优化之后的代码如下:
import org.apache.spark.SparkConf
import org.apache.spark.sql.SparkSession
import org.apache.spark.sql.expressions.Window
import org.apache.spark.sql.types.FloatType
object SparkWindowAgg2 {
    def main(args: Array[String]): Unit = {
        val sparkConf = new SparkConf().setAppName("一道面试题").setMaster("local")

        val spark = SparkSession.builder().config(sparkConf).getOrCreate()

        import spark.implicits._  /**引入隐式转换对象*/
        import org.apache.spark.sql.functions._  /**引入spark内置函数*/

        val df1 = Seq((1, "a", "7"), (2, "a", ""), (3, "a", "6"), (4, "b", "2"), (9,"c", "8"), (7, "d", "5"), (5, "b",""), (6, "b", "1"))
         .toDF("id", "category", "score") //定义数据源,这里只是举个栗子

        val window = Window.rowsBetween(-4, 4) /**定义窗口对象,确定应用的数据窗口*/
        val df2 = df1.withColumn("score", when (column("score") === "", avg($"score".cast(FloatType)) over window)
                .otherwise(column("score").cast(FloatType)))/**通过对空值字段进行基于窗口期数据求均值,并对空值进行替换*/
        df2.show()
    }
}
优化后的代码,其实变化不大,就是为了避免当前空值行参与均值计算,于是把score的类型变成string。

再看运行结果,就符合预期了:



|最后

至于题目中说的考虑效率,目前这种写法,应该就是我目前想到的,最简的解决问题的方式了。


如果你有更好的解决方案,找我切磋哈。




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


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

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

注册时间:2022-02-16

  • 博文量
    228
  • 访问量
    115457