|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等 腾讯数据库技术团队 专注于持续优化数据库内核和架构能力,提升数据库性能和稳定性,为腾讯自研业务和腾讯云客户提供“省心、放心”的数据库服务。此公众号旨在和广大数据库技术爱好者一起推广和分享数据库领域专业知识,希望对大家有所帮助。