MySQL高可用实践
上QQ阅读APP看书,第一时间看更新

3.2 GTID生命周期

3.2.1 典型事务的GTID生命周期

典型事务的GTID生命周期包括以下步骤:

步骤01 客户端事务在主库上执行并提交,此事务被分配一个GTID,该GTID由主服务器的UUID和此服务器上尚未使用的最小非零事务序列号组成。GTID作为Gtid_log_event紧接在事务本身之前,与事务本身一起被写入主库的二进制日志,这是一个原子操作(即不可分割的操作)。如果未将客户端事务写入二进制日志,例如因为事务已被过滤掉,或者事务是只读的,则不会为其分配GTID。轮转二进制日志或关闭MySQL实例时,都会将写入之前二进制日志文件的所有事务的GTID写入mysql.gtid_executed表。

步骤02 如果为事务分配了GTID,则将GTID添加到主库gtid_executed系统变量@@global.gtid_executed的GTID集合中,这一步将在事务提交后进行,并且与事务处理本身不是一个原子操作。gtid_executed系统变量包含所有已提交事务的GTID集,是应用事务的完整记录,并在复制中用作表示服务器状态的标记。mysql.gtid_executed表不包含当前二进制日志文件中的最新GTID记录。

步骤03 在将二进制日志数据传输到从库并存储在从库的中继日志中之后,从库读取GTID并将其设置为gtid_next系统变量的值。这告诉从库必须使用此GTID记录下一个事务。

步骤04 在处理事务本身之前,从库首先读取和检查复制事务的GTID,不仅保证没有先前的事务具有此GTID,还保证没有其他会话已经读取此GTID、尚未提交相关事务。因此,如果多个客户端同时提交同一个GTID事务,则服务器只允许其中一个执行。从库的gtid_owned系统变量@@global.gtid_owned显示当前正在使用的GTID以及拥有它的线程ID。如果已经使用了该GTID,通过自动跳过功能忽略该事务,并且不会引发错误。

步骤05 如果GTID尚未使用,则从库应用复制的事务。gtid_next设置为主库已分配的GTID,从库不会为此事务生成新的GTID,而是使用存储在gtid_next中的GTID。

步骤06 如果在从库上启用了二进制日志记录,则与主库操作类似。GTID会在提交时作为Gtid_log_event原子写入其二进制日志。当轮转二进制日志或关闭MySQL实例时,都会将之前写入二进制日志文件的所有事务的GTID写入mysql.gtid_executed表。

步骤07 如果从库禁用二进制日志记录,则通过将GTID直接写入mysql.gtid_executed表保留GTID。MySQL会在事务中附加一条语句,将GTID插入该表中。从MySQL 8.0开始,此操作对于DDL语句和DML语句都是原子操作。在这种情况下,mysql.gtid_executed表是从库上应用事务的完整记录。

步骤08 从库提交复制事务后,GTID将被添加到从库gtid_executed系统变量@@global.gtid_executed的GTID集合中,这步将在事务应用后进行,并且与事务处理本身不是一个原子操作。

主库上过滤掉的客户端事务未分配GTID,因此它们不会添加到gtid_executed系统变量中的事务集中,也不会添加到mysql.gtid_executed表中。但是,在从库上过滤掉的复制事务的GTID是持久化的。如果在从库上启用了二进制日志,则过滤掉的事务将作为Gtid_log_event写入其二进制日志,后跟仅包含BEGIN和COMMIT语句的空事务。如果禁用二进制日志,则已过滤掉的事务的GTID将写入mysql.gtid_executed表。为过滤掉的事务保留GTID可确保将mysti.gtid_executed表和gtid_executed系统变量中的GTID用GTID集表示。它还确保如果从库重新连接到主库,不会再次检索过滤掉的事务。

在主库或单线程复制的从库上,GTID从1开始单向递增且没有间隙。但在多线程复制的从库(slave_parallel_workers> 0)上,可以并行应用事务,因此复制的事务可能无序提交,除非设置了slave_preserve_commit_order=1。在发生这种情况时,gtid_executed系统变量中的GTID集合将包含多个GTID范围,它们之间可能存在间隙。多线程复制从库上的间隙仅发生在最近应用的事务中,并在复制过程中填充。当使用STOP SLAVE语句停止复制线程时,将应用正在进行的事务以填补空白。如果发生异常关闭,例如服务器故障或使用KILL语句停止复制线程,则可能依然存在间隙。下一章将详细讨论多线程复制。

下面实验中将演示GTID存在间隙的情况。

