Anatomy of a Database System學習筆記 - 事務:並發控制與恢復
這一章看起來是講存儲引擎的。
作者抱怨數據庫被黑為“monolithic”、不可拆分為可復用的組件;但是實際上除了事務存儲引擎管理模塊,其他模塊入解析器、重寫引擎、優化器、執行器、訪問方式都是代碼相對獨立的,他們提供窄接口(寬接口功能強大如Socket,窄接口單一職責入TcpListener)給其他模塊調用。
存儲引擎一般有以下四個深深糾纏的組件:
1. A lock manager,並發控制
2. A log manager,恢復
3. A buffer pool,數據庫I/O分段處理
4. Access methods. 組織磁盤上數據
這篇將不關註算法,概述上述不同組件的角色,重點關註教科書中經常忽略的系統基礎設施,並強調組件之間的相互依賴關系。
概念
Atomicity:鎖(可見性)+日誌(正確性)
Consistency:查詢執行器的檢查(如果事務違反SQL完整性約束,則放棄並返回異常)
Isolation:鎖
Durability:日誌
可序列化:多個提交事務的交錯操作序列必須對應於事務的某些串行執行。商業關系數據庫都通過嚴格的兩階段鎖(2pl)來實現可序列化:事務在讀取或寫入對象之前獲取對象鎖,並在事務提交或中止時釋放所有鎖。鎖管理器是實現2pl的代碼模塊,作為助,還提供了輕量級的鎖(閂/latch),以提供互斥。
鎖:數據庫鎖(lock)只是系統中約定用來表示DBMS管理的物理項(例如磁盤頁)或邏輯項(例如元組、文件、卷)的名稱。任何名稱都可以有一個與之關聯的鎖,即使該名稱表示一個抽象概念。鎖機制(lock mechanism)只是提供了一個註冊和檢查這些名稱的位置。鎖有不同的鎖“模式”,這些模式與鎖模式兼容表相關聯。在大多數系統中,鎖模式就是遵從Gray鎖粒度論文裏的鎖模式。
鎖管理器和閂
鎖管理器支持以下API:
必備 | 描述 | |
lock(lockname, transaction_id, mode) | Y | |
remove_transaction(transaction_id) | Y | 實現2PL,解鎖transaction_id對應的所有資源 |
unlock(lockname, transaction_id) | N | 實現低於serializable的一致性要求 |
lock_upgrade(lockname, transaction_id, newmode) | N | 提升鎖級別,不需要先釋放鎖再獲得鎖,如共享鎖提升到排他鎖 |
conditional_lock(lockname, transaction_id, mode) | N | 不阻塞的返回鎖是否獲取成功,用於索引並發情況 |
為支持以上方法,鎖管理器維護以下各種功能數據結構:
1. 全局鎖表是Key=lockname的動態哈希表,value包含:current_mode鎖模式、waitqueue等待鎖的pair<transaction_id,mode>隊列。
2. 事務表是key=transaction_id的表,value包含:一個事務線程狀態指針用於獲得鎖後調度線程、一個事務所有鎖請求的指針列表用於(commit/abort時)移除這個事務涉及的所有的鎖。
鎖管理器配備有死鎖檢測線程,定期檢查鎖表以便發現waits-for循環。當檢測到死鎖,如何決定終止哪個事務呢?讀論文。shared-nothing系統和shared-distk系統中,分布式死鎖檢測機制如何實現呢?讀Gray和Reuter合作的論文。
從上述數據結構可以看出鎖比較笨重,相對於鎖、閂很輕量級了。
latch閂不太像鎖,而像是監視器:提供對內部數據結構的獨占訪問。例如,緩沖區頁表有一個與每個幀關聯的閂,以保證每時刻只有一個線程替換給定的幀。閂與鎖的區別如下:
1. 鎖存存放在鎖表裏、通過哈希表進行訪問;閂存放在他們所閂住的資源附近、通過地址直接訪問。
2. 鎖服從2PL協議,閂由事務期間的特殊邏輯acquire/drop。
3. 鎖的獲得完全由數據訪問驅動,因此順序和生命周期取決於應用程序和查詢優化器;閂是由DBMS內部的專門代碼獲得的,生命周期取決於代碼策略。
4. 鎖允許產生死鎖、檢測到死鎖後通過重啟事務來解決死鎖;閂禁止產生死鎖,如果有latch deadlock,說明數據庫系統有bug。
5. 鎖請求需要消耗上百個CPU循環;閂請求需要少數幾個CPU循環。
閂的API主要有3個:latch(obj, mode),unlatch(obj),conditional_latch(obj, mode)。mode分為Shared共享和eXclusive排他。Latch對象包含current_mode和waitqueue線程隊列。
ANSI SQL標準的4種隔離級別和3種附加隔離級別
隔離級別是事務發展中一個古老的概念,為支持更多並發、需要提供比串行化更弱的語義,其中影響力最大的是Gary在早年的《Degrees of Consistency》論文中提出的清晰的隔離級別定義和鎖的實現。受gary作品的影響,ANSI SQL標準規定了以下4個隔離級別:
1. READ UNCOMMITTED:事務可以讀到任何版本的數據,包括未提交的數據。這種隔離級別是由read請求不需要獲取任何鎖產生的。
2. READ COMMITTED:事務可以讀到任何版本的已提交數據,重復讀一個對象可能獲得不同的值(這個對象commit了多次的話)。這種隔離基本是由於read請求需要獲得讀鎖、但是讀到數據後又立即釋放讀鎖引起的。
3. REPEATABLE READ:事務只能讀到已提交的信息,而且以後讀到的都和第一次讀到的一樣。這種隔離級別是由於read請求訪問數據前需要獲得讀鎖、且這個鎖一直到事務結束才釋放導致而導致的。
4. SERIALIZABLE:全序列化訪問。
為什麽說RR不是全序列化的呢?因為有幻讀問題。幻讀是事務按照某個謂詞重新訪問的時候,讀到了第一次訪問時候沒有讀到的新的元組。這是因為元組粒度上的2pl並不阻止將新元組插入到表中。表粒度的2PL可以防止出現幻讀,但在事務僅通過索引訪問幾個元組的情況下,表級鎖可能會受到限制。
以上四種級別中,寫鎖都是直到事務結束才釋放的。商業數據庫通過基於鎖的並發控制來提供以上四種級別的隔離機制,但是有思潮認為Gray/ANSI的標準並不清晰:它們以微妙的方式依賴於一個假設,即並發控制基於鎖來實現,而不是樂觀的或多版本並發方案,因此這兩種提議的語義定義不明確(可參見Berenson、Adya的文章)。很多供應商在四種隔離級別之上提供了附加級別,流行的附加級別有:
1. CURSOR STABILITY遊標穩定性,DB2默認隔離級別。解決READ COMMITTED裏丟失更新丟失問題,所以說它可以防止臟讀,但是不能防止不可重復讀和幻讀。舉例說明更新丟失問題:T1以RC級別運行,需要讀X並將X=X+100寫入原賬戶。T2也需要讀X並寫X=X-300到原賬戶。如果T2發生在T1的讀和T1的寫之間,那麽T2的更新效果會丟失。CURSOR STABLITY對READ COMMITTED的補充是,鎖定事務聲明並打開的遊標當前所引用的行,該鎖持續到指針丟失(如另起一個查詢)或事務終止,這樣事務可以對單條記錄進行read-think-write而不影響其他事務的更新。此外,CS像RC一樣,如果事務修改了它檢索到的任何行,那麽在事務終止之前,其他事務不能更新或刪除該行,即使遊標不再位於被修改的行。
2. SNAPSHOT ISOLATION隔離快照,最初是Hal Berenson 1995年提出,MySQL、MongoDB、TiDB、OceanBase都實現SI。SI可以看作“樂觀鎖”,以SI模式運行的事務在事務開始時的數據庫版本上運行;其他事務的後續更新對事務是不可見的。當事務開始時,它從一個單調遞增的計數器獲得一個惟一的啟動時間戳;提交時從計數器獲得一個惟一的結束時間戳。只有當沒有其他具有重疊的start/end-transaction pair的事務寫入了該事務也寫入的數據時,該事務才提交。這種隔離模式依賴於多版本並發實現,而不是鎖(盡管鎖通常共存於支持SI的系統中)。
3. READ CONSISTENCY一致性讀,Oracle最先提出,與SNAPSHTO ISOLATION略微不同。每個SQL語句(在一個事務中可能有很多SQL語句)在語句開始時都看到最近提交的值。對於從遊標獲取的語句,遊標集基於打開時的值。這是通過維護單個元組的多個版本來實現的,一個事務可能引用單個元組的多個版本。修改是通過長期寫鎖來維護的,因此當兩個事務想要寫相同的對象時,第一個寫者“贏”,而在SI中,第一個提交者“贏”。
弱一致性使系統可以有更高並發,因此很多系統使用弱一致性作為默認隔離級別。例如,Oracle默認是Read Committed。這就要求應用程序開發者需要考慮方案的細節,以便保證事務正確運行。
日誌管理
日誌管理負責維持已提交事務的持久性、以及協助事務回滾以支持原子性。日誌管理通過維護磁盤上的日誌記錄序列、內存裏的數據結構來實現以上功能,很明顯內存中數據結構需要能從持久化的日誌和數據庫中重新創建,才能在數據庫crash後保證行為正常。
數據庫日誌復雜又面向細節,所以需要熟讀期刊論文ARIES。這篇論文是數據庫日誌管理的規範參考文獻,討論了協議、其他設計可能性、其他設計可能導致的問題;如果論文難度太高,可看Ramakrishnan/Gehrke的教科書。下文只討論recovery的基礎概念。
數據庫恢復的標準協議是Write-Ahead Logging(WAL預寫式日誌),WAL3個基本原則是:
1. 數據庫頁的修改需要先寫日誌,日誌刷盤後數據庫頁才能刷盤。
2. 數據庫日誌必須按照順序來刷盤,也就是說日誌記錄r必須等到r以前的所有記錄刷盤後,才能刷盤。
3. 對於事務提交請求,COMMIT日誌記錄刷盤後,commit請求才能返回成功。
大多數人只記得第1條原則,但是要3條原則一起生效才能保證數據庫正確性。原則1保證未提交事務可以回滾--原子性;原則2+原則3保證crash後已提交但未刷盤的事務能redone -- 持久性。
WAL原則說起來很簡單,實現起來卻很復雜,因為數據庫有性能要求:保證事務快速提交、保證快速回滾、保證快速crash recovery。對於特殊業務(如事務只能增加增加/減小某字段)的支持導致系統更不規整。下面簡要說明:
1. 為保證快速提交,現代商業數據庫以Härder 和 Reuter的“DIRECT, STEAL/NOT-FORCE”模式運行:
a) DIRECT:data objects are updated in place
b) STEAL:unpined緩沖池幀可以被“steal”偷走(然後修改後的緩沖頁要被寫回到磁盤),即使緩沖頁裏包含未提交事務。
c) NOT-FORCE:事務commit結果返回給用戶前,緩沖頁不能被“force”強行刷盤到數據庫。
這種模式使緩沖區管理和磁盤調度程序不需要考慮事務正確性,帶來很大的性能優勢,但是要求日誌管理器高效的處理被偷頁面的undo問題和不被forec頁面的redo問題。
2. 為了保證日誌記錄盡可能小,以主動增加I/O吞吐,日誌一般采用邏輯記錄("insert (bob, $25000) into EMP")而不是物理記錄(插入元組後的物理變化,如堆文件、索引塊變更)。這樣的好處是redo和undo邏輯操作變得很清晰,但是事務abort或數據庫恢復性能變差。為此,提出了一種結合了“physiological logging”,在ARIES論文裏霧裏日誌用於支持REDO,邏輯日誌用於支持UNDO。邏輯記錄也需要有對應的逆方法。
3. checkpoints機制可提高crash recovery性能,checkpoint使恢復程序只需要讀有限的日誌。嚴格的checkpoint生成性能消耗嚴重,於是使用模糊的checkpoing,這需要數據庫考慮如何用最少日誌保證正確的找到最近的一致狀態檢查點。ARIES論文裏檢查點記錄非常小,僅包含供日誌分析程序初始化、重建crash丟失的主存數據結構所需的最少信息。
4. 數據庫不僅僅是磁盤頁上用戶數據元組的集合,也包含供數據庫管理內部磁盤數據的各種物理信息,這使寫日誌和恢復更佳復雜。
針對索引的鎖和日誌
索引並發性和恢復需要保留的惟一不變條件是,索引總是從數據庫返回事務一致的元組。
B+樹閂用於支持並發
對於索引的修改往往是改了bufferpool頁導致的,因此索引的並發控制策略中最直觀的一種策略是兩階段鎖。這種策略要求每次事務修改索引前都要鎖住B+樹的根、直到事務提交,這樣策略的並發太差了。為了保證並發事務一直找到正確的葉子結點、又不給索引頁加上事務鎖,主要有3個基於閂的方案:
1. 保守方案。允許多個事務在能保證不沖突的情況下同時訪問頁面,這種沖突可能是“一個想要遍歷索引頁內樹的事務,會與一個插入數據的事務”。保守方法犧牲了太多並發性。
2. latch-coupling閂鎖耦合方案,是一種閂的下降方案,又稱為latch crabbing(螃蟹閂?),因為獲得閂的步驟是,先從B+樹根節點獲得鎖,然後找樹枝、獲得樹枝的閂並釋放父親的閂,因為如果子節點是安全的,線程可以釋放父節點上的閂。安全是指“插入時未滿和刪除時超過半滿”。等待樹枝/葉上的閂被釋放的過程中,要一直持有當前樹枝/根的閂。使用閂鎖耦合方式查找索引的方案,可參照IBM的ARIES-IM。閂鎖耦合方案只適用於B+樹,對於基於負責數據的索引樹入R樹就不適用。而right-link方案適用範圍更廣。
3. right-link右連接方案,通過對B+樹結點添加指向右鄰居的連接,把兩個節點當作一個大節點,以減少閂和再遍歷需求。遍歷時,右連接方案不需要有閂鎖耦合(上閂、讀、放開閂),遍歷事務訪問到B+樹結點期間可檢測到節點的拆分,並通過右連接訪問B+樹裏正確的位置。
論文沒把右連接方案寫清楚,也沒有列全B+樹並發控制方案,http://db.cs.berkeley.edu/jmh/cs262b/treeCCR.html講得更詳細。
物理結構的日誌
為了更加高效,索引的日誌邏輯代碼更復雜,舉例說明:insert事務abort後,新分裂的節點沒有必要再合在一起,因此在記錄索引日誌的時候對一些動作會打上‘redo-only’標記。ARIES提出一種針對nested top actions場景的機制,使恢復程序直接跳過物理結構更改記錄而不需要為每個場景都寫代碼。
同樣的思路應用到數據庫的其他類型日誌上,如堆文件(文件鏈表)的插入操作就不需要undo。
為什麽新分裂的節點就不需要合在一起了?假設B+樹並發控制使用latch-coupling模式運行,這樣做不會因為未達到半滿而影響判斷嗎? --- 不知道。
間隙鎖用於在元組級別加鎖、索引可用的同時保證串行化。
幻讀問題與數據庫元組可見性有關,如何鎖住邏輯空隙呢?
1. 昂貴的謂詞鎖。謂詞鎖粒度不固定,封鎖粒度大,則鎖管理開銷小,並行度低;封鎖粒度小,則管理開銷大,基於哈希的鎖表不能實現謂詞鎖,因為謂詞鎖需要檢測並發事務間的謂詞之間是否相容。
2. Next-key lock,鎖定記錄本身+間隙,這是用物理對象(存在的元組)來替代邏輯概念(謂詞),這樣簡單的系統設施(例如基於哈希的鎖表)就能支持復雜的目的。這個物理對象替代邏輯概念的思路是數據庫獨具的,因為沒有其他並發系統需要像數據庫這樣“consider semantic information about logical concepts as part of the systems challenge”,作者鼓勵復雜軟件系統設計者可以考慮戰略儲備這種奇怪的技巧。
題外話,靠鎖實現的並發控制大致有:謂詞封鎖、直接封鎖(固定封鎖粒度)、分層封鎖(DB>Segmen>Relation>Tuple,IS、IX、SIX)/預約封鎖
並發控制、日誌管理、bufferpool、訪問方法四者的糾纏
並發控制與恢復管理之間
預寫式日誌WAL已經暗示了,鎖協議遵循嚴格的兩階段鎖,如果是不嚴格的2PL,WAL不會正確運行,舉例說明:
事務T1回滾階段,恢復代碼從讀T的日誌開始,undo事務的修改。這個處理需要修改T1修改過的頁數據和元組,也就是說T1此時還是需要持有這些頁面或者元組的X鎖的。如果是非嚴格的2PL方案,T1在abort前棄用鎖,那麽rollback程序就有問題了。
一方面,恢復邏輯依賴並發控制,恢復管理器需要了解可能引起何種不一致,然後用日誌記錄下來,以便恢復一棵樹的物理一致性;另一方面,訪問方法的並發控制依賴於恢復邏輯,例如需要知道哪些動作可以被undo跳過。
訪問方法與其他功能模塊的糾纏
因為書本上的線性哈希和R樹太難有效實現了,數據庫主流的原生、事務保護訪問方法是堆文件+B+樹。雖然這樣說,B+樹代碼還是特別費解,因為像第4節討論的那樣,要想支持並發和恢復,就得實現各種閂、鎖、日誌協議。同時堆文件的代碼也非常復雜,堆文件描述的數據結構不同,代碼實現也不同。
並發控制與訪問方法的糾纏。訪問方法的並發控制只在封鎖法領域發展完善,像樂觀法、多版本快照法做訪問方法的並發控制並不現實。
訪問方法的恢復邏輯因系統不同而有差異:恢復協議嚴重依賴訪問方法日誌記錄的時間、內容,數據結構修改內容(以便決定undo還是跳過)、物理日誌和邏輯日誌。
事務存儲唯一獨立的模塊是緩沖區管理
只要管理好緩沖區頁的pin和unpin,換頁邏輯(STEAL)和刷新邏輯(NOT FORCE)就能正常秩序,這是因為並發控制和恢復做了大量的復雜工作來支持緩沖區的簡便性。
Anatomy of a Database System學習筆記 - 事務:並發控制與恢復