資料庫分散式事務XA規範介紹及Mysql底層實現機制【原創】
1. 引言
分散式事務主要應用領域主要體現在資料庫領域、微服務應用領域。微服務應用領域一般是柔性事務,不完全滿足ACID特性,特別是I隔離性,比如說saga不滿足隔離性,主要是通過根據分支事務執行成功或失敗,執行相應的前滾的重試或者後滾的補償操作來達成全域性事務的最終一致性,但是全域性事務與全域性事務之間沒有隔離性。
筆者瞭解到的分散式事務方案有2PC的XA規範,以及Google 的percolator方案(TiDB就採用這個實現,本質上是基於全域性時間戳的樂觀鎖版本校驗)。
mysql的XA應用場景分為外部XA與內部XA,內部XA用於binlog與stroage engine之間,協調binlog與redo事務寫入的原子性。外部XA用於mysql節點與mysql節點之間,協調跨物理庫之間的原子性。本文主要介紹外部XA。
基於mysql的XA兩階段事務提交(2PC)分散式事務,需要一個事務協調器(TransactionManager)來接受應用提交的全域性事務(Global Transaction),全域性事務經過TM的分解後,分解成多個分支事務(Branch Transaction),每個分支事務在具體的某個mysql例項上執行,其中mysql作為資源管理器(Resource Manager)。
在實際的分散式資料庫的分散式事務的開發中,一般選擇DBProxy作為TM載體,比如騰訊的TDSQL和阿里的POLARDB-X的分散式事務方案,都是這樣的實現。
XA的2PC提交流程的主要處理邏輯在事務協調器(Transaction Manager),一般選擇DBProxy作為TM載體,如果DBProxy用Java開發,可以參考Atomikos的實現
2. XA協作流程
圖2.1 XA的2PC協作流程
XA的2PC提交流程如圖2.1,主要分為以下幾個步驟。
1) App傳送start global transaction到TM,TM生成全域性事務ID,xid
2) App傳送global transaction語句到TM,TM根據具體的Sharding演算法分解出 branch transaction,並且傳送到各個mysql節點。
3) App傳送commit語句到TM,TM往各個branch transaction的mysql節點發送XA prepare‘xid’語句。
4)TM收集各個Prepare語句的響應,如果各個響應都是OK,則向每個branch transaction的mysql節點發送XA commit 'xid'語句,如果各個RM響應有不OK的,往每個RM 上傳送XA rollback 'xid'語句。
3. XA優化與異常處理
優化1:持久化事務協調階段的各個狀態
TM作為一個單點的事務協同器,很有可能宕機,出現單點故障。其本身的職責主要是事務協調,屬於無狀態的服務。宕機重啟後,可以根據持久化的全域性事務狀態來恢復TM的執行邏輯,所以,需要將階段的各個協調階段以及該階段中每個RM的執行狀態持久化到獨立的DB中,多個TM共享一個持久化DB。具體的階段有,prepare階段的子階段有branch_tansaction_ send、prepare_send、prepare_ack階段,commit階段的子階段有commit_send、commit_ack階段,記錄每個子階段每個RM的執行狀態
優化2:並行傳送語句
在branch_tansaction_ send、prepare_ send、commit_send階段,如果TM往RM傳送語句是序列執行的,單個global transaction的執行時間加長,TM的TPS(每秒事務請求數)會降低,可以在這些階段將已生成的語句,通過執行緒池並行傳送到各個RM,TM同時同步等待語句的返回值,延時大為降低。
異常1:TM在prepare_send階段前宕機,重啟恢復後,繼續執行prepare_send動作。
異常2:TM在prepare_send階段時宕機,可能會有部分RM收到prepare語句,部份沒有收到,重啟後,往收到prepare語句的RM傳送rollback語句。
異常3:TM在prepare_ack階段記錄完各個RM的執行狀態後宕機,重啟後,根據日誌狀態發起rollback或者commit語句。
異常4:TM在commit_send階段時宕機,可能會有部分RM收到commit語句,部份沒有收到,重啟後,往沒有收到commit語句的RM傳送commit語句。
異常5:TM在commit_ ack階段記錄完各個RM的執行狀態後宕機,重啟後,根據日誌狀態發起重試commit語句或者不操作。
異常6:RM超長時間沒有收到TM的rollback或者commit語句,一直持有記錄鎖,RM要有自動rollback或者commit的功能。
4. 2PC與1PC對比
XA的兩階段提交,直觀感覺和RM的互動次數太多,RPC次數太多,影響單個全域性事務的響應時間,TPS肯定降低。但是,prepare階段有存在的意義,如果某個單機事務處於prepare狀態,一直沒有commit,mysql重啟時,進行崩潰恢復時,如果binlog中沒有該事務,對該事務進行rollback,如果有,則對該事務進行commit。
XA兩階段提交滿足了事務的ACID屬性,原子性:在prepare和commit階段保障了事務的原子性。隔離性:通過mysql原生的記錄鎖,做到讀寫隔離。永續性:基於mysql單機事務的redo實現了永續性。一致性:基於mysql單機事務。
如果放棄prepare階段,只有commit階段,全域性事務的原子性無法保障,例如這個場景,全域性事務的部分分支事務commit成功,另一部分分支事務commit失敗,此時全域性事務就處於既不能commit成功,也不能rollback成功,因為已經成功commit的分支事務無法rollback。
即使通過解析binlog,生成反向SQL進行補償達到rollback的效果,此時也會多產生一次互動,RPC次數和兩階段提交是一樣的了。但是此時又引發一個新問題,全域性事務的隔離性難以保障,因為另一個全域性事務2可能會修改此時全域性事務1的已經commit了的記錄,而全域性事務1正在反向補償同一條已經commit了的記錄。
即使通過以下方法達到了隔離性,只滿足Read Commited隔離級別,Repeated Read等隔離級別沒有實現,而且隔離的粒度比較大,記錄上的Xid,相當於一把記錄寫鎖。
在每個記錄上,增加一個欄位全域性事務ID(Xid),只有滿足以下兩個條件之一方可訪問該記錄。
1)記錄上Xid是本全域性事務的Xid,
2)記錄上Xid不是本全域性事務ID,且該Xid已經不活躍
總結,TM和各個RM都處於完全正常的情況下,1PC的效能比起2PC會好,尤其是TPS。但是在RM處於異常的場景下,例如全域性事務的部分分支事務commit成功,另一部分分支事務commit失敗。1PC的TPS可能跟2PC差不多。
5. XA各個階段的Mysql處理流程
上圖為XA規範圖,規範中xa_open與xa_close不會頻繁呼叫,TM與RM要維持資料庫長連線,避免頻繁的建立、銷燬資料庫連線的開銷。
上圖5.2為mysql內部Xa的流程圖。
xa_start與xa_end起到標識分支事務的作用,具體由mysql服務端Sql_cmd_xa_start::trans_xa_start()函式與Sql_cmd_xa_end::trans_xa_end()函式實現
Sql_cmd_xa_start::trans_xa_start() 把thd->get_transaction()->xid_state設定為XID_STATE::XA_ACTIVE狀態
Sql_cmd_xa_end::trans_xa_end()檢查thd->get_transaction()->xid_state必須為XID_STATE::XA_ACTIVE狀態
6. mysql原始碼跟蹤
xa_prepare內部函式呼叫流程
1 mysql_execute_command() 2 case SQLCOM_XA_PREPARE: 3 res= lex->m_sql_cmd->execute(thd); 4 Sql_cmd_xa_prepare::execute(THD *thd) 5 Sql_cmd_xa_prepare::trans_xa_prepare(THD *thd) 6 ha_prepare(THD *thd) 7 innobase_xa_prepare 8 trx_prepare_for_mysql(trx_t* trx) 9 trx_prepare() 10 trx_prepare_low() 11 trx_undo_set_state_at_prepare() 修改undolog狀態為prepare狀態 12 mlog_write_ulint() 寫redo buffer 13 mtr_commit(&mtr)將redo buffer寫入redo log file,並將髒頁掛載在buffer pool的flushlist,可以看出寫undo segment也需要redo保護View Code
xa_commit內部流程
1 mysql_execute_command() 2 case SQLCOM_XA_COMMIT: 3 res= lex->m_sql_cmd->execute(thd); 4 Sql_cmd_xa_commit::execute(THD *thd) 5 Sql_cmd_xa_commit::trans_xa_commit(THD *thd) 6 MYSQL_BIN_LOG::commit 7 ha_commit_low 8 innobase_commit 9 innobase_commit_low 10 trx_commit_for_mysql() 11 trx_commit() 12 trx_commit_low() 13 trx_commit_in_memory() 14 lock_trx_release_locks() 釋放事務的記錄鎖 15 trx_flush_log_if_needed() 重新整理redo buffer到redo log 16 log_write_up_to(lsn, flush); 17 log_write_flush_to_disk_low() 具體刷盤動作View Code
分支事務update處理流程
1 mysql_execute_command() 2 case SQLCOM_UPDATE: 3 res= lex->m_sql_cmd->execute(thd); 4 Sql_cmd_update::execute(THD *thd) 5 try_single_table_update 6 open_tables_for_query(THD *thd, TABLE_LIST *tables, uint flags) 7 open_and_process_table 8 open_table() 9 mysql_update 10 table->init_cost_model() 11 ha_innobase::info 12 ha_innobase::info_low獲取統計資訊 13 test_quick_select()根據代價模型,獲取開銷最低的表訪問方式,如range\table scan\index scan 14 ha_innobase::try_semi_consistent_read(true),請求儲存引擎開啟半一致性讀,在update 或者delete的語句中。 15 init_read_record設定資料掃描方法,如rr_quick,rr_sequential 16 handler::ha_rnd_init 17 ha_innobase::rnd_init,初始化c 18 rr_sequential 19 handler::ha_rnd_next掃描一條記錄 20 ha_innobase::rnd_next() table scan讀取第一條記錄 21 row_search_mvcc() 22 sel_set_rec_lock() 在一條記錄上加鎖 23 lock_clust_rec_read_check_and_lock在聚集索引上加記錄鎖 24 lock_rec_lock加記錄鎖 25 handler::ha_update_row 26 binlog_log_row 27 THD::binlog_update_row記錄row格式的binlog 28 ha_innobase::update_row(old_row,new_row) 29 row_upd_clust_rec() 更新聚集索引記錄 30 trx_undo_report_row_operation() 記錄undo資訊 31 trx_undo_assign_undo() 分配回滾段 32 trx_undo_page_report_modify() 在回滾段中記錄聚集索引的更改 33 row_upd_rec_in_place() 更新操作寫入聚集索引 34 row_upd_rec_in_place_log()更新操作寫入redo buffer 35 mtr_t::commit() 將redo buffer寫入redo日誌檔案,並將髒頁掛載在buffer pool的flushlistView Code
&n