MySQL Redo Log 深入探索

作者:微信小助手

发布时间:2022-07-13T12:05:07

我是码哥,可以叫我靓仔。今天码哥手把手带大家摸索下 Redo Log。

硬核警告!加粗加红。

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

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

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

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

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

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

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

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

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

本文内容基于 MySQL 8.0.29 源码。

目录

  • 1. 概述

  • 2. Redo 日志产生

  • 3. 写入 log buffer

  • 4. 写入日志文件

  • 5. 日志文件刷盘

  • 6. 总结

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 对象,有个属性