本文節選自:關係型資料庫理論 https://url.wx-coder.cn/DJNQn ,涉及引用/整理的文章列舉在了 Database-List。
關係型資料庫中的事務管理詳解:併發控制與事務日誌
資料庫系統的萌芽出現於 60 年代。當時計算機開始廣泛地應用於資料管理,對資料的共享提出了越來越高的要求。傳統的檔案系統已經不能滿足人們的需要。能夠統一管理和共享資料的資料庫管理系統(DBMS)應運而生。1961 年通用電氣公司(General ElectricCo.)的 Charles Bachman 成功地開發出世界上第一個網狀 DBMS 也是第一個資料庫管理系統—— 整合資料儲存(Integrated DataStore IDS),奠定了網狀資料庫的基礎。
1970 年,IBM 的研究員 E.F.Codd 博士在刊物 Communication of the ACM 上發表了一篇名為“A Relational Modelof Data for Large Shared Data Banks”的論文,提出了關係模型的概念,奠定了關係模型的理論基礎。1974 年,IBM 的 Ray Boyce 和 DonChamberlin 將 Codd 關聯式資料庫的 12 條準則的數學定義以簡單的關鍵字語法表現出來,里程碑式地提出了 SQL(Structured Query Language)語言。在很長的時間內,關聯式資料庫(如 MySQL 和 Oracle)對於開發任何型別的應用程式都是首選,巨石型架構也是應用程式開發的標準架構。
本文即是對關係型資料庫中的事務管理相關內容進行討論。
事務基礎
ACID
事務提供一種全做,或不做(All or Nothing)的機制,即將一個活動涉及的所有操作納入到一個不可分割的執行單元,組成事務的所有操作只有在所有操作均能正常執行的情況下方能提交,只要其中任一操作執行失敗,都將導致整個事務的回滾。資料庫事務具有 ACID 屬性,即原子性(Atomic)、一致性(Consistency)、隔離性(Isolation)、永續性(Durability),在分散式事務 https://url.wx-coder.cn/7p8Xx 中我們也會討論分散式系統中應該如何實現事務機制。
ACID 包含了描述事務操作的整體性的原子性,描述事務操作下資料的正確性的一致性,描述事務併發操作下資料的正確性的隔離性,描述事務對資料修改的可靠性的永續性。針對資料庫的一系列操作提供了一種從失敗狀態恢復到正常狀態的方法,使資料庫在異常狀態下也能夠保持資料的一致性,且面對併發訪問時,資料庫能夠提供一種隔離方法,避免彼此間的操作互相干擾。
-
原子性(Atomicity):整個事務中的所有操作,要麼全部完成,要麼全部不完成,不可能停滯在中間某個環節。事務在執行過程中發生錯誤,會被回滾(Rollback)到事務開始前的狀態,就像這個事務從來沒有執行過一樣。例如:銀行轉賬,從 A 賬戶轉 100 元至 B 賬戶,分為兩個步驟:從 A 賬戶取 100 元;存入 100 元至 B 賬戶。這兩步要麼一起完成,要麼一起不完成。
-
一致性(Consistency):在事務開始之前和事務結束以後,資料庫資料的一致性約束沒有被破壞;即當事務 A 與 B 同時執行,無論 A,B 兩個事務的結束順序如何,資料庫都會達到統一的狀態。
-
隔離性(Isolation):資料庫允許多個併發事務同時對資料進行讀寫和修改的能力,如果一個事務要訪問的資料正在被另外一個事務修改,只要另外一個事務未提交,它所訪問的資料就不受未提交事務的影響。隔離性可以防止多個事務併發執行時由於交叉執行而導致資料的不一致。 例如:現有有個交易是從 A 賬戶轉 100 元至 B 賬戶,在這個交易事務還未完成的情況下,如果此時 B 查詢自己的賬戶,是看不到新增加的 100 元的。
-
永續性(Durability):當某個事務一旦提交,無論資料庫崩潰還是其他未知情況,該事務的結果都能夠被持久化儲存下來。
隔離級別
SQL 標準定義了 4 類隔離級別,包括了一些具體規則,用來限定事務內外的哪些改變是可見的,哪些是不可見的。低階別的隔離級一般支援更高的併發處理,並擁有更低的系統開銷。
隔離級別 | 髒讀(Dirty Read ) | 不可重複讀(NonRepeatable Read ) | 幻讀(Phantom Read ) |
---|---|---|---|
未提交讀(Read Uncommitted) | 可能 | 可能 | 可能 |
提交讀(Read Committed ) | 不可能 | 可能 | 可能 |
可重複讀(Repeatable Read ) | 不可能 | 不可能 | 可能 |
可序列化(Serializable ) | 不可能 | 不可能 | 不可能 |
Read Uncommitted | 未提交讀
在該隔離級別,所有事務都可以看到其他未提交事務的執行結果。本隔離級別很少用於實際應用,因為它的效能也不比其他級別好多少。讀取未提交的資料,也被稱之為髒讀(Dirty Read)。
Read Committed 提交讀
這是大多數資料庫系統的預設隔離級別比如 Sql Server, Oracle 等,但不是 MySQL 預設的。它滿足了隔離的簡單定義:一個事務只能看見已經提交事務所做的改變。這種隔離級別也支援所謂的不可重複讀(Nonrepeatable Read),因為同一事務的其他例項在該例項處理其間可能會有新的 Commit,所以同一查詢可能返回不同結果。
Repeatable Read | 重複讀
當隔離級別設定為 Repeatable Read 時,可以避免不可重複讀。不可重複讀是指事務 T1 讀取資料後,事務 T2 執行更新操作,使 T1 無法再現前一次讀取結果。具體地講,不可重複讀包括三種情況:
- 事務 T1 讀取某一資料後,事務 T2 對其做了修改,當事務 T1 再次讀該資料時,得到與前一次不同的值。例如,T1 讀取 B=100 進行運算,T2 讀取同一資料 B,對其進行修改後將 B=200 寫回資料庫。T1 為了對讀取值校對重讀 B,B 已為 200,與第一次讀取值不一致。
- 事務 T1 按一定條件從資料庫中讀取了某些資料記錄後,事務 T2 刪除了其中部分記錄,當 T1 再次按相同條件讀取資料時,發現某些記錄神密地消失了。
- 事務 T1 按一定條件從資料庫中讀取某些資料記錄後,事務 T2 插入了一些記錄,當 T1 再次按相同條件讀取資料時,發現多了一些記錄,也就是幻讀。
這是 MySQL 的預設事務隔離級別,它確保在一個事務內的相同查詢條件的多次查詢會看到同樣的資料行,都是事務開始時的資料快照。雖然 Repeatable Read 避免了不可重複讀,但還有可能出現幻讀。簡單說,就是當某個事務在讀取某個範圍內的記錄時,另外的一個事務又在該範圍內插入新的記錄。在之前的事務在讀取該範圍的記錄時,就會產生幻行,InnoDB 通過間隙鎖(next-key locking)策略防止幻讀的出現。
Serializable | 序列化
Serializable 是最高的事務隔離級別,它通過強制事務排序,使之不可能相互衝突,從而解決幻讀問題。簡言之,它是在每個讀的資料行上加上共享鎖。在這個級別,可能導致大量的超時現象和鎖競爭。該隔離級別代價也花費最高,效能很低,一般很少使用,在該級別下,事務順序執行,不僅可以避免髒讀、不可重複讀,還避免了幻讀。
併發控制
併發控制旨在針對資料庫中對事務並行的場景,保證 ACID 中的一致性(Consistency)與隔離性(Isolation)。假如所有的事務都僅進行資料讀取,那麼事務之間並不會有衝突;而一旦某個事務讀取了正在被其他事務修改的資料或者兩個事務修改了相同的資料,那麼資料庫就必須來保證事務之間的隔離,來避免某個事務因為未見最新的資料而造成的誤操作。解決併發控制問題最理想的方式就是能夠每當某個事務被建立或者停止的時候,監控所有事務的所有操作,判斷是否存在衝突的事務,然後對衝突事務中的操作進行重排序以儘可能少地減少衝突,而後以特定的順序執行這些操作。絕大部分資料庫會採用鎖(Locks)或者資料版本控制(Data Versioning)的方式來處理併發控制問題。
資料庫技術中主流的三種併發控制技術分別是: Multi-version Concurrency Control (MVCC), Strict Two-Phase Locking (S2PL), 以及 Optimistic Concurrency Control (OCC),每種技術也都有很多的變種。在 MVCC 中,每次寫操作都會在舊的版本之上建立新的版本,並且會保留舊的版本。當某個事務需要讀取資料時,資料庫系統會從所有的版本中選取出符合該事務隔離級別要求的版本。MVCC 的最大優勢在於讀並不會阻塞寫,寫也不會阻塞讀;而像 S2PL 這樣的系統,寫事務會事先獲取到排他鎖,從而會阻塞讀事務。PostgreSQL 以及 Oracle 等 RDBMS 實際使用了所謂的 Snapshot Isolation(SI)這個 MVCC 技術的變種。Oracle 引入了額外的 Rollback Segments,當寫入新的資料時,老版本的資料會被寫入到 Rollback Segment 中,隨後再被覆寫到實際的資料塊。PostgreSQL 則是使用了相對簡單的實現方式,新的資料物件會被直接插入到關聯的 Table Page 中;而在讀取表資料的時候,PostgreSQL 會通過可見性檢測規則(Visibility Check Rules)來選擇合適的版本。
鎖管理器(Lock Manager)
基於鎖的方式基礎理念為:如果某個事務需要資料,則對資料加鎖,操作完畢後釋放鎖;如果過程中其他事務需要鎖,則需要等到該事務釋放資料鎖,這種鎖也就是所謂的排他鎖(Exclusive Lock)。不過使用排他鎖會帶來極大的效能損耗,其會導致其他那些僅需要讀取資料的事務也陷入等待。另一種加鎖的方式稱為共享鎖(Shared Lock),當兩個事務都宣告讀取資料 A 時,它們會分別給 A 新增共享鎖;對於此事需要修改資料 A 的事務而言,它必須等待所有的共享鎖釋放完畢之後才能針對資料 A 新增排他鎖。同樣地,對於已經被設定了排他鎖的資料,僅有讀取請求的事務同樣需要等到該排他鎖被釋放後才能新增共享鎖。
從鎖定的資料範圍鎖粒度(Lock Granularity)來看分為:
- 表鎖:管理鎖的開銷最小,同時允許的併發量也最小的鎖機制。MyIsam 儲存引擎使用的鎖機制。當要寫入資料時,把整個表都鎖上,此時其他讀、寫動作一律等待。在 MySql 中,除了 MyIsam 儲存引擎使用這種鎖策略外,MySql 本身也使用表鎖來執行某些特定動作,比如 ALTER TABLE.
- 行鎖:可以支援最大併發的鎖策略。InnoDB 和 Falcon 兩種儲存引擎都採用這種策略。
鎖管理器(Lock Manager)即負責分配與釋放鎖,大部分資料庫是以雜湊表的方式來存放持有鎖以及等待鎖的事務。在 MySQL 實戰 https://url.wx-coder.cn/Tu5dq 中我們也討論瞭如何觸發鎖機制,譬如查詢加鎖,select * from testlock where id=1 for update;
,即查詢時不允許更改,該語句在自動提交為 off 或事務中生效,相當於更改操作,模擬加鎖;而更像類操作 update testlock name=name;
則是會自動加鎖。
同樣的,參考併發程式設計導論 https://url.wx-coder.cn/Yagu8 中的討論,只要存在鎖的地方就會存在死鎖(Deadlock)的可能性:
在發生死鎖的時候,鎖管理器會根據一定的規則來選取應該終止或者被回滾的事務:
- 根據是否能最小化需要被回滾的資料;
- 根據事務發生的先後順序;
- 根據事務執行所需要的時間,以儘可能避免飢餓狀態的出現;
- 根據需要回滾的關聯事務的數目;
避免死鎖,確保純隔離的最簡單方法是在事務開始時獲取鎖並在事務結束時釋放鎖。這意味著事務必須在啟動之前等待其所有鎖,並且在事務結束時釋放事務持有的鎖,這種方式會浪費很多時間來等待所有鎖。實際的資料庫,譬如 DB2 與 SQL Server 中往往採取兩階段鎖協議(Two-Phase Locking Protocol),即將事務過程切分為兩個階段:
- Growing Phase: 該階段僅可以獲取鎖,而不可以釋放鎖。
- Shrinking Phase: 該階段僅可以釋放鎖,而不可以獲取新的鎖。
該策略能夠減少其他事務等待鎖的時間,並且避免某個事務在中途修改了並不是它初次申請的資料。
MVCC
在併發程式設計導論 https://url.wx-coder.cn/Yagu8 中我們討論了兩種不同型別的鎖:樂觀鎖(Optimistic Lock)與悲觀鎖(Pessimistic Lock),前文介紹的各種鎖即是悲觀鎖,而 MVCC(Multiple Version Concurrency Control) 這樣的基於資料版本的鎖則是樂觀鎖,它能夠保證讀寫操作之間不會相互阻塞:
- 每個事務都可以在同一時間修改相同的資料;
- 每個事務會保有其需要的資料副本;
- 如果兩個事務修改了相同的資料,那麼僅有單個更改操作會被接收,另一個操作會被回滾或者重新執行。
樂觀鎖,大多是基於資料版本(Version)記錄機制實現。資料版本即為資料增加一個版本標識,在基於資料庫表的版本解決方案中,一般是通過為資料庫表增加一個 version
欄位來實現。讀取出資料時,將此版本號一同讀出,之後更新時,對此版本號加一。此時,將提交資料的版本資料與資料庫表對應記錄的當前版本資訊進行比對,如果提交的資料版本號大於資料庫表當前版本號,則予以更新,否則認為是過期資料。而 PostgreSQL 中則是依賴於 txid 以及 Commit Log 結合而成的可見性檢測機制來實現 MVCC,詳情可以參考 PostgreSQL 架構機制 https://url.wx-coder.cn/SgRDQ 中關於併發控制相關的介紹。
日誌管理器(Log Manager)
資料庫事務由具體的 DBMS 系統來保障操作的原子性,同一個事務當中,如果有某個操作執行失敗,則事務當中的所有操作都需要進行回滾,回到事務執行前的狀態。導致事務失敗的原因有很多,可能是因為修改不符合表的約束規則,也有可能是網路異常,甚至是儲存介質故障等,而一旦事務失敗,則需要對所有已作出的修改操作進行還原,使資料庫的狀態恢復到事務執行前的狀態,以保障資料的一致性,使修改操作要麼全部成功、要麼全部失敗,避免存在中間狀態。
訪問磁碟中的資料往往速度較慢,換言之,記憶體中資料的訪問速度還是遠快於 SSD 中的資料訪問速度。基於這個考量,基本上所有資料庫引擎都儘可能地避免訪問磁碟資料。並且無論資料庫表還是資料庫索引都被劃分為了固定大小的資料頁(譬如 8 KB)。當我們需要讀取表或者索引中的資料時,關係型資料庫會將磁碟中的資料頁對映入儲存緩衝區。當我們需要修改資料時,關係型資料庫首先會修改記憶體頁中的資料,然後利用 fsync 這樣的同步工具將改變同步回磁碟中。
不過一旦資料庫突發崩潰,那麼緩衝區中的資料也就丟失,最終打破了事務的永續性。另一個極端情況而言,我們也可以隨時將資料寫入到磁碟中,但是在崩潰的時候,很可能只寫入了一半的資料,而打破了事務的原子性(Atomicity)。為了解決這個問題,我們可以採取以下兩種方案:
- 影子拷貝(Shadow Copies/Pages):每個事務會建立資料庫的部分拷貝,然後針對這些拷貝進行操作。在發生異常的時候,這些拷貝會被移除;正常的情況下,資料庫則會立刻將這個拷貝寫入到磁碟然後移除老的資料塊。
- 事務日誌(Transaction Log):所謂的事務日誌即是獨立的儲存空間,在將資料寫入真正的資料表之外,資料庫都會將事務操作順序寫入到某個日誌檔案中。
在實際情況下,Shadow Copies/Pages 會受到極大的磁碟限制,因此絕大部分資料庫還是選擇了以事務日誌的方式。
事務日誌(Transaction Log)
為了實現資料庫狀態的恢復,DBMS 系統通常需要維護事務日誌以追蹤事務中所有影響資料庫資料的操作,以便執行失敗時進行事務的回滾。以 MySQL 的 InnoDB 儲存引擎為例,InnoDB 儲存引擎通過預寫事務日誌的方式,來保障事務的原子性、一致性以及永續性。它包含 Redo 日誌和 Undo 日誌,Redo 日誌在系統需要的時候,對事務操作進行重做,如當系統當機重啟後,能夠對記憶體中還沒有持久化到磁碟的資料進行恢復,而 Undo 日誌,則能夠在事務執行失敗的時候,利用這些 Undo 資訊,將資料還原到事務執行前的狀態。
事務日誌可以提高事務執行的效率,儲存引擎只需要將修改行為持久到事務日誌當中,便可以只對該資料在記憶體中的拷貝進行修改,而不需要每次修改都將資料回寫到磁碟。這樣做的好處是,日誌寫入是一小塊區域的順序 I/O,而資料庫資料的磁碟迴寫則是隨機 I/O,磁頭需要不停地移動來尋找需要更新資料的位置,無疑效率更低,通過事務日誌的持久化,既保障了資料儲存的可靠性,又提高了資料寫入的效率。
當某個事務需要去更改資料表中某一行時,未提交的改變會被寫入到記憶體資料中,而之前的資料會被追加寫入到 Undo Log 檔案中。Oracle 或者 MySQL 中使用了所謂 Undo Log 資料結構,而 SQL Server 中則是使用 Transaction Log 完成此項工作。PostgreSQL 並沒有 Undo Log,不過其內建支援所謂多版本的表資料,即同一行的資料可能同時存在多個版本。總而言之,任何關係型資料庫都採用的類似的資料結構都是為了允許回滾以及資料的原子性。
某個事務提交之後,記憶體中的改變就需要同步到磁碟中。不過並不是所有的事務提交都會立刻觸發同步,過高頻次的同步反而會對應用效能造成損傷。這裡關係型資料庫就是依靠 Redo Log 來達成這一點,它是一個僅允許追加寫入的基於磁碟的資料結構,它會記錄所有尚未執行同步的事務操作。相較於一次性寫入固定數目的資料頁到磁碟中,順序地寫入到 Redo Log 會比隨機訪問快上很多。因此,關於事務的 ACID 特性的保證與應用效能之間也就達成了較好的平衡。該資料結構在 Oracle 與 MySQL 中就是叫 Redo Log,而 SQL Server 中則是由 Transaction Log 執行,在 PostgreSQL 中則是使用 Write-Ahead Log(WAL)。下面我們繼續回到上面的那個問題,應該在何時將記憶體中的資料寫入到磁碟中。關係型資料庫系統往往使用檢查點來同步記憶體的髒資料頁與磁碟中的對應部分。為了避免 IO 阻塞,同步過程往往需要等待較長的時間才能完成。因此,關係型資料庫需要保證即使在所有記憶體髒頁同步到磁碟之前引擎就崩潰的時候不會發生資料丟失。同樣地,在每次資料庫重啟的時候,資料庫引擎會基於 Redo Log 重構那些最後一次成功的檢查點以來所有的記憶體資料頁。
WAL(Write-Ahead Logging)
WAL 協議主要包含了以下三條規則:
- 每個資料庫中的修改操作都會產生一條記錄,該記錄必須在資料被寫入到資料庫之前被寫入到日誌檔案中;
- 所有的操作日誌都必須嚴格按序記錄,即如果 A 記錄發生在 B 之前,那麼 A 也必須在 B 之前被寫入到日誌中;
- 在事務被提交之後,必須在日誌寫入成功之後才能回覆事務處理成功。
同樣可以參考 PostgreSQL 架構機制 https://url.wx-coder.cn/SgRDQ 中有關於 WAL 的例項討論。