TokuDB的索引結構–分形樹的實現

分形樹簡介

原文:http://www.bitstech.net/2015/12/15/tokudb-index-introduction/

分形樹是一種寫優化的磁盤索引數據結構。 在一般情況下, 分形樹的寫操作(Insert/Update/Delete)性能比較好,同時它還能保證讀操作近似於B+樹的讀性能。據Percona公司測試結果顯示, TokuDB分形樹的寫性能優於InnoDB的B+樹), 讀性能略低於B+樹。 類似的索引結構還有LSM-Tree, 但是LSM-Tree的寫性能遠優於讀性能。

工業界實現分形樹最重要的產品就是Tokutek公司開發的ft-index(Fractal Tree Index)鍵值對存儲引擎。這個項目自2007年開始研發,一直到2013年開源,代碼目前託管在Github上。開源協議採用 GNU General Public License授權。 Tokutek公司爲了充分發揮ft-index存儲引擎的威力,基於K-V存儲引擎之上,實現了MySQL存儲引擎插件提供所有API接口,用來作爲MySQL的存儲引擎, 這個項目稱之爲TokuDB, 同時還實現了MongoDB存儲引擎的API接口,這個項目稱之爲TokuMX。在2015年4月14日, Percona公司宣佈收購Tokutek公司, ft-index/TokuDB/TokuMX這一系列產品被納入Percona公司的麾下。自此, Percona公司宣稱自己成爲第一家同時提供MySQL和MongoDB軟件及解決方案的技術廠商。

本文主要討論的是TokuDB的ft-index。 ft-index相比B+樹的幾個重要特點有:

  • 從理論複雜度和測試性能兩個角度上看, ft-index的Insert/Delete/Update操作性能優於B+樹。 但是讀操作性能低於B+樹。
  • ft-index採用更大的索引頁和數據頁(ft-index默認爲4M, InnoDB默認爲16K), 這使得ft-index的數據頁和索引頁的壓縮比更高。也就是說,在打開索引頁和數據頁壓縮的情況下,插入等量的數據, ft-index佔用的存儲空間更少。
  • ft-index支持在線修改DDL (Hot Schema Change)。 簡單來講,就是在做DDL操作的同時(例如添加索引),用戶依然可以執行寫入操作, 這個特點是ft-index樹形結構天然支持的。 由於篇幅限制,本文並不對Hot Schema Change的實現做具體描述。

此外, ft-index還支持事務(ACID)以及事務的MVCC(Multiple Version Cocurrency Control 多版本併發控制), 支持崩潰恢復。

正因爲上述特點, Percona公司宣稱TokuDB一方面帶給客戶極大的性能提升, 另一方面還降低了客戶的存儲使用成本。

 

ft-index的磁盤存儲結構

ft-index的索引結構圖如下(在這裏爲了方便描述和理解,我對ft-index的二進制存儲做了一定程度簡化和抽象,【右擊】->【在新標籤打開】 可以查看大圖):

在下圖中, 灰色區域表示ft-index分形樹的一個頁,綠色區域表示一個鍵值,兩格綠色區域之間表示一個兒子指針。 BlockNum表示兒子指針指向的頁的偏移量。Fanout表示分形樹的扇出,也就是兒子指針的個數。 NodeSize表示一個頁佔用的字節數。NonLeafNode表示當前頁是一個非葉子節點,LeafNode表示當前頁是一個葉子節點,葉子節點是最底層的存放Key-value鍵值對的節點, 非葉子節點不存放value。 Heigth表示樹的高度, 根節點的高度爲3, 根節點下一層節點的高度爲2, 最底層葉子節點的高度爲1。Depth表示樹的深度,根節點的深度爲0, 根節點的下一層節點深度爲1。

 

 

