作者简介
目前在网易云开发RDS的张永祥,一直关注MySQL和数据库运维,擅长MySQL运维。知乎ID:雁南归。
MySQL 8.0的一个重要新特性是重做日志子系统的重构。通过引入两个新的数据结构,最近写入和最近关闭,删除了之前的两个热点锁:log_sys_t::mutex和log_sys_t::flush_order_mutex。
无锁重构使得不同线程在写重做_log_buffer时可以并行写,但是带来了log_buffer不再按照LSN递增的顺序写,flush_list中的脏页不再严格保证LSN递增顺序的问题。
本文将介绍MySQL 8.0中日志缓冲区相关代码的重构,并介绍并发写日志缓冲区问题的解决方案。
一、MySQL重做日志系统概述
重做日志,也称为WAL(提前写日志),是InnoDB存储引擎事务持久性的关键。
在InnoDB存储引擎中,事务执行过程被划分为MTR (Mini TRansaction),每个MTR在执行过程中改变数据页,会生成相应的日志,即重做日志(Redo Log)。提交事务时,只要重做日志持久化,就可以保证事务的持久性。
因为重做日志在持久化过程中是按顺序写入文件的,持久化重做日志的成本远远小于持久化数据页的成本,所以一般情况下,数据页的持久化远远落后于重做日志。
每个重做日志都有一个相应的序列号LSN(日志序列号)。同时,修改该数据页重做日志的LSN将被记录在该数据页上。当数据页保存到磁盘时,不再需要该数据页记录的LSN之前的重做日志。这个LSN被称为检查站。
作为故障恢复,您只需在检查点后重新应用重做日志,即可获得崩溃前未保留的所有数据页。
InnoDB存储引擎在内存中维护一个全局重做日志缓冲区,以缓存重做日志的修改。当mtr提交时,它会将mtr执行过程中生成的本地日志复制到全局重做日志缓冲区中,并将mtr执行过程中修改的数据页(称为脏页)添加到全局队列中。
根据不同的策略,InnoDB存储引擎会将日志放入重做日志缓冲区,或者刷去刷新列表中的脏页并推送检查点。
在删除脏页和推进检查点的过程中,需要严格保证重做日志先删除后刷脏页的顺序。在MySQL 8之前,InnoDB存储引擎严格保证MTR写入重做日志缓冲区的顺序为LSN升序,刷新列表中的脏页按LSN升序排序。
在多线程中写重做日志缓冲区和刷新列表时,这个约束是通过两个全局锁log_sys_t::mutex和log_sys_t::flush_order_mutex实现的。
二、MySQL 5.7中MTR的提交流程
在MySQL 5.7中,重做日志写入全局重做日志缓冲区和向刷新列表添加脏页的操作是在mtr的提交阶段完成的,简化代码为:
MySQL官方博客里有一张图片可以很好的展示这个过程:
第三,MySQL 8中的无锁设计
从上面的代码可以看出,当有多个mtr并发提交时,实际上,这些mtr是从本地日志复制重做到全局重做日志缓冲区并添加脏页到刷新列表中串行完成的。这里的连载操作是整个港铁提交流程的瓶颈。如果能改成并行,肯定能提高港铁提交效率。
但是,序列化提交可以严格保证重做日志的连续性和刷新列表中页面修改LSN的增量。这两个约束使得将重做日志和脏页刷到磁盘变得很容易。只需将重做日志缓冲区的内容按顺序写入文件,将脏页按刷新列表的顺序刷入表空,并推进检查点即可。
当不再以串行模式提交中期审查时,将解决以下问题:
MTR串行的copy本地日志到全局Redo Log Buffer可以保证每个MTR的日志在Redo Log Buffer中都是连续的不会分割。当并行copy日志的时候,需要有额外的手段保证mtr的日志copy到Redo Log Buffer后仍然连续。MySQL 8.0中使用一个全局的原子变量log_t::sn在copy数据前为MTR在Redo Log Buffer中预留好需要的位置,这样并行copy数据到Redo Log Buffer时就不会相互干扰。由于多个MTR并行copy数据到Redo Log Buffer,那必然会有一些MTR copy的快一些,有些MTR copy的比较慢,这时候Redo Log Buffer中可能会有空洞,那么就需要一种方法来确定Redo Log Buffer中的哪些内容可以写入文件。MySQL 8.0中引入了新的数据结构Link_buf解决了这个问题。 并行的添加脏页到flush list会打破flush list中每个数据页对应LSN的单调性约束,如果仍然按flush list中的顺序将脏页落盘,那如何确定Checkpoint的位置?以下文章将分别讨论上述三个问题:
1.将中期审查复制日志解锁到重做日志缓冲区
在MySQL 8.0中,MTR的提交部分可以用下面的伪代码来表示:
与5.7的代码相比,最明显的区别是Log _ sys-->:互斥锁和Log _ sys-->:flush _ order _ Mutex锁,实现重做日志解锁的关键在于函数log _ buffer _ reserve (* log _ sys,len),其中只有两个关键代码:
可见STD::原子
2.测井缓冲空井眼问题
预分配方法可以使多个mtr无冲突地将数据复制到重做日志缓冲区。但是由于有些线程更快,有些线程更慢,必然会造成重做日志缓冲区空空洞的问题,使得重做日志缓冲区刷入磁盘的行为变得复杂。
如上图所示,重做日志缓冲区中的第一个和第三个线程已经写完重做日志,第二个线程正在写入重做日志缓冲区。此时,不可能删除所有三个线程的重做。MySQL 8.0中引入了一个数据结构Link_buf来解决这个问题。
Link_buf实际上是一个定长数组,保证数组的每个元素的更新都是原子性的,循环重用释放的空。
Link_buf用于指示其他数据结构的使用。在Link_buf中,如果索引位置I对应的值为非零值n,则意味着Link_buf标记的数据结构被I之后的n个元素占用,同时在Link_buf内部维护一个变量m来表示当前最大可达LSN。Link_buf的结构图如下:
在接口级别,Link_buf实际上定义了三种有效的行为:
在重做日志缓冲区中维护了Link_buf类型的两个变量:最近写入和最近关闭,以维护重做日志缓冲区和刷新列表的修改信息。
对于重做日志缓冲区,缓冲区使用量和recent _ written的对应关系如下图所示:
变量buf_ready_for_write_LSN保持可以保证没有空漏洞的最大LSN值,即最近_ written->:tail()的结果和重做日志,然后才能安全地保存到磁盘。
在成功写入第一个空孔的数据后,写入数据的mtr通过调用log将recent_written的内部状态更新为下图。最近_已写。Add _ link (start _ lsn,end _ lsn):
这部分代码在log0log.cc文件的log_buffer_write_completed方法中。
每次修改最近写的,都会触发一个独立的线程log_writer回扫最近写的,更新buf_ready_for_write_lsn值(调用最近写的-->:Advance _ tail()方法)。log_writer线程实际上是将日志写入文件的线程。日志编写器线程扫描的最近写入变量的内部如下图所示:
这样就很好地解决了并发写入MTR到log_buffer所导致的空洞的问题。借助新引入的Link_buf类型的数据结构,可以方便地知道重做日志的哪个部分可以执行写入磁盘的操作。
如需了解更多关于卸货的详细信息,
在MySQL 8中,重做日志的删除过程是由两个独立的线程完成的,即log_writer和log_flusher。前者负责将重做日志缓冲区中的数据写入操作系统缓存,后者负责不断执行fsync操作,将操作系统缓存中的数据写入磁盘。
两个线程由全局原子变量log_t::write_LSN同步,该变量指示已写入操作系统缓存的重做日志最大的LSN。
日志缓冲区中重做日志的丢弃不需要用户线程关心,用户线程只需要根据事务提交时innodb_flush_log_at_trx_commit定义的不同行为,等待log_writer或log_flusher的通知即可。
log_writer线程将在侦听到最近写入的日志已被修改后,将大于log_t::write_lsn且小于buf_ready_for_write_lsn的log_buffer中的重做日志刷入操作系统缓存,并更新log_t::write_lsn。
log_flusher线程在侦听write_lsn的更新后调用fsync()一次,并更新flushed_to_disk_lsn,该lsn保存最新的fsync到文件值。
在这种设计模式下,用户线程只负责将日志写入log_buffer,日志的刷新和删除是完全异步的。根据innodb_flush_log_at_trx_commit定义的不同行为,在提交事务时,用户线程需要等待日志写入操作系统缓存或磁盘。
在8.0之前,是用户线程触发了fsync,或者是提交的线程先执行了fsync( Group Commit行为),而在MySQL 8.0中,用户线程只需要等待flushed_to_disk_lsn足够大即可。
在8.0中,使用分段的消息队列来通知用户线程,例如,用户线程需要等待flushed _ to _ disk _ lsn >: = X将加入X所属的消息队列。碎片化可以有效减少消息同步的丢失和一次需要通知的线程数。
8.0中,等待的用户线程由后台线程log_flush_notifier通知,用户线程、log_writer、log_flusher、log_flush_notifier之间的同步关系为。
在8.0中,为了防止用户线程在进入等待状态后立即醒来,用户线程会在等待检查等待条件前进行自旋。8.0新增两个动态变量:innodb _ log _ spin _ cpu _ ABS _ lwm和innodb_log_spin_cpu_pct_hwm,控制执行自旋操作时CPU的水位,避免自旋操作占用过多CPU。
3.刷新列表并发控制和检查点提升
回到上面mtr提交的代码,可以看到,在将重做日志写入全局日志缓冲区后,MTR立即开始向刷新列表添加脏页的步骤,过程分为三个函数调用。
这里也使用了一个Link_Buf类型的无锁结构,recent_closed,来跟踪刷新列表的并发写状态。
提交时由MTR生成的重做日志的范围是[start_lsn,end_lsn]。在将这些对应于重做的脏页添加到刷新列表之后,MTR立即在最近关闭的结构中标记从开始lsn到结束lsn的时间段。recent_closed还在内部维护一个变量m,它对应于一个LSN,表示所有小于LSN的脏页都被添加到刷新列表中。
与重做日志写入不同,MTR在写入刷新列表之前需要等待m值和start_lsn之间的差值。这是为了将同花顺表上的空孔控制在一个范围内。该过程的示意图如下:
MTR需要等待m值与start_lsn之差为常数l后才能写入刷新列表,这就度量了刷新列表中的无序程度,便于确定检查点(实际代码中,l值是最近_closed的内部容量)。
从上面的代码可以看出,在8.0中,实际加入同花顺列表的行为并不是完全并发的,而是在5.7中并不是完全串行的,而是控制在一个范围l内并行写入的。
中期审查需要等待条件开始
也就是说,在继续按照刷新列表的顺序将脏页刷新到磁盘的原始策略的情况下,只需要将检查点从对应于原始页的LSN提前到LSN-L..
在实际实现MySQL 8.0的时候,检查点提升还是按照Page对应的LSN写的,但是在Recover的时候是从Checkpoint-L执行的。这两种方法其实是等价的。
但是在MySQL 8.0中,Recover阶段是从Checkpoint-L开始的,可能会发生Checkpoint -L是一个Redo的中间位置而不是开始位置的情况,所以对于一些边界情况需要做一些额外的工作。
四.摘要
对于InnoDB存储引擎,重做日志处理是实现事务持久化的关键。在MySQL 5.7及之前,MTR的提交过程通过两个全局锁进行序列化,以保证重做日志和脏页处理的正确性,这使得MTR的提交过程由于锁竞争而无法充分发挥多核的优势。
8.0中引入Link_buf数据结构,将整个模块变为Lock_free模式,必然带来性能提升。
参考
MySQL8.0: 重新设计的日志子系统https://yq.aliyun.com/articles/592215?utm_content=m_49932MySQL 8.0: New Lock free, scalable WAL designhttps://mysqlserverteam.com/mysql-8-0-new-lock-free-scalable-wal-design/MySQL Source Code Documentation/InnoDB Redo Loghttps://dev.mysql.com/doc/dev/mysql-server/8.0.11/PAGE_INNODB_REDO_LOG.htmlInnoDB的Redo Log分析http://www.leviathan.vip/2018/12/15/InnoDB%E7%9A%84Redo-Log%E5%88%86%E6%9E%90/MySQL · 引擎特性 · WAL那些事儿http://mysql.taobao.org/monthly/2018/07/01/1.《redo 源码解读:MySQL 8.0 InnoDB无锁化设计的日志系统》援引自互联网,旨在传递更多网络信息知识,仅代表作者本人观点,与本网站无关,侵删请联系页脚下方联系方式。
2.《redo 源码解读:MySQL 8.0 InnoDB无锁化设计的日志系统》仅供读者参考,本网站未对该内容进行证实,对其原创性、真实性、完整性、及时性不作任何保证。
3.文章转载时请保留本站内容来源地址,https://www.lu-xu.com/caijing/1283381.html