TiDB 原始碼閱讀系列文章(五)TiDB SQL Parser 的實現

PingCAP發表於2019-03-03

本文為 TiDB 原始碼閱讀系列文章的第五篇,主要對 SQL Parser 功能的實現進行了講解,內容來自社群小夥伴——馬震(GitHub ID:mz1999 )的投稿。

TiDB 原始碼閱讀系列文章的撰寫初衷,就是希望能與資料庫研究者、愛好者進行深入交流,我們欣喜於如此短的時間內就收到了來自社群的反饋。後續,也希望有更多小夥伴加入到與 TiDB 『坦誠相見』的陣列中來。

PingCAP 釋出了 TiDB 的原始碼閱讀系列文章,讓我們可以比較系統的去學習瞭解TiDB的內部實現。最近的一篇《SQL 的一生》,從整體上講解了一條 SQL 語句的處理流程,從網路上接收資料,MySQL 協議解析和轉換,SQL 語法解析,查詢計劃的制定和優化,查詢計劃執行,到最後返回結果。

TiDB 原始碼閱讀系列文章(五)TiDB SQL Parser 的實現

其中,SQL Parser 的功能是把 SQL 語句按照 SQL 語法規則進行解析,將文字轉換成抽象語法樹(AST),這部分功能需要些背景知識才能比較容易理解,我嘗試做下相關知識的介紹,希望能對讀懂這部分程式碼有點幫助。

TiDB 是使用 goyacc 根據預定義的 SQL 語法規則檔案 parser.y 生成 SQL 語法解析器。我們可以在 TiDB 的 Makefile 檔案中看到這個過程,先 build goyacc 工具,然後使用 goyacc 根據 parser.y 生成解析器 parser.go

goyacc:
	$(GOBUILD) -o bin/goyacc parser/goyacc/main.go

parser: goyacc
	bin/goyacc -o /dev/null parser/parser.y
	bin/goyacc -o parser/parser.go parser/parser.y 2>&1 ...
複製程式碼

goyaccyacc 的 Golang 版,所以要想看懂語法規則定義檔案 parser.y,瞭解解析器是如何工作的,先要對 Lex & Yacc 有些瞭解。

Lex & Yacc 介紹

Lex & Yacc 是用來生成詞法分析器和語法分析器的工具,它們的出現簡化了編譯器的編寫。Lex & Yacc 分別是由貝爾實驗室的 Mike LeskStephen C. Johnson 在 1975 年釋出。對於 Java 程式設計師來說,更熟悉的是 ANTLRANTLR 4 提供了 Listener+Visitor 組合介面, 不需要在語法定義中嵌入actions,使應用程式碼和語法定義解耦。Spark 的 SQL 解析就是使用了 ANTLRLex & Yacc 相對顯得有些古老,實現的不是那麼優雅,不過我們也不需要非常深入的學習,只要能看懂語法定義檔案,瞭解生成的解析器是如何工作的就夠了。我們可以從一個簡單的例子開始:

TiDB 原始碼閱讀系列文章(五)TiDB SQL Parser 的實現

上圖描述了使用 Lex & Yacc 構建編譯器的流程。Lex 根據使用者定義的 patterns 生成詞法分析器。詞法分析器讀取原始碼,根據 patterns 將原始碼轉換成 tokens 輸出。Yacc 根據使用者定義的語法規則生成語法分析器。語法分析器以詞法分析器輸出的 tokens 作為輸入,根據語法規則建立出語法樹。最後對語法樹遍歷生成輸出結果,結果可以是產生機器程式碼,或者是邊遍歷 AST 邊解釋執行。

從上面的流程可以看出,使用者需要分別為 Lex 提供 patterns 的定義,為 Yacc 提供語法規則檔案,Lex & Yacc 根據使用者提供的輸入檔案,生成符合他們需求的詞法分析器和語法分析器。這兩種配置都是文字檔案,並且結構相同:

... definitions ...
%%
... rules ...
%%
... subroutines ...
複製程式碼

檔案內容由 %% 分割成三部分,我們重點關注中間規則定義部分。對於上面的例子,Lex 的輸入檔案如下:

...
%%
/* 變數 */
[a-z]    {
            yylval = *yytext - 'a';
            return VARIABLE;
         }   
/* 整數 */
[0-9]+   {
            yylval = atoi(yytext);
            return INTEGER;
         }
/* 操作符 */
[-+()=/*\n] { return *yytext; }
/* 跳過空格 */
[ \t]    ;
/* 其他格式報錯 */
.        yyerror("invalid character");
%%
...
複製程式碼

上面只列出了規則定義部分,可以看出該規則使用正規表示式定義了變數、整數和操作符等幾種 token。例如整數 token 的定義如下:

[0-9]+  {
            yylval = atoi(yytext);
            return INTEGER; 
        }
複製程式碼

當輸入字串匹配這個正規表示式,大括號內的動作會被執行:將整數值儲存在變數 yylval 中,並返回 token 型別 INTEGERYacc

再來看看 Yacc 語法規則定義檔案:

%token INTEGER VARIABLE
%left '+' '-'
%left '*' '/'
...
%%

program:
        program statement '\n' 
        |
        ;

statement:
        expr                    { printf("%d\n", $1); }
        | VARIABLE '=' expr     { sym[$1] = $3; }
        ;
        
expr:
        INTEGER
        | VARIABLE              { $$ = sym[$1]; }
        | expr '+' expr         { $$ = $1 + $3; }
        | expr '-' expr         { $$ = $1 - $3; }
        | expr '*' expr         { $$ = $1 * $3; }
        | expr '/' expr         { $$ = $1 / $3; }
        | '(' expr ')'          { $$ = $2; }
        ;

%%
...
複製程式碼

第一部分定義了 token 型別和運算子的結合性。四種運算子都是左結合,同一行的運算子優先順序相同,不同行的運算子,後定義的行具有更高的優先順序。

語法規則使用了 BNF 定義。BNF 可以用來表達上下文無關(context-free)語言,大部分的現代程式語言都可以使用 BNF 表示。上面的規則定義了三個產生式產生式冒號左邊的項(例如 statement)被稱為非終結符INTEGERVARIABLE 被稱為終結符,它們是由 Lex 返回的 token終結符只能出現在產生式的右側。可以使用產生式定義的語法生成表示式:

expr -> expr * expr
     -> expr * INTEGER
     -> expr + expr * INTEGER
     -> expr + INTEGER * INTEGER
     -> INTEGER + INTEGER * INTEGER
複製程式碼

解析表示式是生成表示式的逆向操作,我們需要歸約表示式到一個非終結符Yacc 生成的語法分析器使用自底向上的歸約(shift-reduce)方式進行語法解析,同時使用堆疊儲存中間狀態。還是看例子,表示式 x + y * z 的解析過程:

1    . x + y * z
2    x . + y * z
3    expr . + y * z
4    expr + . y * z
5    expr + y . * z
6    expr + expr . * z
7    expr + expr * . z
8    expr + expr * z .
9    expr + expr * expr .
10   expr + expr .
11   expr .
12   statement .
13   program  .
複製程式碼

點(.)表示當前的讀取位置,隨著 . 從左向右移動,我們將讀取的 token 壓入堆疊,當發現堆疊中的內容匹配了某個產生式的右側,則將匹配的項從堆疊中彈出,將該產生式左側的非終結符壓入堆疊。這個過程持續進行,直到讀取完所有的 tokens,並且只有啟始非終結符(本例為 program)保留在堆疊中。

產生式右側的大括號中定義了該規則關聯的動作,例如:

expr:  expr '*' expr         { $$ = $1 * $3; }
複製程式碼

我們將堆疊中匹配該產生式右側的項替換為產生式左側的非終結符,本例中我們彈出 expr '*' expr,然後把 expr 壓回堆疊。 我們可以使用 $position 的形式訪問堆疊中的項,$1 引用的是第一項,$2 引用的是第二項,以此類推。$$ 代表的是歸約操作執行後的堆疊頂。本例的動作是將三項從堆疊中彈出,兩個表示式相加,結果再壓回堆疊頂。

上面例子中語法規則關聯的動作,在完成語法解析的同時,也完成了表示式求值。一般我們希望語法解析的結果是一棵抽象語法樹(AST),可以這麼定義語法規則關聯的動作:

...
%%
...
expr:
    INTEGER             { $$ = con($1); }
    | VARIABLE          { $$ = id($1); }
    | expr '+' expr     { $$ = opr('+', 2, $1, $3); }
    | expr '-' expr     { $$ = opr('-', 2, $1, $3); }
    | expr '*' expr     { $$ = opr('*', 2, $1, $3); } 
    | expr '/' expr     { $$ = opr('/', 2, $1, $3); }
    | '(' expr ')'      { $$ = $2; }
    ; 
%%
nodeType *con(int value) {
    ...
}
nodeType *id(int i) {
    ...
}
nodeType *opr(int oper, int nops, ...) {
    ...
}    
複製程式碼

上面是一個語法規則定義的片段,我們可以看到,每個規則關聯的動作不再是求值,而是呼叫相應的函式,該函式會返回抽象語法樹的節點型別 nodeType,然後將這個節點壓回堆疊,解析完成時,我們就得到了一顆由 nodeType 構成的抽象語法樹。對這個語法樹進行遍歷訪問,可以生成機器程式碼,也可以解釋執行。

至此,我們大致瞭解了 Lex & Yacc的原理。其實還有非常多的細節,例如如何消除語法的歧義,但我們的目的是讀懂 TiDB 的程式碼,掌握這些概念已經夠用了。

goyacc 簡介

goyacc 是 golang 版的 Yacc。和 Yacc 的功能一樣,goyacc 根據輸入的語法規則檔案,生成該語法規則的 go 語言版解析器。goyacc 生成的解析器 yyParse 要求詞法分析器符合下面的介面:

type yyLexer interface {
	Lex(lval *yySymType) int
	Error(e string)
}
複製程式碼

或者

type yyLexerEx interface {
	yyLexer
	// Hook for recording a reduction.
	Reduced(rule, state int, lval *yySymType) (stop bool) // Client should copy *lval.
}
複製程式碼

TiDB 沒有使用類似 Lex 的工具生成詞法分析器,而是純手工打造,詞法分析器對應的程式碼是 parser/lexer.go, 它實現了 goyacc 要求的介面:

...
// Scanner implements the yyLexer interface.
type Scanner struct {
	r   reader
	buf bytes.Buffer

	errs         []error
	stmtStartPos int

	// For scanning such kind of comment: /*! MySQL-specific code */ or /*+ optimizer hint */
	specialComment specialCommentScanner

	sqlMode mysql.SQLMode
}
// Lex returns a token and store the token value in v.
// Scanner satisfies yyLexer interface.
// 0 and invalid are special token id this function would return:
// return 0 tells parser that scanner meets EOF,
// return invalid tells parser that scanner meets illegal character.
func (s *Scanner) Lex(v *yySymType) int {
	tok, pos, lit := s.scan()
	v.offset = pos.Offset
	v.ident = lit
	...
}
// Errors returns the errors during a scan.
func (s *Scanner) Errors() []error {
	return s.errs
}
複製程式碼

另外 lexer 使用了 字典樹 技術進行 token 識別,具體的實現程式碼在 parser/misc.go

TiDB SQL Parser 的實現

終於到了正題。有了上面的背景知識,對 TiDB 的 SQL Parser 模組會相對容易理解一些。TiDB 的詞法解析使用的 手寫的解析器(這是出於效能考慮),語法解析採用 goyacc。先看 SQL 語法規則檔案 parser.ygoyacc 就是根據這個檔案生成SQL語法解析器的。

parser.y 有 6500 多行,第一次開啟可能會被嚇到,其實這個檔案仍然符合我們上面介紹過的結構:

... definitions ...
%%
... rules ...
%%
... subroutines ...
複製程式碼

parser.y 第三部分 subroutines 是空白沒有內容的, 所以我們只需要關注第一部分 definitions 和第二部分 rules

第一部分主要是定義 token 的型別、優先順序、結合性等。注意 union 這個聯合體結構體:

%union {
	offset int // offset
	item interface{}
	ident string
	expr ast.ExprNode
	statement ast.StmtNode
}
複製程式碼

該聯合體結構體定義了在語法解析過程中被壓入堆疊的的屬性和型別。

壓入堆疊的可能是 終結符,也就是 token,它的型別可以是itemident

這個也可能是 非終結符,即產生式的左側,它的型別可以是 exprstatementitemident

goyacc 根據這個 union 在解析器裡生成對應的 struct 是:

type yySymType struct {
	yys       int
	offset    int // offset
	item      interface{}
	ident     string
	expr      ast.ExprNode
	statement ast.StmtNode
}
複製程式碼

在語法解析過程中,非終結符 會被構造成抽象語法樹(AST)的節點 ast.ExprNodeast.StmtNode。抽象語法樹相關的資料結構都定義在 ast 包中,它們大都實現了 ast.Node 介面:

// Node is the basic element of the AST.
// Interfaces embed Node should have 'Node' name suffix.
type Node interface {
	Accept(v Visitor) (node Node, ok bool)
	Text() string
	SetText(text string)
}
複製程式碼

這個介面有一個 Accept 方法,接受 Visitor 引數,後續對 AST 的處理,主要依賴這個 Accept 方法,以 Visitor 模式遍歷所有的節點以及對 AST 做結構轉換。

// Visitor visits a Node.
type Visitor interface {
	Enter(n Node) (node Node, skipChildren bool)
	Leave(n Node) (node Node, ok bool)
}
複製程式碼

例如 plan.preprocess 是對 AST 做預處理,包括合法性檢查以及名字繫結。

union 後面是對 token非終結符 按照型別分別定義:

/* 這部分的 token 是 ident 型別 */
%token	<ident>
    ...
	add			"ADD"
	all 			"ALL"
	alter			"ALTER"
	analyze			"ANALYZE"
	and			"AND"
	as			"AS"
	asc			"ASC"
	between			"BETWEEN"
	bigIntType		"BIGINT"
    ...

/* 這部分的 token 是 item 型別 */   
%token	<item>
    /*yy:token "1.%d"   */	floatLit        "floating-point literal"
	/*yy:token "1.%d"   */	decLit          "decimal literal"
	/*yy:token "%d"     */	intLit          "integer literal"
	/*yy:token "%x"     */	hexLit          "hexadecimal literal"
	/*yy:token "%b"     */	bitLit          "bit literal"

	andnot		"&^"
	assignmentEq	":="
	eq		"="
	ge		">="
    ...

/* 非終結符按照型別分別定義 */
%type	<expr>
    Expression			"expression"
	BoolPri				"boolean primary expression"
	ExprOrDefault			"expression or default"
	PredicateExpr			"Predicate expression factor"
	SetExpr				"Set variable statement value's expression"
    ...

%type	<statement>
	AdminStmt			"Check table statement or show ddl statement"
	AlterTableStmt			"Alter table statement"
	AlterUserStmt			"Alter user statement"
	AnalyzeTableStmt		"Analyze table statement"
	BeginTransactionStmt		"BEGIN TRANSACTION statement"
	BinlogStmt			"Binlog base64 statement"
    ...
	
%type   <item>
	AlterTableOptionListOpt		"alter table option list opt"
	AlterTableSpec			"Alter table specification"
	AlterTableSpecList		"Alter table specification list"
	AnyOrAll			"Any or All for subquery"
	Assignment			"assignment"
    ...

%type	<ident>
	KeyOrIndex		"{KEY|INDEX}"
	ColumnKeywordOpt	"Column keyword or empty"
	PrimaryOpt		"Optional primary keyword"
	NowSym			"CURRENT_TIMESTAMP/LOCALTIME/LOCALTIMESTAMP"
	NowSymFunc		"CURRENT_TIMESTAMP/LOCALTIME/LOCALTIMESTAMP/NOW"
    ...
複製程式碼

第一部分的最後是對優先順序和結合性的定義:

...
%precedence sqlCache sqlNoCache
%precedence lowerThanIntervalKeyword
%precedence interval
%precedence lowerThanStringLitToken
%precedence stringLit
...
%right   assignmentEq
%left 	pipes or pipesAsOr
%left 	xor
%left 	andand and
%left 	between
...
複製程式碼

parser.y 檔案的第二部分是 SQL 語法的產生式和每個規則對應的 aciton 。SQL語法非常複雜,parser.y 的大部分內容都是產生式的定義。

SQL 語法可以參照 MySQL 參考手冊的 SQL Statement Syntax 部分,例如 SELECT 語法的定義如下:

SELECT
    [ALL | DISTINCT | DISTINCTROW ]
      [HIGH_PRIORITY]
      [STRAIGHT_JOIN]
      [SQL_SMALL_RESULT] [SQL_BIG_RESULT] [SQL_BUFFER_RESULT]
      [SQL_CACHE | SQL_NO_CACHE] [SQL_CALC_FOUND_ROWS]
    select_expr [, select_expr ...]
    [FROM table_references
      [PARTITION partition_list]
    [WHERE where_condition]
    [GROUP BY {col_name | expr | position}
      [ASC | DESC], ... [WITH ROLLUP]]
    [HAVING where_condition]
    [ORDER BY {col_name | expr | position}
      [ASC | DESC], ...]
    [LIMIT {[offset,] row_count | row_count OFFSET offset}]
    [PROCEDURE procedure_name(argument_list)]
    [INTO OUTFILE 'file_name'
        [CHARACTER SET charset_name]
        export_options
      | INTO DUMPFILE 'file_name'
      | INTO var_name [, var_name]]
    [FOR UPDATE | LOCK IN SHARE MODE]]
複製程式碼

我們可以在 parser.y 中找到 SELECT 語句的產生式:

SelectStmt:
	"SELECT" SelectStmtOpts SelectStmtFieldList OrderByOptional SelectStmtLimit SelectLockOpt
    { ... }
|   "SELECT" SelectStmtOpts SelectStmtFieldList FromDual WhereClauseOptional SelectStmtLimit SelectLockOpt
    { ... }  
|   "SELECT" SelectStmtOpts SelectStmtFieldList "FROM"
	TableRefsClause WhereClauseOptional SelectStmtGroup HavingClause OrderByOptional
	SelectStmtLimit SelectLockOpt
    { ... } 
複製程式碼

產生式 SelectStmtSELECT 語法是對應的。

我省略了大括號中的 action ,這部分程式碼會構建出 ASTast.SelectStmt 節點:

type SelectStmt struct {
	dmlNode
	resultSetNode

	// SelectStmtOpts wraps around select hints and switches.
	*SelectStmtOpts
	// Distinct represents whether the select has distinct option.
	Distinct bool
	// From is the from clause of the query.
	From *TableRefsClause
	// Where is the where clause in select statement.
	Where ExprNode
	// Fields is the select expression list.
	Fields *FieldList
	// GroupBy is the group by expression list.
	GroupBy *GroupByClause
	// Having is the having condition.
	Having *HavingClause
	// OrderBy is the ordering expression list.
	OrderBy *OrderByClause
	// Limit is the limit clause.
	Limit *Limit
	// LockTp is the lock type
	LockTp SelectLockType
	// TableHints represents the level Optimizer Hint
	TableHints []*TableOptimizerHint
}
複製程式碼

可以看出,ast.SelectStmt 結構體內包含的內容和 SELECT 語法也是一一對應的。

其他的產生式也都是根據對應的 SQL 語法來編寫的。從 parser.y 的註釋看到,這個檔案最初是用 工具BNF 轉化生成的,從頭手寫這個規則檔案,工作量會非常大。

完成了語法規則檔案 parser.y 的定義,就可以使用 goyacc 生成語法解析器:

bin/goyacc -o parser/parser.go parser/parser.y 2>&1
複製程式碼

TiDB 對 lexerparser.go 進行了封裝,對外提供 parser.yy_parser 進行 SQL 語句的解析:

// Parse parses a query string to raw ast.StmtNode.
func (parser *Parser) Parse(sql, charset, collation string) ([]ast.StmtNode, error) {
    ...
}
複製程式碼

最後,我寫了一個簡單的例子,使用 TiDB 的 SQL Parser 進行 SQL 語法解析,構建出 AST,然後利用 visitor 遍歷 AST

package main

import (
	"fmt"
	"github.com/pingcap/tidb/parser"
	"github.com/pingcap/tidb/ast"
)

type visitor struct{}

func (v *visitor) Enter(in ast.Node) (out ast.Node, skipChildren bool) {
	fmt.Printf("%T\n", in)
	return in, false
}

func (v *visitor) Leave(in ast.Node) (out ast.Node, ok bool) {
	return in, true
}

func main() {

	sql := "SELECT /*+ TIDB_SMJ(employees) */ emp_no, first_name, last_name " +
		"FROM employees USE INDEX (last_name) " +
		"where last_name='Aamodt' and gender='F' and birth_date > '1960-01-01'"

	sqlParser := parser.New()
	stmtNodes, err := sqlParser.Parse(sql, "", "")
	if err != nil {
		fmt.Printf("parse error:\n%v\n%s", err, sql)
		return
	}
	for _, stmtNode := range stmtNodes {
		v := visitor{}
		stmtNode.Accept(&v)
	}
}
複製程式碼

我實現的 visitor 什麼也沒幹,只是輸出了節點的型別。 這段程式碼的執行結果如下,依次輸出遍歷過程中遇到的節點型別:

*ast.SelectStmt
*ast.TableOptimizerHint
*ast.TableRefsClause
*ast.Join
*ast.TableSource
*ast.TableName
*ast.BinaryOperationExpr
*ast.BinaryOperationExpr
*ast.BinaryOperationExpr
*ast.ColumnNameExpr
*ast.ColumnName
*ast.ValueExpr
*ast.BinaryOperationExpr
*ast.ColumnNameExpr
*ast.ColumnName
*ast.ValueExpr
*ast.BinaryOperationExpr
*ast.ColumnNameExpr
*ast.ColumnName
*ast.ValueExpr
*ast.FieldList
*ast.SelectField
*ast.ColumnNameExpr
*ast.ColumnName
*ast.SelectField
*ast.ColumnNameExpr
*ast.ColumnName
*ast.SelectField
*ast.ColumnNameExpr
*ast.ColumnName
複製程式碼

瞭解了 TiDB SQL Parser 的實現,我們就有可能實現 TiDB 當前不支援的語法,例如新增內建函式,也為我們學習查詢計劃以及優化打下了基礎。希望這篇文章對你能有所幫助。

作者介紹:馬震,金蝶天燕架構師,曾負責中介軟體、大資料平臺的研發,今年轉向了 NewSQL 領域,關注 OLTP/AP 融合,目前在推動金蝶下一代 ERP 引入 TiDB 作為資料庫儲存服務。

相關文章