MySQL 高效能儲存引擎:TokuDB初探

標點符發表於2016-12-19

在安裝MariaDB的時候瞭解到代替InnoDB的TokuDB,看簡介非常的棒,這裡對ToduDB做一個初步的整理,使用後再做更多的分享。

什麼是TokuDB?

在MySQL最流行的支援全事務的引擎為INNODB。其特點是資料本身是用B-TREE來組織,資料本身即是龐大的根據主鍵聚簇的B-TREE索引。 所以在這點上,寫入速度就會有些降低,因為要每次寫入要用一次IO來做索引樹的重排。特別是當資料量本身比記憶體大很多的情況下,CPU本身被磁碟IO糾纏的做不了其他事情了。這時我們要考慮如何減少對磁碟的IO來排解CPU的處境,常見的方法有:

  • 把INNODB 個PAGE增大(預設16KB),但增大也就帶來了一些缺陷。 比如,對磁碟進行CHECKPOINT的時間將延後。
  • 把日誌檔案放到更快速的磁碟上,比如SSD。

TokuDB 是一個支援事務的“新”引擎,有著出色的資料壓縮功能,由美國 TokuTek 公司(現在已經被 Percona 公司收購)研發。擁有出色的資料壓縮功能,如果您的資料寫多讀少,而且資料量比較大,強烈建議您使用TokuDB,以節省空間成本,並大幅度降低儲存使用量和IOPS開銷,不過相應的會增加 CPU 的壓力。

TokuDB 的特性

1.豐富的索引型別以及索引的快速建立

TokuDB 除了支援現有的索引型別外, 還增加了(第二)集合索引, 以滿足多樣性的覆蓋索引的查詢, 在快速建立索引方面提高了查詢的效率

2.(第二)集合索引

也可以稱作非主鍵的集合索引, 這類索引也包含了表中的所有列, 可以用於覆蓋索引的查詢需要, 比如以下示例, 在where 條件中直接命中 index_b 索引, 避免了從主鍵中再查詢一次。

見: http://tokutek.com/2009/05/introducing_multiple_clustering_indexes/

3.索引線上建立(Hot Index Creation)

TokuDB 允許直接給表增加索引而不影響更新語句(insert, update 等)的執行。可以通過變數 tokudb_create_index_online 來控制是否開啟該特性, 不過遺憾的是目前還只能通過 CREATE INDEX 語法實現線上建立, 不能通過 ALTER TABLE 實現. 這種方式比通常的建立方式慢了許多, 建立的過程可以通過 show processlist 檢視。不過 tokudb 不支援線上刪除索引, 刪除索引的時候會對標加全域性鎖。

4.線上更改列(Add, Delete, Expand, Rename)

TokuDB 可以在輕微阻塞更新或查詢語句的情況下, 允許實現以下操作:

  • 增加或刪除表中的列
  • 擴充欄位: char, varchar, varbinary 和 int 型別的列
  • 重新命名列, 不支援欄位型別: TIME, ENUM, BLOB, TINYBLOB, MEDIUMBLOB, LONGBLOB

這些操作通常是以表鎖級別阻塞(幾秒鐘時間)其他查詢的執行, 當表記錄下次從磁碟載入到記憶體的時候, 系統就會隨之對記錄進行修改操作(add, delete 或 expand), 如果是 rename 操作, 則會在幾秒鐘的停機時間內完成所有操作。

TokuDB的這些操作不同於 InnoDB, 對錶進行更新後可以看到 rows affected 為 0, 即更改操作會放到後臺執行, 比較快速的原因可能是由於 Fractal-tree 索引的特性, 將隨機的 IO 操作替換為順序 IO 操作, Fractal-tree的特性中, 會將這些操作廣播到所有行, 不像 InnoDB, 需要 open table 並建立臨時表來完成.

