數據庫內核雜談(五):如何實現排序和聚合

上篇文章中,我們着重介紹了對於一個SQL語句,數據庫是怎麼生成一個執行計劃,並根據這個執行計劃,一步一步地讀取,計算並獲得最後結果的。這一期,我們來聊一下兩個非常重要的算子(operator, 上一期我把operator翻譯成操作符,後來讀了其他前輩寫的文章,發現還是算子更貼切: 排序(Sort)和聚合(Aggregate)的實現。爲什麼要把這兩個算子放在一起說呢?其實,它們之間有很多的共同點,比如都是Blocking的算子,即需要得到所有的輸入tuple,才能完成計算後輸出。這就使得它們會遇到同樣的困難,比如內存無法存放所有的輸入tuple,怎麼辦?除了這個問題,排序和聚合還有很多其他藕斷絲連的地方。帶着這個問題,我們一起進入今天的內容吧。

排序

首先,來了解一下排序算子能夠實現哪些SQL語法。 既然是排序,第一想到的當然是ORDER BY語句啦。下圖給出了一些示例語句。

ORDER BY 示例語句

簡單介紹一下,數據庫系統會按照ORDER BY鍵的排列順序輸出結果。默認是升序(ASEC),也可以通過聲明來要求按照降序(DESC)輸出。示例二展示了可以對多個列進行組合排序,排序會按照排序列的先後順序依次進行。示例三展示了一類ORDER BY語句的語法糖(syntax sugar)。可以用編號1, 2來指定。示例四展示了排序的鍵並非一定要是某一個列,也可以是列組成的表達式。

除了可以實現ORDER BY,排序還能實現其他什麼語句嗎?答案是肯定的,比如下面這個示例:

SELECT DISTINCT 語句

上例展示了一個distinct的聚合語句,要求輸出所有不重複的class-id。讀者可能有疑問,排序怎麼實現這個聚合語句呢?哈,是不是覺得這兩個算子的關係有點緊密了?其實答案也並不難想到:先對所有學生以class-id進行排序,然後在上一層的聚合算子裏,只需要維護一個當前的class-id,並且同新的輸入做對比,如果不同就輸出class-id, 如果相同就保留。示例代碼如下:

DistinctAgg實現代碼

除了幫助實現這個聚合,排序帶來的另一個好處在於,把這個聚合變成了一個non-blocking的算子:不再需要等待所有的輸入,只要輸入的class-id和當前不匹配,即可輸出當前class-id,內存的問題也一併消失了。排序是不是挺厲害的,給大家留一個思考題?你還能想到其他SQL語句可以用排序來幫忙實現的嗎?答案在文末揭曉。

聊完了作用,來聊聊具體實現。前文已經說到,排序是blocking的,實現的難點就在於內存消耗。假設輸入的數據可以完全存放在內存中,那我們直接用快速排序就萬事大吉了。如果還要精益求精,那就需要看如何才能減少比較和交換的次數,更有甚者,去追求CPU register或者L1, L2緩存的利用率。如果數據量太大,不能一次性全存放在內存中呢。這就需要用到我們上一期提到過的spill to disk技巧了:需要把數據暫存到文件系統中。這裏,就引出今天提到的第一個算法:外部歸併排序(external merge sort)。工程中要實現一個正確並且高效的外部歸併排序是挺有挑戰的,所以有些數據庫系統在執行時需要消耗大量的內存或者乾脆要求加入limit語句來限制排序數量。

首先,我們應該明確,如何來衡量一個外部歸併排序的好壞。對於排序,你可能會脫口而出“快速排序,時間複雜度O(n * log(n))”。但是一旦牽涉到了文件IO,什麼O(n * log(n))都是浮雲。因爲文件讀寫的速度和內存差了兩個量級(100X),正確的衡量方法應該是大致需要讀寫多少次的數據。爲了方便衡量,我們先假設數據都是以頁(page)的形式存放在文件系統中,並且以頁的形式讀取到內存中(即,每次讀取的最小單位爲1頁)。

外部歸併排序的思路和歸併排序(merge sort)一樣,都是利用了分治算法。整個算法分成兩個階段:階段0)把所有數據分成小段,並對每一段進行排序(這裏假設每一小段都能夠存放在內存中所以我們可以用各種排序算法實現); 階段1) 把每一小段逐漸合併,最終完成全部排序。具體實現起來,我們至少需要分配給這個排序算子3頁的內存(2個用來作爲輸入緩存,1個用來作爲輸出緩存):假設輸入數據總共有n頁,排序只能2頁2頁地讀取,排序,然後輸出到文件系統暫存;然後對於已經排序完的n/2個文件(每個文件2頁),再依次讀取,排序,然後再輸出文件系統暫存,直至得到一個n頁的全排序文件。下圖給出了一個示例:

