innodb重做日志实现原理(上)

2,034 阅读6分钟

1.概念介绍

重做日志主要用来故障恢复数据的修复纠正,保证实现事务的持久性。为了实现持久性,innodb需要将bufferpool数据刷新到磁盘,但是这是一个随机写,性能很差,所以为了减少bufferpool写入磁盘的io开销,innodb引入redolog,把随机写转化成顺序写,推迟了bufferpool的刷新,提高数据库写性能。本文内容:redolog架构,细节概念介绍,日志的写入。日志恢复逻辑在下一篇文章介绍。

重做日志和二进制日志的区别

二进制日志在mysql server层记录,而重做日志在存储引擎层记录。二进制日志在事务提交之后一次写入,记录的是逻辑日志(SQL语句)。而重做日志记录的是物理日志(对页的修改),并且是在事务执行的时候不断的写入。

重做日志实现机制

分为两个部分,一个是内存中的缓存,另外一个是持久化的文件。事务修改先被写入缓冲,事务提交的时候刷新到磁盘。为了保证事务的持久性,每次commit的时候需将数据持久化到磁盘件,因此数据库的写性能比较差,尽管redo log是顺序读写。当然可以通过设置innodb_flush_log_at_trx_commit参数来改变持久化数据的时机。

innodb_flush_log_at_trx_commit参数:

  • 设置为0:启动线程每1s写入磁盘
  • 设置为1:每次commit会写入
  • 设置为2:写入文件系统缓存不进行fsync操作

实现细节:(这个方法在mysql commit事务的时候回调用,主要就是将根据策略将重做日志缓冲区的数据刷新到磁盘)

if (!trx->must_flush_log_later){
    /* Do nothing */
}
else if (srv_flush_log_at_trx_commit == 0){
    /* Do nothing */
}
else if (srv_flush_log_at_trx_commit == 1){
    if (srv_unix_file_flush_method == SRV_UNIX_NOSYNC){
        /* Write the log but do not flush it to disk */
        log_write_up_to(lsn, LOG_WAIT_ONE_GROUP, FALSE);
    }
    else{
        /* Write the log to the log files AND flush them to
                  disk */
        log_write_up_to(lsn, LOG_WAIT_ONE_GROUP, TRUE);
    }
}
else if (srv_flush_log_at_trx_commit == 2){
    /* Write the log but do not flush it to disk */
    log_write_up_to(lsn, LOG_WAIT_ONE_GROUP, FALSE);
}
else{
    ut_error;
}

检查点

为了数据库的持久性,数据需要写入磁盘,但是写入磁盘是随机写,性能很差,所以采用异步写的解决方案,为了应对异步场景数据丢失问题,引入了重新日志,可以通过重做日志应对故障场景。

通过检查点和对应重做日志的lsn,我们就可以知道应该将哪些数据写入磁盘。引入检查点可以缩短恢复时间(只需要恢复从检查点到宕机区间的数据)。

redolog-checkpoint.png

2.整体架构

redolog-arch.png \

  1. redo log buffer :内存中的数据,大小可以通过innodb_log_buffer_size控制
  2. group:通过多个组来提高存储引擎可用性。也就是如果组1损坏,组2依旧可以提供服务。所以每个组存储的数据是一样的。
  3. redolog file:每个组存放多个redolog文件,可以通过参数设置路径和大小。默认5mb。

Redo Log Buffer

内存中的数据结构,以块(block)的方式保存,每个块占用512字节。分配的是一块连续内存。

redolog-buf.png block有头有尾,头部由4部分组成,占12字节,尾部占用4个字节。

头部主要描述了该block的信息。

// LOG_BLOCK_HDR_NO在block的偏移起始位置。从0~4,该值代表block在block数组的位置。
#define LOG_BLOCK_HDR_NO 0
//LOG_BLOCK_HDR_DATA_LEN 同上 代表当前block占用的大小
#define LOG_BLOCK_HDR_DATA_LEN 4
//LOG_BLOCK_FIRST_REC_GROUP 同上 代表block第一个日志所在的偏移量
#define LOG_BLOCK_FIRST_REC_GROUP 6
#define LOG_BLOCK_CHECKPOINT_NO 8
//头部长度
#define LOG_BLOCK_HDR_SIZE 12

举个例子,假如我们要获取这个block当前被写入的字节数。

UNIV_INLINE
ulint
log_block_get_data_len(byte*   log_block) 
{
        return(mach_read_from_2(log_block + LOG_BLOCK_HDR_DATA_LEN));
}

通过LOG_BLOCK_HDR_DATA_LEN以及这个log_block内存指针即可获取到。

具体重做日志数据存储在block的头尾之间。如果某个事务日志大小太大,一个block存不下,就需要两个甚至更多block去存储。

Redo Log File

主要是磁盘中的文件。innodb根据flush策略将内存中的数据刷新到文件中。

redo log file通过log group管理,每个log group由多个文件。log group只是逻辑存在。

log group的物理信息(id)保存在redo log file的header中。

header后面为ckeckpoint,保存了检查点的一些信息。

redolog-group.png redo log file是循环使用的。

数据结构

核心数据结构是log_struct,该数据结构包括buffer内存,以及各种状态数据。因此log_struct控制着重做缓冲、重做日志文件、归档文件的写入,以及在线备份的操作。

log_structRedo Buf写入参数介绍:

buf_next_to_write:写入重做日志文件中的位置

buf_free:可以被写入的位置

max_buf_free:buf_free大于该值,会强制进行一次写入重做日志文件。

redolog-buffer-canshu.png

redo buf写入时机

mtr_commit方法中会触发buf的写入,其实就是mini-transaction提交的时候。将mini-transaction过程产生的日志数据写入到Redobuf。mini-transaction在innodb中非常重要,一个数据的插入会产生多个mini-transaction,直到整个事务提交。后续具体分析。

写入redo buf代码分析

mlog = &(mtr->log);

if (mtr->log_mode == MTR_LOG_ALL) {

    block = mlog;
    while (block != NULL) {
        log_write_low(dyn_block_get_data(block),
                dyn_block_get_used(block));
        block = dyn_array_get_next_block(mlog, block);
    }
} else {
    ut_ad(mtr->log_mode == MTR_LOG_NONE);
    /* Do nothing */    
}

上面是一些主要逻辑代码,其实就是拿到mini-transaction的log数据(动态字节数组),然后遍历调用log_write_low写入。当然写入之前还会调用log_reserve_and_open,保证重做日志缓冲可以写入。不可写入的时候调用刷盘方法讲数据flush到磁盘。这个方法后面介绍。

log_write_low

void
log_write_low(byte*  str,ulint str_len)
{
    log_t*    log   = log_sys;
    ulint    len;
    ulint    data_len;
    byte*    log_block;
#ifdef UNIV_SYNC_DEBUG
    ut_ad(mutex_own(&(log->mutex)));
#endif /* UNIV_SYNC_DEBUG */
part_loop:
    /* Calculate a part length */
    data_len = (log->buf_free % OS_FILE_LOG_BLOCK_SIZE) + str_len;
    if (data_len <= OS_FILE_LOG_BLOCK_SIZE - LOG_BLOCK_TRL_SIZE) {
            /* The string fits within the current log block */
            len = str_len;
    } else {
        data_len = OS_FILE_LOG_BLOCK_SIZE - LOG_BLOCK_TRL_SIZE;
            len = OS_FILE_LOG_BLOCK_SIZE
            - (log->buf_free % OS_FILE_LOG_BLOCK_SIZE)
                - LOG_BLOCK_TRL_SIZE;
    }
    ut_memcpy(log->buf + log->buf_free, str, len);
    str_len -= len;
    str = str + len;
    log_block = ut_align_down(log->buf + log->buf_free,
                        OS_FILE_LOG_BLOCK_SIZE);
    log_block_set_data_len(log_block, data_len);
    if (data_len == OS_FILE_LOG_BLOCK_SIZE - LOG_BLOCK_TRL_SIZE) {
        /* This block became full */
        log_block_set_data_len(log_block, OS_FILE_LOG_BLOCK_SIZE);
        log_block_set_checkpoint_no(log_block,
                        log_sys->next_checkpoint_no);
        len += LOG_BLOCK_HDR_SIZE + LOG_BLOCK_TRL_SIZE;
        log->lsn = ut_dulint_add(log->lsn, len);
        /* Initialize the next block header */
        log_block_init(log_block + OS_FILE_LOG_BLOCK_SIZE, log->lsn);
    } else {
        log->lsn = ut_dulint_add(log->lsn, len);
    }
    log->buf_free += len;
    ut_ad(log->buf_free <= log->buf_size);
    if (str_len > 0) {
        goto part_loop;
    }
        srv_log_write_requests++;
}

