关于数据库:庖丁解InnoDB之REDO-LOG

10次阅读

共计 10233 个字符,预计需要花费 26 分钟才能阅读完成。

简介:数据库故障复原机制的前世今生一文中提到,今生磁盘数据库为了在保障数据库的原子性 (A, Atomic) 和持久性(D, Durability) 的同时,还能以灵便的刷盘策略来充分利用磁盘程序写的性能,会记录 REDO 和 UNDO 日志,即 ARIES 办法。本文将重点介绍 REDO LOG 的作用,记录的内容,组织构造,写入形式等内容,心愿读者可能更全面精确的了解 REDO LOG 在 InnoDB 中的地位。本文基于 MySQL 8.0 代码。

作者 | 瀚之
起源 | 阿里技术公众号

数据库故障复原机制的前世今生一文中提到,今生磁盘数据库为了在保障数据库的原子性 (A, Atomic) 和持久性(D, Durability) 的同时,还能以灵便的刷盘策略来充分利用磁盘程序写的性能,会记录 REDO 和 UNDO 日志,即 ARIES 办法。本文将重点介绍 REDO LOG 的作用,记录的内容,组织构造,写入形式等内容,心愿读者可能更全面精确的了解 REDO LOG 在 InnoDB 中的地位。本文基于 MySQL 8.0 代码。

一 为什么须要记录 REDO

为了获得更好的读写性能,InnoDB 会将数据缓存在内存中(InnoDB Buffer Pool),对磁盘数据的批改也会落后于内存,这时如果过程或机器解体,会导致内存数据失落,为了保障数据库自身的一致性和持久性,InnoDB 保护了 REDO LOG。批改 Page 之前须要先将批改的内容记录到 REDO 中,并保障 REDO LOG 早于对应的 Page 落盘,也就是常说的 WAL,Write Ahead Log。当故障产生导致内存数据失落后,InnoDB 会在重启时,通过重放 REDO,将 Page 复原到解体前的状态。

二 须要什么样的 REDO

那么咱们须要什么样的 REDO 呢?首先,REDO 的保护减少了一份写盘数据,同时为了保证数据正确,事务只有在他的 REDO 全副落盘能力返回用户胜利,REDO 的写盘工夫会间接影响零碎吞吐,不言而喻,REDO 的数据量要尽量少。其次,零碎解体总是产生在始料未及的时候,当重启重放 REDO 时,零碎并不知道哪些 REDO 对应的 Page 曾经落盘,因而 REDO 的重放必须可重入,即 REDO 操作要保障幂等。最初,为了便于通过并发重放的形式放慢重启复原速度,REDO 应该是基于 Page 的,即一个 REDO 只波及一个 Page 的批改。

相熟的读者会发现,数据量小是 Logical Logging 的长处,而幂等以及基于 Page 正是 Physical Logging 的长处,因而 InnoDB 采取了一种称为 Physiological Logging 的形式,来兼得二者的劣势。所谓 Physiological Logging,就是以 Page 为单位,但在 Page 内以逻辑的形式记录。举个例子,MLOG_REC_UPDATE_IN_PLACE 类型的 REDO 中记录了对 Page 中一个 Record 的批改,办法如下:

(Page ID,Record Offset,(Filed 1, Value 1) … (Filed i, Value i) … )

其中,PageID 指定要操作的 Page 页,Record Offset 记录了 Record 在 Page 内的偏移地位,前面的 Field 数组,记录了须要批改的 Field 以及批改后的 Value。

因为 Physiological Logging 的形式采纳了物理 Page 中的逻辑记法,导致两个问题:

1、须要基于正确的 Page 状态上重放 REDO

因为在一个 Page 内,REDO 是以逻辑的形式记录了前后两次的批改,因而重放 REDO 必须基于正确的 Page 状态。然而 InnoDB 默认的 Page 大小是 16KB,是大于文件系统能保障原子的 4KB 大小的,因而可能呈现 Page 内容胜利一半的状况。InnoDB 中采纳了 Double Write Buffer 的形式来通过写两次的形式保障复原的时候找到一个正确的 Page 状态。这部分会在之后介绍 Buffer Pool 的时候具体介绍。

