MVCC在Mysql中的运用
MVCC到底是个啥?
定义: 多版本并发控制,字面理解,在并发过程中利用多个版本进行合理控制(反正我就是从字面这么理解),很明显,这个东西是个抽象的概念,事实也是如此。它主要是出现在一些数据管理软件中。
维持一个数据的多个版本,使得读写操作没有冲突。
- 为什么会有这个东西呢?
我们都知道,数据管理程序提供的功能就是对数据的查询和修改,但是读写过程中如何怎么解决冲突问题呢,为了维护数据的一致性且保持较高的性能,到即使有读写冲突时,也能做到不加锁,非阻塞并发读, MVCC
这种并发控制算法就出现了。
MVCC基础原理
MVCC
是允许一个对象的多个版本同时存在。也就是说,他拥有 "当前" 版本和一个或多个以前的版本。当你在获取版本时可以根据需要使用它的不同版本来解决你面对的问题。在此运行期间, "作者"
可以创建和发布新的对象版本,该版本将成为对象的最新版本, "读者"
依旧也可以使用之前的版本。
到底使用哪些版本提供给 "读者"
呢?这个就和你的真实需求相关了,例如 Mysql
中的隔离级别,不同级别对相同并发操作后的结果看到不一致,我们可以根据需求来合理展示数据对象的版本,在不同隔离级别上实现不同的效果。
既然知道了 MVCC
的抽象,我们再去看看它的实现, MVCC
被利拥到很多的数据管理程序上,无疑证明他是一个很好的设计思路,例如在 Mysql
的 Innodb引擎
、 Etcd存储
、 PostgreSQL
、oracle
等等,我们就以 Innodb
引擎为例,观察一下他是如何实现 MVCC
的
Innodb引擎MVCC探究
在 Innodb
引擎中,我们需要先了解一些基础概念: undolog
、 版本链
和 ReadView
undolog
我们知道,事务是具有原子性的,但是当系统故障,或者手动回滚时,如何保证这次提交的数据全部进行恢复呢,有时候都可能事务只执行到一半,我们要保证它和原来一样,这个事务看起来什么都没有做,举一个生活中的例子:如象棋,当下错子的时候可以申请悔棋, 悔棋
就是一种回滚操作,实际上就是之前执行的操作再次执行一次逆向操作,数据库中的回滚跟 悔棋
差不多,你插入一条记录,回滚操作日志对应的就是删除这个记录;你更新了一条记录,回滚操作对应的就是把该记录更新为旧值;你删除一条记录,对应回滚操作就是插入。
这些为了回滚记录的日志称为撤销日志 undolog
,在真实的 InnoDB
中, undo日志
其实并不像我们上边所说的那么简单,不同类型的操作产生的 undo日志
的格式也是不同的,不过先暂时把这些容易让人脑子糊的具体细节放一放,注意我们文章的重点,接着往下看。
版本链
版本链式用来存储该数据行的历史,每次对记录进行更新后,都会将旧值放到一条 undo日志
中,形成该记录的一个旧版本,随着更新次数的增加,所有版本都会被数据行的 roll_pointer
属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录的最新值。另外,每个版本还包含生成该版本的 事务ID ,大概逻辑如下图所示:
ReadView
在 RC
和 RR
隔离级别中(READ UNCOMMITTED、SERIALIZABLE 是没有使用的Read View的),都必须保证已经读到已经提交了的事务修改过的记录,也就是说假设另一个记录已经修改了但是尚未提交,是不能直接读取最新版本的记录,核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。Read View
这个概念就是为了解决这个问题,它包含了4个内容
m_ids
生成ReadView
时当前系统中活跃的读写事务ID列表min_trx_id
生成ReadView
时当前系统中活跃事务最小的事务ID,即m_ids
中的最小值max_trx_id
生成ReadView
时系统中应该分配给下一个事务IDcreator_trx_id
生成ReadView
时的事务ID只有在对表中的记录做改动时(执行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
时生成该版本的事务已经被提交,该版本可以被访问。
如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边步骤判断可见性。依次类推,直到版本链中的最后一个版本。如果最后一个版本都不可见的话,那就意味着该记录对该事务完全不可见,查询结果也就不包含该记录。
而生成 ReadView
的时机不同也直接影响了查询操作的结果,在 Mysql
中, RC
和 RR
隔离级别最大的区别就是生成 ReadView
的时机不同。
READ COMMITTED —— 每次读取数据前都生成一个ReadView
比方说现在系统里有两个事务id分别为100、200的事务在执行:
# Transaction 100 BEGIN; UPDATE hero SET name = '关羽' WHERE number = 1; UPDATE hero SET name = '张飞' WHERE number = 1;
# Transaction 200 BEGIN; # 更新了一些别的表的记录 ...
小贴士: 再次强调一遍,事务执行过程中,只有在第一次真正修改记录时(比如使用INSERT、DELETE、UPDATE语句),才会被分配一个单独的事务id,这个事务id是递增的。所以我们才在Transaction 200中更新一些别的表的记录,目的是让它分配事务id。
此刻,表hero
中number
为1的记录得到的版本链表如下所示:
假设现在有一个使用READ COMMITTED
隔离级别的事务开始执行:
# 使用READ COMMITTED隔离级别的事务 BEGIN; # SELECT1:Transaction 100、200未提交 SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
这个SELECT1
的执行过程如下:
- 在执行
SELECT
语句时会先生成一个ReadView
,ReadView
的m_ids
列表的内容就是[100, 200],min_trx_id
为100,max_trx_id
为201,creator_trx_id
为0。 - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列
name
的内容是'张飞',该版本的trx_id
值为100,在m_ids
列表内,所以不符合可见性要求,根据roll_pointer
跳到下一个版本。 - 下一个版本的列
name
的内容是'关羽',该版本的trx_id
值也为100,也在m_ids
列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列
name
的内容是'刘备',该版本的trx_id
值为80,小于ReadView
中的min_trx_id
值100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name
为'刘备'的记录。
之后,我们把事务id为100的事务提交一下,就像这样:
# Transaction 100 BEGIN; UPDATE hero SET name = '关羽' WHERE number = 1; UPDATE hero SET name = '张飞' WHERE number = 1; COMMIT;
然后再到事务id为200的事务中更新一下表hero
中number
为1的记录:
# Transaction 200 BEGIN; # 更新了一些别的表的记录 ... UPDATE hero SET name = '赵云' WHERE number = 1; UPDATE hero SET name = '诸葛亮' WHERE number = 1;
此刻,表hero中number为1的记录的版本链就长这样:
然后再到刚才使用READ COMMITTED
隔离级别的事务中继续查找这个number
为1的记录,如下:
# 使用READ COMMITTED隔离级别的事务 BEGIN; # SELECT1:Transaction 100、200均未提交 SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备' # SELECT2:Transaction 100提交,Transaction 200未提交 SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'张飞'
这个SELECT2
的执行过程如下:
- 在执行
SELECT
语句时会又会单独生成一个ReadView
,该ReadView
的m_ids
列表的内容就是[200]
(事务id为100的那个事务已经提交了,所以再次生成快照时就没有它了),min_trx_id
为200,max_trx_id
为201,creator_trx_id
为0。 - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列
name
的内容是'诸葛亮',该版本的trx_id
值为200,在m_ids
列表内,所以不符合可见性要求,根据roll_pointer
跳到下一个版本。 - 下一个版本的列
name
的内容是'赵云',该版本的trx_id
值为200,也在m_ids
列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列
name
的内容是'张飞',该版本的trx_id
值为100,小于ReadView
中的min_trx_id
值200,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name
为'张飞'的记录。
以此类推,如果之后事务id为200的记录也提交了,再次在使用READ COMMITTED
隔离级别的事务中查询表hero
中number
值为1的记录时,得到的结果就是'诸葛亮'了,具体流程我们就不分析了。总结一下就是:使用READ COMMITTED
隔离级别的事务在每次查询开始时都会生成一个独立的ReadView
。
REPEATABLE READ —— 在第一次读取数据时生成一个ReadView,在一个事务中保证多个查询结果一致
对于使用REPEATABLE READ
隔离级别的事务来说,只会在第一次执行查询语句时生成一个ReadView
,之后的查询就不会重复生成了。我们还是用例子看一下是什么效果。
比方说现在系统里有两个事务id分别为100、200的事务在执行:
# Transaction 100 BEGIN; UPDATE hero SET name = '关羽' WHERE number = 1; UPDATE hero SET name = '张飞' WHERE number = 1;
# Transaction 200 BEGIN; # 更新了一些别的表的记录 ...
此刻,表hero
中number
为1的记录得到的版本链表如下所示:
假设现在有一个使用REPEATABLE READ隔离级别的事务开始执行:
# 使用REPEATABLE READ隔离级别的事务 BEGIN; # SELECT1:Transaction 100、200未提交 SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'
这个SELECT1
的执行过程如下:
- 在执行
SELECT
语句时会先生成一个ReadView
,ReadView
的m_ids
列表的内容就是[100, 200],min_trx_id
为100,max_trx_id
为201,creator_trx_id
为0。 - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列
name
的内容是'张飞',该版本的trx_id
值为100,在m_ids
列表内,所以不符合可见性要求,根据roll_pointer
跳到下一个版本。 - 下一个版本的列
name
的内容是'关羽',该版本的trx_id
值也为100,也在m_ids
列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列
name
的内容是'刘备',该版本的trx_id
值为80,小于ReadView
中的min_trx_id
值100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列name
为'刘备'的记录。
之后,我们把事务id为100的事务提交一下,就像这样:
# Transaction 100 BEGIN; UPDATE hero SET name = '关羽' WHERE number = 1; UPDATE hero SET name = '张飞' WHERE number = 1; COMMIT;
然后再到事务id为200的事务中更新一下表hero
中number
为1的记录:
# Transaction 200 BEGIN; # 更新了一些别的表的记录 ... UPDATE hero SET name = '赵云' WHERE number = 1; UPDATE hero SET name = '诸葛亮' WHERE number = 1;
此刻,表hero
中number
为1的记录的版本链就长这样:
然后再到刚才使用REPEATABLE READ
隔离级别的事务中继续查找这个number
为1的记录,如下:
# 使用REPEATABLE READ隔离级别的事务 BEGIN; # SELECT1:Transaction 100、200均未提交 SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备' # SELECT2:Transaction 100提交,Transaction 200未提交 SELECT * FROM hero WHERE number = 1; # 得到的列name的值仍为'刘备'
这个SELECT2
的执行过程如下:
- 因为当前事务的隔离级别为
REPEATABLE READ
,而之前在执行SELECT1时已经生成过ReadView
了,所以此时直接复用之前的ReadView
,之前的ReadView
的m_ids
列表的内容就是[100, 200],min_trx_id
为100,max_trx_id
为201,creator_trx_id
为0。 - 然后从版本链中挑选可见的记录,从图中可以看出,最新版本的列
name
的内容是'诸葛亮',该版本的trx_id
值为200,在m_ids
列表内,所以不符合可见性要求,根据roll_pointer
跳到下一个版本。 - 下一个版本的列
name
的内容是'赵云',该版本的trx_id
值为200,也在m_ids
列表内,所以也不符合要求,继续跳到下一个版本。 - 下一个版本的列
name
的内容是'张飞',该版本的trx_id
值为100,而m_ids
列表中是包含值为100的事务id的,所以该版本也不符合要求,同理下一个列name
的内容是'关羽'的版本也不符合要求。继续跳到下一个版本。 - 下一个版本的列
name
的内容是'刘备',该版本的trx_id
值为80,小于ReadView
中的min_trx_id
值100,所以这个版本是符合要求的,最后返回给用户的版本就是这条列c为'刘备'的记录。
也就是说两次SELECT
查询得到的结果是重复的,记录的列c值都是'刘备',这就是可重复读的含义。如果我们之后再把事务id为200的记录提交了,然后再到刚才使用REPEATABLE READ
隔离级别的事务中继续查找这个number
为1的记录,得到的结果还是'刘备',具体执行过程大家可以自己分析一下。
总结一下
看了上面的 undolog、 版本链 和 ReadView 后,我们大致也有了一些想法, ReadView
主要是对记录的历史进行可见性规则制定和规则校验,而记录的历史则是采用链表结构存储在 undolog
个,三者结合解决了 MVCC
中的读写问题。而 mysql
中所谓的 MVCC
指的就是在使用 RC
和 RR
两种隔离级别的事务在执行普通 SELECT
操作时访问版本链的过程,这样子可以使不同事务的读写,写写操作并发执行,从而提升系统性能,而他们最大的不同点就在于生成 ReadView
的时机不同
References
[1] MySQL 是怎样运行的:从根儿上理解 MySQL: https://juejin.cn/book/6844733769996304392/section/6844733770071801870