本章涉及的內容是TiDB的計算層代碼,就是我們編譯完 TiDB 后在bin目錄下生成的 tidb-server 的可執行文件,它是用 go 實現的,里面對 TiPD 和 TiKV實現了Mock,可以單獨運行;
用explain語句可以看到一條sql在TiDB中生成的最終執行計划,例如:我們有一條關聯子查詢: select * from t1 where t1.a in (select t2.a from t2 where t2.b = t1.b);
tidb> explain select * from t1 where t1.a in (select t2.a from t2 where t2.b = t1.b); +--------------------------+-------+------+--------------------------------------------------------------------------------------------+ | id | count | task | operator info | +--------------------------+-------+------+--------------------------------------------------------------------------------------------+ | HashLeftJoin_9 | 4.79 | root | semi join, inner:TableReader_15, equal:[eq(test.t1.b, test.t2.b) eq(test.t1.a, test.t2.a)] | | ├─TableReader_12 | 5.99 | root | data:Selection_11 | | │ └─Selection_11 | 5.99 | cop | not(isnull(test.t1.a)), not(isnull(test.t1.b)) | | │ └─TableScan_10 | 6.00 | cop | table:t1, range:[-inf,+inf], keep order:false, stats:pseudo | | └─TableReader_15 | 5.99 | root | data:Selection_14 | | └─Selection_14 | 5.99 | cop | not(isnull(test.t2.a)), not(isnull(test.t2.b)) | | └─TableScan_13 | 6.00 | cop | table:t2, range:[-inf,+inf], keep order:false, stats:pseudo | +--------------------------+-------+------+--------------------------------------------------------------------------------------------+
sql算子差不多是這個樣子的:

