首先MVCC是属于InnoDB用来保证事务的一种读取数据的机制,InnoDB读取数据的方式有两种,一种是快照读,一种是当前读,而MVCC就是快照读用来保证事务的机制。
在MySQL读取数据时可以按照是否使用一致性非锁定读来分为快照读和当前读:
1、快照读:InnoDB使用MVCC (Multiversion Concurrency Control)机制来保证被读取到数据的一致性,读取数据时不需要对数据进行加锁,且快照读不会被其他事物阻塞。
2、当前读:也称锁定读(locking read),通过对读取到的数据(索引记录)加锁来保证数据一致性,当前读会对所有扫描到的索引记录进行加锁,无论该记录是否满足WHERE条件都会被加锁。
要了解MVCC,我们首先要知道什么是undo log,undo log是用来保证事务原子性,事务中的所有的增删改操作都需要记录undo日志,是用来做事务回滚的,而undo log中为了区分属于哪种事务,会在undo log记录一个事务ID,同样的,只能增删改才会分配事务ID,如果是普通的查事务,则不会分配事务ID,事务ID本质上就是一个自增的数字,由MySQL全局维护。
现在,我们知道了每在数据库执行一条增删改语句,都会至少产生一条undo日志(update可能会产生多条),那么如果我们对同一条数据执行多次增删改操作,那么这就会形成一个undo日志的版本链。
我们来举个例子:
TX_ID = 10 | TX_ID = 20 |
Begin | |
Begin | |
update user set name = 'zhangs' where id = 1; | |
update user set name = 'lisi' where id = 1; | |
commit | |
update user set name = 'wangw' where id = 1; | |
update user set name = 'shenl' where id = 1; | |
commit |
如图,我们对同一条语句做了以上操作,分别在两个事务中多次进行更新操作,那么这在undo log中就会存储成如下的结构(示意图)
这样就形成了id=1的版本链信息,也是MVCC的基础。
下面我们来说一下InnoDB的事务隔离级别:
隔离级别 | 脏读 | 不可重复读 | 幻读 |
READ UNCOMMITTED | √ | √ | √ |
READ COMMITTED | √ | √ | |
REPEATABLE READ | √ | ||
SERIALIZABLE |
那么InnoDB是怎么实现事务隔离的呢? ----- 就是MVCC
对于隔离级别来说,唯一的区别就是可见性,一个事务操作的数据是否对另外一个事务具有可见性,我们现在有了版本链,所有事务操作的数据我们都可以undo log上找到,但是如何区分哪些记录对当前事务有可见性呢?对此,InnoDB提出了ReadView的概念,ReadView包含了四个属性:
-
m_ids
:表示在生成ReadView
时当前系统中活跃的读写事务的事务id
列表。(活跃的事务表示正在进行操作,未进行commit操作的事务,不活跃的事务表示事务已经进行commit操作) -
min_trx_id
:表示在生成ReadView
时当前系统中活跃的读写事务中最小的事务id
,也就是m_ids
中的最小值。 -
max_trx_id
:表示生成ReadView
时系统中应该分配给下一个事务的id
值(这个值是由InnoDB在系统表空间维护的一个全局变量)。 -
creator_trx_id
:表示生成该ReadView
的事务的事务id
。小贴士: 注意max_trx_id并不是m_ids中的最大值,事务id是递增分配的。比方说现在有id为1,2,3这三个事务,之后id为3的事务提交了。那么一个新的读事务在生成ReadView时,m_ids就包括1和2,min_trx_id的值就是1,max_trx_id的值就是4。
小贴士: 我们前边说过,只有在对表中的记录做改动时(执行INSERT、DELETE、UPDATE这些语句时)才会为事务分配事务id,否则在一个只读事务中的事务id值都默认为0。
有了这个ReadView
,这样在访问某条记录时,只需要按照下边的步骤判断记录的某个版本是否可见:
- 如果被访问版本的
trx_id
属性值与ReadView
中的creator_trx_id
值相同,意味着当前事务在访问它自己修改过的记录,所以该版本可以被当前事务访问。(可见) - 如果被访问版本的
trx_id
属性值小于ReadView
中的min_trx_id
值,表明生成该版本的事务在当前事务生成ReadView
前已经提交,所以该版本可以被当前事务访问。(可见) - 如果被访问版本的
trx_id
属性值大于或等于ReadView
中的max_trx_id
值,表明生成该版本的事务在当前事务生成ReadView
后才开启,所以该版本不可以被当前事务访问。(不可见) - 如果被访问版本的
trx_id
属性值在ReadView
的min_trx_id
和max_trx_id
之间,那就需要判断一下trx_id
属性值是不是在m_ids
列表中,如果在,说明创建ReadView
时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView
时生成该版本的事务已经被提交,该版本可以被访问。(不可见)
现在我们再回过头看MySQL的事务隔离级别:
READ UNCOMMITTED:可以读取到所有活跃和不活跃事务中的数据,这种隔离级别下,所有undo log的记录对当前事务具有可见性。
SERIALIZABLE:其实这采用的是加锁机制保证事务的严格一致性,并没有用到MVCC。
READ COMMITTED:这种隔离级别就运用了ReadView,这个隔离级别的事务会在每一次进行普通读操作时
,生成一个ReadView,我们用一开始的例子说明一下:
假设现在trx_id = 10 和trx_id = 20的事务还是活跃状态,并没有提交,现在又开启了一个读操作,MVCC中,读操作是不会分配trx_id的,统一都是0,那么现在读操作创建的ReadView就是这样的
m_ids | [10,20] |
min_trx_id | 10 |
max_trx_id | 21(姑且认为20是最新的事务,那么系统即将分配的就是21) |
creator_trx_id | 0(引用值) |
此时undo log中的记录是这样的:
按照ReadView可见性的四种情况分析:
第1、2、3、4条记录仅满足第四种情况,即事务ID10和20在min_trx_id和max_trx_id之间,且是活跃状态,所以这四条记录都不对对当前读事务可见,第五条记录满足第二种情况,所以这条记录对当前读事务可见,所以只能select name from user where id = 1 查出来的值就是zs。
还在这个读事务中,我们又进行了一次查询,此时tx_id = 10已经提交了,因为每次读操作时都要新建一个ReadView,所以我们再来看新的ReadView:
m_ids | 20 |
min_trx_id | 20 |
max_trx_id | 21 |
creator_trx_id | 0(引用值) |
此时我们还是通过ReadView判断可见性,第1、2条还是只满足第四种情况,所以不可见,但是此时第三条满足了第二条,所以第三条记录对当前读事务可见,所以查询操作返回的就是lisi。
如上我们可以看出,满足了READ COMMITTED的特性。
REPEATABLE READ:这种隔离级别也运用了ReadView,这个隔离级别的事务会在第一次进行普通读操作时
,生成一个ReadView,我们用一开始的例子说明一下:
m_ids | [10,20] |
min_trx_id | 10 |
max_trx_id | 21(姑且认为20是最新的事务,那么系统即将分配的就是21) |
creator_trx_id | 0(引用值) |
OK,此时跟上边我们分析的一样,还是只能查询到zs的数据,没有区别。
但是第二次查询,就不一样了,因为我们此时的隔离级别是REPEATABLE READ,所以ReadView还是跟第一次一样,所以哪怕此时trx_id = 10 和trx_id = 20的全部都提交了,按照ReadView,我们还是只能看到zs这条数据,因为RV没有变化。
所以我们得出结论,这种方式可以满足REPEATABLE READ。
所以我们总结一下,REPEATABLE READ和READ COMMITTED的区分对于MVCC来说只是ReadView生成的时间不一样。
那么,有小伙伴说了,REPEATABLE READ感觉也可以解决幻读的问题?没毛病,但是还有那么一丢丢的小瑕疵,MySQL官方说,REPEATABLE READ可以很大程度的解决幻读的问题,但是还有一种特殊的情况:
如果此时隔离级别是REPEATABLE READ,第一次查询执行select count(*) from user,此时只有一条zs的数据,此时trx_id = 20又插入了一条id = 2数据,提交了,按理说第二次查询还是只有一条,没毛病,但是如果此时当前这个事务修改了id = 2的这条数据,会有什么效果?
我们前边说了,MVCC只对普通查询操作管用,update、delete和select for update这几种并不在此列,所以ReadView管不到Update,So update可以看见id = 2的这条数据,由于进行了写操作,当前的事务ID由0变更成了21,此时版本链发生了变化:
此时ReadView变成了
m_ids | [10,20] |
min_trx_id | 10 |
max_trx_id | 21(姑且认为20是最新的事务,那么系统即将分配的就是21) |
creator_trx_id | 21(引用值) |
这种情况下,就满足了ReadView第一种,即被访问版本的trx_id
属性值与ReadView
中的creator_trx_id
值相同,这种情况就会出现幻读啦。