Redo 日志从产生到写入日志文件

对于这样的剧情,想必大家不会陌生:美国大片中拯救世界的英雄,平时看起来跟普通人没啥区别,甚至还可能会有点让人看不上。

但是,关键时刻,却能爆发出惊人能量,挽狂澜于既倒,扶大厦于将倾,拯救世界于危难之中。

今天我们要聊的主角:Redo 日志,也是这样的平民英雄。

本来 InnoDB 接收到插入、修改、删除这样的 DML 语句,以及创建表 & 索引、修改表结构这样的 DDL 语句,修改 Buffer Pool 中的数据页之后就完事了。

因为要保证数据不丢失,事情就变的复杂了,修改了数据页不算完,还要生成 Redo 日志,生成了也不算完,还要把它写入 Redo 日志文件

为了方便描述,本文后面会把 Redo 日志文件简称为日志文件

通过以上描述,相信大家能够发现,生成 Redo 日志并写入日志文件,显然是额外操作,会额外消耗资源。

不惜额外消耗宝贵的服务器资源都要保存下来的东西,肯定不能是个绣花枕头,那这个有用的枕头什么时候能派上用场呢?

当然是服务器累了想小憩一下(突然崩溃)的时候了 ^_^。

服务器也不容易,谁还没有个突然崩溃的时候呢?

说了这么多,是时候确定 Redo 日志的历史地位了:Redo 日志,在太平日子里,不但是个鸡肋,更是个累赘,但是,别把它不当英雄,关键时刻还得靠它拯救数据库

饭前甜点到此为止,接下来是正餐。

本文内容基于 MySQL 8.0.29 源码。

目录

[TOC]

正文

1. 概述

MySQL 8.0 以前,Redo 日志是串行写入 log buffer 的,多个用户线程想要同时往 log buffer 里写日志,那是不行的,必须排队等待(获取互斥锁),拿到互斥锁之后,才能往 log buffer 里写日志。

MySQL 8.0 中,串行写入变为并行写入,log buffer 由乡间小道变成了单向 8 车道的高速公路,多个用户线程可以同时往 log buffer 里写入 Redo 日志,效率大大提升。

Redo 日志从产生到刷盘,一共会经历 4 个阶段(产生、写 log buffer、写日志文件、刷盘),本文会用 4 个小节分别介绍这 4 个阶段。

2. Redo 日志产生

以一条非常简单的插入语句为例,这个语句包含自增列,并且只插入一条记录,我们假设插入过程中不会造成索引页分裂,也不会产生溢出页。

不考虑 Undo 日志产生的 Redo 日志,这样一条 SQL 语句会包含 2 条 Redo 日志(这 2 条日志会形成一个日志组):

  • 一条日志中保存着表中自增列的最大值(MySQL 8.0 把自增列的值持久化了)。
  • 另一条日志中保存着插入记录各字段的值。

每条日志中还有可能会包含 InnoDB 需要的其它信息。

插入记录的过程中,会先产生一条 Redo 日志用于记录表中自增列的最大值,然后插入记录,再产生另一条 Redo 日志。

Redo 日志并不会每产生一条就马上写入 log buffer,而是一组 Redo 日志攒到一起往 log buffer 里写。

问题来了,产生了一条 Redo 日志不能马上写入 log buffer,那怎么办?

那就需要有一个地方临时存放日志组中不同时间点产生的日志了,这个地方就是 mtr 中的 m_log 链表

m_log 链表是由一个一个 block 组成的链表,block 大小为 512 字节,每产生一条日志,就追加到 m_log 的 block 中,如果一个 block 写满了,就再申请一个 block 接着写。

那 mtr 又是个啥?

mtr 是 Mini-Transaction 的缩写,是一组不可分隔的操作组成的一个整体,就像前面插入语句的例子中,保存表中自增列的最大值插入记录就是一组不可分隔的操作,必须放入一个 mtr。

两个操作放入一个 mtr,它们的日志也就放在同一个 mtr 中了。这样就能保证两个操作产生的 Redo 日志一起写入 log buffer 和日志文件中。

