MySql —— MVCC版本链

前言
MVCC版本控制是一种Mysql实现隔离级别的机制,其利用版本链以及对应的undo日志,通过快照读取的方法来控制各个级别的事务所能够读取到的信息。

从事务隔离级别问题来说,MVCC能够解决脏读,不可重复读的问题,但是对于幻读则无能为力,所以这里也会顺带总结一下关于数据库幻读的解决方法,便于读者整理完整的思路。

作者本身对于Mysql理解有诸多不足,如果有表述错误或者不当的地方,请及时指出,非常感谢您的阅读。

隔离级别
Mysql提供四隔离级别,分别用来解决四种事务一致性的问题。

那么问题来了,为什么需要需要隔离级别,为什么使用锁技术来让事务串行执行。

答案很简单,效率。

MySql是一个服务器,那么就必须支持多个连接。因此Mysql会为每一个连接都去维护一个session,每个客户端在该session中进行数据的读写请求,那么必然会同时出现多个事务操作同一条语句的问题。如何保证数据的一致性最简单的方法就是让多个事务串行执行,但是在高并发下,这会导致大量的事务堆积,成本上是无法接受的。

因此,Mysql的设计者提出了隔离级别的概念,也就是说。

Mysql的隔离级别是通过牺牲一部分隔离性来保证事务的并发执行效率

关于四种隔离级别和解决的问题这里不赘述,网上资料大把大把,主要来说在MVCC下的版本链和readView

版本链
在InnoDB中,一张表必须包含两个字段,trx_id和roll_pointer。

trx_id : 事务字段,当一个事务去操作某个行的数据时,会将自己的事务Id赋值给trx_id字段
roll_pointer : 回滚指针,当一个事务更新了一个字段的时候,并不会直接删除掉之前的字段,而是将该指针指向之前的字段存储到undo blog
用一张图来解释一下。

每当事务中更新一条数据时,都会将其添加到undo blog中的,随着更新的次数增多,数据会逐渐被连接成一个链,也就是所说的版本链。

ReadView
实际上,MVCC版本控制主要就是靠版本链与ReadView来维护的。

我们可以将Read View看作一个数组,整个数组的左边界和右边界时当前活跃事务的事务Id。举个例子 :

现在存活事务有事务100,150,200,250

那么Read View就是{100,150,200,250}

//查询事务Id的语句
SELECT tx.trx_id
FROM information_schema.innodb_trx tx
WHERE tx.trx_mysql_thread_id = connection_id()
1
2
3
4
这个事务Id也就是对应着版本链中的trx_id,那么它的作用是什么呢?

首先我们来看两个最极端的隔离级别,READ UNCOMMITTED和SERIALIZABLE。当隔离级别为读未提交时,Mysql是不做任何隔离性的要求,所以这个时候只需要读取最新的数据即可,这也是脏读产生的原因。同理,串行化的级别是最严苛的事务隔离界别,在这个级别下,是通过加锁的方式对事务进行串行化执行,所以这两个级别是不会使用到版本链以及Read View。

那么剩下的两个级别都使用到了这两个模块,其中有一些细微差异,我们放在之后说,首先来说下是如何进行操作的。

事务执行过程中,只有在第一次真正修改记录时(比如使用INSERT、DELETE、UPDATE语句),才会被分配一个单独的事务id,这个事务id是递增的

上面提到生成的Read View是当前所有活跃事务的事务Id的集合,那么这个集合就有个范围,所以可以通过事务Id来判断这些事务生成时间顺序。下面来看下当一个事务查询一条记录的时候究竟会发生什么。

SELECT * FROM people WHERE name='Tom'
1
执行上面的语句,会先到版本链中查询,并且获取到最新版本的trx_id。

为什么要获取这个trx_id,实际上我们在查询一条数据的时候,我们想看看是谁提交的这条数据,如果这个提交者是一个很早之前就完成的事务,那么我们就可以放心读取。如果是在我们这个事务开启之后才开启的事务,那么这个数据我们是没有办法读取的。

所以,为了解决这种读取哪个版本的问题,提出了Read View的比对方案,我们将当前活跃的事务Id集合称为m_ids

