MVCC在Mysql中的运用

MVCC在Mysql中的运用

MVCC到底是个啥?

定义: 多版本并发控制,字面理解,在并发过程中利用多个版本进行合理控制(反正我就是从字面这么理解),很明显,这个东西是个抽象的概念,事实也是如此。它主要是出现在一些数据管理软件中。
维持一个数据的多个版本,使得读写操作没有冲突。

  • 为什么会有这个东西呢?

我们都知道,数据管理程序提供的功能就是对数据的查询和修改,但是读写过程中如何怎么解决冲突问题呢,为了维护数据的一致性且保持较高的性能,到即使有读写冲突时,也能做到不加锁,非阻塞并发读, MVCC 这种并发控制算法就出现了。

MVCC基础原理

MVCC 是允许一个对象的多个版本同时存在。也就是说,他拥有 "当前" 版本和一个或多个以前的版本。当你在获取版本时可以根据需要使用它的不同版本来解决你面对的问题。在此运行期间, "作者" 可以创建和发布新的对象版本,该版本将成为对象的最新版本, "读者" 依旧也可以使用之前的版本。

到底使用哪些版本提供给 "读者" 呢?这个就和你的真实需求相关了,例如 Mysql 中的隔离级别,不同级别对相同并发操作后的结果看到不一致,我们可以根据需求来合理展示数据对象的版本,在不同隔离级别上实现不同的效果。

既然知道了 MVCC 的抽象,我们再去看看它的实现, MVCC 被利拥到很多的数据管理程序上,无疑证明他是一个很好的设计思路,例如在 MysqlInnodb引擎Etcd存储PostgreSQL 、oracle 等等,我们就以 Innodb 引擎为例,观察一下他是如何实现 MVCC

Innodb引擎MVCC探究

Innodb 引擎中,我们需要先了解一些基础概念: undolog版本链ReadView

undolog

我们知道,事务是具有原子性的,但是当系统故障,或者手动回滚时,如何保证这次提交的数据全部进行恢复呢,有时候都可能事务只执行到一半,我们要保证它和原来一样,这个事务看起来什么都没有做,举一个生活中的例子:如象棋,当下错子的时候可以申请悔棋悔棋 就是一种回滚操作,实际上就是之前执行的操作再次执行一次逆向操作,数据库中的回滚跟 悔棋 差不多,你插入一条记录,回滚操作日志对应的就是删除这个记录;你更新了一条记录,回滚操作对应的就是把该记录更新为旧值;你删除一条记录,对应回滚操作就是插入。

这些为了回滚记录的日志称为撤销日志 undolog ,在真实的 InnoDB 中, undo日志 其实并不像我们上边所说的那么简单,不同类型的操作产生的 undo日志 的格式也是不同的,不过先暂时把这些容易让人脑子糊的具体细节放一放,注意我们文章的重点,接着往下看。

版本链

版本链式用来存储该数据行的历史,每次对记录进行更新后,都会将旧值放到一条 undo日志 中,形成该记录的一个旧版本,随着更新次数的增加,所有版本都会被数据行的 roll_pointer 属性连接成一个链表,我们把这个链表称之为版本链,版本链的头节点就是当前记录的最新值。另外,每个版本还包含生成该版本的 事务ID ,大概逻辑如下图所示:

65C7F433A7D2B704

ReadView

RCRR 隔离级别中(READ UNCOMMITTED、SERIALIZABLE 是没有使用的Read View的),都必须保证已经读到已经提交了的事务修改过的记录,也就是说假设另一个记录已经修改了但是尚未提交,是不能直接读取最新版本的记录,核心问题就是:需要判断一下版本链中的哪个版本是当前事务可见的。Read View 这个概念就是为了解决这个问题,它包含了4个内容

  1. m_ids 生成 ReadView 时当前系统中活跃的读写事务ID列表
  2. min_trx_id 生成 ReadView 时当前系统中活跃事务最小的事务ID,即 m_ids 中的最小值
  3. max_trx_id 生成 ReadView 时系统中应该分配给下一个事务ID
  4. creator_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 属性值在 ReadViewmin_trx_idmax_trx_id 之间,那就需要判断一下 trx_id 属性值是不是在 m_ids 列表中,如果在,说明创建 ReadView 时生成该版本的事务还是活跃的,该版本不可以被访问;如果不在,说明创建 ReadView 时生成该版本的事务已经被提交,该版本可以被访问。

如果某个版本的数据对当前事务不可见的话,那就顺着版本链找到下一个版本的数据,继续按照上边步骤判断可见性。依次类推,直到版本链中的最后一个版本。如果最后一个版本都不可见的话,那就意味着该记录对该事务完全不可见,查询结果也就不包含该记录。

而生成 ReadView 的时机不同也直接影响了查询操作的结果,在 Mysql 中, RCRR 隔离级别最大的区别就是生成 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。

此刻,表heronumber为1的记录得到的版本链表如下所示:

2FA19065196157DE

假设现在有一个使用READ COMMITTED隔离级别的事务开始执行:

# 使用READ COMMITTED隔离级别的事务
BEGIN;

# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'

这个SELECT1的执行过程如下:

  • 在执行SELECT语句时会先生成一个ReadViewReadViewm_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的事务中更新一下表heronumber为1的记录:

# Transaction 200
BEGIN;

# 更新了一些别的表的记录
...

UPDATE hero SET name = '赵云' WHERE number = 1;

UPDATE hero SET name = '诸葛亮' WHERE number = 1;

此刻,表hero中number为1的记录的版本链就长这样:

B3638932E3A43EFF

然后再到刚才使用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,该ReadViewm_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隔离级别的事务中查询表heronumber值为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;

# 更新了一些别的表的记录
...

此刻,表heronumber为1的记录得到的版本链表如下所示:

39D76CAE66B11201

假设现在有一个使用REPEATABLE READ隔离级别的事务开始执行:

# 使用REPEATABLE READ隔离级别的事务
BEGIN;

# SELECT1:Transaction 100、200未提交
SELECT * FROM hero WHERE number = 1; # 得到的列name的值为'刘备'

这个SELECT1的执行过程如下:

  • 在执行SELECT语句时会先生成一个ReadViewReadViewm_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的事务中更新一下表heronumber为1的记录:

# Transaction 200
BEGIN;

# 更新了一些别的表的记录
...

UPDATE hero SET name = '赵云' WHERE number = 1;

UPDATE hero SET name = '诸葛亮' WHERE number = 1;

此刻,表heronumber为1的记录的版本链就长这样:

633F3DFAACD6CA11

然后再到刚才使用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,之前的ReadViewm_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 指的就是在使用 RCRR 两种隔离级别的事务在执行普通 SELECT 操作时访问版本链的过程,这样子可以使不同事务的读写,写写操作并发执行,从而提升系统性能,而他们最大的不同点就在于生成 ReadView 的时机不同

References

[1] MySQL 是怎样运行的:从根儿上理解 MySQL: https://juejin.cn/book/6844733769996304392/section/6844733770071801870

您的支持是对我最大的鼓励!

发表于: 作者:憧憬。
关注互联网以及分享全栈工作经验的原创个人博客和技术博客,热爱编程,极客精神
Github 新浪微博 SegmentFault 掘金专栏