最近在研究分布式數據庫相關的技術,對于數據庫來說,不管是單機數據庫還是分布式數據庫,事務都是一個繞不去的坎。不光是數據庫,對于微服務架構,不同服務之間也會涉及到分布式事務的處理。本文先介紹事務的基本概念和原理,然后介紹單機事務的實現方案,最后介紹分布式事務的實現方案。
學習事務的過程中參考了不少文章,這些文章比本文更有價值,結尾有這些文章的地址。
什么是事務
描述原理和實現之前,我們首先需要了解究竟什么是事務。就像開會,先劃定議題,介紹背景知識,才能更高效的討論。維基百科中對事務的描述如下:
數據庫事務通常包含了一個序列的對數據庫的讀/寫操作。包含有以下兩個目的:
為數據庫操作序列提供了一個從失敗中恢復到正常狀態的方法,同時提供了數據庫即使在異常狀態下仍能保持一致性的方法。
當多個應用程序在并發訪問數據庫時,可以在這些應用程序之間提供一個隔離方法,以防止彼此的操作互相干擾。
當事務被提交給了數據庫管理系統(DBMS),則DBMS需要確保該事務中的所有操作都成功完成且其結果被永久保存在數據庫中,如果事務中有的操作沒有成功完成,則事務中的所有操作都需要回滾,回到事務執行前的狀態;同時,該事務對數據庫或者其他事務的執行無影響,所有的事務都好像在獨立的運行。
從上面的描述中可以看出,事務有幾個重要的特性,一是原子性,要么全部成功,要么全部回滾,二是一致性,即使在異常狀態也能保持數據的一致,三是隔離性,一個事務的執行不會影響其他事務。要實現這幾個特性,是數據庫事務的主要難點。其實準確地說,是有四個特性,也就是常說的ACID。維基百科對ACID的描述如下:
Atomicity(原子性):一個事務(Transaction)中的所有操作,或者全部完成,或者全部不完成,不會結束在中間某個環節。事務在執行過程中發生錯誤,會被回滾(Rollback)到事務開始前的狀態,就像這個事務從來沒有執行過一樣。即,事務不可分割、不可約簡。
Consistency(一致性):在事務開始之前和事務結束以后,數據庫的完整性沒有被破壞。這表示寫入的資料必須完全符合所有的預設約束、觸發器、級聯回滾等。
Isolation(隔離性):數據庫允許多個并發事務同時對其數據進行讀寫和修改的能力,隔離性可以防止多個事務并發執行時由于交叉執行而導致數據的不一致。事務隔離分為不同級別,包括未提交讀(Read Uncommitted)、提交讀(Read Committed)、可重復讀(Repeatable Read)和串行化(Serializable)。
Durability(持久性):事務處理結束后,對數據的修改就是永久的,即便系統故障也不會丟失。
注意傳統數據庫的一致性是指數據庫本身的完整性,主要是指數據庫的約束條件和觸發器等沒有被破壞,比如設置約束某個字段值不能小于0,事務會保證始終符合這個check約束條件。數據庫中的一致性和CAP中的一致性不是一個概念。CAP中的一致性是指分布式系統中不同副本上的數據是一致的。到了分布式事務中,和傳統數據庫相比,對于一致性的定義就不太一樣了,分布式系統可能沒有預設約束和觸發器等功能,尤其是針對微服務的分布式事務。對于分布式事務,一致性指整體數據的完整性,在事務執行前后,系統的數據是完整的。比如A給B轉賬100塊錢,在事務結束后,A和B的賬戶總和應該跟事務前的總和是一樣的。
單機事務
傳統單機情況下,數據庫是怎么實現原子性和隔離性的呢?以MySQL數據庫為例,原子性通過undo日志實現,持久性通過redo日志實現,隔離性通過鎖、MVCC(Multi Version Concurrency Control)和undo日志實現。下面分別做介紹。
redo日志
介紹redo日志之前,需要先簡單說一下MySQL的數據管理方式。MySQL中的數據并不是每次都是從磁盤中讀取的,而是在內存中有一個緩存Buffer Pool,用于存放磁盤中的熱點數據頁。讀取數據時先從Buffer Pool中讀取,如果沒有的話再從磁盤中讀取,然后保存在Buffer Pool中。寫入數據時也是先寫到Buffer Pool,然后再把Buffer Pool中的數據定期寫入磁盤。
雖然Buffer Pool提高了MySQL效率,但是會導致一個問題,如果在寫入磁盤前,MySQL宕機了,Buffer Pool中的還沒寫盤的數據就丟失了,所以MySQL設計了redo日志來解決這個問題。
redo日志由內存中的redo log buffer和redo日志文件組成。修改數據時,先寫redo日志添加到內存中的redo log buffer,然后修改Buffer Pool中的數據。提交這次事務時,可以選擇是否將redo log buffer中的日志刷新到磁盤。用戶可以通過innodb_flush_log_at_trx_commit參數來控制寫盤時機,有三種取值:
- 0,事務提交時不寫盤,由線程每秒寫盤一次。
- 1,事務提交時調用fsync強制寫盤。
- 2,事務提交時寫入文件系統緩存,由操作系統決定何時將緩存寫入磁盤。
如果設置為0,MySQL服務器進程宕機時有可能丟失數據;如果設置為2,操作系統宕機時有可能丟失數據。
redo日志并不一定是提交才會寫盤,如果innodb_flush_log_at_trx_commit設置為0,即使還沒提交,也可能寫盤。
如果每次修改數據都需要寫redo日志到磁盤,那為什么不把Buffer Pool中的數據直接寫磁盤呢?原因主要有兩個:
- 直接刷新數據是一個隨機IO,每次修改的數據在不同的數據頁中,而redo日志是連續的,寫盤是順序IO。
- 直接刷新數據是以數據頁為單位,MySQL默認是16KB,即使修改的數據只有一個字節也需要寫16KB。而redo日志只包含修改的數據,數據量要少很多。
MySQL中redo日志以塊(block)為單位存儲,每塊的大小為512B,格式如下:
每個block由12字節頭部log block header,492字節日志內容log block和8字節尾部log block tailer組成。
log block日志內容中保存是具體redo日志,格式如下:
- redo_log_type: redo日志類型
- space: 表空間ID
- page_no: 頁偏移量
- redo log body: 根據日志類型的不同,存儲的內容格式也不一樣
在描述數據恢復過程之前,還需要介紹一下MySQL中有個Log Sequence Number(LSN),8個字節,是一個遞增的值,表示當前redo日志總共有多少個字節。LSN保存在redo日志文件和每個數據頁的頭部。寫redo文件時,MySQL會把當前的LSN一起寫入文件,然后修改內存當前數據頁的LSN。等到數據頁寫盤時,LSN也會一起保存在該數據頁對應的磁盤中,而且當前LSN值會寫入數據文件ibdata的第一個page中作為整個數據文件的checkpoint。
MySQL啟動時,首先檢查當前redo日志文件中的LSN和數據文件中的checkpoint對應的LSN,如果兩個一樣,說明沒有數據丟失。如果checkpoint LSN小于redo LSN,說明有數據丟失,從checkpoint LSN開始,遍歷每個redo日志,找到對應的數據頁,如果數據頁的LSN小于redo日志中的LSN,需要對這一頁進行數據恢復。理論上redo日志中所有在checkpoint之后的事務都需要恢復,為什么這里還要比較每一頁的LSN?這是因為MySQL刷臟頁時,是先把所有臟頁寫入磁盤,最后再寫入checkpoint LSN。有可能臟頁已經寫入磁盤,但是在寫入checkpoint LSN前宕機,這就需要在恢復事務時判斷數據頁中的LSN,避免重復恢復。這里只介紹了redo日志在數據恢復時的使用,實際上還要結合binlog一起使用,這就更復雜了,不詳細展開。
undo日志
undo日志用于事務的回滾和MVCC,分別對應原子性和隔離性。MySQL中修改數據時,并不是簡單地在當前數據上修改,而是先把修改前的數據保存在undo log中,然后修改當前數據并且在當前數據中增加一個指針指向修改前的數據。如下圖所示,undo日志組成了一個鏈表:
圖中undo列表由三個SQL操作組成,左上角為當前記錄的內容,第二個方塊是最后一條SQL語句對應的undo日志,日志中保存了事務ID(TRX_ID)和修改前字段的內容("B"),最后一個方塊是insert語句對應的undo日志。
根據不同的操作類型,undo日志的格式不一樣,下面以update操作為例介紹對應的undo日志格式。
- next: 2B,表示下一條undo日志位置
- type_cmpl: 1B,表示undo日志類型
- undo_no: 序號,用來區分一個事務中多個undo日志的順序
- table_id: 表ID
- info_bits: 一些標記位
- DATA_TRX_ID: 這次修改對應的事務ID
- DATA_ROLL_PTR: 回滾指針,記錄當前數據上一個版本在回滾段中的位置
- update vector: 表示修改的數據
- start: 表示上一條undo日志位置
除了上面介紹的字段,undo日志還有一個undo header頭部信息,其中一個字段是TRX_UNDO_STATE,表示undo日志的狀態。取值有下面幾個:
- TRX_UNDO_ACTIVE: 初使狀態
- TRX_UNDO_CACHED:
- TRX_UNDO_TO_FREE: 可以釋放
- TRX_UNDO_TO_PURGE: 可以清理
- TRX_UNDO_PREPARED: 準備狀態,還未提交
undo日志在事務未提交前是TRX_UNDO_PREPARED狀態,事務提交后,根據不同的操作類型轉換成TRX_UNDO_CACHED,TRX_UNDO_TO_FREE或者TRX_UNDO_TO_PURGE狀態,表示滿足一定條件后可以釋放,事務如果需要回滾的話,必須是TRX_UNDO_ACTIVE或者TRX_UNDO_PREPARED狀態。此時從undo日志中取出上一次的數據作為當前數據的值。需要說明的是寫undo日志本身也會產生相應的redo日志。
MVCC
MVCC的全稱是Multi Version Concurrency Control多版本并發控制。它的作用是解決不同事務之間并發執行的時候,數據修改的隔離性問題。數據庫有四種隔離級別,由低到高如下:
- 未提交讀(READ UNCOMMITED):允許讀取其他事務未提交的修改
- 已提交讀(READ COMMITED):只能讀取其他事務已提交的修改
- 可重復讀(REPEATABLE READ):同一個事務內,多次讀取操作得到的每個數據行的內容是一樣的
- 可串行化(SERIALIZABLE):事務執行不受其他事務的影響,就像各個事務之間是按順序執行的
這里解釋一下可串行化。可串行化是指多個事務執行是按照某種順序執行的,每個事務都是一個原子操作,一個事務執行過程中不會看到另一個事務的中間狀態,但不保證這個順序一定是時間上的先后順序。比如事務ABC先后請求,實際執行時可能是ACB的順序。可串行化和線性一致性(Linearizable)不是一個概念。線性一致性是指對于同一個對象,操作的執行順序是和時間順序一致的,一個操作在時間順序上發生在前面,那后面的操作一定可以看到前面操作的結果。把可串行化和線性一致性結合起來,就是嚴格可串行化(Strict Serializable),既滿足可串行化,也滿足線性一致性,是最高的一致性模型。
不同的隔離級別可以解決不同級別的讀問題,如下:
隔離級別 | 臟讀 | 不可重復讀 | 幻讀 |
---|---|---|---|
未提交讀 | 可能發生 | 可能發生 | 可能發生 |
提交讀 | - | 可能發生 | 可能發生 |
可重復讀 | - | - | 可能發生 |
可序列化 | - | - | - |
臟讀、不可重復讀、幻讀的解釋如下:
臟讀
當一個事務允許讀取另外一個事務修改但未提交的數據時,就可能發生臟讀。
舉個例子:
事務 1 | 事務 2 |
---|---|
/* Query 1 */ SELECT age FROM users WHERE id = 1; /* will read 20 */ |
|
/* Query 2 */ UPDATE users SET age = 21 WHERE id = 1; /* No commit here */ |
|
/* Query 1 */ SELECT age FROM users WHERE id = 1; /* will read 21 */ |
|
ROLLBACK; /* lock-based DIRTY READ */ |
不可重復讀
在一次事務中,當一行數據獲取兩遍得到不同的結果表示發生了“不可重復讀”.
事務 1 | 事務 2 |
---|---|
/* Query 1 */ SELECT * FROM users WHERE id = 1; |
|
/* Query 2 */ UPDATE users SET age = 21 WHERE id = 1; COMMIT; /* in multiversion concurrency control, or lock-based READ COMMITTED */ |
|
/* Query 1 */ SELECT * FROM users WHERE id = 1; COMMIT; /* lock-based REPEATABLE READ */ |
幻讀
在事務執行過程中,當兩個完全相同的查詢語句執行得到不同的結果集。這種現象稱為“幻讀(phantom read)”
事務 1 | 事務 2 |
---|---|
/* Query 1 */ SELECT * FROM users WHERE age BETWEEN 10 AND 30; |
|
/* Query 2 */ INSERT INTO users VALUES ( 3, 'Bob', 27 ); COMMIT; |
|
/* Query 1 */ SELECT * FROM users WHERE age BETWEEN 10 AND 30; |
對于隔離性,一種方法是基于鎖。這種方案的問題是性能可能比較低,尤其是讀操作也要加鎖的時候。另一種方案是MVCC。MVCC用于替代讀鎖,寫依舊需要加鎖。MVCC和undo日志是如何支持不同的隔離級別,解決讀問題的呢?
對于未提交讀,只要讀取記錄當前版本的值就行了。
對于已提交讀,在執行事務中每個查詢語句的時候,MySQL會生成一個叫做視圖read view的數據結構,包含以下內容:
- m_ids: 所有正在執行的事務ID,這些事務還未提交
- min_trx_id: 生成read view時正在執行的最小事務ID
- max_trx_id: 生成read view時系統應該分配的下一個事務ID
- creator_trx_id: 生成read view時事務本身的ID
訪問數據時,根據以下規則判斷某個版本的數據是否可見:
- 如果當前版本數據的事務ID和creator_trx_id相同,說明是當前事務修改的記錄,此時該版本數據可見。
- 如果當前版本數據的事務ID小于min_trx_id,說明是已經提交的事務作的修改,該版本數據可見。
- 如果當前版本數據的事務ID大于等于max_trx_id,說明是該版本的事務是在read view創建之后生成的,該版本數據不可見
- 如果當前版本數據的事務ID在min_trx_id和max_trx_id之間,并且在m_ids內,該版本數據不可見,如果不在m_ids內,該版本數據可見。
如果當前版本數據不可見,使用前面介紹的undo日志,根據ROLL PTR回滾指針找到上一個版本的數據,判斷上一個版本的數據是否可見,如果不可見,沿著undo日志鏈表找到符合條件的數據版本。
對于可重復讀,和已提交讀的差別在于已提交讀是在事務中每條查詢語句執行的時候生成read view,而可重復讀是在事務一開始的時候就生成read view。
MVCC解決了臟讀、不可重復讀問題,以及部分幻讀問題。為什么說是部分?對于前面幻讀舉的例子,事務1的兩條select都是讀的同一版本的數據,因為事務2插入的數據版本號不符合事務1的讀取范圍,所以不會讀到,這種情況的幻讀MVCC可以處理。但是另一種幻讀則處理不了,這涉及到快照讀和當前讀的概念。快照讀就是前面介紹的使用undo日志來選擇一個合適的版本來讀取,select操作使用這種方式。而insert、update和delete則使用當前讀,對于這幾個修改操作,必須使用最新的數據進行修改。舉個例子:
事務 1 | 事務 2 |
---|---|
/* Query 1 */ SELECT * FROM users WHERE age BETWEEN 10 AND 30; |
|
/* Query 2 */ INSERT INTO users VALUES ( 3, 'Bob', 27 ); COMMIT; |
|
/* Query 1 */ UPDATE users SET name = 'Tom' where id = 3; |
|
/* Query 1 */ SELECT * FROM users WHERE age BETWEEN 10 AND 30; |
事務1查詢讀到兩條數據,事務2插入id為3的記錄后,事務1執行更新操作,使用當前讀獲取的最新數據,將id為3的記錄名字改成了Tom,然后事務1執行第二次查詢,這時是可以讀到id為3的數據的,兩次select讀到的數據不一樣。因為事務1進行update操作,數據的版本號是事務1自己的事務ID,所以第二次select能讀到id為3的記錄。是不是很復雜?
事務流程
介紹完了redo日志,undo日志后,我們完整描述一下事務流程。
準備階段:
- 分配事務ID
- 如果隔離級別是REPEATABLE READ,創建read view
- 分配undo日志,把修改之前的數據寫入undo日志
- 為undo日志的修改創建redo日志,寫入redo log buffer
- 在buffer pool中修改數據頁,回滾指針指向undo日志
- 為數據頁的修改創建redo日志,寫入redo log buffer
提交階段:
- 寫binlog到磁盤
- 修改undo日志狀態為"purge"
- 根據innodb_flush_log_at_trx_commit判斷是否需要立即把redo log buffer寫入磁盤
- buffer pool中的數據頁根據規則等待合適的時機寫入磁盤
恢復或者回滾階段:
- 根據redo日志中的LSN和事務ID,數據文件中的LSN和binlog中的事務ID決定需要做恢復還是回滾
- 如果事務已提交,但數據頁還未寫入磁盤,需要恢復,根據redo日志中的字段對數據頁進行恢復
- 如果需要回滾,根據undo日志中的上一個版本數據進行回滾
分布式事務協議
分布式事務有兩種形式,一種是分布式數據庫事務,另一種分布式微服務事務。
第一種分布式數據庫事務由傳統單機事務進化而來。當單機數據庫無法支撐所有負載時,必然要將數據拆分到多臺數據庫。如果一次操作涉及到多臺數據庫,那就需要一種方案來維護整個數據庫集群的事務特性,也就是ACID特性。
第二種分布式微服務事務由分布在不同機器上的服務組成。最近微服務架構興起,不同的服務被拆分到不同的服務器進程中。比如一個網絡購物服務,由訂單服務器,支付服務器,倉儲服務器和物流服務器組成。每個服務器使用的數據存儲方案可能不同,有的用MySQL,有的用Redis。如何讓網絡購物服務完整執行,而不會導致支付了但倉庫中沒有剩余庫存,這是分布式服務事務需要處理的問題之一。
分布式事務和單機事務相比,處理的問題更為困難。
第一個問題是因為有多個事務參與者。傳統單機事務,如果宕機,整個事務的執行都會失敗,原子性比較好處理。但是分布式事務,參與者分布在不同的機器上,可能第一個參與者成功鎖住資源,而第二個參與者對資源加鎖失敗,也可能第一個參與者提交事務成功,但是第二個參與者提交時機器宕機,處理起來困難。
第二個問題是網絡超時導致的問題。服務器A告訴服務器B提交事務,然后超時了,沒有收到服務器B的響應。這時有可能服務器B沒收到請求,也可能服務器B收到請求,成功處理,發送給A的響應丟失。如何區分這兩種情況然后進行處理也是困難的地方。
第三個問題是距離導致的延遲問題。傳統單機事務不存在網絡延遲,所有操作都在一臺機器上處理。但是分布式事務由于服務器的物理距離帶來了網絡延時,這可能會導致實現方案的差異。比如前面介紹中我們提到過事務ID的概念,在分布式系統中,如何保證事務ID的唯一性,以及區分并發事務之間的先后順序?一種方案是提供一個全局的服務來分配事務ID,單調遞增。這種方案在同城部署的時候還好,因為同城的網絡往返RTT大概在1ms內,但是如果是全球部署的系統,比如Google Spanner,一個服務器在北美洲,另一個服務器在歐洲,跨洲往返RTT可能是200ms,這個延遲顯然是無法接受的,所以這種全局事務ID服務器方案不行。
那么分布式事務應該如何實現,接下來我們討論原理和常見的幾種方案。
兩階段提交
兩階段提交中有兩個角色,一個是參與者,用于管理本地資源,實現本地事務。另一個是協調者,用于管理分布式事務,協調事務各個參與者之間的操作。
流程如下:
Coordinator Participant
prepare*
QUERY TO COMMIT
-------------------------------->
VOTE YES/NO prepare*/abort*
<-------------------------------
commit*/abort*
COMMIT/ROLLBACK
-------------------------------->
ACKNOWLEDGMENT commit*/abort*
<--------------------------------
end
An * next to the record type means that the record is forced to stable storage.
第一階段Prepare
協調者分配事務ID,寫到磁盤,然后詢問所有參與者是否可以執行事務
參與者執行事務,對資源加鎖,寫redo/undo日志到磁盤
如果參與者執行事務成功,回復Yes,如果執行失敗,回復No
第二階段Commit/Abort
分兩種情況
所有參與者回復Yes,執行Commit
協調者寫Commit日志到磁盤,然后向所有參與者發送Commit請求
參與者執行Commit操作,寫Commit日志到磁盤,釋放資源
參與者回復協調者ACK完成消息
協調者收到所有參與者完成消息后,完成事務
至少有一個參與者回復No,執行Abort
協調者寫Abort日志到磁盤,然后向所有參與者發送Abort請求
參與者使用Undo日志回滾事務,寫Abort日志到磁盤,釋放資源
參與者回復協調者回滾完成消息
協調者收到所有參與者回滾完成消息后,取消事務
整個兩階段流程,看著挺簡單的,但是麻煩的地方在于如何處理服務器宕機和網絡超時問題,我們來分析一下整個過程如何處理這兩個問題。首先有個原則是如果協調者已經寫Commit日志到磁盤,各個參與者就應該提交事務,不允許回滾。
網絡超時問題
協調者發送完Prepare請求后,在規定時間內沒有收到所有參與者的回復。此時簡單的做法是協調者發送Abort請求給所有參與者,參與者執行回滾操作。因為可能所有的參與者都Prepare成功,只是協調者沒有收到回復消息,這種做法選擇了正確性,犧牲了性能。
-
參與者等待協調者的Commit請求超時。
- 如果該參與者Prepare階段回復的是No,此時可以直接Abort事務。因為協調者收到No回復后,給所有參與者發送的也是Abort請求。
- 如果該參與者Prepare階段回復的是Yes就比較麻煩了。因為不知道協調者發送的是Commit還是Abort,該參與者不能直接執行Commit或者Abort。此時該參與者有兩種做法。
- 方案一:向協調者查詢事務狀態。這種做法可能作用不大,因為很可能協調者宕機或者協調者到該參與者之間的網絡不通,這時候查詢也起不到什么作用。
- 方案二:執行終止協議(Termination Protocol),超時的參與者向其他參與者查詢事務狀態。
如果有參與者回復的是No,所有參與者執行Abort操作。
如果有參與者回復說還沒有收到Prepare請求,所有參與者執行Abort操作。
-
如果所有參與者回復的是Yes,此時參與者可以執行Commit操作嗎?不能,原因有兩個。
- 如果之后協調者的Commit請求又被參與者收到了,此時參與者需要能識別出這個事務已經Commit了,不能重復Commit,也就是需要支持冪等,當然這個問題還比較好處理。
- 即使所有參與者都回復的是Yes,協調者如果在接收回復階段超時了,然后寫Abort日志,之后宕機了。此時參與者執行Commit操作會破壞原子性。那怎么辦呢?有三種辦法:
- 第一種辦法是什么也不做,告警,等待人工處理。
- 第二種辦法是等待網絡恢復或者協調者宕機重啟。
- 第三種辦法是保證協調者的可用性(Availablity),主協調者宕機后有其他的協調者能繼續服務。
協調者等待參與者Commit/Abort之后的ACK超時。根據上面的討論,參與者一定會執行Commit/Abort操作,此時協調者可以認為事務已經完成了,返回結果給客戶端。事實上,協調者寫完Commit/Abort日志,發送Commit/Abort請求給參與者后,就可以直接返回結果給客戶端,不必等待最后的ACK。
宕機問題
宕機問題都有可能轉化為超時問題,宕機前如果寫了redo/undo日志,重啟后需要額外處理。
- 協調者發出Prepare請求前宕機。此時事務還未開始,不會有影響。
- 參與者在Prepare階段宕機。此時協調者超時,處理方式和上文網絡超時第一條相同。參與者重啟后需要向協調者查詢事務狀態。
- 在協調者寫Commit/Abort日志前,協調者宕機。協調者重啟后,進入Prepare超時處理流程,處理方式和上文網絡超時第一條相同。
- 在協調者寫Commit/Abort日志后,協調者宕機。協調者重啟后,需要給參與者發送日志中決定的Commit/Abort請求。
- 在協調者寫Commit/Abort日志后,參與者宕機。此時參與者在重啟后需要向協調者查詢事務狀態,執行對應的操作。
通過上面的討論,我們可以看到由于事務狀態分布在協調者和各個參與者之間,要保證兩階段提交的一致性是非常困難的。如果能夠保證協調者的可用性(Availablity),比如采用主備或者Paxos來實現,同時還需要保證各個參與者宕機后能夠重啟恢復,那么正確實現兩階段提交會簡單不少,讀者可以再分析一遍上述情況。
上面只討論了原子性、一致性和持久性,沒討論隔離性的實現,隔離性可以考慮采用鎖方案,實現簡單,但性能可能比較差,另一種就是前文介紹的MVCC方案,性能會好不少,但實現復雜。
兩階段提交協議本身存在的問題
即使解決了前面說的一些問題,兩階段提交協議還是存在一個問題,這個問題是協議本身存在的,這就是參與者資源阻塞。
阻塞問題分成兩個方面,一個方面是整個事務過程中,參與者上的相應資源會鎖住。設想一下在Prepare階段,如果有9個參與者,其中有一個沒有條件完成事務,其他8個參與者還是需要鎖住資源,寫redo/undo日志,然后在Commit階段,這8個參與者又需要回滾。另一個方面是如果協調者或者參與者宕機,必須等待超時或者服務器重啟后發起Commit或者Abort后才能釋放資源。
三階段提交
針對兩階段提交的問題,有人提出了三階段提交。三階段提交比兩階段提交多了一個PreCommit階段,流程如下:
Coordinator Participant
can_commit
QUERY
-------------------------------->
VOTE YES/NO check
<-------------------------------
pre_commit*
PREPARE TO COMMIT
-------------------------------->
VOTE YES/NO prepare*/abort*
<-------------------------------
do_commit*/abort*
COMMIT/ROLLBACK
-------------------------------->
ACKNOWLEDGMENT commit*/abort*
<--------------------------------
end
An * next to the record type means that the record is forced to stable storage.
第一階段CanCommit
- 協調者詢問所有參與者是否可以執行事務
- 參與者檢查是否可以執行事務,如果可以執行事務成功,回復Yes,如果不能,回復No
第二階段PreCommit
分兩種情況
CanCommit階段所有參與者回復
- 協調者向所有參與者發送PreCommit請求
- 參與者執行事務,對資源加鎖,寫redo/undo日志到磁盤
- 如果參與者執行事務成功,回復Yes,如果執行失敗,回復No
CanCommit至少有一個參與者回復No
- 協調者向所有參與者發送Abort請求
- 參與者取消事務
第三階段DoCommit
分兩種情況
PreCommit階段所有參與者回復Yes
- 協調者向所有參與者發送Commit請求
- 參與者執行Commit操作,釋放資源
- 參與者回復協調者ACK完成消息
- 協調者收到所有參與者完成消息后,完成事務
PreCommit至少有一個參與者回復No
- 協調者向所有參與者發送Rollback請求
- 參與者使用Undo日志回滾事務,釋放資源
- 參與者回復協調者回滾完成消息
- 協調者收到所有參與者回滾完成消息后,取消事務
三階段提交和兩階段提交相比,優點在于增加了CanCommit階段,這個階段資源不會加鎖,如果有某個參與者不能執行事務,不會阻塞其他參與者。但是三階段依然存在事務原子性和網絡分區問題。而且三階段增加了一個請求,整個事務的延遲會增加。
分布式事務模型
分布式事務實現上主要有四種模型:XA模型、TCC模型、Saga模型和MQ模型。
XA模型
XA模型是由X/Open組織制定的分布式事務規范和接口。
XA模型中有三種角色:
- AP(Application Program): 客戶端程序,定義事務的內容
- TM(Transaction Manager): 事務的管理者,也即兩階段提交的協調者
- RM(Resource Manager): 資源管理者,也即兩階段提交的參與者
XA接口規范如下:
XA模型的原子性通過兩階段提交實現,隔離性可以通過鎖或者MVCC實現,但是這里的鎖和MVCC不是單機下的,而是分布式鎖和分布式MVCC,實現起來并不容易。
XA模型嚴格保障事務ACID特性。事務執行過程中需要將資源鎖定,這樣可能會導致性能低下。因此eBay架構師Dan Pritchett提出了BASE理論。BASE是三個短語的縮寫:
- Basically Available: 基本可用,允許損失部分可用性
- Soft state: 軟狀態,允許數據存在中間狀態
- Eventually consistent: 最終一致性,數據最終會達到一個一致的狀態
BASE通過犧牲強一致性來獲得可用性和系統性能的提升。下面介紹的TCC、Saga、MQ都屬于BASE理論模型,滿足最終一致性。
TCC模型
TCC模型最早由Pat Helland于2007年發表的一篇名為《Life beyond Distributed Transactions:An Apostate’s Opinion》的論文提出。模型中,事務參與者需要實現Try, Confirm, Cancel三個接口。
- Try: 參與者檢查資源是否有效,預留資源。
- Confirm: 參與者提交資源。
- Cancel: 參與者執行回滾操作,恢復預留資源。
舉個例子,A向B轉賬100塊錢。事務由兩個參與者PA(Participator A)和PB(Participator B)組成,PA負責給A減100塊錢,PB負責給B加100塊錢。TCC模型流程如下:
Try階段
- PA檢查A賬戶是否有足夠的余額,凍結A賬戶的100塊,寫日志到磁盤。這個階段不需要鎖住A賬戶,其他事務可以對A賬戶操作,可以看到這100塊,但是不能對這100塊錢操作。
- PB檢查B賬戶的合法性。
如果PA和PB在Try階段都返回成功,進入Confirm階段
- PA減A賬戶的100塊,寫日志到磁盤。
- PB加100塊到B賬戶,寫日志到磁盤。
如果PA或者PB在Try階段返回失敗,進入Cancel階段
- PA恢復A賬戶的100塊,寫日志到磁盤,結束事務。
- PB結束事務。
TCC模型因為沒有在Try階段加鎖,所以性能高于兩階段提交。不同事務可以并發執行,只要參與者管理的剩余資源足夠。具體實現時,需要注意以下幾個問題:
- 每個參與者需要考慮如何將自身的業務拆分成Try, Confirm, Cancel這三個接口。
- 如果Try成功,需要保證Confirm一定能成功。
- 需要保證三個接口的冪等性。由于網絡超時,請求可能會重發,這時參與者需要保證操作的冪等性,不能重復執行同一個請求。
- 需要處理空Cancel操作。如果參與者沒有收到Try請求,協調者可能觸發Cancel請求的發送,這時協調者需要處理這種沒有收到Try請求,反而收到Cancel請求的情況。
- 需要處理先收到Cancel請求,后收到Try請求。如果由于網絡超時,參與者沒有收到Try請求,協調者可能觸發Cancel請求的發送,參與者先收到Cancel請求,然后之前超時的Try請求又發送到參與者。
- 和兩階段提交一樣,TCC模型也會遇到網絡分區,服務器宕機等問題。
TCC模型使用兩階段提交來實現原子性,但無法滿足隔離性。不同事務并發執行的時候,隔離性只能滿足讀未提交級別(Read Uncommited),而且是由參與者在Try接口中預留資源的方式實現的。
Saga模型
Saga模型由Hector & Kenneth于1987年提出。這個模型中,每個事務參與者需要提供一個正向執行模塊和逆向回滾模塊,執行模塊用于執行事務的正常操作,回滾模塊用于在失敗時執行回滾操作。
執行流程如下,其中Ti表示每個參與者的正向執行模塊,Ci表示每個參與者的逆向回滾模塊:
- 成功流程:T1 -> T2 -> T3 -> ... -> Tn
- 失敗流程:T1 -> T2 -> ... Ti (failed) -> Ci -> ... -> C2 -> C1
具體實現時,根據是否有協調者,有兩種實現方式:
- 基于事件的分布式方案(Events/Choreography):沒有集中式協調者,每個參與者訂閱其他參與者的事件,執行自己的業務,生成新的事件供其他參與者使用。
- 基于命令的協調者方案(Command/Orchestrator):由集中式協調者控制事務流程,協調者發送命令給各個參與者,參與者執行事務后發送結果給協調者。
舉個例子來說明這兩者的不同。一次完整的網絡購物由四個服務組成:訂單服務(Order Service)、支付服務(Payment Service)、庫存服務(Stock Service)和送貨服務(Delivery Service)組成。
基于事件的分布式方案的成功流程如下:
- 訂單服務創建訂單,發出訂單創建事件ORDER_CREATED_EVENT。
- 支付服務訂閱ORDER_CREATED_EVENT,執行支付操作,發出支付完成事件BILLED_ORDER_EVENT。
- 庫存服務訂閱BILLED_ORDER_EVENT,扣除庫存,發出貨物準備完成事件ORDER_PREPARED_EVENT。
- 送貨服務訂閱ORDER_PREPARED_EVENT,送貨,發出貨物交付事件ORDER_DELIVERED_EVENT。
- 訂單服務訂閱ORDER_DELIVERED_EVENT,標記事務完成。
基于事件的分布式方案的失敗流程如下:
庫存服務發現庫存不足,發出庫存不足事件PRODUCT_OUT_OF_STOCK_EVENT。
支付服務訂閱PRODUCT_OUT_OF_STOCK_EVENT,給用戶返回錢。
訂單服務訂閱PRODUCT_OUT_OF_STOCK_EVENT,標記訂單失敗。
基于命令的協調者方案的成功流程如下:
- 訂單服務創建訂單,發送訂單事務給協調者。
- 協調者發送支付命令給支付服務,支付服務執行支付,返回結果給協調者。
- 協調者發送準備訂單命令給庫存服務,庫存服務扣除庫存,返回結果給協調者。
- 協調者發送送貨命令給送貨服務,送貨服務送貨,返回結果給協調者。
- 協調者發送結果給訂單服務,標記事務完成。
基于命令的協調者方案的失敗流程如下:
- 庫存服務發現庫存不足,返回庫存不足結果給協調者。
- 協調者發送返錢命令給支付服務,支付服務返錢給用戶。
- 協調者發送失敗結果給訂單服務,標記事務失敗。
因為Saga模型是一階段,而TCC模型是兩階段,和TCC模型相比,Saga模型的性能要高一些,而且實現的時候要簡單一些。但因為Saga模型沒有Try階段預留操作,在回滾的時候就會麻煩不少。比如發郵件服務,正向階段已經發郵件給用戶,回滾的時候會對用戶不友好。
和TCC類似,Saga模型在實現時也需要注意冪等性,空操作,請求亂序等問題。另外Saga模型也可以滿足原子性,但無法滿足隔離性。不同事務并發執行的時候,隔離性只能滿足讀未提交級別(Read Uncommited)。
MQ模型
MQ模型使用消息隊列(Message Queue)來通知事務的各個參與者執行操作。
MQ模型流程如下:
Sponsor MQ Participant
PREPARE
--------------->
ACK prepare*
<---------------
commit*
COMMIT
--------------->
commit*/abort*
COMMIT
--------------->
ACK commit*
end <---------------
An * next to the record type means that the record is forced to stable storage.
- 事務發起者發送Prepare消息到MQ。
- MQ收到Prepare消息后,保存到磁盤,不發送給事務參與者,返回ACK給事務發起者。
- 事務發起者如果沒收到ACK,取消事務的執行,給MQ發送Abort消息。如果收到ACK,執行本地事務,給MQ發送Commit消息。
- MQ收到消息后,如果是Abort,刪除事務消息。如果是Commit,MQ修改消息狀態為可發送,并發送該事務消息給事務參與者。
- 事務參與者收到消息后,執行事務,然后發送ACK給MQ。
- MQ刪除事務消息,標記事務完成。
流程中幾個需要注意的地方:
- 第4步中,如果MQ沒有收到事務發起者發送的Commit/Abort消息,MQ會向發起者查詢事務狀態,根據狀態執行后續操作。
- 第5步中,如果MQ長時間沒有收到事務參與者的ACK消息,MQ會按照間隔(比如1分鐘,5分鐘,10分鐘,1小時,1天等)不斷重復發送Commit消息給事務參與者,直至收到ACK。
從以上流程可以發現MQ事務模型依賴于MQ支持事務消息,目前只有RocketMQ支持事務消息。如果不用RocketMQ,需要自己實現一個消息可靠性模塊,完成類似的功能。
MQ模型中,需要確保參與者一定能成功執行事務,參與者不能說自己沒有條件執行事務,比如支付服務作為參與者檢查發現用戶余額不足。所以MQ模型有很大的使用范圍限制。一般在邏輯上有可能失敗的操作(比如支付)需要由事務發起者完成,而事務參與者只執行一定會成功的操作(比如充話費、發送游戲道具等)。
和TCC,Saga模型一樣,MQ模型也需要注意事務參與者的冪等性。
各模型開源實現
支持Java項目,支持TCC和Saga模型,支持Spring Cloud和Dubbo
支持Java項目,支持TCC模型
支持Java項目,支持TCC、Saga和MQ模型
阿里開源的分布式事務方案,支持Java,支持AT(針對SQL數據庫)、TCC、Saga模型
華為開源的分布式事務方案,支持Java,支持Saga模型
參考:
MySQL InnoDB Update和Crash Recovery流程
InnoDB Repeatable Read隔離級別之大不同
The basics of the InnoDB undo logging and history system
Saga Pattern How to Implement Business Transactions Using Microservices