MySQL事务实现原理之Redo Log

理论是灰色的,实践之树长青 ——恩格斯

前言

在介绍事务原理之前我们先来说一下事务的四个核心属性:

  • 原子性:事务要么不执行,要么完全执行。如果执行到一半, 宕机重启,已执行的一半要回滚回去;
  • 一致性:各种约束条件,比如主键不能为空、参照完整性等;
  • 隔离性:隔离性和并发性密切相关,因为如果事务全是串行的 (第四个隔离级别),也不需要隔离;
  • 持久性:一旦事务提交了,数据就不能丢;

而说起事务实现,Redo Log是一个重要的话题,它主要实现了事务的持久化属性;

Write-Ahead Log

一个事务要修改多张表的多条记录,多条记录分布在不同的Page里 面,对应到磁盘的不同位置。如果每个事务都直接写磁盘,一次事务提 交就要多次磁盘的随机I/O,性能达不到要求,怎么办呢?

不写磁盘, 在内存中进行事务提交。然后再通过后台线程,异步地把内存中的数据 写入到磁盘中。但有个问题:机器宕机,内存中的数据还没来得及刷盘,数据就丢失了。

为此,就有了Write-ahead Log的思路:先在内存中提交事务,然后写日志(所谓的Write-ahead Log),然后后台任务把内存中的数据异步刷到磁盘。日志是顺序地在尾部Append,从而也就避免了一个事务发生多次磁盘随机 I/O 的问题。明明是先在内存中提交事务,后写的日志,为什么叫作Write-Ahead呢?这里的Ahead,其实是指相对于真正的数据刷到磁盘,因为是先写的日志,后把内存数据刷到磁盘,所以叫Write- Ahead Log。

具体到InnoDB中,Write-Ahead Log是Redo Log。在InnoDB中,不 光事务修改的数据库表数据是异步刷盘的,连Redo Log的写入本身也是 异步的。如图6-7所示,在事务提交之后, Redo Log先写入到内存中的 Redo Log Buffer中,然后异步地刷到磁盘上的Redo Log
MySQL事务实现原理之Redo Log_第1张图片

为此,InnoDB有个关键的参数innodb_flush_log_at_trx_commit控制 Redo Log的刷盘策略,该参数有三个取值:

  • 0:每秒刷一次磁盘,把Redo Log Buffer中的数据刷到Redo Log(默认为0)。
  • 1:每提交一个事务,就刷一次磁盘(这个最安全)。
  • 2:不刷盘。然后根据参数innodb_flush_log_at_timeout设置的值决 定刷盘频率。

很显然,该参数设置为0或者2都可能丢失数据。设置为1最安全, 但性能最差。InnoDB设置此参数,也是为了让应用在数据安全性和性 能之间做一个权衡。

Redo Log的物理和逻辑结构

知道了Redo Log的基本设计思想,下面来看Redo Log的详细结构。

从逻辑上来讲,日志就是一个无限延长的字节流,从数据库安装好 并启动的时间点开始,日志便源源不断地追加,永无结束。

但从物理上来讲,日志不可能是一个永不结束的字节流,日志的物 理结构和逻辑结构,有两个非常显著的差异点:

  1. 磁盘的读取和写入都不是按一个个字节来处理的,磁盘 是“块”设备,为了保证磁盘的I/O效率,都是整块地读取和写入。对于 Redo Log来说,就是Redo Log Block,每个Redo Log Block是512字节。为什么是512字节呢?因为早期的磁盘,一个扇区(最细粒度的磁盘存储单位)就是存储512字节数据。
  2. 日志文件不可能无限制膨胀,过了一定时期,之前的历史日 志就不需要了,通俗地讲叫“归档”,专业术语是Checkpoint。所以Redo Log其实是一个固定大小的文件,循环使用,写到尾部之后,回到头部覆写(实际RedoLog是一组文件,但这里就当成一个大文件,不影响对原理的理解)。之所以能覆写,因为一旦 Page数据刷到磁盘上,日志数据就没有存在的必要了。

图6-8展示了Redo Log逻辑与物理结构的差异,LSN(Log Sequence Number)是逻辑上日志按照时间顺序从小到大的编号。在InnoDB中, LSN是一个64位的整数,取的是从数据库安装启动开始,到当前所写入 的总的日志字节数。实际上LSN没有从0开始,而是从8192开始,这个 是InnoDB源代码里面的一个常量LOG_START_LSN。因为事务有大有 小,每个事务产生的日志数据量是不一样的,所以日志是变长记录,因 此LSN是单调递增的,但肯定不是呈单调连续递增。
MySQL事务实现原理之Redo Log_第2张图片

