MySQL怎么运行的系列(七)25张图爆肝MySQL事务持久性和redo日志原理
数据库事务是mysql执行操作的最小逻辑单位,一个事务可以包含一个或者多个sql语句,这些sql要么都执行成功要么都执行失败。并发操作下,事务的控制使多个事务的sql对同一对象的修改是安全的。
原子性(Atomicity)
原子性是指事务中的所有操作作为一个整体,要么全部成功,要么全部失败。
原子性能实现的关键是在失败的时候能够发生回滚,这依赖于 undo log 日志。事务在更改数据之前会将要更改的数据备份到undo log中(undo log会保存更改前的数据,这是一个行级别的历史数据),如果发生了错误或者用户执行了rollback,就可以通过undo log将数据恢复到事务开始之前的状态。
Undo log怎么做到回滚的呢?你可以理解为:
当你在事务中delete一条记录时,undo log 会记录一条对应的insert 记录。
当你在事务中insert一条记录的时候,undo log 会记录一条对应的delete记录
当你在事务中update一条记录时,它记录一条相反的update记录。
当rollback回滚的时候,就会执行这些相反的操作。
当然了,undo log里面并不会真的记录这些一条条的sql命令,而是存着变更前行数据的内容。
隔离性(isolation)
隔离性是指并发执行的事务不会相互影响,并发执行的操作的结果和他们串行执行时的结果一样。
隔离性是通过锁和MVCC来实现的。锁会再后面的章节中详述,这里先不按下不表,只需要知道隔离性是通过锁来实现的即可。
持久性(Durability)
持久性是指事务一旦提交,对数据库的更新是持久的,事务的所有更改都写入到了磁盘中。之后任何的事务或者故障都不会导致上一个事务数据丢失。
持久性是通过redo log 日志实现的(前滚日志),redo日志记录着一个事务中更改后的数据。
如果事务commit之后,mysql正在将数据写入数据表的时候,mysql发生故障挂掉,此时myslq的表中还是一个旧的数据状态。
但是下次重启mysql的时候,mysql会根据读取 redo log文件的内容,将数据恢复成新的状态。可如果redo log也没来得及持久化,那这部分的数据就真的丢失了。不过redo log的日志数据从缓存写入redo日志文件会比数据写入数据表要快。因为redo日志文件的内容很少,它只保存事务中修改的数据,而且redo日志的写入属于顺序IO,所以redo日志的写入比数据页刷盘到B+树要快。
当然了,如果没有发生任何故障,mysql不宕机,数据成功写入到数据表文件,那么redo日志中保存这的这些数据以后也就用不到了,这个时候mysql会有一套机制将redo日志文件的内容删除。所以redo文件保存的日志是临时的,仅用于故障时防止数据表数据丢失,不会一直存在,否则redo文件会越来越大,而且没有意义。
一句话:数据表的数据没来得及持久化问题不大,只要redo log文件记录的数据在宕机前持久化了就OK。
一致性(Consistency):
一致性体现在两点。
同一个表的在本次事务中有联系的多条记录的状态要对的上,比如转账前后两个账户的金额总和应该不变(两条同一张表的update语句)。
不同表在本次事务中有联系的多条记录的状态要对的上,比如消费后增加用户积分并减少用户金额,那么用户的金额减少后,不能因为故障导致用户积分没增加(两条不同表的update语句)。
上述四个特点中,一致性是事务的最终目的。只要其他三个特性都满足了,那么一致性自然而然也就会满足,也就是说原子性,隔离性和持久性是需要作出的努力,一致性是我们想要的结果。
开启事务
执行begin或start transaction可以开启一个事务,后者还可以修饰符控制事务的行为。
例如:
# 开启一个只读事务和一致性读
start transaction read only, with consistent snapshot;
# 开启一个读写事务和一致性读
start transaction read write, with consistent snapshot;
Read Only 表示这是一个只读事务,该事务内的操作只能是读操作,不能有写操作。
Read Write 表示读写事务,该事务内的操作可读可写(默认情况)。
With Consistent Snapshot:启动一致性读。
一条独立的sql也是一个事务,事务可以设置为自动提交或手动提交,手动提交的事务必须显式的使用 begin 和 commit语句,但是手动提交的模式下,某些情况即使不执行 commit 也会触发自动提交,称为隐式提交。以下情况会发生隐式提交(注意,隐式提交属于手动提交的情况,而不是自动提交的情况):
a. 执行DDL语句(即建表,删表,改表字段);
b. 上一个事务还没提交或回滚是就又使用 start transaction 或 begin 开启了另一个事务,就会隐式的提交上一个事务;
c. 执行主从复制相关的语句、或者导入数据的语句。
保存点
mysql 允许在一个事务的多个语句执行过程中打点,或者说在某个位置设置保存点。如果发生错误,可以选择回滚到指定的保存点而不是回滚到事务开始的状态。
# 打点语句:
savepoint 保存点名称;
# 回滚到某个保存点:
rollback [work] to [savepoint] 保存点名称;
# 删除保存点:
release savepoint 保存点名称;
什么是redo日志,为什么需要redo日志?
一个事务做出了若干个数据变更,在事务提交之前,这些变更已经写入到内存 buffer pool中,但是还未写入到数据表对应的磁盘页(一来写入磁盘开销大,二来事务提交前就写入磁盘不符合原子性)。而且也不会在事务提交的时候(执行commit的时候)写入磁盘页。之所以执行了commit时仍不刷盘原因有两点:浪费 和 随机IO。
浪费:记录刷盘到表的单位是页,如果我们在事务中只修改了页面的1个字节就要对一个页刷盘,未免效率太低。还不如等多个事务对这个页发生了多处修改之后再刷盘到表。
随机IO:一个事务可能包含多条语句,就算事务里只有一条语句也可能会修改到多个页面。最可怕的是修改的这些页面可能不相邻甚至在磁盘中隔得很远。也就是说,一次事务很可能会发生多次随机IO。
这些随机IO必然是要发生的,但是每提交一次事务就发生多次随机IO,这未免也太频繁,可能阻塞用户线程对请求的处理。
所以每次提交事务就更新相应磁盘页会带来 效率低 和 单次事务因多次随机IO耗时太长 这2个问题。
为了解决这个问题,mysql在提交事务时将事务发生的变更记录到一个日志文件中,并且使用定时任务异步的将日志的变更内容刷新到表的磁盘页中。
这样的日志叫做 重做日志(redo 日志)。将更改的数据刷盘到redo日志而不是刷盘到数据表有2个好处(对应上面两个坏处):
1、redo日志记录的变更内容少,只记录事务涉及到的数据行(具体是数据行所在的表空间ID、页号、页内偏移和更新后的值),而不是记录行所在的整个页。
2、redo行记录是顺序写入磁盘的(顺序IO),直接按照日志产生时的顺序追加到redo日志文件的末尾即可,顺序IO比随机IO快很多。
那么到底redo日志长什么样子呢?
redo日志的行格式
type:redo 日志的类型。
MySQ 5.7.22 版本中,一共为 redo 日志设计了 53 种不同的类型。这里的redo日志类型,是指 redo 记录行的类型,一个redo文件内有不同类型的redo行。
space ID:表空间ID
page number:页号
data:这条 redo 日志的具体内容。
redo日志的类型
redo日志(行)可以分为简单redo日志和复杂的redo日志。
简单的redo日志(行)只需记录修改页面的页号、页内偏移量和修改内容,例如:
MLOG_1BYTE类型的redo行:表示在页面的某个偏移量处写入1字节的redo日志。类似的还有 MLOG_2BYTE、MLOG_4BYTE、MLOG_8BYTE。
它们的格式如下所示:
MLOG_WRITE_STRING:表示在页面的某个偏移量处写入一个字节序列的redo日志。
复杂的redo日志包含
插入一条使用非紧凑行格式(REDUNDANT)的记录的redo日志;
插入一条使用紧凑行格式 (COMPACT、DYNAMIC、COMPRESSED) 的记录的redo日志;
创建一个存储紧凑行格式记录的页面的 redo 日志;
删除一条使用紧凑行格式记录的 redo 日志;
从某条给定记录开始删除页面中一系列使用紧凑行格式的记录的 redo 日志;等等。
以组的形式写入redo日志
事务里的一条sql语句可能会修改多个页,就算只修改一个页,也可能修改一个页的多个地方,所以事务中的一条sql语句可能会产生多条redo日志。Innodb将一个sql语句产生的多条redo日志进行分组,每组包含一条或多条redo行,一个组内的redo行具有原子性,即不可分割。
分组的规则如下:
向聚簇索引的B+树的一个页面插入、修改和删除一条记录所产生的的一条或多条redo日志是一组,是不可分割的;
向某个二级索引对应的B+树的页插入、修改和删除一条记录产生的一条或多条redo日志是一组,是不可分割的。因此,如果一个表有3个二级索引,则插入1条记录会产生3个关于二级索引的redo日志组。
还有一些其他的不可分割组,我们不再细究,只需要知道在一个页上的一次操作就会产生一个redo日志行,而一条表记录的增删改在B+树页面上引发的多个操作会产生多个redo日志行,并且这些redo日志行是一组不可分割的redo日志组。
以插入一条记录为例,我们只关注其在主键索引产生的redo日志,假如插入的行所在的页为页A,分2种情况:
1、页A有空闲空间,足够容纳一条待插入记录,只会产生一条insert类型的redo日志,该情况称为乐观插入;此时该插入操作生成的redo日志组里只有一条redo日志(实际上,乐观插入也可能产生多条redo日志)。
2、页A没有空闲空间,插入一条记录会导致页分裂,会涉及创建新页,将旧页的部分数据拷贝到新页,在目录页的添加一个目录项的行并让该目录项指向这个新页,等操作。该情况称为悲观插入;此时插入操作生成的redo日志组里有多条redo日志。
需要注意:假如发生悲观插入,往redo日志文件只写入了一个组的部分redo行,此时mysql发生故障挂掉,那么恢复故障时,mysql是不会恢复一个不完整redo日志组内的操作的。因此,一个redo日志组内的redo日志具有原子性,当然啦,这与事务的原子性是两回事,请不要混淆。
mysql 如何保证redo日志的原子性,或者说如何将多个redo日志行归为一组?
InnoDB的设计者会在一组redo日志的最后加上一条特殊类型的redo日志行(MLOG_MULTI_REC_END),我们可以称之为end类型的redo日志。end类型的日志只有一个type字段。
对于一组里只有一条redo日志的情况,是不会在末尾加上 end 日志,而是直接将type字段的第一个比特位置为1,表示这是一个单条redo日志的组。
Innodb把一组不可分割的日志记录称为一个 Mini-Transaction,我们简称为MTR。系统故障恢复时不会恢复一个不完整的MTR。
一个MTR内不可能出现其他MTR的redo记录,因为一个MTR内的redo记录是不可分割的。
一个事务可以包含若干条语句,每一条语句又包含若干个 MTR.,每个 MTR 又可以包含着若干条 redo 日志(行):
redo日志页
InnoDB将MTR放在大小为512字节的页中(称为 block,你叫他日志块或日志页都行)。
无论是redo日志缓冲区还是redo日志文件都是以512字节的block为单位的,而且内存和磁盘中的redo block是连续的:
图中 block body 内存放的就是MTR日志组。如果一个日志组的日志条数很多,超过了一个block大小,那么这个MTR就会跨多个block存储。
redo 日志文件组
redo日志文件默认有2个,存在于mysql的数据目录下。redo 文件的个数可调整,这些文件形成redo日志文件组。
在逻辑上,这些redo日志文件组是一个成环的队列,一开始向名为 ib_logfile0 的redo日志文件中写入,满了之后再向 ib_logfile1的redo文件写入,以此类推,当 ib_logfile n(最后一个redo文件)被满了就又会从ib_logfile0写入。
我们将这多个redo文件在逻辑上看做一个整体空间,下文所有提及“redo日志文件的偏移量” 或者 “redo日志文件的位置” 是指在这整个空间的偏移量,而不是在单独某一个redo文件内的偏移量。
一个ib_logfile内是由多个连续的 redo log block 组成的,每个redo文件的前4个block存储一些管理信息,后面的block才存储redo记录内存。
这样的成环链表文件组需要考虑一个问题,即后写入的 redo 日志可能覆盖前面写入的 redo日志。
为了解决这个问题,innodb提出了checkpoint的概念(后面介绍)。
redo日志写入过程如下:
1、将事务中产生的redo日志写入redo日志缓冲区(redo log buffer)。
Innodb维护了一个 buf_free 指针,该指针表示后续写入的redo日志应该记录到 日志缓冲区中的位置。在redo日志缓冲区(redo log buffer)为空的时候,buf_free指向 log buffer 的第一个block的第12个字节处,前面12个字节是这个 block 的 header信息。buf_free指针往后的区域是 redo log buffer 的空闲区域。
如下所示:
一个MTR内的多条redo日志是不可分割的,因此即使有多个事务并发的发生,这些事务产生的 redo 记录也是不会交错的写入到redo 日志缓冲(log buffer)的,一个MTR内的日志会先暂存到一个地方,直到整个MTR组内的记录完整了之后才会全部复制到 log buffer中。
MTR内的redo记录不会和其他MTR内的redo记录交错,但是一个事务的多个MTR是可以和另一个事务的多个MTR交错存储的,假设 T1、 T2 的两个事务,每个事务都 包含 2 个MTR。
事务T1 的两个 MTR 分别称为 mtr_t1_1、 mtr_t1_2;
事务T2 的两个 MTR 分别称为 mtr_t2_1、 mtr_t2_2;
他们在log buffer 中可能是这样的:
在事务执行的过程中,除了将redo日志写入到log buffer 之外,还会将MTR执行后修改过的页加入到 buffer pool的flush链表中。
2、redo日志从log buffer刷盘到redo文件,刷盘时机有以下几个:
a. log buffer空间不足时(log buffer的空间剩余约50%左右就会刷盘);
b. 事务提交后的某个时刻刷盘,具体看刷盘策略,可能会在事务提交时马上刷盘、也可能是每秒一次的频率刷盘;
c. 关闭服务器时;
d. 做checkpoint时会把 redo文件从 checkpoint lsn开始的一部分undo日志刷盘(从flush链表刷盘脏页到数据页,然后修改 checkpoint lsn);
这里的刷盘是指日志刷盘而不是表数据刷盘,也就是说是指 redo的block 从 log buffer 刷盘到 redo 文件,而不是 buffer pool 的 page 刷盘到索引的B+树上。
redo日志在事务执行过程(而非commit时)就写入到log buffer,redo日志的刷盘也不一定是在commit一个事务的时候发生的,可能是在commit前发生的,比如log buffer不足的时候。
redo日志序列号 log sequence number(LSN)
LSN是innodb的一个全局变量,记录从服务开启到当前时刻,所产生的了的redo日志的总字节数。初始LSN为8704。
LSN 包含 log buffer内的 block header(12字节) 和 block tailer 字节(4字节)。
每一个MTR有属于它自己的LSN编号,从而标记一个MTR的新旧程度,一个MTR的LSN就是该MTR写入log buffer时的全局LSN号。
例如下面的一个例子,log buffer 包含 2个 MTR,横跨了 3 个block,其中MTR1占200字节,MTR2占1000字节。
一开始LSN为8704,log buffer初始化后,buf_free指针指向log buffer 的 第12个字节处,此时LSN为 8704 + 12 = 8716。
MTR1写入log buffer,MTR1的LSN就是8716。最新的LSN = 8716 + MTR1 的字节数 = 8916。
MTR2写入log buffer,MTR2的LSN就是就是8916。最新的LSN = 8916 + MTR2 的字节数 + 跨越的block header 和 tailer的长度 = 9948。
结论是:每一组由 MTR 生成的 redo 日志都有一个唯一的 Isn 值与其对应;Isn 值越小 ,说 redo 日志产生得越早。
刷入磁盘的LSN(flushed_to_disk_lsn)
innodb 提出 flushed_to_disk_lsn 表示已经刷入磁盘的redo日志的lsn字节数,也是下一个要刷盘的lsn。
如果 flushed_to_disk_lsn 和 buf_free 重合,说明log buffer 中的所有redo日志都已经刷盘。可以轻松的计算出某个LSN对应的redo日志文件的偏移量:
例如某个LSN为8916,我要计算这个LSN在redo日志文件的偏移量。
先计算出8916这个LSN以前的总redo日志量:8916 - LSN初始值 8704 = 216。
redo日志文件的头信息长2048,redo日志要记录在头信息之后,因此这216个字节要记录在2048之后的位置,因此它在日志文件的偏移量为:2048 + 216 = 2260。
flush链表中的脏页
flush链表中的脏页控制块记录了脏页的两个属性:oldest_modificaton 和 newest_modification 表示第一次修改这个页的MTR的LSN号 和 最后一次修改这个页的MTR的末尾的偏移量(也就是下一个MTR的LSN),我们可以简单的认为这两个属性是第一次修改这个页的时间和最后一次修改这个页的时间。
举个例子:
一开始LSN为8716,字节数为200的MTR1修改了数据页a,页a链入flush链表,它的 oldest_modification 是8716,newest_modification是MTR1的末尾的偏移量(8716 + 200 = 8916),它也是MTR2的LSN号。
字节数为1000的MTR2修改了数据页b 和 c,页b 和 c链入flush链表。
MTR3修改了数据页b 和 d,页d链入flush链表,并且修改页b控制块的 newest_modification 为MTR3的末尾的偏移量。
flush 链表中的脏页按照第一次修改发生的时间顺序进行排序,也就是按照 oldest modification 代表的 LSN 值进行排序。被多次更新的页面不会重复插入到 flush 链表中,但是会更新其 newest modification 属性的值。
checkpoint
前面说过checkpoint用于标明redo日志文件组中可以被新日志覆盖的位置,目的是在容量有限的 redo log 日志组被写满后,防止redo文件前面的日志被覆盖。
innodb 提出了checkpoint lsn 这个全局变量表示当前系统中可以被覆盖的redo日志总量是多少,即 redo日志文件中比checkpoint lsn小的lsn的MTR日志都可以被覆盖。
checkpoint机制的原理:
redo日志文件可以被覆盖的MTR日志,就是那些已经被刷盘成功的页,也就是已经弹出flush链表的脏页。
flush链表中的脏页是按照 oldest_modification 排序的,脏页刷盘时也是按照这个顺序对flush链表刷盘的。
所以 flush 链表中第一个页(即flush链表中最早被修改的脏页)对应的 oldest_modification 的LSN值就是checkpoint lsn。
可以轻松的根据checkpoint lsn计算出该lsn在日志文件中的偏移量,这个偏移量称为 checkpoint offset。
checkpoint lsn 和 checkpoint offset 以及checkpoint no(发生checkpoint操作的次数)会被记录在redo文件的文件头(redo文件的前2048个字节,即redo文件的前4个block中),具体说应该是记录在文件头的 checkpoint1 和 checkpoint2字段,当 checkpoint no 为偶数就写到 checkpoint1,奇数则写到 checkpoint2。
checkpoint操作是指从 flush 链表得到当前checkpoint lsn, 并计算checkpoint offset,并将这些checkpoint信息写入到 redo日志文件组的头信息中。因此checkpoint操作会涉及到写入磁盘的,是有开销的。
需要注意,“脏页刷新到磁盘 " 和 "执行一次 checkpoint操作" 是两件事,由不同的线程完成。
下面我们看一个例子:
一开始,整个redo日志的全局最新lsn是最后一个脏页d对应的MTR的最后一个字节序号10000。
而flush链表的第一个脏页是页a,它的oldest_modification 是 8716,因此redo日志文件组的 checkpoint lsn 是 8716。
下一时刻,页a刷盘到数据表,因此 checkpoint lsn 会更新为下一个要刷盘的页(就是页c)的LSN 8916,如下图:
图中,checkpoint lsn之前的redo记录对应的脏页都已经刷盘成功,checkpoint lsn 到 flushed_to_disk_lsn的redo日志对应的脏页还未刷盘成功,但已经成功刷盘到redo文件中。而 flushed_to_disk_lsn 到 最新lsn 之间的部分是log buffer 上还未刷盘到 redo文件的redo日志。
问题:如果系统频繁的执行DML操作,导致脏页的刷盘慢于redo日志的增长,也就意味着最新 LSN 减去 checkpoint lsn 大于redo日志文件组的大小,那么还是会出现redo日志写满的情况,此时怎么办?
这时候 用户线程 就要被迫刷新脏页(刷新脏页这件事本来是由后台线程来完成的),刷新完脏页之后,checkpoint lsn就会增长,这些脏页对应的redo日志就可以被覆盖了。但这会阻塞用户线程处理sql请求。
崩溃恢复时会从redo文件恢复,系统需要确认redo文件的恢复起点和终点。redo文件组中,从起点到终点的这段redo日志对应的数据正是写入了 redo文件 但还未写入到表文件的数据。
确认恢复起点
我们知道redo文件中 checkpoint_lsn 之前的redo日志对应的页是已刷盘了的,而checkpoint_lsn之后的redo日志可能已经刷盘,可能还没刷盘,这是因为 checkpoint操作 和 脏页刷盘操作 由不同的后台线程分开执行的,checkpoint lsn之后的部分字节也可能被写入到表空间。因此故障恢复会以redo文件的 checkpoint lsn 作为起点。
举个例子,flush链表a->b->c->d,a是第一个脏页,a刷盘,后台线程执行checkpoint,checkpoint_lsn 更新为 LSN_b。之后页b刷盘,还未来得及执行checkpoint操作就宕机,系统故障恢复的时候会以 redo文件中的checkpoint_lsn(LSN_b) 作为起点,但实际上可以以 LSN_c作为起点来恢复,原因是页b已经刷盘成功。
只要把redo文件的 checkpoint1 和 checkpoint2 这两个 block 中的checkpoint_no 值读出来比一下大小,哪个更大,就说明哪个checkpoint_lsn 是最新checkpoint_lsn,同时可以读到该 checkpoint_lsn 对应的redo文件偏移量 checkpoint_offset。
确认恢复的终点
恢复的终点应该位于 flushed_to_disk_lsn,即redo日志中最后一个MTR的最后一个字节在redo文件组中的偏移量。如何定位到这个位置?
block的头部有一个属性记录了当前block使用了多少字节的空间(是LOG_BLOCK_HDR_DATA_LEN属性),对于被填满的block,该值永远为512,如果不为512则这个block就是此次崩溃恢复的最后一个block。
假设这个block的最后一个字节偏移是A,那么redo文件中从checkpoint lsn 对应的偏移量 到 位置A就是我们需要恢复的部分。
如何恢复
正常的做法是从redo文件组的 起点checkpoint_lsn 开始扫描后面的redo日志,按照日志中的内容将对应的页面恢复过来。
Innodb使用了两种优化方法:
1、使用哈希表
用于恢复的多条redo日志会修改多个页面,假如有10条redo日志需要执行,第1/3/9条用于恢复页面A,第2/4/5/7条用于恢复页面B,第6/8条用于恢复页面C,这10条redo日志需要从1到10按顺序恢复。那么一共会发生20次随机IO(从磁盘读取10次页,在内存中做修改后,再写入10次页到磁盘,这还是没考虑从根节点往下寻找的过程)。
之所以会发生这么多次随机IO,是因为页面重复读取,例如执行 redo1 时读取了一次页A,执行redo3时又读取了一次页A。
为了减少redo日志执行过程的磁盘IO,Innodb维护一个hashmap,key是redo日志所记录的表空间ID 和 页号,value 是redo日志行,这些修改同一个页面的redo日志行被放到hashmap的同一个槽中组成一个链表。
崩溃恢复时,需要遍历哈希表,由于对一个页进行修改的redo日志都放在一个槽中,因此从磁盘读取一个页到内存之后,可以按按链表上节点的顺序依次在该页面执行redo日志的操作,避免对这个页面重复读取和写入磁盘。
一个链表内的redo日志必须按序执行,否则可能发生错误,例如对某个页面本意操作是先插入一条记录再删除一条记录,不按顺序就变成了先删一条记录,再插入一条记录。
2、跳过已经刷新到磁盘中的页
前面说过,checkpoint_lsn 之后的一部分redo日志对应的页可能是已经刷盘到表里了,Innodb有办法知道哪些页是已经刷盘成功,不再恢复这些页。
B+树的每一个页的头部信息记录了一个 FIL_PAGE_LSN 属性,表示最后一个修改了这个页的LSN日志序列号,也就是最后一个修改了该页的MTR的最后一个字节序号(对应flush链表节点的newest_modification)。
如果一个待修复的页面的 FIL_PAGE_LSN 属性值大于redo链表中最后一个redo日志的lsn,说明它是之前已经刷盘成功的页,无需对其进行修复,会减少一次写IO。