|GTID實踐和分析

語言: CN / TW / HK

提示:公眾號展示程式碼會自動折行,建議橫屏閱讀

「第一部分 GTID簡介」

在MySQL5.6引入了GTID(Global Transaction Identifier)特性,它可以在叢集中唯一標識一個事務,在MySQL主從複製時,從節點可以使用GTID來確定複製位點,用於取代使用binlog檔案偏移量的傳統方式,在發生主備切換時從節點可以自動在新主上找到正確的複製位置,大大簡化了複雜複製拓撲下叢集的維護,也減少了人為設定複製位點發生誤操作的風險,另外,基於GTID的複製可以跳過已經執行過的事務,減少了資料發生不一致的風險。

「第二部分 GTID實踐和分析」

【GTID的組成】

GTID在實現上是由 server_uuid + gno 組成的,例如:3E11FA47-71CA-11E1-9E33-C80AA9429562:23 。server_uuid 是在server啟動時生成的128位的uuid, gno 是序列號(sequence number),在每臺mysql伺服器上從1開始順序遞增,是事務在該例項上的唯一標識。同一個例項上的GTID一般情況下是連續的,例如 3E11FA47-71CA-11E1-9E33-C80AA9429562:1-100 ,如果這組 GTIDs 來自不同的例項,各組例項之間用逗號分隔;如果gno有多個範圍區間,則各組範圍之間用冒號分隔,例如:3E11FA47-71CA-11E1-9E33-C80AA9429562:23, 3E11FA47-71CA-11E1-9E33-C80AA9429562:1-5

舉個例子,show slave status時可以看到在slave上executed_gtid_set包含了兩組GTID,分別是slave上自身執行gtid和和來自主節點上的事務GTID:

【GTID的生命週期】

  1. 主節點在執行和提交一個事務的時候會給這個事務它負責判斷該例項上最小未被使用的事務序號作為GTID的gno分配一個GTID。如果事務不需要寫入binlog(例如該事務被過濾掉或者是隻讀事務)則不會為其分配GTID。GTID是由例項的server_uuid和該例項上目前尚未使用的最小事務序列號組成的。

  2. 如果一個事務分配了GTID,這個GTID會在事務提交時作為Gtid_log_event寫入binlog,在binlog rotate或者mysql例項退出前會將已經寫入的binlog的GTID持久化到mysql.gtid_executed表中。

  3. 事務提交後GTID會被加入到系統變數 @@GLOBAL.gtid_executed 中,該變量表示所有已提交事務的GTID集合。在開啟binlog的情況下,mysql.gtid_executed表中的GTID集合則無法表示該例項在當前時刻已執行完成事務的GTID集合,因為事務提交時並不會同步記錄GTID到該表,此時仍有部分事務GTID尚未從binlog持久化到該gtid_executed表。

  4. 在主從複製中,binlog被傳輸到從節點並存儲在relay log中,從節點從relay log中讀取事務的GTID並將gtid_next設定為該GTID。

  5. 從節點在執行事務前會檢查當下沒有其他執行緒持有該GTID的ownership,並檢查當前事務GTID不存在於gtid_executed中以避免重複執行同一個事務。通過系統變數 @@GLOBAL.gtid_owned 來維護GTID及持有該GTID ownership的執行緒ID,如果一個GTID的ownership已經被其他執行緒持有,則需要等待其他執行緒釋放後才能申請執行,從而保證同一時刻只有一個執行緒在處理該事務。如果該GTID在該例項上已經執行過,則會自動跳過該事務,避免重複執行。

  6. 從節點使用gtid_next為事務賦予GTID,而不是重新生成新的GTID,這保證了叢集中事務GTID唯一性。

  7. 如果從節點也打開了binlog,在事務提交時也會將GTID寫入到binlog,在binlog rotate或者mysql例項退出前都會將已經寫入的binlog的gtid持久化到mysql.gtid_executed表中。

  8. 如果從節點禁用了binlog,MySQL會在事務中增加一條語句來將GTID直接寫入mysql.gtid_executed表進行持久化。從MySQL8.0開始,這個操作對於DDL語句和DML語句都是原子的,在這種情況下,mysql.gtid_executed表也可以表示該節點上應用的完整的事務記錄。

