做者:姚珂男git
在 TiDB 源碼閱讀系列文章(七)基於規則的優化 一文中,咱們介紹了幾種 TiDB 中的邏輯優化規則,包括列剪裁,最大最小消除,投影消除,謂詞下推和構建節點屬性,本篇將繼續介紹更多的優化規則:聚合消除、外鏈接消除和子查詢優化。github
聚合消除會檢查 SQL 查詢中 Group By
語句所使用的列是否具備惟一性屬性,若是知足,則會將執行計劃中相應的 LogicalAggregation
算子替換爲 LogicalProjection
算子。這裏的邏輯是當聚合函數按照具備惟一性屬性的一列或多列分組時,下層算子輸出的每一行都是一個單獨的分組,這時就能夠將聚合函數展開成具體的參數列或者包含參數列的普通函數表達式,具體的代碼實如今 rule_aggregation_elimination.go
文件中。下面舉一些具體的例子。sql
例一:express
下面這個 Query 能夠將聚合函數展開成列的查詢:緩存
select max(a) from t group by t.pk;
被等價地改寫成:函數
select a from t;
例二:優化
下面這個 Query 能夠將聚合函數展開爲包含參數列的內置函數的查詢:3d
select count(a) from t group by t.pk;
被等價地改寫成:code
select if(isnull(a), 0, 1) from t;
這裏其實還能夠作進一步的優化:若是列 a
具備 Not Null
的屬性,那麼能夠將 if(isnull(a), 0, 1)
直接替換爲常量 1(目前 TiDB 還沒作這個優化,感興趣的同窗能夠來貢獻一個 PR)。blog
另外提一點,對於大部分聚合函數,參數的類型和返回結果的類型通常是不一樣的,因此在展開聚合函數的時候通常會在參數列上構造 cast 函數作類型轉換,展開後的表達式會保存在做爲替換 LogicalAggregation
算子的 LogicalProjection
算子中。
這個優化過程當中,有一點很是關鍵,就是如何知道 Group By
使用的列是否知足惟一性屬性,尤爲是當聚合算子的下層節點不是 DataSource
的時候?咱們在 (七)基於規則的優化 一文中的「構建節點屬性」章節提到過,執行計劃中每一個算子節點會維護這樣一個信息:當前算子的輸出會按照哪一列或者哪幾列知足惟一性屬性。所以,在聚合消除中,咱們能夠經過查看下層算子保存的這個信息,再結合 Group By
用到的列判斷當前聚合算子是否能夠被消除。
不一樣於 (七)基於規則的優化 一文中「謂詞下推」章節提到的將外鏈接轉換爲內鏈接,這裏外鏈接消除指的是將整個鏈接操做從查詢中移除。
外鏈接消除須要知足必定條件:
LogicalJoin
的父親算子只會用到 LogicalJoin
的 outer plan 所輸出的列LogicalJoin
中的 join key 在 inner plan 的輸出結果中知足惟一性屬性LogicalJoin
的父親算子會對輸入的記錄去重條件 1 和條件 2 必須同時知足,但條件 2.1 和條件 2.2 只需知足一條便可。
知足條件 1 和 條件 2.1 的一個例子:
select t1.a from t1 left join t2 on t1.b = t2.pk;
能夠被改寫成:
select t1.a from t1;
知足條件 1 和條件 2.2 的一個例子:
select distinct(t1.a) from t1 left join t2 on t1.b = t2.b;
能夠被改寫成:
select distinct(t1.a) from t1;
具體的原理是,對於外鏈接,outer plan 的每一行記錄確定會在鏈接的結果集裏出現一次或屢次,當 outer plan 的行不能找到匹配時,或者只能找到一行匹配時,這行 outer plan 的記錄在鏈接結果中只出現一次;當 outer plan 的行能找到多行匹配時,它會在鏈接結果中出現屢次;那麼若是 inner plan 在 join key 上知足惟一性屬性,就不可能存在 outer plan 的行可以找到多行匹配,因此這時 outer plan 的每一行都會且僅會在鏈接結果中出現一次。同時,上層算子只須要 outer plan 的數據,那麼外鏈接能夠直接從查詢中被去除掉。同理就能夠很容易理解當上層算子只須要 outer plan 的去重後結果時,外鏈接也能夠被消除。
這部分優化的具體代碼實如今 rule_join_elimination.go 文件中。
子查詢分爲非相關子查詢和相關子查詢,例如:
-- 非相關子查詢 select * from t1 where t1.a > (select t2.a from t2 limit 1); -- 相關子查詢 select * from t1 where t1.a > (select t2.a from t2 where t2.b > t1.b limit 1);
對於非相關子查詢, TiDB 會在 expressionRewriter
的邏輯中作兩類操做:
子查詢展開
即直接執行子查詢得到結果,再利用這個結果改寫本來包含子查詢的表達式;好比上述的非相關子查詢,若是其返回的結果爲一行記錄 「1」 ,那麼整個查詢會被改寫爲:
select * from t1 where t1.a > 1;
詳細的代碼邏輯能夠參考 expression_rewriter.go 中的 handleScalarSubquery 和 handleExistSubquery 函數。
子查詢轉爲 Join
對於包含 IN (subquery) 的查詢,好比:
select * from t1 where t1.a in (select t2.a from t2);
會被改寫成:
select t1.* from t1 inner join (select distinct(t2.a) as a from t2) as sub on t1.a = sub.a;
若是 t2.a
知足惟一性屬性,根據上面介紹的聚合消除規則,查詢會被進一步改寫成:
select t1.* from t1 inner join t2 on t1.a = t2.a;
這裏選擇將子查詢轉化爲 inner join 的 inner plan 而不是執行子查詢的緣由是:以上述查詢爲例,子查詢的結果集可能會很大,展開子查詢須要一次性將 t2
的所有數據從 TiKV 返回到 TiDB 中緩存,並做爲 t1
掃描的過濾條件;若是將子查詢轉化爲 inner join 的 inner plan ,咱們能夠更靈活地對 t2
選擇訪問方式,好比咱們能夠對 join 選擇 IndexLookUpJoin
實現方式,那麼對於拿到的每一條 t1
表數據,咱們只需拿 t1.a
做爲 range 對 t2
作一次索引掃描,若是 t1
表很小,相比於展開子查詢返回 t2
所有數據,咱們可能總共只須要從 t2
返回不多的幾條數據。
注意這個轉換的結果不必定會比展開子查詢更好,其具體狀況會受 t1
表和 t2
表數據的影響,若是在上述查詢中, t1
表很大而 t2
表很小,那麼展開子查詢再對 t1
選擇索引掃描可能纔是最好的方案,因此如今有參數控制這個轉化是否打開,詳細的代碼能夠參考 expression_rewriter.go 中的 handleInSubquery 函數。
對於相關子查詢,TiDB 會在 expressionRewriter
中將整個包含相關子查詢的表達式轉化爲 LogicalApply
算子。LogicalApply
算子是一類特殊的 LogicalJoin
,特殊之處體如今執行邏輯上:對於 outer plan 返回的每一行記錄,取出相關列的具體值傳遞給子查詢,再執行根據子查詢生成的 inner plan ,即 LogicalApply
在執行時只能選擇相似循環嵌套鏈接的方式,而普通的 LogicalJoin
則能夠在物理優化階段根據代價模型選擇最合適的執行方式,包括 HashJoin
,MergeJoin
和 IndexLookUpJoin
,理論上後者生成的物理執行計劃必定會比前者更優,因此在邏輯優化階段咱們會檢查是否能夠應用「去相關」這一優化規則,試圖將 LogicalApply
轉化爲等價的 LogicalJoin
。其核心思想是將 LogicalApply
的 inner plan 中包含相關列的那些算子提高到 LogicalApply
之中或之上,在算子提高後若是 inner plan 中再也不包含任何的相關列,即再也不引用任何 outer plan 中的列,那麼 LogicalApply
就會被轉換爲普通的 LogicalJoin
,這部分代碼邏輯實如今 rule_decorrelate.go 文件中。
具體的算子提高方式分爲如下幾種狀況:
inner plan 的根節點是 LogicalSelection
則將其過濾條件添加到 LogicalApply
的 join condition 中,而後將該 LogicalSelection
從 inner plan 中刪除,再遞歸地對 inner plan 提高算子。
以以下查詢爲例:
select * from t1 where t1.a in (select t2.a from t2 where t2.b = t1.b);
其生成的最初執行計劃片斷會是:
LogicalSelection
提高後會變成以下片斷:
到此 inner plan 中再也不包含相關列,因而 LogicalApply
會被轉換爲以下 LogicalJoin :
inner plan 的根節點是 LogicalMaxOneRow
即要求子查詢最多輸出一行記錄,好比這個例子:
select *, (select t2.a from t2 where t2.pk = t1.a) from t1;
由於子查詢出如今整個查詢的投影項裏,因此 expressionRewriter
在處理子查詢時會對其生成的執行計劃在根節點上加一個 LogicalMaxOneRow
限制最多產生一行記錄,若是在執行時發現下層輸出多於一行記錄,則會報錯。在這個例子中,子查詢的過濾條件是 t2
表的主鍵上的等值條件,因此子查詢確定最多隻會輸出一行記錄,而這個信息在「構建節點屬性」這一步時會被髮掘出來並記錄在算子節點的 MaxOneRow
屬性中,因此這裏的 LogicalMaxOneRow
節點其實是冗餘的,因而咱們能夠將其從 inner plan 中移除,而後再遞歸地對 inner plan 作算子提高。
inner plan 的根節點是 LogicalProjection
則首先將這個投影算子從 inner plan 中移除,再根據 LogicalApply
的鏈接類型判斷是否須要在 LogicalApply
之上再加上一個 LogicalProjection
,具體來講是:對於非 semi-join 這一類的鏈接(包括 inner join 和 left join ),inner plan 的輸出列會保留在 LogicalApply
的結果中,因此這個投影操做須要保留,反之則不須要。最後,再遞歸地對刪除投影后的 inner plan 提高下層算子。
inner plan 的根節點是 LogicalAggregation
首先咱們會檢查這個聚合算子是否能夠被提高到 LogicalApply
之上再執行。以以下查詢爲例:
select *, (select sum(t2.b) from t2 where t2.a = t1.pk) from t1;
其最初生成的執行計劃片斷會是:
將聚合提高到 LogicalApply
後的執行計劃片斷會是:
即先對 t1
和 t2
作鏈接,再在鏈接結果上按照 t1.pk
分組後作聚合。這裏有兩個關鍵變化:第一是無論提高前 LogicalApply
的鏈接類型是 inner join 仍是 left join ,提高後必須被改成 left join ;第二是提高後的聚合新增了 Group By
的列,即要按照 outer plan 傳進 inner plan 中的相關列作分組。這兩個變化背後的緣由都會在後面進行闡述。由於提高後 inner plan 再也不包含相關列,去相關後最終生成的執行計劃片斷會是:
聚合提高有不少限定條件:
LogicalApply
的鏈接類型必須是 inner join 或者 left join 。 LogicalApply
是根據相關子查詢生成的,只可能有 3 類鏈接類型,除了 inner join 和 left join 外,第三類是 semi join (包括 SemiJoin
,LeftOuterSemiJoin
,AntiSemiJoin
,AntiLeftOuterSemiJoin
),具體能夠參考 expression_rewriter.go
中的代碼,限於篇幅在這裏就不對此作展開了。對於 semi join 類型的 LogicalApply
,由於 inner plan 的輸出列不會出如今鏈接的結果中,因此很容易理解咱們沒法將聚合算子提高到 LogicalApply
之上。
LogicalApply
自己不能包含 join condition 。以上面給出的查詢爲例,能夠看到聚合提高後會將子查詢中包含相關列的過濾條件 (t2.a = t1.pk
) 添加到 LogicalApply
的 join condition 中,若是 LogicalApply
自己存在 join condition ,那麼聚合提高後聚合算子的輸入(鏈接算子的輸出)就會和在子查詢中時聚合算子的輸入不一樣,致使聚合算子結果不正確。
子查詢中用到的相關列在 outer plan 輸出裏具備惟一性屬性。以上面查詢爲例,若是 t1.pk
不知足惟一性,假設 t1
有兩條記錄知足 t1.pk = 1
,t2
只有一條記錄 { (t2.a: 1, t2.b: 2) }
,那麼該查詢會輸出兩行結果 { (sum(t2.b): 2), (sum(t2.b): 2) }
;但對於聚合提高後的執行計劃,則會生成錯誤的一行結果{ (sum(t2.b): 4) }
。當 t1.pk
知足惟一性後,每一行 outer plan 的記錄都對應鏈接結果中的一個分組,因此其聚合結果會和在子查詢中的聚合結果一致,這也解釋了爲何聚合提高後須要按照 t1.pk
作分組。
聚合函數必須知足當輸入爲 null
時輸出結果也必定是 null
。這是爲了在子查詢中沒有匹配的特殊狀況下保證結果的正確性,以上面查詢爲例,當 t2
表沒有任何記錄知足 t2.a = t1.pk
時,子查詢中不論是什麼聚合函數都會返回 null
結果,爲了保留這種特殊狀況,在聚合提高的同時, LogicalApply
的鏈接類型會被強制改成 left join(改以前多是 inner join ),因此在這種沒有匹配的狀況下,LogicalApply
輸出結果中 inner plan 部分會是 null
,而這個 null
會做爲新添加的聚合算子的輸入,爲了和提高前結果一致,其結果也必須是 null
。
對於根據上述條件斷定不能提高的聚合算子,咱們再檢查這個聚合算子的子節點是否爲 LogicalSelection
,若是是,則將其從 inner plan 中移除並將過濾條件添加到 LogicalApply
的 join condition 中。這種狀況下 LogicalAggregation
依然會被保留在 inner plan 中,但會將 LogicalSelection
過濾條件中涉及的 inner 表的列添加到聚合算子的 Group By
中。好比對於查詢:
select *, (select count(*) from t2 where t2.a = t1.a) from t1;
其生成的最初的執行計劃片斷會是:
由於聚合函數是 count(*)
,不知足當輸入爲 null
時輸出也爲 null
的條件,因此它不能被提高到 LogicalApply
之上,但它能夠被改寫成:
注意 LogicalAggregation
的 Group By
新加了 t2.a
,這一步將本來的先作過濾再作聚合轉換爲了先按照 t2.a
分組作聚合,再將聚合結果與 t1
作鏈接。 LogicalSelection
提高後 inner plan 已經再也不依賴 outer plan 的結果了,整個查詢去相關後將會變爲:
這是基於規則優化的第二篇文章,後續咱們還將介紹更多邏輯優化規則:聚合下推,TopN 下推和 Join Reorder 。