- 總覽
- ACID
- 序列化與衝突操作
- 隔離級別
- 概念層級
- 二階段鎖
- 原理
- 級聯回滾
- 強二階段鎖
- 死鎖檢測和避免
- 鎖層級
- 實踐應用
- 實現的隔離級別
- OOC
- 原理
- 三個階段
- 實現的隔離級別
- 處理幻讀
- MVC
- 原理
- 寫偏差異常(Write Skew Anomaly)
- 版本儲存(Version-storage)
- Append Only
- Time Travel Storage
- Delta Storage
- 垃圾回收
- Tuple Level
- Transition Level
- 對比
- 索引管理
- 刪除
- 各個資料庫的實現方式
- 總結
總覽
該筆記包含了原課程中關於併發控制的四節課的內容:
- 併發控制理論(Concurrency Control Theory)
- 二階段鎖併發控制(Two-Phase Locking Concurrency Control)
- 樂觀併發控制/基於時間戳(Optimistic Concurrency Control)
- 多版本併發控制(Multiple-Version Concurrency Control)
ACID
併發控制與資料庫恢復一體兩面,在資料庫系統中設計到如下部分:
資料庫為達成一個目的的語句塊稱為一個事務,特殊地,一個語句本身就可以視為一個事務。
併發控制與資料庫恢復的目標是實現事務的ACID:
- Atomicity:全或無。
- Consistency:資料整體一致;分散式場景下,強一致或最終一致。
- Isolation:事務並行,但是互不干涉。
- Durability:事務提交,就保證修改已經持久化。
序列化與衝突操作
如何確定事務併發的執行結果是滿足隔離性(Insolation)的?
答:事務是可序列化的,即並行排程結果和序列排程一致。
序列排程(Serial Schedule):資料庫每次只執行一個事務,不併行。
可序列化排程(Serializable Schedule):允許事務並行,但是並行執行的結果可以等效為序列排程。
為什麼有的事務是可序列化的,而有的無法序列化?
答:取決於是否包含(讀寫)衝突操作,存在衝突操作時,往往就是不可序列化的。
衝突操作帶來併發問題:
- 讀寫衝突(R-W):不可重複讀,幻讀[嚴格來說是 Scan / Insert ]
- 寫讀衝突(W-R):髒頁讀
- 寫寫衝突(W-W):更新丟失
如何判斷一組事務是可序列化的?
答:依賴關係圖(Dependency Graph)不成環。
(髒讀一般是回滾時才考慮)
隔離級別
資料不一致問題與隔離級別對照圖。
Dirty Read | Unrepeatable Read | Lost Update | Phantom | |
---|---|---|---|---|
Serializable | No | No | No | No |
Repeatable Read | No | No | No | Maybe |
Read Committed | No | Maybe | Maybe | Maybe |
Read Uncommitted | Maybe | Maybe | Maybe | Maybe |
不同資料庫支援的隔離級別。
其中比較特殊的是Oracle最高支援的是Snapshot Isolation而不是Serializable。
完整的隔離級別層級圖。
事務的序列化排程和序列化隔離級別的關係:
-
序列化排程(Serializable Schedule):保證事務一致性,指兩個事務併發時,效果等價於序列執行,即依賴圖不成環。
-
序列化隔離級別(Serializable Level):指不出現上文的四個併發問題。
-
一般認為,滿足 序列化隔離級別 時,事務間就可以實現序列化排程。
概念層級
併發控制實現的目標是事務的ACID,但由於存在衝突操作,導致出現一系列併發問題,造成資料不一致,為了權衡效能與併發問題的錯誤程度,定義了不同的隔離級別,為了實現不同的隔離級別,有各樣的併發控制機制,如二階段鎖,OOC,索引鎖等等。
概念由抽象到具體,從頂層到底層,結構圖如下:
下面主要介紹不同的併發控制機制的原理與解決的問題。
二階段鎖
原理
有兩類鎖,共享鎖和互斥鎖。
S-Lock | X-Lock | |
---|---|---|
S-Lock | ✓ | ✗ |
X-Lock | ✗ | ✗ |
二階段鎖如何解決併發問題?
- 最粗的粒度:事務開始就加鎖,事務提交時釋放鎖,此時是嚴格的序列化,但是效率不高。
- 最細的粒度:操作時加鎖,操作結束就釋放鎖,沒有解決依賴圖成環的問題,依然是非序列化的。
- 二階段鎖:分為兩個階段,鎖獲取階段和鎖釋放階段,只有獲取完所有鎖以後才能釋放鎖。
由於獲取完所有鎖才會釋放,所以依賴圖不會成環(見“序列化與衝突操作”一節),但是由於我們無法對Abort操作加鎖,並且插入或刪除元素時也無法加鎖,因此二階段鎖可以解決不可重複讀和更新丟失,無法解決髒讀和幻讀。
級聯回滾
髒讀會帶來級聯回滾(Cascading Abort)的問題,見下圖。
\(T_2\)讀取了\(T_1\)未提交的資料,導致\(T_1\)回滾時,\(T_2\)也需要回滾。
強二階段鎖
強二階段鎖(String Strict 2PL):不是操作結束後立刻解鎖,而是在事務提交時統一解鎖。
解決了髒讀問題。因為由於事務提交前不釋放鎖,所以另一個事務無法讀到剛修改的資料。
死鎖檢測和避免
一個死鎖的例子:鎖交錯。
死鎖檢測:檢測是否成環
時序圖上體現為交叉,在依賴圖上體現為環。
處理方式:選擇一個事務進行回滾。
如何選擇:依照年齡,執行進度,鎖數量等。
如何回滾:全部回滾;部分回滾:設定savepoint,回滾到savepoint。
死鎖避免:設定優先順序,保證鎖單向傳遞,不產生交錯
- 事務的開始時間越早,優先順序越高
- 分類:非搶佔式(Wait-Die),搶佔式(Wound-Wait)
高->低 | 低->高 | |
---|---|---|
非搶佔式 | 高優先順序等待【愛幼】 | 低優先順序放棄 |
搶佔式 | 高優先順序搶佔 | 低優先順序等待【尊老】 |
沒有雙向等待,也就不會產生交錯,也就不會有死鎖。
鎖層級
資料庫需要維護不同層級(Hierarchical)的鎖來保證併發度。
意向鎖:在給子層級加鎖時,給父層級加意向鎖,相容矩陣如下。
實踐應用
Select ... For Update
BEGIN;
SELECT balance FROM Accounts WHERE account_id = 1 FOR UPDATE;
UPDATE Accounts SET balance = balance - 100 WHERE account_id = 1;
COMMIT;
扣減餘額時,先select再update:
- select時,
account=1
的tuple上的是S鎖,父結點上的是IS鎖; - update時,
accnount=1
的tuple需要上X鎖,父結點升級為ISX鎖; - 所以可以透過
Select ... For Update
,一開始就給tuple上X,給父結點上ISX鎖
Select ... Skip Locked
可以跳過鎖,避免阻塞。
例如:
SELECT task_id, task_data
FROM task_queue
WHERE status = 'pending'
FOR UPDATE SKIP LOCKED
LIMIT 1;
- 一個任務表儲存了待處理任務,每個任務由不同的執行緒負責處理:
- 每個執行緒獲取一個未鎖定的任務進行處理;
- 已被其他執行緒鎖定的任務會被跳過,從而提高併發處理效率
實現的隔離級別
- SERIALIZABLE:強二階段鎖+幻讀預防措施(如索引鎖,見後文)。
- REPEATABLE READS:強二階段鎖。
OOC
樂觀的併發控制(Optimistic Concurrency Control)。
原理
假設大多數時候沒有衝突,先執行操作,操作結束後再進行一次驗證。
- 如果確實沒有衝突,提交事務,寫入結果
- 如果有衝突,回滾,重新進行
三個階段
- 讀取(Read Phase):每個事務有一個私有的儲存空間,當訪問元組時,將訪問結果讀取到該空間中,後續的操作都在該空間進行。
- 驗證(Validation Phase):賦予事務一個時間戳,並校驗是否有衝突,即是否滿足下面的條件。
- \(WriteSet(T1) ∩ ReadSet(T2) = Ø\)
- 如果此時事務2還處於讀取階段,那麼還需要滿足:\(WriteSet(T1) ∩ WriteSet(T2) = Ø\)
- 寫入(Write Phase):寫入結果,此時修改其他事務可見。
實現的隔離級別
如何解決併發問題:併發問題的圖示見上文“序列化與衝突操作“一節
當處於驗證階段時,如果\(T_1<T_2\):
-
由於讀的是副本:不會出現髒讀和不可重複讀問題。
-
由於讀的是副本:如果\(WriteSet(T1) ∩ ReadSet(T2) ≠ Ø\)
\(T_2\)理應看到\(T_1\)的更新值,但是由於\(T_1\)還沒有把結果寫入磁碟,所以\(T_2\)讀的是副本,而不是\(T_1\)的更新值。
所以此時存在資料不一致問題,因此要保證\(WriteSet(T1) ∩ ReadSet(T2) = Ø\)
-
\(WriteSet(T1) ∩ WriteSet(T2) = Ø\):解決更新丟失問題。
-
沒有解決幻讀問題。
反例1:\(WriteSet(T1) ∩ ReadSet(T2) ≠ Ø\),此時\(T_2\)沒有讀到\(T_1\)的更新。
反例2:\(WriteSet(T1) ∩ WriteSet(T2) ≠ Ø\),可能出現更新丟失問題。
注:在驗證階段,我麼都是與未提交的事務進行校驗,稱為前向校驗(Forward Validation)。
當然也可以與已提交的事務進行校驗,稱為後向校驗(Backward Validation)。
總結:
- SERIALIZABLE:OOC+幻讀預防措施(如索引鎖,見後文)。
- REPEATABLE READS:OOC。
處理幻讀
主要採用索引鎖(Index Lock)的方式。
在插入資料時,鎖住索引見的間隙(Gap),從而阻止插入或刪除。
更進一步:給索引鎖也加上意向鎖這個層級。
MVC
原理
基本思想:事務透過元組(Tuple)的版本,判斷可見性。
-
版本:解決我能看到誰
- 三元組
[begin-Txn, end-Txn, value]
- 讀操作:置
begin-Txn
- 寫操作:置新值
begin-Txn
,舊值end-Txn
- 三元組
-
事務活動表:解決我能不能訪問我看到的,必要時藉助鎖
從上述例子中,可以看出MVCC解決了
-
髒讀,不可重複讀
-
更新丟失
-
幻讀依然沒有解決,需要結合索引鎖等機制
寫偏差異常(Write Skew Anomaly)
只檢測直接的寫衝突,無法捕獲事務之間的隱式邏輯依賴,導致會違背全域性約束。
假設有一個醫院的醫生值班系統,要求 任何時刻至少有一名醫生值班。表 Shifts 記錄了當前醫生是否值班:
DoctorID | OnDuty |
---|---|
1 | Yes |
2 | Yes |
-
T1: 醫生 1 決定取消自己的值班,讀取當前值班情況,發現醫生 2 仍在值班,於是提交一個事務將自己從值班中移除。
-
T2: 醫生 2 決定取消自己的值班,讀取當前值班情況,發現醫生 1 仍在值班,於是提交一個事務將自己從值班中移除。
過程:
- T1 和 T2 基於同一個快照讀取 Shifts 表,發現當前有另一名醫生在值班(醫生 2 和醫生 1)。
- T1 和 T2 分別更新自己的記錄,將 OnDuty 設定為 No。
- 兩個事務沒有直接修改相同的記錄,因此快照隔離認為沒有寫衝突,允許它們同時提交。
- 提交後,Shifts 表中所有醫生的 OnDuty 均為 No,違反了至少 2 名醫生值班的約束。
版本儲存(Version-storage)
元組的版本資訊如何儲存:
- Append only:新舊版本在同一張表空間
- Time travel storage:新舊版本分開
- Delta Storage:不儲存實際值,而是儲存增量delta
Append Only
每個邏輯元組的所有物理版本都儲存在一個相同的表空間中。
不同邏輯元組的物理版本之間用連結串列串聯。
當更新時,新增一個新的物理版本到的表空間中,如下圖所示(省略了begin_Txn
和end_Txn
)。
連結串列的串聯順序可以是由舊到新 Oldest-to-Newest (O2N),也可以是由新到舊 Newest-to-Oldest (N2O)。
Time Travel Storage
有一張主表和一張歷史版本表,當更新的時候,把舊版本寫入歷史版本表中,然後新版本寫到主表上。
比如寫入\(A_3\)時,先寫把\(A_2\)寫到歷史版本表,維護相應指標,然後把\(A_3\)寫入主表。
Delta Storage
依然是有豬逼哦啊和歷史版本表,但是歷史版本表儲存增量而不是實際值。
如 \(A_1=111 \rightarrow A_2 = 222 \rightarrow A_3 = 333\)的版本記錄記錄如下。
垃圾回收
事務的所有歷史版本記錄那都是存放在表空間中,久而久之就會不斷堆積,所以對於沒有用的版本記錄,需要及時回收。
可回收的版本記錄:
- 活躍事務都不可見的版本。
- 終止(abort)的事務的版本。
垃圾回收的目標:找到上述兩類過期版本,並將它們安全地刪除。
垃圾回收的兩個層級:
- 元組層級(Tuple Level)
- 事務層級(Transaction Level)
Tuple Level
Background Vacuuming
後臺清理執行緒集中化清理,適用於所有版本儲存方式。
清理現場掃描歷史版本表,將每個歷史版本的begin_Txn
和end_Txn
與當前所有活躍的事務的id進行比較,判斷是否可以清理。
如上圖,清除了\(A_{100}\)和\(B_{100}\)。
改進:新增髒頁點陣圖,快速跳轉到代清理的版本。
Cooperative Cleaning
分散式清理,清理任務分攤到每個工作執行緒,適用於O2N。
全域性維護一個事務id(Txn),表示當前活躍事務的最小id,當每個工作執行緒在自己的歷史版本表中尋找自己的可見版本時,順帶清理掉那些全域性不可見的版本。
Transition Level
集中化清理,但是舊版本收集分攤到工作執行緒,適用於所有版本儲存方式。
當事務建立了一個新版本後,將舊版本提交給中心清理執行緒,中心執行緒統一清理舊版本。
對比
屬性 | Background Vacuuming | Cooperative Cleaning | Transition Level Vacuuming |
---|---|---|---|
觸發方式 | 後臺任務自動觸發 | 事務或查詢過程中觸發 | 根據事務需求動態觸發 |
舊版本收集 | 清理執行緒 | 工作執行緒 | 工作執行緒 |
舊版本清理 | 清理執行緒 | 工作執行緒 | 清理執行緒 |
系統資源開銷 | 佔用額外資源 | 分散到使用者操作中 | 智慧排程,可能額外增加開銷 |
適用場景 | 資料量大,需釋放磁碟空間 | 實時查詢環境 | 隔離級別需求高的環境 |
優缺點平衡 | 減少使用者影響但有清理滯後風險 | 實時性好但增加使用者操作開銷 | 智慧性高但複雜度和判斷開銷增加 |
索引管理
二級索引維護方式
- 邏輯指標(Logical Pointers):二級索引使用每個元組的固定識別符號(例如主鍵)來指向資料。間接訪問,需要回表。
- 物理指標(Physical Pointers):直接使用實體地址指向版本鏈的頭部。直接訪問,無需回表,但是維護困難。
索引重複值問題(Duplicate Key Problem):
MVCC中不同時間的事務會看到元組的不同版本,所以一個元組會有不同的索引,指向不同的物理版本。
如下圖,begin_Txn < 30
的事務看到的是A已刪除,而begin_Txn >= 30
的事務看到的是A=30。
刪除
如何表示一個版本被刪除。
- 版本上新增一個標識位,標識已刪除
- 新建一個空版本標識已刪除,
各個資料庫的實現方式
Index Management
- Secondary Indexes
- Logical Pointers
- Physical Pointers
- Multiple key Problem(GC)
總結
2PL,OOC,MVCC都是實現事務ACID的方式。
2PL運用鎖來控制併發,比較底層;OOC先執行,後利用時間戳檢測,適合衝突少的情況;MVCC利用版本控制,除了可以控制併發正確性,還能進行版本回溯,是當前的主流方式。
強二階段鎖,OOC和MVCC都能避免髒讀,不可重複讀和更新丟失,但是無法避免幻讀,需要額外利用其他機制,如索引鎖。
MVCC會有寫偏差異常(Write Skew Anomaly),無法實現完全到序列化的隔離級別,往往和其他併發控制機制如2PL,OOC結合使用。