Spark代碼可讀性與性能優化——示例十一(SQL與代碼-螞蟻森林示例)
前言
- 編寫SQL處理業務問題,通常有簡單易用、便捷、適用人羣廣泛等優點,是數據分析師的不二之選。
- 但是,SQL易用的同時也帶來了性能的問題,當爲瞭解決某些複雜的業務時,你不得不編寫幾十至幾百行很複雜的SQL來處理。由於爲了實現複雜的業務,SQL中會存在很多在邏輯層面來看的不必要的操作,導致性能浪費。
- 這個時候代碼的優勢就體現出來了,雖然代碼適用人羣沒有SQL廣泛,但是在某些情況下處理同一個邏輯時相比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 resultList = ListBuffer[UserCarbon]() // 符合條件的UserCarbon val tempList = ListBuffer[UserCarbon]() // 臨時的UserCarbon val format = new SimpleDateFormat("yyyy/MM/dd") // 初始值 var dayMills = 0L // 起始時間 var count = 0L // 連續大於100的個數 // 開始遍歷,treeMap已按時間升序 val entryIter = treeMap.entrySet().iterator() while (entryIter.hasNext) { val entry = entryIter.next() // 碳排放量大於100的 if (entry.getValue >= 100) { val time = format.parse(entry.getKey).getTime // 比前面的日期大1天 if (time == dayMills + 86400L * 1000) { // 計數,並存入臨時區 count += 1 tempList.append(UserCarbon(id, entry.getKey, entry.getValue)) } else { // 如果日期相差不是1天,那麼處理前面臨時的數據 if (count < 3) { // 連續時間小3的,直接清空 tempList.clear() } else { // 連續時間大於等於3的,先保留,再清空 resultList.appendAll(tempList) tempList.clear() } // 重置時間起點 dayMills = time } } } resultList } }