全局事务ID(Global Transaction ID, GTID) 是用来强化数据库在主备复制场景下,可以有效保证主备一致性、提高故障恢复、容错能力。我们来看看 GTID 是如何实现的,以及如何使用。

简介

GTID 是一个已提交事务的编号,并且是一个全局唯一的编号,在 MySQL 中,GTID 实际上是由 UUID+TID 组成的。其中 UUID 是一个 MySQL 实例的唯一标识;TID 代表了该实例上已经提交的事务数量,并且随着事务提交单调递增。
使用 GTID 功能具体可以归纳为以下两点:

  • 可以确认事务最初是在哪个实例上提交的;
  • 方便了 Replication 的 Failover 。

第一条显而易见,对于第二点稍微介绍下。
在 GTID 出现之前,在配置主备复制的时候,首先需要确认 event 在那个 binlog 文件,及其偏移量;假设有 A(Master)、B(Slave)、C(Slave) 三个实例,如果主库宕机后,需要通过
CHANGE MASTER TO MASTER_HOST=’xxx’, MASTER_LOG_FILE=’xxx’, MASTER_LOG_POS=nnnn 指向新库。
这里的难点在于,同一个事务在每台机器上所在的 binlog 文件名和偏移都不同,这也就意味着需要知道新主库的文件以及偏移量,对于有一个主库+多个备库的场景,如果主库宕机,那么需要手动从备库中选出最新的备库,升级为主,然后重新配置备库。这就导致操作特别复杂,不方便实施,这也就是为什么需要 MHA、MMM 这样的管理工具。
之所以会出现上述的问题,主要是由于各个实例 binlog 中的 event 以及 event 顺序是一致的,但是 binlog+position 是不同的;而通过 GTID 则提供了对于事物的全局一致 ID,主备复制时,只需要知道这个 ID 即可。
另外,利用 GTID,MySQL 会记录那些事物已经执行,从而也就知道接下来要执行那些事务。当有了 GTID 之后,就显得非常的简单;因为同一事务的 GTID 在所有节点上的值一致,那么就可以直接根据 GTID 就可以完成 failover 操作。

UUID

MySQL 5.6 用 128 位的 server_uuid 代替了原本的 32 位 server_id 的大部分功能;主要是担心手动设置配置文件中的 server_id 时,可能会产生冲突,通过 UUID(128bits) 避免冲突。
首次启动时会调用 generate_server_uuid() 函数,自动生成一个 server_uuid,并保存到 auto.cnf 文件,目前该文件的唯一目的就是保存 server_uuid;下次启动时会自动读取 auto.cnf 文件,继续使用上次生成的 UUID 。
可以通过如下命令查看当前服务器的 UUID 值。
mysql> SHOW GLOBAL VARIABLES LIKE ‘server_uuid’;
c133fbac-e07b-11e6-a219-286ed488dd40
在 Slave 向 Master 申请 binlog 时,会先发送 Slave 自己的 server_uuid,Master 会使用该值作为 kill_zombie_dump_threads 的参数,来终止冲突或者僵死的 BINLOG_DUMP 线程。

GTID

MySQL 在 5.6 版本加入了 GTID 功能,GTID 也就是事务提交时创建分配的唯一标识符,所有事务均与 GTID 一一映射,其格式类似于:
5882bfb0-c936-11e4-a843-000c292dc103:1
这个字符串,用 : 分开,前面表示这个服务器的 server_uuid,后面是事务在该服务器上的序号。
GTID 模式实例和非 GTID 模式实例是不能进行复制的,要求非常严格;而且 gtid_mode 是只读的,要改变状态必须 1) 关闭实例、2) 修改配置文件、3) 重启实例。
与 GTID 相关的参数可以参考如下:
mysql> SHOW GLOBAL VARIABLES LIKE ‘%gtid%’;
+—————————————————+———-+
| Variable_name | Value |
+—————————————————+———-+
| binlog_gtid_simple_recovery | ON |
| enforce_gtid_consistency | ON |
| gtid_executed | | 已经在该实例上执行过的事务
| gtid_executed_compression_period | 1000 |
| gtid_mode | ON |
| gtid_owned | | 正在执行的事务的gtid以及对应的线程ID
| gtid_purged | | 本机已经执行,且被PURGE BINARY LOG删除
| session_track_gtids | OFF |
+—————————————————+———-+
8 rows in set (0.00 sec)

