全方位解读 MySQL 日志实现内幕(三)
作者介绍
王竹峰,去哪儿网数据库专家,擅长数据库开发、数据库管理及维护,一直致力于 MySQL 数据库源码的研究与探索,对数据库原理及实现具有深刻的理解。曾就职于达梦数据库,多年从事数据库内核开发的工作,后转战人人网,任职高级数据库工程师,目前在去哪儿网负责 MySQL 源码研究与运维、数据库管理和自动化运维平台设计开发及实践工作,是 Inception 开源项目及《MySQL 运维内参》的作者,也是 Oracle MySQL ACE。
------
日志的意义
上面已经讲述过,日志是在逻辑事务对数据库做 DML 操作时,其所包含的物理事务 MTR 所记录的,针对所有涉及的 Buffer Pool 页面的修改记录。
为了更好的讲述日志的意义,这里通过以下几个方面来更好地说明。
假如没有写日志
假如没有写日志,那数据库在做了任何修改之后,必须要直接将 Buffer Page 刷磁盘,不然如果此时数据库挂了,即使事务已经被提交,这些修改还是没办法恢复。这将带来的灾难是,IO 大量增加。此时的数据库,相当于是一个简单的文件系统,无论写什么数据,都必须马上刷入磁盘,Buffer Pool 的作用可能只是一个用来修改文件页面的临时缓存而已。
假如没有写日志,在数据库做了 DML 操作之后,数据库可能在事务没有提交时就将 Buffer Page 刷到磁盘了,但此时需要回滚。而我们知道,回滚段的内容也是通过 Buffer Pool 管理的,它的每个页和 B 树页面是一样的,只是作用不一样而已。由此可知,回滚段数据也是通过 REDO 日志来保证完整性的。那么如果没有了日志,Buffer Page 中的回滚段页面也需要直接写入,没有了任何缓存,性能就会非常低。
假如没有写日志,数据库在关闭(挂掉)后再启动时,就不需要做 REDO 操作了(因为没有写日志),但需要做 UNDO 操作,因为 UNDO 不是通过 REDO 来恢复的,而是自己写入(假设每次写 Buffer Page 之后都直接刷盘了),所以回滚段是有效的,还可以让没有提交的事务回滚掉(因为如果一个事务修改的页面很多的话,肯定会有一部分页面先被刷掉,所以有可能需要回滚),勉强还可以保证数据库的完整性。
综合上面的假设,现在已经明白,日志的作用就是用来保证 Buffer Pool 页面的数据写入不丢失。反过来说,如果每个 Buffer Pool 中的 Page 每次都刷入到磁盘中,这样就不需要 REDO 日志了,此时的数据库就成了一个文件系统,因为 Buffer Pool 每次都进行刷盘,相当于每次写完直接写文件。所以说,日志是数据库管理系统与文件系统最核心的区别。
所以如果没有日志,数据库的性能就低到完全没有办法用了。因为 IO 太大了,同时,这种 IO 操作都是随机写入,很容易导致 IO 到达瓶颈,所以为了提高数据库性能,就必须要使用 REDO 日志机制。
使用日志能提高性能的关键原因,有以下三个方面。
因为日志是用来记录 Buffer Pool 中 Page 的修改记录的,所以把对 Page 的写入转化为对日志的写入,那此时 Page 就不需要每次都刷盘,写 Page 页面只需要在内存中写入即可,性能会非常好。
通常,一个页面是 16KB,如果不写日志的话,每次的写入单位还是 16KB,即使修改很少量的数据,也是如此。这样会导致无效 IO 非常严重,反过来说,也只有通过日志机制,才能真正体现出真实写入的数据量,不会存在对 IO 的浪费,Page 的刷盘数量会大大减少。
如果没有日志,就会每次都刷 Page,而这些 Page 的相对位置是乱的,并不是顺序的,刷盘大多都是随机 IO,这对于机械硬盘来说,性能是非常差的,而有了日志,就可以巧妙地将随机 IO 转化成日志的顺序 IO,这将大大地提高 IOPS,性能也会非常好。
日志文件大小的区别
使用日志对数据库的性能有很大的影响,那对于日志来说,还有什么其他因素会影响数据库的性能呢?那就是日志文件空间容量。
现在已经知道,日志在设置好后其容量是固定的,它是循环使用的,如果不够用了,引发的事件是做一个检查点,让最小有效的 LSN 向前推,让出一部分空间给新产生的日志来使用。也就是说,只要这个日志空间未用完,那么 Buffer Pool 中的 Page 就会一直不刷盘(因为还有其他的刷盘时机,所以这里单指因为日志不够用导致检查点的刷盘),任何修改都是在内存中发生的。那么,下面做一个计算。
假如当前日志容量设置为 128MB,某一个 DML 操作只针对某一行记录一直做修改操作。每次操作产生日志量为 1KB(包括 Buffer 中数据页面的修改及 UNDO 记录的产生),这样算下来,128MB 的日志量可以容纳对这条记录的 131072(128MB/1KB)次修改。也就是说,在这么多次修改之后,这个页面才需要刷盘,才会产生一次随机刷盘操作。而如果把日志文件设置为 1280MB,很容易知道,这将容纳对这条记录的 1310720 次修改,这么多次修改只产生一次随机刷盘操作。而如果还是 128MB 的话,则需要 10 次随机刷盘。很明显,日志容量对数据库的性能还是有很大影响的。
但也不是设置得越大越好,这里有以下两点需要注意。
如果设置得非常大,固然性能可能会很好,但如果某一天(真的有可能到来),数据库异常挂了,此时可能有很多的日志都没有刷盘,也就是 Log flushed up to 与 Last checkpoint at 两个值之间相差太多,恢复起来可能需要比较长的时间。但这个一般问题不大,本身挂的概率不大,同时 REDO 日志的恢复是顺序的,都是根据页面号的大小排序恢复的,所以比较快。同时,在以后的 MySQL 版本中,会有多线程 REDO 恢复(听说的),这样就更快了,所以这一点不需要太担心。
日志容量大小的设置,最好与 Buffer Pool 的总大小匹配。如果日志容量太小,Buffer Pool 太大,这就会导致 Buffer Pool 频繁做检查点,大的 Buffer Pool 不能被好好利用。如果是日志容量很大,而 Buffer Pool 很小,此时 Buffer Page 经常会被淘汰出去,增加了 IO 频次,同时如果数据库意外挂掉,Buffer Pool 小的话,恢复起来也会比较慢。一般情况下,Buffer Pool 的总大小与日志容量的大小比例最好保持在 10~5:1 的范围内。
日志记录格式
前面已经讲述了太多日志相关的内容了,这一节将要讲一下具体到一个日志记录时它是如何组织的,一条日志记录究竟存储了什么。这些问题在这一节都会说清楚。
前面已经讲到,InnoDB 的日志是具有逻辑意义的物理日志,所以,日志记录的格式就不完全是物理信息,而是有一定逻辑意义的。首先看一个基本的格式,如图 11.9 所示。
图 11.9 中各列代表的意义如下。
Type:日志类型,是一个日志记录的最高位,只占一个字节的空间。
Space:表空间 ID 值,如果是系统页面(UNDO 页面,或者是字典表页面),则是 0;如果是索引页面,则是这个索引所在的表空间 ID 值。
Offset:上面 Space 所指定的文件中的页面号,以页面大小为单位,它是第几个页面(从 0 开始计数),则这个 Offset 就是几。
Data:表示这条日志记录对应的数据,这个数据是不确定的,根据不同的 Type 值而不同,分别具有自己的格式。
Type 类型有很多,下面列举了一些在 InnoDB 中比较常用的类型,并简单做一些解释,以便可以更好地理解。InnoDB 中的 REDO,究竟是在做什么?究竟存储了哪些内容?功能是什么?知道每个类型之后,这些问题也就清楚了。
注意:下面讲到的数据记录,都是以 Compact 格式的记录为对象的,其他类型这里不做考虑。
MLOG_1BYTE、MLOG_2BYTES、MLOG_4BYTES、MLOG_8BYTES:这四个类型,表示要在某一个位置,写入一个(两个、四个、八个)字节的内容,在日志记录中,Type 分别是 MLOG_1BYTE(MLOG2_BYTES、MLOG_4BYTES、MLOG_8BYTES),Space 就是对应的表空间ID,Offset 对应的是页面号。在 Data 中,还需要存储三个(四个、六个、十个)字节,前两个为要写入的数据在页面内的偏移值,因为页面大小为 16KB,所以需要用两个字节来存储,而后面才是真正需要写入的数据,占一个(两个、四个、八个)字节,这就是关于这个类型的日志的完整内容。
MLOG_WRITE_STRING:这种类型的日志,其实和 MLOG_1BYTE 是类似的,只是 MLOG_1BYTE 是要写一个固定长度的数据,而 MLOG_WRITE_STRING 是要写一段变长的数据。Data 部分的格式,首先用 2 个字节存储在页面中的写入位置,然后是 2 个字节写入数据长度,最后是存储指定长度的字符串。
MLOG_COMP_REC_MIN_MARK:这个类型的日志,是在将一条记录设置为页面中的最小记录(这个涉及页面管理的内容,在一个页面中只有一个最小记录,它指向的是 B 树下一层的最左边的节点)时产生的,因为只是打个标记,存储内容比较简单,除了基本的日志头外,在 Data 内容中只存储了这条最小记录在页面内的偏移位置。
MLOG_UNDO_INSERT:这个类型的日志,是用来保证一个插入操作可以在事务没有提交的情况下回滚时用的,在插入一条记录时,不止要写一个插入操作的日志(类型为 MLOG_REC_INSERT,后面会着重介绍),还要写一个针对这个操作的回滚记录。我们已经知道,回滚记录的写入,其实也是向 ibdata 文件中写入数据,同样也是写在 Buffer Pool 中的,这个操作对应的 REDO 日志,就是当前介绍的 MLOG_UNDO_INSERT 类型的日志。在数据库恢复时,只有这个 REDO 日志做完了,相应的 UNDO 记录才有效(存在),如果对应的事务没有提交,会通过这个回滚记录将这个插入操作回滚掉,这也正是 REDO 必须要在 UNDO 之前执行的原因。
至于这种类型的日志格式是什么样子的,与前面所说类型的区别还是在 Data 上面。前面两个字节存储的是回滚记录的长度,接着就是回滚记录的完整数据,不包括回滚记录前后各两个字节的指针信息,具体到回滚记录的格式,后面会讲述。
MLOG_INIT_FILE_PAGE:这个类型的日志比较简单,只有前面的基本头信息,没有 Data 部分。因为在 InnoDB 中,初始化一个页面,所有的信息都是固定的,没有额外的处理,只要表明初始化哪一个位置的页面就好了,所以没有 Data 部分。这里初始化页面所做的操作,只涉及对页面中文件管理方面的信息,比如这个页面的页面号、文件号(表空间 ID)等信息,与后面将要介绍的 MLOG_COMP_PAGE_CREATE 是不同的,这个属于页面管理的文件信息部分的初始化,而 MLOG_COMP_PAGE_CREATE 属于页面的索引、数据存储方面的管理信息的初始化。后者是在前者的基础上做的。
MLOG_MULTI_REC_END:这个类型的日志是非常特殊的,它只起一个标记的作用,其存储的内容只有占一个字节的类型值。在前面介绍 MTR 时说到,一个 MTR 所写的日志,要么全部写入,要么全部不写入。那么,如何保证这个原子性就是通过这个类型的日志来实现的呢?即每次 MTR 提交时,都会在后面加上这个日志记录,用来表示这个 MTR 已经结束了。只有在恢复的时候才会使用它,在分析 MTR 时,只有找到这个日志,前面的日志才会去做 REDO,做完之后,再向后扫描找到这个日志,然后再去 REDO,如此反复。如果有一次找不到了,则说明日志文件是不完整的,已经扫描到的 REDO 日志就不会去执行了,从而保证了已经执行的 MTR 每个都是完整的。
MLOG_COMP_REC_CLUST_DELETE_MARK:这个类型的日志是表示,需要将聚集索引中的某一个记录打上删除标志。因为,众所周知,在 InnoDB 数据库中聚簇索引的删除在没有提交之前,只是打了一个删除标志而已。这个类型的日志记录内容,除了基本的内容之外,其 Data 数据的组成主要包括两个字节的索引列的个数 n,以及两个字节的唯一索引的个数 u。接下来存储的是所有索引列的长度信息,每个列用 2 个字节存储,占用空间 2*n 个字节。然后,再存储索引中两个系统列信息,分别是 TRXID 在索引中的列位置信息、ROLLPTR 的值及 TRXID 的值。最后,再存储当前要删除记录在所在页面中的偏移值,也就是那条记录的头指针信息。
这种类型的日志,存储的内容比较复杂,其 Data 部分使用图 11.10 来简明表示一下。
这里有一个奇怪的地方是,在给一个记录打删除标志时,为什么不使用这条记录的主键值来直接定位,而是使用了一些在定位记录时被认为是没有用的东西呢?因为如果需要数据恢复,只需要找到这行记录的主键信息,就可以重新给这个记录打删除标志。那为什么存储的都是一些索引的定义信息呢?比如索引列个数,唯一键列个数,每个列的长度等。关于这个问题,是因为 InnoDB 还需要考虑其自身的问题,那就是它的 REDO 日志是半逻辑半物理的,在恢复时,不能保证对应的数据字典是可用的(因为数据字典的正确性还是需要 REDO 来保证),所以这个日志就会记录一些索引的信息,在恢复时使用这些信息来构造一个 LOG_DUMMY 表及 LOG_DUMMY 索引,然后再用这个表和索引来辅助这个 REDO 日志的执行,这样真正的表及索引可以不正确(暂时的),因为此时是不需要它们的。综合上面所述的日志 Data 部分,就可以知道这条记录的确切信息了,也就可以对它加删除标志了。
MLOG_COMP_REC_UPDATE_INPLACE:这个类型的日志,和上面的 MLOG_COMP_REC_CLUST_DELETE_MARK 基本是差不多的,只是此类型的日志会在最后存储原地更新后的记录信息,包括所有被更新的列的信息。存储方式是:前面用一个字节或两个字节来存储长度,后面跟着的是更新后的数据,直到记录所有的列为止。
MLOG_COMP_REC_DELETE:在 InnoDB 中,删除数据是通过打删除标志来实现的,但是,在事务提交后做 Purge 操作时,这条记录始终是要被删除的,所以,还存在一个真正将数据记录删除的操作,那这个类型的日志就是用来记录这个动作的。不过这个日志需要记录的内容也比较少,除了基本的日志头信息之外,在 Data 中只需要存储这条记录在页面内的偏移即可。那么在恢复数据时,这种类型的日志的作用就是会将这个页面中对应的记录直接删除掉,而不再是打删除标记那么简单了。
MLOG_COMPPAGE_REORGANIZE:这个类型的日志,表示的是要重组指定的页面,其记录的内容也是很简单的,只需要存储要重组哪一个页面即可,没有 Data 部分。在恢复的时候,找到这个页面,对其中的数据碎片做整理,将页面内部的记录一条条向前移,将原来记录之间不能再被使用的空间收回合在一起变成一块连续的空间,这样原来貌似已经满了的页面,又可以插入新的数据了,这就是表的碎片整理过程。
MLOG_COMP_REC_INSERT:这个类型是在插入一条记录时产生的,它的产生过程可能存在一点争议,这里重点说一下。首先,当然还是基本的日志头信息,然后存储的是被插入记录在页面内的偏移信息,接下来就是关于索引的信息,这些都与上面 MLOG_COMP_REC_CLUST_DELETE_MARK 类型日志的内容相同。然而,再往后所存储的信息就比较复杂了。首先会计算出当前要插入的记录与前一条记录第一个不相同的字节的位置,然后在日志中记录从这个位置开始到当前记录结束位置之间的数据,当然还有一些其他的信息,比如第一个不相同的字节的位置信息等。这里主要想说的是它的设计方式,简单一点说,就是当前记录中存储的只是记录的后半部分数据,前半部分数据依赖的是前一条记录,这样存储会比存储整个记录省多少空间呢?最主要的是,这需要依赖插入数据之间的相关性,如果非常像,则可能会省一些,否则可能效果不明显。
上面讲述的是,在 InnoDB 存储引擎中,REDO 日志的一部分类型,并对不同类型做了解释。从解释中可以看到,基本上每一个类型其实都是具有逻辑意义的,与 DML 相关的类型中,不是存储了列数据,就是存储了记录在页面内的偏移等信息,这样做的优点有如下两点。
可以写 REDO 解析工具,去做一个第三方的同步工具,或者了解数据库做了什么操作,类似 Binlog 内容,但侧重点不同。
日志占用空间比全物理日志少。
最大的缺点就是系统首先要保证日志对应页面的正确性,否则会造成逻辑日志执行不成功,或者造成数据不一致等问题,这个问题在 InnoDB 中的解决方式,就是后面介绍的 Double Write 机制。
日志刷盘时机
前面已经介绍了大部分关于 REDO 日志的内容了,但还有一个问题没有讲,就是日志刷盘的时机,也就是什么时候才会将日志刷入磁盘。
现在已经知道,当 MTR 提交时,所产生的日志,都会先写入到 Log Buffer 中,这是日志产生的最初来源。从这个源头开始,InnoDB 会在不同的时机,将这些日志写入到磁盘,分别有下面五种时机。
Log Buffer 空间用完了,便会将已经产生的 Log Buffer 中的日志刷到磁盘中,这个时机在前面介绍 Mtr 时已经说过了。这是最普遍的一种方式。
Master 线程在后台每秒钟刷一次,将当前 Log Buffer 中的日志刷到磁盘中。
每次执行 DML 操作时,都会主动检查日志空间是否足够,如果使用空间的量已经超过了一个预设的经验值,就会主动去刷日志,以保证在后面真正执行时,不会在执行过程中被动地等待刷盘,但这里只会是写文件(写入 OS 缓存中),不会刷磁盘。
在做检查点的时候,要保证所有要刷的数据页面中 LSN 值最小(最旧)的日志已经刷入到磁盘。不然,如果此时数据库挂了,日志不存在,但数据页面已经被修改,从而导致数据不一致,就违背了先写日志的原则。
提交逻辑事务时,会因为参数 innodb_flush_log_at_trx_commit 值的不同,产生不同的行为。如果设置为 0,则在事务提交时,根本不会去刷日志缓冲区,这种设置是最危险的,如果此时运气不好,那对数据库最新的修改都会丢失,即使事务已经提交了,但丢失的事务一般是最新1秒内产生的,因为 Master 线程会每隔 1 秒刷一次。如果设置为 1,则在事务提交时会将日志缓冲区中的日志写入到文件中,同时会将这次写入强刷到磁盘中,保证数据完全不丢失,但这种设置会使得数据库性能下降很多,影响性能。如果设置为 2,则在事务提交时会将日志写入到文件中,但不会去刷盘,只要操作系统不挂,即使数据库挂了,数据还是不会丢失,一般都是设置为 2 即可。关于这个问题,可以用图 11.11 来简单表示。
上面所说的基本上就是全部的日志刷盘时机了,相关内容都已经介绍清楚,在接下来的两节中,会重要讲述数据库的恢复问题。
【END】