分形樹的樹形結構非常類似於B+樹, 它的樹形結構由若干個節點組成(我們稱之爲Node或者Block,在InnoDB中,我們稱之爲Page或者頁)。 每個節點由一組有序的鍵值組成。假設一個節點的鍵值序列爲[3, 8], 那麼這個鍵值將(-00, +00)整個區間劃分爲(-00, 3), [3, 8), [8, +00) 這樣3個區間, 每一個區間就對應着一個兒子指針(Child指針)。 在B+樹中, Child指針一般指向一個頁, 而在分形樹中,每一個Child指針除了需要指向一個Node的地址(BlockNum)之外,還會帶有一個Message Buffer (msg_buffer), 這個Message Buffer 是一個先進先出(FIFO)的隊列,用來存放Insert/Delete/Update/HotSchemaChange這樣的更新操作。

按照ft-index源代碼的實現, 對ft-index中分形樹更爲嚴謹的說法是這樣的:

  • 節點(block或者node, 在InnoDB中我們稱之爲Page或者頁)是由一組有序的鍵值組成, 第一個鍵值設置爲null鍵值, 表示負無窮大。
  • 節點分爲兩種類型,一種是葉子節點, 一種是非葉子節點。 葉子節點的兒子指針指向的是BasementNode, 非葉子節點指向的是正常的Node 。 這裏的BasementNode節點存放的是多個K-V鍵值對, 也就是說最後所有的查找操作都需要定位到BasementNode才能成功獲取到數據(Value)。這一點也和B+樹的LeafPage類似, 數據(Value)都是存放在葉子節點, 非葉子節點用來存放鍵值(Key)做索引。 當葉子節點加載到內存後,爲了快速查找到BasementNode中的數據(Value), ft-index會把整個BasementNode中的key-value都轉換爲一棵弱平衡二叉樹, 這棵平衡二叉樹有一個很逗逼的名字,叫做替罪羊樹, 這裏不再展開。
  • 每個節點的鍵值區間對應着一個兒子指針(Child Pointer)。 非葉子節點的兒子指針攜帶着一個MessageBuffer, MessageBuffer是一個FIFO隊列。用來存放Insert/Delete/Update/HotSchemaChange這樣的更新操作。兒子指針以及MessageBuffer都會序列化存放在Node的磁盤文件中。
  • 每個非葉子節點(Non Leaf Node)兒子指針的個數必須在[fantout/4, fantout]這個區間之內。 這裏fantout是分形樹(B+樹也有這個概念)的一個參數,這個參數主要用來維持樹的高度。當一個非葉子節點的兒子指針個數小於fantout/4 , 那麼我們認爲這個節點的太空虛了,需要和其他節點合併爲一個節點(Node Merge), 這樣能減少整個樹的高度。當一個非葉子節點的兒子指針個數超過fantout, 那麼我們認爲這個節點太飽滿了, 需要將一個節點一拆爲二(Node Split)。 通過這種約束控制,理論上就能將磁盤數據維持在一個正常的相對平衡的樹形結構,這樣可以控制插入和查詢複雜度上限。

注意: 在ft-index實現中,控制樹平衡的條件更加複雜, 例如除了考慮fantout之外,還要保證節點總字節數在[NodeSize/4, NodeSize]這個區間, NodeSize一般爲4M ,當不在這個區間時, 需要做對應的合併(Merge)或者分裂(Split)操作。

 

分形樹的Insert/Delete/Update實現

在前文中,我們說到分形樹是一種寫優化的數據結構, 它的寫操作性能要優於B+樹的寫操作性能。 那麼它究竟如何做到更優的寫操作性能呢?

