MySQL RedoLog 简介

2017-11-08 database mysql

当事务需要修改某条记录时,会先记录到 redo log,在此介绍下其实现。

简介

InnoDB 有 Buffer Pool 也就是数据库的页面缓存,对数据页的任何修改都会先在 BP 上修改,然后这样的页面将被标记为 dirty 并被放到专门的 flush list 上,后续将由 master thread 或专门的刷脏线程阶段性的将这些页面写入磁盘。

这样带来的好处是避免每次写操作都会导致大量的随机 IO,阶段性的刷脏可以将多次对页面的修改合并成一次 IO 操作,同时异步写入也降低了访问的时延。

然而,如果在脏页未刷入磁盘时,服务器或者进程非正常关闭,将会导致这些修改操作丢失,如果写入操作正在进行,甚至会由于损坏数据文件导致数据库不可用。

为了避免上述问题,InnoDB 会将所有对页面的修改操作写入一个特定的文件,也就是 redolog,并在数据库启动时从此文件进行恢复操作。这样推迟了 BP 页面的刷脏,提升了数据库的吞吐,有效的降低了访问时延;带来的问题是额外的写 redo log 操作的开销 (顺序 IO 速度较快),以及数据库启动时恢复操作所需的时间。

接下来将结合 MySQL 代码看下 Log 文件的结构、生成过程以及数据库启动时的恢复流程。

LSN

LSN(Log Sequence Number,日志序列号,ib_uint64_t) 保存在 log_sys.lsn 中,在 log_init() 中初始化,初始值为 LOG_START_LSN(8192) 。改值实际上对应日志文件的偏移量,新的 LSN=旧的LSN + 写入的日志大小,在调用日志写入函数时,LSN 就一直随着写入的日志长度增加。

#define OS_FILE_LOG_BLOCK_SIZE      512
#define LOG_START_LSN       ((lsn_t) (16 * OS_FILE_LOG_BLOCK_SIZE))

日志通过 log_write_low()@log/log0log.c 函数写入。

void log_write_low(byte* str, ulint str_len)
{
    log_t* log = log_sys;
part_loop:
    ... ...                                        // 计算写入日志的长度
    ut_memcpy(log->buf + log->buf_free, str, len); // 将日志内容拷贝到log buffer
    ... ...
    log->lsn += len;
}

如上所述,LSN 是不会减小的,它是日志位置的唯一标记,在重做日志写入、checkpoint 构建和 PAGE 头里面都有 LSN。

例如当前重做日志的 LSN=2048,这时候调用 log_write_low() 写入一个长度为 700 的日志,2048 刚好是 4 个 block 长度,那么需要存储 700 长度的日志,需要两个 block(单个block只能存496个字节),那么很容易得出新的 LSN 为。

LSN=2048+700+2*LOG_BLOCK_HDR_SIZE(12)+LOG_BLOCK_TRL_SIZE(4)=2776

变量设置

简单来说 InnoDB 通过两个核心参数 innodb_buffer_pool_sizeinnodb_log_file_size,分别定义了数据缓存和 redolog 的大小,而后者的大小也决定了 BP 中可以有多少脏页。当然,也不能因此就增大 redolog 文件的大小,这样,可能会导致系统启动时 Crash Recovery 时间增大。

redolog 保存在 innodb_log_group_home_dir 参数指定的目录下,文件名为 ib_logfile*;undolog 保存在共享表空间 ibdata* 文件中。

redolog 由一组固定大小的文件组成,顺序写入,而且文件循环使用,文件名为 ib_logfileN (其中N为从0开始的数字),可以通过 innodb_log_file_sizeinnodb_log_files_in_group 参数控制文件的大小和数目,日志总大小为两者之积。

innodb_log_file_size

简单来说,该变量设置时至少要保证 redo log 在峰值的时候可以容纳 1 小时的日志,当前的写入值可以通过如下方式查看。

mysql> pager grep sequence
PAGER set to 'grep sequence'
mysql> SHOW ENGINE INNODB STATUS\G select sleep(60); SHOW ENGINE INNODB STATUS\G
Log sequence number 3836410803
1 row in set (0.06 sec)
1 row in set (1 min 0.00 sec)
Log sequence number 3838334638
1 row in set (0.05 sec)

mysql> SHOW GLOBAL STATUS LIKE 'Innodb_os_log_written';
+-----------------------+-------+
| Variable_name         | Value |
+-----------------------+-------+
| Innodb_os_log_written | 1024  |
+-----------------------+-------+
1 row in set (0.00 sec)