如果被访问版本的trx_id属性值小于m_ids列表中最小的事务id,表明生成该版本的事务在生成ReadView前已经提交,所以该版本可以被当前事务访问。

如果被访问版本的trx_id属性值大于m_ids列表中最大的事务id,表明生成该版本的事务在生成ReadView后才生成,所以该版本不可以被当前事务访问。

如果被访问版本的trx_id属性值在m_ids列表中最大的事务id和最小事务id之间,那就需要判断一下trx_id属性值是不是在m_ids列表中,如果在,说明创建ReadView时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建ReadView时生成该版本的事务已经被提交,该版本可以被访问。

如果查询结果都不满足,那么就顺着版本链查找下一个符合要求的结果,如果所有结果都不满足则查询为null。

READ COMMITED & REPEATABLE READ
上面解释完版本链与ReadView的工作方式后,还有一个地方没有解释清楚。那就是读已提交和可重复读在MVCC下的实现区别,在MySQL中,READ COMMITTED和REPEATABLE READ隔离级别的的一个非常大的区别就是它们生成ReadView的时机不同。

READ COMMITED
READ COMMITED在每一次查询的时候都会生成一个新的Read View

以上就是定义,那么我们看看这个操作是怎样导致多次重复读取数据会变更的问题。

假设现在已经存在了两个事务A和B,其ID分别为100和200。我们先来做一些操作。

//事务A trx_id = 100
BEGIN;
UPDATE table SET name='JORY' WHERE ID = 1;
UPDATE table SET name='WUJIU' WHERE ID = 1;
1
2
3
4
//事务B trx_id = 200
//做一些其他操作,但是并没有更改table
1
2
那么我们看下此时的ReadView的区域是[100,200],我们来看下他们的结构。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-OYQcjMbV-1608617318566)(https://s1.ax1x.com/2020/04/23/JwC95d.png)]

此时,又有一个新的事务开启了并且执行了一下的语句 :

//事务C trx_id = 300
SELECT * FROM table WHERE id = 1;
1
2
调用查找时首先会生成一个Read View,此时的Read View为[100,200],执行语句后会经历如下过程。

查询到name=WUJIU的记录,但是trx_id=100在Read View,并且该事务当前还存活,不符合要求,向下查询。
查询到name=JORY的记录,但是trx_id=100,同样不符合要求,继续向下查询。
查询到name=Tom的记录,trx_id=2在Read View之前,查询成功,返回。
这个阶段上来说没什么问题,那么我们再去操作一下事务B,整个情形就发生了变化。

//事务B trx_id = 200
BEGIN;
UPDATE table SET name='Mag' WHERE ID = 1;
UPDATE table SET name='Cool' WHERE ID = 1;
1
2
3
4
在事务A执行的时候事务B也执行了一个这样的操作,所以这个版本链变成了这个样子。

此时如果事务C再次进行查询。

//事务C trx_id = 300
SELECT * FROM table WHERE id = 1;
//执行一些其他操作 . . .并且事务A已经提交了
doSomething;
//再次查询
SELECT * FROM table WHERE id = 1;
1
2
3
4
5
6
由于事务A在第一次查询和第二次查询的过程中已经提交,所以当前的Read View为[200],那么此时查询的话能够查询到name=WUJIU的数据行,所以就出现了两次查询结果是不一样的情况。

所以在READ COMMITED隔离界别下,每次查询都会创建一个新的Read View,每次都是读取最新版本的Read View合适的数据行,因此当在事务中出现其他事务对某一数据行操作,得到的两次结果可能不一致。

REPEATABLE READ
在REPEATABLE READ级别下,每个事务只生成一个Read View,该快照作用于整个事务的生命过程

有了以上的内容就很容易理解,REPEATABLE READ的隔离界别下,当我们的事务C进行第二次查询的时候,获取到的Read View依旧是第一次查询所使用的Read View,即

所以,REPEATABLE READ在整个事务周期内,总是使用同一个快照,因此整个事务期间,所能够查找到的数据永远是一致的。

"幻读"解决方法
上文中已经总结了Mysql是如何利用MVCC实现四个隔离级别的,但是对于幻读问题,MVCC是无能为力的。

由于幻读的发生是在表中对整个数据行的增添或者删除,而版本链只能够保证对已经存在的数据进行一些约束,所以针对幻读的情况就变得复杂起来,可以使用两种解决方式。

使用串行化的隔离界别,所有事务串行运行,完全避免
使用Next-Key Lock策略
毫无疑问,在不到万不得已的情况下,我们是不会选择第一种方案的,原因很简单,效率太糟糕,Mysql的设计者也考虑到了这个问题,所以为我们提供了比串行化更加轻便的解决方式。

行锁
首先来回顾一下行锁。

在Mysql中,行锁一共有三种,而后两种往往并不常见,但是却在时时刻刻影响着我们的事务操作。

行锁 : 锁定的是当前行的索引,如果没有索引会默认锁定隐式索引
间隙锁 : 锁定的是一个不存在的间隙
Next-Key Lock : 行锁+间隙锁的实现,也是解决幻读问题的关键
原理
首先来复原一下幻读的发生场景,现有两个事务A,B,同时开启事务并且对表进行操作。

//事务A 事务B
BEGIN; BEGIN;
SELECT COUNT(*) FROM table;
INSERT INTO table values(...);
SELECT COUNT(*) FROM table;


1
2
3
4
5
6
7
显而易见,在这样的情况下,事务A的两次查询结果必然不一致。那么如何解决这个问题,实际上很简单。我们只需要保证一个事务查询的区间在查询过程中不能够被操作即可。

通俗来讲,就是为事务A实现一个范围锁,这个范围就是该区间。

例如执行SELECT * FROM table WHERE id BETWEEN 100 AND 200,那么我们只需要保证在这个区间查找时,别的事务不能够对该区间进行增添删除操作即可,这里就利用到了我们上面所提到的间隙锁。

间隙锁的本质是锁住一个目前并不存在的数据,假设我们在id[100,200]这个区间中只有一个id=150的数据,但是对这个区间添加间隙锁,就能够将id=[100,150)(150,200]的空间全部锁住,也就是说,这时候只能够操作id=150的行,而其他行已经被锁住。

而行锁能够锁住目前在表中存在的某一行,这样二者搭配起来就能够完全的锁住一个区间,这也就是Next-Key Lock的原理。

如果使用了Next-Key Lock,我们再来看下刚才的事务场景。

//事务A 事务B
BEGIN; BEGIN;
SELECT COUNT(*) FROM table; //尝试新增,发现具有Next-key Lock,并且区间是整张表,阻塞
阻塞 - INSERT INTO table values(...);
SELECT COUNT(*) FROM table; //锁释放,执行INSERT语句
INSERT INTO table values(...);

1
2
3
4
5
6
7
可以看到当事务A使用COUNT(*)操作时,会锁止整个表,因为这个语句涉及的区间是整个空间,而如果是类似于BETWEEN ... AND ...这类语句,则只会锁住表内一段空间,如果去操作其他的空间,就不会发生阻塞,有些类似于JDK1.7中ConcurrentMap的分段锁操作,有效的提升了执行效率,避免了串行化所带来的额外开销。

总结
从上边的描述中我们可以看出来,所谓的MVCC(Multi-Version Concurrency Control ,多版本并发控制)指的就是在使用READ COMMITTD、REPEATABLE READ这两种隔离级别的事务在执行普通的SEELCT操作时访问记录的版本链的过程,这样子可以使不同事务的读-写、写-读操作并发执行,从而提升系统性能。READ COMMITTD、REPEATABLE READ这两个隔离级别的一个很大不同就是生成ReadView的时机不同,READ COMMITTD在每一次进行普通SELECT操作前都会生成一个ReadView,而REPEATABLE READ只在第一次进行普通SELECT操作前生成一个ReadView,之后的查询操作都重复这个ReadView就好了。
————————————————
版权声明:本文为CSDN博主「无咎Jory」的原创文章,遵循CC 4.0 BY-SA版权协议,转载请附上原文出处链接及本声明。
原文链接:https://blog.csdn.net/weixin_41582192/article/details/111545118

如果有来生,要做一片树叶。 春天恋上枝,炎夏恋上水。 深秋恋上土,东来化作泥。 润物细无声,生生世世恋红尘。
原文地址:https://www.cnblogs.com/shujiying/p/14527018.html