2、须要保障 REDO 重放的幂等

Double Write Buffer 可能保障找到一个正确的 Page 状态,咱们还须要晓得这个状态对应 REDO 上的哪个记录,来防止对 Page 的反复批改。为此,InnoDB 给每个 REDO 记录一个全局惟一递增的标号 LSN(Log Sequence Number)。Page 在批改时,会将对应的 REDO 记录的 LSN 记录在 Page 上(FIL_PAGE_LSN 字段),这样复原重放 REDO 时,就能够来判断跳过曾经利用的 REDO,从而实现重放的幂等。

三 REDO 中记录了什么内容

晓得了 InnoDB 中记录 REDO 的形式,那么 REDO 里具体会记录哪些内容呢?为了应答 InnoDB 各种各样不同的需要,到 MySQL 8.0 为止,曾经有多达 65 种的 REDO 记录。用来记录这不同的信息,复原时须要判断不同的 REDO 类型,来做对应的解析。依据 REDO 记录不同的作用对象,能够将这 65 中 REDO 划分为三个大类:作用于 Page,作用于 Space 以及提供额定信息的 Logic 类型。

1、作用于 Page 的 REDO

这类 REDO 占所有 REDO 类型的绝大多数,依据作用的 Page 的不同类型又能够细分为,Index Page REDO,Undo Page REDO,Rtree PageREDO 等。比方 MLOG_REC_INSERT,MLOG_REC_UPDATE_IN_PLACE,MLOG_REC_DELETE 三种类型别离对应于 Page 中记录的插入,批改以及删除。这里还是以 MLOG_REC_UPDATE_IN_PLACE 为例来看看其中具体的内容:

其中,Type 就是 MLOG_REC_UPDATE_IN_PLACE 类型,Space ID 和 Page Number 惟一标识一个 Page 页,这三项是所有 REDO 记录都须要有的头信息,前面的是 MLOG_REC_UPDATE_IN_PLACE 类型独有的,其中 Record Offset 用给出要批改的记录在 Page 中的地位偏移,Update Field Count 阐明记录里有几个 Field 要批改,紧接着对每个 Field 给出了 Field 编号(Field Number),数据长度(Field Data Length)以及数据(Filed Data)。

2、作用于 Space 的 REDO

这类 REDO 针对一个 Space 文件的批改,如 MLOG_FILE_CREATE,MLOG_FILE_DELETE,MLOG_FILE_RENAME 别离对应对一个 Space 的创立,删除以及重命名。因为文件操作的 REDO 是在文件操作完结后才记录的,因而在复原的过程中看到这类日志时,阐明文件操作曾经胜利,因而在复原过程中大多只是做对文件状态的查看,以 MLOG_FILE_CREATE 来看看其中记录的内容:

同样的前三个字段还是 Type,Space ID 和 Page Number,因为是针对 Page 的操作,这里的 Page Number 永远是 0。在此之后记录了创立的文件 flag 以及文件名,用作重启复原时的查看。

3、提供额定信息的 Logic REDO

除了上述类型外,还有多数的几个 REDO 类型不波及具体的数据批改,只是为了记录一些须要的信息,比方最常见的 MLOG_MULTI_REC_END 就是为了标识一个 REDO 组,也就是一个残缺的原子操作的完结。

4、REDO 是如何组织的

所谓 REDO 的组织形式,就是如何把须要的 REDO 内容记录到磁盘文件中,以不便高效的 REDO 写入,读取,复原以及清理。咱们这里把 REDO 从上到下分为三层:逻辑 REDO 层、物理 REDO 层和文件层。

1 逻辑 REDO 层

这一层是真正的 REDO 内容,REDO 由多个不同 Type 的多个 REDO 记录收尾相连组成,有全局惟一的递增的偏移 sn,InnoDB 会在全局 log_sys 中保护以后 sn 的最大值,并在每次写入数据时将 sn 减少 REDO 内容长度。如下图所示:

2 物理 REDO 层

磁盘是块设施,InnoDB 中也用 Block 的概念来读写数据,一个 Block 的长度 OS_FILE_LOG_BLOCK_SIZE 等于磁盘扇区的大小 512B,每次 IO 读写的最小单位都是一个 Block。除了 REDO 数据以外,Block 中还须要一些额定的信息,下图所示一个 Log Block 的的组成,包含 12 字节的 Block Header:前 4 字节中 Flush Flag 占用最高位 bit,标识一次 IO 的第一个 Block,剩下的 31 个个 bit 是 Block 编号;之后是 2 字节的数据长度,取值在[12,508];紧接着 2 字节的 First Record Offset 用来指向 Block 中第一个 REDO 组的开始,这个值的存在使得咱们对任何一个 Block 都能够找到一个非法的的 REDO 开始地位;最初的 4 字节 Checkpoint Number 记录写 Block 时的 next_checkpoint_number,用来发现文件的循环应用,这个会在文件层具体解说。Block 开端是 4 字节的 Block Tailer,记录以后 Block 的 Checksum,通过这个值,读取 Log 时能够明确 Block 数据有没有被残缺写完。

Block 中残余的两头 498 个字节就是 REDO 真正内容的寄存地位,也就是咱们下面说的逻辑 REDO。咱们当初将逻辑 REDO 放到物理 REDO 空间中,因为 Block 内的空间固定,而 REDO 长度不定,因而可能一个 Block 中有多个 REDO,也可能一个 REDO 被拆分到多个 Block 中,如下图所示,棕色和红色别离代表 Block Header 和 Tailer,两头的 REDO 记录因为前一个 Block 残余空间有余,而被拆分在间断的两个 Block 中。

因为减少了 Block Header 和 Tailer 的字节开销,在物理 REDO 空间中用 LSN 来标识偏移,能够看出 LSN 和 SN 之间有简略的换算关系:

constexpr inline lsn_t log_translate_sn_to_lsn(lsn_t sn) {
  return (sn / LOG_BLOCK_DATA_SIZE * OS_FILE_LOG_BLOCK_SIZE +
          sn % LOG_BLOCK_DATA_SIZE + LOG_BLOCK_HDR_SIZE);
}

SN 加上之前所有的 Block 的 Header 以及 Tailer 的长度就能够换算到对应的 LSN,反之亦然。

3 文件层

最终 REDO 会被写入到 REDO 日志文件中,以 ib_logfile0、ib_logfile1… 命名,为了防止创立文件及初始化空间带来的开销,InooDB 的 REDO 文件会循环应用,通过参数 innodb_log_files_in_group 能够指定 REDO 文件的个数。多个文件收尾相连程序写入 REDO 内容。每个文件以 Block 为单位划分,每个文件的结尾固定预留 4 个 Block 来记录一些额定的信息,其中第一个 Block 称为 Header Block,之后的 3 个 Block 在 0 号文件上用来存储 Checkpoint 信息,而在其余文件上留空:

其中第一个 Header Block 的数据区域记录了一些文件信息,如下图所示,4 字节的 Formate 字段记录 Log 的版本,不同版本的 LOG,会有 REDO 类型的增减,这个信息是 8.0 开始才退出的;8 字节的 Start LSN 标识以后文件开始 LSN,通过这个信息能够将文件的 offset 与对应的 lsn 对应起来;最初是最长 32 位的 Creator 信息,失常状况下会记录 MySQL 的版本。

当初咱们将 REDO 放到文件空间中,如下图所示,逻辑 REDO 是真正须要的数据,用 sn 索引,逻辑 REDO 按固定大小的 Block 组织,并增加 Block 的头尾信息造成物理 REDO,以 lsn 索引,这些 Block 又会放到循环应用的文件空间中的某一地位,文件中用 offset 索引:

尽管通过 LSN 能够惟一标识一个 REDO 地位,但最终对 REDO 的读写还须要转换到对文件的读写 IO,这个时候就须要示意文件空间的 offset,他们之间的换算形式如下:

