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

在之前的文章中,我們通過存儲索引,瞭解瞭如何把數據存儲在文件系統裏,然後根據不同的查詢語句,通過建立索引來提速讀取。今天,我們來聊一下當數據讀進內存後,數據庫怎麼繼續執行查詢。

之前系列文章都是以自底向上的線索來介紹數據庫內核。但在介紹具體的執行前,我覺得有必要從宏觀角度來看一下數據庫的內部結構是如何對輸入的SQL語句做處理,並返回結果集(Resultset)的。這樣一個宏觀的瞭解也會讓讀者對後續的數據庫執行和優化更加期待。在系列的第一期裏,我們介紹的一小時數據庫是直接根據SQL語句來看如何實現,那一個正兒八經的數據庫內部是怎樣的呢?下圖給出了一個宏觀數據庫的內部架構,我們跟着圖片一一介紹。

數據庫內部結構

編譯(parsing)

當用戶輸入SQL語句後,第一步是通過編譯器(parser)把語句編譯成抽象語法樹(Abstracted Syntax Tree)。這一步的主要過程就是確保輸入語句沒有SQL語法和詞法錯誤。常見的語法錯誤如:關鍵詞拼寫錯誤-把"SELECT"拼成"SELCT"; 是否有多餘的標點符號; 整個語句是否合法-SELECT後需要跟有FROM子語句,並且不應該有兩個WHERE子語句等等。編譯器的實現,一般不需要手動一個規則一個規則地去實現,而是會通過定義詞法和語法,然後由編譯器的庫來生成相關的代碼。一般每個語言都有比較成熟的編譯器庫,比如Java語言的JavaCC。這是網上找到的一個開源JavaCC實現的SQLParser ,有興趣的同學可以去深入瞭解下。生成的語法樹就是一個保留原語句的結構化的樹。比如,把下面示例語句編譯成語法樹

示例SQL

得到如下圖所示:

語法樹

根節點SELECT包含Projection-expression和GroupBy-expression,並且它有一個同爲SELECT的子節點。這個子節點有自己的Projection-expression和Where-clause。這裏,留一個小問題,假如查詢語句如下(查詢一個不存在的數據表),編譯器會報錯嗎?

錯誤示例SQL

綁定(binding)

揭曉答案,編譯器是無法察覺上面的SQL中的錯誤的,因爲它只負責把文本的SQL語句轉化成了一棵符合SQL語法結構的樹。那誰來賦予這棵樹靈魂呢?答案就是binder(姑且就翻譯爲綁定器吧)。顧名思義,綁定器的作用就是將語法樹通過和數據庫的元數據(metadata)結合,爲它附上語義(semantic)。比如語句裏有SELECT…FROM student,綁定器會去查詢元數據確認student表是否存在;如果存在,是否有class和id兩個屬性;對於屬性的後續操作是否符合規則-比如,對於SUBSTR()這個方法,輸入表達式必須是字符串類型等的一系列檢查。檢查過程是自底向上對整棵語法樹的節點依次進行,檢查的同時也把相關表的元數據,屬性的元數據附在語法樹上,最後生成含有語義的語法樹(bound AST)。綁定器在綁定的過程中就能察覺到上述SQL的問題而返回編譯錯誤。一旦綁定完成,這個SQL語句就算是通過編譯過程了。

優化(optimizing)

下一步就是優化器(Optimizer)的表演了。有這麼一個傳言,有這麼多很好用的開源數據庫,爲什麼商業數據庫還賣這麼貴?貴就貴在優化器這。優化器實現非常複雜,往往需要一個團隊來開發。但同時,一個好的優化器可以把執行速度提高好幾個數量級,特別是在針對複雜語句的優化上優勢更加明顯,可能就是一個小時和幾秒的差距(是不是應該買買買!)。後面會有專門的章節介紹優化器,今天稍微提一下大概。給定了語法樹,優化器會先生成一個邏輯執行樹(logical operator tree)。這個執行樹和我們第一章(一小時數據庫)末尾的執行樹類似。以上面的示例語句爲例,生成的執行樹如下:

邏輯執行樹

