1. 内容摘要
众所周知,MySQL 基于 GTID 复制功能的出现,极大地简化了 MySQL 复制拓扑初始化配置和变更以及高可用的切换。在去哪儿网,我们大量使用 PXC(Percona XtraDB Cluster)集群,然而 PXC 中用于记录事务的 Galera GTID 与普通的 MySQL GTID 还是有一点差异,运维过程中如果不加注意,可能会引发一些问题。本文通过记录一次 PXC 集群拆分的过程中由于未深刻理解这两者的差别而导致的问题与原因分析,总结了 Galera GTID 与 MySQL GTID 的异同点以及运维过程中应该注意的事项。
2. 背景
Qunar 机票核心业务某个 PXC 集群 C1 由于运行时间比较久,随着业务的持续发展,集群单个节点的实例数据大小已达到 5T 以上,对数据量如此大的 MySQL 集群进行日常维护(备份、集群节点水平扩容、实例迁移等)以及实例故障恢复都是一项比较耗时费力的工作。经过与研发讨论后决定将集群 C1 中比较大的两个库 DB1 和 DB2 拆分出来,组成一个新的 PXC 集群 C2。
集群拆分前后示意图如下(正常每个集群有三个节点,为简单起见,每个集群只画了一个节点):
3. 方案简要说明
PXC 集群进行库的拆分,大致流程是使用当前集群 C1 的任意一个节点做一个全量副本,利用这个副本再做2个节点的数据,组建一个三节点的新集群 C2。同时为了保持数据的一致性,新集群 C2 的写节点作为原有集群 C1 某个节点的从库,不断同步集群 C1 的数据更新。
原计划是第一步先迁移 DB1,主要流程为:
业务方下线集群 C1 上与 DB1 相关的应用服务,停止对该库中所有表的写入。
为了防止遗漏的应用服务对 DB1 进行写入,DBA 将该库里面所有的表进行改名,即加一个统一的后缀(需提前准备好脚本)。
DBA 确认两个集群直接主从同步无延迟后,在新集群 C2 上恢复 DB1 所有表的名称,即去掉第2步中添加的后缀(需提前准备好脚本)。
业务方发布新的应用服务(业务方已提前修改好代码中的数据源配置),开始访问集群 C2 中的 DB1,各系统验证业务是否正常。
第二步是在完成第一步之后,仍然保持两个集群之间主从同步关系,等使用 C2 中 DB1 相关的业务确认无问题后以同样的方式迁移 DB2。
全部迁移完后观察一段时间,确认各业务流程正常,最后删除两个集群中不需要的 DB。
其中第一步操作过程如下:
不过在顺利完成第一步后出现了意外,原本应该正常同步数据的两个集群出现了复制中断,根据报错信息发现大量的数据(除 DB1 之外的库)在集群 C2 上找不到对应的记录,由于两个集群中 DB2 的数据没法保证一致性,导致不得不中止后续的迁移计划,以至于集群 C2 上只完成了库 DB1 的迁移。
4. 问题分析与复现
4.1 问题分析
正常来说,集群 C1 已经彻底停止(表名已改)了对 DB1 中表的写入,而集群 C2 上只会对 DB1 中表进行写入,其他库的写入不受影响,应该正常复制才对。
既然复制出现了问题,那么原有的“理所当然”的想法肯定存在不合理的地方。经过排查,我们发现了一个令人匪夷所思的问题,两个集群用作复制的两个节点,从库和主库的 GTID 的<source_id>部分竟然是一样的,导致从库在对 DB1 进行写入后,生成的 GTID 的<trx_number>值比主库上大,当接收主库推送过来的 binlog 数据时,发现主库事务的 GTID 的<trx_number>值比自己的小,于是从库直接选择了跳过该事务,并没有重放这部分 binlog,从而出现了主从数据不一样的情况。
4.2 复现过程
为什么新建的从库生成的 GTID 的<source_id>会和主库一样呢?为了找到问题的原因,我们在测试环境用同样的流程对出现的问题进行复现。
首先我们复盘了下搭建主从复制的过程,大致如下:
1、使用 Xtrabackup 备份集群 C1 某个节点的全量数据,并将数据传送到目的服务器,用于新建集群 C2 的第一个节点。
2、在目的服务器 apply 备份日志后,根据生成的文件 xtrabackup_binlog_info 中的内容找到复制信息。
# cat xtrabackup_binlog_infomysql-bin.000015 997 401cdbc9-e228-ee17-496f-5c53bc36ae5b:1-1123,c05582e9-dc11-ee14-6b06-c041b8b7ff2d:1-4,da5e0de8-dc13-ee14-76e6-f074e061cc69:1-2
3、新建文件 grastate.dat,并根据 apply 后生成的文 件 xtrabackup_galera_info 中的内容填写 grastate.dat 文件信息。
4、以 bootstrap-pxc 方式启动该实例,作为集群 C2 的第一个节点,并与老集群 C1 建立复制关系。# cat xtrabackup_galera_info
3faa7d16-23ee-11eb-94f9-3fbe474800d2:4
# vim grastate.dat
# GALERA saved state
version: 2.1
uuid: 3faa7d16-23ee-11eb-94f9-3fbe474800d2
seqno: 4
safe_to_bootstrap: 1
5、向集群 C1 中未迁移的库 test2 中正常写入数据,观察主从 master 信息。# 启动实例
/etc/init.d/mysql.server -P 3311 bootstrap-pxc
mysql> reset slave all;
Query OK, 0 rows affected (0.00 sec)
# 建立新的复制
mysql> set wsrep_on = 0;
Query OK, 0 rows affected (0.00 sec)
mysql> reset master;
Query OK, 0 rows affected (0.00 sec)
mysql> set wsrep_on = 1;
Query OK, 0 rows affected (0.00 sec)
mysql> SET GLOBAL gtid_purged='401cdbc9-e228-ee17-496f-5c53bc36ae5b:1-1123,c05582e9-dc11-ee14-6b06-c041b8b7ff2d:1-4,da5e0de8-dc13-ee14-76e6-f074e061cc69:1-2';
Query OK, 0 rows affected (0.01 sec)
mysql> change master to master_host='10.86.41.xxx',
master_port=3306,
master_user='replication',
master_password='xxxxxxxxxx',
master_auto_position=1;
Query OK, 0 rows affected, 2 warnings (0.02 sec)
mysql> start slave;
Query OK, 0 rows affected (0.00 sec)
# 集群C2此时的master信息
mysql> show master status\G
*************************** 1. row ***************************
File: mysql-bin.000002
Position: 271
Binlog_Do_DB:
Binlog_Ignore_DB:
Executed_Gtid_Set: 401cdbc9-e228-ee17-496f-5c53bc36ae5b:1-1123,
c05582e9-dc11-ee14-6b06-c041b8b7ff2d:1-4,
da5e0de8-dc13-ee14-76e6-f074e061cc69:1-2
1 row in set (0.00 sec)
此时复制没有问题,数据也是正常的。# 写入前集群C1的master状态
mysql> show master status\G
*************************** 1. row ***************************
File: mysql-bin.000015
Position: 997
Binlog_Do_DB:
Binlog_Ignore_DB:
Executed_Gtid_Set: 401cdbc9-e228-ee17-496f-5c53bc36ae5b:1-1123,
c05582e9-dc11-ee14-6b06-c041b8b7ff2d:1-4,
da5e0de8-dc13-ee14-76e6-f074e061cc69:1-2
1 row in set (0.00 sec
# 向集群C1的库test2中写入两个事务的数据后master状态
mysql> use test2;
mysql> insert into t values(13);
Query OK, 1 row affected (0.00 sec)
mysql> insert into t values(14);
Query OK, 1 row affected (0.00 sec)
mysql> show master status\G
*************************** 1. row ***************************
File: mysql-bin.000015
Position: 1481
Binlog_Do_DB:
Binlog_Ignore_DB:
Executed_Gtid_Set: 401cdbc9-e228-ee17-496f-5c53bc36ae5b:1-1123,
c05582e9-dc11-ee14-6b06-c041b8b7ff2d:1-6, # 发生变化的GTID
da5e0de8-dc13-ee14-76e6-f074e061cc69:1-2
1 row in set (0.00 sec)
# 此时集群C2的master信息
mysql> show master status\G
*************************** 1. row ***************************
File: mysql-bin.000002
Position: 735
Binlog_Do_DB:
Binlog_Ignore_DB:
Executed_Gtid_Set: 401cdbc9-e228-ee17-496f-5c53bc36ae5b:1-1123,
c05582e9-dc11-ee14-6b06-c041b8b7ff2d:1-6, # 发生变化的GTID,同步正常
da5e0de8-dc13-ee14-76e6-f074e061cc69:1-2
1 row in set (0.00 sec)
6、向集群 C2 中已迁移的库 test1 中写入两个事务的数据,观察主从 master 信 息。
7、此后如果集群 C1 上继续写入一个事务。mysql> insert into t values(7);
Query OK, 1 row affected (0.00 sec)
mysql> insert into t values(8);
Query OK, 1 row affected (0.00 sec)
mysql> show master status\G
*************************** 1. row ***************************
File: mysql-bin.000002
Position: 1209
Binlog_Do_DB:
Binlog_Ignore_DB:
Executed_Gtid_Set: 401cdbc9-e228-ee17-496f-5c53bc36ae5b:1-1123,
c05582e9-dc11-ee14-6b06-c041b8b7ff2d:1-8, # 写入后,从节点发生变化的GTID
da5e0de8-dc13-ee14-76e6-f074e061cc69:1-2
1 row in set (0.00 sec)
mysql> delete from t where id = 13; # 删除test2库t表中id=13的记录
Query OK, 1 row affected (0.00 sec)
# 集群C1的master信息
mysql> show master status\G
*************************** 1. row ***************************
File: mysql-bin.000015
Position: 1723
Binlog_Do_DB:
Binlog_Ignore_DB:
Executed_Gtid_Set: 401cdbc9-e228-ee17-496f-5c53bc36ae5b:1-1123,
c05582e9-dc11-ee14-6b06-c041b8b7ff2d:1-7, # GTID的gno增加1
da5e0de8-dc13-ee14-76e6-f074e061cc69:1-2
1 row in set (0.00 sec)
# 集群C2的master信息
mysql> show master status\G
*************************** 1. row ***************************
File: mysql-bin.000002
Position: 1209
Binlog_Do_DB:
Binlog_Ignore_DB:
Executed_Gtid_Set: 401cdbc9-e228-ee17-496f-5c53bc36ae5b:1-1123,
c05582e9-dc11-ee14-6b06-c041b8b7ff2d:1-8, # GTID的gno没有发生变化,因为同步过来的事务的GTID的gno值比自己小,选择跳过
da5e0de8-dc13-ee14-76e6-f074e061cc69:1-2
1 row in set (0.00 sec)
# 此时C1集群已经被删掉的记录,在C2集群仍然存在
mysql> use test2;
Database changed
mysql> select * from t where id = 13;
+----+
| id |
+----+
| 13 |
+----+
1 row in set (0.00 sec)
从测试过程中发现,确实通过以上的方式搭建主从节点后,存在主从节点写入后 GTID 值的<source_id>部分相同的情况,此时如果主从节点同时发生写入(针对不同的库),就会导致主从节点数据不一致。
4.3 问题原因
对问题复现过程进行分析后,发现整个过程有一步是多余的,即步骤3(新建 grastate.dat 文件),因为我们的目的是通过搭建从库的方式组建一个新的集群,而不是对原有集群扩增节点,所以在该 slave 节点(针对原集群而言)以 bootstrap 方式启动时,不需要指定原来的集群信息。
当以 bootstrap 方式启动 PXC 实例时,如果 grastate.dat 文件存在,那么该实例会从该文件中获取 uuid 参数的值,赋值给参数 wsrep_cluster_state_uuid,同时该参数的值也决定了事务的 Galera GTID 中的<source_id>部分,所以导致这个实例的与原集群中节点的<source_id>相同,当作为主从的两个节点都有写入时,从库在应用 binlog 时就会出现冲突或者忽略的情况,导致主从数据不一致。
5. 如何改进
通过此次集群拆分过程中出现的问题,总结下原因以及改进措施,避免后续工作中出现类似情况:
5.1 原因
延用了前期类似经验的惯性思维。因为在此之前有过两次类似的迁移操作,只不过当时只需迁移一个库,在原集群停止该库的写入后,等从库复制无延迟后就马上断开了复制,所以没有出现后续复制的问题。
操作流程不够精细。操作过程中没有严格区分 PXC 集群新增节点和拆分集群的流程差异,细节之处欠缺考虑,也反映出个人在对 PXC 的使用和原理方面研究不够深入。
5.2 改进措施
分别制定 PXC 集群新增节点和拆分集群的操作规范,在运维操作时严格按照规范执行。
优化集群拆分方案。比如可建立一个中间节点,对要迁移的数据库进行过滤复制,然后再同步到新集群中,可节省组建新集群的时间,同时可避免后续在新集群上删除多余的库。
3. 在标准规范的基础上,实现运维操作自动化,避免人为主观因素造成影响。
6. 关于 Galera GTID 与 MySQL GTID 的比较
6.1 GTID 的概念
GTID 特性是 MySQL5.6加入的一个强大的特性,全称是 Global Transaction Identifier。MySQL 会为每一个 DML/DDL 操作增加一个唯一标记叫做 GTID,这个标记在整个复制环境中都是唯一的,格式为<source_id:trx_number>。
GTID 相关的几个常见术语:
server_uuid:单个 GTID 的前半部分,即<source_id>部分,是一个32字节+1字节(/0)的字符串。
gno:单个 GTID 的后半部分,即<trx_number>部分,表示事务的序号,gno 的值从全局计数器 next_free_gno 中获取的。
GTID SET:表示一个 GTID 的集合,可以包含多个 server_uuid,如 executed_gtid、gtid_purged。
GTID SET Interval:GTID SET 中某个 server_uuid 可能包含多个区间,比如 GTID 为“23d45aa2-3d1f-11e6-a16b-c81f66e1165d:1-99:110-200”的字符串中,GTID SET Interval 分别是“1-99”和“110-200”。
6.2 GTID 的生成
GTID 是在 SQL 的 commit 命令发起后,order commit 执行到 flush 阶段需要生成 GTID Event 的时候才会获取。MySQL 内部维护了一个全局的 GTID 的计数器 next_free_gno 用于生成 gno。
可参考函数 Gtid_state∶getautomatic_gno,部分代码如下∶
// 定义∶Gtid next_candidate={ sidno,sidno == get_server_sidno()? next_free_gno:1};// 赋值∶while (true) { const Gtid_set::Interval *iv= ivit.getO;// 定义IntervaL指针指向这个链表指针开头,如果在进行下次循环会获得NULL rpl_gno next_interval_start=iv != NULL ? iv->start: MAX_GNO;// 正常情况下不会为NULL,因此 next_interval_start 等于第一个interval的start,当然如果初始化会为NULL,如果Interval->next =NULL 则标示设有区间了。 while (next_candidate.gno < next_interval_start && DBUG_EVALUATE_IF("simulate_gno_exhausted",false,true))// 如果next_candidate.gno正常不会小于next_intervalL_start// 如果Interval->next =NULL或者初始化next_interval_start会被置为MAX_GNO,那么条件成立DBUG_RETURN(next_candidate.gno);// 返回了这个gno 则GTID生成 { // 返回gno,GTID生成 if(owned_gtids.get_ownernext_candidate)==O) DBUG_RETURN(next_candidate.gno) // 如果本GTID已经被其他线程占用,则next_candidate.gno++ 继续判断 next_candidate.gno++; } ...... }
6.3 server_uuid 的生成
MySQL 在启动的时候会调用 init_server_auto_options 来读取 auto.cnf 文件。如果 auto.cnf 文件不存在,则会调用函数 generate_server_uuid 来生成一个新的 server_uuid,这时 GTID 会发生改变。
当 auto.cnf 文件不存在时,调用函数 generate_serve_ruid 生成 server_uuid 的过程中可以看出,server_uuid 的生成至少和下面部分有关∶
数据库的启动时间。
线程的 LWP ID。LWP 是轻量级进程(light-weight process)的简称。
一个随机的内存地址。
下面是部分代码供参考∶
// 获取MySqL启动时间const time_t save_server_start_time=server_start_time;// 加入LWP号运算server_start_time+=((ulonglong)current_pid << 48)+current_pid;// 一个内存指针,即线程结构体的内存地址thd->status_var.bytes_sent=(ulonglong)thd;// 具体的运算过程lex_start(thd);func_uuid= new (thd->mem_root)Item_func_uuid();func_uuid->fixed= 1;func_uid->vaL_str(&uuid);
6.4 Galera GTID
PXC 集群记录事务的 Galera GTID 中<source_id>的生成逻辑与 MySQL GTID 的不太一样,而且也不是 PXC 集群的 wsrep_cluster_state_uuid,还是以上面的 PXC 集群 C2 为例,看下这个几个参数的值:
mysql> select @@server_uuid;
+--------------------------------------+
| @@server_uuid |
+--------------------------------------+
| 4fd32e4d-249f-11eb-8fd9-fa163e05f092 |
+--------------------------------------+
1 row in set (0.00 sec)
mysql> select @@global.gtid_executed;
+---------------------------------------------------------------------------------------------------------------------------------+
| @@global.gtid_executed |
+---------------------------------------------------------------------------------------------------------------------------------+
| 401cdbc9-e228-ee17-496f-5c53bc36ae5b:1-1123,
c05582e9-dc11-ee14-6b06-c041b8b7ff2d:1-8,
da5e0de8-dc13-ee14-76e6-f074e061cc69:1-2 |
+---------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)
mysql> show status like 'wsrep_%_uuid';
+--------------------------+--------------------------------------+
| Variable_name | Value |
+--------------------------+--------------------------------------+
| wsrep_local_state_uuid | 3faa7d16-23ee-11eb-94f9-3fbe474800d2 |
| wsrep_gcomm_uuid | 4f807d05-249f-11eb-a679-ea70d2c3575a |
| wsrep_cluster_state_uuid | 3faa7d16-23ee-11eb-94f9-3fbe474800d2 |
+--------------------------+--------------------------------------+
3 rows in set (0.00 sec)
可以看到,PXC 集群中实例的 server_uuid 并不在它的 GTID SET 中,当 PXC 集群写入数据时,生成的 MySQL GTID 的<source_id>,也不是服务器的 server_uuid。
实际上在 PXC 集群中这么设计合理的,因为 PXC 是一个分布式可多写的集群架构,所有节点共享相同的<source_id>,当在不同的节点写入数据时,将产生同样的 GTID SET,看起来不同的事务像是在同一个服务器上执行的。
关于 PXC 是如何生成 Galera GTID 中的匿名<source_id>,咱们可以将上文中提到的wsrep_local_state_uuid 的值'3faa7d16-23ee-11eb-94f9-3fbe474800d2',与 GTID SET 中本实例的<source_id>的值'c05582e9-dc11-ee14-6b06-c041b8b7ff2d'相加,发现结果正好是'ffffffff-ffff-ffff-ffff-ffffffffffff',因此可以推断 PXC 集群中事务 Galera GTID 中的<source_id>部分是根据集群的 wsrep_cluster_state_uuid 得到的。
6.5 Galera GTID vs MySQL GTID
两种 GTID 使用的格式相同,即<source_id:trx_number>。
对于 Galera 来说,在集群以 bootstrap 启动时会生成<source_id>,且集群中的所有节点共享此<source_id>。
对于 MySQL 来说,<source_id>就是该实例所在服务器的 server_uuid,通过它可以跟容易识别出事务源自哪个服务器。
所以说 PXC 集群中各节点之间用作同步的 Galera GTID 和 MySQL GTID 之间并没有直接关系,在运维过程中切记不要搞混淆。