看看官方對該特性的一些指導說明:

  • 所有的這些操作不是立即執行, 而是放到後臺中由 Fractal Tree 完成, 操作包括主鍵和非主鍵索引。也可以手工強制執行這些操作, 使用 OPTIMIZE TABLE X 命令即可, TokuDB 從1.0 開始OPTIMIZE TABLE命令也支援線上完成, 但是不會重建索引
  • 不要一次更新多列, 分開對每列進行操作
  • 避免同時對一列進行 add, delete, expand 或 drop 操作
  • 表鎖的時間主要由快取中的髒頁(dirty page)決定, 髒頁越多 flush 的時間就越長. 每做一次更新, MySQL 都會關閉一次表的連線以釋放之前的資源
  • 避免刪除的列是索引的一部分, 這類操作會特別慢, 非要刪除的話可以去掉索引和該列的關聯再進行刪除操作
  • 擴充類的操作只支援 char, varchar, varbinary 和 int 型別的欄位
  • 一次只 rename 一列, 操作多列會降級為標準的 MySQL 行為, 語法中列的屬性必須要指定上, 如下:

    rename 操作還不支援欄位: TIME, ENUM, BLOB, TINYBLOB, MEDIUMBLOB, LONGBLOB.
  • 不支援更新臨時表;

5.資料壓縮

TokuDB中所有的壓縮操作都在後臺執行, 高階別的壓縮會降低系統的效能, 有些場景下會需要高階別的壓縮. 按照官方的建議: 6核數以下的機器建議標準壓縮, 反之可以使用高階別的壓縮。

每個表在 create table 或 alter table 的時候通過 ROW_FORMAT 來指定壓縮的演算法:

ROW_FORMAT預設由變數 tokudb_row_format 控制, 預設為 tokudb_zlib, 可以的值包括:

  • tokudb_zlib: 使用 zlib 庫的壓縮模式,提供了中等級別的壓縮比和中等級別的CPU消耗。
  • tokudb_quicklz: 使用 quicklz 庫的壓縮模式, 提供了輕量級的壓縮比和較低基本的CPU消耗。
  • tokudb_lzma: 使用lzma庫壓縮模式,提供了高壓縮比和高CPU消耗。
  • tokudb_uncompressed: 不使用壓縮模式。

6.Read free 複製特性

得益於 Fracal Tree 索引的特性, TokuDB 的 slave 端能夠以低於讀IO的消耗來應用 master 端的變化, 其主要依賴 Fractal Tree 索引的特性,可以在配置裡啟用特性

  • insert/delete/update操作部分可以直接插入到合適的 Fractal Tree 索引中, 避免 read-modify-write 行為的開銷;
  • delete/update 操作可以忽略唯一性檢查帶來的 IO 方面的開銷

不好的是, 如果啟用了 Read Free Replication 功能, Server 端需要做如下設定:

  • master:複製格式必須為 ROW, 因為 tokudb 還沒有實現對 auto-increment函式進行加鎖處理, 所以多個併發的插入語句可能會引起不確定的 auto-increment值, 由此造成主從兩邊的資料不一致.
  • slave:開啟 read-only; 關閉唯一性檢查(set tokudb_rpl_unique_checks=0);關閉查詢(read-modify-write)功能(set tokudb_rpl_lookup_rows=0);

slave 端的設定可以在一臺或多臺 slave 中設定:MySQL5.5 和 MariaDB5.5中只有定義了主鍵的表才能使用該功能, MySQL 5.6, Percona 5.6 和 MariaDB 10.X 沒有此限制

7.事務, ACID 和恢復

  • 預設情況下, TokuDB 定期檢查所有開啟的表, 並記錄 checkpoint 期間所有的更新, 所以在系統崩潰的時候, 可以恢復表到之前的狀態(ACID-compliant), 所有的已提交的事務會更新到表裡,未提交的事務則進行回滾. 預設的檢查週期每60s一次, 是從當前檢查點的開始時間到下次檢查點的開始時間, 如果 checkpoint 需要更多的資訊, 下次的checkpoint 檢查會立即開始, 不過這和 log 檔案的頻繁重新整理有關. 使用者也可以在任何時候手工執行 flush logs 命令來引起一次 checkpoint 檢查; 在資料庫正常關閉的時候, 所有開啟的事務都會被忽略.
  • 管理日誌的大小: TokuDB 一直儲存最近的checkpoing到日誌檔案中, 當日志達到100M的時候, 會起一個新的日誌檔案; 每次checkpoint的時候, 日誌中舊於當前檢查點的都會被忽略, 如果檢查的週期設定非常大, 日誌的清理頻率也會減少。 TokuDB也會為每個開啟的事務維護回滾日誌, 日誌的大小和事務量有關, 被壓縮儲存到磁碟中, 當事務結束後,回滾日誌會被相應清理.
  • 恢復: TokuDB自動進行恢復操作, 在崩潰後使用日誌和回滾日誌進行恢復, 恢復時間由日誌大小(包括未壓縮的回滾日誌)決定.
  • 禁用寫快取: 如果要保證事務安全, 就得考慮到硬體方面的寫快取. TokuDB 在 MySQL 裡也支援事務安全特性(transaction safe), 對系統而言, 資料庫更新的資料不一樣真的寫到磁碟裡, 而是快取起來, 在系統崩潰的時候還是會出現丟資料的現象, 比如TokuDB不能保證掛載的NFS卷可以正常恢復, 所以如果要保證安全,最好關閉寫快取, 但是可能會造成效能的降低.通常情況下需要關閉磁碟的寫快取, 不過考慮到效能原因, XFS檔案系統的快取可以開啟, 不過穿線錯誤”Disabling barriers”後,就需要關閉快取. 一些場景下需要關閉檔案系統(ext3)快取, LVM, 軟RAID 和帶有 BBU(battery-backed-up) 特性的RAID卡

