淺析MongoDB中的意向鎖
成熟的資料庫設計中,需要一個模組對資源的併發控制進行管理。意向鎖就是實現資源併發控制管理的經典方式。在討論它的概念與設計前,我們先舉幾個MongoDB的經典場景。
-
mongoDB 預設是行級併發,我們希望多行併發讀寫互不影響,但是我們又希望對在dropCollection時,不能有任何對錶的讀寫在操作,這個“不希望”也是雙向的,即在對錶併發讀寫時,我們也不希望dropCollection在操作。
在執行dbStats命令時,希望和dropDB/insert命令互斥,但是又不影響對錶的併發讀。
由於寫每個db的每張表,都須要往oplog中寫記錄,因此oplog是全域性的,我們希望在truncate oplog這個全域性操作在進行時,任何db對oplog的寫操作都被阻塞。
第一個例子中,我們似乎用傳統的rwlock就可以解決,在對錶進行併發讀寫前,加rlock,在對錶進行dropCollection前,加wlock。 暫不論rwlock的r狀態和併發寫的行為不一致,至少這樣是行得通的。 可是遇到了第二個例子,我們發現rwlock的rw兩個狀態無法表達我們的鎖需求了,到了第三個例子,只要能隱約覺得,這個鎖,還得有層級結構。
而意向鎖協議,是一種對樹形(層級)資源進行併發控制的協議。它由"操作約定"和"衝突矩陣"兩部分組成,且看下文。
02
MongoDB中的意向鎖的定義
MongoDb使用了簡化版的意向鎖協議,拋卻了SIX狀態,保留了 IS/IX/S/X四種鎖狀態。其衝突矩陣為:

其使用方式為:
-
對一個節點加IX/X鎖時,必須先(遞迴)獲取其父節點的IX鎖。
-
對一個節點加IS/S鎖時,必須先(遞迴)獲取其父節點的IS鎖。
舉個例子:MongoDB中的資源層級結構如下:

在對Collection2中的記錄進行讀操作時,需要先獲得其IS鎖。因此先遞迴獲得其父節點Global的IS鎖。

此時,如果執行對Db2的drop操作,則需要獲得Db2的X鎖,由於Db2 目前處於IS鎖狀態,且IS鎖與X鎖互斥,因此鎖無法立即獲得。

此時,如果執行對collection2的記錄的寫操作,則需要獲得Global的IX鎖,Db2的IX鎖,Collection2的IX鎖,從根節點一路下來,IX與IS狀態互不衝突,因此加鎖成功。如下圖:

通過上述的例子,我們可以發現,意向鎖的設計較為簡潔,僅僅通過一個矩陣(衝突矩陣),兩條原則(遞迴加鎖)就可以滿足資料庫系統中對資源的併發控制的需求。
03
Mongo中意向鎖的實現
雖然意向鎖的設計非常簡潔,但是理論和工程實踐上,我們至少還要考慮如下幾點:
-
一個高併發讀寫的db中,IS/IX鎖源源不斷的加上來,且相互不衝突,在這種條件下,如何避免X鎖的餓死。
-
如何避免死鎖。
帶著這兩個問題,我們分析mongoDB 意向鎖的實現。 整體結構 mongoDB中的意向鎖實現主要在 lock manager.cpp/lock state.cpp兩部分。一個簡化的意向鎖的原語可以用如下兩條語句來表達。

比如,我們想要給DB加上X鎖,就可以執行 (newLockObject).lock("mydb", MODE_X)。
其整體結構如下圖所示:

BucketArray
上圖中,意向鎖劃分為128個元素的BucketsArray, BucketsArray可以無鎖訪問,一個lock(ResourceId, LockMode)操作,首先通過Hash(ResourceId)%128 找到對於的bucket,這一步無鎖操作非常重要,充分利用了不同ResourceId的無關性,使得意向鎖模組具備水平擴充套件性。
Bucket
每個Bucket是ResourceId->LockHead的雜湊表。該雜湊表被Bucket物件中的mutex保護。
LockHead
LockHead是對應於某個ResourceId的鎖物件。LockHead維護著所有對該ResourceId的鎖請求。LockHead由ConflictList和GrantList組成。ConflictList是該鎖的等待佇列, GrantList是持有鎖的物件連結串列。
思考與嘗試
上面我們分析了MongoDB中意向鎖的結構圖,假設我們現在對db1加了大量的IS鎖,現在我們要對db1加IX鎖,為了檢查IX鎖是否和GrantList衝突,需要對GrantList進行遍歷進行衝突檢測,這樣做是不高效的。

為了解決這個問題,MongoDB為GrantList和ConflictList增加了引用計數陣列。在將一個物件增加到GrantList中時,順帶對grantedCounts[mode] 累加,如果grantedCounts[mode]是從0到1的變化, 則將grantedModes對應的bitMask設定為1。 從GrantList中刪除物件時,是一個逆向的對稱操作。這樣,在判斷某個模式是否與GrantList中已有物件衝突時,可以通過對grantedModes和待加節點的mode進行比較,將時間複雜度從O(n)降到O(1)。

一個鎖請求,如果和GrantList無衝突,就將其新增到GrantList中,並加鎖成功,否則就加到ConflictList中,並等待grantedModes變更時,從ConflictList中選擇一批與grantedModes相容的加鎖請求進入GrantList。 這是很顯然的排程策略。不過這個排程策略無法避免一個問題,如果ConflictList中有X鎖在等待,而GrantedList中的IS/IX鎖源源不斷的進來,那麼X鎖就一直得不到排程。
為了解決這個問題,MongoDB中為加鎖操作增加了 compatibleFirst 引數。

該引數的作用機制如下程式碼詮

1. 如果鎖請求與該鎖目前的grantModes衝突,則進入等待,這一點毫無疑問。
2. 207行可以看到如果請求與grantModes不衝突,也未必能加鎖成功,還要檢驗鎖資源上的compatibleFirstCount, 該變數可以解釋為:鎖資源的GrantList中compatibleFirst=true的屬性的鎖請求的元素的個數。如果GrantList中無compatibleFirst的鎖請求,且conflictList非空(有等待的鎖請求),則將請求加入到conflictList中。
3. 如果獲鎖成功,則將鎖請求加入到GrantList中,並累加鎖資源的compatibleFirstCount計數器。
上述第二點,實則提供了等待優先順序的概念。如果所有鎖請求的compatibleFirst都為false,則上述演算法則可以簡述成如下更直接,更容易理解的防餓死控制:

-
和grantedModes衝突,則進入等待。這一點毫無疑問。
-
和grantedMode不衝突,但和conflictModes衝突,依然進入等待,這一點防止了餓死。
而mongoDB引入的compatibleFirst屬性,可以理解為對該簡化版模型的一個優化,引入了等待優先順序,而且將優先順序的設定暴露給了意向鎖的使用者。在mongoDB中,只有Global的S/X鎖設定了compatibleFirst=true,其解釋如下:

04
死鎖檢測
MongoDB意向鎖的死鎖檢測基於廣度優先遍歷(BFS)演算法。某個鎖請求是否會產生死鎖,等價於 “從有向圖中的一點出發,是否可以找到一個環”。如何使用BFS演算法找有向圖的環,不在本文的討論範圍內。在將死鎖檢測規約為成環問題的過程中,如何構圖是關鍵,如何描述"點",點與點的依賴關係(邊)是什麼?讀者不妨先自行思考一下。
死鎖檢測的構圖
MongoDB中,一個鎖依賴圖 G=(V, E), Vi = (Request, Resource, Mode), 即圖中的一個點的含義為對某個資源的某種模式的鎖請求,一個點Vi對另一個點Vj有依賴 當且僅當 Vj 持有 Vi.Resource的鎖,且鎖模式與Vi.Mode衝突,且Vj 本身也在等待其他資源。概念有點繞,舉個例子。

上圖中,有三個Lock,分別為Lock1, Lock2, Lock3,Lock1當前持有Res1,在等待Res2, Lock2當前持有Res2,在等待Res3,Lock3 當前持有Res3,在等待Res1。很明顯死鎖了,但是如何將其轉化為有向圖,使得計算機能幫我們檢測死鎖呢。
我們從Lock1 Acquire Res2來看, 由於Res2被Lock2持有,所以Lock1 Acquire Res2 依賴 Lock2 Release Res2。 而Lock2 Release Res2 依賴 Lock2 Acquire Res3, Lock2 Acquire Res3 依賴 Lock3 Release Res3。 如下圖所示:

圖中Release動作的依賴並不是必須的,可以簡化成:

在工程實踐中,可以通過GrantList判斷某個資源是否被某個鎖持有。核心程式碼如下:

-
程式碼框架上,使用_queue進行BFS的迭代。
-
979行迭代ResourceId對於的Lock的GrantList,如果某個GrantList中的元素也有依賴的Resource,則將其入隊。
-
970行檢查node是否為初始入隊元素。根據BFS的性質判斷是否成環。
原文釋出時間為:2018-10-18
本文作者: Mongoing中文社群
本文來自雲棲社群合作伙伴“ ofollow,noindex">Mongoing中文社群 ”,瞭解相關資訊可以關注“ Mongoing中文社群 ”。