摘要:當多個引擎/節點同時訪問和修改數據時,如何保證數據在各個引擎/節點之間的一致性成為了一項挑戰。本文將深入探討MySQL集群在保持數據一致性的解決方案。 本文分享自華為雲社區《【華為雲MySQL技術專欄】MySQL 8.0事務提交原理解析!》,作者:GaussDB資料庫。 1. 概述 MySQL ...
摘要:當多個引擎/節點同時訪問和修改數據時,如何保證數據在各個引擎/節點之間的一致性成為了一項挑戰。本文將深入探討MySQL集群在保持數據一致性的解決方案。
本文分享自華為雲社區《【華為雲MySQL技術專欄】MySQL 8.0事務提交原理解析!》,作者:GaussDB資料庫。
1. 概述
MySQL是一個插件式、支持多存儲引擎架構的資料庫。一方面,MySQL支持一個事務跨多個引擎進行讀寫,使得資料庫系統具備良好的可擴展性和靈活性;另一方面,MySQL也支持一個事務跨多節點進行讀寫,通過分散式節點架構使MySQL消除了單點故障,提高資料庫系統的可靠性和可用性。
然而,當多個引擎/節點同時訪問和修改數據時,如何保證數據在各個引擎/節點之間的一致性成為了一項挑戰。
本文將深入探討MYSQL集群在保持數據一致性的解決方案。MySQL集群通過XA事務(X/Open Distributed Transaction Processing Model,簡稱X/Open DTP Model)解決了此問題。XA事務分為內部XA和外部XA事務,本文將聚焦內部XA的源碼實現。
2. XA事務
XA事務定義了三個參與角色(APP、TM、RM),並通過兩個階段實現分散式事務。
圖2.1 XA事務模型
- XA事務中的三個參與角色分別是:
APP(Application Program,簡稱APP):應用程式,定義事務的開始和結束。
TM(Transaction Manager,簡稱TM): 事務管理器,充當事務的協調者,監控事務的執行進度,負責事務的提交、回滾等。
RM(Resource Manager,簡稱RM): 資源管理器,充當事務的參與者,如資料庫、文件系統,提供訪問資源的方式。
- 實現分散式事務的兩個階段:
階段一: TM向所有的RM發出PREPARE指令,RM進行完成提交前的準備工作,並刷新相關操作日誌,此時不會進行事務提交。如果在PREPARE指令下發過程中某一RM節點失敗,則回滾事務,TM向所有RM節點下發ROLLBACK指令,防止數據不一致的情況發生。
階段二: 如果TM收到所有RM的成功消息,則TM向RM發出COMMIT指令,RM向TM返回提交成功的消息後,TM確認整個事務完成。如果任意一個RM節點COMMIT失敗,則TM嘗試重新下發COMMIT指令,嘗試失敗到上限次數後將返回報錯,整個事務失敗。
在單實例節點中,當Server層作為TM,多個存儲引擎作為RM,就會產生內部XA事務,MySQL利用內部事務保證了多個存儲引擎的一致性。外部XA事務一般是針對跨多MySQL實例的分散式事務,因此,外部XA的協調者是用戶的應用,參與者是MySQL節點。
外部XA事務與內部XA事務核心邏輯類似,同時給用戶提供了一套XA事務的操作命令,包括XA start,XA end,XA prepare和XA commit等。
3. 內部XA事務
在單個MYSQL實例中,使用內部XA事務來解決Server層Binlog日誌和Storage層事務日誌的一致性等問題。其中,Server層作為事務協調器,而多個存儲引擎作為事務參與者。
3.1 協調者對象tc_log
MySQL啟動時,包含了事務協調者的選擇。如果開啟了Binlog,並且存在事務引擎,則XA協調器為mysql_bin_log對象,使用Binlog物理文件記錄事務狀態;如果關閉了Binlog,且存在不少於2個事務引擎,則XA協調器為tc_log_mmap對象,使用記憶體結構來記錄事務狀態;其他情況(沒有事務引擎),則不需要XA,tc_log設置為tc_log_dummy 對象。
無論tc_log_dummy還是mysql_bin_log或tc_log_mmap都基於TC_LOG這個基類來實現的。TC_LOG是一個全局指針,作為事務提交的協調器,實現了事務的prepare,commit,rollback等介面。
圖3.1 TC_LOG類關係圖
mysql_bin_log,tc_log_mmap和tc_log_dummy作為協調者的基本邏輯如下:
mysql_bin_log作為協調者: prepare:ha_prepare_low commit:write-binlog + ha_comit_low tc_log_mmap作為協調者: prepare:ha_prepare_low commit:wrtie-xid + ha_commit_low tc_log_dummy作為協調者: prepare:ha_prepare_low commit:ha_commit_low
其中tc_log_dummy不會記錄事務日誌,只是做簡單的轉發,將Server層的調用路由到Storage層調用。tc_log_mmap是一個標準的事務協調者實現,它會創建一個名為tc.log的日誌並使用操作系統的記憶體映射(memory-map,mmap)機制將內容映射到記憶體中,tc.log文件中分為一個一個PAGE,每個PAGE上有多個XID(X/Open transaction IDentifier,全局事務唯一ID)。Binlog同樣基於TC_LOG來實現事務協調者功能,會遞增生成mysql-binlog.xxxx的文件,每個文件中包含多個事務產生的Binlog event,併在Binlog event中包含XID。tc_log_mmap和Binlog都基於XID來確定事務是否已提交。
本文主要關註於如何通過內部XA 保證Binlog和Redo log的一致性,即以Binlog作為協調器的場景,這裡的Binlog既是協調者也是參與者。
3.2 事務提交過程
如圖3.2為一個事務的執行過程,當客戶端發出COMMIT指令時,MYSQL內部將通過Prepare和Commit兩個階段完成事務的提交。
圖3.2 事務提交過程
Prepare階段,事務的Undo log設置為prepare狀態,寫Prepare Log(Prepare階段產生的Redo Log),將事務狀態設為TRX_PREPARED,寫XID(事務ID號)到Redo Log,同時把Redo Log刷新到磁碟中。
Commit階段,Binlog寫入文件並刷盤,同時也會把XID寫入到Binlog。調用引擎的Commit完成事務的提交,同時會對事務的Undo log從prepare狀態設置為提交狀態(可清理狀態),寫Commit Log(Commit階段產生的Redo log),釋放鎖、read view等,最後將事務狀態設置為TRX_NOT_STARTED狀態。
兩階段提交保證了事務在多個引擎之間的原子性,以Binlog寫入成功作為事務提交的標誌。
在崩潰恢復中,是以Binlog中的XID和Redo log中的XID進行比較,XID在Binlog 里存在則提交,不存在則回滾。我們來看崩潰恢復時具體的情況:
情況一:寫入Redo log後,處於Prepare狀態的時候崩潰了,此時:
由於Binlog還沒寫,Redo log處於Prepare狀態還沒提交,所以崩潰恢復的時候,這個事務會回滾,此時Binlog還沒寫,所以也不會傳到備庫。
情況二:假設寫完Binlog之後崩潰了,此時:
Redo log中的日誌是不完整的,處於Prepare狀態,還沒有提交,那麼恢復的時候,首先檢查Binlog中的事務是否完整(事務XID在Binlog里中存在,標誌該事務已經完成),如果事務完整,則直接提交事務,否則回滾事務。
情況三:假設Redo log處於Commit狀態的時候崩潰了,如果Binlog中的事務完整,那麼會重新寫入Commit標誌,並完成提交,否則回滾事務。由此可見,兩階段提交能夠確保數據的一致性。
一般常用的SQL語句都是通過公共介面mysql_execute_command來執行,我們來分析該介面執行的流程:
mysql_execute_command { switch (command) { case SQLCOM_COMMIT trans_commit(); break; } if thd->is_error() //語句執行報錯 trans_rollback_stmt(thd); else trans_commit_stmt(thd); }
MySQL的Server層有兩個提交函數trans_commit_stmt()和trans_commit()。前者在每個語句執行完成時調用,一般標記語句的結束。而後者是在整個事務真正提交的時候調用,一般對應顯示執行COMMIT語句,或開啟一個新事務BEGIN/START TRANSCATION,或執行一條非臨時表的DDL語句等場景。
3.3 多語句事務提交
多語句事務提交一般指BEGIN/COMMIT顯示事務,主要邏輯在trans_commit()中,以下是具體實現:
// mysql層進行的事務提交 int ha_commit_trans(THD *thd, bool all, bool ignore_global_read_lock) { Transaction_ctx *trn_ctx = thd->get_transaction(); // all為true,意味著當前是事務級提交範圍,否則是語句級提交範圍 Transaction_ctx::enum_trx_scope trx_scope = all ? Transaction_ctx::SESSION : Transaction_ctx::STMT ; // 獲得註冊在當前事務的引擎列表,在trans_register_ha()中初始化 Ha_trx_info *ha_info = trn_ctx->ha_trx_info(trx_scope); // 當前註冊的可讀可寫存儲引擎的數量,只有事務引擎支持讀寫 uint rw_ha_count = 0; // 檢查是否可以跳過兩階段提交機制 rw_ha_count = ha_check_and_coalesce_trx_read_only(thd, ha_info, all); trn_ctx->set_rw_ha_count(trx_scope, rw_ha_count); // Prepare 階段 if (!trn_ctx->no_2pc(trx_scope) && (trn_ctx->rw_ha_count(trx_scope) > 1)) error = tc_log->prepare(thd, all); } // Commit 階段 if (error || (error = tc_log->commit(thd, all))) { ha_rollback_trans(thd, all); goto end; } }
協調者如何確認是否走2PC(兩階段提交)邏輯?
這裡主要根據事務修改是否涉及多個引擎來決定,即函數ha_check_and_coalesce_trx_read_only()。特殊的是,如果打開Binlog,Binlog也會作為參與者而被考慮在內,最終協調者會統計事務中涉及修改的參與者數量。如果數量超過1個,則進行2PC提交流程。
當滿足以上條件,進入Prepare階段,調用Binlog協調器的prepare介面。Prepare階段,Binlog Prepare介面沒什麼可做,而InnoDB Prepare介面主要做的事情就是修改事務和Undo段的狀態,以及記錄XID。
InnoDB Prepare介面會把記憶體中事務對象的狀態修改為TRX_STATE_PREPARED,並將事務對應Undo段在記憶體中的對象狀態修改為TRX_UNDO_PREPARED。然後,把XID信息寫入當前事務對應日誌組的Undo Log Header中的XID區域。修改TRX_UNDO_STATE欄位值和寫入XID,這兩個操作都要修改Undo頁。修改Undo頁之前,會先記錄相應的Redo日誌。最後,刷事務更新產生的Redo日誌。
// innodb prepare,innodb層事務準備階段 static void trx_prepare(trx_t *trx) /*!< in/out: transaction */ { lsn_t lsn = 0; // 對於系統和undo表空間回滾段,如果有更新需要持久化到redo中 if (trx->rsegs.m_redo.rseg != nullptr && trx_is_redo_rseg_updated(trx)) { // lsn = mtr.commit_lsn(); 開啟第一個mtr,並返回寫入redo log buffer後的最新位點,提交時刻對應的lsn lsn = trx_prepare_low(trx, &trx->rsegs.m_redo, false); } // 對於臨時表空間回滾段,如果有更新不需要持久化到redo中 if (trx->rsegs.m_noredo.rseg != nullptr && trx_is_temp_rseg_updated(trx)) { trx_prepare_low(trx, &trx->rsegs.m_noredo, true); } // 更新事務和事務系統狀態信息 trx->state = TRX_STATE_PREPARED; trx_sys->n_prepared_trx++; // 釋放RC及以下隔離級別的GAP lock if (trx->isolation_level <= TRX_ISO_READ_COMMITTED) { trx->skip_lock_inheritance = true; lock_trx_release_read_locks(trx, true); } switch (thd_requested_durability(trx->mysql_thd)) { // thd初始化時預設設置為HA_REGULAR_DURABILITY case HA_REGULAR_DURABILITY: trx->ddl_must_flush = false; // redolog刷新 trx_flush_log_if_needed(lsn, trx); } }
緊接著進入2PC的Commit階段,trans_commit()調用binlog協調器的MYSQL_BIN_LOG::Commit()介面,功能集中在MYSQL_BIN_LOG::ordered_commit()函數中。到了Commit階段,一個事務就已經接近尾聲了。寫操作(包括增、刪、改)已經完成,記憶體中的事務狀態已經修改,Undo狀態也已經修改,XID信息也已經寫入Undo Log Header,Prepare階段產生的Redo日誌已經寫入到Redo日誌文件。剩餘的收尾工作,包括Redo日誌刷盤、事務的Binlog日誌從臨時存放點拷貝到Binlog日誌文件、Binlog日誌文件刷盤以及InnoDB事務提交。
// tc_log->commit ==> MYSQL_BIN_LOG::commit() MYSQL_BIN_LOG::commit() // 這個函數很重要,它包含了binlog組提交三步曲, int MYSQL_BIN_LOG::ordered_commit(THD *thd, bool all, bool skip_commit) { //1:Flush Stag:按照事務提交的順序,先刷Redo log到磁碟,然後把每個事務產生的 binlog 日誌從臨時存放點拷貝到 binlog 日誌文件緩存中 flush_error = process_flush_stage_queue(&total_bytes, &do_rotate, &wait_queue); //2: Sync Stage: binlog 日誌刷盤之前會進入等待過程,目的是為了攢到更多的binlog日誌後,合併IO單次刷盤 sync_binlog_file(false);//binlog fsync to disk //3: Commit Stage: 各線程按序提交事務 process_commit_stage_queue(thd, commit_queue); }
Redo Binlog日誌刷盤都涉及到磁碟IO。如果每提交一個事務,都把該事務中的 Redo日誌、Binlog日誌刷盤,那麼就會涉及到很多小數據量的IO操作,但是頻繁的小數量IO操作非常消耗磁碟的讀寫性能。
為了提高磁碟IO效率併進一步提升事務的提交效率,MySQL從5.6開始引入了Binlog日誌組提交功能。該功能將事務的Commit階段細分為3個子階段。對於每個子階段,都可以有多個事務同時處於該子階段,寫日誌和刷盤操作可以合併。
- Flush子階段,先將Redo日誌刷盤,接著將所有的binlog caches寫入到binlog文件緩存中。
- Sync子階段,對binlog文件緩存做fsync操作,多個線程的 binlog 合併為一次刷盤。
- Commit子階段,依次將redolog中已經prepare的事務在引擎層提交,commit階段不用刷盤,因為flush階段中的redolog刷盤已經足夠保證資料庫崩潰時的數據安全了。當前Commit子階段主要包含了InnoDB層的事務提交,真正執行事務提交入口函數為trx_commit_low()。trx_commit_low()主要分成兩個部分trx_write_serialisation_history()和trx_commit_in_memory()。trx_write_serialisation_history()處理整個事務執行過程中所使用insert/update的回滾段的收尾工作。trx_commit_in_memory()在記憶體中設置事務提交的標誌trx->state = TRX_STATE_COMMITTED_IN_MEMORY,本事務的數據可以即刻被其他事務可見;在設置事務提交已經完成的標誌後,才會釋放當前事務的Read View和事務過程中所持有的table lock和record lock,清除trx_sys系統中的當前事務等。
3.4 單語句事務提交
從SQL的執行過程分析可以看到,無論執行何種語句,最後都會執行trans_commit_stmt(),即單語句提交函數。如果當前是單語句事務,一般指AUTOCOMMIT為ON的場景,那麼會走事務提交邏輯,即ha_commit_trans()函數。額外考慮到COMMIT和DDL語句等已經在調用trans_commit_stmt()之前將事務提交,所以在這裡只需要標記語句結束即可。
// 執行單語句事務提 bool trans_commit_stmt(THD *thd, bool ignore_global_read_lock) { int res = false; // 單語句事務,需要走2PC提交邏輯 if (thd->get_transaction()->is_active(Transaction_ctx::STMT)) { res = ha_commit_trans(thd, false, ignore_global_read_lock); } else if (tc_log) // COMMIT/DDL等,只需要走引擎層提交邏輯,置為false,只標識語句結束,跳過真正提交階段 res = tc_log->commit(thd, false); thd->get_transaction()->reset(Transaction_ctx::STMT); return res; }
ha_commit_trans()最後會走到innobase_commit()中,innobase_commit()中的參數commit_trx控制是否真的進行存儲引擎層的提交處理,trans_commit_stmt()里會設置 commit_trx為0,允許跳過事務提交。
這裡的判斷邏輯是,只有當commit_trx= 1或者設置autocommit=1的情況下,才會真正進入事務提交邏輯。而多語句事務對應的trans_commit()函數里會設置commit_trx=1,進入innobase_commit_low()執行真正的事務提交邏輯。
/** 在innodb層提交一個事務 thd:需要提交事務的會話 commit_trx:true,需要提交事務。false,跳過事務提交。 */ static int innobase_commit(handlerton *hton, THD *thd, bool commit_trx) { trx_t *trx = check_trx_exists(thd); // innobase_commi僅在“真正的”commit時被調用,而且在每個語句之後(走trans_commit_stmt()函數)也被調用,因此這裡需要will_commit判斷是否要真正去提交事務。 bool will_commit = commit_trx || (!thd_test_options(thd, OPTION_NOT_AUTOCOMMIT | OPTION_BEGIN)); // autocommit=1且不在顯示事務塊中 if (will_commit) { /* 在顯示提交commit,或者autocommit=1、且不在顯示事務塊內*/ innobase_commit_low(trx); } else { /* 其他情況,我們只是標記SQL語句結束,不做事務提交 */ trx_mark_sql_stat_end(trx); } return 0; }
4. 總結
本文從多語句/單語句事務提交原理角度出發,介紹了MySQL的兩階段提交協議。在prepare階段,InnoDB把數據更新到記憶體後記錄Redo log,此時Redo log的狀態為prepare狀態;在Commit階段,Server生成Binlog後落盤,InnoDB把剛寫入的Redo log狀態更新為commit狀態。兩階段提交保證了事務在多個引擎和Binlog之間的原子性,同樣保證了通過備份和Binlog恢復出的資料庫和原資料庫的數據一致性。