8.過程追蹤

TokuDB 提供了追蹤長時間執行語句的機制. 對 LOAD DATA 命令來說,SHOW PROCESSLIST 可以顯示過程資訊, 第一個是類似 “Inserted about 1000000 rows” 的狀態資訊, 下一個是完成百分比的資訊, 比如 “Loading of data about 45% done”; 增加索引的時候, SHOW PROCESSLIST 可以顯示 CREATE INDEX 和 ALTER TABLE 的過程資訊, 其會顯示行數的估算值, 也會顯示完成的百分比; SHOW PROCESSLIST 也會顯示事務的執行情況, 比如 committing 或 aborting 狀態.

9.遷移到 TokuDB

可以使用傳統的方式更改表的儲存引擎, 比如 “ALTER TABLE … ENGINE = TokuDB” 或 mysqldump 匯出再倒入, INTO OUTFILE 和 LOAD DATA INFILE 的方式也可以。

10.熱備

Percona Xtrabackup 還未支援 TokuDB 的熱備功能, percona 也為表示有支援的打算 http://www.percona.com/blog/2014/07/15/tokudb-tips-mysql-backups/ ;對於大表可以使用 LVM 特性進行備份, https://launchpad.net/mylvmbackup , 或 mysdumper 進行備份。TokuDB 官方提供了一個熱備外掛 tokudb_backup.so, 可以進行線上備份, 詳見 https://github.com/Tokutek/tokudb-backup-plugin, 不過其依賴 backup-enterprise, 無法編譯出 so 動態庫, 是個商業的收費版本, 見 https://www.percona.com/doc/percona-server/5.6/tokudb/tokudb_installation.html

總結

TokuDB的優點:

  • 高壓縮比,預設使用zlib進行壓縮,尤其是對字串(varchar,text等)型別有非常高的壓縮比,比較適合儲存日誌、原始資料等。官方宣稱可以達到1:12。
  • 線上新增索引,不影響讀寫操作
  • HCADER 特性,支援線上欄位增加、刪除、擴充套件、重新命名操作,(瞬間或秒級完成)
  • 支援完整的ACID特性和事務機制
  • 非常快的寫入效能, Fractal-tree在事務實現上有優勢,無undo log,官方稱至少比innodb高9倍。
  • 支援show processlist 進度檢視
  • 資料量可以擴充套件到幾個TB;
  • 不會產生索引碎片;
  • 支援hot column addition,hot indexing,mvcc

TokuDB缺點:

  • 不支援外來鍵(foreign key)功能,如果您的表有外來鍵,切換到 TokuDB引擎後,此約束將被忽略。
  • TokuDB 不適大量讀取的場景,因為壓縮解壓縮的原因。CPU佔用會高2-3倍,但由於壓縮後空間小,IO開銷低,平均響應時間大概是2倍左右。
  • online ddl 對text,blob等型別的欄位不適用
  • 沒有完善的熱備工具,只能通過mysqldump進行邏輯備份

適用場景:

  • 訪問頻率不高的資料或歷史資料歸檔
  • 資料表非常大並且時不時還需要進行DDL操作

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

