SQL的弱點(1):複雜SQL不易理解,以及軟件工程如何來幫忙

1. SQL是經久不衰的基礎

能經過時間考驗的SQL,其優點毋庸置疑。

對於日常處理數據的朋友們(BI顧問,數據開發,數倉建模,數據研發,ETL工程師,AI工程師等),SQL更是一項非常重要的基礎技能。

這裏就不再列舉SQL的優點了(很多),而只談談SQL使用中的一些問題,這裏是系列文章的開篇:複雜SQL不易理解。

2. 講故事

先講個故事來示例,注:

  • 示例中的表和場景都是經過簡化的,實際中可能複雜非常多
  • 示例的SQL都不保證是最優的寫法
  • 示例中的表結構也只是示例作用

數據開發工程師小吳在一家零售企業工作,他最近的工作就是幫助運營小胡分析客戶畫像。

公司有2張表,都是直接存儲在最簡單好用的 Postgresql 12.2 數據庫中:

  • orders:訂單表
  • customers:客戶表

具體內容如下:

orders:

customers

2.1 Step1 - 需要統計每個 customer_id 的總消費額

小吳快速的寫了個SQL:

  1. SELECT
  2. customer_id,
  3. SUM(unit * unit_price *(1- discount)) AS total_sales
  4. FROM orders
  5. GROUP BY customer_id
  6. ORDER BY total_sales DESC

注:小吳是處女座的,所以SQL還是要經過排版的, 數據也是排好序的。

得到了如下結果:

2.2 Step2 - 加上客戶名和過濾掉非正常用戶

小胡很快給出了反饋:

  1. 雖然你是開發,你熟悉於直接用ID稱呼客戶,但是我不習慣, 我需要看中文名字
  2. 這個客戶ID 2, 我記得很清楚, 是我們的測試用戶,上次我們上線後,我就把它從數據庫中標記 is_delete 爲 True 了,你需要去除掉

小吳說:好的

在解決了如下問題後:

  1. 查閱了JOIN的幾種語法
  2. 通過表別名解決了錯誤:column reference "customer_id" is ambiguous
  3. 通過 max() 解決了錯誤:column "customers.customer_name" must appear in the GROUP BY clause or be used in an aggregate function

得到了如下SQL (注意:修改散落在多個地方)

  1. SELECT
  2. orders.customer_id,
  3. MAX(customer_name) AS customer_name,
  4. SUM(unit * unit_price *(1- discount)) AS total_sales
  5. FROM orders JOIN customers
  6. ON orders.customer_id = customers.customer_id
  7. WHERE customers.is_delete=False
  8. GROUP BY orders.customer_id
  9. ORDER BY total_sales DESC

得到結果:

2.3 Step3 - 複雜的任務來了,要把客戶分等級了

運營同學在阿里進修了一門《人人都可以當運營》課程,回來對數據小吳說:小吳呀,我們的會員體系要做起來呀,會員是我們以後上市的支柱,即使對我們的天使輪也是非常有用的呀。而且我學到了:“一定要結合客戶所在地做會員分級”,所以,我決定:

  1. 對於所在地在”上海“的客戶:如果他/她的消費額 >= 300, 那麼他/她是白金會員,如果在區間 [100, 300), 則是黃金會員,否則就是普通會員
  2. 對於所在地爲”杭州“的客戶:如果他/她的消費額 >= 250, 那麼他/她是白金會員,如果在區間 [80, 250), 則是黃金會員,否則就是普通會員

小吳這下要好好考慮這個問題了。

2.3.1 同一層SQL上改

首先,他試着在上步驟的SQL中,直接把會員等級這個直接算出來,

  1. SELECT
  2. orders.customer_id,
  3. MAX(customer_name) AS customer_name,
  4. SUM(unit * unit_price *(1- discount)) AS total_sales,
  5. CASE city
  6. WHEN '上海' THEN
  7. CASE WHEN SUM(unit * unit_price *(1- discount))>=300 THEN '白金'
  8. WHEN SUM(unit * unit_price *(1- discount))>=100 THEN '黃金'
  9. ELSE '普通'END
  10. WHEN '杭州' THEN
  11. CASE WHEN SUM(unit * unit_price *(1- discount))>=250 THEN '白金'
  12. WHEN SUM(unit * unit_price *(1- discount))>=80 THEN '黃金'
  13. ELSE '普通'END
  14. ENDas customer_rank
  15. FROM orders JOIN customers
  16. ON orders.customer_id = customers.customer_id
  17. WHERE customers.is_delete=False
  18. GROUP BY orders.customer_id
  19. ORDER BY total_sales DESC

得到結果:

2.3.2 重構

小吳突然想起了自己在從事“數據工程師”之前,自己在某電商公司還做過兩年"軟件工程師",當時的研發經理,天天用發音不太準的英語告訴小吳:

Do Not Repeat Yourself!

雖然沒直接問研發經理,不過愛好學習的小吳猜測經理可能是從小吳也看過的經典著作《重構》 (《Refactoring》)中看來的。

帶上“軟件工程師”的帽子後,小吳看看自己寫的SQL,除了感慨“同樣是工程,爲啥SQL工程和軟件工程差別咋就這麼大呢”。也發現了上面SQL還有不少問題:

  1. 重複的內容也太多了, 比如計算消費總額的時候, 不停的寫 SUM(unit * unit_price * (1 - discount))
  2. 嵌套的CASE WHEN也太複雜(雖然小吳分別用了CASE WHEN的兩種寫法,但是並沒有感覺到茴香豆的幾種寫法所帶來的快感),另外,如果以後客戶不光是“上海”,“杭州”了怎麼辦?

所以,小吳仔細重構了一版

  1. SELECT
  2. customer_id,
  3. customer_name,
  4. total_sales,
  5. CASE WHEN total_sales >= baijin_bar THEN '白金'
  6. WHEN total_sales >= huangjin_bar THEN '黃金'
  7. ELSE '普通'
  8. ENDas customer_rank
  9. FROM (
  10. SELECT
  11. orders.customer_id,
  12. MAX(customer_name) AS customer_name,
  13. MAX(city) AS city,
  14. SUM(unit * unit_price *(1- discount)) AS total_sales
  15. FROM orders JOIN customers
  16. ON orders.customer_id = customers.customer_id
  17. WHERE customers.is_delete=False
  18. GROUP BY orders.customer_id
  19. ORDER BY total_sales DESC
  20. ) t1 JOIN (
  21. VALUES ('上海',300,100),
  22. ('杭州',250,80))
  23. AS rank_dict(city, baijin_bar, huangjin_bar)
  24. ON t1.city = rank_dict.city

得到結果:

小吳看到:

  1. 沒有重複的計算“消費額”的邏輯
  2. 關於會員等級的計算, 通過查表的方式解決了不同城市不同計算方法的問題。

雖然:

  • SQL多了一層子查詢
  • 也請忽略程序員常見的中英文結合的名字, 比如:baijin_bar(白金會員入門門檻), huangjin_bar

小吳看着SQL很滿意,向欣賞一件藝術品一樣欣賞了10分鐘,並額外花了5分鐘調整了一下縮進和空格, 覺得自己同時是:

  • 寫SQL最好的程序員
  • 寫程序最好的SQL工程師

2.3.3 衝突

客戶覺得自己收到了重視,營業額多了2個百分點,公司很高興, 多找了一個數據開發工程師大吳來一起做數據(寫SQL)。

大吳第一天來找小吳熟悉之前寫的SQL,但是大吳花了半天時間仍沒有理解到底小吳寫的SQL是啥。因爲:

  • 業務需求是逐步增加的
  • SQL是那種寫的時候知道自己在做什麼,但是寫好後就不知道每個地方都是做了什麼了。

不過大吳經驗豐富,很快和小吳達成了如下共識,並說是實現了小吳很欣賞的“邏輯隔離”。他們每做一個來自運營小胡的新需求,就在之前的SQL上套上一層以上SQL,經過一段時間, SQL變爲:

  1. --- add byDaWu
  2. SELECT col1,col2,col3
  3. FROM (
  4. --- add byXiaoWu, feature 123
  5. SELECT col3, col4
  6. FROM (
  7. --- add byDaWu
  8. SELECT col5,col6,col7,col8
  9. FROM
  10. (
  11. -- add byXiaoWu, skip check
  12. ...............
  13. ...............
  14. ...............
  15. ...............
  16. ...............
  17. )ttt
  18. ) t99
  19. ) ttabc

當SQL行數超過了200 行,小吳覺得好像這樣不太好,不過大吳告訴小吳:彆着急,我之前所在的銀行, 普通的SQL都有幾千行,我們這算小菜一碟。

另外,小吳在向大吳提出了幾次縮進要求(每行要比上一個邏輯塊空出4個空格,不要寫TAB)後,也不再提了,因爲隨着層級太多, 每行開頭有幾百個空格也實在是對不齊了。而且小吳也聽過之前關於LISP程序員的程序最後一頁全是“)))))))”的笑話。於是,小吳繼續空4個空格寫,大吳繼續不留空格寫邏輯,兩個人竟彷彿達到了像一起工作多年的夥伴一樣的默契。

3. 捫心自問

在2020年初,經過了一個漫長的寒假後,小吳也在長假中有了機會思考一下之前SQL的問題,於是發起了“捫心自問”

  • 寫上面那些意大利麪式(spaghetti)的SQL好嗎?看着不太好
  • 意大利麪式SQL有自己的優勢嗎?有,從小吳和大吳的SQL的和諧相處可以看出還是有價值的
  • 我自己能看懂SQL所有的部分都是做什麼的嗎?不能。

又帶上“軟件工程師”的“帽子”,小吳陷入了沉思。

3.1 是否能用 temp table 解決

小吳想了半天,最終還是放棄了。

  • 意大利麪式的SQL的子查詢嵌套層級實在太多了,每個臨時數據都存到新的臨時表中, 實在是太多空間了
  • 那麼是否寫一些 drop table 命令,來在該臨時表不用時馬上釋放掉?想了想後,表示:自己也不知道啥時候臨時表不用了
  • 臨時表不光是佔空間, 而且還沒有索引,以及統計信息(statistics)等, 需要手工建立索引, 以及手工分析(ANALYZE) 來生成必要的統計信息

3.2 如何才能結合軟件工程的實踐

小吳又仔細讀起了 PostgreSQL 的文檔:https://www.postgresql.org/docs/current/index.html

突然有了靈感。WITH Queries (Common Table Expressions):https://www.postgresql.org/docs/current/queries-with.html 好像可以。

於是小吳結合自己之前的編程經驗,把這個方案詳細的寫了下來

4. 初步方案

大吳的意大利麪SQL的寫法有其優勢:

  • 每次的業務需求就是一層SQL
  • 雖然放在一起比較難看,但是分開寫好像會比較清晰

比如:要做到第2章的例子,小吳可以這樣寫:

  1. Steps:
  2. - name: step_filter_customer1
  3. comment:過濾掉非法客戶
  4. sql:|-
  5. SELECT *
  6. FROM customers
  7. WHERE customers.is_delete=False
  8. - name: step_calculate_total_sales
  9. comment:計算客戶的總消費額
  10. sql:|-
  11. SELECT orders.customer_id,
  12. MAX(customer_name) AS customer_name,
  13. MAX(city)as city,
  14. SUM(unit * unit_price *(1- discount)) AS total_sales
  15. FROM orders JOIN step_filter_customer1
  16. ON orders.customer_id = step_filter_customer1.customer_id
  17. GROUP BY orders.customer_id
  18. ORDER BY total_sales DESC
  19. - name: step_rank_dict
  20. comment:存儲根據城市和消費額來決定會員等級的記錄
  21. sql:|-
  22. SELECT *
  23. FROM
  24. (VALUES ('上海',300,100),
  25. ('杭州',250,80))
  26. AS rank_dict(city, baijin_bar, huangjin_bar)
  27. - name: step_compute_customer_rank
  28. comment:計算客戶的會員等級
  29. sql:|-
  30. SELECT step_calculate_total_sales.*,
  31. CASE WHEN total_sales >= baijin_bar THEN '白金'
  32. WHEN total_sales >= huangjin_bar THEN '黃金'
  33. ELSE '普通'
  34. ENDas customer_rank
  35. FROM step_calculate_total_sales JOIN step_rank_dict
  36. ON step_calculate_total_sales.city = step_rank_dict.city

 

小吳選取了最新最流行的 YAML 文件格式,而沒選擇之前的:INI,XML,JSON等格式,小吳也覺得自己還是挺 In Time 的。

這樣, 我們就可以:

  • 把編寫SQL分成:面向人的SQL和麪向數據庫的SQL。面向人的SQL注重可讀性,面向數據庫的則注重效率。這一點有點像編程中的高級語言JAVA和麪向機器的彙編語言之前的關係
  • 把複雜的SQL拆分成多個小的SQL,每個小的SQL只負責一小塊邏輯
  • 把各個步驟之前的SQL按照引用關係,轉爲一個有向無環圖(Directed Acyclic Graph, DAG), 這樣我們可以用比較成熟的DAG遍歷來組合成最終的SQL

通過讀取上面人工編寫的yaml文件, 經過我們的小的程序轉化後, 面向機器執行的SQL變爲:

  1. WITH step_calculate_total_sales AS (
  2. WITH step_filter_customer1 AS (
  3. SELECT *
  4. FROM customers
  5. WHERE customers.is_delete=False
  6. )
  7. SELECT orders.customer_id,
  8. MAX(customer_name) AS customer_name,
  9. MAX(city)as city,
  10. SUM(unit * unit_price *(1- discount)) AS total_sales
  11. FROM orders JOIN step_filter_customer1
  12. ON orders.customer_id = step_filter_customer1.customer_id
  13. GROUP BY orders.customer_id
  14. ORDER BY total_sales DESC
  15. ), step_rank_dict AS (
  16. SELECT *
  17. FROM
  18. (VALUES ('上海',300,100),
  19. ('杭州',250,80))
  20. AS rank_dict(city, baijin_bar, huangjin_bar)
  21. )
  22. SELECT step_calculate_total_sales.*,
  23. CASE WHEN total_sales >= baijin_bar THEN '白金'
  24. WHEN total_sales >= huangjin_bar THEN '黃金'
  25. ELSE '普通'
  26. ENDas customer_rank
  27. FROM step_calculate_total_sales JOIN step_rank_dict
  28. ON step_calculate_total_sales.city = step_rank_dict.city

得到結果:

Yeah,成功把複雜SQL拆分成面向人的多個SQL,並最終執行時, 還是有翻譯好的高效的面向機器的唯一SQL。

4.2 如何利用DAG來易化“轉化程序”的書寫

其實DAG是計算機領域非常成熟的概念,以 Apache DolphinScheduler 中的相關代碼爲例,

注:Apache DolphinScheduler是國人發起的“分佈式易擴展的可視化工作流任務調度“開源項目,並已經進入Apache孵化,筆者作爲早期參加者和PPMC,也非常希望能吸引更多的人士加入到DolphinScheduler的開發。DolphinScheduler的項目地址在:https://github.com/apache/incubator-dolphinscheduler

比如DolphinScheduler中的DAG類:https://github.com/apache/incubator-dolphinscheduler/blob/dev/dolphinscheduler-common/src/main/java/org/apache/dolphinscheduler/common/graph/DAG.java

  1. publicclass DAG<Node,NodeInfo,EdgeInfo>{
  2. // add node information
  3. publicvoid addNode(Node node,NodeInfo nodeInfo)
  4. publicboolean addEdge(Node fromNode,Node toNode)
  5. publicboolean containsNode(Node node)
  6. // whether this edge is contained
  7. publicboolean containsEdge(Node fromNode,Node toNode)
  8. // get node description
  9. publicNodeInfo getNode(Node node)
  10. publicint getNodesCount()
  11. publicint getEdgesCount()
  12. publicCollection<Node> getBeginNode()
  13. publicCollection<Node> getEndNode()
  14. // Gets all previous nodes of the node
  15. publicSet<Node> getPreviousNodes(Node node)
  16. // Get all subsequent nodes of the node
  17. publicSet<Node> getSubsequentNodes(Node node)
  18. // Gets the degree of entry of the node
  19. publicint getIndegree(Node node)
  20. // whether the graph has a ring
  21. publicboolean hasCycle()
  22. // DAG has a topological sort
  23. publicList<Node> topologicalSort()throwsException
  24. }

這個流程變爲:

  1. 遍歷yaml中最上層數組的每個記錄
  2. 對於每條記錄,判斷是否有前置依賴(有的話加 edge),把本身作爲 node 加入 DAG
  3. 進行拓撲排序(topologicalSort)
  4. 把排序好的節點從前到後一個一個處理,通過WITH語句串起來

4.3 上面只是一種可行思路, 但是細節是魔鬼

上面的思路,感覺對Postgresql的SQL可讀性做了非常棒的探索。但是,真正能用用於商業還是有很多細節的, 比如:每個步驟的schema信息,每個步驟的預覽,以及某一步的schema變化後的處理。

所以,除了自行探索,也可以使用現成的商業產品。比如:筆者所在的創業公司——觀遠數據,就有豐富的數據可視化和數據開發平臺等多個產品,歡迎訪問官網進行了解:https://www.guandata.com/

注:文中所描述的方法並不是觀遠數據系統ETL中所使用的實現方法,觀遠數據系統中有着更先進、完善的實現。

5. 想象空間

有了上面的方案, 我們可以把SQL變爲可拆分,容易讀懂的方式,並且每一步轉化都是有註釋的可以理解的小步驟。

我們還可以繼續參考”軟件工程“中的其它實踐來管理SQL, 比如:

  1. SQL yaml文件上傳到github,進行版本控制
  2. 也可以編寫單元測試
  3. 通過Github的Action做CI/CD, 自動化測試等

從此SQL也逐漸軟件工程起來。

正所謂:

  • 軟件工程用的好,SQL寫的好
  • 軟件工程用的好,下班早,頭髮多
  • 軟件工程用的好,徹底重寫少

注:本文來自於觀遠數據吳寶琪原創,轉載或更多交流請關注公衆號:架構578

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章