更多优质内容
请关注公众号

MySQL怎么运行的系列(九)事务隔离级别和MVCC原理-阿沛IT博客

正文内容

MySQL怎么运行的系列(九)事务隔离级别和MVCC原理

栏目:数据库 系列:mysql怎么运行的 发布时间:2022-09-04 16:52 浏览量:2171

一、事务的隔离级别

为了保证事务与事务之间的修改操作不会互相影响,innodb希望不同的事务是隔离的执行的,互不干扰。

两个并发的事务在执行过程中有 读读、读写(一个事务在读某条数据的同时另一个事务在写这条数据)、写读 和 写写 这4种情况。

读读(相同的数据)的并发并不会带来一致性问题,而后面三种情况的并发则可能带来一致性问题。

隔离的本质就是让多个事务对相同数据的访问在 读写、写读和写写的情况下,对其排队串行执行,比如事务A修改了行1但没提交,事务B在修改行1时就会被阻塞,这通常是通过加锁实现的。

当然,在实际的数据库应用中,读写和写读不一定非要串行执行,而是可以通过MVCC来实现不同事务对同一条记录的读和写是并发进行的。

需要注意:这里的排队串行是指写相同数据的时候才需要,如果是对不同数据行的写,是可以并发执行的。

下面我们讨论,事务并发执行会遇到哪些一致性问题。


并发执行的一致性问题

脏写:如果一个事务成功的修改了另一个未提交事务所修改过的数据,就是脏写。

脏读:如果一个事务成功的读到了另一个未提交事务所修改过的数据,就是脏读。

不可重复读:如果一个事务A修改了另一个事务B读取的数据(B读先发生,A写后发生),事务B第二次再读这个数据的时候发现这个数据变了,就发生了不可重复读。

幻读:如果事务A根据某条件读到了一些记录(此时事务A未提交),事务B修改了一些符合这个条件的记录(insert、update或delete),下次A再读这些记录发现结果和上次不同,就发生了幻读。

幻读和不可重复读的相同点都是两次读到的数据不同,区别是幻读强调两次读取(我们称为前读和后读)的结果集合的行数不同,不可重复读强调读取同一条记录的内容不同。

在mysql中,幻读强调的是事务的后读,读到了前读所没有读到的行,这些多出来的行可能是其他事务insert或者update产生的,多出来的这些记录称为幻影记录;不可重复读强调无法读到前读读到的行,这些消失掉的行可能是由其他事务delete或update造成的,使得后读无法复现这些行。

此外对于当前读而言,避免不可重复读和幻读的方式不同,解决不可重复读使用记录锁锁住指定行即可,解决幻读要用间隙锁和临键锁锁住行与间隙。(对于快照读,避免不可重复读和幻读都是使用MVCC)。如果对这两段话的一些名词看不懂的同学,可以先忽略,后面的章节还会再介绍这些锁机制。

一致性问题的严重性:脏写>脏读>不可重复读>幻读。

为此SQL指定了几种隔离级别



事务的四个隔离级别,级别从低到高为

读未提交【read uncommitted】(会出现脏读、不可重复读和幻读的问题)

读已提交【read committed】(会出现不可重复读和幻读)

可重复读【repeatable read】(会出现幻读)

串行化【serializable】


隔离级别越高,安全性越高,但是性能越低。Mysql事务的默认级别是可重复读,而oracle的事务是读已提交的级别。由于读未提交的数据安全得不到保证,而串行化这个级别下并发度低,所以大多数数据库的隔离级别都是读已提交或可重复读这两种。

串行化的隔离级别(serializable)不代表事务和事务之间是串行的(如果是的话就变成表锁了),而是指事务和事务之间如果涉及到对同一行数据的写和读需要串行。但串行化级别的两个事务对不同行的写读和写写还是可以并发的。


例如在串行化的隔离级别下,如果事务A对行1进行了update但不提交,事务B对行1进行select会阻塞,只有当A提交了事务,B才能查询成功,而且查到的是A的已提交数据;反过来A先对行1select但不提交,B事务再对行1进行update也会被阻塞。

serializable的写读和读写之所以会串行,是因为serialzable在事务A读行1的时候会对行1上读锁(而且这个读锁在commit或者rollback时才会释放),事务A提交之前,事务B对行1进行select会由于锁没有释放而阻塞。

而 可重复读和读已提交 在读的时候无需加任何锁,而是通过MVCC实现读和写并发。


这也是 serializable 和 可重复读/读已提交 的区别之一:前者的读写/写读是串行,后者的读写/写读是并发的。

当然,无论哪种隔离级别,写都是要加写锁的,因此任何隔离级别的写写都是串行的,无法并发,但也是因为这样才避免了脏写的发生。



二、MVCC原理

MVCC(多版本并发控制 ) 设计出来的目的是为了在不加锁的情况下,解决脏读和不可重复读的问题,从而一定程度提高 读写 和 写读 的并发。

MVCC的实现依赖于记录undo日志过程中,针对聚簇索引的行构建出来的版本链 和 事务查询时创建的 ReadView(一致性视图)


版本链

从上一节undo日志的知识中我们知道:一次事务中,每次对某个聚簇索引的行进行改动的时候,行的旧版本会被写入到undo日志,本次事务的事务被写入到当前行的trx_id隐藏列,undo日志的地址会被写入到行的roll_pointer隐藏列中。

这样一来,多个事务对一个记录的多次修改所产生的undo日志就会形成一个版本链,如图所示:




版本链的头结点就是B+树页面的记录。

对于串行化隔离级别的事务,innodb采用加锁的方式来读和写(串行),因此根本不会出现脏读和不可重复读,无需用到版本链。

对于读未提交 read uncommit 隔离级别,它允许出现脏读和不可重复读,所以可以当事务A写的同时,事务B可以直接读取版本链的头结点的数据,也就是最新版本的行,从而实现A和B并发读写。

对于 读已提交 隔离级别,需要保证读的是已提交的数据。

对于 可重复读 隔离级别,需要保证如果事务A在事务B提交前开始的,那么事务A读的是在A内的行1数据,即使B对行1的已提交,A也不能读到B的已提交数据,这样才能实现可重复读。

很明显,如果 读已提交 和 可重复读 隔离级别要完成自己的隔离目标 又要要求 读写并发,光靠版本链还是不够的,还需要借助一致性视图。



一致性视图 ReadView

一致性视图(有些书叫做“快照”,所以从一致性视图读取数据又叫快照读) ReadView 是在事务进行过程中产生的,可以和版本链结合共同实现MVCC。每一个事务在初次尝试做读取操作时,会生成一个属于该事务自己的ReadView,ReadView是一个由下面4个重要内容组成的信息集合:

m_ids:在生成本 ReadView 时,当前系统未提交的读写事务的事务id列表。

min_trx_id:在生成本 ReadView 时,当前系统未提交的读写事务中的最小事务id,即 m_ids的最小值。

max_trx_id:在生成本 ReadView 时,下一个未来事务的事务id。

creator_trx_id:生成本ReadView的事务id。


需要提示一点:任何事务开启后,执行DML语句(增删改语句)前,该事务的id都是0,只有事务执行了第一条DML语句,才会被分配事务id。

假如当前系统有 1、2、3 这3个事务,并且事务3已经提交了,事务1和2还没提交。此时开启了新事务4,事务4的事务id是0,事务4读取记录时生成的ReadView,m_ids就是1和2,min_trx_id是1,max_trx_id是4。


ReadView 如何配合 版本链 完成MVCC

根据 ReadView 判断 版本链的某个版本对本事务是否可见是实现MVCC的关键。

现在我们只关注一行数据,从这行数据的版本链的头结点(最新版本)开始作为当前版本。

1、如果 当前版本的 trx_id(不是当前事务的 trx_id,别搞混了) == ReadView.creator_trx_id,说明当前事务在访问自己修改过的记录,该版本可以被访问。

