本文准备通俗的讲解MySQL的InnoDB存储引擎事务的实现原理。
首先,我们知道事务具有ACID四个特性。也即:原子性,一致性,隔离性,持久性。
这四个性质我们不用干瘪的文字去阐述,我们只需要知道事务保证了一系列的操作要么全部执行,要么一个也不执行,同时一旦事务提交,则其所做的修改会永久保存到数据库即可。
接下来我们一起看看InnoDB怎么实现的事务。
ACD三个特性是通过Redo log(重做日志)和Undo log 实现的。 而隔离性是通过锁来实现的。由于隔离性和锁在之前的文章讲过了。所以本文重点关注Redo log 和Undo log。
一、Redo log
重做日志用来实现事务的持久性,即D特性。它由两部分组成:
①内存中的重做日志缓冲
②重做日志文件
一看有内存和磁盘上的两个对应实体,我们就知道这样做一定是为了效率考虑,因为内存的读写效率要比磁盘读写效率高太多。
Innodb是支持事务的存储引擎,在事务提交时,必须先将该事务的所有日志写入到redo日志文件中,待事务的commit操作完成才算整个事务操作完成。在每次将redo log buffer写入redo log file后,都需要调用一次fsync操作,因为重做日志缓冲只是把内容先写入操作系统的缓冲系统中,并没有确保直接写入到磁盘上,所以必须进行一次fsync操作。因此,磁盘的性能在一定程度上也决定了事务提交的性能。
关于fsync这个操作用户是可以干预的,因为每次提交事务都执行一次fsync,确实影响数据库性能。通过innodb_flush_log_at_trx_commit来控制redo log刷新到磁盘的策略。该参数的默认值为1,表示每次提交事务时都执行一次fsync操作。0则表示事务提交时不进行写入重做日志文件,这个写入操作由master thread进程来完成,master thread每一秒会进行一次重做日志文件的fsync操作。2则表示事务提交时将重做日志写入重做日志文件,但仅写入文件系统的缓存中,并不进行fsync操作。用户可以通过设置0或者2啦提高事务提交的性能,也可以设置1来要求确保redo log是写入文件中的,总之三种方法各有利弊。
还有需要了解的是:
redo log buffer将内存中的log block刷新到磁盘是有一定的规则的:事务提交时(前面已经提到)、当log buffer中有一半的内存空间被使用时、log checkpoint时。
那接下来我们就需要看看redo log file存储的内容到底是什么了。
为了避免大家懵圈,不打算把存储格式一个一个细钻(我也没那实力,哈哈)。我们只需要知道他大致是怎么设计的就行了。这样,我们以后如果自己设计一个类似场景的产品,就完全可以借鉴它的设计思想啦。
好,开始:
在InnoDB存储引擎中,重做日志都是以512字节为单位进行存储的,这意味着重做日志缓存、重做日志文件块都是以块(block)的方式进行保存的,称为重做日志块(redo log block)。每块的大小512字节。由于重做日志块的大小和磁盘扇区大小一样,都是512字节,因此重做日志的写入可以保证原子性,不需要double write技术。
每个重做日志块的内容快除了日志记录本身之外,还由日志块头(log block header)及日志块尾(log block tailer)两部分组成。重做日志头一共占用12字节,重做日志尾占用8字节。这两部分是固定的。故每个重做日志块实际可以存储的大小为492字节(512-12-8),如下图显示重做日志块缓存的结构:
![5679451-b06bb15d6c151b4a.jpg](https://i-blog.csdnimg.cn/blog_migrate/7c85748dbecd8b39ec201e3b92b1da14.jpeg)
在图中标注出来不用太过关注这几个字段的含义,因为他们对理解Redo log实现事务的机制没有太大影响,反而如果关注这些,容易让人看到这些大写字母的变量感到头晕。
ps:这些变量是维护log block状态的一些变量。比如表示log block当前使用量,当前redo block的第一个redo log开始位置等等。举个例子吧:
事务T1的重做日志1占用762字节,事务T2的重做日志占用100字节,。由于每个log block实际只能保存492字节,因此其在log buffer的情况应该如下图所示:
![5679451-8f782e0558dea90d.png](https://i-blog.csdnimg.cn/blog_migrate/3f3ac491055e101fdcbdf0623f206d99.png)
实现这个功能就是靠log block的头部的字段来实现的。好了,这不是我们关注的问题,讲这个只是为了满足大家的好奇心以及对这些变量的初步认识。
重做日志块中出去header和tailer的内容就是具体的redo log了。不同的数据库操作会有对应的重做日志格式。此外,由于InnoDB存储引擎的存储管理是基于页的,故其重做日志格式也是基于页的。虽然有着不同的重做日志格式,但他们有着通用的头部格式,如图:
![5679451-b486340f971d5690.png](https://i-blog.csdnimg.cn/blog_migrate/59348ab0309eb6842b881fb6d6932460.png)
通用的头部格式由一下3部分组成
redo_log_type: 重做日志类型
space:表空间ID
page_no 页的偏移量即页的位置
之后是redo log body ,根据重做日志类型的不同,会有不同的存储内容,例如,对于页上记录的插入和删除操作,分别对应的如图的格式(同样,不要细扣每一个字段的含义,这不是我们要抓的重点):
![5679451-8acf4dae731525b2.png](https://i-blog.csdnimg.cn/blog_migrate/3fd6da8b76fc3e9ee03d7f9412417994.png)
大体上的redo log结构介绍完了。在说从redo log file恢复之前,还要说一个LSN的概念,LSN是Log Sequence Number的缩写,其代表的是日志序列号,在InnoDB存储引擎中,LSN占用8个字节,并且单调递增。
LSN表示事务写入重做日志字节的总量。例如当前重做日志的LSN为1000,有一个事务T1写入了100字节的重做日志,那么LSN就变成1100,若又有事务T2写入200字节的重做日志,那么LSN就变为1300。
LSN不仅记录在重做日志中,还存在每个页中,在每个页的头部,有一个值FIL_PAGE_LSN,记录了该页的LSN,在页中,LSN表示该页最后刷新时LSN的大小。因为重做日志记录的是每个页的日志,因此页中的LSN可以判断页是否需要进行恢复操作。例如,页P1的LSN为10000,而数据库启动时,InnoDB检测到写入重做日志中的LSN为13000,并且事务已经提交,那么数据库需要进行恢复操作。将重做日志应用到P1页中,同样的,对于重做日志中LSN小于P1页的LSN,不需要进行重做,因为P1页中的LSN表示已经被刷新到该位置,在此位置之前的内容已经被成功的处理了。
接下来就是恢复操作了:
InnoDB存储引擎在启动时不管上次数据运行是否正常关闭,都会尝试进行恢复操作,因为重做日志记录的是物理日志(不要纠结这个),因此恢复的速度比逻辑日志,如二进制日志要快的多,于此同时,InnoDB存储引擎自身也对恢复进行了一定程度的优化,如顺序读取及并行应用重做日志,这样可以进一步提高数据库恢复的速度
由于checkpoint表示已经刷新到磁盘页上的LSN,因此在恢复过程中仅需恢复checkpoint开始的日志部分。对于图中的例子,当数据库在checkpoint的LSN为10 000时发生宕机,恢复操作仅恢复LSN 10000~13000范围内的日志。
![5679451-cacc0f122b85dcdc.png](https://i-blog.csdnimg.cn/blog_migrate/50a27a9044ee70ef893a301a97fdee18.png)
物理日志
举个例子,对于Insert操作,物理日志记录的是每个页的变化:
若执行SQL语句:INSERT INTO t SELECT 1,2;
其记录的重做日志大致类似这个样子:page(2,3),offset 32,value 1,2
二、Undo log
第二部分是Undo log,它可以实现如下两个功能:
1.实现事务回滚
2.实现MVCC
undo log和redo log记录物理日志不一样,它是逻辑日志。可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然,当update一条记录时,它记录一条对应相反的update记录。
当执行回滚时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚。有时候应用到行版本控制的时候,也是通过undo log来实现的:当读取的某一行被其他事务锁定时,它可以从undo log中分析出该行记录以前的数据是什么,从而提供该行版本信息,帮助用户实现一致性非锁定读取。我们举一个具体的例子。例子来自此文。
这个例子主要演示事务对某行记录的更新过程:
在演示之前,补充一下:
InnoDB为每行记录都实现了三个隐藏字段,用来实现MVCC:
- 6字节的事务ID(DB_TRX_ID ,每处理一个事务,其值自动+1。
- 7字节的回滚指针(DB_ROLL_PTR),指向写到rollback segment(回滚段)的一条undo log记录。
- 隐藏的ID
1. 初始数据行
![5679451-98d34fe237e0c655.png](https://i-blog.csdnimg.cn/blog_migrate/49ff669d05aab9c61d7cb2e348cd213b.png)
F1~F6是某行列的名字,1~6是其对应的数据。后面三个隐含字段分别对应该行的事务号和回滚指针,假如这条数据是刚INSERT的,可以认为ID为1,其他两个字段为空。
2.事务1更改该行的各字段的值
![5679451-5e99c883192277c2.png](https://i-blog.csdnimg.cn/blog_migrate/ccb905bca034f909c7ccb298e0a698a9.png)
当事务1更改该行的值时,会进行如下操作:
- 用排他锁锁定该行
- 记录redo log
- 把该行修改前的值Copy(可以理解成Copy,不要纠结前面说反向更新这里说复制,原理是一样的)到undo log,即上图中下面的行
- 修改当前行的值,填写事务编号,使回滚指针指向undo log中的修改前的行。
3.事务2修改该行的值
![5679451-33270caba012a5bc.png](https://i-blog.csdnimg.cn/blog_migrate/82a65495c1297b9c7d0545a98dc21e2b.png)
与事务1相同,此时undo log,中有有两行记录,并且通过回滚指针连在一起。
这些通过回滚指针联系起来的行相当于是数据的多个快照,从而实现MVCC的一致性非锁定读了。
具体规则如下:
InnoDB的MVCC,是通过上面我们说的每行纪录后面隐藏的列来实现的。他们保存了行的创建时间和行的过期时间(或删除时间),当然存储的并不是实际的时间值,而是系统版本号。每开始一个新的事务,系统版本号都会自动递增。事务开始时刻的系统版本号会作为事务的版本号,用来和查询到的每行纪录的版本号进行比较。在REPEATABLE READ隔离级别下,MVCC具体的操作如下:
SELECT
InnoDB会根据以下两个条件检查每行纪录:
- InnoDB只查找版本早于当前事务版本的数据行,即,行的系统版本号小于或等于事务的系统版本号,这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的。
- 行的删除版本,要么未定义,要么大于当前事务版本号。这样可以确保事务读取到的行,在事务开始之前未被删除。
只有符合上述两个条件的纪录,才能作为查询结果返回。
INSERT
- InnoDB为插入的每一行保存当前系统版本号作为行版本号。
DELETE
- InnoDB为删除的每一行保存当前系统版本号作为行删除标识。
UPDATE
- InnoDB为插入一行新纪录,保存当前系统版本号作为行版本号,同时,保存当前系统版本号到原来的行作为行删除标识。
读到这里,也许会有一个疑问,考虑如下执行序列:
![5679451-961629618cb774fe.png](https://i-blog.csdnimg.cn/blog_migrate/c3afcb5e740affa0769300af1af1c05f.png)
按照之前的Select规则,会话B 的事务是在 会话A的后面开启的,那么B的事务版本号大于A的事务版本号。这样在A中插入的数据在未提交的情况下,B可以读到A修改的数据,这不就自相矛盾了么?
其实不是,InnoDB通过read view来确定一致性读时的数据库snapshot,InnoDB的read view确定一条记录能否看到,有两条法则 :
1 看不到read view创建时刻以后启动的事务
2 看不到read view创建时活跃的事务
对于Session A,start transaction时并没有创建read view,而是在update语句才创建。所以Session A 的read view创建时间要比Session B的晚。所以B是不会看到A的操作的。因此防止了不可重复读。
两条法则原文描述如下:
Rule 1: When the read view object is created it notes down the smallest transaction identifier that is not yet used as a transaction identifier (trx_sys_t::max_trx_id). The read view calls it the low limit. So the transaction using the read view must not see any transaction with identifier greater than or equal to this low limit.Rule 2: The transaction using the read view must not see a transaction that was active when the read view was created.
补充:如果undo log一直不删除,则会通过当前记录的回滚指针回溯到该行创建时的初始内容,所幸的时在Innodb中存在purge线程,它会查询那些比现在最老的活动事务还早的undo log,并删除它们,从而保证undo log文件不至于无限增长。
关注公众号: “Java不睡觉”, 回复:“资源”。获取大数据全套视频和大量Java书籍