在数据库系统中,事务是指由一系列数据库操作组成的一个完整的逻辑过程,事务的基本特性是ACID:
A : Atomicity (原子性)
C: Consistency (一致性)
I: Isolation (隔离性)
D: 持久性(Durability)
由于大部分数据库都是高并行发的,即允许多个事务同时对数据进行读写和修改,隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致。不同的隔离级别代表了数据库在性能和可靠性之间的取舍。那么MySQL是如何保证事务基本特性ACID中的I(Isolation 隔离性)?答案就是InnoDB的多版本并发控制(Multi-Version Concurrency Control MVCC)。
目录
一、事务隔离级别简介
1.1 读未提交(read uncommitted)
1.2 读已提交(read committed)
1.3 可重复读(repeatable read)
1.4 串行化(Serializable)
二、MVCC的概念
三、MVCC实现原理
3.1 undo日志
3.2 版本链
3.3 Readview(读视图)
3.4 ReadView判断示例
3.5 不同隔离级别下ReadView的生成策略
MVCC主要作用就是实现事务的隔离级别,这里先解释一下事务隔离级别及各中隔离级别存在的问题。
SQL标准规定了4种事务隔离级别,分别是:
会话发起的select语句可以读取其他会话事务还"未提交"的数据,这种现象我们称为"脏读",脏读会带来很多问题,一般不会采用这种隔离级别。
会话发起的select语句可以读取其他会话事务还"已提交"的数据,这种情况读取的数据都是其他会话已经提交的,通常不会有什么大问题,Oracle数据库的默认事务隔离级别就是 read committed。
Read Committed隔离级别会导致两个现象:
不可重复读与幻读的主要区别在于,不可重复读是数据本身的变化,而幻读是记录数量的变化。
这个是MySQL InnoDB存储引擎的默认隔离级别,在读已提交的隔离级别基础上解决了"不可重复读"问题,但是依然会有"幻读"的问题。
所有事务按照串行的方式运行,避免了并行访问数据,由于效率极低,生产环境一般不会使用这种隔离级别。
最常见到的就是"读已提交"和"可重复读"两个隔离级别,另外两个隔离级由于太极端基本不会使用。
四种隔离级别和脏读、不可重复读、幻读的关系总结如下:
脏读 |
不可重复读 |
幻读 |
|
读未提交(Read uncommitted) |
是 |
是 |
是 |
读已提交(read committed) |
否 |
是 |
是 |
可重复读(repeatable read) |
否 |
否 |
是 |
串行化(Serializable) |
否 |
否 |
否 |
注:表中标红的部分特别解释一下,MySQL的默认隔离级别repeatable read,在标准的ANSI隔离级别定义中, repeatable read解决了"不可重复读"的问题,但是是允许"幻读"的。但是MySQL的实现略有不同,在MySQL的repeatable read隔离级别中,通过间隙锁的方式"基本"解决了幻读问题(并没有完全解决,某些情况下还会出现幻读)。
多版本并发控制(Multi-Version Concurrency Control MVCC)的主要作用是解决读和写的冲突,实现事务隔离,每次修改记录时都会存储这条记录被修改之前的版本,修改之前的内容会记录在undo日志中。如果此时有用户要读取改记录,就可以通过undo日志重构修改前的数据,从而保证读一致性(undo日志还可以用来回滚事务)。
而多次修改的版本之间串联起来就形成了一条版本链,可以保证不同时刻启动的事务可以获得不同版本的数据(MVCC根据控制事务可以看到的数据版本实现了"读已提交"和"可重复读"两个隔离级别)。
MySQL在我们插入数据时,实际为每行数据新增了3个隐藏字段:
在MVCC的实现中,主要用到db_trx_id和db_roll_pointer两个隐藏字段。
undo log,即回滚日志,它记录了数据更新前的信息,可以用来重构更新前的数据,也是MVCC版本链实现的基础。除了通过MVCC实现一致性读,undo log还会用来回滚事务。
undo log分为两类:
对于update undo log,如果事务长时间不提交。为了维持版本链,那么undo日志就无法释放,可能导致逐渐累积过大。这就是为什么事务仅仅查询也会影响性能的原因。因此推荐定期的提交事务,释放相关资源。
MySQL在更新记录时,每次都在undo日志中记录下更新前的原记录,并通过db_roll_pointer连接起来,当多次更新后,便形成了一个版本链,假设下列场景:
对于一张表MVCC_Chain,有id和value两个字段:
事务 ID 是递增分配的,越晚申请的事务ID越大,中间的事务ID假设被其他事务占用了。
那么形成的版本链应该如下:
版本链中共存在4个版本的数据(通过db_roll_pointer串联起来,每次更新都指向上一个版本的数据):
每行前面的时间代表SQL执行时间,只是为了方便理解。
有了版本链,MySQL执行select语句时如何判断应该读取链中的哪条数据呢?答案是ReadView,ReadView包含了select语句发起时事务的统计信息,通过这些信息结合版本链中的trx_id,就可以判断该select可以读取哪个版本的数据。
ReadView只有select语句才会生成,其主要包含以下4类信息:
生成ReadView后,会从最新的数据版本开始,通过trx_id,判断数据是否可见,判断步骤如下:
如果经过上面4步判断,数据依然不可见,则通过版本链向上追溯一个版本,继续上面4步判断,直到找到可见的数据为止。
可以总结出,ReadView实际上找两类数据:
场景1:假设现在为4:01,我执行了查询select value from mvcc_chain where id=1; 查询事务ID为15,当前数据库中活跃事务为14,15,17(16号事务已提交,所以不在m_ids中),此时生成的ReadView如下,
场景2:假设现在为4:01,我执行select value from mvcc_chain where id=1; 事务ID为17,生成的ReadView如下:
经过上面4步判断,发现trx_id为15的数据对当前会话不可见,那么只能向上追溯一个版本(3:00更新的版本,value值为"CCC",trx_Id为10),继续4步判断:
场景3:假设现在为1:30(数据在1:00插入,且还没有任何更新),我执行了查询select value from mvcc_chain where id=1,查询事务ID为3,生成的ReadView如下:
假设这个事务3一直没提交,到了4:01又执行了查询select value from mvcc_chain where id=1,此时如果重新生成ReadView(ReadView和场景2基本相同,只是这次加入了1:30开始的一个事务3):
上面4步判断和场景2相同,trx_id为15的数据对当前会话不可见,那么只能向上追溯一个版本(3:00更新的版本,value值为"CCC",trx_Id为10),继续4步判断:
同一个事务3,在1:30和4:01发出相同的查询,一次返回了"AAA",一次返回了"CCC"(不可重复读现象),其原因就是在4:01的查询时重新生成了ReadView。
如果4:01的查询依然拿1:30生成的ReadView来判断,那么它能看到的数据依然是"AAA"。虽然此时数据"BBB"(trx_id=5)和"CCC"(trx_id=10)已提交,但是对于第3步条件trx_id(5/10)>=max_trx_id(5),说明它们是在ReadView生成(1:30)后发起的事务(分别是2:00和3:00发起),数据不可见。
在这个场景中,虽然"BBB"和"CCC" 没有查询能查到,但是为了重构"AAA"的数据,它们也是要一直保存在版本链中的,如果事务3一直挂着不提交(MySQL对事务执行时长没有限制),那么会导致版本链越来越长,undo log占用越来越大。这就是为什么需要定期提交事务的原因。
这两种ReadView生成策略即代表了MySQL的两种隔离级别: