基本概念
重做日志用来实现事务的持久性,即事务ACID中的D,其由两部分组成:一是内存中的重做日志缓冲(redo buffer),其是易失的,二是重做日志文件(redo log file),其是持久的。
InnoDB是事务的存储引擎,其通过Force Log at Commit机制实现事务的持久性,即当事务提交(COMMIT)时,必须先将该事务的所有日志写入到重做日志文件进行持久化,待事务的COMMIT操作完成才算完成。
这里的日志是指重做日志,在InnoDB存储引擎中,由两部分组成,即Redo Log和Undo Log。Redo Log基本上是顺序写的,在数据库运行时不需要对Redo Log的文件进行读取操作,而Undo Log是需要进行随机读写的。
为什么需要Redo Log?
一个事务要修改多张表的多条记录,多条记录分布在不同的Page里面,对应到磁盘的不同位置。
如果每个事务都直接写磁盘,一次事务提交就要多次磁盘的随机I/O,性能达不到要求。怎么办呢?
解决方案:我们可以不写磁盘,在内存中进行事务提交。然后再通过后台线程,异步地把内存中的数据写入到磁盘中。但这种做法有个问题:机器宕机,内存中的数据还没来得及刷盘,数据就丢失了。
为了取得更好的读写性能,InnoDB会将数据缓存在内存中(InnoDB Buffer Pool),对磁盘数据的修改也会落后于内存,这时如果进程或机器崩溃,会导致内存数据丢失。
为了保证数据库本身的一致性和持久性,InnoDB维护了Redo Log。修改Page之前需要先将修改的内容记录到Redo Log中,并保证Redo Log早于对应的Page落盘,也就是常说的WAL,Write Ahead Log。
当故障发生导致内存数据丢失后,InnoDB会在重启时,通过重放Redo Log,将Page恢复到崩溃前的状态。
为了确保每次日志都写入Redo Log文件,在每次将重做日志缓冲写入Redo Log文件后,innodb存储引擎都需要调用一次fsync操作。由于重做日志文件打开并没有使用O_DIRECT选项,因此重做日志缓冲先写入文件系统缓存。
为了确保重做日志写入磁盘,必须进行一次fsync操作。由于fsync的效率取决于磁盘的性能,因此磁盘的性能决定了事务提交的性能,也就是数据库的性能。
innodb_flush_log_at_trx_commit参数
InnoDB存储引擎允许用户手动设置非持久性的情况发生,以此来提高数据库的性能,即当事务提交时,日志不写入重做日志文件,而是等待一个事件周期后再执行fsync操作,由于并非强制在事务提交时进行一次fsync操作,显然这可以显著提供数据库的性能。但是当数据库发生宕机时,因此会丢失最后一段时间的事务。
参数innodb_flush_log_at_trx_commit用来控制重做日志刷新到磁盘的策略,可以设置3个值
- 0:每秒将日志写入磁盘并将其刷新一次。未刷新日志的事务可能会在崩溃中丢失。
- 1:在每次事务提交时将日志写入并刷新到磁盘。
- 2:每次事务提交后写入日志,并每秒将日志刷新到磁盘一次。未刷新日志的事务可能会在崩溃中丢失。
Redo Log 用于保证 crash-safe 能力。innodb_flush_log_at_trx_commit 这个参数设置成 1 的时候,表示每次事务的 Redo Log 都直接持久化到磁盘。这个参数建议设置成 1,这样可以保证 MySQL 异常重启之后数据不丢失。
对于设置 0 和 2,每秒刷新一次不能 100% 保证。 由于 DDL 更改和其他内部 InnoDB 活动导致日志独立于 innodb_flush_log_at_trx_commit 设置刷新,刷新可能会更频繁地发生,有时由于调度问题而不太频繁。 如果每秒刷新一次日志,则崩溃中可能会丢失多达一秒的事务。 如果日志刷新频率高于或低于每秒一次,则可能丢失的事务量会相应地变化。
日志刷新频率由 innodb_flush_log_at_timeout 控制,它允许您将日志刷新频率设置为 N 秒(其中 N 为 1 … 2700,默认值为 1)。 但是,任何 mysqld 进程崩溃都可以擦除最多 N 秒的事务。
InnoDB崩溃恢复工作与innodb_flush_log_at_trx_commit设置无关。事务要么被完全应用,要么被完全删除。
双1
在使用InnoDB和事务的复制设置中,为了持久性和一致性:
- 如果启用了二进制日志,设置sync_binlog=1。
- 总是设置innodb_flush_log_at_trx_commit = 1。
Redo Log的逻辑与物理结构
从逻辑上来讲,日志就是一个无限延长的字节流,从数据库安装好并启动的时间点开始,日志便源源不断地追加,永无结束。但从物理上来讲,日志不可能是一个永不结束的字节流。
日志的物理结构和逻辑结构,有两个非常显著的差异点:
(1)磁盘的读取和写入都不是按一个个字节来处理的,磁盘是“块”设备,为了保证磁盘的I/O效率,都是整块地读取和写入。对于redo log来说,就是redo log Block。每个Redo Log Block是512字节。为什么是512字节呢?因为早期的磁盘,一个扇区(最细粒度的磁盘存储单位)就是存储512字节数据。
(2)日志文件不可能无限制膨胀,过了一定时期,之前的历史日志就不需要了,通俗地讲叫“归档”,专业术语是Checkpoint。所以Redo Log其实是一个固定大小的文件,循环使用,写到尾部之后,回到头部覆写(实际Redo Log是一组文件,但这里就当成一个大文件,不影响对原理的理解)。之所以能覆写,因为一旦 Page 数据刷到磁盘上,日志数据就没有存在的必要了。
下图展示了Redo Log逻辑与物理结构的差异,LSN(Log Sequence Number)是逻辑上日志按照时间顺序从小到大的编号。在InnoDB中,LSN是一个64位的整数,取的是从数据库安装启动开始,到当前所写入的总的日志字节数。实际上LSN没有从0开始,而是从8192开始,这个是InnoDB源代码里面的一个常量LOG_START_LSN。因为事务有大有小,每个事务产生的日志数据量是不一样的,所以日志是变长记录,因此LSN是单调递增的,但肯定不是呈单调连续递增。
物理上面,一个固定的文件大小,每 512 个字节一个 Block,循环使用。显然,很容易通过LSN换算出所属的Block。反过来,给定Redo Log,也很容易算出第一条日志在什么位置。
假设在Redo Log中,从头到尾所记录的LSN依次如下所示:
(200,289,378,478,30,46,58,69,129)
很显然,第1条日志是30,最后1条日志是478,30以前的已经被覆盖。
查看LSN
mysql> SHOW ENGINE INNODB STATUS;
…………………………………..
Log
Log sequence number 502178316
Log buffer assigned up to 502178316
Log buffer completed up to 502178316
Log written up to 502178316
Log flushed up to 502178316
Added dirty pages up to 502178316
Pages flushed up to 502178316
Last checkpoint at 502178316
13 log i/o’s done, 0.00 log i/o’s/second
我们主要看这下面几个参数
log sequence number就是当前的redo log(in buffer)中的lsn;
log flushed up to是刷到redo log file on disk中的lsn;
pages flushed up to是已经刷到磁盘数据页上的lsn;
last checkpoint at是上一次检查点所在位置的lsn
Log Block里面Log的存储格式
知道了Redo Log的整体结构,下面进一步来看每个Log Block里面Log的存储格式。这个问题很关键,是数据库事务实现的一个核心点。
(1)记法1。类似Binlog的statement格式,记原始的SQL语句,insert/delete/update。
(2)记法2。类似Binlog的RAW格式,记录每张表的每条记录的修改前的值、修改后的值,类似(表,行,修改前的值,修改后的值)。
(3)记法3。记录修改的每个Page的字节数据。由于每个Page有16KB,记录这16KB里哪些部分被修改了。一个Page如果被修改了多个地方,就会有多条物理日志,如下所示:
(Page ID,offset1,len1,改之前的值,改之后的值)
(Page ID,offset2,len2,改之前的值,改之后的值)
前两种记法都是逻辑记法;第三种是物理记法。
Redo Log采用了哪种记法呢?它采用了逻辑和物理的综合体,就是先以Page为单位记录日志,每个Page里面再采取逻辑记法(记录Page里面的哪一行被修改了)。这种记法有个专业术语,叫Physiological Logging。
(Page ID,Record Offset,(Filed 1, Value 1) … (Filed i, Value i) … )
其中,PageID指定要操作的Page页,Record Offset记录了Record在Page内的偏移位置,后面的Field数组,记录了需要修改的Field以及修改后的Value。
Physiological Logging导致的问题:
1,需要基于正确的Page状态上重放Redo Log
由于在一个Page内,Redo Log是以逻辑的方式记录了前后两次的修改,因此重放Redo Log必须基于正确的Page状态。然而InnoDB默认的Page大小是16KB,是大于文件系统能保证原子的4KB大小的,因此可能出现Page内容成功一半的情况。InnoDB中采用了Double Write Buffer的方式来通过写两次的方式保证恢复的时候找到一个正确的Page状态。
2,需要保证Redo Log重放的幂等
Double Write Buffer能够保证找到一个正确的Page状态,我们还需要知道这个状态对应Redo Log上的哪个记录,来避免对Page的重复修改。为此,InnoDB给每个Redo Log记录一个全局唯一递增的标号LSN(Log Sequence Number)。Page在修改时,会将对应的Redo Log记录的LSN记录在Page上(FIL_PAGE_LSN字段),这样恢复重放Redo Log时,就可以来判断跳过已经应用的Redo Log,从而实现重放的幂等。
Redo Log Block结构
Log Block还需要有Check sum的字段,另外还有一些头部字段。事务可大可小,可能一个Block存不下产生的日志数据,也可能一个Block能存下多个事务的数据。所以在Block里面,得有字段记录这种偏移量。
下图展示了一个Redo Log Block的详细结构,头部有12字节,尾部Check sum有4个字节,所以实际一个Block能存的日志数据只有496字节。
头部4个字段的含义分别如下:
Block No:每个Block的唯一编号,可以由LSN换算得到。
Date Len:该Block中实际日志数据的大小,可能496字节没有存满。
First Rec Group:该 Block 中第一条日志的起始位置,可能因为上一条日志很大,上一个Block没有存下,日志的部分数据到了当前的Block。如果First Rec Group=Data Len,则说明上一条日志太大,大到横跨了上一个Block、当前Block、下一个Block,当前Block中没有新日志。
Checkpoint No:当前Block进行Check point时对应的LSN
事务、LSN与Log Block的关系
知道了Redo Log的结构,下面从一个事务的提交开始分析,看事务和对应的Redo Log之间的关联关系。假设有一个事务,伪代码如下:
其产生的日志,如下图所示。应用层所说的事务都是“逻辑事务”,具体到底层实现,是“物理事务”,也叫作Mini Transaction(Mtr)。
在逻辑层面,事务是三条SQL语句,涉及两张表;在物理层面,可能是修改了两个Page(当然也可能是四个Page,五个Page……),每个Page的修改对应一个Mtr。每个Mtr产生一部分日志,生成一个LSN。
这个“逻辑事务”产生了两段日志和两个LSN。分别存储到Redo Log的Block里,这两段日志可能是连续的,也可能是不连续的(中间插入的有其他事务的日志)。所以,在实际磁盘上面,一个逻辑事务对应的日志不是连续的,但一个物理事务(Mtr)对应的日志一定是连续的(即使横跨多个Block)。
下图展示了两个逻辑事务,其对应的Redo Log在磁盘上的排列示意图。可以看到,LSN是单调递增的,但是两个事务对应的日志是交叉排列的。
同一个事务的多条LSN日志也会通过链表串联,最终数据结构类似下表。其中,TxID是InnoDB为每个事务分配的一个唯一的ID,是一个单调递增的整数。
事务Rollback与崩溃恢复(ARIES算法)
1.未提交事务的日志也在Redo Log中
通过上面的分析,可以看到不同事务的日志在Redo Log中是交叉存在的,这意味着未提交的事务也在Redo Log中!因为日志是交叉存在的,没有办法把已提交事务的日志和未提交事务的日志分开,或者说前者刷到磁盘的Redo Log上面,后者不刷。比如上面的场景,逻辑事务1提交了,要把逻辑事务1的Redo Log刷到磁盘上,但中间夹杂的有逻辑事务2的部分Redo Log,逻辑事务2此时还没有提交,但其日志会被“连带”地刷到磁盘上。
所以这是ARIES算法的一个关键点,不管事务有没有提交,其日志都会被记录到Redo Log上。当崩溃后再恢复的时候,会把Redo Log全部重放一遍,提交的事务和未提交的事务,都被重放了,从而让数据库“原封不动”地回到宕机之前的状态,这叫Repeating History。
重放完成后,再把宕机之前未完成的事务找出来。这就有个问题,怎么把宕机之前未完成的事务全部找出来?需要使用checkpoint。
把未完成的事务找出来后,逐一利用Undo Log回滚。
2.Rollback转化为Commit
回滚是把未提交事务的Redo Log删了吗?显然不是。在这里用了一个巧妙的转化方法,把回滚转化成为提交。
如下图所示,客户端提交了Rollback,数据库并没有更改之前的数据,而是以相反的方向生成了三个新的SQL语句,然后Commit,所以是逻辑层面上的回滚,而不是物理层面的回滚。
同样,如果宕机时一个事务执行了一半,在重启、回滚的时候,也并不是删除之前的部分,而是以相反的操作把这个事务“补齐”,然后Commit,如下图所示。
这样一来,事务的回滚就变得简单了,不需要改之前的数据,也不需要改Redo Log。相当于没有了回滚,全部都是Commit。对于Redo Log来说,就是不断地append。这种逆向操作的SQL语句对应到Redo Log里面,叫作Compensation Log Record(CLR),会和正常操作的SQL的Log区分开。
3.ARIES恢复算法
如下图所示,有T0~T5共6个事务,每个事务所在的线段代表了在Redo Log中的起始和终止位置。发生宕机时,T0、T1、T2已经完成,T3、T4、T5还在进行中,所以回滚的时候,要回滚T3、T4、T5。
ARIES算法示意图:
ARIES算法分为三个阶段:
(1)阶段1:分析阶段
第一,确定哪些数据页是脏页,为阶段2的Redo做准备。发生宕机时,虽然T0、T1、T2已经提交了,但只是Redo Log在磁盘上,其对应的数据Page是否已经刷到磁盘上不得而知。如何找出从Checkpoint到Crash之前,所有未刷盘的Page呢?
第二,确定哪些事务未提交,为阶段3的Undo做准备。未提交事务的日志也写入了Redo Log。对应到此图,就是T3、T4、T5的部分日志也在Redo Log中。如何判断出T3、T4、T5未提交,然后对其回滚呢?
这就要谈到ARIES的Checkpoint机制。Checkpoint是每隔一段时间对内存中的数据拍一个“快照”,或者说把内存中的数据“一次性”地刷到磁盘上去。但实际上这做不到!因为在把内存中所有的脏页往磁盘上刷的时候,数据库还在不断地接受客户端的请求,这些脏页一直在更新。除非把系统阻塞住,不再接受前端的请求,这时Redo Log也不再增长,然后一次性把所有的脏页刷到磁盘中,叫作SharpCheckpoint。
Sharp Checkpoint 的应用场景很狭窄,因为系统不可能停下来,所以用的更多的是 FuzzyCheckpoint,具体怎么做呢?
在内存中,维护了两个关键的表:活跃事务表和脏页表。活跃事务表是当前所有未提交事务的集合,每个事务维护了一个关键变量lastLSN,是该事务产生的日志中最后一条日志的LSN。
活跃事务表:
脏页表是当前所有未刷到磁盘上的Page的集合(包括了已提交的事务和未提交的事务),recoveryLSN是导致该Page为脏页的最早的LSN。比如一个Page本来是clean的(内存和磁盘上数据一致),然后事务1修改了它,对应的LSN是LSN1;之后事务2、事务3又修改了它,对应的LSN分别是LSN2、LSN3,这里recoveryLSN取的就是LSN1。
脏页表:
所谓的Fuzzy Checkpoint,就是对这两个关键表做了一个Checkpoint,而不是对数据本身做Checkpoint。这点非常巧妙!因为Page本身很多、数据量大,但这两个表记录的全是ID,数据量很小,很容易备份。所以,每一次 Fuzzy Checkpoint,就把两个表的数据生成一个快照,形成一条 Checkpoint日志,记入Redo Log。
基于这两个关键表,可以求取两个问题:
问题(1):求取Crash的时候,未提交事务的集合。
以ARIES算法示意图为例,在最近的一次Checkpoint 2时候,未提交事务集合是{T2,T3},此时还没有T4、T5。从此处开始,遍历Redo Log到末尾。
在遍历的过程中,首先遇到了T2的结束标识,把T2从集合中移除,剩下{T3};
之后遇到了事务T4的开始标识,把T4加入集合,集合变为{T3,T4};
之后遇到了事务T5的开始标识,把T5加入集合,集合变为{T3,T4,T5}。
最终直到末尾,没有遇到{T3,T4,T5}的结束标识,所以未提交事务是{T3,T4,T5}。
下图展示了事务的开始标识、结束标识以及Checkpoint在Redo Log中的排列位置。
其中的S表示Start transaction,事务开始的日志记录;C表示Commit,事务结束的日志记录。
每隔一段时间,做一次Checkpoint,会插入一条Checkpoint日志。
Checkpoint日志记录了Checkpoint时所对应的活跃事务的列表和脏页列表(脏页列表在图中未展示)。
事务在Redo Log上排列示意图:
问题(2):求取Crash的时候,所有未刷盘的脏页集合。
假设在Checkpoint2的时候,脏页的集合是{P1,P2}。从Checkpoint开始,一直遍历到Redo Log末尾,一旦遇到Redo Log操作的是新的Page,就把它加入脏页集合,最终结果可能是{P1,P2,P3,P4}。
这里有个关键点:从Checkpoint2到Crash,这个集合会只增不减。可能P1、P2在Checkpoint之后已经不是脏页了,但把它认为是脏页也没关系,因为Redo Log是幂等的。
阶段2:进行Redo
假设最后求出来的脏页集合是{P1,P2,P3,P4,P5}。在这个集合中,可能都是真的脏页,也可能是已经刷盘了。取集合中所有脏页的recoveryLSN的最小值,得到firstLSN。从firstLSN遍历RedoLog到末尾,把每条Redo Log对应的Page全部重刷一次磁盘。
关键是如何做幂等?磁盘上的每个Page有一个关键字段——pageLSN。这个LSN记录的是这个 Page刷盘时最后一次修改它的日志对应的 LSN。如果重放日志的时候,日志的 LSN <=pageLSN,则不修改日志对应的Page,略过此条日志。
如下图所示,Page1被多个事务先后修改了三次,在Redo Log的时间线上,分别对应的日志的LSN为600、900、1000。当前在内存中,Page1的pageLSN=1000(最新的值),因为还没来得及刷盘,所以磁盘中 Page1 的 pageLSN=900(上一次的值)。现在,宕机重启,从LSN=600的地方开始重放,从磁盘上读出来pageLSN=900,所以前两条日志会直接过滤掉,只有LSN=1000的这条日志对应的修改操作,会被作用到Page1中。
这点与 TCP 在接收端对数据包的判重有异曲同工之妙!在 TCP 中,是对发送的数据包从小到大编号(seq number),这里是对所有日志从小到大编号(LSN),接收的一方发现收到的日志编号比之前的还要小,就说明不用重做了。
有了这种判重机制,我们就实现了Redo Log重放时的幂等。从而可以从firstLSN开始,将所有日志全部重放一遍,这里面包含了已提交事务和未提交事务的日志,也包含对应的脏页或者干净的页。
Redo 完成后,就保证了所有的脏页都成功地写入到了磁盘,干净页也可能重新写入了一次。并且未提交事务T3、T4、T5对应的Page数据也写入了磁盘。接下来,就是要对T3、T4、T5回滚。
阶段3:进行Undo
在阶段1,我们已经找出了未提交事务集合{T3,T4,T5}。从最后一条日志逆向遍历,因为每条日志都有一个prevLSN字段,所以可以沿着T3、T4、T5各自的日志链一直回溯,最终直到T3的第一条日志。
所谓的Undo,是指每遇到一条属于T3、T4、T5的Log,就生成一条逆向的SQL语句来执行,其执行对应的Redo Log是Compensation Log Record(CLR),会在Redo Log尾部继续追加。所以对于Redo Log来说,其实不存在所谓的“回滚”,全部是正向的Commit,日志只会追加,不会执行“物理截断”之类的操作。
要生成逆向的SQL语句,需要记录对应的历史版本数据,这点需要undo log。
这里要注意的是:Redo的起点位置和Undo的起点位置并没有必然的先后关系,图中画的是Undo的起点位置小于Redo的起点位置,但实际也可以反过来。以为Redo对应的是所有脏页的最小LSN,Undo对应的是所有未提交事务的起始LSN,两者不是同一个维度的概念。
在进行 Undo 操作的时候,还可能会遇到一个问题,回滚到一半,宕机,重启,再回滚,要进行“回滚的回滚”。
如下图所示,假设要回滚一个未提交的事务T,其有三条日志LSN分别为600、900、1000。
第一次宕机重启,首先对LSN=1000进行回滚,生成对应的LSN=1200的日志,这条日志里会有一个字段叫作 UndoNxtLSN,记录的是其对应的被回滚的日志的前一条日志,即UndoNxtLSN=900。
这样当再一次宕机重启时,遇到LSN=1200的CLR,首先会忽略这条日志;然后看到UndoNxtLSN=900,会定位到LSN=900的日志,为其生成对应的CLR日志LSN=1600;然后继续回滚,LSN=1700的日志,回滚的是LSN=600。
这样,不管出现几次宕机,重启后最终都能保证回滚日志和之前的日志一一对应,不会出现“回滚嵌套”问题。
参考资料:
《MySQL技术内幕 InnoDB存储引擎》
https://dev.mysql.com/doc/refman/5.7/en/innodb-redo-log.html
http://mysql.taobao.org/monthly/2020/02/01/
http://mysql.taobao.org/monthly/2015/05/01/
https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_flush_log_at_trx_commit