這個過程通常是語法樹節點到操作符節點一對一生成。生成後執行樹上的每個節點,稱爲邏輯操作符(logical operator)。再下一步,就是對應每個邏輯操作符,擴展出所有的物理操作符(physical operator)。何爲邏輯和物理操作符呢?比如TableScan,只是說明了這個操作符所要做的就是讀取某個表的數據,這就是邏輯操作符。而對應的物理操作符則同時表明了應該用什麼方法來實現這個功能,比如SequentialTableScan(全表掃描)就是TableScan的一個物理實現,指明瞭通過掃描全表來得到數據。而如果用BTreeIndexScan就表明通過讀取該表的BTree索引來讀取數據(建立在相應屬性已建立BTree索引的前提下)。再比如示例中的GroupByOperator,有什麼樣對應的物理操作符呢?方法一,通過建立Hash表來實現GroupBy(HashGroupByOperator);方法二,通過對子節點的輸入的key屬性進行排序,然後對於相同key進行聚合操作再輸出(SortGroupByOperator)。(注:後續文章講GroupByOperator實現的時候我們會深入講解,這邊就先簡略帶過)擴展之後的物理執行樹,相對應與原來的邏輯執行樹,相當於變出了很多分身:每個邏輯節點對位多個物理節點,比如一個物理執行樹可以用HashGroupBy配SequentialTableScan,也可以用HashGroupBy配BTreeIndexScan。對應示例的邏輯執行樹,相當於總共形成了4棵物理執行樹。下一步就是最最困難的,在這浩如煙海的物理執行樹中選出最好的一個,作爲執行計劃(Physical query plan)。看到這,讀者可能一臉懵比?4個等於浩如煙海?這只是因爲示例的語句很簡單,沒有太多的組合可能。那什麼情況會形成執行樹數量的爆炸呢?就是表的聯合(join)。假如一個SQL語句包含10個表的聯合,這10個表可以相互兩兩聯合形成中間表(intermediate result),這些中間表還需要再一次進行兩兩聯合,然後再繼續。並且,每一次聯合有兩種選擇(table1 join table2或者table2 join table1),而且聯合對應的物理操作符又有好幾個(HashJoin或者MergeSortJoin 注:在講Join operator的時候會深入講解)。這樣一來,一個複雜的查詢語句對應上百萬個執行樹就不難理解了。這裏先不深入講解優化器是怎麼做出選擇的,我們暫且假設它就是個黑盒操作選出了一個它認爲最優的作爲執行計劃。

執行(executing)

有了這個執行計劃,執行器要做的就是加載相應操作符的代碼,然後依次執行這些代碼。這些代碼和我們第一章的一小時數據庫給出的示例代碼功能類似。從執行樹的底層,由讀取表數據開始,依次向上執行。最後把執行得到的結果以Rowset的形式返回給用戶。

至此,一個完整的由輸入SQL語句開始,到輸出結果集的生命週期完整結束。梳理一下:

1)用戶輸入SQL語句 -> 編譯器 -> 抽象語法樹

2)抽象語法樹 -> 綁定器 -> 綁定語義的語法樹

3)綁定語義的語法樹 -> 優化器 -> 物理執行計劃

4)物理執行計劃 -> 執行器 -> 運行執行計劃,得到結果集,返回給用戶

執行模式

瞭解了整個流程,我們就可以更好地來看執行器是如何根據執行計劃,一步一步將數據讀取出來,然後計算出結果集的。先回到咱們第一章的一小時數據庫的執行器,看看它是怎麼執行的。在那個執行器裏,我們定義了兩個抽象的操作符,單元操作符(unary operator)和二元操作符(binary operator),示例代碼如下。

UnaryOperator和BinaryOperator

它們都實現了process邏輯,然後相應的物理操作符比如SequentialTableScan或者SortOperator只需要實現具體的__impl方法即可。根據這樣的執行器,生成如下的執行計劃代碼,即可運行我們示例中的SQL語句:

對應示例語句的執行計劃代碼

代碼運行時,自底向上,每個節點process方法只需要運行一次,一次性處理子節點的全部輸入Rowset,__impl處理後,返回處理過的Rowset。這種執行模式稱爲materialization模式(額,不知如何翻譯成中文)。寓意爲把自己的輸出打包一次性傳給上層節點。這種執行模式有哪些優點呢?首先,非常直觀,實現起來也相對容易,上下operator的交互只有一次。那這種模式有什麼缺點嗎?所謂成也風雲,敗也風雲。簡單就是它的缺點:一次性需要處理所有的輸入。如果我們要處理的數據特別大,假設某個表有1億條數據,TableScan需要把所有數據先讀取到內存中,再傳輸給上層節點,可能在這個過程中,就已經OOM(out of memory)了。因此,materialization模式並不適用數據量相對很大的OLAP(online analytical processing)查詢語句。

如何改進能夠避免OOM呢?有同學可能想到了,每個操作符並不需要把所有數據一次性處理完再打包傳給上層節點,完全可以借鑑時下流行的流系統(streaming system)的運行模式:每一個操作符既是producer,又是consumer:consume子節點的輸入,然後produce輸出給上層節點:數據就像水流那樣流過所有的節點,最後以一個一個tuple的形式返回給user。其實呢,可能正確的說法應該是streaming system借鑑了數據庫的這種運行模式。這個模式稱之爲iterator model(迭代模式)或者叫Volcano model(火山模式),最早由科學家Goetz Graefe於1990年提出(再次感謝一下計算機先賢)。下圖給出了簡單的僞代碼實現:

迭代模式下的UnaryOperator

每個operator會有一個next_tuple函數,用來讓上層節點調用來獲得下一個tuple,以及emit函數用來輸出一個處理過的tuple到上層節點。整個process的過程入下圖示例代碼:

迭代模式下的process邏輯

在while循環中,不斷獲取子節點的下一個輸入tuple,處理後輸出給上層節點直至子節點輸入全部處理完畢。這個迭代模式,是不是就完美解決了所有的OOM問題呢?答案是否定的。因爲,並不是所有的操作都適用於流模型,比如處理order by語句的SortOperator,如果要對全部輸入進行排序,必須等到所有輸入都得到後才能進行排序,因此執行過程會堵塞(block)。再比實現示例語句中的group by語句的HashGroupByOperator,也是需要獲取所有的輸入後才能做聚合操作來得到正確的結果再輸出。下圖給出了HashGroupByOperator的僞代碼實現:

迭代模式裏的HashGroupByOperator

對於子節點的每個輸入,我們先進行哈希表的建立和更新,當所有的輸入都結束後,依次輸出哈希表的鍵值對。這類操作也稱之爲堵塞操作(blocking operator)。那如何解決堵塞操作的OOM問題呢?這就需要這些操作能夠在處理的過程中把中間的結果集(intermediate result)暫存到文件系統中(spill to disk)。比如sort,可以用external file sorting algorithm。具體的這些操作符的實現我們會在後續的章節中詳細介紹。這裏插個題外話:大家可能覺得,實現spill to disk功能對數據庫引擎是必須的。但從工程角度來說,實現正確又高效是挺有難度的。比如作者公司內部使用最多的Presto, 已經是一個比較出名的開源數據庫系統實現。但至今爲止,感覺spill to disk功能也沒完全實現。在運行某些語句時,經常因爲遭遇單個節點內存limit或者集羣內存limit而報錯。吐槽一下!既然吐槽了自家公司,就再吐槽一家很出名的大數據公司。當時它們也推出了一款分佈式數據庫執行引擎。然後在測試的過程中發現,執行order by語句必須同時加上limit限定語句。哈,這一看就是當時sorting還不支持spill to disk. 留個思考題給大家,猜猜當時他們是怎麼實現sorting的。答案在結尾揭曉。

總結一下,迭代模式實現了流式處理,配合上spill to disk的實現來解決堵塞操作符,就是一個非常通用的執行模式,完美解決了materialization模式的缺點。那它自己有什麼缺點嗎?其實materialization的優點就是它的缺點:實現複雜度很高。而且數據是一個一個tuple在操作符間傳遞,這導致不同的操作符之間需要多次的協調,因此處理相同的數據,迭代模式比前者更慢。

materialization模式是一次性處理所有數據,而迭代模式是一個一個tuple處理數據,有沒有一種折中的方式呢?就是今天的最後一個知識點向量模式(vectorization model),或者叫批處理模式(batch model)。相比於迭代模式一個一個tuple處理,向量模式是一批一批處理數據,僞代碼如下圖所示:

向量模式下的UnaryOperator

向量模式下的process邏輯

向量模式相比於迭代模式,每一次處理多個數據,減少了操作符之間的交互;而相較於materialization模式,又更不容易導致OOM,所以實現相對容易。並且,可以更好地利用處理器的SIMD(single instruction multiple data)指令來提高執行速度。向量模式的另一個優點在於,很切合列存:批量從列存中讀取數據後進行批量處理。因此,比較適用於數據量很大的OLAP查詢語句。

最後,我們對今天介紹的所有執行模式來一個總結:

1)materialization模式:執行的過程自底向上,每個節點都一次性處理所有數據。優勢是實現簡單,但對於數據量很大的OLAP語句不太合適,但比較適合單次操作數據量較小的OLTP(online transactional processing)語句。

2)迭代模式(或者叫volcano model): 一種通用的執行模式。流式的執行過程,數據以一個一個tuple形式傳遞與操作符之間。有一些操作符會需要阻塞等待所有數據,需要spill to disk實現。缺點是實現複雜,由於操作符之間不斷交互,所以效率相對較低。

3)向量模式:介於前兩者之間,批量處理數據。更好地利用SIMD來提高執行速度。對於大量數據處理比迭代模式高效,所以也更適合OLAP語句。

這一期,我們先從宏觀上大致講解了數據庫的內部構造,然後具體聊了聊不同的執行模式以及它們的優缺點。下一期,我們聊一聊那些比較複雜的操作如sorting, join和GroupByOperator的具體實現,盡情期待。

揭曉上面的思考題,要求sorting加limit語句來限制總數,應該是用了minHeap來實現的排序。

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