其实,通过上述的两个变量查看的值会有些区别,一般后者的计算值会偏大,严格来说:LSN 是在写入 log buffer 时递增;而 Innodb_os_log_written 则是在写入文件时递增的。

而将 redo log buffer 中的内容写入到文件的时候是以 512 字节为单位来写的,而且会覆盖写。

简单举例来说:

  1. 事务 A 写了 100B 日志,此时 LSN 的值增加 100,而刷到磁盘时,会从某个偏移开始写入 512B,也就是说 innodb_os_log_written 增加 512 ,只是这 512B 本次有效的只有 100B 。
  2. B 写了 200B 日志,此时 LSN 的值增加 200,刷磁盘时 innodb_os_log_written 又增加 512 。

也就是说最好参考 SHOW ENGINE INNODB STATUS 变量中的 Log sequence number 计算值。

innodb_log_buffer_size

该参数就是用来设置 InnoDB 的 Log Buffer 大小,系统默认值为 16MB,主要作用就是缓冲 redo log 数据,增加缓存可以使大事务在提交前不用写入磁盘,从而提高写 IO 性能。

可以通过系统状态参数,查看性能统计数据来分析 Log 的使用情况:

mysql> SHOW STATUS LIKE 'innodb_log%';
+---------------------------+-------+
| Variable_name             | Value |
+---------------------------+-------+
| Innodb_log_waits          | 0     |  由于缓存过小,导致事务必须等待的次数
| Innodb_log_write_requests | 30    |  日志写请求数
| Innodb_log_writes         | 21    |  向日志文件的物理写次数
+---------------------------+-------+
3 rows in set (0.03 sec)

文件结构

先明确下常用概念,每个日志组都会在第一个文件中有头部信息,通过 LOG_FILE_HDR_SIZE 宏定义,也就是 4*OS_FILE_LOG_BLOCK_SIZE(512)

redolog 写入通常是以 OS_FILE_LOG_BLOCK_SIZE (512字节,编译时设置) 为单位顺序写入文件,每个 LogBlock (512B) 包含了一个 header 段、一个 tailer 段、一组 LogRecords (多条记录组成);每条记录都有自己的 LSN,表示从日志记录创建开始到特定的日志记录已经写入的字节数。

接着看看各个部分的定义。

头部信息

头部信息,在 log0log.h 文件中定义,简单看下常用的保存字段。

LOG_HEADER_FORMAT
  之前为LOG_GROUP_ID,用于标示redolog的分组ID,但是现在只支持一个分组;
LOG_HEADER_START_LSN
  日志文件记录的初始LSN,占用8字节,注意其偏移量与老版本有所区别;
LOG_HEADER_CREATOR
  备份程序会将填写备份程序和创建时间,通常是使用xtrabackup、mysqlbackup时会填充;
LOG_CHECKPOINT_1/2
  在头文件中定义的checkpoint位置,注意只有日志组了的第一文件会有该记录,每次checkpoint都会更新这两个值;

块的宏偏移量同样在 log0log.h 文件中定义,其中头部包括了 LOG_BLOCK_HDR_SIZE(12) 个字节,详细如下:

LOG_BLOCK_HDR_NO
  四字节标示这是第几个block块,该值是通过LSN计算得来,详见log_block_convert_lsn_to_no()函数;
LOG_BLOCK_HDR_DATA_LEN
  两字节表示该block中已经有多少字节被使用;
LOG_BLOCK_FIRST_REC_GROUP
  两个字节表示该block中作为一个新的MTR开始log
LOG_BLOCK_CHECKPOINT_NO
  四个字节表示该block的checkpoint,也就是log_sys->next_checkpoint_no;
LOG_BLOCK_CHECKSUM
  四个字节记录了该block的校验值,通过innodb_checksum_algorithm变量指定算法;

源码解析

在此涉及到两块内存缓冲,包括了 mtr_tlog_sys 等内部结构,接下来一一介绍。

数据结构

首先看下 InnoDB 在内存中保存的一个全局变量,也就是 log_t* log_sys=NULL; 定义,其维护了一块称为 log buffer 的全局内存区域,也就是 log_sys->buff,同时维护有若干 lsn 值等信息表示 logging 进行的状态;会在 log_init() 中对所有的内部区域进行分配并对各个变量进行初始化。