首先, 這裏說的寫操作性能,指的是隨機寫操作。 舉個簡單例子,假設我們在MySQL的InnoDB表中不斷執行這個SQL語句: insert into sbtest set x = uuid(), 其中sbtest表中有一個唯一索引字段爲x。 由於uuid()的隨機性,將導致插入到sbtest表中的數據散落在各個不同的葉子節點(Leaf Node)中。 在B+樹中, 大量的這種隨機寫操作將導致LRU-Cache中大量的熱點數據頁落在B+樹的上層(如下圖所示)。這樣底層的葉子節點命中Cache的概率降低,從而造成大量的磁盤IO操作, 也就導致B+樹的隨機寫性能瓶頸。但B+樹的順序寫操作很快,因爲順序寫操作充分利用了局部熱點數據, 磁盤IO次數大大降低。

 

下面來說說分形樹插入操作的流程。 爲了方便後面描述,約定如下:

a. 我們以Insert操作爲例, 假定插入的數據爲(Key, Value);
b. 下文說的 加載節點(Load Page),都是先判斷該節點是否命中LRU-Cache。僅當緩存不命中時, ft-index纔會通過seed定位到偏移量讀取數據頁到內存;
c. 爲體現核心流程, 我們暫時不考慮崩潰日誌和事務處理。

詳細流程如下:

  1. 加載Root節點;
  2. 判斷Root節點是否需要分裂(或合併),如果滿足分裂(或者合併)條件,則分裂(或者合併)Root節點。 具體分裂Root節點的流程,感興趣的同學可以開開腦洞。
  3. 當Root節點height>0, 也就是Root是非葉子節點時, 通過二分搜索找到Key所在的鍵值區間Range,將(Key, Value)包裝成一條消息(Insert, Key, Value) , 放入到鍵值區間Range對應的Child指針的Message Buffer中。
  4. 當Root節點height=0時,即Root是葉子節點時, 將消息(Insert, Key, Value) 應用(Apply)到BasementNode上, 也就是插入(Key, Value)到BasementNode中。

這裏有一個非常詭異的地方,在大量的插入(包括隨機和順序插入)情況下, Root節點會經常性的被撐飽滿,這將會導致Root節點做大量的分裂操作。然後,Root節點做了大量的分裂操作之後,產生大量的height=1的節點, 然後height=1的節點被撐爆滿之後,又會產生大量height=2的節點, 最終樹的高度越來越高。 這個詭異的之處就隱藏了分形樹寫操作性能比B+樹高的祕訣: 每一次插入操作都落在Root節點就馬上返回了, 每次寫操作並不需要搜索樹形結構最底層的BasementNode, 這樣會導致大量的熱點數據集中落在在Root節點的上層(此時的熱點數據分佈圖類似於上圖), 從而充分利用熱點數據的局部性,大大減少了磁盤IO操作。

Update/Delete操作的情況和Insert操作的情況類似, 但是需要特別注意的地方在於,由於分形樹隨機讀性能並不如InnoDB的B+樹(後文會詳細描述)。因此,Update/Delete操作需要細分爲兩種情況考慮,這兩種情況測試性能可能差距巨大:

  • 覆蓋式的Update/Delete (overwrite)。 也就是當key存在時, 執行Update/Delete; 當key不存在時,不做任何操作,也不需要報錯。
  • 嚴格匹配的Update/Delete。 當key存在時, 執行update/delete ; 當key不存在時, 需要報錯給上層應用方。 在這種情況下,我們需要先查詢key是否存在於ft-index的basementnode中,於是Point-Query默默的拖了Update/Delete操作的性能後退。

此外,ft-index爲了提升順序寫的性能,對順序插入操作做了一些優化,例如順序寫加速, 這裏不再展開。

 

分形樹的Point-Query實現

在ft-index中, 類似select from table where id = ? (其中id是索引)的查詢操作稱之爲Point-Query; 類似selectfrom table where id >= ? and id <= ? (其中id是索引)的查詢操作稱之爲Range-Query。 上文已經提到, Point-Query讀操作性能並不如InnoDB的B+樹, 這裏詳細描述Point-Query的相關流程。 (這裏假設要查詢的鍵值爲Key)

  1. 加載Root節點,通過二分搜索確定Key落在Root節點的鍵值區間Range, 找到對應的Range的Child指針。
  2. 加載Child指針對應的的節點。 若該節點爲非葉子節點,則繼續沿着分形樹一直往下查找,一直到葉子節點停止。 若當前節點爲葉子節點,則停止查找。

查找到葉子節點後,我們並不能直接返回葉子節點中的BasementNode的Value給用戶。 因爲分形樹的插入操作是通過消息(Message)的方式插入的, 此時需要把從Root節點到葉子節點這條路徑上的所有消息依次apply到葉子節點的BasementNode。 待apply所有的消息完成之後,查找BasementNode中的key對應的value,就是用戶需要查找的值。

分形樹的查找流程基本和 InnoDB的B+樹的查找流程類似, 區別在於分形樹需要將從Root節點到葉子節點這條路徑上的messge buffer都往下推(下推的具體流程請參考代碼,這裏不再展開),並將消息apply到BasementNode節點上。注意查找流程需要下推消息, 這可能會造成路徑上的部分節點被撐飽滿,但是ft-index在查詢過程中並不會對葉子節點做分裂和合並操作, 因爲ft-index的設計原則是: Insert/Update/Delete操作負責節點的Split和Merge, Select操作負責消息的延遲下推(Lazy Push)。 這樣,分形樹就將Insert/Delete/Update這類更新操作通過未來的Select操作應用到具體的數據節點,從而完成更新。

 

分形樹的Range-Query實現

下面來介紹Range-Query的查詢實現。簡單來講, 分形樹的Range-Query基本等價於進行N次Point-Query操作,操作的代價也基本等價於N次Point-Query操作的代價。 由於分形樹在非葉子節點的msg_buffer中存放着BasementNode的更新操作,因此我們在查找每一個Key的Value時,都需要從根節點查找到葉子節點, 然後將這條路徑上的消息apply到basenmentNode的Value上。 這個流程可以用下圖來表示。

 

但是在B+樹中, 由於底層的各個葉子節點都通過指針組織成一個雙向鏈表, 結構如下圖所示。 因此,我們只需要從跟節點到葉子節點定位到第一個滿足條件的Key, 然後不斷在葉子節點迭代next指針,即可獲取到Range-Query的所有Key-Value鍵值。因此,對於B+樹的Range-Query操作來說,除了第一次需要從root節點遍歷到葉子節點做隨機寫操作,後繼數據讀取基本可以看做是順序IO。

 

通過比較分形樹和B+樹的Range-Query實現可以發現, 分形樹的Range-Query查詢代價明顯比B+樹代價高,因爲分型樹需要遍歷Root節點的覆蓋Range的整顆子樹,而B+樹只需要一次Seed到Range的起始Key,後續迭代基本等價於順序IO。

 

總結

本文以分形樹的樹形結構爲切入點,詳細介紹分形樹的增刪改查操作。總體來說,分形樹是一種寫優化的數據結構,它的核心思想是利用節點的MessageBuffer緩存更新操作,充分利用數據局部性原理, 將隨機寫轉換爲順序寫,這樣極大的提高了隨機寫的效率。Tokutek研發團隊的iiBench測試結果顯示: TokuDB的insert操作(隨機寫)的性能比InnoDB快很多,而Select操作(隨機讀)的性能低於InnoDB的性能,但是差距較小,同時由於TokuDB採用有4M的大頁存儲,使得壓縮比較高。這也是Percona公司宣稱TokuDB更高性能,更低成本的原因。

另外,在線更新表結構(Hot Schema Change)實現也是基於MessageBuffer來實現的, 但和Insert/Delete/Update操作不同的是, 前者的消息下推方式是廣播式下推(父節點的一條消息,應用到所有的兒子節點), 後者的消息下推方式單播式下推(父節點的一條消息,應用到對應鍵值區間的兒子節點), 由於實現類似於Insert操作,所以不再展開描述。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章