mtr 的用途可不止打包一组 Redo 日志这么简单,它还会对 SQL 执行过程中 mtr 需要访问的 Buffer Pool 中的页加锁、修改页中的数据、释放锁,本文我们只介绍 Redo 日志,对于 mtr 就不再展开了。

还有一个概念需要解释一下,日志组就是一个 mtr 中的所有日志。

3. 写入 log buffer

mtr 中一组不可分隔的操作都完成之后,就该提交了,mtr 提交过程中要干的第一件事就是把它里面临时存放的一组 Redo 日志写入到 log buffer 中。

一个事务中可能会包含多个 mtr,mtr 的提交和事务的提交不是一个概念,不要混淆。

前面说到在 MySQL 8.0 中,往 log buffer 里写日志不需要排队等待(获取互斥锁),多个用户线程可以同时写入。

这个无锁化设计是通过在 log buffer 中为每个 mtr 中的 Redo 日志预留空间实现的,每个 mtr 都有一段属于自己的空间,各自往自己专属的空间内写入日志,相互之间就不影响了。

用户线程的 mtr 往 log buffer 写 Redo 日志前,会先获取一段序列号。

以当前系统中已产生的最大序列号(SN)作为 start_sn,加上本次要往 log buffer 中写入的 Redo 日志的字节数(len),得到 end_sn(end_sn = start_sn + len)。

start_sn ~ end_sn 就是本次要写入 log buffer 的 Redo 日志的序列号区间。

获取 start_sn、end_sn 的过程是原子操作,多个线程之间不会出现冲突,不会获取到有交叉的序列号区间。

拿到 start_sn ~ end_sn 只是第一步,还需要进行一次转换,把序列号(SN)转换为日志序列号(LSN),得到一个 LSN 的范围:start_lsn ~ end_lsn,这个范围对应着 log_buffer 中为 mtr 即将写入的 Redo 日志预留的空间。

SN 是截止某个时刻,InnoDB 中实际产生的 Redo 日志字节数。

SN 按照 496 字节拆分,拆分后每 496 字节,加上 12 字节的头信息、4 字节尾部检验码,得到 512 字节的 block,经过这样的转换之后,得到的数字就是 LSN。

至此,写入日志到 log buffer 的准备工作又往前推进了一步。

但是,别着急,也许还要再等等,如果 log buffer 中剩余空间不够写入当前 mtr 的 Redo 日志,那就需要等到 log buffer 中的 Redo 日志被写入日志文件,为当前 mtr 的 Redo 日志腾出空间才行。

这里的写入日志文件,只是调用了操作系统的写文件方法,把 Redo 日志写入日志文件的操作系统缓冲区中,日志文件暂时还不会刷新到磁盘上。

那怎么判断 log buffer 中是否有空间呢?

要回答这个问题,我们需要先介绍一个属性 log_sys.write_lsn,表示 LSN 小于 log_sys.writen_lsn 的日志都已经写入到日志文件缓冲区中。

end_sn <= log_sys.write_lsn + innodb_log_buffer_size(默认 16M),就表示 log buffer 中有空间写入当前 mtr 的 Redo 日志。

如果要等,总不能一直等吧,等到什么时候是个头呢?

如果需要等待,用户线程会监听 log.write_events 事件,log buffer 中有空间写入 Redo 日志之后,当前用户线程会收到事件通知。

谁会给这些等待的用户线程发送事件通知呢?后面会有介绍,请继续往下看。

等到 log buffer 中有空间之后,往里面写入日志就很简单了,直接把 mtr 中的 Redo 日志拷贝到 log buffer 中就完事了。

写完之后,还需要根据 mtr 的 start_lsn 在 recent_written.m_links 中找到对应的 SLOT,然后把 mtr 的 end_lsn 写入这个 SLOT,表示这个 mtr 已经把它的全部 Redo 日志写入 log buffer 了。

如果根据 start_lsn 在 recent_written.m_links 中找到的 SLOT 正在被其它 mtr 使用,当前这个用户线程会采用循环 + 间隔休眠 20 毫秒的方式,直到 SLOT 可以使用。

前面两段涉及到 recent_written 的介绍,大家看了可能会觉得一头雾水,先不要着急,有个模糊印象就行。
因为这两段逻辑是在写日志到 log buffer 这个阶段发生的,所以这里必须要提一下露个脸,相当于占个位,但是详细介绍放到 4. 写入日志文件小节更合适。

说完了写入 Redo 日志到 log buffer,我们回到用户线程等待 log buffer 中有空间写入它的 Redo 日志,这个等待过程是个躺平的过程,在这个过程中,用户线程除了等待事件通知,其它事情啥也不干。

在用户线程看来,等待的过程中岁月静好,但是,世上本来没有岁月静好,它感受到的岁月静好,无非是因为有人替它负重前行。

谁在负重前行?

那是另一个默默工作的线程,它的名字叫作 log_writer,它是一个搬运工,一个专门把 log buffer 中的 Redo 日志写入到日志文件的线程。

log_writer 线程只调用操作系统写文件方法,把 Redo 日志写入日志文件,不会刷新到磁盘上,此时,Redo 日志还在日志文件的操作系统缓冲区中。

接下来,就是 log_writer 线程的主场了。

4. 写入日志文件

log writer 线程把 log buffer 中的 Redo 日志写入日志文件缓冲区,写入的这一段 Redo 日志必须是连续的,中间不能出现空洞。

上一个步骤中,不同用户线程可以并行把各自 mtr 中的 Redo 日志写入 log buffer 中,解决了写入速度慢的问题,同时也带来了新问题。

不同用户线程的 Redo 日志量可能不一样,有的线程会先写完,有的线程后写完,如果某一个范围内,头部的日志写完了,尾部的日志也写完了,中间的日志还没写完,这就出现了空洞。

举个例子,假设有 3 个不同的用户线程,各有一个 mtr 要提交,我们把这 3 个用户线程的 mtr 分别叫作 mtr 10、mtr 11、mtr 12。

mtr 10 的 Redo 日志占用 200 字节,LSN 范围是 start_lsn(2097252) ~ end_lsn(2097452)。

mtr 11 的 Redo 日志占用 12045 字节,LSN 范围是 start_lsn(2097452) ~ end_lsn(2109497)。

mtr 12 的 Redo 日志占用 300 字节,LSN 范围是 start_lsn(2109497) ~ end_lsn(2109797)。

每一个 mtr 的 end_lsn 其实是不属于它的,而是属于下一个 mtr,是下一个 mtr 的 start_lsn。所以,每个 mtr 的 LSN 范围是一个左闭右开区间,例如:mtr 10 [2097252, 2097452)

mtr 10、mtr 12 的日志比较小,mtr 11 的日志比较大,可能会存在这样的情况,mtr 10、mtr 12 的日志都已经全部写入 log buffer,mtr 11 的日志只有一部分写入了 log buffer,中间是存在空洞的。

因为存在空洞,log_writer 线程不能把 mtr 10 ~ 12 的 Redo 日志都写入日志文件,只能把 mtr 10 的 Redo 日志写入日志文件。

等到 mtr 11 的 Redo 日志全部写入 log buffer 之后,才能把 mtr 11 ~ 12 的 Redo 日志一起写入日志文件。

那它怎么知道截止到哪个位置的日志是连续的,可以写入日志文件的呢?

也许我们都能很快想到用一个变量把这个位置记录下来就好了。

没错,InnoDB 也是这么干的,全局日志对象(log_sys)中,有一个 recent_written 属性,这个属性也是个对象,它有一个属性 m_tail(log_sys.recent_written.m_tail),用来记录 log buffer 中小于哪个 LSN 的日志都是连续的。

知道了用什么记,现在有个关键问题,那就是怎么记?

recent_written 对象,有个属性 m_links(recent_written.m_links),这是个数组,默认有 1048576 个元素,每个元素是一个 SLOT,每个 SLOT 占用 8 字节,总共占用 8M 内存空间。

m_links 的每个 SLOT 对应 log buffer 中的一个 LSN,每个用户线程的 mtr 往 log buffer 中写入它的全部 Redo 日志之后,会根据 start_lsn 在 m_links 中找到一个 SLOT,并把 end_lsn 写入这个 SLOT。

还是以前面的 mtr 10 ~ 12 为例,当 mtr 10 把它的所有 Redo 日志全部写入 log buffer 之后,根据 start_lsn(2097252) 找到对应的 SLOT 并写入 end_lsn(2097452)。

SLOT 下标 = start_lsn(2097252) % SLOT 数量(1048576) = 100。

m_links[100] = end_lsn(2097452),m_links[101 ~ 299] 对应着 LSN 2097253 ~ 2097451,也属于 mtr 10 的范围,不过这个区间只是用来占位的,mtr 10 并不会往其中的 SLOT 写入 LSN。

重要说明:实际上,因为 m_links 被当作环形结构循环、重复使用,每个 SLOT 都有可能曾经被其它 mtr 写入过 end_lsn。

对于 mtr 10 来说,除了 start_lsn 对应的 SLOT(m_links[100])的值是 end_lsn(2097452) 之外,其它 SLOT(m_links[101 ~ 299])的值可能是 0,也可能是之前的某个 mtr 写入的 end_lsn。

如果 SLOT 的值是之前的某个 mtr 写入的 end_lsn,这个 end_lsn 一定是小于等于 mtr 10 的 start_lsn 的。

mtr 12 把它的所有 Redo 日志全部写入 log buffer 之后,根据 start_lsn(2109497) 找到对应的 SLOT 并写入 end_lsn(2109797)。

SLOT 下标 = start_lsn(2109497) % SLOT 数量(1048576) = 12345。

m_links[12345] = end_lsn(2109797),m_links[12346 ~ 12644] 对应着 LSN 2109498 ~ 2109796,也属于 mtr 12 的范围,这个区间内 SLOT 的值可能为 0 或者小于等于 start_lsn(2109497) 的数字(具体原因可以参照 mtr 10 的说明)。

此时,mtr 11 的 Redo 日志还没有全部写入 log buffer,m_links[300 ~ 12344] 对应着 LSN 2097452 ~ 2109496,属于 mtr 11 的范围,这个区间内 SLOT 的值可能为 0 或小于等于 start_lsn(2097452) 的数字(具体原因可以参照 mtr 10 的说明)。

说完了 mtr 10 ~ 12 的状态,接下来就要正式介绍 Redo 日志写入日志文件的关键步骤了:根据 recent_written.m_links 找到 log buffer 中连续的日志区间。

先来回忆一下

  • recent_written.m_tail,表示 log buffer 中小于 recent_written.m_tail 的日志都是连续的。
  • log_sys.write_lsn, 表示 log buffer 中小于 log_sys.write_lsn 的日志都已经写入日志文件了。

假设,此时 recent_written.m_tail = 2097252,这是 mtr 10 的 start_lsn,表示 mtr 10 之前的 mtr 往 log buffer 中写入的 Redo 日志已经是连续的了。

log_writer 线程接下来从 m_tail 对应的 LSN(2097252)开始,寻找更大范围的连续日志区间。

计算 m_tail 对应的 SLOT 下标 = m_tail(2097252) % SLOT 数量(1048576) = 100。

读取 SLOT 100(下标为 100 的 SLOT)的值,得到 2097452,这是 mtr 10 的 end_lsn,也是 mtr 11 的 start_lsn,说明 mtr 10 的日志已写入 log buffer。

LSN < 2097452 的区间,Redo 日志都是连续的了,更新 m_tail 的值,recent_written.m_tail = 2097452。

继续寻找,计算 m_tail 对应的 SLOT 下标 = m_tail(2097452) % SLOT 数量(1048576) = 300。

读取 SLOT 300 的值,得到 0,说明 mtr 11 还没有把 Redo 日志全部写入 log buffer 了,本次寻找更大范围的连续日志区间结束,m_tail 保持为 2097452 不变。

log_writer 线程可以把 log buffer 中 LSN < m_tail(2097452) 的 Redo 日志写入到日志文件,写完之后,更新 log_sys.write_lsn 的值,log_sys.write_lsn = 2097452。

然后,log_writer 线程或 log_write_notifier 线程会通知正在等待往 log buffer 中 LSN < m_tail(2097452) 这个区间写 Redo 日志的用户线程,告诉它们可以写 Redo 日志了。

为了减轻 log_writer 线程的负担,通知用户线程这个逻辑做了区分:

如果只有一个用户线程正在等待往 log buffer 中 LSN < m_tail(2097452) 区间写 Redo 日志,log_writer 线程顺手就通知这个用户线程了。

如果有多个用户线程正在等待往 log buffer 中 LSN < m_tail(2097452) 区间写 Redo 日志,log_writer 线程会让 log_write_notifier 线程去通知等待这个范围可写的所有用户线程。

3. 写入 log buffer 小节说过,如果用户线程需要等待 log buffer 中有空间写入它的 Redo 日志,这个用户线程会监听 log.write_events 事件,log_writer & log_write_notifier 线程就是通过这个事件通知用户线程的。

实际上,用户线程监听的是 log.write_events[slot]slot 是对 mtr 的 start_lsn 取模计算得到的,计算公式是这样的:slot = start_lsn % recent_written.m_links 的 SLOT 数量(默认 1048576)。
监听到具体的 slot 上是为了保证每个用户线程只会接收到 log.write_events 事件中和自己有关的通知。

过了一小会,log_writer 线程又要开始工作了,此时,mtr 11 中的全部 Redo 日志都写入 log buffer 了。

上次结束时,recent_written.m_tail = 2097452,其对应的 SLOT 下标为 300,这次从 SLOT 300 开始继续寻找。

读取 SLOT 300 的值,得到 2109497,这是 mtr 11 的 end_lsn,也是 mtr 12 的 start_lsn,说明 LSN < 2109497 的区间,Redo 日志都是连续的了,更新 m_tail 的值,recent_written.m_tail = 2109497。

继续寻找,计算 m_tail 对应的 SLOT 下标 = m_tail(2109497) % SLOT 数量(1048576) = 12345。

读取 SLOT 12345 的值,得到 2109797,这是 mtr 12 的 end_lsn,也是 mtr 12 之后的下一个 mtr 的 start_lsn,说明 LSN < 2109797 的区间,Redo 日志都是连续的了,更新 m_tail 的值, recent_written.m_tail = 2109797。

继续寻找,计算 m_tail 对应的 SLOT 下标 = m_tail(2109797) % SLOT 数量(1048576) = 12645。

读取 SLOT 12645 的值,得到 0,说明 Redo 日志连续的区间到这里暂时结束,m_tail 保持为 2109797 不变。

log_writer 线程可以把 log buffer 中 LSN < m_tail(2109797) 的 Redo 日志写入到日志文件了,写完之后,更新 log_sys.write_lsn 的值,log_sys.write_lsn = 2109797。

然后,log_writer 线程或 log_write_notifier 线程会触发 log.write_events 事件,通知正在等待往 LSN < m_tail(2109797) 区间内写 Redo 日志的用户线程,告诉它们可以写 Redo 日志了。

5. 日志文件刷盘

Redo 日志从 log buffer 写入日志文件中,并不是直接就写到磁盘文件中了,而是会先进入日志文件在操作系统的缓冲区中,还需要经过刷盘操作才能最终写到磁盘上的日志文件中,成为持久化的日志。

Redo 日志文件刷盘,也是由专门的线程完成的,这个线程是 log_flusher

log_flusher 线程的常规工作是大概每秒执行一次刷盘操作

全局日志对象(log_sys)中有一个属性 flushed_to_disk_lsn 表示小于 log_sys.flushed_to_disk_lsn 的 Redo 日志都已经刷新到磁盘上的日志文件中了。

前面我们还提到了另一个属性 log_sys.write_lsn,表示 log buffer 中小于 log_sys.write_lsn 的日志都已经写入日志文件了。

每次执行刷盘操作时,对比这两个属性的值,就能判断出来日志文件缓冲区中是不是有新的 Redo 日志需要刷盘。

如果 log_sys.write_lsn 大于 log_sys.flushed_to_disk_lsn,说明需要刷盘,否则本次不需要执行刷盘操作,log_flusher 线程可以愉快的躺平大概 1s 左右,然后等待下一次时间到了,再进行同样的逻辑判断,确定是否需要刷盘。

不出意外的话,log_flusher 线程就是这么简单平凡,日复一日,年复一年的机械单调的工作着。

但是,这显然不符合剧情发展,单调的故事中总是会时不时出现点刺激的剧情。

log_flusher 线程除了常规的每秒执行一次刷盘操作,还会监听一个事件:log.flusher_event,通过这个事件和外界保持联系,接受外部刺激。

我们来看一个带给 log_flusher 线程刺激场景:

innodb_flush_log_at_trx_commit = 1 时,事务每次提交的时候,都心急火燎的,不可能心平气和的等着 log_flusher 每秒执行一次刷盘操作,必须让 log_flusher 立马起来干活(事务会触发 log.flusher_event 事件),把事务中产生的 Redo 日志刷盘,然后,事务才能向客户端交差。

innodb_flush_log_at_trx_commit = 2 时,事务心急火燎的对象就不是 log_flusher 线程了,而是 log_writer 线程,因为这种场景下,事务只需要等待 log_writer 线程把它的 Redo 日志写入日志文件缓冲区就可以了,不需要等待刷盘。

事务催促 log_flusher 执行刷盘操作之后,会等待刷盘操作完成。等待过程是通过监听 log.flush_events[slot] 事件实现的。

slot 是对事务中最后一个 mtr(一个事务可以包含多个 mtr)的 end_lsn 取模计算得到的,计算公式是这样的:slot = end_lsn % recent_written.m_links 的 SLOT 数量(默认 1048576)。

slot 的作用是保证每个用户线程只会接收到 log.flush_events 事件中和自己有关的通知。

刷盘操作完成后,log_flusher 线程或 log_flush_notifier 线程会通知正在等待 LSN < m_tail(2097452) 这个区间内的 Redo 日志刷盘的用户线程。

为了减轻 log_flusher 线程的负担,通知用户线程这个逻辑做了区分:

如果只有一个用户线程正在等待本次刷盘结果,log_flusher 线程顺手就通知这个用户线程了。

如果有多个用户线程正在等待本次刷盘结果,log_flusher 线程会让 log_flush_notifier 线程去通知等待本次刷盘结果的所有用户线程。

6. 总结

Redo 日志是以日志组为单位写入 log buffer 和日志文件的,每个日志组的 Redo 日志都来源于一个 mtr。

多个用户线程的 mtr 以无锁的方式并行往 log buffer 里写入 Redo 日志,只需要写入之前计算出来 mtr 中 Redo 日志的 LSN 范围,通过这个 LSN 范围在 log buffer 中锁定一段区间,多个用户线程锁定的区间不一样,不会出现冲突。

log_writer 线程把已经写入 log buffer 的 Redo 日志写入日志文件,需要保证 Redo 日志是连续的,InnoDB 用 log_sys.recent_written 对象中的 m_links 数组、m_tail 属性来辅助 log_writer 线程找到连续的日志区间。

log_writer 线程把 log buffer 中的 Redo 日志写入日志文件之后,会通知等待 log buffer 为它腾出空间的用户线程,或者让 log_write_notifier 线程通知用户线程。

log_flusher 线程每秒执行一次刷盘操作,同时还监听了 log.flusher_event 事件,用于接收外部刺激,触发它在周期性刷盘工作的时候也能够更及时的刷盘。

如果 log_sys.write_lsn 大于 log_sys.flushed_to_disk_lsn 说明需要执行刷盘操作,否则不需要。

log_flusher 线程执行完刷盘操作之后,也会通知等待刷盘操作完成的用户线程,或者让 log_flush_notifier 线程通知用户线程。

最后,放上一张整体流程图,希望能够有助于大家理解 Redo 日志刷盘的整体流程。




欢迎扫码关注公众号,我们一起学习更多 MySQL 知识: