原文鏈接:https://www.infoq.cn/article/cwuk2o*aW8ih9ygu5UeJ
本文將從以下幾個方面介紹:首先講一下 TiDB 的整體架構,接下來就是優化器的兩個比較重要的模塊,一個是 SQL 優化,做執行計划生成;另一個模塊就是統計信息模塊,其作用是輔助執行計划生成,為每一個執行計划計算 cost 提供幫助。最后介紹下優化器還有哪些后續工作需要完成。
1. TiDB 的整體架構
TiDB 架構主要分為四個模塊:TiDB、TiKV、TiSpark 和 PD,TiKV 是用來做數據存儲,是一個帶事務的、分布式的 key-value 存儲,PD 集群是對原始數據里用來存儲 key-value 里每一個范圍的的 k-v 存儲在每一個具體的 k-v 元數據信息,也會負責做一些熱點調度;如熱點 region 調度。在 Tikv 中做數據復制和分布式調度都是 rastgroup 做的,每一個讀寫請求都下放到 Tikv 的 leader 上去,可能會存在某些 Tikv 的 server 或者機器的 region leader 特別多,這個時候 PD 集群就會發揮熱點調度功能,將一些熱點 leader 調度到其他機器上去。TiDB 是所有場景中對接用戶客戶端的一層,也負責做 SQL 的優化,也支持所有 SQL 算子實現。Spark 集群是用來做重型 IP 的 SQL 或者作業查詢,做一些分布式計算。
刨除 Spark,TiDB 集群主要有三個核心部分。最上層 TiDB 對接用戶的各種 My SQL/Maria DB clients,ORMs,JDBC/ODBC,TiDB 的節點與節點之間本身是不做任何數據交互,是無狀態的,其節點就是解析用戶的 query,query 的執行計划生成。把一些執行計划下推到一些 Tikv 節點,將一些數據從 Tikv 節點拿上來,然后在 PD 中做計算,這就是整個 TiDB 的概覽。
講優化器之前需要講一下 TiDB 中結構化的數據是如何映射到 K-V 數據的。在 TiDB 中只有兩種數據,一種是表數據,一種是為表數據創建的 index 數據。表數據就是 tableID 加 RowID 的形式將其映射為 Key-Value 中的 key,表數據中具體每一行的數據一個 col 的映射為其 value,以 Key-Value 的形式存儲到 Tikv 中。索引數據分為兩種一種是唯一索引和非唯一索引,唯一索引就是 tableID+IndexID+ 索引的值構成 Key-Value 中的 key,唯一索引對應的那一行的 RowID,非唯一索引就是將 rowID encode 到 Key 中。
下面是 TiDB SQL 層的應用組件,左邊是協議層,主要負責用戶的 connect 連接,和 JDBC/ODBC 做一些數據協議,解析用戶的 SQL,將處理好的結果數據以 MySQL 的形式 encode 成符合 MySQL 規范的格式化數據返回給客戶端。中間的 Session Context 主要負責一個 session 里面需要處理的一些用戶設置的各種變量,最右邊就是各種權限管理的 manager、源信息管理、DDL Worker,還有 GC Worker 也是在 TiDB 層。
今天主要介紹 SQL 經過 parser 再經過 AST,然后 Optimize,經過 TiDB 的 SQL 執行引擎,還有經過 Tikv 提供的 Coprocessor,Coprocessor 支持簡單的表達式計算、data scan、聚合等。Tikv 能讓 TiDB 將一些大量操作都下推到 Tikv 上,減少 Tikv 與 TiDB 的數據交互帶來的網絡開銷,也能讓一部分計算在 Tikv 上分布式並行執行。
2. Query Optimizer
上圖中的執行計划比較簡單,就是兩個表做 join,然后對 join 的結果做 count(*),join 方式是 merge join。
查詢優化器解決的工作很復雜,比如需要考慮算子的下推,比如 filter 的下推,盡量下推到數據源,這樣能減少所有執行數據的計算量;還有索引的選擇,join Order 和 join 算法的選擇,join Order 指的是當有多個表做 join 時以什么樣的順序去執行這些 join,不同的 join Order 意味着有不同大小的中間結果,而且 join Oder 也會去影響某一些 join 節點算法的選擇;還有子查詢的優化,如硬子查詢是將其優化成 inner join 還是嵌套的方式去執行硬子查詢而不去 join,這些在各種場景中因為數據源的分布不同,每一種策略都會在一種場景中有它自身的優勢,需要考慮的方面很多,實現起來也比較困難。
優化器進行優化邏輯復雜,進行優化需要進行一些比較重的計算,為了降低一些不必要的計算。比如對一些簡單的場景點查,根據一些組件查一條數據,這種就不需要經過特別復雜的計算,這種需要提前標記出來,直接將索引的唯一值 ID 解析出來,變成一次 k-v scan,這種就不需要做復雜的優化,不用去做執行樹的迭代。目前 TiDB 中的 update、delete 、scan 都支持 k-v scan,還有 PointGet Plan 也支持這種優化。
TiDB 的 SQL 優化器分為物理優化階段和邏輯優化階段,邏輯優化階段的輸入是一個邏輯優化執行計划,有了初始邏輯優化執行計划后,TiDB 的邏輯優化過程需要把這個邏輯執行計划去應用一些 rule,每一個 rule 必須具備的特點是輸出的邏輯執行計划與輸出的邏輯執行計划在邏輯上是等價的。邏輯優化與物理優化的區別是邏輯優化區別數據的形態是什么,是先 join 再聚合還是先聚合再 join,它並不會去聚合算子是 stream 聚合還好 hash 聚合,也不會去關注 join 算子是哪一種物理算子。同時也要求 rule 將產生的每一個新的邏輯執行計划一定要比原來輸入的邏輯執行計划要更優,如將一些算子下推到數據源比不下推下去要更優,下推后上層處理的數據量變少,整體計算量就比原來少。
接下來就講一下 TiDB 中已經實現的一些邏輯優化規則,如 Column Pruning 就是裁減掉一些不需要的列,Partition Pruning 針對的是分區表,可以依據一些謂詞掃描去掉,Group By Elimination 指的是聚合時 Group By 的列是表的唯一索引時可以不用聚合。Project Emination 是消除一些中間的沒用的一些投影操作,產生的原因是在一些優化規則以自己實現簡單會加一些 Project ,還有就是從 AST 構造到最初邏輯執行計划時也會為了實現上的簡單會去添加一些中間節點的投影操作,Outer Join Simplification 主要針對 null objective,如 A>10,而 A 有又是 null 而且又是 inner 表中的列時,Outer Join 就可以轉化為 inner join。
Max/Min Eliminatation 在有索引的時候非常有用,如 Max A 是一個索引列,直接在 A 上做一個逆序掃第一行數據就可以對外返回結果,頂層還有一個 Max A,這個是為了處理 join 異常情況,如 Max 和 count 對空輸入結果值行為結果是不一樣的,需要有一個頂層的聚合函數來處理異常情況,這樣就不需要對所有數據做 max,這樣做的好處就是不用做全表掃描。
Outer Join Elimination 可以將其轉化為只掃描 Outer 表,比如當用戶只需要使用 Outer Join 的 Outer 表,如例子中只需要 t1 表中的數據,如何 inner 表上的 key 剛好是 inner 表上的索引,那么這個 inner 表就可以扔掉,因為對於 outer 表中的每一條數據如果能 join 上,只會和 inner 表的一行數據 join 上,因為 inner 表上的 key 是唯一值,如果對應不上就是 null,而返回的數據只需要 outer 表,inner 表上的數據不需要。還有一種情況是父節點只需要 outer 表的唯一值,再做 outer join 如果對應上會膨脹很多值,而上層只需要不同值這樣就不需要膨脹,這樣就可以消除在 outer 表做一個 select 的 distinct 操作。
Subquery Decorrelation 是一個多年研究的問題,上圖例子是先從 t1 表中掃一行數據,去構造 t2 表的 filter,然后去掃描 t2 表中滿足這樣的數據,對 t2 表的 A 做一個聚合,最終是 t1 表的 A 類數據小於求的和,才把 t1 表的這行數據輸出。如果執行計划按照上述邏輯執行,那么每一行 t1 的值都會對 t2 進行全表掃描,這樣就會對集群產生非常大的負擔,也會做很多無用的計算。因此可以將優化成先聚合再 join,就是先把 t2 表先按過濾的條件的列做一個 group by,每一個 group 求 t2 表 A 的和,將其求得的和再去和 t1 表做 join。上層的 arcconditon,這樣就不會對 inner 表頻繁的做 inner 操作,從整體上看不用做全表掃描,每一行 outer 都會對 t2 表做掃描。
聚合下推不一定要優,但在某些場景很有用。兩個表做 join,以上面一個表為例,join 的結果以 t1 的 a 做一個 group by。如果 t1 表的 t1.a 列重復的值很多,先去做 join 就會導致重復的值和 t2 表能夠匹配的值重復很厲害,再去做聚合計算量也非常大,有一種策略是將聚合下推到 t1 表上。將 t1 表上 a 做一個聚合,很多重復的 t1.a 再 join 之間就壓縮成一條,join 操作的計算量非常輕,在更上層的聚合相應減輕不少負擔。但是不一定每種情況都有用,如果 t1.a 中的數據重復值不多,那么下推下去的聚合將數據過濾一遍又沒有起到聚合的效果。Top N Limit Push Down 只需要將其 outer join push 到 outer 端,這是因為 outer 表的數據要輸出,只需要拿三條數據和 inner 表做 join,如果有膨脹,再放一個 top/limit 將數據只限制在三條。相反如果將 topN 不 push 下去,那么從 table3 讀取的數據會很大。
還有一個難題是 Join Reorder,目前 Join Reorder 的算法有很多。統計信息精准度一定的情況下,選出一個最好的 Join Reorder 算法最好的方式是用 DP 算法。如果兩者信息精確,利用動態規划得出的算法一定是最優的,但是現實中統計信息不一定優,如兩張表信息是優但是 join 后的結果不一定符合數據真實分布,可能有推導誤差。A、B 統計節點是推導出來的,再去推導節點的統計信息,誤差就被放大,因此 DP 的 join order 在使用真實的統計信息做 join order 再去推導統計節點的統計信息所做出來的 order 也不一定是好的。
在 TiDB 中使用的 join order 是一個子樹,使用狀態壓縮的方式做的,就是 6 的整數用二進制的形式表示 110, 0 表示節點不存在,1 表示節點存在,第 1、2 節點存在,第 0 號節點不存在。就決定了最優的 join 順序是什么,這樣 DP 算法推導就比較簡單,不斷的枚舉其子集合,6 可以分為 110 和 10,分別 join 兩個子集合,選擇所有情況中最小的一個;這種方式時間復雜度很高,如果節點過多,做 join reorder 的時間會很長。還有 DP 算法是用整數代替 join 節點,如果 10 個節點就是 210,20 個節點就是 1M 內存。因此當節點比較大的時候采用貪心策略做 join reorder,實現原理是先將所有的 join recount 估算,從小打到大排序,一次選擇按邊相連的節點去做 join,如圖一開始初始是 t1 和 t2 做 join 結果估算有 800,由於 t3 的 count 也是 100,也需要考慮 t1 和 t3 做 join,join 出來是 200,則 t1 和 t3 優先做 join,然后再遍歷節點數后最小的節點與當前 join 數做 join,當為 join 節點集合為空時整個 join 樹就生成了。但是局部最優不一定全局最優,並不能把所有情況都考慮最好的 join 順序。
接下來是物理優化階段,邏輯優化並不決定以什么算法去執行,只介紹了 join 順序,並沒有說要用那種 join 方式。物理優化需要考慮不同的節點,不同的算法對輸入輸出有不同的要求,如 hash 和 merge join 實現的時間復雜度本身不一樣。要理解物理優化的過程要理解什么是物理屬性。物理屬性是一個物理算法所具備的屬性,在 TiDB 就有 task type 屬性,就是這個算法是應該在 TiDB 中執行還是在 Tikv 中執行;data order 說的是算法所產生的數據應該以什么樣的順序屬性,如 merge join 是按 outer join 的 key 有序的。Stream 聚合也是按照 group by 的 column 有序。但是有些算法無法提供 join 順序,如 hash join,還會破壞數據的順序,hash join 無法對外提供任何順序上的保證。在分布式場景中做執行計划時需要考慮分布的屬性,如 hash join 在一個分式的節點上執行,考慮的是選表多下搜的方式,如果想正確出結果最好的方式是將小表和大表的數據都按照 join 的 key 下放到不同的機器上,那么分布式的 hash join 特點就是 join 的 key 分布在同一台機器上。在 TiDB 沒有考慮數據分布的特性,動態規划的狀態就是輸入的邏輯狀態是什么,實現的邏輯執行計划的物理執行計划需要滿足什么樣的物理屬性,最后推導出一個最佳的物理執行計划。這樣同一個邏輯節點可能會多次被父節點以不同路勁訪問它,因此需要緩存中間節點,下次父節點以同樣的動態規划狀態訪問直接將之前最佳的結果返回就行。
上圖的實例是對兩個表做 join,join 后數據按照 join key 排序,假設 t1 和 t2 表都在各自的 join key 上有索引,對於 t1 和 t2 表掃描有兩種方式,一種是 index scan 能夠滿足返回的數據以 index 有序,或者 table scan 不能滿足 index scan 有序,nominalsort 是 TiDB 內部優化算子,既不會出現在邏輯執行計划里面也不會出現物理執行計划里面,只是在做物理執行計划輔助作用,從一開始調用動態規划過程,輸入邏輯計划要求滿足的物理屬性是空,接下來可以用物理 sort 算子和 nominalsort 算子,其本身不 排數據,而是將排數據的功能傳遞給子節點。
在物理優化中比較重要的一點是如何選擇索引,沒有索引一個慢查詢會導致所有集群都慢。最后引入 Skyline index Pruning,當要選擇那個選項最優時有多個維度可以考量,訪問一個表的方式有多種方式選擇,其要求就是父節點要求子節點返回的數據是否有序,還有就是索引能夠覆蓋多少列,這是因為用戶建索引並不是一定按照最優解來建。
從優化過程來說,算法並不是最優的,應用完一個 rule 不會再次去應用,但是實際是會多次使用的。解決有 Memo 優化,就是將所有表達式存儲,將等價表達式存儲於一個 group 里面,將所有 rule 用最小化、原子化做 group expression。
3. Statistics
統計信息是用來估算 row count,需要估算的 row count 有 filter、join、聚合。TIDB 中存儲的統計信息有直方圖,主要用於估算范圍查詢的統計信息,被覆蓋的其 count 直接加上去,部分覆蓋的桶使用連續均勻分布的假設,被覆蓋的部分乘以桶的 rowcount 加上去;另一個是估算點查詢的 rowcount,可以理解 Min-Sketch,只是估算的值不再是 0 和 1,數據代表是這個位置被 hash 到了多少次,如一個數據有 D 個 hash 函數,將其 hash 到 D 的某個位置,對具體位置加上 1,查詢也做同樣的操作,最后取這 D 位置最小的值作為 count 估計,這個估計在實際中精度較高。
TiDB 收集統計信息的方式有很多,首先手動執行 analyze 語句做統計信息的搜集;也可以配置自動 analyze,就是表的更新超過某些行數會自動做 analyze;還有 Query Feedback,就是在查詢請求,如果查的數據分布和以前統計的數據分布信息不太匹配回去糾正已有的統計信息。
4. Future Work
接下來一些工作就是查詢計划的穩定性,重要的是索引的准確,還有就是有些算法的選擇也會影響查詢計划的穩定性;The Cascades Planner 就是要解決搜索空間的搜索算法的效率問題,搜索空間導致執行計划不夠優的問題。還有快孫 analyze,目前表以億起步,如果現場采樣,會比較慢因此會采取一些手段加速 analyze 過程。Multi-Column Statistics 主要生死用來解決多列之間的相關性,以前做 row count 估算都是基於 column 與 column 間的不相關假設做 row count,這樣估計的值比實際值偏大,有多列相關估算准確度會提高很多。