1. 程式人生 > >MySQL · 引擎特性 · B+樹併發控制機制的前世今生

MySQL · 引擎特性 · B+樹併發控制機制的前世今生

前言

B+樹是1970年Rudolf Bayer教授在《Organization and Maintenance of Large Ordered Indices》一文中提出的[1]。它採用多叉樹結構,降低了索引結構的深度,避免傳統二叉樹結構中絕大部分的隨機訪問操作,從而有效減少了磁碟磁頭的尋道次數,降低了外存訪問延遲對效能的影響。它保證樹節點中鍵值對的有序性,從而控制search/insert/delete/update操作的時間複雜度在O(log(n))的範圍內。鑑於上述優勢,B+樹作為索引結構的構建模組,被廣泛應用在大量資料庫系統和儲存系統中,其中就包括MySQL。

索引結構作為影響系統性能的關鍵因素之一,對資料庫系統在高併發場景下的效能表現具有重大的影響。從1970年B+樹提出至今,學術界有大量論文嘗試優化B+樹在多執行緒場景下的效能,這些文章被廣泛發表在資料庫/系統領域頂級會議VLDB/SIGMOD/EuroSys

上。然而,由於過長的時間跨度(1970s-2010s,40多年時間),目前網路上缺乏討論B+樹併發機制的較為系統的分析文章,尤其在中文文章方面。本文嘗試選取不同時間點上幾個具有代表性的研究工作,分析B+樹併發控制機制的發展過程,探討B+樹併發機制在MySQL中的發展及優化。由於篇幅和時間限制,本文的部分觀點可能尚不完善,讀者可根據文章末尾的引用論文,深入閱讀相關工作。

  • B+樹的資料結構及基礎操作
  • B+樹併發控制機制的基本要求
  • B+樹併發控制機制的發展歷程(從1970s至今)
  • Mysql5.7的B+樹併發控制機制
  • Lock-Free B+樹 & 總結

B+樹的資料結構及基礎操作

btree.png

一棵傳統的B+樹需要滿足以下幾點要求:

  • 從根節點到葉節點的所有路徑都具有相同的長度
  • 所有資料資訊都儲存在葉節點上,非葉節點僅作為葉節點的索引存在
  • 根結點至少擁有兩個鍵值對
  • 每個樹節點最多擁有M個鍵值對
  • 每個樹節點(除了根節點)擁有至少M/2個鍵值對

一棵傳統的B+需要支援以下操作:

  • 單鍵值操作:Search/Insert/Update/Delete(下文以Search/Insert操作為例,其它操作的實現相似)
  • 範圍操作:Range Search

由於篇幅所限,本文假設讀者對B+樹的基礎結構和操作原理有一定的瞭解,僅對B+樹的基本結構和操作做簡單介紹,有需求的讀者可根據引用[1]的文章自行閱讀。

B+樹併發控制機制的基本要求

按照筆者的理解,正確的B+樹併發控制機制需要滿足以下幾點要求:

  • 正確的讀操作
    • R.1 不會讀到一個處於中間狀態的鍵值對:讀操作訪問中的鍵值對正在被另一個寫操作修改
    • R.2 不會找不到一個存在的鍵值對:讀操作正在訪問某個樹節點,這個樹節點上的鍵值對同時被另一個寫操作(分裂/合併操作)移動到另一個樹節點,導致讀操作沒有找到目標鍵值對
  • 正確的寫操作
    • W.1 兩個寫操作不會同時修改同一個鍵值對
  • 無死鎖
    • D.1 不會出現死鎖:兩個或多個執行緒發生永久堵塞(等待),每個執行緒都在等待被其他執行緒佔用並堵塞了的資源

不管B+樹使用的是基於鎖的併發機制還是Lock-Free的併發機制,都必須滿足上述需求。在下文中,本文將針對不同的併發機制,分別闡述它們是如何滿足上述要求,並達到了什麼樣的優化效果。

B+樹併發控制機制的發展歷程(從1970s至今)

本文使用的一些標記

首先,介紹本文虛擬碼中需要使用的一些標記。

  • SL (Shared Lock): 共享鎖 — 加鎖
  • SU (Shared Unlock) : 共享鎖 — 解鎖
  • XL (Exclusive Lock) : 互斥鎖 — 加鎖
  • XU (Exclusive Unlock): 互斥鎖 — 解鎖
  • SXL (Shared Exclusive Lock) : 共享互斥鎖 — 加鎖
  • SXU (Shared Exclusive Unlock): 共享互斥鎖 — 解鎖
  • R.1/R.2/W.1/D.1: 併發機制需要滿足的正確性要求
  • safe nodes:判斷依據為該節點上的當前操作是否會影響祖先節點。以傳統B+樹為例:(1) 對於插入操作,當鍵值對的數量小於M時,插入操作不會觸發分裂操作,該節點屬於safe node;反之當鍵值對數量等於M時,該節點屬於unsafe node;(2)對於刪除操作,當鍵值對的數量大於M/2時,不會觸發合併操作,該節點屬於safe node;反之當鍵值對數量等於M/2時,該節點屬於unsafe node。當然,對於MySQL而言,一個節點是否是安全節點取決於鍵值對的大小和頁面剩餘空間大小等多個因素,詳細程式碼可查詢MySQL5.7的btr_cur_will_modify_tree()函式。

基礎併發控制機制(MySQL5.6)

MySQL5.6以及之前的版本採用了一種較為基礎的併發機制:它採用了兩種粒度的鎖:(1)index粒度的S/X鎖;(2)page粒度的S/X鎖(本文等同於樹節點粒度)。前者被用來控制對樹結構訪問及修改操作的衝突,後者被用來控制對資料頁訪問及修改操作的衝突。下文將以虛擬碼的形式詳細分析讀寫操作的過程。

/* Algorithm1. 讀操作 */
1.   SL(index)
2.   Travel down to the leaf node
3.   SL(leaf)
4.   SU(index)
5.   Read the leaf node
6.   SU(leaf)

Algorithm 1中,讀操作首先對整個B+樹加S鎖(step1),其次訪問樹結構直到對應的葉節點(step2),接著對葉節點的page加S鎖(step3),再釋放index的S鎖(step4),然後訪問葉節點的內容(step5),最後釋放葉節點的S鎖(step6)。從上述步驟可以看出,讀操作通過index的S鎖,避免在訪問到樹結構的過程中樹結構被其它寫操作所修改,從而滿足R.2的正確性要求。其次,讀操作到達葉節點後先申請葉節點頁的鎖,再釋放index的鎖,從而避免在訪問具體的鍵值對資訊時資料被其它寫操作所修改,滿足R.1的正確性要求。由於讀操作在訪問樹結構的過程中對B+樹加的是S鎖,所以其它讀操作可以並行訪問樹結構,減少了讀-讀操作之間的併發衝突。

/* Algorithm2. 悲觀寫操作 */
1.   XL(index)
2.   Travel down to the leaf node
3.   XL(leaf)   /* lock prev/curr/next leaves */
4.   Modify the tree structure 
5.   XU(index)  
6.   Modify the leaf node 
7.   XU(leaf)

因為寫操作可能會修改整個樹結構,所以需要避免兩個寫操作同時訪問B+樹。為了解決這個問題,Algorithm 2採用了一種較為悲觀的方案。每個寫操作首先對B+樹加X鎖(step1),從而阻止了其它讀寫操作在這個寫操作執行過程中訪問B+樹,避免它們訪問到一個錯誤的中間狀態。其次,它遍歷樹結構直到對應的葉節點(step2),並對葉節點的page加X鎖(step3)。接著,它判斷該操作是否會引發split/merge等修改樹結構的操作。如果是,它就修改整個樹結構(step4)後再釋放index的鎖(step5)。最後,它在修改葉節點的內容(step6)後釋放了葉節點的X鎖(step7)。雖然悲觀寫操作通過索引粒度的互斥鎖滿足了W.1的正確性要求,然而因為寫操作在訪問樹結構的過程中對B+樹加的是X鎖,所以它會堵塞其它的讀/寫操作,這在高併發場景下會導致糟糕的多執行緒擴充套件性。這是否存在可優化的空間呢?請接著看下文。

/* Algorithm3. 樂觀寫操作 */
1.   SL(index)
2.   Travel down to the leaf node
3.   XL(leaf)
4.   SU(index)
5.   Modify the leaf node
6.   XU(leaf)

實際上,因為每一個樹節點頁可以容納大量的鍵值對資訊,所以B+樹的寫操作在多數情況下並不會觸發split/merge等修改樹結構的操作。因此,相比於Algorithm 2中的悲觀思想,Algorithm 3中採用了一種樂觀思想,即假設大部分寫操作並不會修改樹結構。在Algorithm 3中,寫操作的整個過程與Algorithm 1大致相同,它在訪問樹結構過程中持有樹結構的S鎖,從而支援其它讀/樂觀寫操作同時訪問樹結構。Algorithm 3Algorithm 1主要的區別在於寫操作對葉節點持有X鎖。在MySQL5.6中,B+樹往往優先執行樂觀寫操作,只有樂觀寫操作失敗才會執行悲觀寫操作,從而減少了操作之間的衝突和堵塞。不管是悲觀寫操作還是樂觀寫操作,它都通過索引粒度或者頁粒度的鎖避免相互之間修改相同的資料,所以滿足W.1的正確性要求。

