Spark代码可读性与性能优化——示例十一(SQL与代码-蚂蚁森林示例)
前言
- 编写SQL处理业务问题,通常有简单易用、便捷、适用人群广泛等优点,是数据分析师的不二之选。
- 但是,SQL易用的同时也带来了性能的问题,当为了解决某些复杂的业务时,你不得不编写几十至几百行很复杂的SQL来处理。由于为了实现复杂的业务,SQL中会存在很多在逻辑层面来看的不必要的操作,导致性能浪费。
- 这个时候代码的优势就体现出来了,虽然代码适用人群没有SQL广泛,但是在某些情况下处理同一个逻辑时相比SQL更为清晰、简单(SQL为了一个功能,可能会绕一大圈)。
- 在这里,想讨论的就是此部分的业务,使用代码,抛弃无用的处理逻辑,以提升应用性能。
- 另外你还可以查看Spark代码可读性与性能优化——示例六,此处也有SQL(文章最后)与代码的示例,但是SQL可能会导致数据倾斜,影响性能。
业务描述
-
此处使用蚂蚁森林的一个业务问题,用作展示。为了易懂,会去除部分不必要的数据。
-
数据表
user_id_carbon
- 数据
user_id data_dt low_carbon u_001 2018/2/3 100 u_001 2018/2/3 20 u_002 2018/2/3 50 u_004 2018/2/4 70 u_004 2018/2/5 90 - 字段描述:
user_id
代表用户的唯一iddata_dt
代表日期low_carbon
代表当日某次的减少的碳排放量(每日可能存在多次)
-
业务需求
- 获取在2018年中连续三天(及以上)每天一共减少的碳排放量大于100的用户
-
业务条件分析
- 时间范围需要在2018年
- 每个用户每天可能会多次降低碳排放,因此需要统计每个用户每天降低的总的碳排放量
- 每个用户每天降低的总的碳排放量需要大于100,并且要求连续三天(及以上)
-
注意:后面的示例中,SQL已在HIVE中测试过,SparkSQL未知
使用SQL解决业务问题 - 方案1
- 得到2018年每个用户每日低碳量超过100的,生成表
t1
SELECT user_id, data_format(regexp_replace(data_dt, '/', '-'), 'yyyy-MM-dd') dt FROM user_low_carbon WHERE SUBSTRING(data_dt, 1, 4) = '2018' GROUP BY user_id, data_dt HAVING SUM(low_carbon) >= 100;
- 对于每个用户,获取到当前行前2行、后2行的日期,生成表
t2
SELECT user_id, dt, -- 使用开窗函数,LAG获取当前行前n行的数据 LAG(dt, 2, '1970-01-01') OVER(PARTITION BY user_id ORDER BY dt) lag2, LAG(dt, 1, '1970-01-01') OVER(PARTITION BY user_id ORDER BY dt) lag1, -- 使用开窗函数,LEAD获取当前行后n行的数据 LEAD(dt, 1, '9999-99-99') OVER(PARTITION BY user_id ORDER BY dt) lead1, LEAD(dt, 2, '9999-99-99') OVER(PARTITION BY user_id ORDER BY dt) lead2 FROM t1;
- 用户连续3天大于100的,生成表
t3
SELECT user_id, dt, -- DATADIFF日期相减 DATADIFF(dt, lag2) lag2_diff, DATADIFF(dt, lag1) lag1_diff, DATADIFF(dt, lead1) lead1_diff, DATADIFF(dt, lead2) lead2_diff FROM t2 WHERE (lag2_diff = '2' AND lag1_diff = '1') OR (lag1_diff = '1' AND lead1_diff = '-1') OR (lead1_diff = '-1' AND lead2_diff = '-2');
- 对于当前条满足其一,即可满足连续3天降低的碳排放量大于100
当天比前2行的日期小2
且当天比前1行的日期小1
当天比前1行的日期小1
且当天比后1行的日期大1
当天比后1行的日期大1
且当天比后2行的日期大2
使用SQL解决业务问题 - 方案2
- 得到2018年每个用户每日低碳量超过100的,生成表
t1
SELECT user_id, data_format(regexp_replace(data_dt, '/', '-'), 'yyyy-MM-dd') dt FROM user_low_carbon WHERE SUBSTRING(data_dt, 1, 4) = '2018' GROUP BY user_id, data_dt HAVING SUM(low_carbon) >= 100;
- 正对每个用户进行日期排序,并生成序号,生成表
t2
SELECT user_id, data_dt, -- 开窗函数,根据用户id分组,并根据日期排序,生成序号 RANK() OVER(PARTITION BY user_id ORDER BY data_dt) rk FROM t1;
- 使用日期减去序号,生成表
t3
SELECT user_id, data_dt, DATE_SUB(data_dt, rk) date_sub_rk FROM t2;
- 根据
用户+日期差值
分组,并计数,如果大于等于3,那么说明连续3天降低的碳排放量大于100SELECT user_id, data_dt -- 开窗函数,根据用户id、日期差值分组,并计数 COUNT(1) OVER(PARTITION BY user_id, date_sub_rk) c FROM t3 HAVING c >= 3;
使用代码带来更高的性能
- 前面的两种SQL方案中使用了大量的Shuffle操作(分组聚合、开窗函数、排序),显然是会影响性能的。这点其实已经不言自明了!^_^
- 从逻辑上来说,我首先只需要对用户id分组一次,然后在聚合时统计每天的总碳排放量,过滤掉小于100的,然后看下是否有连续3天(及以上)的即可,完全不需要这么多次Shuffle操作。
- 写代码,总体来说有两种方案
- 完全由代码实现处理逻辑(以此为示例)
- SQL+代码实现处理逻辑(利用SQL进行分组,然后自定义聚合函数,在里面写代码)
- 代码示例如下(未完)
object AnalysisApp { def main(args: Array[String]): Unit = { val conf = new SparkConf() .setAppName("AnalysisApp") val spark = SparkSession.builder() .config(conf) .enableHiveSupport() .getOrCreate() import spark.implicits._ // 开始处理 spark.read.table("user_low_carbon") .as[UserCarbon] .filter(_.data_dt.startsWith("2018")) // 只获取2018年的数据 .groupByKey(_.user_id) // 按用户id分组 .flatMapGroups(continue3Func) // 计算出碳排放量连续3天大于100的用户 .show() spark.stop() } // 原始数据示例:u_001, 2018/2/3, 100 private case class UserCarbon(user_id: Long, data_dt: String, low_carbon: Long) // 计算碳排放量连续3天大于100的函数 private val continue3Func = (id: Long, iter: Iterator[UserCarbon]) => { // 统计每天总的碳排放量 // TreeMap默认根据key排序(日期排序),自然顺序、升序 val treeMap = new java.util.TreeMap[String, Long]() for (userCarbon <- iter) { val value = treeMap.getOrDefault(userCarbon.data_dt, 0L) treeMap.put(userCarbon.data_dt, value + userCarbon.low_carbon) } // 计算连续3天及以上碳排放量大于100的 val finalList = ListBuffer[UserCarbon]() // 符合条件的UserCarbon val tempList = ListBuffer[UserCarbon]() // 临时的UserCarbon val format = new SimpleDateFormat("yyyy/MM/dd") // 初始值 var dayMills = 0L // 起始时间 var count = 0L // 连续大于100的个数 // 定义一个方法,处理已有的连续数据 def doClear(): Unit = { // 如果不满足,那么处理前面临时的数据 if (count < 3) { // 连续时间小3的,直接清空 tempList.clear() } else { // 连续时间大于等于3的,先保留,再清空temp finalList.appendAll(tempList) tempList.clear() } } // 开始遍历,treeMap已按时间升序 val entryIter = treeMap.entrySet().iterator() while (entryIter.hasNext) { val entry = entryIter.next() // 大于100,时间连续 -> 保存,count+=1,dayMills=time // 大于100,时间不连续 -> 处理前面,获得新起点,count=1,dayMills=time // 小于100,时间连续 -> 处理前面,count=0,dayMills=0 // 小于100,时间不连续 -> 处理前面,count=0,dayMills=0 // 碳排放量大于100的 if (entry.getValue >= 100) { // 比前面的日期大1天,即日期连续 val time = format.parse(entry.getKey).getTime if (time == dayMills + 86400L * 1000) { count += 1 } else { // 处理前面连续的数据 doClear() // 新的起点 count = 1L } tempList.append(UserCarbon(id, entry.getKey, entry.getValue)) dayMills = time } else { // 处理前面连续的数据,全部重置 doClear() count = 0L dayMills = 0L } } // 如果最后一条也连续,就将最后所有连续的刷入 doClear() finalList } }
- 对于
计算碳排放量连续3天大于100的函数continue3Func
的测试object AnalysisAppTest { def main(args: Array[String]): Unit = { val data = List( UserCarbon(1L, "2020/05/01", 45L), UserCarbon(1L, "2020/05/02", 80L), UserCarbon(1L, "2020/05/01", 45L), UserCarbon(1L, "2020/05/03", 140L), UserCarbon(1L, "2020/05/02", 45L), UserCarbon(1L, "2020/05/03", 70L), UserCarbon(1L, "2020/05/04", 70L), UserCarbon(1L, "2020/05/04", 90L), UserCarbon(1L, "2020/05/05", 60L), UserCarbon(1L, "2020/05/08", 145L), UserCarbon(1L, "2020/05/04", 70L), UserCarbon(1L, "2020/05/05", 75L), UserCarbon(1L, "2020/05/06", 45L), UserCarbon(1L, "2020/05/07", 120L), UserCarbon(1L, "2020/05/09", 160L), UserCarbon(1L, "2020/05/12", 145L), UserCarbon(1L, "2020/05/13", 145L), UserCarbon(1L, "2020/05/14", 145L), UserCarbon(1L, "2020/05/15", 145L) ) for (elem <- continue3Func(1L, data.iterator)) { println(elem) } } // 省略代码…… }
# 打印的结果如下 UserCarbon(1,2020/05/02,125) UserCarbon(1,2020/05/03,210) UserCarbon(1,2020/05/04,230) UserCarbon(1,2020/05/05,135) UserCarbon(1,2020/05/07,120) UserCarbon(1,2020/05/08,145) UserCarbon(1,2020/05/09,160) UserCarbon(1,2020/05/12,145) UserCarbon(1,2020/05/13,145) UserCarbon(1,2020/05/14,145) UserCarbon(1,2020/05/15,145)