理论是灰色的,实践之树长青 ——恩格斯
在介绍事务原理之前我们先来说一下事务的四个核心属性:
而说起事务实现,Redo 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。
为此,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的详细结构。
从逻辑上来讲,日志就是一个无限延长的字节流,从数据库安装好 并启动的时间点开始,日志便源源不断地追加,永无结束。
但从物理上来讲,日志不可能是一个永不结束的字节流,日志的物 理结构和逻辑结构,有两个非常显著的差异点:
- 磁盘的读取和写入都不是按一个个字节来处理的,磁盘 是“块”设备,为了保证磁盘的I/O效率,都是整块地读取和写入。对于 Redo Log来说,就是Redo Log Block,每个Redo Log Block是512字节。为什么是512字节呢?因为早期的磁盘,一个扇区(最细粒度的磁盘存储单位)就是存储512字节数据。
- 日志文件不可能无限制膨胀,过了一定时期,之前的历史日 志就不需要了,通俗地讲叫“归档”,专业术语是Checkpoint。所以Redo Log其实是一个固定大小的文件,循环使用,写到尾部之后,回到头部覆写(实际RedoLog是一组文件,但这里就当成一个大文件,不影响对原理的理解)。之所以能覆写,因为一旦 Page数据刷到磁盘上,日志数据就没有存在的必要了。
图6-8展示了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以前的已经被覆盖。
知道了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,就得知道逻辑日志和 物理日志的对应关系:
要实现事务的原子性,先得考虑磁盘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损坏了,只能丢弃,但无法恢复数据。
有两个解决办法:
Log Block还需要有Check sum的字段,另外还有一些头部字段。事务可大可小,可能一个Block存不下产生的日志数据,也可能一个Block 能存下多个事务的数据。所以在Block里面,得有字段记录这种偏移量。
图6-9展示了一个Redo Log Block的详细结构,头部有12字节,尾部 Check sum有4个字节,所以实际一个Block能存的日志数据只有496字节。
头部4个字段的含义分别如下:
知道了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)。
图6-11展示了两个逻辑事务,其对应的Redo Log在磁盘上的排列示 意图。可以看到,LSN是单调递增的,但是两个事务对应的日志是交叉排列的。
同一个事务的多条LSN日志也会通过链表串联,最终数据结构类似 表6-9。其中,TxID是InnoDB为每个事务分配的一个唯一的ID,是一个单调递增的整数。
通过上面的分析,可以看到不同事务的日志在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回滚。
回滚是把未提交事务的Redo Log删了吗?显然不是。在这里用了一 个巧妙的转化方法,把回滚转化成为提交。
如图6-12所示,客户端提交了Rollback,数据库并没有更改之前的 数据,而是以相反的方向生成了三个新的SQL语句,然后Commit,所以 是逻辑层面上的回滚,而不是物理层面的回滚。
同样,如果宕机时一个事务执行了一半,在重启、回滚的时候,也 并不是删除之前的部分,而是以相反的操作把这个事务“补齐”,然后 Commit,如图6-13所示。
这样一来,事务的回滚就变得简单了,不需要改之前的数据,也不 需要改Redo Log。相当于没有了回滚,全部都是Commit。对于Redo Log来说,就是不断地append。这种逆向操作的SQL语句对应到Redo Log里面,叫作Compensation Log Record(CLR),会和正常操作的 SQL的Log区分开。
如图6-14所示,有T0~T5共6个事务,每个事务所在的线段代表了 在Redo Log中的起始和终止位置。发生宕机时,T0、T1、T2已经完 成,T3、T4、T5还在进行中,所以回滚的时候,要回滚T3、T4、T5。
ARIES算法分为三个阶段:
分析阶段,要解决两个核心问题。
第一,确定哪些数据页是脏页,为阶段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时所对应的活跃事务的列表和脏页列表(脏页列表在图中未 展示)。
问题(2):求取Crash的时候,所有未刷盘的脏页集合。
假设在Checkpoint2的时候,脏页的集合是{P1,P2}。从Checkpoint开 始,一直遍历到Redo Log末尾,一旦遇到Redo Log操作的是新的Page, 就把它加入脏页集合,最终结果可能是{P1, P2,P3,P4}。 这里有个关键点:从Checkpoint2到Crash,这个集合会只增不减。可能P1、P2在Checkpoint之后已经不是脏页了,但把它认为是脏页也没 关系,因为Redo Log是幂等的。
假设最后求出来的脏页集合是{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中。
有了这种判重机制,我们就实现了Redo Log重放时的幂等。从而可 以从firstLSN开始,将所有日志全部重放一遍,这里面包含了已提交事 务和未提交事务的日志,也包含对应的脏页或者干净的页。
Redo 完成后,就保证了所有的脏页都成功地写入到了磁盘,干净 页也可能重新写入了一次。并且未提交事务T3、T4、T5对应的Page数 据也写入了磁盘。接下来,就是要对T3、T4、T5回滚。
在阶段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。
这样,不管出现几次宕机,重启后最终都能保证回滚日志和之前的 日志一一对应,不会出现“回滚嵌套”问题。
在此先对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
欢迎一起交流学习!