mysql> SHOW SESSION VARIABLES LIKE ‘gtid_next’;
+———————-+—————-+
| Variable_name | Value |
+———————-+—————-+
| gtid_next | AUTOMATIC | session级别变量,表示下一个将被使用的gtid
+———————-+—————-+
1 row in set (0.02 sec)
对于 gtid_executed 变量,执行 reset master 会将该变量置空;而且还可以通过设置 gtid_next 执行一个空事务,来影响 gtid_executed 。

生命周期

一个 GTID 的生命周期包括:

  1. 事务在主库上执行并提交,此时会给事务分配一个 gtid,该值会被写入到 binlog 中;
  2. 备库读取 relaylog 中的 gtid,并设置 session 级别的 gtid_next 值,以告诉备库下一个事务必须使用这个值;
  3. 备库检查该 gtid 是否已经被使用并记录到他自己的 binlog 中;
  4. 由于 gtid_next 非空,备库不会生成一个新的 gtid,而是使用从主库获得的 gtid 。

由于 GTID 在全局唯一性,通过 GTID 可以在自动切换时对一些复杂的复制拓扑很方便的提升新主库及新备库。

通讯协议

开启 GTID 之后,除了将原有的 file+position 替换为 GTID 之外,实际上还实现了一套新的复制协议,简单来说,GTID 的目的就是保证所有节点执行了相同的事务。
老协议很简单,备库链接到主库时会带有 file+position 信息,用来确认从那个文件开始复制;而新协议则是在链接到主库时会发送当前备库已经执行的 GTID Sets,主库将所有缺失的事务发送给备库。

源码实现

在 binlog 中,与 GTID 相关的事件类型包括了:

  • GTID_LOG_EVENT 随后事务的 GTID;
  • ANONYMOUS_GTID_LOG_EVENT 匿名 GTID 事件类型;
  • PREVIOUS_GTIDS_LOG_EVENT 当前 binlog 文件之前已经执行过的 GTID 集合,会记录在 binlog 文件头。

如下是一个示例:
# at 120
# 130502 23:23:27 server id 119821 end_log_pos 231 CRC32 0x4f33bb48 Previous-GTIDs
# 10a27632-a909-11e2-8bc7-0010184e9e08:1,
# 7a07cd08-ac1b-11e2-9fcf-0010184e9e08:1-1129
除 gtid 之外,还有 gtid set 的概念,类似 7a07cd08-ac1b-11e2-9fcf-0010184e9e08:1-31,其中变量 gtid_executed 和 gtid_purged 都是典型的 gtid set 类型变量;在一个复制拓扑结构中,gtid_executed 可能包含好几组数据。

结构体

在内存中通过 Gtid_state *gtid_state 全局变量维护了三个集合。

classGtid_state

{
private:
Gtid_set lost_gtids; // 对应gtid_purged
Gtid_set executed_gtids; // 对应gtid_executed
Owned_gtids owned_gtids; // 对应gtid_owned
};
Gtid_state *gtid_state= NULL;

GTID 限制

开启 GTID 之后,会由部分的限制,内容如下。
更新非事务引擎表
GTID 同步复制是基于事务的,所以 MyISAM 存储引擎不支持,这可能导致多个 GTID 分配给同一个事务。
mysql> CREATE TABLE error (ID INT) ENGINE=MyISAM;
Query OK, 0 rows affected (0.00 sec)
mysql> INSERT INTO error VALUES(1),(2);
Query OK, 2 rows affected (0.00 sec)
Records: 2 Duplicates: 0 Warnings: 0