const auto real_offset =
      log.current_file_real_offset + (lsn - log.current_file_lsn);

切换文件时会在内存中更新以后文件结尾的文件 offset,current_file_real_offset,以及对应的 LSN,current_file_lsn,通过这两个值能够不便地用下面的形式将 LSN 转化为文件 offset。留神这里的 offset 是相当于整个 REDO 文件空间而言的,因为 InnoDB 中读写文件的 space 层实现反对多个文件,因而,能够将首位相连的多个 REDO 文件看成一个大文件,那么这里的 offset 就是这个大文件中的偏移。

五 如何高效地写 REDO

作为保护数据库正确性的重要信息,REDO 日志必须在事务提交前保障落盘,否则一旦断电将会有数据失落的可能,因而从 REDO 生成到最终落盘的残缺过程成为数据库写入的要害门路,其效率也间接决定了数据库的写入性能。这个过程包含 REDO 内容的产生,REDO 写入 InnoDB Log Buffer,从 InnoDB Log Buffer 写入操作系统 Page Cache,以及 REDO 刷盘,之后还须要唤醒期待的用户线程实现 Commit。上面就通过这几个阶段来看看 InnoDB 如何在高并发的状况下还能高效地实现写 REDO。

1 REDO 产生

咱们晓得事务在写入数据的时候会产生 REDO,一次原子的操作可能会蕴含多条 REDO 记录,这些 REDO 可能是拜访同一 Page 的不同地位,也可能是拜访不同的 Page(如 Btree 节点决裂)。InnoDB 有一套残缺的机制来保障波及一次原子操作的多条 REDO 记录原子,即复原的时候要么全部重放,要不全副不重放,这部分将在之后介绍复原逻辑的时候具体介绍,本文只波及其中最根本的要求,就是这些 REDO 必须间断。InnoDB 中通过 min-transaction 实现,简称 mtr,须要原子操作时,调用 mtr_start 生成一个 mtr,mtr 中会保护一个动静增长的 m_log,这是一个动态分配的内存空间,将这个原子操作须要写的所有 REDO 先写到这个 m_log 中,当原子操作完结后,调用 mtr_commit 将 m_log 中的数据拷贝到 InnoDB 的 Log Buffer。

2 写入 InnoDB Log Buffer

高并发的环境中,会同时有十分多的 min-transaction(mtr)须要拷贝数据到 Log Buffer,如果通过锁互斥,那么毫无疑问这里将成为显著的性能瓶颈。为此,从 MySQL 8.0 开始,设计了一套无锁的写 log 机制,其外围思路是容许不同的 mtr,同时并发地写 Log Buffer 的不同地位。不同的 mtr 会首先调用 log_buffer_reserve 函数,这个函数里会用本人的 REDO 长度,原子地对全局偏移 log.sn 做 fetch_add,失去本人在 Log Buffer 中独享的空间。之后不同 mtr 并行的将本人的 m_log 中的数据拷贝到各自独享的空间内。

/* Reserve space in sequence of data bytes: */
const sn_t start_sn = log.sn.fetch_add(len);

3 写入 Page Cache

写入到 Log Buffer 中的 REDO 数据须要进一步写入操作系统的 Page Cache,InnoDB 中有独自的 log_writer 来做这件事件。这里有个问题,因为 Log Buffer 中的数据是不同 mtr 并发写入的,这个过程中 Log Buffer 中是有空洞的,因而 log_writer 须要感知以后 Log Buffer 中间断日志的开端,将间断日志通过 pwrite 零碎调用写入操作系统 Page Cache。整个过程中应尽可能不影响后续 mtr 进行数据拷贝,InnoDB 在这里引入一个叫做 link_buf 的数据结构,如下图所示:

link_buf 是一个循环应用的数组,对每个 lsn 取模能够失去其在 link_buf 上的一个槽位,在这个槽位中记录 REDO 长度。另外一个线程从开始遍历这个 link_buf,通过槽位中的长度能够找到这条 REDO 的结尾地位,始终遍历到下一地位为 0 的地位,能够认为之后的 REDO 有空洞,而之前曾经间断,这个地位叫做 link_buf 的 tail。上面看看 log_writer 和泛滥 mtr 是如何利用这个 link_buf 数据结构的。这里的这个 link_buf 为 log.recent_written,如下图所示:

图中上半局部是 REDO 日志示意图,write_lsn 是以后 log_writer 曾经写入到 Page Cache 中日志开端,current_lsn 是以后曾经调配给 mtr 的的最大 lsn 地位,而 buf_ready_for_write_lsn 是以后 log_writer 找到的 Log Buffer 中曾经间断的日志结尾,从 write_lsn 到 buf_ready_for_write_lsn 是下一次 log_writer 能够间断调用 pwrite 写入 Page Cache 的范畴,而从 buf_ready_for_write_lsn 到 current_lsn 是以后 mtr 正在并发写 Log Buffer 的范畴。上面的间断方格便是 log.recent_written 的数据结构,能够看出因为两头的两个全零的空洞导致 buf_ready_for_write_lsn 无奈持续推动,接下来,如果 reserve 到两头第一个空洞的 mtr 也实现了写 Log Buffer,并更新了 log.recent_written*,如下图:

这时,log_writer 从以后的 buf_ready_for_write_lsn 向后遍历 log.recent_written,发现这段曾经间断:

因而晋升以后的 buf_ready_for_write_lsn,并将 log.recent_written 的 tail 地位向前滑动,之后的地位清零,供之后循环复用:

紧接 log_writer 将间断的内容刷盘并晋升 write_lsn。

4 刷盘

log_writer 晋升 write_lsn 之后会告诉 log_flusher 线程,log_flusher 线程会调用 fsync 将 REDO 刷盘,至此实现了 REDO 残缺的写入过程。

5 唤醒用户线程

为了保证数据正确,只有 REDO 写完后事务才能够 commit,因而在 REDO 写入的过程中,大量的用户线程会 block 期待,直到本人的最初一条日志完结写入。默认状况下 innodb_flush_log_at_trx_commit = 1,须要等 REDO 实现刷盘,这也是最平安的形式。当然,也能够通过设置 innodb_flush_log_at_trx_commit = 2,这样,只有 REDO 写入 Page Cache 就认为实现了写入,极其状况下,掉电可能导致数据失落。

大量的用户线程调用 log_write_up_to 期待在本人的 lsn 地位,为了防止大量有效的唤醒,InnoDB 将阻塞的条件变量拆分为多个,log_write_up_to 依据本人须要期待的 lsn 所在的 block 取模对应到不同的条件变量下来。同时,为了防止大量的唤醒工作影响 log_writer 或 log_flusher 线程,InnoDB 中引入了两个专门负责唤醒用户的线程:log_wirte_notifier 和 log_flush_notifier,当超过一个条件变量须要被唤醒时,log_writer 和 log_flusher 会告诉这两个线程实现唤醒工作。下图是整个过程的示意图:

多个线程通过一些外部数据结构的辅助,实现了高效的从 REDO 产生,到 REDO 写盘,再到唤醒用户线程的流程,上面是整个这个过程的时序图:

六 如何平安地革除 REDO

因为 REDO 文件空间无限,同时为了尽量减少复原时须要重放的 REDO,InnoDB 引入 log_checkpointer 线程周期性的打 Checkpoint。重启复原的时候,只须要从最新的 Checkpoint 开始回放后边的 REDO,因而 Checkpoint 之前的 REDO 就能够删除或被复用。

咱们晓得 REDO 的作用是防止只写了内存的数据因为故障失落,那么打 Checkpiont 的地位就必须保障之前所有 REDO 所产生的内存脏页都曾经刷盘。最间接的,能够从 Buffer Pool 中取得以后所有脏页对应的最小 REDO LSN:lwm_lsn。但光有这个还不够,因为有一部分 min-transaction 的 REDO 对应的 Page 还没有来的及退出到 Buffer Pool 的脏页中去,如果 checkpoint 打到这些 REDO 的后边,一旦这时产生故障复原,这部分数据将失落,因而还须要晓得以后曾经退出到 Buffer Pool 的 REDO lsn 地位:dpa_lsn。取二者的较小值作为最终 checkpoint 的地位,其外围逻辑如下:

/* LWM lsn for unflushed dirty pages in Buffer Pool */
lsn_t lwm_lsn = buf_pool_get_oldest_modification_lwm();

/* Note lsn up to which all dirty pages have already been added into Buffer Pool */
const lsn_t dpa_lsn = log_buffer_dirty_pages_added_up_to_lsn(log);

lsn_t checkpoint_lsn = std::min(lwm_lsn, dpa_lsn);

MySQL 8.0 中为了可能让 mtr 之间更大程度的并发,容许并发地给 Buffer Pool 注册脏页。相似与 log.recent_written 和 log_writer,这里引入一个叫做 recent_closed 的 link_buf 来解决并发带来的空洞,由独自的线程 log_closer 来晋升 recent_closed 的 tail,也就是以后间断退出 Buffer Pool 脏页的最大 LSN,这个值也就是下面提到的 dpa_lsn。须要留神的是,因为这种乱序的存在,lwm_lsn 的值并不能简略的获取以后 Buffer Pool 中的最老的脏页的 LSN,激进起见,还须要减掉一个 recent_closed 的容量大小,也就是最大的乱序范畴,简化后的代码如下:

/* LWM lsn for unflushed dirty pages in Buffer Pool */
const lsn_t lsn = buf_pool_get_oldest_modification_approx();
const lsn_t lag = log.recent_closed.capacity();
lsn_t lwm_lsn = lsn - lag;

/* Note lsn up to which all dirty pages have already been added into Buffer Pool */
const lsn_t dpa_lsn = log_buffer_dirty_pages_added_up_to_lsn(log);

lsn_t checkpoint_lsn = std::min(lwm_lsn, dpa_lsn);

这里有一个问题,因为 lwm_lsn 曾经减去了 recent_closed 的 capacity,因而实践上这个值肯定是小于 dpa_lsn 的。那么再去比拟 lwm_lsn 和 dpa_lsn 来获取 Checkpoint 地位或者是没有意义的。

下面曾经提到,ib_logfile0 文件的前三个 Block 有两个被预留作为 Checkpoint Block,这两个 Block 会在打 Checkpiont 的时候交替应用,这样来防止写 Checkpoint 过程中的解体导致没有可用的 Checkpoint。Checkpoint Block 中的内容如下:

首先 8 个字节的 Checkpoint Number,通过比拟这个值能够判断哪个是最新的 Checkpiont 记录,之后 8 字节的 Checkpoint LSN 为打 Checkpoint 的 REDO 地位,复原时会从这个地位开始重放后边的 REDO。之后 8 个字节的 Checkpoint Offset,将 Checkpoint LSN 与文件空间的偏移对应起来。最初 8 字节是后面提到的 Log Buffer 的长度,这个值目前在复原过程并没有应用。

七 总结

本文零碎的介绍了 InnoDB 中 REDO 的作用、个性、组织构造、写入形式曾经清理机会,根本笼罩了 REDO 的大多数内容。对于重启复原时如何应用 REDO 将数据库复原到正确的状态,将在之后介绍 InnoDB 故障复原机制的时候具体介绍。

参考

[1] MySQL 8.0.11Source Code Documentation: Format of redo log
https://dev.mysql.com/doc/dev…

[2] MySQL 8.0: New Lock free, scalable WAL design
https://mysqlserverteam.com/m…

[3] How InnoDB handles REDO logging
https://www.percona.com/blog/…

[4] MySQL Source Code
https://github.com/mysql/mysq…

[5] 数据库故障复原机制的前世今生
http://catkang.github.io/2019…

原文链接
本文为阿里云原创内容,未经容许不得转载。

正文完
 0