2、如果 当前版本的 trx_id >= ReadView.max_trx_id,说明当前事务访问的当前版本是在当前开启之后,又有新事务开启并修改了该行而产生的版本,因此该版本对当前事务不可见,不可访问。

3、如果 当前版本的 trx_id < ReadView.min_trx_id,说明当前事务访问的当前版本是以前已提交的事务更改所生成的版本,该版本可以被访问。

4、如果 当前版本的 trx_id 在 [min_trx_id, max_trx_id]之间,则需要判断 当前版本的trx_id是否命中 m_ids列表的 trx_id,如果命中,说明当前版本是未提交事务对行1更改而产生的版本,该版本不可访问;否则,可以访问。


如果某个版本对当前事务不可见(不可访问),则顺着版本链找到下一个更早的版本,并继续执行上面的流程,直到找到可见的版本 或者 到达版本链最早的一个版本都没有找到可见的版本才结束。如果是最后一个版本都不可见,说明查询结果不包含该记录。

读已提交 和 可重复读 隔离级别之间非常大的区别就是它们生成 ReadView 的时机不同 。读已提交会在每次读取数据前都生成一个ReadView,可重复读在第一次读取数据时生成一个ReadView。


栗子:假设现在表 hero 中只有一条由事务id 为 80 的事务插入的记录:



现在系统中有2个事务id为100和200的事务正在执行。



此时有个使用 READ COMMITED 隔离级别的新事务trx_id=300开始执行一条select:

# 使用read commited隔离级别的事务
begin;

#select1:transaction 100,200未提交
select * from hero where number=1;


在 select 语句执行前,系统就会生成一个 ReadView,m_ids是[100,200],max_trx_id=201,creator_trx_id = 0。

根据上面的规则,该事务只能读到 刘备 这条记录。


之后把 事务id = 100 的事务提交,再在 事务id = 200 的事务中修改行1:

# transaction 200
begin;
update hero set name=’赵云’ where number = 1;
update hero set name=’诸葛亮’ where number = 1; 


事务300又执行一条select 行1的操作,事务300就会再生成一个 ReadView覆盖之前的ReadView,由于它的 m_ids 为 [200]。所以按照规则,会查询到 张飞 这条数据。



我们模拟上面一模一样的场景,但换成可重复读的隔离级别,那么可重复读的事务300只会在第一次select 时生成一个 ReadView,m_ids 是 [100, 200],min_trx_id=100, creator_trx_id = 0。

之后不会再生成新的ReadView,因此第一次select 查到刘备,事务100提交后,事务300第二次select 查到的还是刘备,因为 m_ids 还是[100, 200]。

所以能做到 重复读 就是因为可重复读隔离级别的一个事务只生成一次 ReadView。



二级索引和MVCC

如果某个查询语句的查询字段只有二级索引,那么系统只会读取二级索引的页的记录,不会回表去读取聚簇索引的页记录。但是,版本链的头结点在聚簇索引中,不在二级索引中,通过二级索引的记录无法直接找到版本链。在这种情况下如何使用MVCC?

二级索引页的头部有一个 page_max_trx_id 表示修改过该页的最大事务id。

执行select时命中该页,如果 ReadView 的 min_trx_id 比该页的 page_max_trx_id 大,说明这个二级索引页修改的事务已经提交,该页的所有记录对本事务的本次查询可见。

否则,就要对“在二级索引页找到的匹配条件的记录”进行回表操作,在聚簇索引对应的记录中按照之前所说的规则找到可见版本。


考虑一种情况,如果原本有3条满足 where key1 = "a" 的二级索引记录(id分别是 1、2、3),事务1将id=1的"a"改成了"b",将一条id=4的 "c" 改成了"a",并且事务1没有提交。此时新事务查询 where key1 = "a" 应该查询到 id为 1、2、3这三条记录。问题在于id为1的行已经把索引 "a"改成"b"了,innodb怎么能根据 key1="a" 这个条件拿到 id=1 进行回表呢?

其实前面介绍undo日志时说过这种情况,如果update语句修改的是主键或者索引字段,会先在页中假删除对应的主键值或索引值的记录(记录的deleted_flag字段置为1,但不会放到垃圾链表中),再按照新值在对应页中新增一条记录。

由于是假删除,所以新事务其实仍然可以在页中找到 被未提交事务删除或更改的那条"a"记录。之所以假删除就是考虑到MVCC。



Purge

insert undo日志在事务提交后可以释放掉,而update undo日志需要为MVCC服务,因此不能立刻删除掉。

在介绍undo日志那一节的时候我们知道一个事务会产生至少1组undo日志(insert undo一组,update undo一组),1组undo日志对应一条undo页链表。

当事务提交后,update undo链表的头结点会被添加到一个History链表的头部,通过history链表就能找到提交事务后还未释放的undo日志链表,正式因为undo日志链表没有被释放,因此版本链中的undo日志行才会继续存在。

history链表(的基节点)存放在回滚段中,一个回滚段存放一个history链表。


问题来了:update undo日志是否会永远存在,一直不释放?如果释放,那么释放的时机是什么?答案肯定是要释放的,不然不断增长的undo日志会占用很多磁盘空间。

我们假设系统目前只有事务id为 1、2、 3、 4、 5这五个事务,隔离级别为 repeatable read。trx1修改了行1并提交,2~5在1提交之后依次同时开启,2修改了行1并提交后,3 修改了行1不提交,5修改行1不提交,4查询行1。

此时 行1 的版本链应该是 trx5(最新版本,放在数据页)->trx3(undo日志)->trx2(undo日志)->trx1(undo日志)。

由于trx2 和 trx1都已经提交,trx4生成的ReadView的m_ids列表是 [3, 5],max_trx_id = 6。

明显,trx1对应的undo日志可以删除回收,因为trx4生成的ReadView可见的最晚的版本是 trx2的版本。


所以结论是:

系统中仍处于活跃状态的最早的那个ReadView不再访问的那些update undo日志可以回收。

什么是活跃状态的ReadView?

例如,对于 read committed 级别,事务1执行了第一次查询,会生成一个ReadView1,只要该事务不执行第二次查询,那么ReadView1就是活跃的ReadView;如果执行第二次查询,就会生成ReadView2覆盖ReadView1。此时ReadView1就不是活跃的ReadView,ReadView2才是。

对于 repeatable read  级别,事务1执行了第一次查询,会生成一个ReadView1,执行第二次查询不会生成新的ReadView,因此ReadView1就是活跃的ReadView。


回到正题,一个事务提交时,会为其生成一个名为事务no的值来表示事务提交的顺序(事务id则是事务开启的顺序),事务no会记录到undo链表的头结点。已提交的undo日志组的链表头结点就是按照事务提交的顺序放入到history链表的。Readview也会包含一个事务no属性,在创建的ReadView时候会保存当前系统最大的事务no+1给这个属性。

系统中所有活跃的ReadView会按照创建时间连成一个链表。

purge线程做的事情就是遍历所有history链表的所有undo链表头结点的事务no 与 活跃的最早的ReadView的事务no对比,如果一组undo日志的事务no小于当前系统最早的活跃ReadView的事务no,就可以将这组undo日志从History链表移除并释放这些undo页的占用空间。并将这些undo日志对应的deleted_flag为1但仍在页内正常记录链表的记录移到垃圾链表中。


注意:如果某个事务是 repeatable read  级别,该事务会一直复用最初产生的ReadView,如果这个事务运行很久都没有commit,则该ReadView会一直处于活跃状态,系统中很早的update undo日志和打了删除标签的记录会越来越多不会被释放,导致表空间越来越大,版本链越来越长,影响性能。




更多内容请关注微信公众号
zbpblog微信公众号

如果您需要转载,可以点击下方按钮可以进行复制粘贴;本站博客文章为原创,请转载时注明以下信息

张柏沛IT技术博客 > MySQL怎么运行的系列(九)事务隔离级别和MVCC原理

热门推荐
推荐新闻