mysql> CREATE TABLE hello (ID INT) ENGINE=InnoDB;
Query OK, 0 rows affected (0.00 sec)
mysql> INSERT INTO hello VALUES(1),(2);
Query OK, 2 rows affected (0.00 sec)
Records: 2 Duplicates: 0 Warnings: 0

mysql> SET AUTOCOMMIT = 0;
Query OK, 0 rows affected (0.00 sec)
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)
mysql> UPDATE hello SET id = 3 WHERE id =2;
Query OK, 1 row affected (0.00 sec)
Rows matched: 1 Changed: 1 Warnings: 0
mysql> UPDATE error SET id = 3 WHERE id =2;
ERROR 1785 (HY000): When @@GLOBAL.ENFORCE_GTID_CONSISTENCY = 1, updates to non-transactional
tables can only be done in either autocommitted statements or single-statement transactions,
and never in the same statement as updates to transactional tables.

CREATE TABLE … SELECT

上述的语句不支持,因为该语句会被拆分成 CREATE TABLE 和 INSERT 两个事务,并且这个两个事务被分配了同一个 GTID,这会导致 INSERT 被备库忽略掉。
mysql> CREATE TABLE hello ENGINE=InnoDB AS SELECT * FROM hello;
ERROR 1786 (HY000): Statement violates GTID consistency: CREATE TABLE … SELECT.

临时表

事务内部不能执行创建删除临时表语句,但可以在事务外执行,但必须设置 set autocommit=1 。
mysql> CREATE TEMPORARY TABLE test(id INT);
ERROR 1787 (HY000): Statement violates GTID consistency: CREATE TEMPORARY TABLE and DROP
TEMPORARY TABLE can only be executed outside transactional context. These statements are
also not allowed in a function or trigger because functions and triggers are also considered
to be multi-statement transactions.

mysql> SET AUTOCOMMIT = 1;
Query OK, 0 rows affected (0.00 sec)
mysql> CREATE TEMPORARY TABLE test(id INT);
Query OK, 0 rows affected (0.04 sec)
与临时表相关的包括了 CREATE/DROP TEMPORARY TABLE 临时表操作。

总结

实际上,一般启动 GTID 时,可以启用 enforce-gtid-consistency 选项,从而在执行上述不支持的语句时,将会返回错误。

运维相关

简单介绍一些常见的运维操作。
当备库配置为 GTID 复制时,可以通过 SHOW SLAVE STATUS 命令查看其中的 Retrieved_Gtid_Set和 Executed_Gtid_Set,分别表示已经从主库获取,以及已经执行的事务。
mysql> SHOW SLAVE STATUS\G
* 1. row *
Slave_IO_State: Waiting for master to send event
Master_Host: 192.168.0.123
Master_User: mysync
Master_Port: 3306
Connect_Retry: 60
Master_Log_File: mysqld-bin.000005
Read_Master_Log_Pos: 879
Relay_Log_File: mysqld-relay-bin.000009 # 备库中的relaylog文件
Relay_Log_Pos: 736 # 备库执行的偏移量
Relay_Master_Log_File: mysqld-bin.000005
Slave_IO_Running: Yes
Slave_SQL_Running: No
… …
Skip_Counter: 0
Exec_Master_Log_Pos: 634
Relay_Log_Space: 1155
… …
Last_IO_Errno: 0
Last_IO_Error:
Last_SQL_Errno: 1062
Last_SQL_Error: Error ‘Duplicate entry ‘1’ for key ‘PRIMARY’’ on query.
Default database: ‘’. Query: ‘insert into wb.t1 set i=1’
Replicate_Ignore_Server_Ids:
Master_Server_Id: 3
Master_UUID: 46fdb7ad-5852-11e6-92c9-0800274fb806
… …
Retrieved_Gtid_Set: 46fdb7ad-5852-11e6-92c9-0800274fb806:1-4,
4fbe2d57-5843-11e6-9268-0800274fb806:1-3
Executed_Gtid_Set: 46fdb7ad-5852-11e6-92c9-0800274fb806:1-3,
4fbe2d57-5843-11e6-9268-0800274fb806:1-3,
81a567a8-5852-11e6-92cb-0800274fb806:1
Auto_Position: 1
1 row in set (0.00 sec)
一般来说是已经从主库复制过来,只是在执行的时候报错,可以从上述的状态中查看,然后通过命令 show relaylog events in ‘mysqld-relay-bin.000009’ from 736\G 确认。

忽略复制错误

当备库复制出错时,如果仍采用传统的跳过错误方法,也就是设置 sql_slave_skip_counter,然后再 START SLAVE;但如果打开了 GTID,在设置上述参数时,就会报错。
提示的错误信息告诉我们,可以通过生成一个空事务来跳过错误的事务,示例如下。
mysql> CREATE DATABASE test;
Query OK, 1 row affected (0.00 sec)
mysql> USE test;
Database changed
mysql> CREATE TABLE foobar(id INT PRIMARY KEY);
Query OK, 0 rows affected (0.01 sec)

——- 备库执行如下SQL
mysql> INSERT INTO foobar VALUES(1),(2),(3);
Query OK, 3 rows affected (0.00 sec)
Records: 3 Duplicates: 0 Warnings: 0
——- 主库执行如下SQL
mysql> INSERT INTO foobar VALUES(1),(4),(5);
Query OK, 3 rows affected (0.00 sec)
Records: 3 Duplicates: 0 Warnings: 0
mysql> SHOW MASTER STATUS;
+—————————+—————+———————+—————————+—————————————————————+
| File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
+—————————+—————+———————+—————————+—————————————————————+
| mysql-bin.000002 | 1109 | | | ab298681-00f5-11e7-a02a-ac2b6e8b4228:1-5 |
+—————————+—————+———————+—————————+—————————————————————+
1 row in set (0.00 sec)

——- 备库执行如下SQL
mysql> SHOW SLAVE STATUS \G
* 1. row *
… …
Slave_IO_Running: Yes
Slave_SQL_Running: No
Last_Errno: 1062
Last_Error: Error ‘Duplicate entry ‘1’ for key ‘PRIMARY’’ on query.
Default database: ‘test’. Query: ‘INSERT INTO foobar VALUES(1),(4),(5)’
Skip_Counter: 0
Retrieved_Gtid_Set: ab298681-00f5-11e7-a02a-ac2b6e8b4228:1-5
Executed_Gtid_Set: ab298681-00f5-11e7-a02a-ac2b6e8b4228:1-4,
ad9b6105-00f5-11e7-a114-ac2b6e8b4228:1-2
Auto_Position: 1
… …
1 row in set (0.00 sec)

mysql> SET @@SESSION.GTID_NEXT= ‘ab298681-00f5-11e7-a02a-ac2b6e8b4228:5’;
Query OK, 0 rows affected (0.00 sec)
mysql> BEGIN;
Query OK, 0 rows affected (0.00 sec)
mysql> COMMIT;
Query OK, 0 rows affected (0.00 sec)
mysql> SET SESSION GTID_NEXT = AUTOMATIC;
Query OK, 0 rows affected (0.00 sec)

mysql> START SLAVE;
Query OK, 0 rows affected (0.00 sec)
再查看 SHOW SLAVE STATUS 时,就会发现错误事务已经被跳过了;这种方法的原理很简单,空事务产生的 GTID 加入到 GTID_EXECUTED 中,相当于告诉备库,这个 GTID 对应的事务已经执行了。
注意,此时主从会导致数据不一致,需要进行修复。

主库事件被清除

变量 gtidpurged 记录了本机已经执行过,且已被 PURGE BINARY LOGS TO 命令清理的 gtid_set ;在此,看看如果主库上把某些备库还没有获取到的 gtid event 清理后会有什么样的结果。
——- 主库执行如下SQL
mysql> FLUSH LOGS; CREATE TABLE foobar1 (id INT) ENGINE=InnoDB;
Query OK, 0 rows affected (0.01 sec)
Query OK, 0 rows affected (0.02 sec)
mysql> FLUSH LOGS; CREATE TABLE foobar2 (id INT) ENGINE=InnoDB;
Query OK, 0 rows affected (0.01 sec)
Query OK, 0 rows affected (0.02 sec)
mysql> SHOW MASTER STATUS;
+—————————+—————+———————+—————————+—————————————————————+
| File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set |
+—————————+—————+———————+—————————+—————————————————————+
| mysql-bin.000004 | 379 | | | 91116597-016c-11e7-94db-ac2b6e8b4228:1-5 |
+—————————+—————+———————+—————————+—————————————————————+
1 row in set (0.00 sec)
mysql> SHOW GLOBAL VARIABLES LIKE ‘gtid
%’;
+—————————————————+—————————————————————+
| Variablename | Value |
+—————————————————+—————————————————————+
| gtid_executed | 91116597-016c-11e7-94db-ac2b6e8b4228:1-5 |
| gtid_executed_compression_period | 1000 |
| gtid_mode | ON |
| gtid_owned | |
| gtid_purged | |
+—————————————————+—————————————————————+
5 rows in set (0.02 sec)
mysql> PURGE BINARY LOGS TO ‘mysql-bin.000004’;
Query OK, 0 rows affected (0.00 sec)
mysql> SHOW GLOBAL VARIABLES LIKE ‘gtid
%’;
+—————————————————+—————————————————————+
| Variable_name | Value |
+—————————————————+—————————————————————+
| gtid_executed | 91116597-016c-11e7-94db-ac2b6e8b4228:1-5 |
| gtid_executed_compression_period | 1000 |
| gtid_mode | ON |
| gtid_owned | |
| gtid_purged | 91116597-016c-11e7-94db-ac2b6e8b4228:1-4 |
+—————————————————+—————————————————————+
5 rows in set (0.01 sec)

——- 在备库上执行如下SQL
mysql> START SLAVE;
Query OK, 0 rows affected (0.01 sec)
mysql> SHOW SLAVE STATUS\G
* 1. row *
……
Slave_IO_Running: No
Slave_SQL_Running: Yes
……
Last_IO_Errno: 1236
Last_IO_Error: Got fatal error 1236 from master when reading data from
binary log: ‘The slave is connecting using CHANGE MASTER TO MASTER_AUTO_POSITION = 1,
but the master has purged binary logs containing GTIDs that the slave requires.’
……
1 row in set (0.00 sec)
接下来我们在备库忽略 purged 的部分,然后强行同步,在备库同样设置 gtid_purged 变量。
——- 备库上清除gtid_executed,然后设置gtid_purged,忽略主库的事务
mysql> RESET MASTER;
Query OK, 0 rows affected (0.05 sec)
mysql> SET GLOBAL gtid_purged = “91116597-016c-11e7-94db-ac2b6e8b4228:1-5”;
Query OK, 0 rows affected (0.05 sec)

——- 备库上执行上述的SQL,需要根据具体情况修复
mysql> CREATE TABLE foobar1 (id INT) ENGINE=InnoDB;
Query OK, 0 rows affected (0.01 sec)
mysql> CREATE TABLE foobar2 (id INT) ENGINE=InnoDB;
Query OK, 0 rows affected (0.02 sec)

——- 启动备库即可
mysql> START SLAVE;
Query OK, 0 rows affected (0.02 sec)
实际生产应用中,当遇到上述的情况后,需要 DBA 人为保证该备库数据和主库一致;或者即使不一致,这些差异也不会导致今后的主从异常,例如,所有主库上只有 insert 没有 update 。

Errant-Transaction

简单来说,就是没有在主库执行,而是直接在备库执行的事务,通常可能是在修复备库的问题或者应用异常写入了备库导致。
如果发生 ET 的备库被提升为主库,那么根据 GTID 协议,新主库就会发现备库没有执行 ET 中的事务,接下来就可能会发生如下两种情况:

  1. 备库中 ET 对应的 binlog 仍然存在,那么会将相应的事件发送给新的备库,此时则会导致数据不一致或者发生其它异常;
  2. 备库中 ET 对应的 binlog 已经被删除,由于无法发送给备库,那么会导致复制异常。

对于有些需要修复备库的任务可以通过 SET sql_log_bin=0 命令,设置会话参数,防止生成 ET,当然,此时需要保证数据一致性。在修复时有两种方案:

  1. 在 GTID 的执行历史中删除 ET,这样即使备库被提升为主库,也不会发生异常;
  2. 在其它 MySQL 服务中执行空白的事务,使其它库认为已经执行了 ET,那么 Failover 之后也不会尝试获取相应的事件。

接下来看个示例。
——- 在主库执行如下SQL,查看主库已执行事务对应的GTID Sets
mysql> SHOW MASTER STATUS\G
* 1. row *
… …
Executed_Gtid_Set: 8e349184-bc14-11e3-8d4c-0800272864ba:1-30,
8e3648e4-bc14-11e3-8d4c-0800272864ba:1-7

——- 同上,在备库执行
mysql> SHOW SLAVE STATUS\G
… …
Executed_Gtid_Set: 8e349184-bc14-11e3-8d4c-0800272864ba:1-29,
8e3648e4-bc14-11e3-8d4c-0800272864ba:1-9

——- 比较两个GTID Sets
mysql> SELECT gtid_subset(‘8e349184-bc14-11e3-8d4c-0800272864ba:1-29,
8e3648e4-bc14-11e3-8d4c-0800272864ba:1-9’,’8e349184-bc14-11e3-8d4c-0800272864ba:1-30,
8e3648e4-bc14-11e3-8d4c-0800272864ba:1-7’) AS slave_is_subset;
+————————-+
| slave_is_subset |
+————————-+
| 0 |
+————————-+
1 row in set (0.00 sec)

——- 获取对应的差值
mysql> SELECT gtid_subtract(‘8e349184-bc14-11e3-8d4c-0800272864ba:1-29,
8e3648e4-bc14-11e3-8d4c-0800272864ba:1-9’,’8e349184-bc14-11e3-8d4c-0800272864ba:1-30,
8e3648e4-bc14-11e3-8d4c-0800272864ba:1-7’) AS errant_transactions;
+—————————————————————+
| errant_transactions |
+—————————————————————+
| 8e3648e4-bc14-11e3-8d4c-0800272864ba:8-9 |
+—————————————————————+
1 row in set (0.00 sec)
接下来,看看如何修复,假设有 3 个服务,A (主库)、B (备库的异常 XXX:3) 以及 C (备库的异常 YYY:18-19),那么,接下来可以在不同的服务器上写入空白事务。
# A
- Inject empty trx(XXX:3)
- Inject empty trx(YYY:18)
- Inject empty trx(YYY:19)
# B
- Inject empty trx(YYY:18)
- Inject empty trx(YYY:19)
# C
- Inject empty trx(XXX:3)
当然,也可以使用 MySQL-Utilities 中的 mysqlslavetrx 脚本写入空白事务。
$ mysqlslavetrx —gtid-set=’457e7d57-1da2-11e7-9c71-286ed488dd40:5’ —verbose \
—slaves=’root:new-password@127.0.0.1:3308,root:new-password@127.0.0.1:3309’

参考

MySQL Reference Manual - Replication with Global Transaction Identifiers