外部排序示例 (picture credit:https://15445.courses.cs.cmu.edu/fall2018/)

上述的外排算法,總共讀取了多少文件頁呢。我們這樣來算:

n #總共n頁數據# * 2 #讀寫各一次# * (1 #第一次讀和最後一次讀# + log2(n) #總共log2(n)次歸併#) 即:

如果要對n頁大小的數據進行排序並且只有3頁的內存,需要讀寫2n * (1 + log2(n))頁。

現在假設排序算子能分配b頁的內存,又該如何計算呢?思路是每一次可以合併b-1個頁面,最後答案是:

2n * ( 1 + log_b-1(N/b) ):這裏對數的底變成了b-1而不是原來的2。留給大家自行推導啦。

據我所知,所有數據庫的spill to disk排序大體思路都是外部歸併排序,當然最後的快慢還是需要工程實踐中的精益求精。

除了外排,還有什麼方法能夠過實現排序?不知大家是否回想起索引就是如果對要排序的鍵已經建有B+樹索引,可以通過B+樹索引查找到指定的葉節點,然後依次讀取數據即可。

總結一下,我們討論了排序能夠過實現哪些SQL語句,並且介紹了兩種排序的實現,分別是外部歸併排序和讀取B+樹索引結果。

聚合(aggregation)

聊完了排序,我們再來看聚合算子。聚合算子和聚合語句類型一一對應,那常見的聚合語句又有哪些呢?首先是單項聚合(scalar aggregation), 指聚合後的結果集是一個單一值(線性代數裏稱標量)。比如下面這些示例:

求學生總人數

求所有學生的平均年齡

其次就是組隊聚合(group aggregation),其結果是先對所有數據根據group by鍵分組,然後對每個組分別計算聚合值。比如下面示例:

求每個班的學生個數

求每個班的學生平均年齡

考考大家,下面這個聚合屬於哪個類別呢?

SELECT COUNT DISTINCT 語句

乍看之下,似乎是單項聚合因爲結果集是一個標量,求總共有多少個不同的班級。但其實,這個語句可以看成是一個單項聚合和組隊聚合的疊加,如下圖所示:

COUNT + GROUP BY

語句中先對班級進行組隊聚合,雖然聚合值是空,然後對班級再進行單項聚合求COUNT。

聊完了作用,來聊實現。單項聚合的實現非常簡單,算子只需要保存一個聚合的中間值(running aggregate value),然後根據新輸入不斷更新即可。而且,絕大部分聚合函數的是不需要存儲原始輸入的,比如max, min, count, avg等,因此內存消耗也不大。示例代碼如下:

ScalarAgg 實現代碼

再考考大家,你能想到有什麼單項聚合函數的實現是需要消耗大量內存的嗎?我唯一想到的就是求整個輸入集合的熵(因爲要統計所有不同元素的出現概率,相當於內部建立一個哈希表), 雖然感覺好像熵並不是一個SQL標準的聚合函數。

再來看組隊聚合的實現。單從聚合函數角度出發,組隊聚合於單項聚合的唯一區別就是,先要把group by鍵相同的輸入放到一個組裏,然後對每個組求解聚合函數即可。具體實現方法又有哪些呢?一就是上文提過的排序。我們可以藉助排序先把輸入按照group by鍵進行排序,然後我們只要針對相同的鍵來計算聚合函數即可。示例代碼如下:

SortGroupByAgg 實現代碼

從示例代碼來看,由於髒活累活都讓排序替我們幹了,實現和單項聚合類似,並且從內存消耗角度來看,依然不大。

除了排序,還有什麼辦法能夠幫我們實現聚合? 相信讀者馬上就能想到了,對group by鍵建立哈希表來維繫鍵和中間值的狀態。示例代碼如下:

HashGroupByAgg 代碼實現

從示例代碼來看,邏輯依然簡單明瞭。但難點在於,如果數據量特別大,就需要維護一個超級大的哈希表。考慮到需要維護哈希表的性能,一般維持使用率在50%左右,所以真正使用的內存空間應該會更大。這就遇到和前面排序一樣內存不夠的問題了。所以說,出來混,遲早要還的。

解決思路當然依舊是藉助文件系統暫存數據。這裏,我們引出外部哈希表的算法。算法的思路依然是分而治之。我們假設聚合算子總共能分配到b個頁面的內存。預留1頁用作輸入緩存,b-1頁用作分區(partition),對於那1頁輸入緩存中的所有數據,根據group by鍵求一個簡單的哈希來分配到其他b-1頁中(可以用下面這樣的哈希函數:hash_fun(key) % (b -1 ))。這b-1個頁作爲b-1個分區的輸出緩存,一旦寫滿就輸出到文件系統中。掃描過一遍數據後,我們把整個數據就分成了b-1個文件。假設每個文件的大小在b頁以內,那對於每一個文件,就可以利用上述的哈希表方法來實現組隊聚合,並且能夠保證不會超出b頁內存。

讀者可能會有疑問,萬一有文件依然大於b頁呢?有句名人名言怎麼說的來着,沒有什麼事情是一頓火鍋不能解決的,如果有,就兩頓。解決思路就是對這個超大文件再用一次分區算法:換一個哈希函數,再把這些數據分成b-1個小分區。一層分區理論上能夠解決b^2頁大小的數據,而二層分區就能解決b^3大小。因此即使輸入數據很大,也不會需要很多層的分區。

至此,我們介紹了外部哈希表的算法來解決聚合算子的內存消耗問題。

總結

今天,我們分別討論了排序和聚合這兩個算子用來實現哪些SQL語義,詳細介紹了工程實現的要點,即通過外部歸併排序和外部哈希表方法來解決內存消耗問題。並且也從中瞭解了排序算子可以用來協助實現聚合算子。

解答開篇的思考題,排序除了能夠幫助實現ORDER BY,GROUP BY語句,還能實現表的聯合JOIN:思路和歸併排序一樣,對於二元聯合 table_a JOIN table_b,我們只要針對聯合鍵分別對 table_a 和 table_b 進行排序,然後,對於兩個表分別維護一個指針,不斷往後迭代,當兩邊的鍵值相同的時候,就可以輸出聯合的結果。具體的實現,咱們就放在下期表的聯合(JOIN)的時候再聊。

作者介紹:

顧仲賢,現任Facebook Tech Lead,專注於數據庫,分佈式系統,數據密集型應用後端架構與開發。擁有多年分佈式數據庫內核開發經驗,發表數十篇數據庫頂級期刊並申請獲得多項專利,對搜索,即時通訊系統有深刻理解,愛設計愛架構,持續跟進互聯網前沿技術。

2008年畢業於上海交大軟件學院,2012年,獲得美國加州大學戴維斯計算機碩士,博士學位;2013-2014年任Pivotal數據庫核心研發團隊資深工程師,開發開源數據庫優化器Orca;2016年作爲初創員工加入Datometry,任首席工程師,負責全球首家數據庫虛擬化平臺開發;2017年至今就職於Facebook任Tech Lead,領導重構搜索相關後端服務及數據管道, 管理即時通訊軟件WhatsApp數據平臺負責數據收集,整理,並提供後續應用。

相關閱讀:

數據庫內核雜談(一):一小時實現一個基本功能的數據庫

數據庫內核雜談(二):存儲“演化論”

數據庫內核雜談(三):索引優化

數據庫內核雜談(四):執行模式

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