MySQL 事務兩階段提交原理簡析
引言
MySQL 中的日志非常重要,包括實例內的事務以及實例間的主從復制均基于日志實現。
計劃通過多篇文章分析多種日志,從而串聯日志、事務、復制三個模塊之間的關系,本文是第一篇文章,介紹兩階段提交。
其中首先介紹為什么需要兩階段提交,然后簡單分析兩階段提交的實現,期間介紹相關知識點,包括分布式事務與崩潰恢復。
概念
兩份日志
MySQL 中最重要的兩份日志是 redo log 與 binlog。
為什么會有兩份日志,原因是使用場景不同。
其中:
- redo log 用于實現事務的持久性,具體是通過 crash-safe 能力;
- binlog 用于實現主從復制與數據恢復。
兩份日志主要有以下三點不同;
- redo log 是 InnoDB 存儲引擎層實現的特有的日志,binlog 是 Server 層實現的通用的日志;
- redo log 是物理日志,binlog 是邏輯日志;
- redo log 是循環寫入,binlog 是追加寫入。
兩階段提交
為了保證兩份日志之間的邏輯一致,也就是數據與備份的一致性,引入兩階段提交(two-phase commit protocol,2PC)。
為什么需要兩階段提交,那么如果沒有兩階段提交,會發生什么呢?
由于 redo log 和 binlog 是兩個獨立的邏輯,如果不用兩階段提交,要么就是先寫完 redo log 再寫 binlog,或者采用反過來的順序。
假設執行 update,將值從 1 改為 2。
假設:
- 先寫 redo log 后寫 binlog,如果 redo log 寫完后 MySQL 進程異常重啟,redo log 崩潰恢復后值為 2,但是基于 binlog 備份恢復值為 1,并導致備份恢復少了一個事務;
- 先寫 binlog 后寫 redo log,如果 binlog 寫完后 MySQL 進程異常重啟,基于 binlog 備份恢復值為 2,但是 redo log 還沒寫因此崩潰恢復后事務無效,值為 1,并導致備份恢復多了一個事務。
顯然,如果沒有兩階段提交,無法保證數據與日志的一致性。
那么,有兩階段提交時會怎么樣呢?
首先,介紹下兩階段提交的過程,其中將 redo log 的提交拆分為兩個步驟,包括 prepare 與 commit,期間寫入 binlog。
因此,如果在兩階段提交的不同時刻,MySQL 異常重啟會發生什么呢?
- 如果在時刻 A 重啟,也就是 redo log prepare 之后,寫入 binlog 之前,崩潰恢復時發現 redo log 沒有 commit,因此回滾。binlog 還沒寫,因此不會傳到備庫,數據與日志保持一致;
- 如果在時刻 B 重啟,也就是寫入 binlog 之后,redo log commit 之前,崩潰恢復時發現 redo log 雖然沒有 commit,但是 redo log 有完整的 prepare,且對應的事務 binlog 完整,因此提交事務。binlog 寫入,因此會傳到備庫,數據與日志保持一致。
崩潰恢復
從前一節的描述中可以發現崩潰恢復時根據兩階段提交的進度進行處理。
參考 MySQL 45 講,崩潰恢復(crash-recovery)時的完整判斷邏輯為:
- 如果 redo log 里面的事務完整,也就是已經有了 commit 標識,直接提交;
- 如果 redo log 里面的事務只有完整的 prepare,進一步判斷對應的事務 binlog 是否存在且完整:
- 如果是,提交事務;
- 否則,回滾事務。
因此,redo log prepare 后 commit 前崩潰恢復時可能發生回滾或提交,具體與 binlog 的完整性有關。
顯然,時刻 B 發生 crash 的情況對應 redo log prepare 完整,且 binlog 完整的場景,因此事務提交。
這里可以提出以下兩個問題:
1)如何判斷 binlog 完整
2)如何根據 redo log 定位對應的 binlog
接下來分別回答這兩個問題。
1)如何判斷 binlog 完整
判斷 binlog 的完整性有以下兩種方式:
- 在事務提交時記錄 XID event 到 binlog 中以標記事務的結束。這個機制確保了事務的完整性和一致性,無論使用哪種復制格式;
- 在 MySQL 5.6.2 版本以后,還引入了 binlog-checksum 參數,用于驗證 binlog 內容的正確性。通過為 binlog 中的每個事件添加校驗和(checksum),MySQL 能夠檢測到寫入 binlog 時由于磁盤錯誤等原因導致的數據損壞。
如下所示,測試顯示 row 與 statement 兩種 bnlog 格式中事務的最后一個 event 都是 XID event。
2)如何根據 redo log 定位對應的 binlog
redo log 與 binlog 有一個共同的數據字段,稱為 XID。
崩潰恢復的時候,會按順序掃描 redo log:
- 如果碰到既有 prepare、又有 commit 的 redo log,就直接提交;
- 如果碰到只有 parepare、而沒有 commit 的 redo log,就拿著 XID 去 binlog 找對應的事務。
其中:
- redo log 掃描的起點是 InnoDB 最后一次 checkpoint 操作的 lsn(last_checkpoint_lsn)。
- XID 與分布式事務有關,下一節中介紹。
這里可以提出另一個問題,根據事務的持久性,到什么進度后事務將無法回滾?
理論上 MySQL 中通過 redo log 實現事務的持久性,因此 redo log 刷盤后就可以保證對數據庫的修改是永久性的,即使發生崩潰也不會丟失,當然也不會回滾。
不過根據事務的兩階段提交協議,binlog 寫入代表事務提交,同樣不可能發生回滾。
因此,事務無法回滾的關鍵點是事務的提交,而不是單純的 redo log 或 binlog 的寫入。在事務提交的過程中,兩階段提交機制確保了 redo log 和 binlog 的一致性,這個提交過程標志著事務從可回滾轉變為不可回滾。
XA 事務
分布式事務是一種跨多個獨立的數據庫、系統或網絡區域的事務處理方法。
XA 事務是一種遵循 XA 規范的分布式事務,因此 XA 事務是分布式事務的一種實現。
XA 事務依賴兩階段提交(2PC)協議實現分布式事務的一致性和原子性。
兩階段提交是最常見的分布式事務協議,用于保證分布式事務的原子性,顯然并不是 MySQL 獨有的。
根據 XA 規范,兩階段提交的實現過程中包括兩個角色:
- 資源管理器(Resource Manager),可以稱為執行器,用于管理分布式數據庫的一個本地事務;
- 事務管理器(Transaction Manager),可以稱為協調器,用于協調事務的提交、回滾、崩潰恢復。
兩階段提交中將提交操作分為兩個階段:
- prepare 階段,協調器詢問所有執行器,是否可以提交事務,如果任何一個執行器的本地事務無法提交時,分布式事務都需要通知所有執行器進行回滾操作;
- commit 階段,協調器在收到每一個執行器的提交確認后,通知執行器各自提交自己的本地事務。
MySQL 中的 XA 事務分為外部 XA 與內部 XA。其中:
- 外部 XA,MySQL 服務器作為執行器,連接服務器的客戶端程序作為協調器,對應多個支持分布式事務的數據庫實例,比如多套 MySQL(使用分庫分表中間件)、Oracle + MySQL;
- 內部 XA,對應單個 MySQL 實例,分為以下兩種場景:
- 沒有開啟 binlog,SQL 語句涉及一個或多個支持事務的存儲引擎;
開啟 binlog,SQL 語句涉及一個或多個支持事務的存儲引擎。
其中,由于 binlog 與存儲引擎是獨立單元,可以將 binlog 也看作一個存儲引擎,因此需要通過 XA 事務實現 binlog 與存儲引擎的數據一致性和原子性,從而保證全部操作要么全部提交,要么全部回滾。
在分布式事務中,XID作為全局事務的唯一標識符,用于跟蹤和協調不同數據庫實例中的事務部分。這個標識符在事務的所有參與者之間是共享的,以確保事務的一致性和完整性。
因此在 XA 事務中,XID用于在多個數據庫實例之間協調事務。
在 MySQL 中,XID(Transaction Identifier)是事務的唯一標識符,用于標記事務的提交。
binlog 中一個事務由一系列事件(event)組成,這個序列由 BEGIN 事件開始,以 XID 事件結束(對于提交的事務)。
因此如果事務被回滾,不會記錄 XID 事件,而是記錄一個 ROLLBACK 事件。
參考 chatgpt,XID 與 GTID 的主要區別包括:
- XID:是事務的標識符,用于標記事務的結束,主要用于事務的恢復和復制過程中確定事務邊界。對于分布式事務,所有 MySQL 實例使用相同的 XID 來提交事務;
- GTID(全局事務標識符):是 MySQL 5.6 及更高版本中引入的,用于唯一標識每個事務。每個 GTID 都是全局唯一的,即使在不同的 MySQL 實例中也是如此。GTID 使得跟蹤和復制事務變得更加簡單和可靠。
實現
prepare
參考文章 MySQL 事務二階段提交 與 MySQL 核心模塊揭秘 | 07 期 | 二階段提交 (1) prepare 階段,prepare 階段做的事情分為兩類:
- binlog prepare,對應 binlog_prepare 函數,什么都不做;
- InnoDB prepare,對應 innobase_xa_prepare 函數,具體做五件事情:
- 把分配給事務的所有 Undo segment 的狀態 TRX_UNDO_STATE 從 TRX_UNDO_ACTIVE 修改為 TRX_UNDO_PREPARED;
- 把事務 XID 寫入所有 Undo segment 中當前提交事務的 Undo Log Segment Header;
- 把內存中的事務對象狀態從 TRX_STATE_ACTIVE 修改為 TRX_STATE_PREPARED,標識事務已經進入二階段提交的 prepare 階段;
- 如果當前提交事務的隔離級別是讀未提交(READ-UNCOMMITTED)或讀已提交(READ-COMMITTED),InnoDB 會釋放事務給記錄加的共享、排他 GAP 鎖;
- 調用 trx_flush_logs(),處理 redo log 刷盤的相關邏輯,其中實際上并不會將 redo log 刷盤,也就是同樣什么都不做。
其中 undo log 非常重要,原因是:
- TRX_UNDO_STATE 用于崩潰恢復過程中,標記哪些事務需要恢復,哪些事務不用恢復。
- XID 用于崩潰恢復過程中,決定數據庫崩潰時處于 prepared 階段的事務,是要回滾還是要提交。
參考文章 XA事務與兩階段提交。
Undo頁面鏈表的第一個頁面的結構見下圖,其中記錄了一些關于這個事務的一些屬性。
其中 Undo Log Segment Header 結構見下圖,其中 TRX_UNDO_STATE 字段表示事務所處的狀態。
其中 Undo Log Header 結構見下圖。
其中:
- TRX_UNDO_XID_EXISTS:表示有沒有 XID 信息;
- XID信息:表示具體的 XID 是什么。
TRX_UNDO_STATE 的取值包括:
- TRX_UNDO_ACTIVE:活躍狀態,也就是一個活躍的事務正在往這個段里邊寫入 undo log;
- TRX_UNDO_CACHED:被緩存的狀態。處在該狀態的 Undo 頁面鏈表等待著之后被其他事務重用;
- TRX_UNDO_TO_FREE:對于 insert undo 鏈表來說,如果在它對應的事務提交之后,該鏈表不能被重用,那么就會處于這種狀態。Undo 頁面鏈表可以被馬上清理;
- TRX_UNDO_TO_PURGE:對于 update undo 鏈表來說,如果在它對應的事務提交之后,該鏈表不能被重用,那么就會處于這種狀態。Undo 頁面鏈表不可以被馬上清理,而是加入 History 鏈表用于 MVCC,等待 purge 線程清理;
- TRX_UNDO_PREPARED:包含處于 prepare 階段(這個階段是在分布式事務中會出現)的事務產生的 undo log。
commit
commit 階段做的事情同樣分為兩類:
- binlog 刷盤,對應 flush 函數,將事務執行過程中產生的 binlog 寫入硬盤;
- InnoDB commit,對應 innobase_commit 函數,完成存儲引擎層面的事務提交。
具體 commit 階段的實現與組提交有關,計劃下一篇文章中介紹。
因此,在客戶端執行 commit 語句或自動 commit 時,MySQL 開啟內部 XA 事務,分兩階段完成 XA 事務的提交。
崩潰恢復
崩潰恢復全過程分為多個階段,其中與事務兩階段提交有關的階段包括:
- 恢復數據頁,通過 doublewrite buffer 修復部分頁寫入(partial page write)導致的數據頁損壞;
- 讀取 redo log,從 last_checkpoint_lsn 開始讀取 redo log;
- 應用 redo log 到數據頁,將沒有寫入數據頁的日志重做一遍,從而保證事務的持久性;
- 初始化事務子系統,從 undo 表空間文件讀取未完成的事務;
- 處理未完成事務,其中:
- 如果事務 XID 對應 binlog 已寫入文件,事務提交;
- 如果事務 XID 對應 binlog 未寫入文件,事務回滾。
- 清理已提交事務,對應 TRX_STATE_COMMITTED_IN_MEMORY,包括 DDL 與 DML;
- 回滾未提交事務,對應 TRX_STATE_ACTIVE,包括 DDL 與 DML;
- 處理 prepare 事務,對應 TRX_STATE_PREPARED,其中:
未完成事務的狀態可能是以下三種之一:
- TRX_STATE_ACTIVE,表示事務還沒有進入提交階段。
- TRX_STATE_PREPARED,表示事務已經提交了,但是只完成了二階段提交的 PREPARE 階段,還沒有完成 COMMIT 階段。
- TRX_STATE_COMMITTED_IN_MEMORY,表示事務已經完成了二階段提交的 2 個階段,還剩一些收尾工作沒做,這種狀態的事務修改的數據已經可以被其它事務看見了。
其中未提交事務 TRX_STATE_ACTIVE 對應 redo log 已經刷盤的未提交事務,包括以下三種場景:
- 后臺線程定時將 redo log buffer 中的日志刷盤時將事務執行中間過程的 redo log 持久化到磁盤;
- redo log buffer 占用的空間即將達到 innodb_log_buffer_size 一半時,后臺線程會主動寫盤,即使事務并沒有提交;
- 并行的事務提交時,順帶將這個事務的 redo log buffer 持久化到磁盤。假設一個事務 A 執行到一半,已經寫了一些 redo log 到 buffer 中,這時候有另外一個線程的事務 B 提交,如果 innodb_flush_log_at_trx_commit 設置的是 1,那么按照這個參數的邏輯,事務 B 要把 redo log buffer 里的日志全部持久化到磁盤。這時候,就會帶上事務 A 在 redo log buffer 里的日志一起持久化到磁盤。
因此,為了保證事務的原子性,需要在崩潰恢復時將這些未提交事務回滾,而找到這些未提交事務依賴 undo log。
結論
MySQL 通過事務的兩階段提交實現數據與日志的一致性。
其中數據指 redo log,日志指 binlog,可以認為是兩個不同的存儲引擎,因此基于分布式事務的 XID 協議實現一致性。
具體實現中將 redo log 的提交拆分為兩個步驟,包括 prepare 與 commit,期間寫入 binlog。
因此,寫入的不同階段異常重啟時:
- redo log commit crash,binlog 完整,因此事務提交;
- binlog crash,redo log 沒有 commit,且沒有寫入 binlog,因此事務回滾。
具體是在崩潰恢復過程中基于兩階段提交保證事務的一致性。
其中:
- redo log application 階段用于將沒有寫入數據頁的日志重做一遍,把系統恢復到崩潰前的狀態,其中都是提交,沒有回滾;
- 初始化事務子系統階段從表空間中找到各個 Undo 頁面鏈表的首個頁面的頁號,然后根據事務的狀態處理未完成事務。其中:
- TRX_STATE_ACTIVE,表明是未提交事務,因此回滾事務;
- TRX_STATE_PREPARED,進一步判斷 XID 對應 binlog 是否存在,如果有,提交事務,否則回滾事務;
- TRX_STATE_COMMITTED_IN_MEMORY,表明是已提交事務,因此提交事務,具體是清理已提交事務。
因此,可以將崩潰恢復過程中使用的日志的順序理解為 redo log、undo log、binlog。