欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

全方位解读 MySQL 日志实现内幕(四)

程序员文章站 2022-06-24 21:56:03
...
全方位解读 MySQL 日志实现内幕(四)`
王竹峰

去哪儿网数据库专家,擅长数据库开发、数据库管理及维护,一直致力于 MySQL 数据库源码的研究与探索,对数据库原理及实现具有深刻的理解。曾就职于达梦数据库,多年从事数据库内核开发的工作,后转战人人网,任职高级数据库工程师,目前在去哪儿网负责 MySQL 源码研究与运维、数据库管理和自动化运维平台设计开发及实践工作,是 Inception 开源项目及《MySQL 运维内参》的作者,也是 Oracle MySQL ACE。

本文作者将出版于《MySQL 运维内参》中部分内容进行分享,通过多篇文章连载形式,全方位介绍 MySQL 日志实现内幕,可持续关注我们的推文哦!


REDO日志恢复

前面已经很全面地介绍了日志的生成、格式、刷盘、工作原理等,但这些实际上只是数据库运行时的一个“累赘”,没办法才会这样做,因为如果数据库不挂,日志是没有用的,但不挂是不可能的,所以日志是必须要有的。而前面介绍的所有内容都是建立在有日志的前提下,解决如何提高性能,如何保证数据完整性等问题的。那这里将介绍关于日志的新内容,日志的用途之一:数据库恢复。

在第5章中,已经介绍了在 InnoDB 存储引擎的启动过程中,InnoDB 需要做的事情有哪些,具体细节可以参考第 5 章了解。在这一节中,需要重点关注的主要有两个,包括 recv_recovery_from_checkpoint_start 及 recv_recovery_from_checkpoint_finish 两个函数的处理(关于两个函数的关系,请参阅第 5 章相关章节)。

InnoDB 启动之前,肯定是处于 shutdown 状态的,而导致 shutdown 的原因只有两种可能性,即正常关闭及 Crash 关闭。这里所说的数据恢复,主要处理的就是针对异常关闭时的情况。当然了,有一个叫 innodb_fast_shutdown 的参数,如果设置为 2,也相当于是一次 Crash 了,道理也是一样的。

那可能有人就要问了,如果正常关闭(innodb_fast_shutdown 设置为 0 或者 1),那是不是就不执行数据库恢复了?其实不是这样的,不管如何关闭数据库,启动时都会做数据库恢复的操作,只不过正常关闭的情况下,不存在没有做过 checkpoint 的日志,或者说,最新的 checkpoint 已经在最新的 LSN 位置了,又或者说所有的数据页面都已经被刷成了最新的状态。说法可以有多种,但意义其实是一样的。

日志扫描

在开始准备做数据库恢复时,首先要做的就是从日志文件中找到最新的检查点信息。我们已经知道,在日志文件最开始的 4 个页面(每个页面 512 字节)中,存储的是用来管理日志文件及日志写入情况的信息,具体格式可以从前面看到。这里所关注的检查点信息是存储在第1号页面和第3号页面中的,即所谓的 LOG_CHECKPOINT_1 和 LOG_CHECKPOINT_2。在做检查点时,这两个存储位置是轮换着使用的。

基于此,想要找到最新的检查点位置,就需要从上面的两个位置中找到一个最大值,也就是在这个点之前所有的日志都是失效的,并且对应的数据页面都是完整的。而在这个位置之后的页面,有可能是完整的,也有可能需要做 REDO,这个决定于当时 Buffer Pool 的刷盘情况,如果正好有被淘汰出去的页面,那就是完整的,否则还需要通过 REDO 日志来恢复。

先来看一段对应的精简后的代码,如下。

UNIV_INTERN dberr_t 
recv_recovery_from_checkpoint_start_func(    
	lsn_t   min_flushed_lsn,/*!< in: min flushed lsn from data files */    
	lsn_t   max_flushed_lsn)/*!< in: max flushed lsn from data files */
{    
	/* loval variables ... */
    
	if (srv_force_recovery >= SRV_FORCE_NO_LOG_REDO) {
        ib_logf(IB_LOG_LEVEL_INFO,
                    "The user has set SRV_FORCE_NO_LOG_REDO on, "                              
                    "skipping log redo");        
		return(DB_SUCCESS);    
}

    recv_recovery_on = TRUE;

    mutex_enter(&(log_sys->mutex));
    
/* Look for the latest checkpoint from any of the log groups */
    
/* 如上所述,这里的工作就是用来从两个Checkpoint的位置,找到最新的
    max_cp_group中保存的Checkpoint对应的信息,包括最新LSN信息、LSN对应的
    日志文件中位置信息等。前面已经知道,5.6版本之后的InnoDB都支持
    总空间超过4GB大小的日志文件,所以这个位置信息包括了低32位值和高32
    位值。max_cp_field用来表示最新位置是LOG_CHECKPOINT_1还是LOG_CHECKPOINT_1 */
    err = recv_find_max_checkpoint(&max_cp_group, &max_cp_field);    
	if (err != DB_SUCCESS) {
        mutex_exit(&(log_sys->mutex));        
        return(err);    
}
    
	/* 根据前面找到的max_cp_field信息,把这个位置对应的检查点信息全部读取出来,
    并存储到log_sys->checkpoint_buf空间中,下面会用到这部分数据 */
    log_group_read_checkpoint_info(max_cp_group, max_cp_field);
    buf = log_sys->checkpoint_buf;
    
	/* 从上面的log_sys->checkpoint_buf中拿到最新的检查点对应的LSN值及checkpoint_no值。
    checkpoint_no就是在InnoDB做检查点时,给每一次分配的一个编号,顺序增长,值越大,
    表示这个检查点越是最近做的 */
    checkpoint_lsn = mach_read_from_8(buf + LOG_CHECKPOINT_LSN);
    checkpoint_no = mach_read_from_8(buf + LOG_CHECKPOINT_NO);
    
	/* Read the first log file header to print a note if this is
    a recovery from a restored InnoDB Hot Backup */
    
	/* 读出日志头的前4个页面(一个页面512字节)*/

    fil_io(OS_FILE_READ | OS_FILE_LOG, true, max_cp_group->space_id, 	0,           
 	0, 0, LOG_FILE_HDR_SIZE,           
 	log_hdr_buf, max_cp_group);
    
	/* 从上面读取出的信息中,找到存储了ib_logfile的文件管理中,
    每一个块大小的位置。什么?文件块大小可以改变?是的,在MySQL官方版本中,
    块大小是不可以修改的,都是512字节,但Percona为了适应存储设备方面的科技进步,
    就支持了这个功能。当然,支持是支持了,但不用也没关系,如果不用,那么这个位置的值就是0,
    就认为还是默认值512字节 */
    
	/* 声明:不过需要注意的是,这里是为了说明一下这个特性在Percona中已经得到了支持。
    在本章“REDO LOG日志文件管理的用途”一节中,之所以在说明日志文件格式时没有讲到这个值,
    是因为在前面讲到的内容中,在LOG_FILE_WAS_CREATED_BY_HOT_BACKUP之后,就没有其他内容了,
    这个页面就是空的了。而Percona是将块大小的信息追加到这个信息之后,做到了与官方MySQL的兼容 */
    log_hdr_log_block_size = mach_read_from_4(log_hdr_buf + LOG_FILE_OS_FILE_LOG_BLOCK_SIZE);    
	if (log_hdr_log_block_size == 0) {        
	/* 0 means default value */       
	 log_hdr_log_block_size = 512;    
}
    
	/* Percona在这里很亲切地问候你,如果日志文件中存储的块大小和当前系统设置的值不一样,
    也就是说这次数据库启动时修改了这个参数,那么它会告诉你,并且会给出友好的建议,
    可以RECREATE日志文件,很贴心 */    
	if (UNIV_UNLIKELY(log_hdr_log_block_size != srv_log_block_size)) {
        fprintf(stderr,            
			"InnoDB: Error: The block size of ib_logfile (" ULINTPF            
			") is not equal to innodb_log_block_size.\n"
            "InnoDB: Error: Suggestion - Recreate log files.\n",
            log_hdr_log_block_size);        
		return(DB_ERROR);    
	}
    
	/* Start reading the log groups from the checkpoint lsn up. The
    variable contiguous_lsn contains an lsn up to which the log is
    known to be contiguously written to all log groups. */
    
	/* 到此为止,用来做恢复的信息,都已经获取到了:
    checkpoint_lsn:表示的是从这个位置开始,后面的日志需要做APPLY操作 */
    recv_sys->parse_start_lsn = checkpoint_lsn;
    recv_sys->scanned_lsn = checkpoint_lsn;
    recv_sys->scanned_checkpoint_no = 0;
    recv_sys->recovered_lsn = checkpoint_lsn;
    srv_start_lsn = checkpoint_lsn;
    
	/* 因为文件读取需要对齐到块大小,所以recv_sys->scanned_lsn
    会做对齐处理,contiguous_lsn表示的就是对齐之后的值 */
    contiguous_lsn = ut_uint64_align_down(recv_sys->scanned_lsn, OS_FILE_LOG_BLOCK_SIZE);
    
	/* 目前,InnoDB只支持一个GROUP,所以这里的遍历实际上没有什么意义,
    这里的处理是最重要的,所做的工作就是从contiguous_lsn的位置开始
    扫描所有的日志数据,然后进一步做分析、恢复等操作 */    
	group = UT_LIST_GET_FIRST(log_sys->log_groups);    
	while (group) {
        recv_group_scan_log_recs(group, &contiguous_lsn, &group_scanned_lsn);        
        group->scanned_lsn = group_scanned_lsn;        
        group = UT_LIST_GET_NEXT(log_groups, group);    
	}
    
	/* other codes ... */    
	/* 做完数据库恢复之后,要处理一下收尾工作。这个收尾工作非常重要,
    类似于一个工程,在工作实施完成之后,还有一步是最后验收,验收的
    时候一般会打上一个验收合格的标志,那么这里的操作也是同样的道理,
    具体的操作就是再做一次检查点,更新一下最新的检查点信息,这样之前
    处理的所有REDO日志就失效了,如果数据库再挂了,那也是重新洗牌,与
    这次就没有什么关系了 */
    recv_synchronize_groups();    
	/* The database is now ready to start almost normal processing of user
    transactions: transaction rollbacks and the application of the log
    records in the hash table can be run in background. */
    
	return(DB_SUCCESS);
}

上面的代码,其实就是我们所熟悉的函数 recv_recovery_from_checkpoint_start_func 的执行过程。归纳起来,其所做的操作包括以下两部分。

  • 从日志文件的固定位置找到最新的检查点信息。

  • 从最新的检查点位置开始扫描日志文件,做数据库恢复。

现在,主要的工作就落在了 recv_group_scan_log_recs 上面,这个函数所要做的工作,就是将 checkpoint_lsn 位置开始的日志分片处理,每一片为 2MB 大小,对应的精简之后的代码如下。

static void recv_group_scan_log_recs(    
	log_group_t*    group,      
	lsn_t*      contiguous_lsn, 
	lsn_t*      group_scanned_lsn
)
{
    
	/* local variables ... */
    finished = FALSE;    
    start_lsn = *contiguous_lsn;
    
	/* 等待分析完毕 */    
	while (!finished) {
        
		/* RECV_SCAN_SIZE大小为4*16KB,也就是分片大小为64KB,
        因为已经知道,InnoDB的日志LSN的增长和数据量写入的增长是同步的。
        也就是说LSN加1,表示日志就多写入一个字节,所以这里在LSN的计算中,加上
        64KB,表示的就是2MB的日志量 */
        end_lsn = start_lsn + RECV_SCAN_SIZE;
        
		/* 在下面这个函数中,会根据之前读出来的LSN所对应的日志文件偏移位置,
        将2MB内容读取出来,存到log_sys->buf中,以待后面分析 */
        log_group_read_log_seg(LOG_RECOVER, log_sys->buf, group, start_lsn, end_lsn, FALSE);
        
		/* recv_scan_log_recs中,会检查到日志已经分析完毕,那
        数据库的REDO就算基本完成了,上面的while循环停止,具体如何判断日志
        内容读取完毕,请待进一步的讲述 */

        finished = recv_scan_log_recs(            
			(buf_pool_get_n_pages()            
			- (recv_n_pool_free_frames * srv_buf_pool_instances))            
			* UNIV_PAGE_SIZE,
            TRUE, log_sys->buf, RECV_SCAN_SIZE,
            start_lsn, contiguous_lsn, group_scanned_lsn);
        
		/* 下一个分片,从上一个分片的结束位置开始 */
        start_lsn = end_lsn;    
	}
}

从上面的函数可以看到,数据库恢复时会根据最新检查点的位置,将日志不断分片读取,然后进行分片处理,这里再来分析一下 InnoDB 是如何做分片处理的。继续看精简之后的代码,如下。

UNIV_INTERN
ibool
recv_scan_log_recs(
    ulint       available_memory,
    ibool       store_to_hash,      
    const byte* buf,        /*!< in: buffer containing a log segment or garbage */
    ulint       len,        /*!< in: buffer length */    
    lsn_t       start_lsn,  /*!< in: buffer start lsn */    
    lsn_t*      contiguous_lsn,    
    lsn_t*      group_scanned_lsn)
{    
	/* local variables ... */
    /* 通过finished来表示恢复过程是否已经做完,如果做完则返回值为true */
    finished = FALSE;    
    /* 存储了64KB的日志 */
    log_block = buf;
    scanned_lsn = start_lsn;
    more_data = FALSE;
    
	do {       
		/* 读出当前块中存储的数据量,一个块,默认大小为512字节,
        如果没有扫描到最后一块,这个大小就都是512,因为日志都是连续存储的 */
        data_len = log_block_get_data_len(log_block);

        scanned_lsn += data_len;
        
		/* 如果当前块中的数据量大于0,就会处理当前块 */
        if (scanned_lsn > recv_sys->scanned_lsn) {
            /* recv_sys,用来存储分析之后的日志。这里的工作是将从日志
            文件中读取出来的原始数据去掉头(12字节)尾(4字节)数据之后,
            将中间真正的日志取出来,放到recv_sys所指的缓存空间中,这部分数据
            才是REDO恢复真正需要的数据,而在日志文件中存储的原始日志(包括头尾)
            是为了更好更方便地管理而设置的,所以在这里会有这么一个转换的步骤。*/

            /* 如果recv_sys的缓存空间已快要超过分析缓冲区大小(RECV_PARSING_BUF_SIZE=2MB),
            则说明当前recv_sys中缓存的日志太多,并且这些日志还不能满足APPLY的条件。
            此时说明日志存储出现了错误,会在errlog中报出下面的信息,表示Recovery可能失败了。
            为什么是RECV_PARSING_BUF_SIZE的大小呢?因为InnoDB认为,在写日志时,不会有
            MTR所写的日志量超过这个值,如果有,则只能是日志存储或者解析出了问题 */
            if (recv_sys->len + 4 * OS_FILE_LOG_BLOCK_SIZE >= RECV_PARSING_BUF_SIZE) {
                fprintf(stderr, "InnoDB: Error: log parsing"
                    " buffer overflow."
                    " Recovery may have failed!\n");
                recv_sys->found_corrupt_log = TRUE;            
            } else if (!recv_sys->found_corrupt_log) {
                /* 这里就是将当前块中真正的日志内容拿出来,存储到recv_sys缓存中去 */
                more_data = recv_sys_add_to_parsing_buf(log_block, scanned_lsn);
			}

            /* 更新scanned_lsn,表示已经扫描的LSN值已经到了这个位置 */
            recv_sys->scanned_lsn = scanned_lsn;
            recv_sys->scanned_checkpoint_no = log_block_get_checkpoint_no(log_block);
        }
        /* 从这里也可以印证上面所述,如果一个日志块不足OS_FILE_LOG_BLOCK_SIZE(默认512字节),
        则说明整个REDO日志扫描已经结束,已经扫描到了日志结尾的位置 */
        if (data_len < OS_FILE_LOG_BLOCK_SIZE) {            
        	/* Log data for this group ends here */
            finished = TRUE;
            break;
        } else {
            /* 没有结束则向前扫描OS_FILE_LOG_BLOCK_SIZE(512字节)的偏移量 */
            log_block += OS_FILE_LOG_BLOCK_SIZE;
        }
    } while (log_block < buf + len && !finished);
    *group_scanned_lsn = scanned_lsn;
    /* 上面已经将当前块或之前块的日志放入到了recv_sys的缓冲区中了,
    下面就会对这部分日志做一次处理,调用的核心函数为recv_parse_log_recs,
    这个函数所要做的工作,接下来会以代码讲解的方式详细讲述 */
    if (more_data && !recv_sys->found_corrupt_log) {
        /* Try to parse more log records */
        recv_parse_log_recs(store_to_hash);

        /* 从这里看到,recv_parse_log_recs将日志进一步处理之后,如果占用的
        缓存空间大于available_memory,就需要APPLY了,而这个缓存空间就是用于
        恢复HASH表,这个HASH表后面会讲述。available_memory的大小,与Buffer Pool
        有关系,InnoDB会拿一部分Buffer Pool空间来做REDO日志的恢复。
        下面这个函数recv_apply_hashed_log_recs,
        也会在后面说到 */
        if (store_to_hash && mem_heap_get_size(recv_sys->heap) > available_memory) {
            recv_apply_hashed_log_recs(FALSE);        
		}

        /* 在recv_parse_log_recs中,处理掉一部分日志之后,缓冲区中
        一般会有剩余的不完整的日志,这部分日志还不能被处理,需要等待读取
        更多的日志进来,拼接之后才能继续处理,那么这里就需要将剩余的
        这部分日志移到缓冲区最开始的位置,以便继续拼接更多的日志内容 */
        if (recv_sys->recovered_offset > RECV_PARSING_BUF_SIZE / 4) {
            /* Move parsing buffer data to the buffer start */
            recv_sys_justify_left_parsing_buf();
        }
    }

    return(finished);
}

从上面的代码中,可以知道,InnoDB 为了更好地管理日志文件,将连续的日志内容以块为单位来存储,加上头尾信息,继续连续存储,而在使用它的时候,又将这些日志以块为单位读取进来,掐头去尾,拼接在一起,进一步做分析处理。下面就看一下 recv_parse_log_recs 是如何做日志分析的。

static ibool recv_parse_log_recs(
    ibool   store_to_hash
)
{
    /* local variables ... */
    /* 一个大的循环,连续处理恢复缓冲区中的日志内容,
    直到处理完,或者剩下的不是一个完整的MTR为止 */
loop:
    /* 当前日志缓冲区中,日志的开始位置 */
    ptr = recv_sys->buf + recv_sys->recovered_offset;
    /* 当前日志缓冲区中,日志的结束位置 */
    end_ptr = recv_sys->buf + recv_sys->len;
    if (ptr == end_ptr) {
        return(FALSE);
    }

    /* MLOG_SINGLE_REC_FLAG表示的是,当前日志所对应的MTR,只写了这一条日志,
    所以这里就作为特殊情况特别处理了。一般情况下,初始化一个页面,或者创建
    一个页面等,属于这种情况,在写日志的时候,会在日志头中加上这个标志 */
    single_rec = (ulint)*ptr & MLOG_SINGLE_REC_FLAG;
    if (single_rec || *ptr == MLOG_DUMMY_RECORD) {
        /* The mtr only modified a single page, or this is a file op */
        old_lsn = recv_sys->recovered_lsn;

        /* 如注释所述:Try to parse a log record, fetching its type, space id,
        page no, and a pointer to the body of the log record */
        len = recv_parse_log_rec(ptr, end_ptr, &type, &space, &page_no, &body);

       /* 更新进度 */
        recv_sys->recovered_offset += len;
        recv_sys->recovered_lsn = new_recovered_lsn;

        if (type == MLOG_DUMMY_RECORD) {
            /* Do nothing */
        } else if (!store_to_hash) {
            /* In debug checking, update a replicate page
            according to the log record, and check that it
            becomes identical with the original page */
        } else if (type == MLOG_FILE_CREATE || type == MLOG_FILE_CREATE2
               || type == MLOG_FILE_RENAME || type == MLOG_FILE_DELETE) {
            /* In normal mysqld crash recovery we do not try to
            replay file operations */
        } else {
            /* 将分析出来的日志信息存到一个HASH表中,又是一层缓存,
            这是第三层。后面可以了解HASH表的管理方法 */
            recv_add_to_hash_table(type, space, page_no, body, ptr + len, old_lsn,
                           recv_sys->recovered_lsn);
        }
    } else {
        /* 与上面相反的是,这里表示的是,一个MTR,
        包括多个日志记录,所以这里需要一个个地去分析处理  */
        total_len = 0;
        n_recs = 0;

        /* 这里很关键,在前面介绍的日志记录类型中,已经提到过关于
        MLOG_MULTI_REC_END类型的作用,它用来标志一个MTR是不是结束
        了。如果找到了这么一条日志,则说明前面的日志是完整的,那这个MTR
        就是可以做APPLY的。而MTR,为何被称为mini-transaction,也正是因为
        事务所具备的特性是原子性,要么全做,要么全不做,只有找到了
        这个标志,才说明这个MTR(物理事务)是完整的,这部分日志才可以被
        APPLY。可能有人会问,这个标志有没有可能找不到?答案是有可能。
        如果真的找不到,这个日志就不正常,说明这个MTR后面一部分日志
        没有被完整地写入日志文件,那这个逻辑事务必定未提交或未提交成功
        (如果提交,则与参数innodb_flush_log_at_trx_commit有关),这个MTR
        就被忽略了。不过可以肯定的是,这个MTR也是本次数据库启动时,涉及
        日志内容中的最后一个MTR了(除非日志文件内容存储或者解析出错了)*/
        for (;;) {
            len = recv_parse_log_rec(ptr, end_ptr, &type, &space, &page_no, &body);
            /* 没有完整内容了,则返回,不会继续处理了 */
            if (len == 0 || recv_sys->found_corrupt_log) {
                if (recv_sys->found_corrupt_log) {
                    recv_report_corrupt_log( ptr, type, space, page_no);
                }
                return(FALSE);
            }
            total_len += len;
            n_recs++;
            ptr += len;
            if (type == MLOG_MULTI_REC_END) {
                /* Found the end mark for the records */
                break;
            }
        }

        /* 能到这里,说明上面已经找到了MTR的结束标志,说明这个MTR是完整的,这样
        就会重新处理这部分日志。啊?重新处理?是的,将上面检查过的重新扫描一遍。
        不过这次就可以自信满满地去处理每一个日志记录了,而不需要担心日志的
        原子性问题了 */

        /* 不过,这里的代码是不是可以做一些优化?对于每一个
        MTR,都要扫描两遍?这样感觉会对性能造成不小的影响。
        至于如何优化,方法总是有的,事在人为,关键是对于那些将Log文件设置得
        很大,并且经常出现异常挂机的用户来说,他们有没有对性能的需求。方法总是
        跟着需求走的,有了需求,问题自然可以解决 */
        /* Add all the records to the hash table */
        ptr = recv_sys->buf + recv_sys->recovered_offset;
        for (;;) {
            old_lsn = recv_sys->recovered_lsn;
            /* 继续分析日志记录,找到类型、表空间ID、页面号及日志内容 */
            len = recv_parse_log_rec(ptr, end_ptr, &type, &space, &page_no, &body);

            /* 更新进度 */
            recv_sys->recovered_offset += len;
            recv_sys->recovered_lsn = recv_calc_lsn_on_data_add(old_lsn, len);
            /* 又见MLOG_MULTI_REC_END,说明已经处理完了这个MTR,则需要继续处理下一个
            MTR。结束之后,做一次大循环,直接goto loop,从头再来 */
            if (type == MLOG_MULTI_REC_END) {
                /* Found the end mark for the records */
                break;
            }

            /* 将每一个分析出来的日志记录,加入到HASH表中。如此看来,这个HASH
            表的管理,就是下一步要研究清楚的内容了 */
            if (store_to_hash) {
                recv_add_to_hash_table(type, space, page_no, body, ptr + len,
                               old_lsn, new_recovered_lsn);
            }

            ptr += len;
        }
    }

    /* 从头再来,下一个MTR */
    goto loop;
}

从上面的代码中可以看出来,InnoDB拿到连续的日志内容之后,以一个 mini-transaction(MTR,物理事务)所包含的日志为单位做分析,再将一个 MTR 中所有的日志记录一个个地分开,存储到 HASH 表中,以便做 APPLY。那么下面再来看加入到 HASH 表中的操作是如何做的。

static
void
recv_add_to_hash_table(
/*===================*/
    byte    type,       /*!< in: log record type */
    ulint   space,      /*!< in: space id */
    ulint   page_no,    /*!< in: page number */
    byte*   body,       /*!< in: log record body */
    byte*   rec_end,    /*!< in: log record end */
    lsn_t   start_lsn,  /*!< in: start lsn of the mtr */
    lsn_t   end_lsn)    /*!< in: end lsn of the mtr */
{
    recv_t*     recv;
    ulint       len;
    recv_data_t*    recv_data;
    recv_data_t**   prev_field;
    recv_addr_t*    recv_addr;

    len = rec_end - body;
    /* 针对每一条日志记录,都会有一个recv_t的结构来存储它,其包括的成员从下面可以看到 */
    recv = static_cast<recv_t*>(mem_heap_alloc(recv_sys->heap, sizeof(recv_t)));

    /* 成员赋值 */
    recv->type = type;
    recv->len = rec_end - body;
    recv->start_lsn = start_lsn;
    recv->end_lsn = end_lsn;
    /* 这里很重要,可以看到,InnoDB是根据space和page_no获取一个recv_addr。
    如果没有recv_addr,就创建一个,被管理到recv_sys->addr_hash的HASH表中,这里
    出现了上面提到的HASH表,也就是说,这个HASH表的键值是space, page_no
    的组合值,也就是所有日志中对应的表空间页面,都会有这样一个缓存对象 */
    recv_addr = recv_get_fil_addr_struct(space, page_no);
    if (recv_addr == NULL) {
        recv_addr = static_cast<recv_addr_t*>(mem_heap_alloc(recv_sys->heap, sizeof(recv_addr_t)));
        recv_addr->space = space;
        recv_addr->page_no = page_no;
        recv_addr->state = RECV_NOT_PROCESSED;
        UT_LIST_INIT(recv_addr->rec_list);
        HASH_INSERT(recv_addr_t, addr_hash, recv_sys->addr_hash,
                recv_fold(space, page_no), recv_addr);
        recv_sys->n_addrs++;
    }

    /* 将当前日志记录,放到与之对应的缓存对象中,表示当前日志所要恢复的位置
    就是在space, page_no页面中 */
    UT_LIST_ADD_LAST(rec_list, recv_addr->rec_list, recv);

    /* 存储日志内容时,会用到下面代码 */
    prev_field = &(recv->data);

    /* 如上面注释所述,将日志记录的内容,即日志体(body)
    写入到日志记录recv_t结构对象的data中 */
    while (rec_end > body) {
        len = rec_end - body;
        if (len > RECV_DATA_BLOCK_SIZE) {
            len = RECV_DATA_BLOCK_SIZE;
        }
        recv_data = static_cast<recv_data_t*>(mem_heap_alloc(recv_sys->heap, sizeof(recv_data_t) + len));
        *prev_field = recv_data;
        memcpy(recv_data + 1, body, len);
        prev_field = &(recv_data->next);
        body += len;
    }

    *prev_field = NULL;
}

上面这段代码让我们明白,InnoDB 将每一个日志记录分开之后,存储到了以表空间 ID 及页面号为键值的 HASH 表中。也就是说,相同的页面肯定是存储在一起的,并且在同一个页面上的日志是以先后顺序挂在这个对应的 HASH 节点中的,从而保证了 REDO 操作的有序性。

从这些代码段中可以看到,缓存到 HASH 表之后,应该是可以找合适的时机去 APPLY 了。那什么时候才是合适的时机呢?返回去看到函数 recv_scan_log_recs 的最后调用了函数 recv_apply_hashed_log_recs,那么这就是真正做 APPLY 的函数了。下面详细看一下它的实现。

UNIV_INTERN void recv_apply_hashed_log_recs(
    ibool   allow_ibuf
)   
{
    /* local vaiables ... */
loop:
    recv_sys->apply_log_recs = TRUE;
    recv_sys->apply_batch_on = TRUE;
    /* 遍历HASH表?是的,将HASH表中的每一个桶中的每一个页面做连续处理 */
    for (i = 0; i < hash_get_n_cells(recv_sys->addr_hash); i++) {
        /* 遍历HASH表每一个桶中的多个地址 */
        for (recv_addr = static_cast<recv_addr_t*>(
                HASH_GET_FIRST(recv_sys->addr_hash, i));
             recv_addr != 0;
             recv_addr = static_cast<recv_addr_t*>(
                HASH_GET_NEXT(addr_hash, recv_addr))) {

            /* 针对每一个页面,做这个页面上所有的REDO操作 */
            ulint   space = recv_addr->space;
            ulint   zip_size = fil_space_get_zip_size(space);
            ulint   page_no = recv_addr->page_no;

            if (recv_addr->state == RECV_NOT_PROCESSED) {
                mutex_exit(&(recv_sys->mutex));

                if (buf_page_peek(space, page_no)) {
                    buf_block_t*    block;

                    mtr_start(&mtr);
                    block = buf_page_get(
                        space, zip_size, page_no,
                        RW_X_LATCH, &mtr);
                    buf_block_dbg_add_level(
                        block, SYNC_NO_ORDER_CHECK);

                    /* 恢复一个页面的数据,使用了一个MTR来恢复APPLY recv_addr中
                    存储的所有REDO记录。需要注意的是,这个MTR只是用来获取页面时,
                    给这个页面加锁使用的,而不会涉及REDO操作,因为REDO是不需要
                    再写日志的,所以不用担心这个MTR涉及的日志量太大的问题 */
                    recv_recover_page(FALSE, block);
                    mtr_commit(&mtr);
                } else {
                    /* 此处的操作是,如果上面的buf_page_peek没有在Buffer Pool中
                    找到这个页面,那么就从文件中将这个页面载入到Buffer Pool,
                    并且预读32个页面以提高性能。恢复方法也是一样的 */
                    recv_read_in_area(space, zip_size, page_no);
                }
                mutex_enter(&(recv_sys->mutex));
            }
        }
    }

    /* Wait until all the pages have been processed */
    while (recv_sys->n_addrs != 0) {
        mutex_exit(&(recv_sys->mutex));
        os_thread_sleep(500000);
        mutex_enter(&(recv_sys->mutex));
    }

    /* Wait for any currently run batch to end.
    如注释所述,如果上面的操作做完了,则需要保证这些日志APPLY之后
    在ibdata及ibd(s)中落地,此时就会将Buffer Pool中全部的脏页刷一遍,
    以保证已经处理的这些日志失效。可能有人会问,如果在恢复的过程中,假设
    就是这里,还没有做刷盘操作,数据库又挂了,那怎么办?
    其实没关系,整个恢复过程中,日志也没有写,只是扫描了一遍,并且有可能在
    Buffer Pool中已经写了很多页面,有可能这些页面因为LRU已经刷过
    了,但这些操作是可重入的,也就是说,数据库再启动,可以重新做一次REDO
    操作,直到成功为止 */
    success = buf_flush_list(ULINT_MAX, LSN_MAX, NULL);
    recv_sys->apply_log_recs = FALSE;
    recv_sys->apply_batch_on = FALSE;

    /* 将HASH表中缓存的所有内容清空 */
    recv_sys_empty_hash();
    mutex_exit(&(recv_sys->mutex));
}

到这里,应该已经清楚了 REDO 数据库恢复的整个过程,并且可以返回到函数 recv_recovery_from_checkpoint_start_func 中。看一下最后的说明,做完 REOD 之后,做一次检查点以说明这次数据库恢复已经完成。

各位同学有没有发现这里有一个细节,那就是 InnoDB 在辛辛苦苦将所有日志分析并且根据不同页面通过 HASH 表进行存储之后,特别要注意下面两点特征。

  • 对于同一个页面的 REDO 记录,必然是存储在同一个 HASH 桶中的。

  • 对于某一个页面的所有日志记录,是按照先后顺序来管理的。

这两个特征非常重要,因为 REDO 日志的 APPLY 与顺序有关系,LSN 小的必定要比 LSN 大的先做 APPLY,不然有可能造成数据的覆盖。但是这有一个前提就是同一个页面,不同页面之间是不存在这样的问题的。

那我们想想,是不是只需要保证同一个页面的日志顺序执行其所有的日志记录即可,而不同页面就没必要守这个规则了,答案是肯定的。

目前的 InnoDB 难道不是这样做的吗?在上面的代码中已经看到,它是用了一个两层循环,扫描了整个 HASH 表,慢慢地一条条地做 REDO 恢复。基于上面的分析,其实可以大胆想象一下,REDO 恢复可以实现并行恢复。按照桶的下标为键值分配线程,这样同一个桶必然会分到同一个线程中去做,自然就保证了同一个页面的执行顺序,而不同桶之间的页面是没有关系的,自然就可以并行恢复了。

啊?可以这样?这个想法,可能会让那些把日志文件设置得很大、又经常出现机器宕机问题的同学(上面已经提到了他们)心潮澎湃,这样性能提升得不只一点点了。

还是那句话,这个在需要把日志文件设置很大,并且经常出现宕机时,才会有明显的优化效果。有需求,就能解决,希望这个优化会出现在某个版本中,少一些浪费的时间。

到现在为止,REDO 日志的恢复就做完了。这个时候,才真正体现了这个“累赘”的价值,感谢有你!

上面所讲的,是使用 REDO 日志来恢复数据库的过程,做完之后,整个数据库就是完整的了,保证了所有的数据库表都没有丢数据的情况,所有的数据库页面也已经是完整的了。假设此时对数据库做 DML 操作,也是可以的了。但还有一个问题没有处理,那就是此时的数据库存在脏数据。因为有些事务没有提交,但数据已经存在了(举一个例子,事务在做的过程中,日志已经写完并刷盘,就是没有提交,此时数据库挂了),那根据事务的 ACID 特性,这样的数据就不应该存在,此时,InnoDB 需要做的就是把这些事务回滚掉,这就用到了下面将要讲的“数据库回滚”。





全方位解读 MySQL 日志实现内幕(四)