今天也沒啥講的,就跟大家一起探索一下MySQL編譯器。
數據庫系統能夠接受SQL語句,並返回數據查詢的結果,或者對數據庫中的數據進行修改,可以說幾乎每個程序員都使用過它。
而MySQL又是目前使用最廣泛的數據庫。所以,解析一下MySQL編譯並執行SQL語句的過程,一方面能幫助你加深對數據庫領域的編譯技術的理解;另一方面,由於SQL是一種最成功的DSL(特定領域語言),所以理解了MySQL編譯器的內部運作機制,也能加深你對所有使用數據操作類DSL的理解,比如文檔數據庫的查詢語言。另外,解讀SQL與它的運行時的關系,也有助於你在自己的領域成功地使用DSL技術。
那么,數據庫系統是如何使用編譯技術的呢?接下來,我就會花兩講的時間,帶你進入到MySQL的內部,做一次全面的探秘。
今天這一講,我先帶你了解一下如何跟蹤MySQL的運行,了解它處理一個SQL語句的過程,以及MySQL在詞法分析和語法分析方面的實現機制。
好,讓我們開始吧!
編譯並調試MySQL’
按照慣例,你要下載MySQL的源代碼。我下載的是8.0版本的分支。
源代碼里的主要目錄及其作用如下,我們需要分析的代碼基本都在sql目錄下,它包含了編譯器和服務端的核心組件。
MySQL的源代碼主要是.cc結尾的,也就是說,MySQL主要是用C++編寫的。另外,也有少量幾個代碼文件是用C語言編寫的。
為了跟蹤MySQL的執行過程,你要用Debug模式編譯MySQL,具體步驟可以參考這篇開發者文檔。
如果你用單線程編譯,大約需要1個小時。編譯好以后,先初始化出一個數據庫來:
./mysqld --initialize --user=mysql
這個過程會為root@localhost用戶,生成一個缺省的密碼。
接着,運行MySQL服務器:
./mysqld &
之后,通過客戶端連接數據庫服務器,這時我們就可以執行SQL了:
./mysql -uroot -p #連接mysql server
最后,我們把GDB調試工具附加到mysqld進程上,就可以對它進行調試了。
gdb -p `pidof mysqld` #pidof是一個工具,用於獲取進程的id,你可以安裝一下
提示:這一講中,我是采用了一個CentOS 8的虛擬機來編譯和調試MySQL。我也試過在macOS下編譯,並用LLDB進行調試,也一樣方便。
注意,你在調試程序的時候,有兩個設置斷點的好地方:
● dispatch_command:在sql/sql_parse.cc文件里。在接受客戶端請求的時候(比如一個SQL語句),會在這里集中處理。
● my_message_sql:在sql/mysqld.cc文件里。當系統需要輸出錯誤信息的時候,會在這里集中處理。
這個時候,我們在MySQL的客戶端輸入一個查詢命令,就可以從雇員表里查詢姓和名了。在這個例子中,我采用的數據庫是MySQL的一個示例數據庫employees,你可以根據它的文檔來生成示例數據庫。
mysql> select first_name, last_name from employees; #從mysql庫的user表中查詢信息
這個命令被mysqld接收到以后,就會觸發斷點,並停止執行。這個時候,客戶端也會老老實實地停在那里,等候從服務端傳回數據。即使你在后端跟蹤代碼的過程會花很長的時間,客戶端也不會超時,一直在安靜地等待。給我的感覺就是,MySQL對於調試程序還是很友好的。
在GDB中輸入bt命令,會打印出調用棧,這樣你就能了解一個SQL語句,在MySQL中執行的完整過程。為了方便你理解和復習,這里我整理成了一個表格:
我也把MySQL執行SQL語句時的一些重要程序入口記錄了下來,這也需要你重點關注。它反映了執行SQL過程中的一些重要的處理階段,包括語法分析、處理上下文、引用消解、優化和執行。你在這些地方都可以設置斷點。
好了,現在你就已經做好准備,能夠分析MySQL的內部實現機制了。不過,由於MySQL執行的是SQL語言,它跟我們前面分析的高級語言有所不同。所以,我們先稍微回顧一下SQL語言的特點。
SQL語言:數據庫領域的DSL
SQL是結構化查詢語言(Structural Query Language)的英文縮寫。舉個例子,這是一個很簡單的SQL語句:
select emp_no, first_name, last_name from employees;
其實在大部分情況下,SQL都是這樣一個一個來做語句執行的。這些語句又分為DML(數據操縱語言)和DDL(數據定義語言)兩類。前者是對數據的查詢、修改和刪除等操作,而后者是用來定義數據庫和表的結構(又叫模式)。
我們平常最多使用的是DML。而DML中,執行起來最復雜的是select語句。所以,在本講,我都是用select語句來給你舉例子。
那么,SQL跟我們前面分析的高級語言相比有什么不同呢?
第一個特點:SQL是聲明式(Declarative)的。這是什么意思呢?其實就是說,SQL語句能夠表達它的計算邏輯,但它不需要描述控制流。
高級語言一般都有控制流,也就是詳細規定了實現一個功能的流程:先調用什么功能,再調用什么功能,比如if語句、循環語句等等。這種方式叫做命令式(imperative)編程。
更深入一點,聲明式編程說的是“要什么”,它不關心實現的過程;而命令式編程強調的是“如何做”。前者更接近人類社會的領域問題,而后者更接近計算機實現。
第二個特點:SQL是一種特定領域語言(DSL,Domain Specific Language),專門針對關系數據庫這個領域的。SQL中的各個元素能夠映射成關系代數中的操作術語,比如選擇、投影、連接、笛卡爾積、交集、並集等操作。它采用的是表、字段、連接等要素,而不需要使用常見的高級語言的變量、類、函數等要素。
所以,SQL就給其他DSL的設計提供了一個很好的參考:
● 采用聲明式,更加貼近領域需求。比如,你可以設計一個報表的DSL,這個DSL只需要描述報表的特征,而不需要描述其實現過程。
● 采用特定領域的模型、術語,甚至是數學理論。比如,針對人工智能領域,你完全就可以用張量計算(力學概念)的術語來定義DSL。
好了,現在我們分析了SQL的特點,從而也讓你了解了DSL的一些共性特點。那么接下來,順着MySQL運行的脈絡,我們先來了解一下MySQL是如何做詞法分析和語法分析的。
詞法和語法分析
詞法分析的代碼是在sql/sql_lex.cc中,入口是MYSQLlex()函數。在sql/lex.h中,有一個symbols[]數組,它定義了各類關鍵字、操作符。
MySQL的詞法分析器也是手寫的,這給算法提供了一定的靈活性。比如,SQL語句中,Token的解析是跟當前使用的字符集有關的。使用不同的字符集,詞法分析器所占用的字節數是不一樣的,判斷合法字符的依據也是不同的。而字符集信息,取決於當前的系統的配置。詞法分析器可以根據這些配置信息,正確地解析標識符和字符串。
MySQL的語法分析器是用bison工具生成的,bison是一個語法分析器生成工具,它是GNU版本的yacc。bison支持的語法分析算法是LALR算法,而LALR是LR算法家族中的一員,它能夠支持大部分常見的語法規則。bison的規則文件是sql/sql_yacc.yy,經過編譯后會生成sql/sql_yacc.cc文件。
sql_yacc.yy中,用你熟悉的EBNF格式定義了MySQL的語法規則。我節選了與select語句有關的規則,如下所示,從中你可以體會一下,SQL語句的語法是怎樣被一層一層定義出來的:
select_stmt:
query_expression
| ...
| select_stmt_with_into
;
query_expression:
query_expression_body opt_order_clause opt_limit_clause
| with_clause query_expression_body opt_order_clause opt_limit_clause
| ...
;
query_expression_body:
query_primary
| query_expression_body UNION_SYM union_option query_primary
| ...
;
query_primary:
query_specification
| table_value_constructor
| explicit_table
;
query_specification:
...
| SELECT_SYM /*select關鍵字*/
select_options /*distinct等選項*/
select_item_list /*select項列表*/
opt_from_clause /*可選:from子句*/
opt_where_clause /*可選:where子句*/
opt_group_clause /*可選:group子句*/
opt_having_clause /*可選:having子句*/
opt_window_clause /*可選:window子句*/
;
...
其中,query_expression就是一個最基礎的select語句,它包含了SELECT關鍵字、字段列表、from子句、where子句等。
你可以看一下select_options、opt_from_clause和其他幾個以opt開頭的規則,它們都是SQL語句的組成部分。opt是可選的意思,也就是它的產生式可能產生ε。
opt_from_clause:
/* Empty. */
| from_clause
;
另外,你還可以看一下表達式部分的語法。在MySQL編譯器當中,對於二元運算,你可以大膽地寫成左遞歸的文法。因為它的語法分析的算法用的是LALR,這個算法能夠自動處理左遞歸。
一般研究表達式的時候,我們總是會關注編譯器是如何處理結合性和優先級的。那么,bison是如何處理的呢?
原來,bison里面有專門的規則,可以規定運算符的優先級和結合性。在sql_yacc.yy中,你會看到如下所示的規則片段:
你可以看一下bit_expr的產生式,它其實完全把加減乘數等運算符並列就行了。
bit_expr :
...
| bit_expr '+' bit_expr %prec '+'
| bit_expr '-' bit_expr %prec '-'
| bit_expr '*' bit_expr %prec '*'
| bit_expr '/' bit_expr %prec '/'
...
| simple_expr
如果你只是用到加減乘除的運算,那就可以不用在產生式的后面加%prec這個標記。但由於加減乘除這幾個還可以用在其他地方,比如“-a”可以用來表示把a取負值;減號可以用在一元表達式當中,這會比用在二元表達式中有更高的優先級。也就是說,為了區分同一個Token在不同上下文中的優先級,我們可以用%prec,來說明該優先級是上下文依賴的。
好了,在了解了詞法分析器和語法分析器以后,我們接着來跟蹤一下MySQL的執行,看看編譯器所生成的解析樹和AST是什么樣子的。
在sql_class.cc的sql_parser()方法中,編譯器執行完解析程序之后,會返回解析樹的根節點root,在GDB中通過p命令,可以逐步打印出整個解析樹。你會看到,它的根節點是一個PT_select_stmt指針(見圖3)。
解析樹的節點是在語法規則中規定的,這是一些C++的代碼,它們會嵌入到語法規則中去。
下面展示的這個語法規則就表明,編譯器在解析完query_expression規則以后,要創建一個PT_query_expression的節點,其構造函數的參數分別是三個子規則所形成的節點。對於query_expression_body和query_primary這兩個規則,它們會直接把子節點返回,因為它們都只有一個子節點。這樣就會簡化解析樹,讓它更像一棵AST。關於AST和解析樹(也叫CST)的區別,我在解析Python的編譯器中講過了,你可以回憶一下。
query_expression:
query_expression_body
opt_order_clause
opt_limit_clause
{
$$ = NEW_PTN PT_query_expression($1, $2, $3); /*創建節點*/
}
| ...
query_expression_body:
query_primary
{
$$ = $1; /*直接返回query_primary的節點*/
}
| ...
query_primary:
query_specification
{
$$= $1; /*直接返回query_specification的節點*/
}
| ...
最后,對於“select first_name, last_name from employees”這樣一個簡單的SQL語句,它所形成的解析樹如下:
而對於“select 2 + 3”這樣一個做表達式計算的SQL語句,所形成的解析樹如下。你會看到,它跟普通的高級語言的表達式的AST很相似:
圖4中的PT_query_expression等類,就是解析樹的節點,它們都是Parse_tree_node的子類(PT是Parse Tree的縮寫)。這些類主要定義在sql/parse_tree_nodes.h和parse_tree_items.h文件中。
其中,Item代表了與“值”有關的節點,它的子類能夠用於表示字段、常量和表達式等。你可以通過Item的val_int()、val_str()等方法獲取它的值。
由於SQL是一個個單獨的語句,所以select、insert、update等語句,它們都各自有不同的根節點,都是Parse_tree_root的子類。
好了,現在你就已經了解了SQL的解析過程和它所生成的AST了。前面我說過,MySQL采用的是LALR算法,因此我們可以借助MySQL編譯器,來加深一下對LR算法家族的理解。
重溫LR算法
你在閱讀yacc.yy文件的時候,在注釋里,你會發現如何跟蹤語法分析器的執行過程的一些信息。
你可以用下面的命令,帶上“-debug”參數,來啟動MySQL服務器:
mysqld --debug="d,parser_debug"
然后,你可以通過客戶端執行一個簡單的SQL語句:“select 2+3*5”。在終端,會輸出語法分析的過程。這里我截取了一部分界面,通過這些輸出信息,你能看出LR算法執行過程中的移進、規約過程,以及工作區內和預讀的信息。
我來給你簡單地復現一下這個解析過程。
第1步,編譯器處於狀態0,並且預讀了一個select關鍵字。你已經知道,LR算法是基於一個DFA的。在這里的輸出信息中,你能看到某些狀態的編號達到了一千多,所以這個DFA還是比較大的。
第2步,把select關鍵字移進工作區,並進入狀態42。這個時候,編譯器已經知道后面跟着的一定是一個select語句了,也就是會使用下面的語法規則:
query_specification:
...
| SELECT_SYM /*select關鍵字*/
select_options /*distinct等選項*/
select_item_list /*select項列表*/
opt_from_clause /*可選:from子句*/
opt_where_clause /*可選:where子句*/
opt_group_clause /*可選:group子句*/
opt_having_clause /*可選:having子句*/
opt_window_clause /*可選:window子句*/
;
為了給你一個直觀的印象,這里我畫了DFA的局部示意圖(做了一定的簡化),如下所示。你可以看到,在狀態42,點符號位於“select”關鍵字之后、select_options之前。select_options代表了“distinct”這樣的一些關鍵字,但也有可能為空。
第3步,因為預讀到的Token是一個數字(NUM),這說明select_options產生式一定生成了一個ε,因為NUM是在select_options的Follow集合中。
這就是LALR算法的特點,它不僅會依據預讀的信息來做判斷,還要依據Follow集合中的元素。所以編譯器做了一個規約,也就是讓select_options為空。
也就是,編譯器依據“select_options->ε”做了一次規約,並進入了新的狀態920。注意,狀態42和920從DFA的角度來看,它們是同一個大狀態。而DFA中包含了多個小狀態,分別代表了不同的規約情況。
你還需要注意,這個時候,老的狀態都被壓到了棧里,所以棧里會有0和42兩個狀態。棧里的這些狀態,其實記錄了推導的過程,讓我們知道下一步要怎樣繼續去做推導。
第4步,移進NUM。這時又進入一個新狀態720。
而舊的狀態也會入棧,記錄下推導路徑:
第5~8步,依次依據NUM_literal->NUM、literal->NUM_literal、simple_expr->literal、bit_expr->simple_expr這四條產生式做規約。這時候,編譯器預讀的Token是+號,所以你會看到,圖中的紅點停在+號前。
第9~10步,移進+號和NUM。這個時候,狀態又重新回到了720。這跟第4步進入的狀態是一樣的。
而棧里的目前有5個狀態,記錄了完整的推導路徑。
到這里,其實你就已經了解了LR算法做移進和規約的思路了。不過你還可以繼續往下研究。由於棧里保留了完整的推導路徑,因此MySQL編譯器最后會依次規約回來,把棧里的元素清空,並且形成一棵完整的AST。
課程小結
這一講,我帶你初步探索了MySQL編譯SQL語句的過程。你需要記住幾個關鍵點:
● 掌握如何用GDB來跟蹤MySQL的執行的方法。你要特別注意的是,我給你梳理的那些關鍵的程序入口,它是你理解MySQL運行過程的地圖。
● SQL語言是面向關系數據庫的一種DSL,它是聲明式的,並采用了領域特定的模型和術語,可以為你設計自己的DSL提供啟發。
● MySQL的語法分析器是采用bison工具生成的。這至少說明,語法分析器生成工具是很有用的,連正式的數據庫系統都在使用它,所以你也可以大膽地使用它,來提高你的工作效率。我在最后的參考資料中給出了bison的手冊,希望你能自己閱讀一下,做一些簡單的練習,掌握bison這個工具。
● 最后,你一定要知道LR算法的運行原理,知其所以然,這也會更加有助於你理解和用好工具。
我依然把本講的內容給你整理成了一張知識地圖,供你參考和復習回顧:
參考資料
- MySQL的內行手冊(MySQL Internals Manual)能提供一些重要的信息。但我發現文檔內容經常跟源代碼的版本不同步,比如介紹源代碼的目錄結構的信息就過時了,你要注意這點。