变量 log_sys 是 InnoDB 日志系统的中枢及核心对象,控制着日志的拷贝、写入、checkpoint 等核心功能,是连接 InnoDB 日志文件及 log buffer 的枢纽。

接下来,简单介绍下 log_t 中比较重要的字段值:

struct log_t{
  char        pad1[CACHE_LINE_SIZE];
  lsn_t       lsn;                    // 接下来将要生成的log record使用此lsn的值;
  ulint       buf_size;               // buf大小,通过innodb-log-buffer-size变量指定
  ulint       buf_free;               // 下条log record写入的位置
  ulint       max_buf_free;           // 当buf_free超过该值后,需要进行刷新,可以查看log_free_check()函数
  byte*       buf;                    // 全局的log buffer
  ulint       buf_next_to_write;      // 下条记录写入到磁盘的偏移量
  lsn_t       write_lsn;              // 已经写入的LSN
  lsn_t       flushed_to_disk_lsn;    // 已经刷新到磁盘的LSN值,小于该值的日志已经被安全地记录到了磁盘上

  UT_LIST_BASE_NODE_T(log_group_t)    // 日志组,当前版本仅支持一组日志
          log_groups;                 // 包含了当前日志组的文件个数、每个文件的大小、space id等信息

  lsn_t       log_group_capacity;     // 当前日志文件的总容量在log_calc_max_ages()中计算,
                                      // (redo-log文件大小-头部)*0.9,其中乘0.9是一个安全系数

  lsn_t       max_modified_age_async;
                  /*!< when this recommended
                  value for lsn -
                  buf_pool_get_oldest_modification()
                  is exceeded, we start an
                  asynchronous preflush of pool pages */
  lsn_t       max_modified_age_sync;
                  /*!< when this recommended
                  value for lsn -
                  buf_pool_get_oldest_modification()
                  is exceeded, we start a
                  synchronous preflush of pool pages */
  lsn_t       max_checkpoint_age_async;
                  /*!< when this checkpoint age
                  is exceeded we start an
                  asynchronous writing of a new
                  checkpoint */
  lsn_t       max_checkpoint_age;
                  /*!< this is the maximum allowed value
                  for lsn - last_checkpoint_lsn when a
                  new query step is started */
  ib_uint64_t next_checkpoint_no;
                  /*!< next checkpoint number */
  lsn_t       last_checkpoint_lsn;
                  /*!< latest checkpoint lsn */
  lsn_t       next_checkpoint_lsn;
                  /*!< next checkpoint lsn */

日志生成

mtr (mini-transactions) 在代码中对应了 struct mtr_t 结构体,其内部有一个局部 buffer,会将一组 log record 集中起来,然后批量写入 log buffer;mtr_t 的结构体如下所示:

struct mtr_t {
  struct Impl {
    mtr_buf_t  m_memo;  // 由此mtr涉及的操作所造成的脏页列表
    mtr_buf_t  m_log;   // mtr的局部缓存,记录log-records;
  };
};

其中 m_memo 对象会记录与本次事务相关的页以及锁信息,并在提交时 (复制到 log buffer) 之后将脏页添加到 flush_list 并释放所持有的锁,详细的内容可以参考 mtr_commit()->mtr_memo_pop_all() 函数调用。

简单来说,log record 的生成过程如下:

  1. 创建一个 mtr_t 类型的对象;
  2. 执行 mtr_start() 初始化 mtr_t 的字段,包括 local buffer;
  3. 在对 BP 中的 Page 进行修改的同时,调用 mlog_write_ulint()mlog_write_string()mlog_write_null() 类似函数,生成 redo log record 并保存在 local buffer 中;
  4. 执行 mtr_commit() 将 local buffer 中的日志拷贝到全局的 log_sys->buf+log_sys->buf_free,同时将脏页添加到 flush list,供后续执行 flush 操作时使用。

对于这一过程的执行,可以将 page_cur_insert_rec_write_log() 函数作为参考。

mtr_commit()                          提交mtr,对应了mtr_t::commit()@mtr0mtr.cc
 |-mtr_t::Command::execute()          执行,同样在mtr0mtr.cc文件中
   |-mtr_t::Command::prepare_write()  准备写入,会返回字节数
     |-fil_names_write_if_was_clean()
       |-fil_names_dirty_and_write()  如果在fil_names_clear()之后这是第一次写入