(1)从库开启多线程复制:

     set global slave_parallel_workers=8;
     stop slave;
     start slave;
     show processlist;

在最后的输出中可以看到8个复制线程:

(2)在主库上执行一个可以并行复制的长操作。

因为并行复制默认是按数据库分配线程的,所以会建立多个库表:

(3)在上一步正在执行过程中杀掉从库的mysqld进程,模拟异常宕机:

     ps -ef | grep mysqld | grep -v grep | awk {'print $2'} | xargs kill -9

(4)启动从库,不自动启动复制:

(5)查看从库的GTID间隙:

GTID范围的输出是排序的,可以看到42172、42180、42188、42196……这些GTID没有出现在gtid_executed变量中,这些就是GTID间隙。查询各个库的记录数(已经执行的事务)也是各不相同的。

(6)启动从库的复制,检查复制情况:

     start slave;

当所有事务都执行完后,再次查看gtid_executed系统变量,已经合并为一个GTID范围,所有间隙都已经被填充:

从show slave status的输出和各个库表的记录数,也能确认复制正常。通过这个简单的实验可以看到,启用并行复制的从库,在复制期间从库实例异常终止会产生GTID间隙,但在实例重启后复制会自动填充GTID间隙,最终达到主从数据一致。

3.2.2 GTID分配

典型情况是服务器为已提交的事务生成新的GTID。写入二进制日志的每个数据库更改(DDL或DML)都会分配一个GTID。这包括自动提交的更改以及使用BEGIN或START TRANSACTION和COMMIT语句提交的更改。当数据库以及非表数据库对象,例如过程、函数、触发器、事件、视图、用户、角色,在创建、更改或删除时都会分配GTID。授权语句和非事务表的更新也会分配GTID。

当二进制日志中的生成语句自动删除表时,会为该语句分配GTID。例如,当具有打开临时表的用户会话断开连接时,将会自动删除临时表,或者使用MEMORY存储引擎的表在服务器启动后会自动删除。

未写入二进制日志的事务不会被分配GTID。这包括回滚的事务,或在禁用二进制日志时执行的事务,或指定sql_log_bin=0时执行的事务,或空事务(begin;commit;)等。

XA事务为事务的XA PREPARE阶段和事务的XA COMMIT或XA ROLLBACK阶段分配了单独的GTID。XA事务的准备阶段是持久化的,以便用户可以在发生故障时将其提交或回滚。因此,事务的两个部分是分开复制的,因此两个阶段必须有自己单独的GTID。

在以下特殊情况下,单个语句可以生成多个事务,因此会分配多个GTID:

  • 调用存储过程时,为过程提交的每个更新事务生成一个GTID。
  • 多表DROP TABLE语句中包含任何不支持原子DDL存储引擎的表(如myisam)或临时表时,会生成多个GTID。

注意,触发器内的语句和触发它的语句是在一个事务中,因此不会单独分配GTID。MySQL不支持类似Oracle自治事务的功能。

3.2.3 gtid_next系统变量

gtid_next是会话级系统变量。默认情况下,对于在用户会话中提交的新事务,服务器会自动生成并分配新的GTID。在从库上应用事务时,将保留来自原始服务器的GTID。可以通过设置gtid_next系统变量的会话值来更改此行为:

  • 当gtid_next设置为默认值AUTOMATIC,并且事务已提交并写入二进制日志时,服务器会自动生成并分配新的GTID。如果由于其他原因而回滚事务或未将事务写入二进制日志,则服务器不会生成和分配GTID。
  • 如果将gtid_next设置为有效的单个GTID,服务器会将该GTID分配给下一个事务。只要事务提交,就会将此GTID分配并添加到gtid_executed。

在将gtid_next设置为特定GTID并且已提交或回滚事务之后,必须在任何其他语句之前发出显式SET @@SESSION.gtid_next语句。如果不想分配更多GTID,可以将此选项值的值设置回AUTOMATIC。

正如前面所讲,从库的SQL线程应用复制事务时使用此技术,将@@SESSION.gtid_next设置为在源服务器上分配给事务的GTID。这意味着保留来自原始服务器的GTID,而不是由从库生成和分配的新GTID。即使从库禁用log_bin或log_slave_updates,或者事务是空操作或在从库上过滤掉时,GTID也会添加到从库上的gtid_executed中。

客户端可通过在执行事务之前将@@SESSION.gtid_next设置为特定GTID来模拟复制的事务。mysqlbinlog使用此技术生成二进制日志的转储,客户端可以重放该转储以保留GTID。通过客户端提交的模拟复制事务完全等同于通过复制应用程序线程提交的复制事务,事后是无法区分它们的。

