在之前的公众号文章《 slave crash unsafe常见问题分析》中提到slave的master_info_repository和relay_log_info_repository参数的某些配置可能导致crash unsafe,同时在该文章的末尾提到设置relay_log_recovery = on可以避免slave crash unsafe,参考文献[1]、参考文献[2]、参考文献[3]。
因此本文继续之前的思路,首先着重分析GTID-based replication场景unsafe因素,然后分析设置relay_log_recovery = on时,实例crash后如何避免这些unsafe因素的。
本文主要回答以下两个问题:
另外本文的场景中,slave的默认配置如下,并且其中分析主要针对5.7版本。
# binary file position based replication configmaster_info_repository=TABLErelay_log_info_repository=TABLEsync_master_info=10000sync_relay_log_info=10000
# GTID-based replication configgtid_mode=ONenforce_gtid_consistency=ON
# replica replay configslave_parallel_type=LOGICAL_CLOCKslave_parallel_workers=8
innodb_flush_log_at_trx_commit=1
在slave执行崩溃恢复后,第一次和master建立连接时,master需要确定该slave在binlog日志文件中复制的起始位点,并从这个位点开始给slave发送binlog日志。在GTID模式下,这个过程主要分为以下两步:
2.1 slave初始化当前持有的GTID集合
在slave 启动时在执行完整的崩溃恢复后保证了本地数据的一致性后,便开始计算自己当前的held_gtids,这个gtid set可以用如下公式来说明:
held_gtids = UNION(@@GLOBAL.gtid_executed, Retrieved_gtid_set - last_received_GTID)
即当前已经执行的gtid set和已经接受到的gtid set(去掉最后一个不完整的gtid事务)之并集,这个过程可以参见:request_dump函数。
if (gtid_executed.add_gtid_set(mi->rli->get_gtid_set()) != RETURN_STATUS_OK || gtid_executed.add_gtid_set(gtid_state->get_executed_gtids()) != RETURN_STATUS_OK) { global_sid_lock->unlock(); goto err; }
那么这里是如何确定gtid_executed,Retrieved_gtid_set以及last_received_GTID的呢?gtid_executed是可以从mysql.gtid_executed表以及slave的binlog文件(如果slave开启binlog)中得到恢复(参见:前一篇公众号《GTID实践和分析》中【GTID的维护】一节或者参考文献[4]),那么Retrieved_gtid_set和last_received_GTID又是如何确定的呢?下面我们逐步来弄清楚这个问题。
首先需要弄清楚的是在MySQL实例启动时,是如何获取Retrieved_gtid_set这个gtid set的呢?这里有些类似于redo的checkpoint的恢复机制,在每次rotate relay log时开始做Retrieved_gtid_set的checkpoint。其中checkpoint点位是每个relay log文件头的第二个event(previous_gtids_log_event),它记录了slave和master建立主从关系开始,收到的所有gtid集合。其恢复过程一般要经历一反一正两次扫描。由于一个事务的日志可以跨多个relay log,最后一个relay log中剩下的日志可能没有包含跨文件事务的gtid,因此这里首先要反向扫描,直到找到第一个包含gtid的relay log文件,然后读取该文件previous_gtids_log_event,这样就获取了最后一个checkpoint点位。然后为了获取剩下relay log日志中存在的gtid,需要做一次正向扫描,将剩下relay log日志中完整的gtid加入到Retrieved_gtid_set中。以上过程可以参见函数:MYSQL_BIN_LOG::init_gtid_sets,其伪代码描述如下:
iterator checkpoint_it;gtid retrieved_gtid_set;for_each(rbegin(relay_log.index), rend(relay_log.index), [](string& relay_log_file_name)) { if (read_gtids_from_binlog(relay_log_file_name) == GOT_GTIDS) { // 检查该日志文件中是否有gtid event checkpoint_it = iterator(relay_log_file_name); // 记录checkpoint点位 retrieved_gtid_set = previous_gtids_log_event(relay_log_file_name); break; }}
在这第二遍扫描的过程中,会对每个gtid事务做完整性判断,对于DML这类多语句事务来说:只有符合类似于:GTID(1), QUERY(BEGIN), QUERY(INSERT), QUERY(INSERT), XID(COMMIT)这种形式的才是完整的gtid事务,类似于:GTID(1), QUERY(BEGIN), QUERY(INSERT), GTID(2), QUERY(DROP ...)这种形式,其中gtid(1)的事务是不完整的,需要丢弃的。这类不完整的gtid便是上面提到的last_received_GTID。以上过程可以参见函数:MYSQL_BIN_LOG::init_gtid_sets和read_gtids_and_update_trx_parser_from_relaylog,其伪代码表示如下:
gtid partial_trx;for_each(checkpoint_it, end(relay_log.index), [](string& relay_log_file_name)) { if (read_gtids_and_update_trx_parser_from_relaylog(relay_log_file_name, &retrieved_gtid_set, &partial_trx)) break; // 将完整事务的gtid加入到retrieved_gtid_set中,非完整事务的gtid设置在partial_trx中}
在下面的例子中,第一遍反向扫描确定checkpoint点位为rl-bin.000001,其previous_gtids_log_event记录的gtid set为空。之后从rl-bin.000001开始做正向扫描确定了两个完整事务的gtid,最终确定Retrieved_gtid_set为UUID:1-2。
/* Suppose the following relaylog:
rl-bin.000001 | rl-bin.000002 | rl-bin.000003 | rl-bin-000004 ---------------+---------------+---------------+--------------- PREV_GTIDS | PREV_GTIDS | PREV_GTIDS | PREV_GTIDS (empty) | (UUID:1) | (UUID:1) | (UUID:1) ---------------+---------------+---------------+--------------- GTID(UUID:1) | QUERY(INSERT) | QUERY(INSERT) | XID ---------------+---------------+---------------+--------------- QUERY(CREATE | TABLE t1 ...) | ---------------+ GTID(UUID:2) | ---------------+ QUERY(BEGIN) | ---------------+ */
在上面我们提到允许一个事务的日志可以跨多个relay log,但是实际上slave在接受binlog日志时,并不允许在事务中间rotate,参见:MYSQL_BIN_LOG::after_append_to_relay_log,其伪代码如下:
bool can_rotate= mi->transaction_parser.is_not_inside_transaction();if (can_rotate && size(relay_log_file) > max_relay_log_file_size) { // execute rotate process}
上面Retrieved_gtid_set - last_received_GTID的恢复流程如下图所示:
以上是基于relay_log_recovery=off时,slave中held_gtids的恢复过程。这个过程中如果存在last_received_GTID,则可能出现crash unsafe的因素:
(1)出现某些event只写了部分,这些event不完整;
(2)event都是完整的,但是出现不完整的事务。
在实践中发现:其中(2)可能导致slave在回放时coordinator和worker线程相互等待,出现死锁的问题。
如果设置relay_log_recovery=on,则会先跳过对Retrieved_gtid_set的恢复,然后做MTS recovery,补足由于slave在crash的前一刻MTS过程中出现的gap。这时当前拥有的held_gtids的计算方式变为:
held_gtids = UNION(@@GLOBAL.gtid_executed, Gap GTID in MTS)
这一步主要是为了获取当前slave的held_gtids,以上过程可以参见:Relay_log_info::rli_init_info函数,主要流程如下:
init_gtid_sets // Retrieved_gtid_set - last_received_GTID init_recovery // mts recoveryrecover_relay_log // recovery relay log information
下一步便是利用held_gtids来和master相互沟通,获取binlog拉取的起始位点。
2.2 master做gtid对比并确定dump的起始位点
启动之后,当我们执行start slave io_thread或者start slave后,slave会向master发送请求,要求master 从合适的位置dump binlog日志给它。这个过程中,slave给master发出的第一个packet便是将当前的held_gtids发送给master,其中这个packet的类型是COM_BINLOG_DUMP_GTID。以上过程参见:request_dump。
gtid_executed.encode(ptr_buffer);ptr_buffer+= encoded_data_size
在master接受到该packet后,解析其中的held_gtids。如果它是UNION(owned_gtids, @@GLOBAL.gtid_executed)的子集,但不是lost_gtids的子集(关于owned_gtids,lost_gtids的概念可以参见:前一篇公众号《GTID实践和分析》中【GTID的维护】一节),则按照binlog index file中记录的binlog文件的顺序来逆序扫描其中的binlog文件,读取其中的previous_gtids_log_event,直到读取到某个binlog文件中该previous_gtids_log_event是held_gtids的子集 ,以此来确定dump binlog的文件,以上过程参见:Binlog_sender::check_start_file。
if (!m_exclude_gtid->is_subset_for_sid(>id_executed_and_owned, gtid_state->get_server_sidno(), subset_sidno)) { // error } if (!gtid_state->get_lost_gtids()->is_subset(m_exclude_gtid)) { // error } if (mysql_bin_log.find_first_log_not_in_gtid_set(index_entry_name, m_exclude_gtid, &first_gtid, &errmsg)) { // error }
之后顺序读取该binlog文件直到找到第一个不在held_gtids中的gtid,来确定后续要dump binlog文件位置,参见:Binlog_sender::skip_event。
gtid.sidno= gtid_ev.get_sidno(m_exclude_gtid->get_sid_map()); gtid.gno= gtid_ev.get_gno(); DBUG_RETURN(m_exclude_gtid->contains_gtid(gtid));
通过上面的步骤,确定了要dump的文件名和文件位置,之后master就从该位置,按照顺序来一个个的dump binlog文件中的event到slave。
与此同时,在slave,如果设置relay_log_recovery=on,crash前从master拉取的relay log全部文件会被废弃,然后从新的relay log开始拉取binlog日志。同时由于slave的held_gtids全部都已经执行,因此SQL线程也从新的relay log文件开始执行。这样就可以避免前面提到的last_received_GTID不为空时的两种crash unsafe因素。
和GTID是一样的,在Binary Log File Position Based场景中,为了获取复制的起始位点,这个过程分为以下两步:
3.1 slave确定拉取的binlog文件名和文件位置
这里以master_info_repository=TABLE为例来说明这中间存在的unsafe因素,slave在启动后首先从mysql.slave_master_info中读取自己在crash之前IO线程记录的文件名和文件偏移,这样就确定了自己当前持有的binlog位点信息,这里将其记为held_file_and_pos。在这个过程中如果sync_master_info!=1,则held_file_and_pos的更新往往是落后于实际已经写入的binlog位置。如果sync_master_info==1,由于写relay log操作和写mysql.slave_master_info表是非原子操作,如果中间出现crash,则还是会导致中间出现不一致。因此Binary Log File Position Based Replication场景,held_file_and_pos的位点错误非常容易导致event被重复拉取,进而导致重复应用,最终造成主从数据不一致。
除了GTID中出现了两个unsafe因素,上面提到unsafe因素在《slave crash unsafe常见问题分析》中也有详细的说明。
3.2 master确定dump的起始位点
当slave链接上master后,给master发出的第一个packet便是held_file_and_pos。master接受后,解析其中的binlog file name和binlog file position,然后从这个位点开始发送日志给slave。其中这个包的类型是COM_BINLOG_DUMP。参见:request_dump。
int4store(ptr_buffer, DBUG_EVALUATE_IF("request_master_log_pos_3", 3, static_cast<uint32>(mi->get_master_log_pos())));ptr_buffer+= ::BINLOG_POS_OLD_INFO_SIZE // ...memcpy(ptr_buffer, mi->get_master_log_name(), BINLOG_NAME_INFO_SIZE);ptr_buffer+= BINLOG_NAME_INFO_SIZE;
如果设置relay_log_recovery=on,则slave会回调mysql.slave_master_info中记录的位置信息,将其位置重新设置为mysql.slave_relay_log_info中记录的文件名和文件偏移,然后再做MTS recovery。因为mysql.slave_relay_log_info或者slave_worker_info的更新是作为非DDL事务的一部分,因此mysql.slave_relay_log_info和已经执行的事务是一致的。
这里额外提一点的是专门针对5.6和5.7的DDL事务。因为在DDL事务中,mysql.slave_relay_log_info或者slave_worker_info的更新和DDL操作不具备原子性,如果在中间crash,则可能导致部分DDL被重复执行。同理,如果slave不开启binlog,则每个事务结束后,必须要更新mysql.gtid_executed表,这也会出现上面的问题。在8.0中DDL事务具备原子性,上述问题得以最终解决。
以上我们总结了slave在启动时是如何恢复持有的日志信息(held_gtids或held_file_and_pos),然后根据这些信息和master进行交互并恢复正常的binlog IO流程。在这个过程中,说明这些过程中出现的unsafe因素,并总结了relay_log_recovery=on时对这些流程的影响。开启relay_log_recovery则保证了启动后拉取的binlog位置是安全的。反之,则会在一定程度上影响slave复制的安全性。
下面我们将relay_log_recovery=off时,主要的unsafe因素列举如下:
1、存在event部分写入:若crash后,启动时未执行的relay log中有不完整的event则会报下面的1594错误:
若已经执行的relay log中有不完整的event,则会报下面的错误
下面几种情况中,relay log中均不存在不完整的event。
2、存在事务部分写入:若crash前,有些事务只写入部分,在启动后MySQL会新建一个日志文件,之前的事务仍保持截断状态,后面SQL回放这个处于截断状态的事务时,某些情况下可能会导致MTS中coordinator和slave相互卡住。
3、IO位点和SQL位点错误:这类错误,往往导致IO位点和SQL位点出现回调,导致同一个事务反复被执行,由此导致实例的数据出现不一致,例如上面提到的DDL事务不具备原子性问题,导致同一个DDL事务被反复执行的问题,这类问题即使设置relay_log_recovery=on也无法解决。而在Binary Log File Position Based Replication场景中出现大部分IO位点和SQL位点错误则可以通过relay_log_recovery=on得以解决。
尽管relay_log_recovery=on可以规避上面主要的unsafe因素,但是当master和slave网络出现问题或者master无法正常拉起时,如果slave在sbm比较大时发生crash,然后slave按照上面的流程回退了位点后,可能导致中间很多正常的relay log日志被跳过执行。此时由于master不能正常拉起,如果slave中间的relay log没有被purge,那么立即shutdown slave,然后强行修改mysql.slave_master_info和mysql.slave_relay_log_info、mysql.slave_work_info等表中位点信息到之前老的位点,则可以恢复之前的数据。如果中间的relay log被purge,那么则存在丢失数据的风险。为此,需要在启动时判断只有存在event部分写入和事务部分写入时才去执行前面提到的relay_log_recovery=on时的逻辑,否则不执行。这样既可以规避前面提到的1和2两种unsafe因素,也可以将丢失数据的风险降到最低。我们团队将思路提交给了官方,详见参考文献[5]。
腾讯数据库技术团队对内支持QQ空间、微信红包、腾讯广告、腾讯音乐、腾讯新闻等公司自研业务,对外在腾讯云上依托于CBS+CFS的底座,支持TencentDB相关产品,如TDSQL-C(原CynosDB)、TencentDB for MySQL(CDB)、CTSDB、MongoDB、CES等。腾讯数据库技术团队专注于持续优化数据库内核和架构能力,提升数据库性能和稳定性,为腾讯自研业务和腾讯云客户提供“省心、放心”的数据库服务。此公众号旨在和广大数据库技术爱好者一起推广和分享数据库领域专业知识,希望对大家有所帮助。