TokuDB和InnoDB最大的不同在於TokuDB採用了一種叫做Fractal Tree的索引結構,使其在隨機寫資料的處理上有很大提升。目前無論是SQL Server,還是MySQL的innodb,都是用的B+Tree(SQL Server用的是標準的B-Tree)的索引結構。InnoDB是以主鍵組織的B+Tree結構,資料按照主鍵順序排列。對於順序的自增主鍵有很好的效能,但是不適合隨機寫入,大量的隨機I/O會使資料頁分裂產生碎片,索引維護開銷很多大。TokuDB解決隨機寫入的問題得益於其索引結構,Fractal Tree 和 B-Tree的差別主要在於索引樹的內部節點上,B-Tree索引的內部結構只有指向父節點和子節點的指標,而Fractal Tree的內部節點不僅有指向父節點和子節點的指標,還有一塊Buffer區。當資料寫入時會先落到這個Buffer區上,該區是一個FIFO結構,寫是一個順序的過程,和其他緩衝區一樣,滿了就一次性刷寫資料。所以TokuDB上插入資料基本上變成了一個順序新增的過程。

BTree和Fractal tree的比較:

Structure Inserts Point Queries Range Queries
B-Tree Horrible Good Good (young)
Append Wonderful Horrible Horrible
Fractal Tree Good Good Good

Fractal tree(分形樹)簡介

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

ft-index的磁碟儲存結構

ft-index採用更大的索引頁和資料頁(ft-index預設為4M, InnoDB預設為16K), 這使得ft-index的資料頁和索引頁的壓縮比更高。也就是說,在開啟索引頁和資料頁壓縮的情況下,插入等量的資料, ft-index佔用的儲存空間更少。ft-index支援線上修改DDL (Hot Schema Change)。 簡單來講,就是在做DDL操作的同時(例如新增索引),使用者依然可以執行寫入操作, 這個特點是ft-index樹形結構天然支援的。 此外, ft-index還支援事務(ACID)以及事務的MVCC(Multiple Version Cocurrency Control 多版本併發控制), 支援崩潰恢復。正因為上述特點, Percona公司宣稱TokuDB一方面帶給客戶極大的效能提升, 另一方面還降低了客戶的儲存使用成本。

ft-index的索引結構圖如下:

MySQL 高效能儲存引擎:TokuDB初探

灰色區域表示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次數大大降低。

MySQL 高效能儲存引擎:TokuDB初探

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

  • 以Insert操作為例, 假定插入的資料為(Key, Value)
  • 載入節點(Load Page),都是先判斷該節點是否命中LRU-Cache。僅當快取不命中時, ft-index才會通過seed定位到偏移量讀取資料頁到記憶體
  • 暫時不考慮崩潰日誌和事務處理。

詳細流程如下:

  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; 類似select from 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上。 這個流程可以用下圖來表示。

MySQL 高效能儲存引擎:TokuDB初探

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

MySQL 高效能儲存引擎:TokuDB初探

通過比較分形樹和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操作,所以不再展開描述。

TokuDB的多版本併發控制(MVCC)

在傳統的關係型資料庫(例如Oracle, MySQL, SQLServer)中,事務可以說是研發和討論最核心內容。而事務最核心的性質就是ACID。

  • A表示原子性,也就是組成事務的所有子任務只有兩種結果:要麼隨著事務的提交,所有子任務都成功執行;要麼隨著事務的回滾,所有子任務都撤銷。
  • C表示一致性,也就是無論事務提交或者回滾,都不能破壞資料的一致性約束,這些一致性約束包括鍵值唯一約束、鍵值關聯關係約束等。
  • I表示隔離性,隔離性一般是針對多個併發事務而言的,也就是在同一個時間點,t1事務和t2事務讀取的資料應該是隔離的,這兩個事務就好像進了同一酒店的兩間房間一樣,各自在各自的房間裡面活動,他們相互之間並不能看到各自在幹嘛。
  • D表示永續性,這個性質保證了一個事務一旦承諾使用者成功提交,那麼即便是後繼資料庫程式crash或者作業系統crash,只要磁碟資料沒壞,那麼下次啟動資料庫後,這個事務的執行結果仍然可以讀取到。

TokuDB目前完全支援事務的ACID。 從實現上看, 由於TokuDB採用的分形樹作為索引,而InnoDB採用B+樹作為索引結構,因而TokuDB在事務的實現上和InnoDB有很大不同。

在InnoDB中, 設計了redo和undo兩種日誌,redo存放頁的物理修改日誌,用來保證事務的永續性; undo存放事務的邏輯修改日誌,它實際存放了一條記錄在多個併發事務下的多個版本,用來實現事務的隔離性(MVCC)和回滾操作。由於TokuDB的分形樹採用訊息傳遞的方式來做增刪改更新操作,一條訊息就是事務對該記錄修改的一個版本,因此,在TokuDB原始碼實現中,並沒有額外的undo-log的概念和實現,取而代之的是一條記錄多條訊息的管理機制。雖然一條記錄多條訊息的方式可以實現事務的MVCC,卻無法解決事務回滾的問題,因此TokuDB額外設計了tokudb.rollback這個日誌檔案來做幫助實現事務回滾。

這裡主要分析TokuDB的事務隔離性的實現,也就是常提到的多版本併發控制(MVCC)。

TokuDB的事務表示

在tokudb中, 在使用者執行的一個事務,具體到儲存引擎層面會被拆開成許多個小事務(這種小事務記為txn)。 例如使用者執行這樣一個事務:

對應到TokuDB儲存引擎的redo-log中的記錄為:

對應的事務樹如下圖所示:

MySQL 高效能儲存引擎:TokuDB初探

對一個較為複雜一點,帶有savepoint的事務例子:

對應的redo-log的記錄為:

這個事務組成的一棵事務樹如下:

MySQL 高效能儲存引擎:TokuDB初探

在tokudb中,使用{parent_id, child_id}這樣一個二元組來記錄一個txn和其他txn的依賴關係。這樣從根事務到葉子幾點的一組標號就可以唯一標示一個txn, 這一組標號列表稱之為xids, xids我認為也可以稱為事務號。 例如txn3的xids = {17, 2, 3 } , txn2的xids = {17, 2}, txn1的xids= {17, 1}, txn0的xids = {17, 0}。

於是對於事務中的每一個操作(xbegin/xcommit/enq_insert/xprepare),都有一個xids來標識這個操作所在的事務號。 TokuDB中的每一條訊息(insert/delete/update訊息)都會攜帶這樣一個xids事務號。這個xids事務號,在TokuDB的實現中扮演這非常重要的角色,與之相關的功能也特別複雜。

事務管理器

事務管理器用來管理TokuDB儲存引擎所有事務集合, 它主要維護著這幾個資訊:

  • 活躍事務列表。活躍事務列表只會記錄root事務,因為根據root事務其實可以找到整棵事務樹的所有child事務。 這個事務列表儲存這當前時間點已經開始,但是尚未結束的所有root事務。
  • 映象讀事務列表(snapshot read transaction)。
  • 活躍事務的引用列表(referenced_xids)。這個概念有點不好理解,假設一個活躍事務開始(xbegin)時間點為begin_id, 提交(xcommit)的時間點為end_id。那麼referenced_xids就是維護(begin_id, end_id)這樣一個二元組,這個二元組的用處就是可以找到一個事務的整個生命週期的所有活躍事務,用處主要是用來做後文說到的full gc操作。

分形樹LeafEntry

上文分形樹的樹形結構中說到,在做insert/delete/update這樣的操作時,會把從root到leaf的所有訊息都apply到LeafNode節點中。 為了後面詳細描述apply的過程,先介紹下LeafNode的儲存結構。

leafNode簡單來說,就是由多個leafEntry組成,每個leafEntry就是一個{k, v1, v2, … }這樣的鍵值對, 其中v1, v2 .. 表示一個key對應的值的多個版本。具體到一個key對應得leafEntry的結構詳細如下圖所示。

MySQL 高效能儲存引擎:TokuDB初探

由上圖看出,一個leafEntry其實就是一個棧, 這個棧底部[0~5]這一段表示已經提交(commited transaction)的事務的Value值。棧的頂部[6~9]這一段表示當前尚未提交的活躍事務(uncommited transaction)。 棧中存放的單個元素為(txid, type, len, data)這樣一個四元組,表明了這個事務對應的value取值。更通用一點講,[0, cxrs-1]這一段棧表示已經提交的事務,本來已經提交的事務不應存在於棧中,但之所以存在,就是因為有其他事務通過snapshot read的方式引用了這些事務,因此,除非所有引用[0, cxrs-1]這段事務的所有事務都提交,否則[0, cxrs-1]這段棧的事務就不會被回收。[cxrs, cxrs+pxrs-1]這一段棧表示當前活躍的尚未提交的事務列表,當這部分事務提交時,cxrs會往後移動,最終到棧頂。

