一文帶你深度解析MySQL 8.0事務提交原理

华为云开发者联盟發表於2024-08-13

摘要:當多個引擎/節點同時訪問和修改資料時,如何保證資料在各個引擎/節點之間的一致性成為了一項挑戰。本文將深入探討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事務中的三個參與角色分別是:

APPApplication Program,簡稱APP):應用程式,定義事務的開始和結束。

TMTransaction Manager,簡稱TM: 事務管理器,充當事務的協調者,監控事務的執行進度,負責事務的提交、回滾等。

RMResource 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中的XIDRedo log中的XID進行比較,XIDBinlog 裡存在則提交,不存在則回滾。我們來看崩潰恢復時具體的情況:

情況一:寫入Redo log後,處於Prepare狀態的時候崩潰了,此時:

由於Binlog還沒寫,Redo log處於Prepare狀態還沒提交,所以崩潰恢復的時候,這個事務會回滾,此時Binlog還沒寫,所以也不會傳到備庫。

情況二:假設寫完Binlog之後崩潰了,此時:

Redo log中的日誌是不完整的,處於Prepare狀態,還沒有提交,那麼恢復的時候,首先檢查Binlog中的事務是否完整(事務XIDBinlog裡中存在,標誌該事務已經完成),如果事務完整,則直接提交事務,否則回滾事務。

情況三:假設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恢復出的資料庫和原資料庫的資料一致性。

點選關注,第一時間瞭解華為雲新鮮技術~

相關文章