海山数据库(He3DB)技术分享:
海山MySQL 5.7版本GTID丢失问题分析及解决方案
本文将从源码层面分析MySQL 5.7因异常重启导致整个binlong中GTID丢失问题,并逐步梳理出MySQL 5.7中GTID持久化和初始化的过程。本问题的复现步骤、产生的原因、修复方案均以BUG和patch的方式反馈给了社区,percona社区已经在其5.7版本中merge了我们贡献的patch代码。
注:本文以MySQL 5.7.44版本源码进行分析和梳理,且只针对主库。
问题现象
在主备实例环境中,主库重启之后,备库同步报错,备库的GTID要比主库多很多。进一步排查发现主库的GTID存在丢失,具体表现如下:
- mysql.gtid_executed表的GTID信息没更新
- global.gtid_executed没有更新
- 倒数第二个binlog日志中没有Previous-GTIDs信息,且大小为123kb
- 最新产生的binlog中的Previous-GTIDs和最近一个含有Previous-GTIDs的值相同
- 最新产生的binlog中的GTID起始值和最近一个含有GTID EVENT的GTID起始值相同
注:发生上述问题的前置条件为第一次重启前的最后一个binlog中存在GTID EVENT
问题原因
管控问题
数据库第一次重启流程没有完成时(准确的讲是在第一次重启时没有在新产生的binlog中写入Previous-GTIDs信息),其他组件将mysqld进程强制kill。
内核源码问题
内核源码中,读取binlog(第二个GTID持久化介质)时,在反向扫描最后一个binlog时,如果此时binlog中不包含任何Previous-GTIDs和GTID EVENT,且参数binlog_gtid_simple_recovery为ON的情况下,则不会再去读取任何其他的binlog文件。
问题分析
问题复现
通过GDB设置断点的方式,控制mysqld的启动流程,具体复现步骤如下:
- 安装MySQL-5.7.44
具体安装步骤略
- 开启binlog和gtid
在参数文件中配置参数
log-bin=mysql-bin
server_id=1
enforce_gtid_consistency=on
gtid_mode=on
- 重启数据库
systemctl restart mysqld
- 手动生成部分GTID EVENT
create database testdb;
Use testdb;
Create table t1(id int, name varchar(20));
Insert into t1 values(1,’test01’); —可多条重复执行
- 关闭数据库
systemctl stop mysqld
- GDB启动mysqld并设置断点
gdb --args /usr/sbin/mysqld
b MYSQL_BIN_LOG::open_binlog
b MYSQL_BIN_LOG::init_gtid_sets
b Gtid_state::save
run
注:在MYSQL_BIN_LOG::open_binlog执行完成之后,在Gtid_state::save完成之前kill掉mysqld和GDB进程
- KILL掉mysqld和GDB进程
此时会产生一个大小123KB且无Previous-GTIDs信息的binlog
- 再次启动数据库
此次重启会再产生一个新的binlog,但该binlog中的Previous-GTIDs和mysql-bin.000004中的一致,且global.gtid_executed变量中的信息亦和mysql-bin.000004中的一致,最终导致的结果是mysql-bin.000004中的GTID EVENT
全部丢失,问题复现。
源码分析
此部分只针对数据库启动的代码流程,且只关注主库的GTID初始化和各个模块中GTID更新的时机,因为GTID的更新在很多场景下都会进行,比如:purge binary、flush logs等,而备库的更新流程则还跟参数log_slave_updates有关。
binlog创建
MySQL开启binlog,在每次重启过程中,都会执行MYSQL_BIN_LOG::open_binlog函数创建一个新的binlog文件,并初始化standard header信息,但不包括Previous-GTIDs信息。在mysqld.cc中通过if (opt_bin_log)进入,关键步骤如下:
- 初始化新binlog的文件名,如:mysql-bin.000010
init_and_set_log_file_name(log_name, new_name)
- 在binlog目录下生成新的binlog文件,此时在文件系统上就可以看到生成的文件
if (open(
#ifdef HAVE_PSI_INTERFACE
m_key_file_log,
#endif
log_name, new_name))
{
#ifdef HAVE_REPLICATION
close_purge_index_file();
#endif
DBUG_RETURN(1);
/* all warnings issued */
}
- 将standard header信息写入到binlog文件,但不包含Previous-GTIDs信息
if (flush_io_cache(&log_file) ||
mysql_file_sync(log_file.file, MYF(MY_WME)))
goto err;
- 将新生成的binlog文件名写入到mysql-bin.index
add_log_to_index((uchar*) log_file_name, strlen(log_file_name), need_lock_index))
注:该函数完成之后,在binlog存放的目录下可以看到生成了一个不含Previous-GTIDs信息的binlog,大小为123kb。如果在此函数执行的过程中kill掉mysqld进程,则不会出现我们复现的故障,因为此时会有一个mysql-bin.rec的文件保证原子性,此时重启,则不会生成新的binlog,会复用本次生成的binlog文件名(log_file_name);如果在该函数执行完成后,立即kill掉mysqld进程,则会复现故障。
初始化server uuid
通过init_server_auto_options函数初始化server uuid信息,此函数比较简单,且和后面逻辑关联不强,但uuid是GTID的重要组成部分。
读取第一个GTID持久化介质
该步比较重要,读取第一个GTID持久化介质,即mysql.gtid_executed表,通过gtid_state->read_gtid_executed_from_table函数完成,其真正调用的是gtid_table_persistor->fetch_gtids函数,主要的逻辑是一行一行的读取mysql.gtid_executed表中的数据,并加入gtid_executed变量
while(!(err= table->file->ha_rnd_next(table->record[0])))
{
/* Store the gtid into the gtid_set */
/**
@todo:
- take only global_sid_lock->rdlock(), and take
gtid_state->sid_lock for each iteration.
- Add wrapper around Gtid_set::add_gno_interval and call that
instead.
**/
global_sid_lock->wrlock();
if (gtid_set->add_gtid_text(encode_gtid_text(table).c_str()) != RETURN_STATUS_OK)
{
global_sid_lock->unlock();
break;
}
global_sid_lock->unlock();
}
注:因主库的mysql.gtid_executed表信息是不正确的,即不是实时更新的,是旧的(不包括最近异常binlog中的GTID EVENT信息),所以在正常情况下,就算mysql.gtid_executed表的数据库被清空了,也不会影响数据库重启之后GTID的正确性,在接下来的读取第二个GTID持久化介质(binary log)才是重点。
读取第二个持久化介质
MYSQL_BIN_LOG::init_gtid_sets函数是我们本次问题的核心函数,第一次在mysql.gtid_executed表中读取到不正确GTID的逻辑下,进行了第二个持久化介质(binary log)的读取。读取中包含两个最核心的逻辑。
- 先循环读取本地的binlog日志并将其加入到filename_list中
for (error= find_log_pos(&linfo, NULL, false/*need_lock_index=false*/); !error;
error= find_next_log(&linfo, false/*need_lock_index=false*/))
{
DBUG_PRINT(“info”, (“read log filename ‘%s’”, linfo.log_file_name));
filename_list.push_back(string(linfo.log_file_name));
}
- 如果是重启,在该步前会生成一个新的binlog,该binlog不是需要去读取的最后一个binlog,所以要在filename_list中去掉
if (is_server_starting && !is_relay_log && !filename_list.empty())
filename_list.pop_back();
注:该步导致的结果是,上一次重启产生的既没有Previous-GTIDs、也没有GTID EVENT的binlog就成为了下一步反向扫描中的最后一个binlog
- 先反向扫描,获取最后一个binlog中包含的最新GTID EVENT和Previous-GTIDs
rit= filename_list.rbegin();
bool can_stop_reading= false;
reached_first_file= (rit == filename_list.rend());
DBUG_PRINT(“info”, (“filename=’%s’ reached_first_file=%d”,
reached_first_file ? “” : rit->c_str(),
reached_first_file));
while (!can_stop_reading && !reached_first_file)
{
const char *filename= rit->c_str();
assert(rit != filename_list.rend());
rit++;
reached_first_file= (rit == filename_list.rend());
DBUG_PRINT(“info”, ("filename=’%s’ can_stop_reading=%d "
"reached_first_file=%d, ",
filename, can_stop_reading, reached_first_file));
switch (read_gtids_from_binlog(filename, all_gtids,
reached_first_file ? lost_gtids : NULL,
NULL/* first_gtid */,
sid_map, verify_checksum, is_relay_log))
{
case ERROR:
{
error= 1;
goto end;
}
case GOT_GTIDS:
{
can_stop_reading= true;
break;
}
case GOT_PREVIOUS_GTIDS:
{
/*
If this is a binlog file, it is enough to have GOT_PREVIOUS_GTIDS.
If this is a relaylog file, we need to find at least one GTID to
start parsing the relay log to add GTID of transactions that might
have spanned in distinct relaylog files.
*/
if (!is_relay_log)
can_stop_reading= true;
break;
}
case NO_GTIDS: //出现问题的核心逻辑
{
/*
Mysql server iterates backwards through binary logs, looking for
the last binary log that contains a Previous_gtids_log_event for
gathering the set of gtid_executed on server start. This may take
very long time if it has many binary logs and almost all of them
are out of filesystem cache. So if the binlog_gtid_simple_recovery
is enabled, and the last binary log does not contain any GTID
event, do not read any more binary logs, GLOBAL.GTID_EXECUTED and
GLOBAL.GTID_PURGED should be empty in the case.
*/
if (binlog_gtid_simple_recovery && is_server_starting && !is_relay_log)
{
assert(all_gtids->is_empty());
assert(lost_gtids->is_empty());
goto end; //直接跳出了while循环,不再继续读取上一个binlog
}
/*FALLTHROUGH*/
}
case TRUNCATED:
{
break;
}
}
}
注:如果反向读取的最后一个binlog中既没有Previous-GTIDs、也没有GTID EVENT,不再继续反向读取前一个binlog,最终导致更新到global.gtid_executed变量的信息是第一次读取GTID持久化介质mysql.gtid_executed表中陈旧的信息。
- 再正向扫描,获取第一个binary log中的lost gtid(即Previous-GTID),该步和本次线上问题无关,不做过多阐述,但和解决方案的选取有关
if (lost_gtids != NULL && !reached_first_file) //正向查找获得purged_gtids_from_binlog
{
/*
This branch is only reacheable by a binary log. The relay log
don’t need to get lost_gtids information.
A 5.6 server sets GTID_PURGED by rotating the binary log.
A 5.6 server that had recently enabled GTIDs and set GTID_PURGED
would have a sequence of binary logs like:
master-bin.N : No PREVIOUS_GTIDS (GTID wasn’t enabled)
master-bin.N+1: Has an empty PREVIOUS_GTIDS and a ROTATE
(GTID was enabled on startup)
master-bin.N+2: Has a PREVIOUS_GTIDS with the content set by a
SET @@GLOBAL.GTID_PURGED + has GTIDs of some
transactions.
If this 5.6 server be upgraded to 5.7 keeping its binary log files,
this routine will have to find the first binary log that contains a
PREVIOUS_GTIDS + a GTID event to ensure that the content of the
GTID_PURGED will be correctly set (assuming binlog_gtid_simple_recovery
is not enabled).
*/
DBUG_PRINT(“info”, ("Iterating forwards through binary logs, looking for "
"the first binary log that contains both a "
“Previous_gtids_log_event and a Gtid_log_event.”));
assert(!is_relay_log);
for (it= filename_list.begin(); it != filename_list.end(); it++)
{
/*
We should pass a first_gtid to read_gtids_from_binlog when
binlog_gtid_simple_recovery is disabled, or else it will return
right after reading the PREVIOUS_GTIDS event to avoid stall on
reading the whole binary log.
*/
Gtid first_gtid= {0, 0};
const char *filename= it->c_str();
DBUG_PRINT(“info”, (“filename=’%s’”, filename));
switch (read_gtids_from_binlog(filename, NULL, lost_gtids,
binlog_gtid_simple_recovery ? NULL :
&first_gtid,
sid_map, verify_checksum, is_relay_log))
{
case ERROR:
{
error= 1;
/*FALLTHROUGH*/
}
case GOT_GTIDS:
{
goto end;
}
case NO_GTIDS:
case GOT_PREVIOUS_GTIDS:
{
/*
Mysql server iterates forwards through binary logs, looking for
the first binary log that contains both Previous_gtids_log_event
and gtid_log_event for gathering the set of gtid_purged on server
start. It also iterates forwards through binary logs, looking for
the first binary log that contains both Previous_gtids_log_event
and gtid_log_event for gathering the set of gtid_purged when
purging binary logs. This may take very long time if it has many
binary logs and almost all of them are out of filesystem cache.
So if the binlog_gtid_simple_recovery is enabled, we just
initialize GLOBAL.GTID_PURGED from the first binary log, do not
read any more binary logs.
*/
if (binlog_gtid_simple_recovery)
goto end;
/*FALLTHROUGH*/
}
case TRUNCATED:
{
break;
}
}
}
}
持久化Previous-GTIDs
通过gtid_state->save函数将不在mysql.executed_gtids表中,但在binlog中的gtid信息保存到mysql.executed_gtids中,即上述步骤中得到的gtids_in_binlog_not_in_table信息,实际调用的函数为:gtid_table_persistor->save
注:此步的作用是将最后一个binlog中的Previous-GTIDs信息持久化到mysql.executed_gtids表中。
更新executed_gtids变量
通过executed_gtids->add_gtid_set函数在executed_gtids变量中加入不在mysql.executed_gtids表中,但在binlog中的gtid信息,至此,executed_gtids变量的信息才是准确的信息。
previous set信息写入新的binlog
最后通过flush_io_cache(mysql_bin_log.get_log_file()) || mysql_file_sync()函数将previous set信息写入到新的binlog中。
注:至此,重启过程中的GTID初始化结束。
GTID初始化流程
根据上面源码的分析,可以梳理出MySQL 5.7中GTID初始化的流程图。
注:在MySQL 5.7中,gtid_executed表是GTID持久化的一个介质,其更新(持久化)时机是在binary log切换时。所以在数据库启动读取的信息不是最新的、不准确的。
修复方案
根据上述逻辑,只需要修改MYSQL_BIN_LOG::init_gtid_sets中出现NO_GTIDS时,不跳出反向读取binlog的逻辑即可,可通过如下两种方案进行修复。
修改参数
该修改方式有一定的缺陷,比如当做purge binary logs操作时会存在一定的性能问题
binlog_gtid_simple_recovery=OFF;
内核修改
修改MYSQL_BIN_LOG::init_gtid_sets中出现NO_GTIDS时的逻辑,使其继续向上扫描binlog,直到扫描的binlog中存在Previous-GTIDs信息。
原始逻辑:
if (binlog_gtid_simple_recovery && is_server_starting && !is_relay_log)
{
assert(all_gtids->is_empty());
assert(lost_gtids->is_empty());
goto end;
}
修改为:
if (binlog_gtid_simple_recovery && is_server_starting && !is_relay_log && reached_first_file)
{
assert(all_gtids->is_empty());
assert(lost_gtids->is_empty());
goto end;
}
问题思考
该问题只在MySQL 5.7中出现,且根据上面的逻辑,可以继续探讨如下遗留问题。
- GTID第一个持久化介质(即mysql.gtid_executed表)中GTID信息既然不是实时更新的,那为什么启动的时候要去读呢?为什么不直接读取第二个持久化介质(即binary log)中的GTID信息呢?
- GTID第一个持久化介质(即mysql.gtid_executed表)的作用是什么?如果可以直接读取第二个持久化介质,那mysql.gtid_executed表可以直接去掉么?
- 该问题只在MySQL 5.7中出现,那MySQL 8.0中GTID持久化又是怎么处理的?
作者:
戴浩 - 中国移动云能力中心数据库产品部 - 数据库内核研发工程师