對於死鎖問題,MySQL5.6採用的是“從上到下,從左到右”的加鎖順序,不會出現兩個執行緒加鎖順序成環的現象,所以不會出現死鎖的情況,滿足D.1的正確性要求。

只鎖住被修改的分支

雖然MySQL5.6採用樂觀寫操作減少了執行緒間的衝突,但是它的主要問題在於:即使其它讀寫操作訪問的是樹結構的不同分支,在實際執行過程中不會產生相互間的影響,但是悲觀寫操作依然會堵塞其它所有讀/寫操作,直到樹結構修改完成,這導致了過高的堵塞開銷。是否存在一種併發機制,它只鎖住B+樹中被修改的分支,而不是鎖住整個樹結構呢?答案是肯定的,《B-trees in a system with multiple users》在1976年就已經提出了一種可行的方案[2]。

與MySQL5.6不同,Algorithm 4-5中的演算法不再使用索引粒度的S/X鎖,而只使用樹節點粒度的S/X鎖。因為樹節點粒度鎖的支援,當修改樹結構時,寫操作不再只是粗暴地對整個索引結構加鎖,而只對修改的分支加鎖。首先,我們先看讀操作的具體實現。

/* Algorithm4. 讀操作 */
1.   current <= root
2.   SL(current) 
3.   While current is not leaf do {
4.     SL(current->son)
5.     SU(current)
6.     current <= current->son
7.   }
8.   Read the leaf node 
9.   SU(current)

Algorithm 4中,讀操作從根節點出發,首先持有根節點的S鎖(step1-2)。在(step3-7)的過程中,讀操作先獲得子節點的S鎖,再釋放父節點的S鎖,這個過程反覆執行直到找到某個葉節點。最後,它在讀取葉節點的內容(step8)後釋放了葉節點的S鎖(step9)。因為讀操作在持有子節點的鎖後才釋放父節點的鎖,所以不會讀到一個正在修改的樹節點,不會在定位到某個子節點後子節點的鍵值對被移動到其它節點,因此能滿足R.1/R.2的正確性要求。

/* Algorithm5. 寫操作 */
1.   current <= root
2.   XL(current)
3.   While current is not leaf do {
4.      XL(current->son)
5.      current <= current->son
6.      If current is safe do {
7.         /* Unlocked ancestors on stack. */
8.         XU(locked ancestors)
9.      }     
10.  }
11.  /* Already lock the modified branch. */
12.  Modify the leaf and upper nodes 
13.  XU(current) and XU(locked ancestors) 

Algorithm 5中,寫操作同樣從根節點出發,首先持有根節點的X鎖(step1-2)。在step3step10的過程中,寫操作先獲得子節點的X鎖,然後判斷子節點是否是一個安全節點(操作會引起該節點的分裂/合併等修改樹結構的操作)。如果子節點是安全節點,寫操作立即釋放祖先節點(可能包含多個節點)的X鎖,否則就會暫時保持父節點的鎖,這個過程反覆執行直到找到某個葉節點。當到達了葉節點後,寫操作就已經持有了修改分支上所有樹節點的X鎖,從而避免其它讀/寫操作訪問該分支(step11),滿足W.1的正確性要求。最後,它在修改這個分支的內容(step12)後釋放了分支的鎖(step13)。

對於死鎖問題,Algorithm 4-5同樣採用的是“從上到下”的加鎖順序,滿足D.1的正確性要求。 值得注意的是,Algorithm 4-5中提出的併發機制被使用到MySQL5.7中,詳細情況將在後文中說明。

SX鎖橫空出世

Algorithm 4-5提出的併發機制的主要問題在於它其實依然採用了一種較為悲觀的思想:寫操作在到達被修改分支之前,對途徑的樹節點加的是X鎖,這在一定程度上阻塞了其它操作訪問對應的樹節點。當這個寫操作需要頻繁將樹節點從磁碟讀取到記憶體產生較高的IO延遲時,這個堵塞開銷會更高。如前文所述,在大部分情況下,寫操作並不會修改途徑的非葉節點,所以不會對訪問相同節點的讀操作產生影響。但是,如果寫操作到達某個子節點時發現子節點是unsafe的,它必須一直持有父節點的鎖,否則父節點可能已被其它寫操作所修改。因此出現一個問題,是否存在一種位於S鎖與X鎖之間的SX鎖,它可以堵塞其它的SX/X加鎖操作(寫操作),但可以允許S加鎖操作(讀操作),並且當它確定要修改該節點時可升級為X鎖堵塞其它讀寫操作。

