TiDB 源碼閱讀系列文章(三)SQL 的一生
本文作者: @申礫
概述
本文為 TiDB 源碼閱讀系列文章的第三篇。上一篇文章講解了 TiDB 項目的結構以及三個核心部分,本篇文章從 SQL 處理流程出發,介紹哪裡是入口,對 SQL 需要做哪些操作,知道一個 SQL 是從哪裡進來的,在哪裡處理,並從哪裡返回。
SQL 有很多種,比如讀、寫、修改、刪除以及管理類的 SQL,每種 SQL 有自己的執行邏輯,不過大體上的流程是類似的,都在一個統一的框架下運轉。
框架
我們先從整體上看一下,一條語句需要經過哪些方面的工作。如果大家還記得上一篇文章所說的三個核心部分,可以想到首先要經過協議解析和轉換,拿到語句內容,然後經過 SQL 核心層邏輯處理,生成查詢計劃,最後去存儲引擎中獲取數據,進行計算,返回結果。這個就是一個粗略的處理框架,本篇文章會把這個框架不斷細化。
對於第一部分,協議解析和轉換,所有的邏輯都在 server 這個包中,主要邏輯分為兩塊:一是連接的建立和管理,每個連接對應於一個 Session;二是在單個連接上的處理邏輯。第一點本文暫時不涉及,感興趣的同學可以翻翻代碼,看看連接如何建立、如何握手、如何銷毀,後面也會有專門的文章講解。對於 SQL 的執行過程,更重要的是第二點,也就是已經建立了連接,在這個連接上的操作,本文會詳細講解這一點。
對於第二部分,SQL 層的處理是整個 TiDB 最複雜的部分。這部分為什麼複雜?原因有三點:
1. SQL 語言本身是一門複雜的語言,語句的種類多、數據類型多、操作符多、語法組合多,這些『多』經過排列組合會變成『很多』『非常多』,所以需要寫大量的代碼來處理。
2. SQL 是一門表意的語言,只是說『要什麼數據』,而不說『如何拿數據』,所以需要一些複雜的邏輯選擇『如何拿數據』,也就是選擇一個好的查詢計劃。
3. 底層是一個分散式存儲引擎,會面臨很多單機存儲引擎不會遇到的問題,比如做查詢計劃的時候要考慮到下層的數據是分片的、網路不通了如何處理等情況,所以需要一些複雜的邏輯處理這些情況,並且需要一個很好的機制將這些處理邏輯封裝起來。這些複雜性是看懂源碼比較大的障礙,所以本篇文章會盡量排除這些干擾,給大家講解核心的邏輯是什麼。
這一層有幾個核心概念,掌握了這幾個也就掌握了這一層的框架,請大家關注下面這幾個介面:
- Session
- RecordSet
- Plan
- LogicalPlan
- PhysicalPlan
- Executor
下面的詳細內容中,會講解這些介面,用這些介面理清楚整個邏輯。
對於第三部分可以認為兩塊,第一塊是 KV 介面層,主要作用是將請求路由到正確的的 KV Server,接收返回消息傳給 SQL 層,並在此過程中處理各種異常邏輯;第二塊是 KV Server 的具體實現,由於 TiKV 比較複雜,我們可以先看 Mock-TiKV 的實現,這裡有所有的 SQL 分散式計算相關的邏輯。 接下來的幾節,會對上面的三塊詳細展開描述。
協議層入口
當和客戶端的連接建立好之後,TiDB 中會有一個 Goroutine 監聽埠,等待從客戶端發來的包,並對發來的包做處理。這段邏輯在 server/conn.go 中,可以認為是 TiDB 的入口,本節介紹一下這段邏輯。 首先看 clientConn.Run(),這裡會在一個循環中,不斷的讀取網路包:
445: data, err := cc.readPacket()
然後調用 dispatch() 方法處理收到的請求:
465: if err = cc.dispatch(data); err != nil {
接下來進入 clientConn.dispatch()方法:
func (cc *clientConn) dispatch(data []byte) error {
這裡要處理的包是原始 byte 數組,裡面的內容讀者可以參考 MySQL 協議,第一個 byte 即為 Command 的類型:
580: cmd := data[0]
然後根據 Command 的類型,調用對應的處理函數,最常用的 Command 是 COM_QUERY,對於大多數 SQL 語句,只要不是用 Prepared 方式,都是 COM_QUERY,本文也只會介紹這個 Command,其他的 Command 請讀者對照 MySQL 文檔看代碼。 對於 Command Query,從客戶端發送來的主要是 SQL 文本,處理函數是 handleQuery():
func (cc *clientConn) handleQuery(goCtx goctx.Context, sql string) (err error) {
這個函數會調用具體的執行邏輯:
850: rs, err := cc.ctx.Execute(goCtx, sql)
這個 Execute 方法的實現在 server/driver_tidb.go 中,
func (tc *TiDBContext) Execute(goCtx goctx.Context, sql string) (rs []ResultSet, err error) { rsList, err := tc.session.Execute(goCtx, sql)
最重要的就是調用 tc.session.Execute,這個 session.Execute 的實現在 session.go 中,自此會進入 SQL 核心層,詳細的實現會在後面的章節中描述。
經過一系列處理,拿到 SQL 語句的結果後會調用 writeResultset 方法把結果寫回客戶端:
857: err = cc.writeResultset(goCtx, rs[0], false, false)
協議層出口
出口比較簡單,就是上面提到的 writeResultset 方法,按照 MySQL 協議的要求,將結果(包括 Field 列表、每行數據)寫回客戶端。讀者可以參考 MySQL 協議中的 COM_QUERY Response 理解這段代碼。
接下的幾節我們進入核心流程,看看一條文本的 SQL 是如何處理的。我會先介紹所有的流程,然後用一個圖把所有的流程串起來。
Session
Session 中最重要的函數是 Execute,這裡會調用下面所述的各種模塊,完成語句執行。注意這裡在執行的過程中,會考慮 Session 環境變數,比如是否 AutoCommit
,時區是什麼。
Lexer & Yacc
這兩個組件共同構成了 Parser 模塊,調用 Parser,可以將文本解析成結構化數據,也就是抽象語法樹 (AST):
session.go 699: return s.parser.Parse(sql, charset, collation)
在解析過程中,會先用 lexer 不斷地將文本轉換成 token,交付給 Parser,Parser 是根據 yacc 語法生成,根據語法不斷的決定 Lexer 中發來的 token 序列可以匹配哪條語法規則,最終輸出結構化的節點。 例如對於這樣一條語句 SELECT * FROM t WHERE c > 1;
,可以匹配 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 }
其中,FROM t
會被解析為 FROM
欄位,WHERE c > 1
被解析為 Where
欄位,*
被解析為 Fields
欄位。所有的語句的結構夠都被抽象為一個 ast.StmtNode
,這個介面讀者可以自行看注釋,了解一下。這裡只提一點,大部分 ast 包中的數據結構,都實現了 ast.Node
介面,這個介面有一個 Accept
方法,後續對 AST 的處理,主要依賴這個 Accept 方法,以 Visitor 模式遍歷所有的節點以及對 AST 做結構轉換。
制定查詢計劃以及優化
拿到 AST 之後,就可以做各種驗證、變化、優化,這一系列動作的入口在這裡:
session.go 805: stmt, err := compiler.Compile(goCtx, stmtNode)
我們進入 Compile 函數,可以看到三個重要步驟:
plan.Prepprocess
: 做一些合法性檢查以及名字綁定;plan.Optimize
:制定查詢計劃,並優化,這個是最核心的步驟之一,後面的文章會重點介紹;- 構造
executor.ExecStmt
結構:這個 ExecStmt 結構持有查詢計劃,是後續執行的基礎,非常重要,特別是 Exec 這個方法。
生成執行器
在這個過程中,會將 plan 轉換成 executor,執行引擎即可通過 executor 執行之前定下的查詢計劃,具體的代碼見 ExecStmt.buildExecutor():
executor/adpter.go 227: e, err := a.buildExecutor(ctx)
生成執行器之後,被f="https://github.com/pingcap/tidb/blob/source-code/executor/adapter.go#L260">封裝在一個 recordSet 結構中:
return &recordSet{ executor: e, stmt: a, processinfo: pi, txnStartTS: ctx.Txn().StartTS(), }, nil
這個結構實現了 ast.RecordSet
介面,從字面上大家可以看出,這個介面代表了查詢結果集的抽象,我們看一下它的幾個方法:
// RecordSet is an abstract result set interface to help get data from Plan. type RecordSet interface { // Fields gets result fields. Fields() []*ResultField // Next returns the next row, nil row means there is no more to return. Next(ctx context.Context) (row types.Row, err error) // NextChunk reads records into chunk. NextChunk(ctx context.Context, chk *chunk.Chunk) error // NewChunk creates a new chunk with initial capacity. NewChunk() *chunk.Chunk // SupportChunk check if the RecordSet supports Chunk structure. SupportChunk() bool // Close closes the underlying iterator, call Next after Close will // restart the iteration. Close() error }
通過注釋大家可以看到這個介面的作用,簡單來說,可以調用 Fields() 方法獲得結果集每一列的類型,調用 Next/NextChunk() 可以獲取一行或者一批數據,調用 Close() 可以關閉結果集。
運行執行器
TiDB 的執行引擎是以 Volcano 模型運行,所有的物理 Executor 構成一個樹狀結構,每一層通過調用下一層的 Next/NextChunk() 方法獲取結果。 舉個例子,假設語句是 SELECT c1 FROM t WHERE c2 > 1;
,並且查詢計劃選擇的是全表掃描+過濾,那麼執行器樹會是下面這樣:
大家可以從圖中看到 Executor 之間的調用關係,以及數據的流動方式。那麼最上層的 Next 是在哪裡調用,也就是整個計算的起始點在哪裡,誰來驅動這個流程? 有兩個地方大家需要關注,這兩個地方分別處理兩類語句。 第一類語句是 Select 這種查詢語句,需要對客戶端返回結果,這類語句的執行器調用點在給客戶端返回數據的地方:
row, err = rs.Next(ctx)
這裡的 rs
即為一個 RecordSet
介面,對其不斷的調用 Next()
,拿到更多結果,返回給 MySQL Client。 第二類語句是 Insert 這種不需要返回數據的語句,只需要把語句執行完成即可。這類語句也是通過 Next
驅動執行,驅動點在href="https://github.com/pingcap/tidb/blob/source-code/executor/adapter.go#L251">構造 recordSet 結構之前:
// If the executor doesnt return any result to the client, we execute it without delay. if e.Schema().Len() == 0 { return a.handleNoDelayExecutor(goCtx, e, ctx, pi) } else if proj, ok := e.(*ProjectionExec); ok && proj.calculateNoDelay { // Currently this is only for the "DO" statement. Take "DO 1, @a=2;" as an example: // the Projection has two expressions and two columns in the schema, but we should // not return the result of the two expressions. return a.handleNoDelayExecutor(goCtx, e, ctx, pi) }
總結
上面描述了整個 SQL 層的執行框架,這裡用一幅圖來描述整個過程:
通過這篇文章,相信大家已經了解了 TiDB 中語句的執行框架,整個邏輯還是比較簡單,框架中具體的模塊的詳細解釋會在後續章節中給出。下一篇文章會用具體的語句為例,幫助大家理解本篇文章。
推薦閱讀:
※申礫:細說分散式資料庫的過去、現在與未來
※TiDB,為SQL注入分散式可擴展性
※TiDB 1.0 GA Release
※TiDB RC4 Release