本文僅僅從應用的角度來談一談Berkeley DB中鎖相關的理論與實踐經驗,接下來還會有一篇博客來介紹BDB鎖的內部實現。
鎖粒度
除了Queue Access Method,其他所有的Access Pattern都是頁級鎖(page-level locking),而Page大小默認為操作系統filesystem的block size(Linux下默認為4K)。
(可以通過減少Page大小,使一個Page上容納更少的記錄來減少頁級鎖粒度,但是減小Page會影響數據庫的IO效率,在缺乏足夠性能數據支撐的情況下,很少會這樣做。)
BDB的頁級別的鎖粒度一向是比較惱人的問題,由於Queue並不常用(key為邏輯記錄號,value為定長),而一般使用BDB的都需要較為松散自由的key-value存取,來滿足靈活(Schema-Free)的數據,注定了使用BDB大部分情況下都要和頁級鎖打交道。
頁級鎖的存在大大增加了鎖沖突的可能,減少了高並發情況下的吞吐量。對於讀-寫沖突,可以根據業務邏輯的需要選擇“臟讀"(uncommited read)或者使用快照事務(snapshot)來避免,但是對於寫-寫沖突,鎖爭用無法避免,應用程序需要隨時做好應付死鎖的准備。關於這兩點,下文會詳細說明。
鎖協議與隔離級別
默認情況下,BDB的事務采用的是嚴格的二階段鎖協議(strong strict 2-phase locking, SS2PL),即隨着事務的進行不斷獲取鎖(讀鎖/寫鎖),直到事務結束(commit/abort)時才會釋放所有的鎖。
實際上,SS2PL的約束過於強烈,如果在某些情況下不需要如此之高的隔離程度,可以配置BDB不同的隔離級別(Isolation level)以放寬SS2PL的限制,減少鎖爭用以提高整個系統的吞吐。
Berkeley DB有4種隔離級別以供選擇:
(注意:所有的隔離級別都不允許”臟寫“,即一個事務更改另一個事務未經提交的數據,這是事務隔離的最基本保證)
1. Read-Uncommitted :允許”臟讀“(一個事務可以讀取另一個事務修改中但未提交的數據)。這是能夠配置的最低的隔離級別,讀寫沖突最小。
2. Read-Committed :不允許”臟讀“,基本上和默認級別一樣,除了事務游標(Cursor)在移動時會釋放之前的持有的鎖。
3. Serializable:(默認級別)可序列化,遵守SS2PL。相對於Read-Committed級別,游標的鎖在其關閉之前不會釋放,保證了游標的”可重復讀“(repeatable reads)。
4. Snapshot:快照隔離,能夠保證和Serializable一樣的隔離效果。snapshot事務讀DB時不會請求讀鎖,大大減少讀-寫沖突。
談談隔離級別的選擇:
Berkeley DB對隔離級別的配置是很靈活的,允許統一數據庫環境下的不同的事務采用不同的隔離級別,只要顯示在數據層開啟了該級別的支持。
1. 對於數據一致性要求不高的場景(如大部分的Web應用),對大部分non-critical數據的讀寫可以采用Read-Uncommitted級別。該級別下,由於允許”臟讀“,讀寫幾乎沒有沖突(為什么是”幾乎“,下文還會說明),但讀到的數據不一定正確。
2. Read-Committed和默認級別幾乎沒有區別,除了Cursor的鎖協議。
在默認級別下,如果使用事務游標遍歷數據庫,游標會逐漸獲取所有的讀鎖,極大阻塞其他事務的進行,使用Read-Committed級別會使游標使用鎖耦合(lock-coupling)協議,在獲取到下一頁的鎖的同時釋放上一頁的鎖,減少了鎖的占用。
3. 默認級別不多說,適合大部分對數據一致性要求高的場景,如處理關鍵/敏感數據的應用
4. Snapshot在保證默認級別隔離程度的同時減少了讀寫沖突,適用於多個讀事務/單個寫事務的應用場景。
快照隔離的原理是多版本並發控制(Multi-version Concurrency Control),所有事務都會采用Copy-on-write的協議,即需要寫數據時,先將原頁面的內容拷貝到一個新的頁面上,在新的頁面上進行修改,提交時再合並到數據庫中:由於寫事務沒有在原頁面上修改,保證了快照事務可以安全地讀取該頁面——實際上,快照事務讀數據時不需要加讀鎖。
快照隔離不是萬能的。1.耗內存:由於需要寫前復制,數據庫需要的Cache數量大約是不開啟MVCC支持前的2倍(可以使用db_stat -m查看當前數據庫使用cache的情況)2.依然不能解決寫-寫沖突。
鎖類型
除了常見的讀/寫鎖,為了減少鎖沖突、提高吞吐量,Berkeley DB提供了多種粒度的意向鎖(multi-granularity intention lock)。
BDB的鎖相容矩陣(conflict matrix)如下圖所示:(橫欄表示當前持有的鎖類型,豎欄表示加鎖請求,勾表示鎖沖突)
圖1:BDB的鎖相容矩陣

