TiDB 原始碼閱讀系列文章(十二)統計資訊(上)

PingCAP發表於2018-07-06

作者: 謝海濱

在 TiDB 裡,SQL 優化的過程可以分為邏輯優化和物理優化兩個部分,在物理優化階段需要為邏輯查詢計劃中的運算元估算執行代價,並選擇其中代價最低的一條查詢路徑作為最終的查詢計劃。這裡非常關鍵的一點是如何估算查詢代價,本文所介紹的統計資訊是這個估算過程的核心模組。

這部分內容非常複雜,所以會分成兩篇文章來介紹。本篇文章介紹統計資訊基本概念、TiDB 的統計資訊收集/更新機制以及如何用統計資訊來估計運算元代價。上篇側重於介紹原理,下篇會結合原理介紹 TiDB 的原始碼實現。

統計資訊是什麼

為了得到查詢路徑的執行代價,最簡單的辦法就是實際執行這個查詢計劃,不過這樣子做就失去了優化器的意義。不過,優化器並不需要知道準確的代價,只需要一個估算值,以便能夠區分開代價差別較大的執行計劃。因此,資料庫常常會維護一些實際資料的概括資訊,用以快速的估計代價,這便是統計資訊。

在 TiDB 中,我們維護的統計資訊包括表的總行數,列的等深直方圖,Count-Min Sketch,Null 值的個數,平均長度,不同值的數目等等。下面會簡單介紹一下直方圖和 Count-Min Sketch。

1. 直方圖簡介

直方圖是一種對資料分佈情況進行描述的工具,它會按照資料的值大小進行分桶,並用一些簡單的資料來描述每個桶,比如落在桶裡的值的個數。大多數資料庫都會選擇用直方圖來進行區間查詢的估算。根據分桶策略的不同,常見的直方圖可以分為等深直方圖和等寬直方圖。
在 TiDB 中,我們選擇了等深直方圖,於 1984 年在 Accurate estimation of the number of tuples satisfying a condition 文獻中提出。相比於等寬直方圖,等深直方圖在最壞情況下也可以很好的保證誤差。所謂的等深直方圖,就是落入每個桶裡的值數量儘量相等。舉個例子,比方說對於給定的集合 {1.6, 1.9, 1.9, 2.0, 2.4, 2.6, 2.7, 2.7, 2.8, 2.9, 3.4, 3.5},並且生成 4 個桶,那麼最終的等深直方圖就會如下圖所示,包含四個桶 [1.6, 1.9],[2.0, 2.6],[2.7, 2.8],[2.9, 3.5],其桶深均為 3。

1-直方圖.png

2. Count-Min Sketch 簡介

Count-Min Sketch 是一種可以處理等值查詢,Join 大小估計等的資料結構,並且可以提供很強的準確性保證。自 2003 年在文獻 An improved data stream summary: The count-min sketch and its applications 中提出以來,由於其建立和使用的簡單性獲得了廣泛的使用。
Count-Min Sketch 維護了一個 d*w 的計數陣列,對於每一個值,用 d 個獨立的 hash 函式對映到每一行的一列中,並對應修改這 d 個位置的計數值。如下圖所示:

2-count-min.png

這樣在查詢一個值出現了多少次的時候,依舊用 d 個 hash 函式找到每一行中被對映到的位置,取這 d 個值的最小值作為估計值。

直方圖和 CM-Sketch 是常用的兩種資料概要手段,想了解更多相關技術,可以參考 《Synopses for Massive Data: Samples,Histograms, Wavelets, Sketches》。

統計資訊建立

通過上面的描述,我們知道統計資訊主要需要建立和維護的是直方圖和 Count-Min Sketch。
通過執行 analyze 語句,TiDB 會收集上述所需要的資訊。在執行 analyze 語句的時候,TiDB 會將 analyze 請求下推到每一個 Region 上,然後將每一個 Region 的結果合併起來。對於 Count-Min Sketch,其建立和合並都比較簡單,在這裡略去不講。以下主要介紹列和索引的直方圖的建立。

1. 列直方圖的建立

在建立直方圖的時候,需要資料是有序的,而排序的代價往往很高,因此我們在 TiDB 中實現了抽樣演算法,對抽樣之後的資料進行排序,建立直方圖,即會在每一個 Region 上進行抽樣,隨後在合併結果的時候再進行抽樣。

在 sample.go 中,我們實現了蓄水池抽樣演算法,用來生成均勻抽樣集合。令樣本集合的容量為 S,在任一時刻 n,資料流中的元素都以 S/n 的概率被選取到樣本集合中去。如果樣本集合大小超出 S,則從中隨機去除一個樣本。舉個例子,假如樣本池大小為 S = 100 ,從頭開始掃描全表,當讀到的記錄個數 n < 100 時,會把每一條記錄都加入取樣池,這樣保證了在記錄總數小於取樣池大小時,所有記錄都會被選中。而當掃描到的第 n = 101 條時,用概率 P = S/n = 100⁄101 決定是否把這個新的記錄加入取樣池,如果加入了取樣池,取樣池的總數會超過 S 的限制,這時需要隨機選擇一箇舊的取樣丟掉,保證取樣池大小不會超過限制。