【GTID的產生】

GTID是在事務提交階段產生的,在Group Commit的Flush Stage階段 MYSQL_BIN_LOG::process_flush_stage_queue 函式中會呼叫 assign_automatic_gtids_to_flush_group 為Commit Group中每個事務產生GTID,同一個Commit Group內由Leader執行緒負責為Group中所有事務產生GTID。

相應呼叫棧為:

MYSQL_BIN_LOG::ordered_commit()            
// stage #1 flushing transactions to binary log.
|-process_flush_stage_queue
|-assign_automatic_gtids_to_flush_group // Leader遍歷queue
|-gtid_state::generate_automatic_gtid
|-get_automatic_gno // 產生gno
|-acquire_ownership // 申請ownership

Gtid_state::generate_automatic_gtid 函式負責產生GTID併為執行緒申請該GTID的ownership,其中產生GTID的一個關鍵函式是 Gtid_state::get_automatic_gno ,它負責判斷該例項上最小未被使用的事務序號作為GTID的gno:

rpl_gno Gtid_state::get_automatic_gno(rpl_sidno sidno) const {
DBUG_TRACE;
Gtid_set::Const_interval_iterator ivit(&executed_gtids, sidno);
Gtid next_candidate = {sidno,
sidno == get_server_sidno() ? next_free_gno : 1};
while (true) {
const Gtid_set::Interval *iv = ivit.get();
rpl_gno next_interval_start = iv != nullptr ? iv->start : MAX_GNO;
while (next_candidate.gno < next_interval_start &&
DBUG_EVALUATE_IF("simulate_gno_exhausted", false, true)) {
DBUG_PRINT("debug",
("Checking availability of gno= %llu", next_candidate.gno));
if (owned_gtids.is_owned_by(next_candidate, 0)) return next_candidate.gno;
next_candidate.gno++;
}
if (iv == nullptr ||
DBUG_EVALUATE_IF("simulate_gno_exhausted", true, false)) {
my_error(ER_GNO_EXHAUSTED, MYF(0));
return -1;
}
if (next_candidate.gno <= iv->end) next_candidate.gno = iv->end;
ivit.next();
}
}

在產生完GTID後,會呼叫 Gtid_state::acquire_ownership 函式申請GTID ownership:

enum_return_status Gtid_state::generate_automatic_gtid(
THD *thd, rpl_sidno specified_sidno, rpl_gno specified_gno,
rpl_sidno *locked_sidno) {
// ...


if (automatic_gtid.gno == 0) {
automatic_gtid.gno = get_automatic_gno(automatic_gtid.sidno);
if (automatic_gtid.sidno == get_server_sidno() &&
automatic_gtid.gno != -1)
next_free_gno = automatic_gtid.gno + 1;
}


if (automatic_gtid.gno != -1)
acquire_ownership(thd, automatic_gtid);
else
ret = RETURN_STATUS_REPORTED_ERROR;
// ...
}

在分配GTID時,會從當前例項上可用的最小GTID開始單調遞增分配,通常情況下一個例項上GTID的分配是不會產生空洞的,如果由於特殊情況(例如手動set gtid_next)使得GTID產生空洞,在使用AUTOMATIC模式分配GTID時也會從最小未被使用的GTID開始分配,從而消除空洞。
此處做個簡單的演示:

