關係型數據庫中的事務管理詳解:併發控制與事務日誌

本文節選自:關係型數據庫理論 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)對於開發任何類型的應用程序都是首選,巨石型架構也是應用程序開發的標準架構。

image.png

本文即是對關係型數據庫中的事務管理相關內容進行討論。

事務基礎

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)的可能性:

image.png

在發生死鎖的時候,鎖管理器會根據一定的規則來選取應該終止或者被回滾的事務:

  • 根據是否能最小化需要被回滾的數據;
  • 根據事務發生的先後順序;
  • 根據事務執行所需要的時間,以儘可能避免飢餓狀態的出現;
  • 根據需要回滾的關聯事務的數目;

避免死鎖,確保純隔離的最簡單方法是在事務開始時獲取鎖並在事務結束時釋放鎖。這意味着事務必須在啓動之前等待其所有鎖,並且在事務結束時釋放事務持有的鎖,這種方式會浪費很多時間來等待所有鎖。實際的數據庫,譬如 DB2 與 SQL Server 中往往採取兩階段鎖協議(Two-Phase Locking Protocol),即將事務過程切分爲兩個階段:

  • Growing Phase: 該階段僅可以獲取鎖,而不可以釋放鎖。
  • Shrinking Phase: 該階段僅可以釋放鎖,而不可以獲取新的鎖。

image.png

該策略能夠減少其他事務等待鎖的時間,並且避免某個事務在中途修改了並不是它初次申請的數據。

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 的實例討論。

延伸閱讀

歡迎關注某熊的技術之路公衆號或某熊的技術之路指北,讓我們一起前行。

image.png

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章