取樣完成後,將所有的資料排序,由於知道取樣過後總的行數和直方圖的桶數,因此就可以知道每個桶的深度。這樣就可以順序遍歷每個值 V:

  • 如果 V 等於上一個值,那麼把 V 放在與上一個值同一個桶裡,無論桶是不是已經滿,這樣可以保證每個值只存在於一個桶中。
  • 如果不等於,那麼判斷當前桶是否已經滿,如果不是的話,就直接放入當前桶,否則的話,就放入下一個桶。

2. 索引直方圖的建立

在建立索引列直方圖的時候,由於不能事先知道有多少行的資料,也就不能確定每一個桶的深度,不過由於索引列的資料是已經有序的,因次可以採用如下演算法:在確定了桶的個數之後,將每個桶的初始深度設為 1,用前面列直方圖的建立方法插入資料,這樣如果到某一時刻所需桶的個數超過了當前桶深度,那麼將桶深擴大一倍,將之前的每兩個桶合併為 1 個,然後繼續插入。

在收集了每一個 Region 上分別建立的直方圖後,還需要把每個 Region 上的直方圖進行合併。對於兩個相鄰 Region 上的直方圖,由於索引是有序的,因此前一個的上界不會大於後一個的下界。不過為了保證每個值只在一個桶裡,我們還需要先處理一下交界處桶的問題,即如果交界處兩個桶的上界和下界相等,那麼需要先合併這兩個桶。如果直方圖合併之後桶的個數超過了限制,那麼只需要把兩兩相鄰的桶合二為一。

統計資訊維護

在 2.0 版本中,TiDB 引入了動態更新機制(2.0 版本預設沒有開啟, 2.1-beta 版本中已經預設開啟),可以根據查詢的結果去動態調整統計資訊。對於直方圖,需要調整桶高和桶的邊界;對於 CM Sketch,需要調整計數陣列,使得估計值和查詢的結果相等。

1. 桶高的更新

在範圍查詢的時候,涉及的桶都有可能對最終的結果貢獻一些誤差。因此,一種更新的方法便是假定所有桶貢獻的誤差都是均勻的,即如果最終估計的結果為 E,實際的結果為 R,某一個桶的估計結果為 b = 桶高 h 覆蓋比例 r,那麼就可以將這個桶的桶高調整為 (b / r) (R / E) = h * (R / E)。不過如果可以知道落在每一個桶範圍中的實際結果,便可以不去假定所有桶貢獻的誤差都是均勻的。

為了知道落在每一個桶範圍中的實際結果,需要先把查詢的範圍按照直方圖桶的邊界切分成不相交的部分,這樣在 TiKV 在執行查詢的時候,可以統計出每一個範圍中實際含有的行數目。這樣我們便可以按照類似於前述的方法調整每一個桶,不過這個時候不需要假定每個桶貢獻的誤差都是均勻的,因為我們可以準確知道每一個桶貢獻的誤差。

2. 桶邊界的更新

在用直方圖估計的時候,對於那些只被查詢範圍覆蓋了一部分的桶,主要的誤差來自連續平均分佈假設。這樣桶邊界更新的主要目便是使得查詢的邊界能儘量的落在與桶的邊界不遠的地方。桶邊界的更新主要方法包括分裂和合並。
對於分裂,需要解決的問題是哪些桶需要分裂,分裂成幾個,分裂的邊界在哪裡:

  • 哪些桶需要分裂,分裂成幾個:如果決定了每一次更新最多分裂 10 個桶,那麼如果一個桶上落入了 10% 的查詢點,那個這個桶就可以分裂一次,如果落入了 20% 的查詢點,那麼這個桶就可以分裂兩次,以此類推。
  • 分裂的邊界:由於目標是使得查詢的邊界能儘量的落在與桶的邊界不遠的地方,那麼如果這個桶要分裂 N 次,就需要選擇不超過 N 個查詢點,使得剩下的查詢點與這 N 個查詢點的最近距離之和最小。不過這種方法比較複雜,我們也可以採用比較簡單的方法,即假定每個不同的查詢點之間的距離都是相等的,這樣只需要每隔幾個點取一個作為邊界就可以。

分裂完成後,我們還要去合併桶。首先分裂得來的桶是不能合併的;除此之外,考慮連續的兩個桶,如果第一個桶佔合併後桶的比例為 r,那麼令合併後產生的誤差為 abs(合併前第一個桶的高度 – r * 兩個桶的高度和) / 合併前第一個桶的高度,就只需要去合併誤差最小的那些連續的桶。

3. Count-Min Sketch 的更新

CM Sketch 的更新比較簡單,對於某一個等值查詢的反饋結果 x,其估計值是 y,那麼我們只需要將這個值涉及到的所有點加上 c = x-y。

統計資訊使用

