海山数据库(He3DB)技术分享:海山MySQL 5.7版本GTID丢失问题分析及解决方案

2024年 7月 25日 64.2k 0

海山数据库(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存在丢失,具体表现如下:

  1. mysql.gtid_executed表的GTID信息没更新
  2. global.gtid_executed没有更新
  3. 倒数第二个binlog日志中没有Previous-GTIDs信息,且大小为123kb
  4. 最新产生的binlog中的Previous-GTIDs和最近一个含有Previous-GTIDs的值相同
  5. 最新产生的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进程

海山数据库(He3DB)技术分享:海山MySQL 5.7版本GTID丢失问题分析及解决方案-1

海山数据库(He3DB)技术分享:海山MySQL 5.7版本GTID丢失问题分析及解决方案-2

  • KILL掉mysqld和GDB进程

此时会产生一个大小123KB且无Previous-GTIDs信息的binlog

海山数据库(He3DB)技术分享:海山MySQL 5.7版本GTID丢失问题分析及解决方案-3

海山数据库(He3DB)技术分享:海山MySQL 5.7版本GTID丢失问题分析及解决方案-4

  • 再次启动数据库

此次重启会再产生一个新的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)进入,关键步骤如下:

  1. 初始化新binlog的文件名,如:mysql-bin.000010

init_and_set_log_file_name(log_name, new_name)

  1. 在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 */

}

  1. 将standard header信息写入到binlog文件,但不包含Previous-GTIDs信息

if (flush_io_cache(&log_file) ||

            mysql_file_sync(log_file.file, MYF(MY_WME)))

    goto err;

  1. 将新生成的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)的读取。读取中包含两个最核心的逻辑。

  1. 先循环读取本地的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));

}

  1. 如果是重启,在该步前会生成一个新的binlog,该binlog不是需要去读取的最后一个binlog,所以要在filename_list中去掉

if (is_server_starting && !is_relay_log && !filename_list.empty())

    filename_list.pop_back();

海山数据库(He3DB)技术分享:海山MySQL 5.7版本GTID丢失问题分析及解决方案-5

注:该步导致的结果是,上一次重启产生的既没有Previous-GTIDs、也没有GTID EVENT的binlog就成为了下一步反向扫描中的最后一个binlog

  1. 先反向扫描,获取最后一个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表中陈旧的信息。

  1. 再正向扫描,获取第一个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中。

海山数据库(He3DB)技术分享:海山MySQL 5.7版本GTID丢失问题分析及解决方案-6

注:至此,重启过程中的GTID初始化结束。

GTID初始化流程

根据上面源码的分析,可以梳理出MySQL 5.7中GTID初始化的流程图。

海山数据库(He3DB)技术分享:海山MySQL 5.7版本GTID丢失问题分析及解决方案-7

注:在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中出现,且根据上面的逻辑,可以继续探讨如下遗留问题。

  1. GTID第一个持久化介质(即mysql.gtid_executed表)中GTID信息既然不是实时更新的,那为什么启动的时候要去读呢?为什么不直接读取第二个持久化介质(即binary log)中的GTID信息呢?
  2. GTID第一个持久化介质(即mysql.gtid_executed表)的作用是什么?如果可以直接读取第二个持久化介质,那mysql.gtid_executed表可以直接去掉么?
  3. 该问题只在MySQL 5.7中出现,那MySQL 8.0中GTID持久化又是怎么处理的?

作者:

戴浩 - 中国移动云能力中心数据库产品部 - 数据库内核研发工程师

相关文章

Oracle如何使用授予和撤销权限的语法和示例
Awesome Project: 探索 MatrixOrigin 云原生分布式数据库
下载丨66页PDF,云和恩墨技术通讯(2024年7月刊)
社区版oceanbase安装
Oracle 导出CSV工具-sqluldr2
ETL数据集成丨快速将MySQL数据迁移至Doris数据库

发布评论