物理上面,一个固定的文件大小,每512个字节一个 Block,循环使 用。显然,很容易通过LSN换算出所属的Block。反过来,给定Redo Log,也很容易算出第一条日志在什么位置。假设在Redo Log中,从头 到尾所记录的LSN依次如下所示: (200,289,378,478,30,46,58,69,129) 很显然,第1条日志是30,最后1条日志是478,30以前的已经被覆盖。

Physiological Logging

知道了Redo Log的整体结构,下面进一步来看每个Log Block里面 Log的存储格式。

这个问题很关键,是数据库事务实现的一个核心点:

  • 记法1:类似Binlog的statement格式,记原始的SQL语句, insert/delete/update。
  • 记法2:类似Binlog的RAW格式,记录每张表的每条记录的修 改前的值、修改后的值,类似(表,行,修改前的值,修改后的值)。
  • 记法3:记录修改的每个Page的字节数据。由于每个Page有 16KB,记录这16KB里哪些部分被修改了。一个Page如果被修改了多个 地方,就会有多条物理日志,如下所示: (PageID,offset1,len1,改之前的值,改之后的值)
    (Page ID,offset2,len2,改之前的值,改之后的值)

前两种记法都是逻辑记法;第三种是物理记法。Redo Log采用了哪 种记法呢?它采用了逻辑和物理的综合体,就是先以Page为单位记录日 志,每个Page里面再采取逻辑记法(记录Page里面的哪一行被修改 了)。这种记法有个专业术语,叫Physiological Logging。

要搞清楚为什么要采用Physiological Logging,就得知道逻辑日志和 物理日志的对应关系:

  • 一条逻辑日志可能产生多个Page的物理日志。比如往某个表 中插入一条记录,逻辑上是一条日志,但物理上可能会操作两个以上的 Page?为什么呢,因为一个表可能有多个索引,每个索引都是一颗 B+树,插入一条记录,同时更新多个索引,自然可能修改多个Page。 如果Redo Log采用逻辑日志的记法,一条记录牵涉的多个Page写到 一半系统宕机了,要恢复的时候很难知道到底哪个Page写成功了,哪个 失败了。
  • 即使1条逻辑日志只对应一个Page,也可能要修改这个Page的 多个地方。因为一个Page里面的记录是用链表串联的,所以如果在中间 插入一条记录,不仅要插入数据,还要修改记录前后的链表指针。对应 到Page就是多个位置要修改,会产生多条物理日志。 所以纯粹的逻辑日志宕机后不好恢复;物理日志又太大,一条逻辑 日志就可能对应多条物理日志。Physiological Logging综合了两种记法的 优点,先以Page为单位记录日志,在每个Page里面再采用逻辑记法。

IO写入的原子性(Double Write)

要实现事务的原子性,先得考虑磁盘I/O的原子性。一个Log Block 是512个字节。假设调用操作系统的一次Write,往磁盘上写入一个Log Block(512个字节),如果写到一半机器宕机后再重启,请问写入成功 的字节数是0,还是[0,512]之间的任意一个数值?

这个问题的答案并不唯一,可能与操作系统底层和磁盘的机制有 关,如果底层实现了512个字节写入的原子性,上层就不需要做什么事情;否则,在上层就需要考虑这个问题。假设底层没有保证512个字节的原子性,可以通过在日志中加入checksum解决。通过checksum能判断 出宕机之后重启,一个LogBlock是否完整。如果不完整,就可以丢弃 这个LogBlock,对日志来说,就是做截断操作。

除了日志写入有原子性问题,数据写入的原子性问题更大。一个 Page有16KB,往磁盘上刷盘,如果刷到一半系统宕机再重启,请问这 个 Page 是什么状态?在这种情况下,Page 既不是一个脏的Page,也不 是一个干净的Page,而是一个损坏的Page。既然已经有Redo Log了,不 能用Redo Log恢复这个Page吗?

因为Redo Log也恢复不了。因为Redo Log是Physiological Logging, 里面只是一个对Page的修改的逻辑记录,Redo Log记录了哪个地方修改 了,但不知道哪个地方损坏了。另外,即使为这个Page加了checksum,也只能判断出Page损坏了,只能丢弃,但无法恢复数据。

有两个解决办法

  • 让硬件支持16KB写入的原子性。要么写入0个字节,要么 16KB全部成功。
  • Double write。把16KB写入到一个临时的磁盘位置,写入成功后再拷贝到目标磁盘位置。 这样,即使目标磁盘位置的16KB因为宕机被损坏了,还可以用备份去恢复。

Redo Log Block结构

Log Block还需要有Check sum的字段,另外还有一些头部字段。事务可大可小,可能一个Block存不下产生的日志数据,也可能一个Block 能存下多个事务的数据。所以在Block里面,得有字段记录这种偏移量。

图6-9展示了一个Redo Log Block的详细结构,头部有12字节,尾部 Check sum有4个字节,所以实际一个Block能存的日志数据只有496字节。
MySQL事务实现原理之Redo Log_第3张图片

头部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(下文会 专门讲Checkpoint)。

事务、LSN与Log Block的关系

知道了Redo Log的结构,下面从一个事务的提交开始分析,看事务 和对应的Redo Log之间的关联关系。假设有一个事务,伪代码如下:

start transaction
	update table1 某行记录
	update table2 某行记录
	update table3 某行记录
commit

其产生的日志,如图6-10所示。应用层所说的事务都是“逻辑事 务”,具体到底层实现,是“物理事务”,也叫作Mini Transaction(Mtr)。在逻辑层面,事务是三条SQL语句,涉及两张表; 在物理层面,可能是修改了两个Page(当然也可能是四个Page,五个 Page……),每个Page的修改对应一个Mtr。每个Mtr产生一部分日志, 生成一个LSN。

这个“逻辑事务”产生了两段日志和两个LSN。分别存储到Redo Log 的Block里,这两段日志可能是连续的,也可能是不连续的(中间插入 的有其他事务的日志)。所以,在实际磁盘上面,一个逻辑事务对应的 日志不是连续的,但一个物理事务(Mtr)对应的日志一定是连续的 (即使横跨多个Block)。
MySQL事务实现原理之Redo Log_第4张图片

图6-11展示了两个逻辑事务,其对应的Redo Log在磁盘上的排列示 意图。可以看到,LSN是单调递增的,但是两个事务对应的日志是交叉排列的。
MySQL事务实现原理之Redo Log_第5张图片

同一个事务的多条LSN日志也会通过链表串联,最终数据结构类似 表6-9。其中,TxID是InnoDB为每个事务分配的一个唯一的ID,是一个单调递增的整数。
MySQL事务实现原理之Redo Log_第6张图片

事务Rollback与崩溃恢复(ARIES算法)

1.未提交事务的日志也在Redo Log中

通过上面的分析,可以看到不同事务的日志在Redo Log中是交叉存 在的,这意味着未提交的事务也在Redo Log中!因为日志是交叉存在 的,没有办法把已提交事务的日志和未提交事务的日志分开,或者说前 者刷到磁盘的Redo Log上面,后者不刷。比如图6-11的场景,逻辑事务 1提交了,要把逻辑事务1的Redo Log刷到磁盘上,但中间夹杂的有逻辑 事务2的部分Redo Log,逻辑事务2此时还没有提交,但其日志会被“连 带”地刷到磁盘上。

所以这是ARIES算法的一个关键点,不管事务有没有提交,其日志 都会被记录到Redo Log上。当崩溃后再恢复的时候,会把Redo Log全部 重放一遍,提交的事务和未提交的事务,都被重放了,从而让数据 库“原封不动”地回到宕机之前的状态,这叫Repeating History。

重放完成后,再把宕机之前未完成的事务找出来。这就有个问题, 怎么把宕机之前未完成的事务全部找出来?这点讲Checkpoint时会详细 介绍。

把未完成的事务找出来后,逐一利用Undo Log回滚。

2.Rollback转化为Commit

回滚是把未提交事务的Redo Log删了吗?显然不是。在这里用了一 个巧妙的转化方法,把回滚转化成为提交。

如图6-12所示,客户端提交了Rollback,数据库并没有更改之前的 数据,而是以相反的方向生成了三个新的SQL语句,然后Commit,所以 是逻辑层面上的回滚,而不是物理层面的回滚。
MySQL事务实现原理之Redo Log_第7张图片

同样,如果宕机时一个事务执行了一半,在重启、回滚的时候,也 并不是删除之前的部分,而是以相反的操作把这个事务“补齐”,然后 Commit,如图6-13所示。
MySQL事务实现原理之Redo Log_第8张图片

这样一来,事务的回滚就变得简单了,不需要改之前的数据,也不 需要改Redo Log。相当于没有了回滚,全部都是Commit。对于Redo Log来说,就是不断地append。这种逆向操作的SQL语句对应到Redo Log里面,叫作Compensation Log Record(CLR),会和正常操作的 SQL的Log区分开。

3.ARIES恢复算法

如图6-14所示,有T0~T5共6个事务,每个事务所在的线段代表了 在Redo Log中的起始和终止位置。发生宕机时,T0、T1、T2已经完 成,T3、T4、T5还在进行中,所以回滚的时候,要回滚T3、T4、T5。
MySQL事务实现原理之Redo Log_第9张图片

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也不再增长,然 后一次性把所有的脏页刷到磁盘中,叫作Sharp Checkpoint。

Sharp Checkpoint 的应用场景很狭窄,因为系统不可能停下来,所以用的更多的是 Fuzzy Checkpoint,具体怎么做呢?

在内存中,维护了两个关键的表:活跃事务表脏页表

活跃事务表是当前所有未提交事务的集合,每个事务维护了一个关 键变量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的时候,未提交事务的集合。
以图6-14为例,在最近的一次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}。

图6-15展示了事务的开始标识、结束标识以及Checkpoint在Redo Log中的排列位置。其中的S表示Start transaction,事务开始的日志记 录;C表示Commit,事务结束的日志记录。每隔一段时间,做一次 Checkpoint,会插入一条Checkpoint日志。Checkpoint日志记录了 Checkpoint时所对应的活跃事务的列表和脏页列表(脏页列表在图中未 展示)。
MySQL事务实现原理之Redo Log_第10张图片

问题(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遍历Redo Log到末 尾,把每条Redo Log对应的Page全部重刷一次磁盘。 关键是如何做幂等?磁盘上的每个Page有一个关键字段—— pageLSN。这个LSN记录的是这个 Page 刷盘时最后一次修改它的日志对 应的 LSN。如果重放日志的时候,日志的 LSN <=pageLSN,则不修改 日志对应的Page,略过此条日志。

如图6-16所示,Page1被多个事务先后修改了三次,在Redo Log的 时间线上,分别对应的日志的LSN为600、900、1000。当前在内存中, Page1的pageLSN = 1000(最新的值),因为还没来得及刷盘,所以磁 盘中 Page1的 pageLSN = 900(上一次的值)。现在,宕机重启,从 LSN=600的地方开始重放,从磁盘上读出来pageLSN = 900,所以前两 条日志会直接过滤掉,只有LSN = 1000的这条日志对应的修改操作,会 被作用到Page1中。
MySQL事务实现原理之Redo Log_第11张图片

有了这种判重机制,我们就实现了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 操作的时候,还可能会遇到一个问题,回滚到一半, 宕机,重启,再回滚,要进行“回滚的回滚”。

如图6-17所示,假设要回滚一个未提交的事务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事务实现原理之Redo Log_第12张图片

这样,不管出现几次宕机,重启后最终都能保证回滚日志和之前的 日志一一对应,不会出现“回滚嵌套”问题。

总结

在此先对Redo Log做一个总结:

  • 一个事务对应多条Redo Log,事务的Redo Log不是连续存储的。
  • Redo Log不保证事务的原子性,而是保证了持久性。无论提交 的,还是未提交事务的日志,都会进入Redo Log。从而使得Redo Log回 放完毕,数据库就恢复到宕机之前的状态,称为Repeating History。
  • 同时,把未提交的事务挑出来并回滚。回滚通过Checkpoint记录 的“活跃事务表”+ 每个事务日志中的开始/结束标记 + Undo Log 来实 现。
  • Redo Log具有幂等性,通过每个Page里面的pageLSN实现。
  • 无论是提交的、还是未提交的事务,其对应的 Page 数据都可能被 刷到了磁盘中。未提交的事务对应的Page数据,在宕机重启后会回滚。
  • 事务不存在“物理回滚”,所有的回滚操作都被转化成了Commit。

参考书籍:《软件架构设计》
个人github账号:https://github.com/SpecialAll
欢迎一起交流学习!

你可能感兴趣的:(数据库,mysql,事务,ACID,数据库)