在查詢語句中,我們常常會使用一些過濾條件,而統計資訊估算的主要作用就是估計經過這些過濾條件後的資料條數,以便優化器選擇最優的執行計劃。在這篇 文件 中,介紹到 explain 輸出結果中會包含的一列 count,即預計當前 operator 會輸出的資料條數,便是基於統計資訊以及 operator 的執行邏輯估算而來。
在這個部分中,我們會先從最簡單的單一列上的過濾條件開始,然後考慮如何處理多列的情況。

1. 範圍查詢

對於某一列上的範圍查詢,TiDB 選擇了常用的等深直方圖來進行估算。

在前面介紹等深直方圖時,我們得到了一個包含四個桶 [1.6, 1.9],[2.0, 2.6],[2.7, 2.8],[2.9, 3.5],其桶深均為 3 的直方圖。假設我們得到了這樣一個直方圖,並且想知道落在區間 [1.7, 2.8] 範圍內的有多少值。把這個區間對應到直方圖上,可以看到有兩個桶是被完全覆蓋的,即桶 [2.0, 2.6] 和桶 [2.7,2.8],因此區間 [2.0, 2.8] 內一共有 6 個值;但是第一個桶只被覆蓋了一部分,那麼問題就變成了已經知道區間 [1.6, 1.9] 範圍內有 3 個值,怎樣估算 [1.7, 1.9] 內有多少個值呢?一個常用的方法是假設這個範圍的值是連續且均勻的,那麼我們就可以按照查範圍佔桶的比例去估算,也就是 (1.9 – 1.7) / (1.9 – 1.6) * 3 = 2。

不過這裡還有一個問題是估算的時候要去算比例,這對於數值型別很簡單,對於其他型別,比方說字串型別怎麼辦呢?一個方法是把字串對映成數字,然後計算比例,具體可以參見 statistics/scalar.go

2. 等值查詢

對於類似查詢等於某個值的這樣的等值查詢,直方圖就捉襟見肘了。一般常用的估計方法是假設每個值出現的次數都相等,這樣就可以用(總行數/不同值的數量)來估計。不過在 TiDB 中,我們選擇了 Count-Min Sketch 的來進行等值查詢的估算。

由於 Count-Min Sketch 估計的結果總是不小於實際值,因此在 TiDB 中,我們選擇了文獻 New estimation algorithms for streaming data: Count-min can do more 中提出了一種 Count-Mean-Min Sketch,其與 Count-Min Sketch 在更新的時候是一樣的,區別在與查詢的時候:對於每一行 i,若 hash 函式對映到了值 j,那麼用 (N - CM[i, j]) / (w-1)(N 是總共的插入的值數量)作為其他值產生的噪音,因此用 CM[i,j] - (N - CM[i, j]) / (w-1) 這一行的估計值,然後用所有行的估計值的中位數作為最後的估計值。

3. 多列查詢

上面兩個小節介紹了 TiDB 是如何對單列上的查詢條件進行估計的,不過實際的查詢語句中往往包含多個列上的多個查詢條件,因此我們需要考慮如何處理多列的情況。在 TiDB 中,selectivity.go 中的 Selectivity 函式實現了這個功能,它是統計資訊模組對優化器提供的最重要的介面。

在處理多列之間的查詢條件的時候,一個常見的做法是認為不同列之間是相互獨立的,因此我們只需要把不同列之間的過濾率乘起來。不過,對於索引上的可以用來構造索引掃描的範圍的過濾條件,即對於一個 (a, b, c) 這樣的索引,類似 (a = 1 and b = 1 and c < 5) 或者 (a = 1 and b = 1) 這樣的條件,將索引中的值編碼後,就可以用前面提到的方法進行估算,這樣就不需要假定列之間是相互獨立的。

因此,Selectivity 的一個最重要的任務就是將所有的查詢條件分成儘量少的組,使得每一組中的條件都可以用某一列或者某一索引上的統計資訊進行估計,這樣我們就可以做盡量少的獨立性假設。

在 Selectivity 中,首先計算了每一列和每一個索引可以覆蓋的過濾條件,並用一個 int64來當做一個 bitset,將該列可以覆蓋的過濾條件的位置置為 1。接下來的任務就是選擇儘量少的 bitset,來覆蓋儘量多的過濾條件,在這一步中,我們使用了貪心演算法,即每一次在還沒有使用的 bitset 中,選擇一個可以覆蓋最多尚未覆蓋的過濾條件。最後一步便是用前面提到的方法對每一個列和每一個索引上的統計資訊進行估計,並用獨立性假設將它們組合起來當做最終的結果。

TiDB 原始碼閱讀系列文章:

(十一)Index Lookup Join

(十)Chunk 和執行框架簡介

(九)Hash Join

(八)基於代價的優化

(七)基於規則的優化

(六)Select 語句概覽

(五)TiDB SQL Parser 的實現

(四)Insert 語句概覽

(三)SQL 的一生

(二)初識 TiDB 原始碼

(一)序

相關文章