1.根据数据长度进行计算

len:对于当前数据(str),如果str长度比当前block剩余空间大,len赋值为block剩余长度,否则为str的长度。因此如果str一个block写不下,会写入到下一个block。

2.将数据copy到对应的block

3.更新len和str_len

4.判断block是否full。如果full则分配一个新的block。更新log的lsn,更新buff_free

5.如果str没有完全写入重复执行上面流程。

重做日志刷盘

主要逻辑在log_write_up_to中。

如果待写入的lsn已经被刷新到磁盘则直接返回。

没抢到锁、或者正在flush、或正在写入则挂起等待,因为flush操作会释放锁(所以flush和此逻辑可能并行执行)。

start_offset = log_sys->buf_next_to_write;
end_offset = log_sys->buf_free;
area_start = ut_calc_align_down(start_offset, OS_FILE_LOG_BLOCK_SIZE);
area_end = ut_calc_align(end_offset, OS_FILE_LOG_BLOCK_SIZE);
ut_ad(area_end - area_start > 0);
log_sys->write_lsn = log_sys->lsn;

if (flush_to_disk) {
    log_sys->current_flush_lsn = log_sys->lsn;
}
log_sys->one_flushed = FALSE;
log_block_set_flush_bit(log_sys->buf + area_start, TRUE);
log_block_set_checkpoint_no(
        log_sys->buf + area_end - OS_FILE_LOG_BLOCK_SIZE,
        log_sys->next_checkpoint_no);
/* Copy the last, incompletely written, log block a log block length
up, so that when the flush operation writes from the log buffer, the
segment to write will not be changed by writers to the log */
ut_memcpy(log_sys->buf + area_end,
        log_sys->buf + area_end - OS_FILE_LOG_BLOCK_SIZE,
        OS_FILE_LOG_BLOCK_SIZE);
log_sys->buf_free += OS_FILE_LOG_BLOCK_SIZE;
log_sys->write_end_offset = log_sys->buf_free;
group = UT_LIST_GET_FIRST(log_sys->log_groups);
/* Do the write to the log files */
while (group) {
    log_group_write_buf(group,
        log_sys->buf + area_start,
        area_end - area_start,
        ut_dulint_align_down(log_sys->written_to_all_lsn,
                    OS_FILE_LOG_BLOCK_SIZE),
        start_offset - area_start);
    log_group_set_fields(group, log_sys->write_lsn);
    group = UT_LIST_GET_NEXT(log_groups, group);
}
mutex_exit(&(log_sys->mutex));

上面的代码为执行写入逻辑

1.获取待写入的startoffset和endoffset

2.更新log_sys对应的数据(current_flush_lsn,write_lsn)

3.设置讲检查点编号写入block中

4.将buf最后512字节数据往后拷贝一份,避免在flush的时候,其他线程对内存的的修改,因为为了提高并发,flush在释放锁之后执行。

ut_memcpy(log_sys->buf + area_end,log_sys->buf + area_end - OS_FILE_LOG_BLOCK_SIZE,OS_FILE_LOG_BLOCK_SIZE);

5.获取到group调用log_group_write_buf将数据写入redolog file。这个方法主要就是构成redo log文件的格式(上面提到的header等),然后将数据写入文件缓冲区(os缓冲)

6.释放锁,如果需要flush到disk则调用fil_flush方法将刚才写入的数据flush到磁盘。

总结

重做日志的写入逻辑相对简单,对于ckeckpoint本文并没有详细介绍,下一篇重做日志恢复的时候回对checkpoint进行介绍。