# 此例項上已執行事務的GTID序號為1-119686
mysql> show variables like '%gtid%';
+----------------------------------+-----------------------------------------------+
| Variable_name | Value |
+----------------------------------+-----------------------------------------------+
| binlog_gtid_simple_recovery | ON |
| enforce_gtid_consistency | ON |
| gtid_executed | d4255688-0718-11ec-9687-506b4b430198:1-119686 |
| gtid_executed_compression_period | 1000 |
| gtid_mode | ON |
| gtid_next | AUTOMATIC |
| gtid_owned | |
| gtid_purged | |
| session_track_gtids | OFF |
+----------------------------------+-----------------------------------------------+
9 rows in set (0.00 sec)


# 手動設定gtid_next=119690 (大於已分配的最大序號119686,使得產生空洞)
mysql> set gtid_next='d4255688-0718-11ec-9687-506b4b430198:119690';
mysql> update sbtest9 set k=k+1 where id=1;


# 此時已執行事務的GTID產生了空洞:d4255688-0718-11ec-9687-506b4b430198:1-119686:119690
mysql> show variables like '%gtid%';
+----------------------------------+------------------------------------------------------+
| Variable_name | Value |
+----------------------------------+------------------------------------------------------+
| binlog_gtid_simple_recovery | ON |
| enforce_gtid_consistency | ON |
| gtid_executed | d4255688-0718-11ec-9687-506b4b430198:1-119686:119690 |
| gtid_executed_compression_period | 1000 |
| gtid_mode | ON |
| gtid_next | d4255688-0718-11ec-9687-506b4b430198:119690 |
| gtid_owned | |
| gtid_purged | |
| session_track_gtids | OFF |
+----------------------------------+------------------------------------------------------+


# 恢復 gtid_next='automatic'
mysql> set gtid_next='automatic';


# 執行一條update語句
mysql> update sbtest9 set k=k+1 where id=1;


# 可以看到gtid是從最小未使用的gtid開始分配的,是119687而不是119691
mysql> show variables like '%gtid%';
+----------------------------------+------------------------------------------------------+
| Variable_name | Value |
+----------------------------------+------------------------------------------------------+
| binlog_gtid_simple_recovery | ON |
| enforce_gtid_consistency | ON |
| gtid_executed | d4255688-0718-11ec-9687-506b4b430198:1-119687:119690 |
| gtid_executed_compression_period | 1000 |
| gtid_mode | ON |
| gtid_next | AUTOMATIC |
| gtid_owned | |
| gtid_purged | |
| session_track_gtids | OFF |
+----------------------------------+------------------------------------------------------+

可以發現在automatic模式下GTID分配是從最小可用序號開始分配的,因此GTID的大小並不能代表事務實際執行的順序。

【GTID的維護】 MySQL通過Gtid_state物件來整體維護GTID系統的正常運轉,在實現中有一些常見的資料結構和變數:

  • sid:server uuid,128位

  • gno:在每個server從1開始遞增的序列號

  • gtid: (sidno, gno),在叢集中全域性唯一標識

  • sidno:和server_uuid一一對應,int32,用在sid_map中作為陣列索引,從1開始

  • sid_map:雙向map<sidno, sid>,維護所有server_uuid到sidno的對映關係。

  • gtid_set:array( sidno => link_list(Interval) ),維護所有gtid的資料結構

  • Interval:(start_gno, end_gno),單鏈表的一個節點,表示gno的一個區間,加上對應的sidno可以組成一個gtid區間。

在gtid_state中還有幾個關鍵物件用於維護GTID的生命週期:

  • owned_gtids用於維護當前例項上GTID和持有GTID ownership的執行緒的對應關係,用於保證一個事務在同一個時間只有一個執行緒在處理。在group commit的flush stage中為事務產生分配GTID後會申請GTID的ownership,此時會將GTID和申請執行緒的資訊加入owned_gtids中;在commit stage事務成功提交後會釋放ownership,此時相關資訊會從owned_gtids中刪除。

  • executed_gtids用於維護例項上已提交事務的GTID集合,使用者可以通過 @@GLOBAL.gtid_owned 系統變數來檢視。事務在group commit的commit stage中事務在成功提交後會同步加入executed_gtids中,因此它儲存的GTID集合是實時更新的。

  • mysql.gtid_executed表用於持久化記憶體中的gtid_executed值。雖然binlog中的Gtid_event也能夠作為Gtid_executed的持久化工具,但是我們不可能一直保留MySQL的所有binlog,而且在從庫上不一定會開啟binlog,因此需要及時將該資訊持久化到mysql.gtid_executed表。這裡需要注意的是,主節點開啟binlog情況下,在事務提交時GTID並不會實時寫到gitd_executed表中,所以記憶體中的gtid_executed_set的值是和gtid_executed表中的值不一定是一致的。在從庫中,如果沒有開啟binlog,提交事務的Gtid無法持久化到binlog檔案(在記憶體中的gtid_executed_set裡維護並不算持久化),所以每次事務提交,必須要把執行事務的Gtid寫到gtid_executed表中。

  • lost_gtids維護了從 binlog 刪除的GTID集合。它對應的 MySQL 系統變數是 gtid_purged 。由於binlog檔案需要定期進行清理以避免佔用大量磁碟空間,當binlog被清理時,被刪除的binlog檔案中的GTID會被記錄在gtid_purged中,它是gtid_executed的子集。

group_commit中相關呼叫棧:

MYSQL_BIN_LOG::ordered_commit()           
// stage #1 flushing transactions to binary log.
|-process_flush_stage_queue
|-fetch_and_process_flush_stage_queue
|-ha_flush_logs
|-assign_automatic_gtids_to_flush_group // Leader遍歷queue
|-gtid_state::generate_automatic_gtid
|-get_automatic_gno // 產生gno
|-acquire_ownership // 申請ownership,加入owned_gtids
|-flush_thread_caches
|-flush_cache_to_file

// stage #2 Syncing binary log file to disk.
// ......

// stage #3 Commit all transactions in order.
|-change_stage // 該階段受到binlog_order_commits引數限制
|-process_commit_stage_queue
|-ha_commit_low
|-gtid_state::update_commit_group // Leader遍歷queue
|-Gtid_state::update_gtids_impl_own_gtid // 從owned_gtids中刪除,並加入executed_gtids
|-process_after_commit_stage_queue
|- stage_manager.signal_done
|
|-finish_commit // binlog_order_commits=0時,執行緒各自提交
|-Gtid_state::update_on_commit/update_on_rollback
|-Gtid_state::update_gtids_impl
|-Gtid_state::update_gtids_impl_own_gtid // 從owned_gtids中刪除,並加入executed_gtids

「第三部分 小結」

本文從GTID的組成、生命週期、GTID的產生和維護4個方面對MySQL中的GTID實現進行了簡單介紹。在主從複製中,GTID的出現大大降低了維護的複雜度,但由於gtid_state中維護各個不同GTID集合物件依賴於全域性鎖 global_sid_lock 和針對各個sidno的鎖 sid_locks ,在高併發場景下可能存在鎖衝突瓶頸,存在一定優化提升空間,這塊txsql核心正在做相關優化,敬請期待。

「第四部分 參考文件」

https://dev.mysql.com/doc/refman/5.6/en/replication-gtids-concepts.html

https://dev.mysql.com/doc/refman/8.0/en/replication-gtids.html

騰訊資料庫技術團隊 對內支援QQ空間、微信紅包、騰訊廣告、騰訊音樂、騰訊新聞等公司自研業務,對外在騰訊雲上依託於CBS+CFS的底座,支援TencentDB相關產品,如TDSQL-C(原CynosDB)、TencentDB for MySQL(CDB)、CTSDB、MongoDB、CES等 騰訊資料庫技術團隊 專注於持續優化資料庫核心和架構能力,提升資料庫效能和穩定性,為騰訊自研業務和騰訊雲客戶提供“省心、放心”的資料庫服務。此公眾號旨在和廣大資料庫技術愛好者一起推廣和分享資料庫領域專業知識,希望對大家有所幫助。