从事务出发学习InnoDB
零、前言
提起数据库基础,首先上来就是数据库事务ACID、隔离级别,理论吹得溜溜的,那么我们撸起SQL来宛如一把梭的MySQL是怎么实现ACID的呢?我们一起总结学习一下。
一、从事务出发
首先还是回滚一下数据库事务的四大基本要素,原子性、持久性、隔离性、一致性。
-
原子性,比较容易理解,就是一个事务中的操作要么全部成功要么全部失败。
一个事务必须被视为一个不可分割的最小工作单元,整个事务中的所有操作要么全部提交成功,要么全部失败回滚,对于一个事务来说,不可能只执行其中的一部分操作,这就是事务的原子性。
-
一致性,是四要素中比较难说清楚的,书上的解释是
数据库总是从一个一致性状态转换到另外一个一致性状态。
在事务开始之前和事务结束以后,数据库的完整性约束没有被破坏。
这个解释太抽象了,听起来好像很有道理,但是要自己解释出来,又说不出个所以然。然后网上很多对一致性的解释都是用转账作为栗子,张三减100块,李四加100块,张三和李四账户的总金额不变,张三减了100之后,李四没加100之前的中间状态对外不可见,可是这不是原子性和隔离性吗?
其实这事儿还得从完整性约束说起,数据的完整性约束分为四类:
- 实体完整性:表的每一行在表中是唯一的实体,对应唯一性约束。
- 域完整性:表中的列必须满足某种特定的数据类型约束,对应类型约束。
- 参照完整性:指两个表的主关键字和外关键字的数据应一致,对应外键约束。
- 用户定义的完整性:不同的系统根据应用环境不同,往往还需要一些特殊的约束条件,比如上面讲的转账的例子,就是转账前后两个账户的总金额应该是不变的。一般可以通过断言来实现,但是mysql不支持断言,需要程序员在应用层来保障,这可能也是一致性让人觉得疑惑的原因之一吧
-
隔离性,是指多个事务之间的读写隔离,也可以理解为对事务的并发控制,在不同的隔离级别下,隔离的程度不太相同。
一个事务所做的修改在最终提交前,对其他事务是不可见的。
-
持久性,比较容易理解,就是不会丢数据。
一旦事务提交,则其所做的修改就会永久保存到数据库中。此时即使系统崩溃,修改的数据也不会丢失。
二、InnoDB的持久性
2.1.单机数据库
回顾了事务的四要素,那么InnoDB到底是怎么实现的呢?
我们首先来看事务的持久性是怎么实现的,在介绍持久性之前,我们先来看一看InnoDB存储引擎的整体结构,InnoDB存储引擎主要分为三个部分:后台线程、内存池、物理文件。
从下往上,首先是物理文件,InnoDB引擎主要包括表空间文件和重做日志(redo log)两种文件,表空间文件主要包含表结构、表数据。接下来是内存池,内存池主要包括缓冲池和重做日志缓冲,其中缓冲池中以页的形式保存了热点的数据和索引。
InnoDB是索引组织表,主键索引和二级索引的数据结构都是B+树,在文件读写时,都是以页为最小读写单元,即读取一行数据是读取行所在的页,修改一行数据也是修改行所在的页。
在读场景下,优先从内存缓冲池查找页,如果页在缓冲池中,直接使用缓冲池中的页;如果页不在缓冲池中,则将页从文件中加载到缓冲池中,再使用缓冲池中的页。
在写场景下,如果页在内存缓冲池中,则直接修改缓冲池中的页;如果页不在缓冲池中,则将页从文件中加载到缓冲池中,再修改缓冲池中的页。
接下来就是重点了,如何实现持久性的呢?
简单的思路是在事务提交时,把事务中修改过的页,将内存中的页数据写入到磁盘文件中,如果都写入成功,那么事务就提交成功了;否则事务就不能成功。
MySQL没有采用这个方案,因为如果涉及到的页比较多时,每一页数据的写入都是一次随机写,性能会非常差。
那么InnoDB实际是怎么实现的呢?
首先是对缓冲池中的页修改之后,会记录一条重做日志到重做日志缓冲中,重做日志中记录了对页的变更操作。
然后在事务提交时,将内存中的重做日志缓冲刷到磁盘的重做日志文件,但并不会将内存缓冲池中修改过的页刷到磁盘的文件中。
这样在事务提交时,就只有一次顺序写日志文件的磁盘操作了,相对于多次随机写,性能大大提升,这种先写事务日志的方案叫做预写式日志 WAL(write-ahead logging)。
这里我们可以看到,事务提交后,重做日志已经持久化到磁盘了,但数据页的变更还只存在于内存中。这个时候如果突然断电了,页上的(a3,b3)这一条变更就丢了,但是不用担心,因为我们提前已经把变更记录在重做日志中了,所以是可以恢复的,恢复流程如下:
逐条扫描redo log
读取redo log对应的页到缓冲池中
如果redo log的lsn> page的lsn,说明redo log中记录的变更还没来得及持久化到磁盘上,则对页执行一次redo log记录的操作
总结一下,InnoDB通过redo log实现了预写式日志,保障持久性。到这里,持久化最核心的点就讲完了,但还有一些坑没填完,就继续掰扯掰扯。事务提交后,没有持久化到磁盘的数据页被称为脏页,那么这些脏页会在什么时候被刷到磁盘上呢?
- 后台线程以每秒一次的频率刷到磁盘上,每次刷多少脏页,有一套逻辑计算,简单理解就是闲时多刷、忙时少刷。
- 内存缓冲池空间不够用了,按照LRU算法,将一部分最近最少使用的页刷到磁盘上,以释放一部分可用的内存空间,加载其他页。
- redo log文件空间不够用了,redo log文件是循环使用的,如果redo log中记录的页变更,对应的页已经刷到磁盘了,那么这一段redo log文件就是可以被复用的了。所以这个时候会从checkpoint开始顺序扫描redo log,把对应的脏页刷到磁盘中。
关于持久化还要结合文件系统说一下,文件系统写入两个操作:
- 第一个是write,写入到文件系统的page cache中,具体什么时候刷入磁盘,由文件系统决定,响应很快,本质上还是写内存
- 第二个是fsync,强制将文件系统的page cache刷入磁盘,响应较慢,涉及磁盘的写入
根据两种操作的差异,redo log提供了三种不同的redo log写入策略:
- innodb_flush_log_at_trx_commit=0,事务提交时,不调用write也不调用fsync,事务提交后redo log留在redo log buffer中,由每秒一次的后台线程负责刷磁盘。响应较快,数据库挂了就会丢数据。
- innodb_flush_log_at_trx_commit=1,事务提交时,调用write也调用fsync,事务提交后redo log已经刷入磁盘。响应较慢,事务只要提交成功,就不会丢数据。
- innodb_flush_log_at_trx_commit=2,事务提交时,调用write不调用fsync,事务提交后redo log写入文件系统的page cache中,什么时候刷磁盘由文件系统决定。响应较快,数据库挂了不会丢数据,操作系统挂了会丢数据。
策略1由于响应时间和策略3相当,但是持久性更差,一般都不使用。策略2速度慢一点,但是不会丢数据,策略3速度快一点,可能会丢数据;由于持久性是很重要的特性,一般都是选择策略2,但是部分时候结合业务特性,也可以选择策略3。(附:据说有的RAID技术,磁盘有独立电池,可以保证掉电情况下,文件系统page cache中的数据也能刷入磁盘)
2.1.主从数据库
前面说的都是一个数据库内部怎么实现持久性,保证不丢数据,但在生产环境中,为了提高可用性,一般都是采用主从部署。正常情况下,客户端读写主库(先不考虑读写分离),主库和从库之间通过binlog同步数据;当主库出现异常的时候,可以通过主从切换,将原从库切换成新主库,客户端读写新主库。
主库和从库之间通过binlog同步数据,前面讲的事务流程中没有考虑binlog,如果事务提交成功,后续异步写binlog失败,则会导致依赖于binlog同步数据的从库丢失数据,所以当启用binlog时,binlog的写入也是需要在事务中管理的。
那么应该怎么保证redo log和binlog都写入成功呢?答案是分布式事务的两阶段提交,两阶段提交协议在这里不扩展开来说了,简单的说就是给了所有事务的参与者在commit之前一个说OK的机会,所有事物的参与者先prepare,如果都prepare成功,则都进行commit,如果有参与者commit失败,则rollback其他事物参与者。具体到redo log和binlog,mysql的实现方式是将redo log拆分为prepare和commit两种状态:
- 先写prepare状态的redo log
- 再写binlog
- 最后写commit状态的redo log
如果有疑问,为什么要用两阶段提交,直接写两个日志不行吗?那我们就来假设看一看:
先写redo log后写binlog,在写完redo log之后,写入binlog之前,MySQL进程crash;主库重启恢复的时候通过redo log可以恢复出这个事务,从库由于没有binlog,则没有这个事务的数据。
先写binlog后写redo log,在写完binlog之后,写入redo log之前,MySQL进程crash;主库重启恢复的时候通过redo log不能恢复出这个事务,从库接受到binlog,可以回放出这个事务。
那么在两阶段提交的过程中,如果MySQL进程crash,会出现什么情况呢?崩溃恢复时的判断规则如下:
- 如果redo log里面的事务是完整的,也就是已经有了commit状态的redo log,则直接提交
- 如果redo log里面的事务只有完整的prepare,则判断对应的事务binlog是否存在且完整:
a. 如果是,则提交事务;
b. 否则,回滚事务。
那么在两阶段提交的过程中,如果在时刻A crash,此时binlog还没写入,redo log还没提交,在这个时候crash,事务会回滚。在时刻B crash,此时binlog已经写入,redo log还没提交,在这个时候crash,事务会自动提交。
到这里,两阶段提交已经保证了事务提交时redo log和binlog都要写入成功,但是其实还有一个问题,binlog写入成功,不等于从库接受到binlog了,有可能binlog写入成功了,但是还没来得及发送出去,MySQL进程crash了,这个时候主库重启恢复后有数据,但是从库没有接收到binlog,还是没有数据。为了解决这个问题,MySQL引入了半同步复制(semi-sync),半同步复制做了这样的设计:
- 事务提交的时候,主库把binlog发送给从库;
- 从库接受到binlog以后,发回给主库一个ack,表示接受到了binlog;
- 主库接受不了到这个ack以后,才能给客户端返回"事务完成"的确认。
最后总结一下,在主从复制的分布式环境中,为了避免丢数据,MySQL采用了两阶段提交的方式保证redo log和binlog都写入成功,半同步复制可以解决binlog写入成功但是来不及发送就crash的问题。
三、InnoDB的原子性
接下来是原子性,原子性需要保证事务的操作全部成功或者全部失败,所以重点在于将部分成功部分失败变成全部失败,也就是回滚。InnoDB通过undo log实现回滚,当事务中有对数据的增删改时,就会产生对应的回滚日志,回滚日志中记录了操作的类型,变更的字段,变更之前的值。当需要回滚时,可以通过回滚日志中记录的信息,产生一个与原操作互补的操作,从而完成对数据的回滚。
undo log存储在共享表空间,有专门的回滚段存储。undo log所在的页增删改时也会产生相应的redo log,从而保障undo log不会因为断电丢失。
四、InnoDB的隔离性
原子性讲的比较简单,隔离性可就复杂了,有不同的隔离级别,不同隔离级别下又需要解决不同的问题。
我们还是先从问题聊起吧,在并发处理事务时,可能会遇到以下几个问题:脏读、不可重复读、幻读;和四种隔离级别:未提交读、提交读、可重复读、可串行的关系:
4.1.脏读
脏读比较容易理解,是指未提交事务的变更对其他事务可见,脏读只会在未提交读隔离级别下出现。
解决脏读的办法,就是在事务进行过程中,对于修改的数据加X锁,在事务提交或者回滚时才会释放锁(这个用时加锁,提交时释放的协议叫做两阶段锁);其他事务,在读取数据时会加S锁。对于正在进行中的事务,在事务提交前持有修改过的数据的X锁;其他事务想要访问这些数据,需要加S锁,需要等待事务提交之后才能查询到数据,这样在提交前,其他事务就查询不到这些变更的数据了,也就解决了脏读的问题。
4.2.不可重复读是怎么解决的?
不可重复读是指事务执行两次相同的查询,由于其他事务在两次查询间修改了数据,导致两次读出来的结果不一样。不可重复读会在未提交读、提交读两种级别下出现,在可重复读级别下不会出现。
MySQL在可重复读级别下,解决不可重复读的办法是在事务启动时,创建数据一致性视图(逻辑上相当于对全库的数据做了一个快照),后续事务中的查询都是基于一致性视图读取,这样也就不会查询到事务启动后提交的数据变更了,保证了多次查询结果的相同。
那么,这个视图是怎么实现的呢?答案是基于undo log的多版本并发控制(MVCC)。
首先是InnoDB每个事务都有一个唯一的事务ID,叫作transaction id。它是在事务开始的时候向InnoDB的事务系统申请的,是按申请顺序严格递增的。
其次是每行数据也是有多个版本的,每行数据有一列隐藏trx_id,记录的就是最后一次事务更新的transaction id。在更新时,新的数据会记录到行中,trx_id会更新为当前事务的transaction id;同时旧的行也会保留,trx_id为上一次更新的transaction id,但是旧的行并不是逻辑存在的,而是由新的行结合undo log计算出来的。下图的V4是当前最新的行,U1、U2、U3是undo log,通过V4结合U3、U2、U1可以计算出版本V3、V2、V1的数据行。
最后,有了多版本的行记录,再来想一下前面的快照。按照可重复的定义,一个事务启动的时候,能够看到所有已经提交的事务结果。但是之后,这个事务执行期间,其他事务的更新对它不可见。因此,一个事务只需要在启动的时候声明,"以启动的时刻为准,如果一个数据版本是在启动之间就生成的,就可以使用;如果是在启动以后才生成的,就不能使用,必须要找到上一个版本,如果上一个版本也不可见,那么就需要继续找"。
实际上,InnoDB为每个事务构造了一个数组,用来保存这个事务启动瞬间,当前所有启动未提交的事务ID。其中,低水位是事务中的最小值,高水位是当前系统中已经创建的事务ID的最大值+1,一致性视图其实就是多版本行记录结合当前事务启动时的活跃事务数据和高低水位,通过数据可见性规则计算得到。可见性规则:
- 低于低水位,说明在创建视图时,事务已经提交,数据是可见的;
- 高于高水位,说明在创建视图时,事务还未开始,数据是不可见的;
- 低于高水位,但不在活跃事务数组中的,说明在创建视图前,事务已经提交,数据是可见的;
- 低于高水位,并在活跃事务数组中的,说明在创建视图时,事务未提交,数据是不可见的;
总结一下,可重复读级别,在事务启动时,创建包含活跃事务ID和高低水位的一致性视图;在查询时,结合多版本行数据,通过可见性规则计算出当前事务可见的行数据,从而解决不可重复读的问题。
问题1、读一致性视图需要加锁吗?
不需要,快照意味着数据已经是历史,已经不可变,从而也实现了非锁定一致性读,即当前行虽然被其他事务加了X锁,但是由于当前事务是快照读,所以并不会被阻塞。
问题2、
4.3.幻读
幻读的定义有的文章中比较模糊,容易和不可重复读搞混,幻读是指某个事务在读取某个范围内的记录时,另外一个事务又在该范围内插入了新的记录,当之前的事务再次读取该范围的记录时,会产生幻行。幻读会在可重复读级别下发生,当然MySQL在可重复读级别下也处理了幻读的问题。
幻读相对于不可重复读有几个特征:1.范围查询;2.插入操作;3.当前读,当前读和快照读相对,访问最新的数据行即为当前读,select … lock in share mode、select … for update、update …、delete …都是当前读。幻读可以理解为特殊场景下的不可重复读,那么幻读会造成什么问题呢,以至于需要单独被拎出来讨论。
这里我们看一个例子,新建一个由id,c,d三个字段的表,插入6行数据,并按时序执行SQL。
CREATE TABLE `t` (
`id` int(11) NOT NULL,
`c` int(11) DEFAULT NULL,
`d` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `c` (`c`)
) ENGINE=InnoDB;
insert into t values(0,0,0),(5,5,5),
(10,10,10),(15,15,15),(20,20,20),(25,25,25);
先假定加锁时,只会锁定满足条件的行,即session A在T1时刻只会锁定id=5的行,
- 在T1时刻,id=5这一行由(5,5,5)变成(5,5,100),这个结果在T6时刻提交;
- 在T2时刻,id=0这一行由(0,0,0)变成(0,0,5),再变成(0,5,5);
- 在T4时刻,表中插入一行(1,1,5),再变成(1,5,5);
然后我们看一下binlog的执行序列
- 在T2时刻,session B事务提交,写入了两条语句;
- 在T4时刻,session C事务提交,写入了两条语句;
- 在T6时刻,session A事务提交,写入一条语句;
update t set d=5 where id=0; /*(0,0,5)*/
update t set c=5 where id=0; /*(0,5,5)*/
insert into t values(1,1,5); /*(1,1,5)*/
update t set c=5 where id=1; /*(1,5,5)*/
update t set d=100 where d=5;/* 所有 d=5 的行,d 改成 100*/
这个语句序列,在从库执行会使得三行由主库的(5,5,100),(0,5,5),(1,5,5)都变成了(5,5,100),(0,5,100),(1,5,100),会导致数据不一致,那么这个不一致是怎么引入的呢?
是由于我们假设select * from t where d=5 for update
只锁定d=5,也就是id=5这一行导致的。
所以,我们修改假设,把扫描过程中的行锁定,这里由于d=5没有索引,也就是会将所有行都锁定,那么再看看执行效果。
由于session A给所有行都上了写锁,所以session B在执行第一行update的时候就被blocked住了,需要等到T6时刻session A提交以后,session B才能继续执行。
对于id=0这一行,在数据库里的最终结果还是(0,5,5)。在binlog里面执行序列如下,
insert into t values(1,1,5); /*(1,1,5)*/
update t set c=5 where id=1; /*(1,5,5)*/
update t set d=100 where d=5;/* 所有 d=5 的行,d 改成 100*/
update t set d=5 where id=0; /*(0,0,5)*/
update t set c=5 where id=0; /*(0,5,5)*/
可以看到,按照日志顺序执行id=0这一行的最终结果还是(0,5,5),所以id=0的这一行的问题就解决了。但是,可以看到id=1,这一行数据库中还是(1,5,5),而根据binlog的执行结果是(1,5,100),也就是说幻读的问题还是存在。
我们明明已经将所有的行都加锁了,仍然阻止不了新插入的记录,这也是幻读被单独拎出来解决的原因。
现在我们知道了,幻读产生的原因是,行锁只能锁住行,但是插入记录这个东西,是更新记录之间的间隙。所以,为了解决幻读问题,InnoDB引入了新的锁,间隙锁。
例如,刚刚的例子,在主键索引上有0,5,10,15,20,25六行记录,对应的就有7个间隙。如果我们在执行select * from t where d=5 for update
不仅会锁定6行记录,还会锁定7个间隙,阻止向间隙插入记录的动作,这样session C在插入时也会被blocked,就解决了幻读的问题。
这里需要说明一下,跟行锁有冲突的是另外一个行锁;但是间隙锁不一样,跟间隙锁有冲突的往间隙插入记录这个动作,间隙锁和间隙锁之间不冲突。
在MySQL中,加锁实际是以行锁+间隙锁组成的next-key lock为单位,所以例子中实际是加了(-∞,0],(0,5],(5,10],(10,15],(15,20],(20,25],(25,+supermum]七个next-key lock。
总结一下,InnoDB新增了间隙锁,通过锁住记录中的间隙阻止向间隙插入记录的动作,从而解决幻读问题。但是需要注意的是幻读是在当前读下才会发生的问题,如果想要提前锁住间隙,保障后续操作的执行,需要使用当前读语句select … for update 或者 select … lock in share mode,直接使用select 是快照读,不会加锁。
四、InnoDB的一致性
一致性研究不深,简单说一说,一致性主要主要落在完整性约束上,根据完整性约束的四个方面:
- 实体完整性:唯一索引、主键索引,实现唯一性约束
- 域完整性:字段类型约束、长度约束、精度约束、非空约束、值约束(枚举)
- 参照完整性:外键约束
- 用户自定义约束:不支持断言,可以使用触发器模拟实现简单的自定义约束
在实际实现中,一致性往往更多的需要应用层的保证,帮助数据库分担压力。
五、参考资料
《极客时间 MySQL实战45讲 林晓斌》
《MySQL技术内幕 InnoDB存储引擎》
《高性能MySQL》