3.2.4 gtid_purged系统变量

gtid_purged是全局系统变量。@@GLOBAL.gtid_purged中的GTID集包含已在服务器上提交,但在服务器上的任何二进制日志文件中不存在的所有事务的GTID。gtid_purged是gtid_executed的子集。以下类别的GTID位于gtid_purged中:

  • 第一种情况:在从库上禁用二进制日志记录时提交的复制事务的GTID。
  • 第二种情况:已清除的二进制日志文件中事务的GTID。
  • 第三种情况:通过语句SET @@GLOBAL.gtid_purged明确添加到集合中的GTID。

第一种情况:

第二种情况:

第三种情况:

可以更改gtid_purged的值,以便在服务器上记录已应用某个GTID集中的事务,尽管它们不存在于服务器上的任何二进制日志中。将GTID添加到gtid_purged时,它们也会添加到gtid_executed中。下面来看一个相对极端的例子。

(1)从库清除二进制日志和gtid_executed信息:

     reset master;
     stop slave;
     reset slave all;
     show variables like 'gtid%';

RESET MASTER会导致gtid_purged和gtid_executed的全局值重置为空字符串。最后的输出为:

(2)重置复制:

最后的输出中会显示以下错误:

可以看到,从主库读到的GTID已经到了10005,但没有已经执行的GTID。实际上这些事务都已经在从库中应用了,只是由于reset master而没有留下执行的痕迹,因此要从1开始执行,重复执行事务会造成这样的错误。

(3)将所有已读的GTID都标记为已执行,然后重启复制:

     set global gtid_purged='8eed0f5b-6f9b-11e9-94a9-005056a57a4e:1-10005';
     stop slave;
     start slave;
     show slave status\G
     show variables like 'gtid%';

从show slave status的输出中可以看到复制已恢复正常,最后的输出为:

服务器启动时,将初始化gtid_executed和gtid_purged系统变量中的GTID集。每个二进制日志文件都以事件Previous_gtids_log_event开头,该事件包含所有先前二进制日志文件中的GTID集,由前一个文件的Previous_gtids_log_event中的GTID和前一个文件中每个Gtid_log_event的GTID组成。最旧和最新的二进制日志文件中Previous_gtids_log_event的内容用于计算服务器启动时的gtid_executed和gtid_purged的GTID集:

  • gtid_executed是最新二进制日志文件中Previous_gtids_log_event中的GTID、该二进制日志文件中事务的GTID和存储在mysql.gtid_executed表中的GTID这三者的并集。此GTID集包含服务器上已使用或显式添加到gtid_purged的所有GTID(无论它们当前是否位于服务器上的二进制日志文件中)。这个GTID集不包括当前正在服务器上处理事务的GTID(@@GLOBAL.gtid_owned)。
  • gtid_purged的计算方法是首先添加最新二进制日志文件Previous_gtids_log_event中的GTID,再添加该二进制日志文件中事务的GTID。此步提供当前或曾经记录在服务器上的二进制日志中的GTID集(gtids_in_binlog)。然后从gtids_in_binlog中减去最旧的二进制日志文件中的Previous_gtids_log_event中的GTID。此步骤提供当前记录在服务器上的二进制日志中的GTID集(gtids_in_binlog_not_purged)。最后,从gtid_executed中减去gtids_in_binlog_not_purged。结果是服务器上已经执行,但当前未记录在服务器上的二进制日志文件中的GTID集,此结果用于初始化gtid_purged。

下面用一个例子来说明gtid_executed和gtid_purged的计算过程。

服务器重启后,gtid_executed的值为1-11006,gtid_purged值为1-10005,下面倒推这些数是怎么得来的。

当前有三个二进制日志文件,最旧的是binlog.000001,最新的是binlog.000003:

binlog.000001的Previous-GTIDs为空,文件本身也没有GTID:

binlog.000002的Previous-GTIDs由binlog.000001的Previous-GTIDs和binlog.000001本身的GTID组成,由于两者都为空,因此binlog.000002的Previous-GTIDs也为空:

binlog.000002本身的GTID为10006-11006:

binlog.000003的Previous-GTIDs由binlog.000002的Previous-GTIDs和binlog.000002本身的GTID组成,所以binlog.000003的Previous-GTIDs为10006-11006:

binlog.000003本身没有GTID。mysql.gtid_executed的记录为:

按照gtid_executed的计算方法,gtid_executed为10006-11006和1-11006的并集,于是得出1-11006。

gtid_purged的计算过程如下: