说到事务实现原理,就要提到ARIES算法(Algorithms for recovery and isolation expoliting semantics),这个算法是由早年IBM的几个研究员提出的算法集合,论文名字叫《ARIES: A Transaction Recovery method supporting Fine-Granularity Locking and Partial Rollback Using Write-Ahead Logging》,这个论文影响深远,DB2,MySQL,InnoDB,SQL Server,Oracle在事务的实现的很多方面都吸取了该思想。
Data Len, 该Block中payload实际存储的日志数据大小,可能496字节都没存满,没存满填充某些特定字节就好了
First Rec Group, 该Block中第一条日志的起始位置,可能因为上一条日志很大,上一个BlOCK没有存下,日志的部分数据到了当前的Block。如果First Rec Group = Data Length,那么说明上一条日志太大,大到横跨了上一个Block,当前Block,下一个Block,当前Block中没有新日志。
start transaction
update table1 set a = "newvalue"
delete table2 where xxx = "condition"
insert table3 values("value")
rollback
...
end transaction
把上面逻辑事务语句反向转化为以下commit为回滚事务:
start transaction
update table1 set a = "newvalue"
delete table2 where xxx = "condition"
insert table3 values("value")
delete table3 where xxx = "value"
insert table2 values("condition")
update table1 set a = oldvalue
commit
end transaction
start transaction
update table1 set a = "newvalue"
delete table2 where xxx = "condition"
...
...
...
end transaction
如果只执行了2条语句,那么生成后的commit事务为:
start transaction
update table1 set a = "newvalue"
delete table2 where xxx = "condition"
insert table2 values("condition")
update table1 set a = oldvalue
commit
end transaction
Force + No Steal。这种策略是最简单的,直接连Log(Redo + Undo)都不需要了,仅仅依靠内存这个特性就可以实现原子性和持久性。但是这显然不可行,未提交的事务不能写入磁盘,暂时还可以勉强接受,已提交的事务必须强制写入磁盘,fsync会耗费可观的性能,所以才有了Redo Log,即WAL这样的思想,WAL可不止是为事务服务的,其中一点就是服务于IO性能。
No Force + No Steal。这种策略也还行,已经提交的事务可以不立即写入磁盘,未提交的事务只能保留在内存中,这样只需要Redo Log就可以保证事务的AD属性
Force + Steal。已提交的事务立即写入磁盘,未提交的事务也立即写入磁盘。这样需要Undo Log回滚宕机时未提交的事务,不需要Redo Log。
No Force + Steal。 这样的策略是业内主流的做法,是InnoDB的策略。就是已经提交的事务可以不立即写入磁盘,未提交的事务既可以立即写入磁盘,也可以延迟写入磁盘,写磁盘的时机任意,可以通过配置来配置,比如这个配置innodb_flush_log_at_trx_commit
写时复制(CoW),所谓的Copy on Write。写的时候把数据对象拷贝一份,等写完之后,再把数据对象的指针(引用)一次性赋值回去,读的时候直接读取原始数据。读读,读写都可以并发,写写也可以并发。当然,Java的JUC包都有类似的数据结构了,比如:CopyOnWriteArrayList和CopyOnWriteArraySet等。
而对于update delete,由MVCC控制,所以Undo Log是类似于通过指针串联起来的last data -> ver 3 data ->
ver 2 data -> ver 1 data。 每个节点记录对应的修改事务的tx id。别说,真是大道归一,区块链,git版本控制,DDD中的event sourcing都有这样的思想,只是解决的问题不在一个层面而已。
数据库事务实现原理
之前写过《程序员应该知道的数据库知识》,原本是打算把事务实现写在那里的,但是发现篇幅太长,所以需要拿出来单独写成一篇。
Redo Log
数据库事务有ACID四个核心属性,之前说过,再提一下:
Atomic,原子性。事务要么不执行,要么完全执行。如果执行到一半,宕机重启,已执行的一半要回滚回去
Consistency,一致性。这里的一致性跟分布式的CAP理论的一致性不是一个概念,这里的一致性是各种约束条件,比如主键不为空,参照完整性等,也就是说这里是关系模型中的完整性约束。
Isolation,隔离性。这个概念与多线程,并发性密切相关,如果事务全是串行的(最高隔离级别),也就不需要隔离,因为全是排队独占,事务没有并发。
Durability,持久性。一个事务一旦被commit提交,它对数据库中的数据改变就是永久性的,接下来即使数据库宕机,也不应该对其有任何影响,说白话一点就是事务关联的数据确定落盘了。
这四个属性中,D比较容易,最简单,C主要是上层的各种规则来约束,也相对简单。但是A和I牵扯到并发问题,崩溃恢复(掉电保护),是事务实现的核心难点。
说到事务实现原理,就要提到ARIES算法(Algorithms for recovery and isolation expoliting semantics),这个算法是由早年IBM的几个研究员提出的算法集合,论文名字叫《ARIES: A Transaction Recovery method supporting Fine-Granularity Locking and Partial Rollback Using Write-Ahead Logging》,这个论文影响深远,DB2,MySQL,InnoDB,SQL Server,Oracle在事务的实现的很多方面都吸取了该思想。
然后,你也看到论文标题了,重点就是事务的恢复和回滚,而且是用WAL(Write-Ahead logging)这种技术思想来实现的,WAL的思想影响深远流行,你在LevelDB,RocksDB的源码里都能看到,甚至是Raft共识算法中的副本状态机的模型也是基于这种思想。这论文主要讨论的是事务的A,I,D这三个属性。所以接下来会分析事务的A,I,D三个属性,C这个属性一般是上层保证,就暂时不分析了。=,先说最简单的D,D要涉及OS的I/O问题。
Write-Ahead logging
WAL又叫写前日志,或者叫预写日志,看它名字,你就知道它只是一种指导思想。
一个事务要修改多张表的多条记录,多条记录分散在不同的Page里,这个Page就是之前文章提到的数据页,是逻辑上的,InnoDB默认16KB,不同的Page对应到磁盘的不同位置。如果每个事务都直接写磁盘,一次事务提交就要多次磁盘的随机读写(即使有B+树降低I/O次数的保证),性能达不到要求。
怎么办?如果不写磁盘,在内存中进行事务commit提交,然后通过后台线程异步把内存中的数据写入磁盘中这样会遇到问题,就是机器在宕机,系统崩溃的时候,内存中的数据还没有来得及刷盘,数据就丢失或者损坏了,而且没有历史记录来做恢复和回滚。
所以就有了WAL的思路,先在内存中commit事务,然后写日志,然后后台线程把内存中的数据异步刷到磁盘,日志是AOF(Append-Only File),只管在文件尾追加内容,顺序写,性能很高,从而避免了一个事务发生多次磁盘随机读写的问题。也就是WAL是先写日志,再写数据到数据库文件落盘。
因为WAL只是指导思想,对应到InnoDB中,WAL就是Redo Log,就是重做日志,可以对历史进行重放。在InnoDB中,不光事务修改的数据库表数据是异步刷盘的,连Redo Log也是异步刷盘到AOF中的。在事务commit后,Redo Log模块先写入到内存中的Redo Log Buffer,然后异步刷到磁盘上的Redo Log文件中。
所以,InnoDB有个关键参数,innodb_flush_log_at_trx_commit控制Redo Log的刷盘策略,就是在Redo Log Buffer中每提交多少个事务就刷一次盘到Redo Log文件,显然,每提交一个事务就刷一次盘这个最安全(不会丢数据),但是性能最差。
以上就是简单的InnoDB异步刷盘文本示意图。
Redo Log的逻辑与物理结构
知道了Redo Log的设计思想,那么来看看它的详细结构,这部分跟实现相关。
从逻辑上说,日志就是一个流式数据,日志源源不断追加,永无结束。但从物理上说,日志不可能无限追加,因为是物理世界,主要是以下两点:
磁盘读写都不是按照一个字节一个字节来读写,磁盘是块设备,为了保证磁盘的I/O效率都是整块读取和写入。对于Redo Log File来说,就是Redo Log Block,每个Redo Log Block是512字节。为什么?因为早期的磁盘,一个扇区(最细粒度的磁盘存储单位)就是存储512字节。
Redo Log File不可能无限追加,过了一段时间,之前的历史日志可能就不需要了,其实就是Checkpoint。所以Redo Log File其实是一个固定大小的文件,循环使用,写到尾部之后,回到文件头覆盖写入(实际在InnoDB中,Redo Log是一组文件,从逻辑上看可以看成一个固定大小的大文件)。之所以能覆写,是因为一旦Page数据被刷到磁盘上,日志数据就没有存在必要了。
所以,下面需要分析下Redo Log的逻辑结构和物理结构的差异,从逻辑上看,Redo Log不断追加的写入特性,就是一个序列递增,所以有了LSN(Log Sequence Number)的概念,LSN是逻辑上日志按照时间顺序从小到大的编号写入。在InnoDB中,LSN是一个64位整数,取的是从数据库安装启动开始,到当前所写入的总的日志字节数。因为不同的事务有大小,每个事务产生的日志数据量是不一样的,所以日志的追加是变长的记录,因此LSN是单调递增,但是递增不均匀。
从物理上看,一个固定大小的Redo Log File,每个512字节是一个Block,循环使用。很显然,可以根据对应的LSN换算出所属的Block。反过来,也很容易算出第一条日志在什么位置。
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, old_value1, changed_value1)
(PageID, offset2, len2, old_value1, changed_value2)
前两种格式都是逻辑格式,最后一个是物理格式。Redo Log采用的是逻辑和物理的综合,就是先以Page为单位记录日志,每个Page里面再采用逻辑格式(记录Page里的哪一行被修改了)。这种格式就是Physiological Logging。
为什么要采用这种混合式的日志呢?
一条逻辑日志可能产生多个Page的物理日志。比如往某个表中插入一条记录,但是一个表中可能有多个索引,每个索引对应一颗B+树,插入一条记录,同时更新多个索引,B+树的节点又对应一个Page,自然修改多个Page
即使1条逻辑日志只对应一个Page,也可能要修改这个Page的多个地方。因为Page里面的记录是用链表串联的。
所以纯粹的逻辑日志宕机后不好恢复,但是物理日志又太大,Physiological Logging综合了两个优点。当然,如果自己写玩具数据库的WAL,暂时可以不用这么复杂,越简单越好,WAL是可以简化很多的。
I/O写入的原子性
要实现事务的原子性,也就是ACID中的A,就先得考虑磁盘I/O的原子性。一个Log Block是512字节。假设OS的一次write往磁盘上写入一个Log Block(512 bytes),如果写到一半机器宕机(或者写完没有调用fsync)后再重启,那么写入写入成功的字节数是0,还是0-512之间的任意一个字节数?
这个问题其实没有答案,与OS底层和磁盘的机制有关,如果底层保证了512字节的写入原子性,上层是不需要关心的。否则上层就得考虑这个问题,方法可以通过在WAL日志中加入checksum解决。通过checksum能判断宕机重启后Log Block是否完整。如果不完整,就可以丢弃这个Log Block,对WAL日志文件来说就是做截断操作。
除了WAL日志写入有原子性问题,实体数据写入得原子性问题更大。一个Page有16KB,往磁盘上刷盘,如果刷到一半系统宕机再重启,请问这个Page是什么状态?在这种情况下,Page就是一个被损坏的Page。但是,既然有了WAL日志,不能用WAL恢复这个Page吗?答案:不能。
因为WAL也恢复不了。因为MySQL的Redo Log是Physiological Logging,里面只是一个对Page的修改的逻辑操作,Redo Log记录了哪些地方修改了,但是不知道哪个地方损坏了。另外,即使为了这个Page加了Page的checksum,也只能判断Page是否损坏,但无法修复,只能丢弃,有两个解决办法:
让硬件支持16KB写入的原子性。要不写入0个字节,要不就16KB写入全部成功(512能被16整除 512 / 16 = 32)
双写(Double Write)。 把16KB写入到一个临时的磁盘位置,写入成功后再拷贝到目标磁盘位置。这样,即使目标磁盘位置的16KB因为宕机被损坏了,还可以用临时位置的备份去恢复。当然,如果临时16KB损坏了,那索性就不管了,权当没成功。
Redo Log Block的结构
Log Block需要有checksum字段,另外海域一些头部字段。事务可大可小,可能一个Block可以存储好几个事务的操作记录,有可能一个事务的操作记录占用好几个Block。所以在Block里面,得有字段记录这种offset。
以下展示了Block得结构,header部分有12字节,尾部checksum有4个字节。所以一个Block的payload只能存496字节的操作记录数据。
|---Block 1---|---Block 2---|---Block 3---|---Block 4---|---Block N---|
header部分四个字段:
事务,LSN与Log Block的关系
了解了Redo Log的结构,下面从一个事务的commit开始分析,看事务和对应的Redo Log之间的关联关系。假设一个事务代码如下:
其产生的日志,我们如下:
SQL层乃至应用层所说的事务都是逻辑事务,具体的底层实现是“物理事务”,也叫做Mini Transaction(Mtr)。在逻辑层面,事务是三条SQL语句,设计两张表A,B。在物理层面,该事务可能是修改了两个Page,Page 1和Page 2,也可能更多Page,每个Page的修改对应一个Mtr。每个Mtr生产一部分日志,生成一个LSN。
由上示意图,这个逻辑事务产生了两段日志和两个LSN。分别存储到了Block 1和Block 2里,这两段日志可能是连续的,也可能不是连续的(中间插入的可能是其他事务的日志,因为事务并发顺序写日志文件)。所以在实际上,一个逻辑事务对应的日志大概率是不连续的,但一个Mtr对应的日志是连续的,不然就不会由LSN了。
下面展示下两个逻辑事务在磁盘的Redo Log文件中的位置结构,LSN单调递增:
每个一个事务都会有一个唯一的ID,是一个单调递增的整数,每个事务都会关联一个自己的LSN链表,这样就可以通过逻辑的LSN链表拿到这个事务在Redo Log中的文件偏移读取并整合事务的操作日志。记住一点,LSN就是一个Mtr在某个Page上的操作记录数据在Redo Log文件中的字节偏移数。
事务的回滚与崩溃恢复
到这里,不得不提到IBM他们搞出来的经典论文,ARIES,前几节提到过,涉及到事务的RollBack与Recovery。
未提交的事务的日志也在Redo Log中
通过之前的分析,可以看到不同事务的日志在Redo Log中是交叉存在的,这意味着未提交(uncommited)事务也在Redo Log中。因为日志是交叉存在的,没有办法把已提交的(commited)事务的日志和未提交(uncommited)事务的人分开,或者说前者刷到磁盘的Redo Log上,后者没刷。从上面交叉存在的例子中,比如事务A提交了,但是事务B还没有提交,但是事务A中间夹杂了事务B的LSN 382,LSN 382肯定会被连带刷到Redo Log的磁盘上,也就是事务B已经被刷了一部分了。
所以这个是ARIES算法的一个关键点,不管事务有没有提交,其日志都会被记录到Redo Log上。当崩溃后再恢复的时候会把Redo Log重放一遍,提交的事务和未提交的事务都被重放了,从而让数据库恢复到了宕机那一瞬间的状态,这叫做Repeating History。
重放完成后,再把宕机之前未完成的事务找出来。这个就有问题,怎么把宕机之前未完成的事务全部找出来?那么就需要检查点(Check Point)的辅助了,把未完成的事务找出来后,逐一利用Undo Log回滚事务。
把RollBack 转化成 Commit
回滚是把未提交的事务的Redo Log删除吗? 显然不是,Redo Log是Append only file,记录了就记录了,不应该修改,因为是流式的,有点像BTC的UTXO模型一样的叠加记录。这里是用了一个巧妙的方法,把回滚转化为提交。CQRS的存储层也是类似的思想。
举个例子,Client提交了Rollback,数据库并没有更改之前的数据,而是以相反的方向顺序做反向操作,生成对应的反向操作语句,然后Commit。这是逻辑层面上的回滚,不是物理层的。
把上面逻辑事务语句反向转化为以下commit为回滚事务:
以上转化是一个正常的逻辑事务回滚场景,机器正常运行,没有宕机,但是如果宕机,也是一样的,如果宕机,事务执行了一半,在重启和回滚的时候,也并不是删除之前的部分,而是以相反的操作把这个事务补齐对应的那一部分,然后commit。
如果只执行了2条语句,那么生成后的commit事务为:
这样一来,事务的回滚就简化了些,不需要修改之前的数据,也不需要改Redo Log。相当于没有了“回滚”,全部都是commit。对于Redo Log来说,就是不断低顺序写append。这种逆向操作的SQL语句对应到Redo Log里面叫做Compensation Log Record(CLR),会和正常操作的SQL的Log区分开。
ARIES恢复算法
本质就是处理未commit的事务(在进行中的事务)当在宕机的时候,处理这些事务的回滚的算法。
该算法分为3个阶段:
分析阶段要解决两个核心问题。
第一,确定哪些数据页是脏页,为阶段2的Redo做准备(确定哪些事务被提交,但是未被刷盘)。发生宕机时,虽然被提交的事务已经提交了,但是只是Redo Log在磁盘上,其对应的数据Page是否已经刷到磁盘不得而知。如何找出从CheckPoint到crash之前,所有未被刷盘的Page呢?
第二, 确定哪些事务未提交,为阶段3的Undo做准备。未提交的事务的日志也写入了Redo Log,但是只是事务的一部分日志在Redo Log中,因为Redo Log也不是随时都在刷盘的。如何判断这些未提交的事务,然后对其进行回滚呢?
这就要谈到ARIES的CheckPoint机制。CheckPoint是每个一段时间对内存中的数据拍一个“快照”,或者说把内存中的数据“一次性”地刷到磁盘上。但实际做不到!因为在把内存中所有的脏页往磁盘上刷的时候,数据库可能还在不断地接受客户端的请求,这些脏页一直在更新。除非把系统阻塞住,不再接受请求,这时内存中的Redo Log也不再增长,然后一次性地把所有脏页刷到磁盘中,这叫Sharp Checkpoint。
但是Sharp CheckPoint的应用场景很窄,因为这降低了系统的可用性,系统不应该停下来,所以用的更多的是Fuzzy CheckPoint,具体怎么做?
在内存中,维护了两个关键的表:活跃事务表和脏页表。
活跃事务表示当前所有未提交事务的集合,每个事务维护了一个关键变量lastLSN,是该事务产生的日志中最后一条日志的LSN。(两个字段:tx_id 和 lastLSN)。
脏页表示当前所有未刷盘到磁盘上的Page的集合(包括已提交和未提交事务),recoveryLSN是导致该Page为脏页的最早的LSN。比如一个Page本来是clean的(内存和磁盘上数据一致),然后事务1修改了它,对应的LSN是LSN1。之后事务2,事务3又修改了它,对应的LSN分别是LSN2,LSN3,这里的recoveryLSN取的就是LSN1(两个字段:page_no和recoveryLSN)
所谓的Fuzzy Checkpoint,就是对这两个关键表做了一个Checkpoint,而不是对数据本身做Checkpoint。这点非常重要,因为Page本身很多,数据量大,做快照刷盘压力大,但这两张表记录的全是ID,数据量小,容易做快照。
所以,每一次Fuzzy Checkpoint,就把两个表的数据生成一个快照,形成一条Checkpoint日志,记入Redo Log。
基于这两个关键表的Checkpoint,可以求两个问题了:
找到最近一次(lastest)在Redo Log中的Checkpoint,查看这个检查点在哪些事务的区间内(通过活跃事务表拿到),先暂时把这些事务初始化纳入到一个未提交的事务集合A中,从检查点开始,遍历Redo Log到末尾(也就是crash点)。
在遍历的过程中,如果有遇到某个事务的结束符,那么就将这个事务从从集合A中删除,如果遇到某个事务的开始符,就把这个事务加入到集合A中,最终直到末尾。
找到最近一次(latest)在Redo Log中的Checkpoint,查看这个检查点有哪些脏页(通过脏页表拿到),把脏页表的page项初始化到脏页集合B中,从检查点开始遍历到Redo Log末尾,一旦遇到Redo Log操作的是新Page(集合B中找不到),就把它加入脏页集合,注意了,这样的遍历算法,只会让集合B只增不减,这是与之前的集合A不一样的地方,当然可能有一种特例,就是一些脏页其实已经不是脏页了,但是暂时认为它是脏页也没关系,因为Redo Log是核心是不可变数据结构的重放,是幂等的。
假设之前在分析阶段得到的脏页集合是集合B,那么在这个集合B中,可能都是真的脏页,也有可能是已经刷盘的脏页,不过都没关系,取集合中所有脏页的recoveryLSN的最小值min(recoveryLSN,....),得到firstLSN。从firstLSN遍历Redo Log到末尾,把每条Redo Log对应的Page全部重新刷一次磁盘。
关键如何做到幂等?磁盘上的每一个Page有一个关键字段:pageLSN。这个LSN记录的是这个Page刷盘时最后一次修改它的日志对应的LSN。如果重放日志时,日志的LSN < pageLSN, 则不修改日志对应的Page,略过此条日志。
比如某个Page被多个事务先后修改了三次,在Redo Log的时间线上,分别对应LSN为600, 900, 1000。当前内存中,该Page的pageLSN就是1000,因为还没有来得及刷盘,所以磁盘中的该Page的pageLSN是900。现在,宕机重启,所以从firstLSN,也就是LSN=600的地方开始重放,从磁盘读取出来的LSN为600, 900,都小于等于900,所以600 对应的两一条日志被略过,900对应的修改就会重做一遍,作用到该Page上。
这点与TCP接收端对数据包的判重差不多,对发送的数据包从小到大有一个编号(seq number),接收方一方发现收到的编号比之前的还小,就skip掉。
Redo完成后,就保证了所有的脏页都成功地写入到了磁盘,干净页也有可能重新写一次,这点是没关系的。
在阶段1的时候,已经找到了未提交事务的集合A。从最后一条日志逆向遍历,因为每条日志都有一个prevLSN字段,所以可以沿着每个事务各自的日志链一直回溯,最终到某个事务的第一条日志
所谓的Undo,是指每遇到一条属于某事务的Log,就生成一条逆向的SQL语句来执行,其执行对应的Redo Log是之前提到的CLR(早已经被写入进Redo Log)。
要生成逆向的SQL语句,需要记录对应的历史版本数据。这点稍后说明。
Redo的起点位置与Undo的起点位置没有必然的先后关系,因为Redo的起点对应的是所有脏页的最小LSN,Undo对应的是所有未提交事务的起始LSN,两者不是一个概念。
在进行Undo操作的时候,还可能会遇到一个问题,回滚到一半,宕机重启,需要再回滚,要进行“回滚的回滚”。
假设,要回滚一个未提交的事务T,其有三条日志LSN,分别是600, 900, 1000.第一次重启宕机,首先对LSN=1000进行回滚,比如生成对应的LSN=1200的日志,这条日志里会有一个字段叫UndoNxtLSN,记录的是其对应的被回滚的日志的前一条日志(LSN=900)。这样当再一次宕机重启时,遇到LSN=1200的CLR,首先会忽略这条日志,然后看到UndoNxtLSN=900, 会直接定位到LSN=900的日志,为其生成对应的CLR日志LSN=1600,然后继续回滚, LSN=1700的CLR日志,回滚的是LSN=600.
这样以来,不管出现几次宕机,重启后最终都能保证回滚日志和之前的日志一一对应,不会出现“回滚嵌套”问题。
到此为止,已经对事务的原子性(A)和持久性(D)有了一个大概的了解。后面会讨论I的实现,再次对Redo Log做一个总结:
Undo Log
Undo Log 是否一定需要
说到Undo Log,很多人想到的只是“事务回滚”。事务回滚有四种场景:
对于这四种场景,ARIES算法都兼顾到了,并没有区别,除了ARIES算法,是否还有其他的方法可以做事务回滚,或者说,Undo Log真的需要吗?
回滚就是取消已经执行的操作,无论是物理上还是逻辑上,达到目的即可。假设Page的数据都在内存里,每个事务执行,都只在内存中修改数据,必须等到事务Commit之后,再写Redo Log,再把Page数据刷盘。在这种策略下,不需要Undo Log也可以实现,这种策略直接用了内存断电消失的特性,磁盘上存储的肯定都是已提交的数据,宕机重启,内存中还未完成的事务自然一笔勾销了。简而言之就是,未提交的事务不会进入Redo Log,全部在内存里!InnoDB的是未提交的事务也会进入Redo Log。注意,这两种策略完全不一样!好像后者这样的策略更简单无脑啊,为什么基本不常用呢?
把这个展开就是4种Page刷盘的策略:
先来说下Force, No Force, Steal , No Steal的意思
Force是指已经提交的事务必须强制写入磁盘,No force是指已经提交的事务可以保留在内存里,暂时不用写入磁盘。Steal是指未提交的事务也能写入,如果事务需要回滚,再更改磁盘上的数据。No Steal是指未提交的事务不能写入磁盘,只能在内存中操作,等到事务提交完,再一次性写入磁盘。
Force + No Steal。这种策略是最简单的,直接连Log(Redo + Undo)都不需要了,仅仅依靠内存这个特性就可以实现原子性和持久性。但是这显然不可行,未提交的事务不能写入磁盘,暂时还可以勉强接受,已提交的事务必须强制写入磁盘,fsync会耗费可观的性能,所以才有了Redo Log,即WAL这样的思想,WAL可不止是为事务服务的,其中一点就是服务于IO性能。
No Force + No Steal。这种策略也还行,已经提交的事务可以不立即写入磁盘,未提交的事务只能保留在内存中,这样只需要Redo Log就可以保证事务的AD属性
Force + Steal。已提交的事务立即写入磁盘,未提交的事务也立即写入磁盘。这样需要Undo Log回滚宕机时未提交的事务,不需要Redo Log。
No Force + Steal。 这样的策略是业内主流的做法,是InnoDB的策略。就是已经提交的事务可以不立即写入磁盘,未提交的事务既可以立即写入磁盘,也可以延迟写入磁盘,写磁盘的时机任意,可以通过配置来配置,比如这个配置innodb_flush_log_at_trx_commit
策略四非常灵活,配置不得当的话,可能会造成事务丢失,在这种策略下,为了实现事务的AD属性,才有了如此复杂的Redo Log和Undo Log机制,也才有了ARIES算法,一切都是为了工业界的可行。
Undo Log之MVCC
Undo Log除了事务的回滚,还有两个核心作用:
在传统的多线程中,并发读写的策略一般是3种:
以上三种策略并发度越来越高,InnoDB就是采用了CoW的思想,是在Undo Log里实现的。每个事务修改记录之前,都会先把该记录拷贝一份出来,拷贝出来的这个备份就存在Undo Log里。因为事务又唯一的编号ID,ID从小到大递增,每修改一次,就是一个新的版本,类似git历史,因此Undo Log维护了数据的从旧到新的每一个版本,每个版本之间的记录通过链表串联。
也正是因为每条记录都有多个版本,才比较容易实现事务ACID中的I隔离性,事务要并发,多个事务要读写同一条记录,为了实现隔离级别。就不能让事务读取到正在修改的数据,只能读取历史版本。
也正是因为有了MVCC这种特性,通常select语句都是不加锁的,读取的全是数据的历史版本,支撑高并发查询。这种读,专业叫“快照读”(snapshot read),与之对应的是“当前读”
以下是快照读与当前读对应的SQL语句:
Undo Log不是Log
了解了Undo Log之后,接下来进一步看Undo Log的结构。其实Undo Log比较有误导性,严格来说它不是Log,而是数据。为什么?
日志通常是append操作,Undo Log其实不像Redo Log那样一样按照LSN编号,从小到大以此执行append操作顺序写入。Undo Log没有顺序,多个事务是并行地向Undo Log中随机写入的
一个事务一旦commit之后,数据就“固定”了,固定了之后不可能再回滚。这意味着Undo Log只在事务Commit过程中有用,一旦事务commit了,就可以删除Undo Log。
对于insert记录,没有历史版本数据,因此insert的Undo Log只记录了该记录的主键ID,当事务commit后,该Undo Log就可以删除了。
对于update,delete记录,因为MVCC的存在,其历史版本数据可能还被当前未提交的其他事务所引用,一旦未提交的事务提交了,其对应的Undo Log就可以删除了。
而对于update delete,由MVCC控制,所以Undo Log是类似于通过指针串联起来的last data -> ver 3 data -> ver 2 data -> ver 1 data。 每个节点记录对应的修改事务的tx id。别说,真是大道归一,区块链,git版本控制,DDD中的event sourcing都有这样的思想,只是解决的问题不在一个层面而已。
Undo Log与Redo Log的联系
Undo Log本身也要写入磁盘,但一个事务修改多条记录,会产生多条Undo Log,不可能同步写入磁盘,所以遇到了开始说到WAL时的问题。如何解决Undo Log需要多次写入磁盘的性能问题?
Redo Log记录的是对数据的修改,凡是对数据的修改,都必须记入Redo Log。可能把Undo Log当作数据,在内存中记录Undo Log,异步刷盘,宕机重启时,用Redo Log恢复Undo Log。
比如用一个事务区间来举例:
那么把Undo Log和Redo Log加进去,大概是这样写的日志:
在以上实例中,所有Undo Log和Redo Log的写入都可以只写在内存中进行,只要保证commit语句之后Redo Log一定落盘即可,Undo Log可以一直保留在内存中,之后异步刷盘。
各种锁
MVCC解决了快照读和写之间的问题,但是对于写和写之间,当前读和写之间的并发,MVCC就无能为力了,这时就需要用到锁。
在MySQL的官方文档中,介绍了InnoDB的7种锁:
这种分类方法比较迷惑,因为他们不是正交的,比如记录锁可能是共享锁也可能是排它锁。间隙锁也可能是共享锁或者排它锁。
共享锁和排他锁是读写锁的另一种说法,共享锁即“读锁”,读读之间并发。排他锁就是“写锁”,读写和写写不能并发。InnoDB通常加锁的粒度是行,所以有对应的行共享锁,行排他锁,但是有些场景会在表的粒度加锁,比如DDL语句(ALTER, DROP语句什么的)
有了之前提到的两把锁,为什么还有意向锁呢?假设事务A给表中的某一行记录加了一把行级排他锁,现在事务B由于DDL语句要给整张表加表级排他锁,事务B要怎么处理?显然事务B加锁不会成功,因为表中的某一行正在被事务A修改中,事务B一定要做出这个判断,它需要遍历表中的每一行,看是否某行被加了行级排他锁,只要发现有行级排他锁,就意味着整个表加了表排他锁。
但是!这样判断的效率太低了,无法实用,而意向锁就是为了解决这个锁判断效率问题产生的,意向锁是专门加在表上,在行上没有意向锁,意向锁实际上是表(S,X锁)和行锁(S,X锁)之间的桥梁,通过意向锁加快表和行之间的判断。
举个例子:事务A要给某张表加入一个意向S锁,就暗示接下来要给表中的某一行加S锁。事务A要给某张表加入一个意向X锁,就暗示接下来要给表中某一行加X锁。反过来说就是,一个事务要给表中的某一行加S锁,那么它首先必须先获得这张表的IS锁。行X锁同理!
所以,IX锁,IS锁之间都不互斥,可以并发获得。IX锁,IS锁只是和表级的S和X锁互斥。
它是一种表级锁,专门针对AUTO_INCREMENT的列。为什么需要这种类型的锁?先看以下事务:
想象一下一上事务处于并发状态,假设表table1中某一列是自增的,连续insert两条记录,再select出来,在同一个事务内自增的一列取值应该是连续的,比如第一次insert,自增列的取值是6,第二次insert应该是7.如果不加入自增锁,另外的并发事务可能会在这两条insert中间插入一条记录,那么第二次的insert可能就是8,select出来是8当然明显不对,不符合AUTO_INCREMENT原则。
间隙锁,其实应该叫范围锁,它只是锁一个区间范围,不包括记录本身,一个开区间,目的是避免另一个事务在这个区间上插入新的记录
临键锁是间隙锁和记录锁的综合,不仅锁记录,也锁记录之间的范围
插入意向锁,也是一种间隙锁专门针对INSERT操作。多个事务在同一索引,同一个范围区间内可以并发插入,也就是该锁之间不互斥
间隙锁的各种算法非常复杂,是否加锁和事务隔离级别密切相关,所以可能要借助分析工具来查看。
BinLog与主从复制
BinLog与Redo Log的主要差异
在MySQL中,Redo Log是记录事务的执行日志,Binlog也记录日志,但是它们两要非常大的差别。首先MySQL是支持多存储引擎的数据库,InnoDB只是其中一种(最重要的一种)。Redo Log和Undo Log都是存储引擎里面实现的机制,但是Binlog却是MySQL层独有的机制。
不同于Redo 和Undo Log用来实现事务,Binlog的主要用途就是做主从复制,如果是单机,没有主从复制,可以不打开Binlog。当然,在互联网应用中,Binlog有了第二个用途:一个应用进程把自己伪装成Slave,监听Master的Binlog,然后把数据库的变更以消息的形式推送出来,业务系统可以消费消息,执行对应的业务逻辑,比如更新缓存什么的,或者同步数据到ElasticSearch之类的异构数据库中。阿里开源的Canal和国外的Databus都是这样的中间件。
与Redo Log一样,Binlog也有自己的刷盘策略,由参数
sync_binlog
控制,0就是事务提交后不主动刷盘,依靠操作系统自身的刷盘机制可能会丢失数据。1是每提交一个事务,刷一次盘。n是每提交n个事务刷一次盘。显然0和n都不安全。为了不丢失数据,一般都建议双1保证,就是sync_binlog和innodb_flush_log_at_trx_commit的值都取为1。当然这个东西也看业务类型,看你数据的重要程度,有一些数据丢失一部分也可以,金融相关的数据就不能这样做了。
从上表可以看出来,Binlog比Redo Log要简单的多,在不宕机的情况下,未提交的事务,回滚事务都不会写入Binlog。(宕机情况后面再分析)
同时,Binlog的日志是连续线性串行的,这样会造成一个问题,Binlog全局只有一份,任何表的事务都会排序进入这里,每个事务都需要串行写入,这里性能上有问题。因此在,MySQL 5.6的Group Commit之前,各类第三方都在优化这个问题。Group Commit的思路也简单,虽然从逻辑上看Binlog必须串行地写入,但是不需要提交一个事务刷一次盘,而是把事务的提交和刷盘放到不同的线程里,刷盘时可以对多个已经提交的事务同时刷盘,串行还是串行,但是批量化了。
内部的“分布式事务”(内部XA) - BinLog与Redo Log的一致性问题
一个事务既要写Binlog,也要写Redo Log,如何保证两份日志文件的数据的原子性?这涉及到一个双文件的双写一致性问题,一个写成功,写另一个的时候发生宕机,重启如何处理?
在讨论这个问题之前,先说一下Binlog自身写入的原子性问题:Binlog刷盘到一半,还没有返回,出现宕机了。这个问题和之前的Redo Log的写入原子性是同样的问题,都是写文件,都是通过类似于Checksum的办法,或者BinLog中有结束标记,来判断出这是部分的,不完整的BinLog,把最后一段截断掉。对于数据库的Client来说,此时宕机,事务肯定是没有成功提交的,所以截掉也没有问题(先写日志,再写数据)。
那这两个不同的日志数据如何保证一致性呢?这个问题有点类似分布式事务,分布式事务无非就是在不同的机器节点上写不同的文件,现在只是这不同的文件在一个机器上了,可以几乎近似于分布式的事务的问题。都会产生不一致。所以如果熟悉分布式事务提交原理的,应该就知道两阶段提交(2 Phase Commit),对,这里也是用2PC。
下面就详细说一下这里的2PC的具体细节做法:
第一阶段(Prepare阶段):InnoDB的Prepare,是把事务在提交之前,对应的Redo Log和Undo Log全部都写入(可能已经落盘了,也可能没有)。Binlog也已经写入到内存,只等刷盘。其实就是MySQL Server层发Prepare消息给InnoDB,通知它写WAL,InnoDB返回OK,然后Server继续在内存中写入Binlog返回OK,等待刷盘
第二阶段(Commit阶段):先刷盘Binlog,然后让InnoDB执行commit。
2PC一个显著特点就是,分段完成任务,当然3PC也是一样的。在Prepare阶段就把99%的工作做完了,就等最后一阶段的收尾,所以在第二阶段只要不宕机,就可以提交成功,但是如果发生宕机,如何恢复?
既然要维护Binlog和Redo Log这两个数据文件的一致性,那么从逻辑上看,肯定要以其中一个数据文件为基准,也就是向谁“看齐”的问题。
从以上的内部XA来看,在这里,显然是以Binlog为基准,让Redo Log向Binlog靠拢,也就是让Redo Log跟Binlog保持一致:
在第一阶段,此时Binlog全部在内存中,宕机就掉电消失了,当作啥也没发生。Redo Log肯定是要记录未提交的日志,但是不用担心,它重启的时候会回滚未提交的日志。
在第二阶段宕机,Binlog写了一半(刷盘刷了一半),InnoDB此时的Commit直接还没有来得及执行。对Binlog做截断(有结束标记),对Redo Log做回滚,与上面的方法差不多。
同样在第二阶段宕机,Binlog写完了,也就是fsync返回了!这时候宕机,InnoDB未提交。此时遍历Binlog,找出Binlog中存在,但是InnoDB中不存在的事务,再对InnoDB发起某个事务的提交操作。
在第二阶段宕机,InnoDB发起提交,但是没有完全返回,这个之前在WAL的时候说过,也可以截断。
三种主从复制方式
MySQL支持三种主从复制方式:
同步复制。等待所有Slave都接收到Binlog,并且接收完,Master才认为事务提交成功,再对Client返回。这样最安全,但是在主流场景下,性能无法忍受,一般不会用
异步复制。只要Master事务提交成功,就对Client返回成功。后台线程异步地把Binlog同步给Slave,然后Slave重放Binlog。性能最快,但是可能丢数据。丢的场景很多,比如,如果Master宕机,那么在Master上已经提交的事务并没有通过Binlog传到Slave上,如果此时,强行将Slave切换为Master,那么可能导致新的Master上的数据不是最新的。如果不主从切换,等待Master重启完成,那么这段时间,业务不能进行,数据无法写入,系统不可用。
半同步复制。Master提交事务,同时可以把Binlog同步给Slave,只要一部分Slave接收到了Binlog,就认为事务提交成功,Client返回。业内用这个用的比较多。
这样可以看出,似乎半同步机制是最好的,确实是,但是它是不是就不丢数据了呢?不是的,半同步复制为了系统的可用性,有可能会退化成异步复制。因为Master不可能无限等待Slave,当超过某个时间,Slave还没有回复ACK时,Master就会切换为异步复制。参数rpl_semi_sync_master_wait_slave_count可以设置在半同步模式下,需要等待几个Slave的ACK,才认为事务提交成功。默认是1。
所以这样来看,除了同步复制,都可能在主从切换的时候丢数据,数据Commit了,但是在新的Master上找不到了!业务上一般的做法是牺牲一致性来换取可用性,就是用半同步复制将就着用吧,在主从切换的时候,忍受少量的数据丢失(最好是同机房),后续人工介入修复。
但是如果主从复制的延迟太大,切换到Slave,丢失数据太多,也难以接受。为了降低主从复制的延迟,MySQL的并行复制应运而生,这个技术在跨机房的情况下,尤其必要!
如果还想深入,可以参考一个数据库内核从业人员的文章,怎么保证主从一致,当然,有点标题党了,实质是Master必须进行“双一”设置,从库非“双一”:《MySQL如何在非“双一”时保证数据不丢失?》。 当然,根据评论,MySQL在MGR模式(MySQL 5.7.17)下是可以做到Master不用“双一”,MGR就是解决异步和半同步复制丢失数据的问题的,这是MySQL的一个整体高可用方案。
注意:以上提到的都是MySQL非MGR模式下的。
并行复制
并行复制是为了尽可能降低主从复制延迟,传统的主从复制原理大概分两个阶段:
阶段1: 把Master中的Binlog传输到Slave上,形成RelayLog。在这个传输过程中,Master和Slave两边各有一个线程,在Master上叫dump thread,Slave上叫I/O thread。
阶段2:Slave把RelayLog回放到数据库,通过一个叫做SQL thread的线程执行
可见传统的复制过程,无论Log的传输还是回放过程都是单线程的。而并行复制,就是把回放阶段并行化了,传输过程还是单线程的,为什么传输不用多线程?多线程传输还要重新排序和重组,复杂度高,可能得不偿失。
所以,所谓的并行复制不如说并行回放,其难点在于事务的并行提交。Binlog本身是全局一份的,同一个MySQL的实例,不同库,不同表的事务都在Binlog里面串行排列,并行回放就是一次性地从RelayLog中拿出多个事务,并行执行。这就涉及到了什么样的事务可以并行,什么不可以。就是检查事务有没有修改相同的数据。这里非常复杂。比如有commit_id的概念,id相同意味着可以并发,不同也不意味着不可以并发。
这里复杂,不多说了。
EOF