我們討論過代碼編寫的難和繁的原理問題,現在關注性能問題,運行速度當然是非常重要的事情。
我們知道,軟件不能改變硬件的性能,CPU 和硬盤該多快就多快。不過,我們可以設計出低復雜度的算法,也就是計算量更小的算法,計算機執行的動作變少,自然也就會快了。本來要做 1 億次運算,如果有個好算法能把計算量降低到 100 萬次,那快出 100 倍就不奇怪了。但是,光想出算法還不夠,還要把這個算法實實在在地用某種程序語言寫出來,否則計算機不會執行。
然而,如果采用的程序語言不給力,就有可能真地寫不出來,這時候就干瞪眼忍受低速度。
SQL 就會經常發生這種事。我們用這個簡單例子來說明:從 1 億條數據中取出前 10 名,用 SQL 寫出來并不復雜:
SELECT TOP 10 * FROM Orders ORDER BY Amount DESC
這個語句里有個 ORDER BY 的字樣,對應的執行邏輯就是要對 1 億條數據做大排序。我們知道,排序是個非常慢的動作,要把數據遍歷多次(具體復雜度是 N*logN 次 ),如果數據量大到內存裝不下,那還需要外存做緩存,性能還會急劇下降。如果嚴格按這句 SQL 描述的邏輯去執行,這個運算無論如何是跑不快的。
其實,我們知道,只是取前 10 名并不需要將所有數據做大排序,只要在遍歷時始終保持一個前 10 名的小集合,遍歷過程中不斷地修正這個小集合,一次遍歷可以了,復雜度是 N*log10,和 log1億相比差了 8 倍左右,也就是計算量能小 8 倍,而且這種算法完全不需要外存做緩存。
遺憾的是,用 SQL 無法描述這樣的計算過程,只能寫成上面那個樣子,然后指望數據庫去優化。所幸,幾乎所有數據庫都會優化這個句子,沒有傻到去做大排序了,所以也能跑得比較快。
但是,情況再復雜一些會怎樣呢?比如我們把需求改成計算分組內的前 10 名,SQL 寫出來是這樣的:
SELECT * FROM ( SELECT *, ROW_NUMBER() OVER (PARTITION BY Area ORDER BY Amount DESC) rn FROM Orders ) WHERE rn<=10
這和剛才針對全集取前 10 名的寫法相差就很大了,這是我們之前說的缺失離散性導致的。要先借助窗口函數造一個組內序號出來,再用子查詢過濾出符合條件的記錄,也就是有點“繞”了。
無論如何,這里還是有 ORDER BY 字樣,其中的運算邏輯還是要排序。我們實際測試發現,在 Oracle 中,同樣的數據量,計算這種分組前 10 名要比上面那個全集前 10 名慢出幾十倍,按說多個分組應該只慢一點點才對。Oracle 有很大可能性真地去做了排序甚至是外存排序(當然我們沒讀過 Oracle 的源代碼并不能確定),數據庫優化引擎在這種稍復雜的情況下就暈掉了,只能老老實實按 SQL 寫的邏輯去執行,性能就會陡降。
當然,也許哪天數據庫又進化了,碰到這種情況也會優化了(事實上確實有了)。但總有更復雜的情況,而數據庫優化引擎的進化速度是非常慢的。
比如這么一個更復雜的需求,做電商系統中的漏斗分析,計算每一步動作后的用戶流失率。下面是一個實際業務中發生的三步漏斗計算,SQL 會寫成這樣:
WITH e1 AS ( SELECT userid, visittime AS step1_time, MIN(sessionid) AS sessionid, 1 AS step1 FROM defined_events e1 JOIN eventgroup ON eventgroup.id = e1.eventgroup WHERE visittime >= DATE_ADD(arg_date,INTERVAL -14 day) AND visittime < arg_date AND eventgroup.name='SiteVisit' GROUP BY userid,visittime ), e2 AS ( SELECT e2.userid, MIN(e2.sessionid) AS sessionid, 1 AS step2, MIN(visittime) AS step2_time, MIN(e1.step1_time) AS step1_time FROM defined_events e2 JOIN e1 ON e1.sessionid = e2.sessionid AND visittime > step1_time JOIN eventgroup ON eventgroup.id = e2.eventgroup WHERE visittime < DATE_ADD(step1_time ,INTERVAL +1 day) AND eventgroup.name = 'ProductDetailPage' GROUP BY e2.userid ), e3 AS ( SELECT e3.userid, MIN(e3.sessionid) AS sessionid, 1 AS step3, MIN(visittime) AS step3_time, MIN(e2.step1_time) AS step1_time FROM defined_events e3 JOIN e2 ON e2.sessionid = e3.sessionid AND visittime > step2_time JOIN eventgroup ON eventgroup.id = e3.eventgroup WHERE visittime < DATE_ADD(step1_time ,INTERVAL +1 day) AND (eventgroup.name = 'OrderConfirmationType1') GROUP BY e3.userid ) SELECT s.devicetype AS devicetype, COUNT(DISTINCT CASE WHEN funnel_conversions.step1 IS NOT NULL THEN funnel_conversions.step1_userid ELSE NULL END) AS step1_count, COUNT(DISTINCT CASE WHEN funnel_conversions.step2 IS NOT NULL THEN funnel_conversions.step2_userid ELSE NULL END) AS step2_count, COUNT(DISTINCT CASE WHEN funnel_conversions.step3 IS NOT NULL THEN funnel_conversions.step3_userid ELSE NULL END) AS step3_count, COUNT(DISTINCT CASE WHEN funnel_conversions.step3 IS NOT NULL THEN funnel_conversions.step3_userid ELSE NULL END) / COUNT(DISTINCT CASE WHEN funnel_conversions.step1 IS NOT NULL THEN funnel_conversions.step1_userid ELSE NULL END) AS step3_rate FROM ( SELECT e1.step1_time AS step1_time, e1.userid AS userid, e1.userid AS step1_userid, e2.userid AS step2_userid,e3.userid AS step3_userid, e1.sessionid AS step1_sessionid, step1, step2, tep3 FROM e1 LEFT JOIN e2 ON e1.userid=e2.userid LEFT JOIN e3 ON e2.userid=e3.userid ) funnel_conversions LEFT JOIN sessions s ON funnel_conversions.step1_sessionid = s.id GROUP BY s.devicetype
這個 SQL“繞”得很嚴重了,看懂非常費勁,擺在這里就是感受一下。這還只是三步,想再多算幾步還得寫更多子查詢,那就擺不出來了。這種復雜的 SQL,真想不出這能怎么做優化。結果,這句 SQL 在 Snwoflake 的 Medium 集群上(4 節點)跑了 3 分鐘沒出來,用戶只能放棄。
所以,不能也不要指望數據庫優化,還是要自己寫出高性能算法出來。
那么,什么樣的程序語言才能寫出剛才說的這些算法呢?
很多,C++,Java 這些都可以。理論上用 SQL 寫的存儲過程也可以,但工程實現的結果還是會太慢,這還是因為缺失離散性。比如 TopN 問題,在 SQL 中要保存那 10 個成員的小集合也得用臨時表,很難寫也很慢。
那就不用 SQL,用 C++,Java 這些去寫好了。
這又回到我們之前說的集合化特性了,這些語言缺乏集合化,寫出來很繁瑣。像分組 TopN,如果要考慮各種數據類型、多個分組鍵、還可能帶著計算式,幾千行也未必寫得出來。而漏斗分析這種復雜運算,還要考慮大數據存儲問題,寫個幾萬行也很正常。開發成本極高,以后維護時也累死人。
這樣,在現實中就沒有可操作性了。
所以,代碼能寫著簡單就變得非常有意義了。一方面是短小,這意味著工作量少,另一方面還要容易,這意味著更多的程序員可以寫。從這個角度上看,寫著簡單和跑得快是一回事。想跑得快,就是要有一種程序語言能讓高性能算法寫著簡單,這才有可操作性。
這樣的標準,可能只有 esProc SPL 適合了,它同時擁有集合化和離散性兩種特性,又提供相當多固有的高性能算法庫函數,這才能寫著簡單,從而跑得快。
前面的前 10 名問題用 SPL 寫出來是這樣的:
Orders.groups(;top(10;-Amount) Orders.groups(Area;top(10;-Amount))
SPL 把 TopN 理解為聚合計算,這個語句中沒有排序的字樣,也就不會做大排序,而采用剛才說的快速算法了。而且,這里分組前 10 名和全集前 10 名的寫法基本一樣,只是多了分組鍵。這也是在集合化的基礎上支持了離散性的結果。
漏斗分析用 SPL 寫出來是這樣:
A | |
1 | =now() |
2 | =eventgroup=file("eventgroup.btx").import@b() |
3 | =devicetype=file("devicetype.btx").import@b() |
4 | =long(elapse(arg_date,-14)) |
5 | =long(arg_date) |
6 | =long(arg_date+1) |
7 | =A2.(case(NAME,"SiteVisit":1,"ProductDetailPage":2,"OrderConfirmationType1":3;null)) |
8 | =file("defined_events.ctx").open() |
9 |
=A8.cursor@m(USERID,SESSIONID,VISITTIME,EVENTGROUPNO;VISITTIME>=A4 && VISITTIME |
10 | =sessions=file("sessions.ctx").open().cursor@m(USERID,ID,DEVICETYPENO;;A9) |
11 | =A9.joinx@m(USERID:SESSIONID,A10ID,DEVICETYPENO) |
12 | =A11.group(USERID) |
13 |
=A12.new(~.align@a(3,EVENTGROUPNO):e,e(1).select(VISITTIME |
14 |
=A13.run(e=join@m(e1:e1,SESSIONID;e2:e2,SESSIONID).select(e2=e2.select(VISITTIME>e1.VISITTIME && VISITTIME |
15 |
=A14.run(e0=e1.id(DEVICETYPENO),e1=e.min(e1.VISITTIME),e2=e.min(e2),e=e.min(e1.SESSIONID),e3=e3.select(SESSIONID==e && VISITTIME>e2 && VISITTIME |
16 | =A15.news(e;~:DEVICETYPE,e2,e3) |
17 | =A16.groups(DEVICETYPE;count(1):STEP1_COUNT,count(e2):STEP2_COUNT,count(e3):STEP3_COUNT,null:STEP3_RATE) |
18 | =A17.run(DEVICETYPE=devicetype.m(DEVICETYPE).DEVICETYPE,STEP3_RATE=STEP3_COUNT/STEP1_COUNT) |
19 | =interval@s(A1,now()) |
這個代碼要比前面的 SQL 更短,而且更靈活,再增加幾步也還是這段代碼。實測的結果,這段代碼用單臺服務器 10 秒就跑完了。 有了集合化和離散性的 SPL,才能做到寫著簡單又跑得快。
本文來源:github.com
審核編輯:湯梓紅
-
cpu
+關注
關注
68文章
10825瀏覽量
211151 -
計算機
+關注
關注
19文章
7421瀏覽量
87718 -
SQL
+關注
關注
1文章
760瀏覽量
44076 -
程序
+關注
關注
116文章
3777瀏覽量
80851
原文標題:寫著簡單和跑得快是一回事,SQL 為什么不可能跑得快?
文章出處:【微信號:芋道源碼,微信公眾號:芋道源碼】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論