上面是一個物理查詢計划樹,頂層算子是Join算子,t1表和t2表的數據關聯操作用的是Hash Join,因為只返回左表(Outer Plan)的數據,所以用了左連接(Left Join); Join條件是 t1.b = t2.b,t1.a = t2.a,其中內表數據取自 TableReader_15---就是t2表的數據,t2表作為 Inner Plan的數據;
下層左右兩邊的算子類似,都是TableReader接了Selection算子,Selection算子負責過濾掉空數據;底層算子是2個最基本的掃表算子(TableScan),分別掃 t1 和 t2 的數據,返回給上層算子;
TiDB代碼中,explain語句和select語句類似,都是下面這樣的處理邏輯:
clientConn.handleQuery---處理mysql客戶端來的請求
TiDBContext.Execute
session.execute
session.ParseSQL---解析SQL
Compiler.Compile---遍歷SQL語法樹,生成邏輯計划樹和物理計划樹
session.executeStatement
clientConn.writeResultset
clientConn.writeChunks
ResultSet.Next---Next函數驅動數據行的獲取
clientConn.writePacket---將數據寫回客戶端
explain語句結果生成的代碼在這里:
ExplainExec.Next
ExplainExec.generateExplainInfo
Explain.RenderResult
Explain.explainPlanInRowFormat---遍歷物理執行計划樹,格式化輸出
Explain.explainPlanInRowFormat 會從根節點開始遞歸的訪問PhysicalPlan和子樹,遍歷物理執行計划樹,生成explain的結果集;
explain顯示的信息是最終優化生成的執行計划;
我們接着來看看中間生成的執行計划是怎樣的:
sql的解析和生成邏輯計划樹的代碼在這里:
func Optimize(ctx sessionctx.Context, node ast.Node, is infoschema.InfoSchema) (plannercore.Plan, error) {
//根據sql語法樹(ast.Node)生成邏輯執行計划(LogicalPlan)
func (b *PlanBuilder) Build(node ast.Node) (Plan, error) {
func (b *PlanBuilder) buildExplainPlan(targetPlan Plan, format string, analyze bool, execStmt ast.StmtNode) (Plan, error) {
func (b *PlanBuilder) buildSelect(sel *ast.SelectStmt) (p LogicalPlan, err error) {
// DoOptimize optimizes a logical plan to a physical plan.
//將邏輯執行計划樹(LogicalPlan)轉為物理執行計划樹(PhysicalPlan)
func DoOptimize(flag uint64, logic LogicalPlan) (PhysicalPlan, error) {
buildExplain 調用 buildSelect 來生成 select 語句的邏輯執行計划樹 (LogicalPlan),接着調用DoOptimize來進行優化;比如類似這樣的優化:對關聯子查詢進行改寫,應用關系代數的一些規則,將 in子句 轉為 semi join,因為 semi join 可以有多種方式進行高效的數據集連接操作;
然后,我們看看優化之前的執行計划:

這個執行計划大體是這樣的,執行計划的根節點是一個投影算子(Projection),取表 t1 的兩個列 t1.a 和 t1.b;根節點下面是一個 Apply 算子,Apply 算子是為了滿足關聯子查詢的需要,子查詢語句中用到了外面的結果集,就像我們示例的 sql,子查詢里面的選擇算子是 t2.b = t1.b,t1.b 就是關聯到外部執行計划的列;
Apply關聯了兩個算子,一個是上圖中左邊的DataSource,這個DataSource算子是對t1的掃表操作;另一個算子是上圖中的 下面那個 LogicalProjection 算子,這個算子下面接了選擇算子(LogicalSelection),相當於執行 t1.b = t2.b 的操作,然后是掃表算子,對 t2 進行掃表;
相對於我們這條語句,Apply算子大概是這樣的執行步驟:
從掃表算子(TableScan)中獲取一條 t1 的數據;
從投影算子(Projection)獲取一條 t2 的數據;
投影算子調用選擇算子(Selection);
選擇算子拿的外部關聯的數據 t1.a;
選擇算子對 t2 掃表,條件是 t2.b = t1.b,獲取 t2.a;其中,t1.b 相對於Apply 算子是外部計划的列;
執行一個標量算子,判斷 t1.a = t2.a;不管 t2.a 的記錄有多少,只要有一條滿足,即成功;
可以看到,對關聯子查詢,需要執行嵌套循環(NestedLoop),對 t1 的每條記錄循環,傳給 Apply算子, Apply算子再用這條記錄去 t2 表對每條記錄執行循環;
而我們看到 explain 語句顯示的最終執行計划,是用 semi join 來改寫的,t1 和 t2 用 semi join 來連接,連接條件是 t1.a = t2.a and t1.b = t2.b,連接方式是左半連接;
說的直白一點就是:t1 和 t2 做 自然連接獲取左表數據,然后對結果集去重;
semi join有很多種策略,mysql支持差不多5種 Semi Join;
TiDB執行計划優化代碼在這里:
// DoOptimize optimizes a logical plan to a physical plan.
func DoOptimize(flag uint64, logic LogicalPlan) (PhysicalPlan, error) {
......
logic, err := logicalOptimize(flag, logic)
......
physical, err := physicalOptimize(logic)
......
finalPlan := postOptimize(physical)
return finalPlan, nil
}
調用 PlanBuilder.buildSelect 生成的邏輯計划(LogicalPlan),會傳到 DoOptimize 進行優化,DoOptimize主要做了兩件事:
一件事做基於規則的優化(RBO),應用關系代數規則,對關系代數進行等價改寫,生成更優的執行計划,例如:一些簡單的關系代數轉換---謂詞下推,常量折疊,子查詢展開,投影合並等等;或者一些更高級的關系代數轉換,子查詢轉 semi join,子查詢轉各種連接,等等;基本上涉及到數據集連接或者有子集嵌套的sql優化起來難度要大很多;要應用到的關系代數規則也更多;
另一件事是做基於代價的優化(CBO),這個我也不懂 😄;
在傳統數據庫如 mysql, oracle中,這些優化做完之后已經是極限了,但是在NewSQL中,還有更多的優化空間,例如:謂詞下推,列剪裁這種簡單的規則,可以從計算節點下推到KV節點上;再比如:NewSQL中的KV是多副本和Range分片的,這樣可以將計算分布在不同的節點上;
傳統數據庫都是基於關系代數設計的,所以對集合連接,集合條件過濾操作非常友好;但是隨着數據的增長,我們在數據庫架構上不得不使用分片來提高海量數據的讀寫能力;
分片可以獲得優良的單條數據的讀寫能力,但是失去了數據庫的事務性;比起犧牲事務性,更大的問題是犧牲了傳統的關系代數運算,分片之后,多個表的數據連接變得異常困難,對於子查詢再嵌套子查詢,幾乎是無法完成的任務;
為了折衷,大部分企業在分片的數據庫集群下游接了一個OLAP集群,來滿足傳統意義上的關系代數操作;
關系代數最早是由早期的SQL提出的;
后來各種大數據平台Hadoop,HBase和 MapReduce 計算框架出現后,碰到很多數據集和數據過濾的問題,不得不通過關系代數來解決,於是人們將目光轉回到了傳統的關系代數上,Hive,Calcite這種支持SQL的外掛引擎出現了,這些引擎實現了SQL的解析和規則優化,我們只需要將算子應用到底層的數據存儲就可以了;
結尾;
附:關系代數幾個常用的符號,這些符號可以對應到SQL里面的算子,算子優化是通過關系代數的等價轉換來進行的:
σF(R):對集合R選擇;--- 相當於 where t1.b=t2.b;
ΠA(R):對集合R投影;--- 相當於 select t1.a, t1.b;
R×S:連接;這是關系代數最重要的操作,連接有很多種,自然連接,左連接,右連接,笛卡爾積,Semi Join 等等,子查詢這種場景通常被轉換為了各種連接;
R A⊗ E:Apply,對R中的每條記錄,代入E中進行運算,然后把記錄做各種連接;