Rudolf Bayer教授於1977年在《Concurrency of operations on B -trees》一文中提出了SX鎖,SX鎖被使用到了MySQL5.7中,詳細情況將在後文中說明。此外,這篇文章提出了先嚐試樂觀寫操作,再執行悲觀寫操作的優化策略,如前文所述,該策略已經應用到MySQL5.6中。

/* Algorithm6. 寫操作 */
1.   current <= root
2.   SXL(current)
3.   While current is not leaf do {
4.      SXL(current->son)
5.      current <= current->son
6.      If current is safe do {
7.          /* Unocked ancestors on stack. */
8.          SXU(locked ancestors)
9.      }     
10. }
11. XL(modified nodes) /* SX->X, top-to-down*/
12. /* Already lock the modified branch. */
13. Modify the leaf and upper nodes 
13. XU(current) and XU(locked ancestors) 

因為SX鎖只與寫操作有關,所以本章節使用了與Algorithm 4相同的讀操作,只介紹不同的寫操作。Algorithm 6與在Algorithm 5十分相似,主要的區別在於,Algorithm 6step2,4,8中使用SX鎖取代了X鎖。到達某個葉節點後,它再將修改分支上的SX鎖升級為X鎖。這樣做的好處在於,在寫操作將影響分支上的鎖升級為X鎖前,所有讀操作都可以訪問被這個寫操作訪問過的非葉節點,從而減少了執行緒之間的衝突。由於SX鎖的存在,不會出現多個寫操作修改同一個分支的情況,從而滿足了W.1的正確性要求。

鎖,請你使用地儘可能少一些

前文中的併發控制機制在很大程度上減少了執行緒間的衝突,但是依然存在一個問題:不論讀/寫操作,它們在訪問一個樹節點前都需要對樹節點加S/SX/X鎖。頻繁加鎖產生的開銷,在核數越來越多/硬體效能越來越強的今天,開始慢慢成為一個不可忽略的開銷,尤其在記憶體資料庫/記憶體系統中。發表在2001年資料庫領域頂級會議VLDB的文章《Cache-Conscious Concurrency Control of Main-Memory Indexes on Shared-Memory Multiprocessor Systems》詳細說明了這個問題[6]。

vldb01.png

頻繁加鎖操作在多核處理器上會產生Coherence Cache Miss過高的問題。以上圖為例,假設有4個處理器(p1/p2/p3/p4),每個處理器分別有自己的private cache(c1/c2/c3/c4)。為了便於說明,同樣假設有4個執行緒(p1/p2/p3/p4),與處理器一一繫結。下文中的n1/n2/n3/n4/n5/n6/n7可以指的是樹節點的鎖,也可以指代樹節點,兩者在下文敘述中等價。下面開始說明為什麼頻繁加鎖會引入較高的Coherence Cache Miss開銷:

  • a. p1訪問樹節點n1/n2/n4,然後將它們放在快取c1;
  • b. p2訪問樹節點n1/n2/n5,然後將它們放在快取c2;
  • c. p2修改的S鎖會導致快取c1中的n1/n2失效;
  • d. 注意即使快取c1中有足夠大的空間,這些快取缺失操作依然會發生;
  • e. p3訪問樹節點n1/n2/n5,然後導致快取c2中的n1/n2失效;
  • f. p4訪問樹節點n1/n3/n7,然後導致快取c3中的n1/n3失效;

如上圖所示,不論哪個執行緒訪問樹結構,由於它在訪問每個樹節點時都需要修改對應的樹節點鎖,這會導致其它private cache裡的資料失效,帶來過高的快取一致性開銷。隨著多核處理器的發展,以NUMA架構為基礎的多核處理器產品在資料中心中被廣泛使用,而這類產品上的快取一致性開銷將會成為更加嚴重的問題。為了更加明確地說明這一點,本文從Intel官方資料中引用了部分資料[5]。

numa-1.pngnuma-2.png

上文分別列舉了E7-4800和E5-4600兩款處理器的引數,我們可以看到跨socket的訪問延遲高達200-300ns。大家都知道,訪問一次記憶體的操作僅為50-100ns,而加鎖操作導致的跨socket同步操作帶來的延遲遠高於訪存操作。當系統中存在大量cache coherence miss時,勢必會提高處理器/匯流排的資源消耗,產生較高的延遲開銷,這在記憶體資料庫/記憶體系統中更是一個不可忽略的問題。那是否可以減少加鎖的頻率,在保證正確性的基礎上減少鎖操作帶來的開銷呢?答案依然是肯定的。