MVCC實現

1)寫入操作

這裡我們認為寫入操作包括三種,分別為insert / delete / commit 三種型別。對於insert和delete這兩種型別的寫入操作,只需要在LeafEntry的棧頂放置一個元素即可。 如下圖所示:

MySQL 高效能儲存引擎:TokuDB初探

對於commit操作,只需把LeafEntry的棧頂元素放到cxrs這個指標處,然後收縮棧頂指標即可。如下圖所示:

MySQL 高效能儲存引擎:TokuDB初探

2)讀取操作

對讀取操作而言, 資料庫一般支援多個隔離級別。MySQL的InnoDB支援Read UnCommitted(RU)、Read REPEATABLE(RR)、Read Commited(RC)、SERIALIZABLE(S)。其中RU存在髒讀的情況(髒讀指讀取到未提交的事務), RC/RR/RU存在幻讀的情況(幻讀一般指一個事務在更新時可能會更新到其他事務已經提交的記錄)。

TokuDB同樣支援上述4中隔離級別, 在原始碼實現時, ft-index將事務的讀取操作按照事務隔離級別分成3類:

  • TXN_SNAPSHOT_NONE : 這類不需要snapshot read, SERIALIZABLE和Read Uncommited兩個隔離級別屬於這一類。
  • TXN_SNAPSHOT_ROOT : Read REPEATABLE隔離級別屬於這類。在這種其情況下, 說明事務只需要讀取到root事務對應的xid之前已經提交的記錄即可。
  • TXN_SNAPSHOT_CHILD: READ COMMITTED屬於這類。在這種情況下,兒子事務A需要根據自己事務的xid來找到snapshot讀的版本,因為在這個事務A開啟時,可能有其他事務B做了更新,並提交,那麼事務A必須讀取B更新之後的結果。

多版本記錄回收

隨著時間的推移,越來越多的老事務被提交,新事務開始執行。 在分形樹中的LeafNode中commited的事務數量會越來越多,假設不想方設法把這些過期的事務記錄清理掉的話,會造成BasementNode節點佔用大量空間,也會造成TokuDB的資料檔案存放大量無用的資料。 在TokuDB中, 清理這些過期事務的操作稱之為垃圾回收(Garbage Collection)。 其實InnoDB也存在過期事務回收這麼一個過程,InnoDB的同一個Key的多個版本的Value存放在undo log 頁上, 當事務過期時, 後臺有一個purge執行緒專門來複雜清理這些過期的事務,從而騰出undo log頁給後面的事務使用, 這樣可以控制undo log無限增長。

TokuDB儲存引擎中沒有類似於InnoDB的purge執行緒來負責清理過期事務,因為過期事務的清理都是在執行更新操作是順便GC的。 也就是在Insert/Delete/Update這些操作執行時,都會判斷以下當前的LeafEntry是否滿足GC的條件, 若滿足GC條件時,就刪除LeafEntry中過期的事務, 重新整理LeafEntry 的記憶體空間。按照TokuDB原始碼的實現,GC分為兩種型別:

  • Simple GC:在每次apply 訊息到leafentry 時, 都會攜帶一個gc_info, 這個gc_info 中包含了oldest_referenced_xid這個欄位。 那麼simple_gc的意思是什麼呢? simple_gc就是做一次簡單的GC, 直接把commited的事務列表清理掉(記住要剩下一個commit事務的記錄, 否則下次查詢這條commited的記錄怎麼找的到? )。這就是simple_gc, 簡單暴力高效。
  • Full GC:full gc的觸發條件和gc流程都比較複雜, 根本意圖都是要清理掉過期的已經提交的事務。這裡不再展開。

總結

本文大致介紹了TokuDB事務的隔離性實現原理, 包括TokuDB的事務表示、分形樹的LeafEntry的結構、MVCC的實現流程、多版本記錄回收方式這些方面的內容。 TokuDB之所有沒有undo log,就是因為分形樹中的更新訊息本身就記錄了事務的記錄版本。另外, TokuDB的過期事務回收也不需要像InnoDB那樣專門開啟一個後臺執行緒非同步回收,而是才用在更新操作執行的過程中分攤回收。總之,由於TokuDB基於分形樹之上實現事務,因而各方面的思路都有大的差異,這也是TokuDB團隊的創新吧。

參考資料:

相關文章