iRead/iWrite/iRW都是意向鎖,意向鎖是為了支持層次化鎖(hierarchical locking),舉例說明:
如果我們需要寫某數據某頁的某一條記錄,我們將會發出一連串原子的鎖請求:給DB加iWrite鎖,給page加iWrite鎖,給record加Write鎖。在每個鎖請求都被允許的條件下,加鎖才算成功,否則放棄之前步驟已經獲取的鎖。
我們可以看出,對單條記錄的讀寫操作在DB和Page層加的都是意向鎖,意向鎖比讀/寫鎖弱的多,與之沖突的鎖類型大大減少。只有DB級的全局操作(如遍歷全記錄、修改)才會在DB加上標准的讀/寫鎖。
在這種層次化場景下,意向鎖使得鎖的粒度被減少了,同時加鎖時檢查的效率被提高了。
uRead/iwasWrite不是意向鎖,而是BDB為了支持”臟讀“(Read-Uncommitted)而使用的特殊的鎖類型。
iwasWrite:在Read-Uncommitted級別下,所有事務的寫操作先獲取寫鎖,在Page上完成具體的修改后,寫鎖降級(downgrade)為iwasWrite——”已寫鎖“:iwasWrite的鎖相容列表和普通的寫鎖基本相同:除了允許uRead。
uRead:在Read-Uncommitted級別下,允許”臟讀“的事務在讀數據時,會嘗試獲取該數據的”臟讀鎖“(uRead),在Page上完成一次完整的讀取后,釋放該uRead鎖(注意是完成讀取后即釋放,"臟讀鎖“是一種臨時鎖,不會被長期持有,想一想為什么)
死鎖與死鎖檢測
決定使用事務的一刻起,我們注定要與鎖沖突進行無休止的戰爭。正如前文所述:
1. 盡管我們可以設置各種隔離級別來減少讀-寫沖突,寫-寫沖突總是不可避免的。
2. 即使應用層能夠保證不會同時寫同一個邏輯記錄,頁級鎖的存在常常使這樣的努力成為徒勞:)
除了影響並發性能,鎖沖突帶來的另一個嚴重問題是死鎖。有兩種情形可能造成BDB的死鎖:
1. 資源的循環依賴:如線程1中的事務A持有Page1的鎖,想要獲取Page2的鎖;線程2的事務B持有Page2的鎖,想要獲取Page1的鎖:誰也不撒手。
2. ”自死鎖“:同一個線程中開啟了兩個事務,一個事務等待另一個事務的鎖,其實上是自己等待自己。
對於第一種死鎖,Berkeley DB提供了兩種死鎖檢測接口:
1. 自動檢測:env->set_lk_detect(reject policy),每當一個加鎖請求即將被阻塞時,BDB都會遍歷內部的鎖表(lock table)以檢測是否有死鎖發生。
如果有死鎖發生,一部分擁有鎖的事務將會被強制abort以解除死鎖(abort時會釋放所有已獲得的鎖)。可以指定BDB選擇abort事務的策略,默認情況下是隨機,為了系統的吞吐量考慮,一般選擇abort掉擁有寫鎖數量最少的事務(DB_LOCK_MINWRITE),因為持有寫鎖多的事務一般是已經執行了更多工作的事務。
2. 手工檢測,如直接使用db_deadlock工具來檢測並解決當前的死鎖,在調試時極為有用。
然而對於第二種的“自死鎖”,BDB的死鎖檢測無能為力。根據我們項目中使用BDB的經驗來看,由於程序不慎而導致的“自死鎖”還是比較常見的。
可以使用如下的方法判斷是死鎖還是“自死鎖”:
1. 使用db_stat -Co工具來打印當前數據庫的鎖表,查看是否有鎖的循環依賴:
圖2:一個典型的死鎖:

圖3:一個典型的“自死鎖”:

2. 使用db_deadlock工具,如果是正常的死鎖,則一定可以被檢測並解除。
應用層策略
Design for failure:
在支持事務的數據庫中,死鎖是常態。一定要在系統設計中考慮到死鎖的可能,盡可能防止死鎖,並提供相應的容錯、重試的策略。
盡可能地防止死鎖:
1. 所有事務使用一致地順序來獲取鎖
如按照固定的順序訪問多個數據庫、按Key的順序重排(reorder)記錄寫入數據庫的次序
2. 在事務的最后訪問熱點資源(hot spot),使其鎖持有時間盡可能短
容錯與重試:
在事務進行的任意一點,都有可能因為出現死鎖而被BDB終止。如果需要重試,必須回到原事務起點,開啟一個新事物並重新執行。
(這也是不鼓勵長事務的原因:除了長時間持有鎖影響了並發的其他事務,在發現死鎖時,長事務也相對較難找到一個起點,將之前的操作重演一遍)
參考資料
《Oracle Berkeley DB Programmer's Reference Guide》
《Oracle Berkeley DB API Reference for C++》
《Oracle Berkeley DB Getting Started with Transaction Processing for C++》