前文中所述的併發機制,往往採用自頂向下的加鎖策略,在安全地獲取到子節點的鎖後釋放父節點的鎖。然而我們很容易發現,這種加鎖方式依然是十分悲觀的:大部分獲取到的鎖其實是無意義的,尤其在樹的上層,因為離根節點越近的樹節點被更新的概率越低。因此,如果存在一種自底向上加鎖的策略,只有在樹節點分裂或者合併或者刪除的情況下向上加鎖,只對被修改的樹節點加鎖,就可以在很大程度上減少加鎖的範圍和頻率,從而提高B+樹的多執行緒擴充套件性。為了實現這個目標,我們首先需要支援在不持有鎖的狀態下從根節點訪問到葉節點的功能。想知道這個目標是如何實現的,請耐下性子接著看下文 :D。

Blink樹,對後世影響深遠的多執行緒B+樹

1981年,資料庫領域頂級期刊TODS上發表了一篇文章《Efficient locking for concurrent operations on B-trees》[4],介紹Blink樹`這一簡單有效的多執行緒B+樹。由於年代深遠,Blink-Tree當時假設的計算機模型與現在主流的計算機並不相同。筆者對其的總結是:Blink樹假設訪問樹節點的讀寫操作是原子性的,讀操作不會讀到寫操作修改到一半的狀態(即已滿足R.1正確性要求),但寫操作之間修改同一份資料時會出現衝突。具體的計算機模型讀者可查閱[4]這篇論文,這一假設存在的問題會在後文中得到解決。Blink樹提出為每一個樹節點配置一個右指標。別小看這麼簡單的一個設計,它的出現使得在無鎖狀態下自頂向下的訪問策略+自底向上的加鎖策略成為可能,對後續多種多執行緒B+樹的設計產生深遠的影響。

/* Algorithm7. 讀操作 */
1.   current <= root
2.   While current is not leaf do {
3.      current <= scanNode(current, v)
4.      current <= current->son
5.   }
6.   /* Keep move right if necessary. */
7.   /* Deal with the leaf node. */
/*  scanNode函式 */
8.  Func scanNode(node* t, int v) {
9.     If t->next->key[0] <= v do 
10.       t <= scanNode(t->next, v)
11.   return t;
12. } 

Algorithm 7中,讀操作從根節點出發,遍歷整個樹結構,直到找到某個葉子節點(step1-5)。注意在這個過程中,讀操作並不持有鎖。Algorithm 7的特殊之處在於在每到達一個子節點後,它都會呼叫scanNode函式,這個函式就是Blink樹的精髓所在。因為讀操作在遍歷樹結構的過程中不持有鎖,這導致它訪問的某個樹節點可能被其它寫操作所分裂或者刪除。當讀操作準備訪問某個子節點時,這時這個子節點被其它寫操作分裂,可能導致目標鍵值對被移動到子節點的兄弟節點,導致讀操作找不到一個其實存在的鍵值對,就發生了R.2錯誤。當讀操作訪問某個子節點的過程中,這個子節點被其它寫操作刪除,那麼這個讀操作可能會發生dangling pointer的錯誤。

前文提到,Blink樹提出為每一個樹節點配置一個右指標,這個右指標為讀操作提供了另一種方式去訪問子節點的右兄弟節點。Blink樹規定樹的分裂操作順序必然是從左至右,因此目標鍵值對只有可能被分裂到子節點的右兄弟節點。在(step8-12)中說明了scanNode的實現。大致的過程就是讀操作會判斷子節點的右兄弟節點的最小值是否大於它正在查詢的目標鍵值,如果不是說明目標鍵值對在右兄弟節點或者更右邊的節點,指標就會往右走,直到找到某個右兄弟節點的最小值大於目標鍵值。因此,右指標的存在幫助讀操作能定位到真實存在的所有鍵值對,從而滿足R.2的正確性要求。

對於刪除操作,年代久遠的Blink樹採用一種比較粗暴的方式(也許它認為刪除操作的執行次數相對較少:D)。當發生刪除操作時,它採用index粒度的X鎖,堵塞其它讀/寫操作,避免了dangling pointer錯誤的發生。

/* Algorithm8. 寫操作 */
1.   current <= root                                  
2.   While current is not leaf do {             
3.      current <= scannode(current, v)     
4.      stack <= current                           
5.      current <= current->son                 
6.   }                                                          
7.   XL(current)   /* lock the current leaf */ 
8.   moveRight(current)                             
9.   DoInsertion:                                        
10.  If current is safe do                                       
11.    insert(current) and XU(current)              
12.  else {
13.    allocate(next)
14.    shift(next) + link(next)
15.    modify(current)
16.    oldnode <= current
17.    current <= pop(stack)
18.    XL(current)
19.    moveRight(current) 
20.    XU(oldnode)
21.    goto DoInsertion; 
22. } 

雖然Blink樹的寫操作相對複雜一些,但是對讀操作的原理有了一定的理解後,寫操作理解起來也不再那麼複雜。寫操作使用和讀操作類似的方式定位到目標葉節點current並加鎖(step1-8)。為了支援自底向上加鎖,寫操作遍歷過程中將訪問到的樹節點壓入棧stack中。如果葉節點是安全節點,直接插入後釋放鎖就可以了(step10-11)。如果葉節點不是安全節點,就分配一個新的next節點,將葉節點的資料移動到next節點,修改current節點並將右指標指向next節點(step13-15)。然後,寫操作從棧中彈出上一層的父節點並加鎖(step16-18)。由於父節點也可能被分裂,所以也需要通過moveRight函式移動到正確的上一層節點(step19),然後重複上述的DoInsertion過程。moveRightscanNode相似,主要的區別在於前者是在加鎖狀態下向右走,拿到右節點的鎖後可釋放當前結點的鎖。寫操作通過樹節點粒度的鎖,避免了多個寫操作同時修改同一個樹節點,滿足W.1的正確性要求。

對於死鎖,由於Blink樹只支援“自左向右,自底向上”加鎖的策略,所以不會出現死鎖的問題。

OLFIT樹,版本號你值得擁有

雖然Blink樹有效減少了加鎖頻率,但是它依然存在兩個問題:1. 不實際的假設:讀寫樹節點的操作是原子性的;2. 刪除操作竟然需要鎖住整個索引結構,效率太差了。針對這些問題,還是2001年VLDB上的這篇文章《Cache-Conscious Concurrency Control of Main-Memory Indexes on Shared-Memory Multiprocessor Systems》,它提出了OLFIT樹,在Blink樹基礎上引入了版本號的概念。

/* Algorithm9. 樹節點的寫操作 */
1.   XL(current)
2.   Update the node content
3.   INCREASE(version)
4.   XU(current)
/* Algorithm10. 樹節點的讀操作 */
1.   RECORD(version)
2.   Read the node content
3.   If node is lock, go to step1
4.   If version differs, go to step1

Algorithm 9-10顯示版本號相關的具體操作。Algorithm 9顯示寫操作在每個樹節點上的執行過程:它首先鎖住這個節點(step1),接著更新這個節點的內容(step2),然後遞增樹節點的版本號(step3),最後釋放這個節點的鎖(step4)。因為讀操作在讀取某個樹節點時樹節點可能被修改/分裂/刪除,寫操作通過鎖告知讀操作這個樹節點正在被修改,通過版本號告知讀操作這個樹節點已經被修改。Algorithm 10顯示讀操作在每個樹結點上的執行過程:它首先記錄這個樹節點的版本號(step1),再讀取這個樹節點的內容(step2),在讀操作結束後再次讀取節點的鎖和版本號。如果節點的鎖或者版本號發生變化,它判斷自己讀取的樹節點可能處於不一致的中間狀態,因此從(step1)重新開始執行。

/* Algorithm11. OLFIT樹的讀操作 */
1.   current <= root
2.   While current is not leaf do {
3.      RECORD(version)
4.      next <= scanNode(current, v)
5.      If version/lock is not modified do
6.          current <= next            
7.   }
8.   /* Keep move right if necessary. */
9.   /* Deal with the leaf node. */

Algorithm 11顯示了讀操作的完整過程。(step1-7)的過程與Blink樹相似,區別在於OLFIT樹在訪問每個節點時根據版本號/鎖的狀態判斷自己是否讀到正確的資料,從而避免讀到修改到一半的樹節點,滿足R.1的正確性要求。對於刪除操作,為了避免讀操作正在訪問的節點被其它寫操作刪除,OLFIT樹可以採用epoch-based reclamation機制[7]。原理簡單來說就是將刪除操作分為邏輯刪除和物理刪除兩個步驟,只有在確保一個樹節點沒有被任何操作訪問時才可以回收這個樹節點的物理空間。筆者曾完整實現epoch-based garbage collector,但由於篇幅和時間所限,有機會在以後的月報中再詳細分析,本文對此不做贅述。感興趣的讀者可以參考引用[7]這篇文章。

此外,除了版本號操作以外,OLFIT樹的寫操作與Blink樹相似,所以將寫操作的虛擬碼留給讀者自己思考。另外,OLFIT樹的加鎖順序與Blink樹一樣,所以不存在死鎖問題。

Masstree,B+樹優化機制的集大成者

系統領域頂級會議EuroSys在2012年發表一篇文章:《Cache craftiness for fast multicore key-value storage》,提出了Masstree。Masstree融合了大量B+樹的優化策略,包括單執行緒場景下和多執行緒場景下的。本文簡單介紹一下Masstree採用的幾個主要優化策略,具體情況可參照原論文。

在單執行緒場景下:

  • 採用了B+樹 + Tri樹的混合資料結構。在傳統B+樹中,為了支援變長鍵值這一場景,B+樹要麼在樹節點中預留很大的鍵值空間,然而這會導致儲存空間的浪費。還有一種方式就是B+樹採用定長指標指向變長鍵值,這節約了儲存空間,負面效果就是存在大量指標訪問,可能導致處理器快取命中率的嚴重降低,影響索引結構的效能。為了解決這一問題,Masstree提出B+樹 + Tri樹的混合資料結構,將變長鍵值分為多個固長的部分,固長部分通過B+樹組織,多個固長部分間的關係通過Tri樹組織,取得了在空間利用率+效能兩者間的平衡。
  • 基於int型別的比較。Masstree將變長鍵值劃分成多個固長部分,每個固長部分可以通過int型別表示,而不是char型別。由於處理器處理int型別比較操作的速度遠遠快於char陣列的比較,因此Masstree通過int型別的比較進一步加速了查詢過程。
  • 預取指令。對於B+樹從根節點到葉節點的遍歷操作,絕大部分延遲是訪存延遲(對於基於外存的B+樹,則是外存延遲)造成的,所以Masstree通過預取指令減少訪存延遲。

在多執行緒場景下:

  • 雙向連結串列:之前的OLFIT樹論文並沒有很清楚地說明併發刪除操作的實現,Masstree通過維護雙向連結串列,可以在樹節點粒度的鎖基礎上實現併發刪除操作。
  • 消除不必要的版本號變化,減少重做開銷:在OLFIT樹中,任何一個樹節點的操作都會導致樹節點的版本號發生變化,這會導致同時訪問該節點的讀操作重做。然而,有一部分樹節點的寫操作並不會導致讀操作讀到一個錯誤的狀態,所以不需要改變版本號。例如,對於更新操作,在8B範圍內的更新操作是原子性的,讀操作不會讀到一個錯誤的狀態;超過8B範圍的更新操作也可以做成原子性的,即用指標指向超過8B範圍的資料,更新操作只需要修改8B的指標就可以了;對於插入操作,傳統B+樹的插入操作往往會導致鍵值對的重排序,這需要通過版本號的變化通知讀操作可能讀到不一致的狀態。而在Masstree中,它通過8B的permutation維護樹節點鍵值對的有序性,避免傳統B+樹中鍵值對排序的操作,但是每個樹節點最多隻能容納15個鍵值對。

Mysql5.7的B+樹併發控制機制

在分析完B+樹併發控制機制幾十年的發展後,本文重新審視MySQL中B+樹併發控制機制的現狀。從MySQL5.6版本升級到5.7版本的過程中,B+樹的併發機制發生了比較大的變化,主要包括以下幾點:(1)引入了SX鎖;(2)寫操作儘可能只鎖住修改分支,減少加鎖的範圍。具體虛擬碼如下:

/* Algorithm12. 讀操作 */
1.   SL(index)
2.   While current is not leaf do {
3.      SL(non-leaf)
4.   }
5.   SL(leaf)
6.   SU(non-leaf)
7.   SU(index)
8.   Read the leaf node
9.   SU(leaf)

Algorithm 12中,每個讀操作首先對樹結構加S鎖(step1),其次訪問樹結構直到對應的葉節點(step2-4)。這裡與5.6不同之處在於,讀操作對經過的所有葉節點加S鎖。接著,它對葉節點的page加S鎖(step5)後釋放了索引結構和非葉節點的S鎖(step6-7)。最後,它訪問葉節點的內容(step8),釋放了葉節點的鎖(step9)。顯而易見,讀操作能滿足R.1/R.2的正確性要求。

/* Algorithm13. 樂觀寫操作 */
1.   SL(index)
2.   While current is not leaf do {
3.      SL(non-leaf)
4.   }
5.   XL(leaf)
6.   SU(non-leaf)
7.   SU(index)
8.   Modify the leaf node
9.   XU(leaf)

Algorithm 13中的樂觀寫操作與Algorithm 12十分相似,主要的區別在於寫操作對葉節點加X鎖。

/* Algorithm14. 悲觀寫操作 */
1.   SX(index) 
2.   While current is not leaf do {
3.      XL(modified non-leaf)
4.   }
5.   XL(leaf)      /* lock prev/curr/next leaf */
6.   Modify the tree structure 
7.   XU(non-leaf)
8.   SXU(index) 
9.   Modify the leaf node 
10.  XU(leaf)

Algorithm 14中,寫操作首先對樹結構加SX鎖(step1),在遍歷樹結構的過程中對被影響的分支加X鎖(step2-4),對葉節點加X鎖(step5),然後修改樹結構後釋放非葉節點和索引的鎖(step6-8),最後修改葉節點並釋放鎖(step9-10)。寫操作和無死鎖的正確性與前文相似,不做贅述。相比於MySQL5.6,5.7中的悲觀寫操作不會再鎖住整個樹結構,而是鎖住被修改的分支,從而沒有衝突的讀操作可以併發執行,減少了執行緒之間的衝突。

然而,將之前幾種B+樹併發控制機制與MySQL5.7相比,讀者不免會有幾個疑惑:

  • [1] 為什麼在頁鎖的基礎上還需要索引鎖?
  • [2] 為什麼讀/樂觀寫操作在持有索引鎖後,還需要一直對非葉節點加鎖?
  • [3] MySQL是否可以像Masstree/OLFIT樹/Blink樹一樣,自底向上加鎖,減少加鎖開銷?如果可以,又有多大的收益?

MySQL中的索引結構已經不再是一棵普通的B+樹,它需要支援spatial index這樣更加複雜的索引結構,需要在history list過大的時候優先支援purging執行緒,存在需要鎖住左-當前節點-右節點這樣的情況,所以它依賴索引的S/SX/X鎖來避免有兩個寫操作同時修改樹結構。此外,它還需要支援類似modify_prev/search_prev等相對複雜的回溯操作,所以需要對非葉節點加鎖,避免被其它操作所修改。並且,一個例項可能存在多個聚集索引和二級索引,MySQL中B+樹考慮的情況變得十分複雜。如何在現有MySQL基礎上提高索引結構的多執行緒擴充套件性,請持續關注阿里雲資料庫核心組後續的工作。

Lock-Free B+樹 & 總結

索引結構作為影響資料庫系統性能的關鍵模組,對資料庫系統在高併發場景下的效能表現具有重大影響。本文以B+樹併發機制的幾個代表性工作為例,深入分析了併發機制的發展和現有系統中可能存在的改進空間,希望大家看完以後有一定的收穫。隨著多執行緒索引結構的不斷研究,學界已經出現了幾種Lock-Free B+樹的設計,其中有幾種設計受到了工業界的廣泛關注,甚至已經使用到真實產品中。由於本文篇幅所限,在後續的月報中,我們還會詳細分析以Bw-Tree為代表的Lock-Free B+樹,並且剖析其它型別索引結構的原理和發展(例如LSM-Tree,SkipList,Hash等等),請持續關注阿里雲資料庫核心團隊!!!

為了高併發場景下更加優異的效能,POLARDB一直在努力,歡迎使用POLARDB!!!歡迎加入POLARDB!!!

引用

  • [1] Bayer R, Mccreight E. Organization and maintenance of large ordered indices[C]// ACM Sigfidet. ACM, 1970:107-141.
  • [2] Samadi B. B-trees in a system with multiple users ☆[J]. Information Processing Letters, 1976, 5(4):107-112.
  • [3] Bayer R, Schkolnick M. Concurrency of operations on B -trees[J]. Acta Informatica, 1977, 9(1):1-21.
  • [4] Lehman P L, Yao S B. Efficient locking for concurrent operations on B-trees[J]. Acm Transactions on Database Systems, 1981, 6(4):650-670.
  • [5] Memory Latencies on Intel® Xeon® Processor E5-4600 and E7-4800 product families https://software.intel.com/en-us/blogs/2014/01/28/memory-latencies-on-intel-xeon-processor-e5-4600-and-e7-4800-product-families
  • [6] Cha S K, Hwang S, Kim K, et al. Cache-Conscious Concurrency Control of Main-Memory Indexes on Shared-Memory Multiprocessor Systems[J]. Proc of Vldb, 2001:181–190.
  • [7] K. Fraser. Practical lock-freedom. Technical Report UCAM- CL-TR-579, University of Cambridge Computer Laboratory, 2004.
  • [8] Mao Y, Kohler E, Morris R T. Cache craftiness for fast multicore key-value storage[C]// ACM European Conference on Computer Systems. ACM, 2012:183-196.


轉自:http://mysql.taobao.org/monthly/2018/09/01/