共计 7376 个字符,预计需要花费 19 分钟才能阅读完成。
导读 | MySQL 8.0 以前,Redo 日志是串行写入 log buffer 的,多个用户线程想要同时往 log buffer 里写日志,那是不行的,必须排队等待(获取互斥锁),拿到互斥锁之后,才能往 log buffer 里写日志。 |
对于这样的剧情,想必大家不会陌生:美国大片中拯救世界的英雄,平时看起来跟普通人没啥区别,甚至还可能会有点让人看不上。
但是,关键时刻,却能爆发出惊人能量,挽狂澜于既倒,扶大厦于将倾,拯救世界于危难之中。
今天我们要聊的主角:Redo 日志,也是这样的平民英雄。
本来 InnoDB 接收到插入、修改、删除这样的 DML 语句,以及创建表 & 索引、修改表结构这样的 DDL 语句,修改 Buffer Pool 中的数据页之后就完事了。
因为要保证数据不丢失,事情就变的复杂了,修改了数据页不算完,还要生成 Redo 日志,生成了也不算完,还要把它写入 Redo 日志文件。
为了方便描述,本文后面会把 Redo 日志文件简称为日志文件。
通过以上描述,相信大家能够发现,生成 Redo 日志并写入日志文件,显然是额外操作,会额外消耗资源。
不惜额外消耗宝贵的服务器资源都要保存下来的东西,肯定不能是个绣花枕头,那这个有用的枕头什么时候能派上用场呢?
当然是服务器累了想小憩一下(突然崩溃)的时候了 ^_^。
服务器也不容易,谁还没有个突然崩溃的时候呢?
说了这么多,是时候确定 Redo 日志的历史地位了:Redo 日志,在太平日子里,不但是个鸡肋,更是个累赘,但是,别把它不当英雄,关键时刻还得靠它拯救数据库。
饭前甜点到此为止,接下来是正餐。
本文内容基于 MySQL 8.0.29 源码。
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 个阶段。
以一条非常简单的插入语句为例,这个语句包含自增列,并且只插入一条记录,我们假设插入过程中不会造成索引页分裂,也不会产生溢出页。
不考虑 Undo 日志产生的 Redo 日志,这样一条 SQL 语句会包含 2 条 Redo 日志(这 2 条日志会形成一个日志组):
每条日志中还有可能会包含 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 中的所有日志。
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 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 = 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
继续寻找,计算 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
然后,log_writer 线程或 log_write_notifier 线程会通知正在等待往 log buffer 中 LSN
继续寻找,计算 m_tail 对应的 SLOT 下标 = m_tail(2109497) % SLOT 数量 (1048576) = 12345。
读取 SLOT 12345 的值,得到 2109797,这是 mtr 12 的 end_lsn,也是 mtr 12 之后的下一个 mtr 的 start_lsn,说明 LSN
继续寻找,计算 m_tail 对应的 SLOT 下标 = m_tail(2109797) % SLOT 数量 (1048576) = 12645。
读取 SLOT 12645 的值,得到 0,说明 Redo 日志连续的区间到这里暂时结束,m_tail 保持为 2109797 不变。
log_writer 线程可以把 log buffer 中 LSN
然后,log_writer 线程或 log_write_notifier 线程会触发 log.write_events 事件,通知正在等待往 LSN
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,通过这个事件和外界保持联系,接受外部刺激。
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