一文了解 MySQL 中的多版本并发控制
作者:京东零售 李泽阳
最近在阅读《认知觉醒》这本书,里面有句话非常打动我:通过自己的语言,用最简单的话把一件事情讲清楚,最好让外行人也能听懂。
也许这就是大道至简,只是我们习惯了烦琐和复杂。
希望借助今天这篇文章,能用大白话说清楚这个相对比较底层和复杂的 MVCC 机制。
在开始之前,先抛出一个问题:我们都知道,目前(MySQL 5.6 以上)数据库已普遍使用 InnoDB 存储引擎,InnoDB 相对于 MyISAM 存储引擎其中一个好处就是在数据库级别锁和表级别锁的基础上支持了行锁,还有就是支持事务,保证一组数据库操作要么成功,要么失败。基于此,问题来了,在 InnoDB 默认隔离级别(可重复读)下,一个事务想要更新一行数据,如果刚好有另外一个事务拥有这个行锁,那么这个事务就会进入等待状态。既然进入等待状态,那么等到这个事务获取到行锁要更新数据的时候,它读取到的值是什么呢?
具体的问题见下图,我们设定有一张表 user,初始化语句如下,试想在这样的场景下,事务 A 三次查询的值分别是什么?
想要把这件事情回答正确,我们先来铺垫一下基础知识。
提到事务,首先会想到的就是 ACID(Atomic 原子性、Consist 一致性、Isolate 隔离性、Durable 持久性),今天我们主要关注隔离性,当有多个事务同时执行发生并发时,数据库可能会出现脏读、不可重复读和幻读等问题,为了解决这些问题,“隔离级别”这位大哥上场,包含:读未提交、读已提交、可重复读和串行。
但我们都知道,隔离级别越高,执行效率越低。毕竟大哥就是大哥,级别越高,越谨慎,常在河边走哪能不湿鞋。
我们通过一个例子简单说一下这四种隔离级别:
• 读未提交:一个事务还未提交,它的变更就能被其他事务看到。V1 为 B,V2 为 B,V3 为 B。
• 读已提交:一个事务提交之后,变更结果对其他事务可见。V1 为 A,V2 和 V3 为 B。
• 可重复读:一个事务执行过程中看到的数据与事务启动时一致。V1 为 A,V2 为 A,V3 为 B。
• 串行:不管读和写,加锁就完了,就是干!V1 和 V2 均为 A,V3 为 B。
**事务是怎么实现的呢?**实际上,事务执行时,数据库会创建一个视图,读未提交直接返回最新值,没有视图概念;串行是直接加锁避免并发访问;读已提交是在每个 SQL 语句开始执行时创建的视图。可重复读的视图是在事务启动的时候创建的,整个事务都会使用这个视图。这样的话,上面四种不同隔离级别下的 V1、V2、V3 值便对号入座,有了结果。
**MySQL 是怎么实现的呢?**我们以 MySQL 默认的可重复读隔离级别为例,实际上每条行记录在更新时都会记录一条回滚日志,也就是大家常说的 undo log。通过回滚操作,都可以得到前一个状态的值。假设 name 值从初始值 A 被依次更新为 B、C、D,我们看一下回滚日志:
当前值是 D,但是在查询这条记录的时候,不同时刻启动的事务会有不同的视图,看到的值也就不一样。在视图 1、2、3、4 里面,记录的 name 值分别是 A、B、C、D。同一条行记录在数据库中可以存在多个版本,这就是多版本并发控制(MVCC)。对于视图 1,如果想要将 name 值回到 A,那么就要依次执行图中所有回滚操作。
到这里,你已经接触到了 MVCC 的概念,也许你已经对文章最开始的问题有了一点点想法,别着急,我们先来简单总结下 MVCC 的特点:
MVCC 的出现使得一条行记录在不同隔离级别下不同的事务操作会形成一条不同版本的链路,从而实现在不加锁的前提下使不同事务的读写操作能够并发安全执行,这个版本链就是通过回滚日志 undo log 实现的。用大白话说,你这个事务想要查询一条行记录,MVCC 会通过你这个事务所在视图确认版本链中哪个版本的行数据对你可见。刚才我们提到,四种隔离级别下,只有读已提交和可重复读会用到视图。对于读已提交,MVCC 会在每次查询前都会生成一个视图,可重复读隔离级别只会在第一次查询时生成一个视图,之后在这个事务中的所有查询操作都会重复使用这个视图。行业上,将创建视图的那一刻称为快照,晃你一下子,让你激灵激灵,别发生脏读,变脏喽~
想要解决文章最开始的那个问题,我们还得展开说说版本链是如何形成的和快照的原理,稍有枯燥,先忍一下,耐心看下去,乖~
对于 InnoDB 存储引擎来说,主键索引(也称为聚簇索引)记录中除了正常的字段数据外,还包含两个隐藏列:
(1)trx_id:每次一个事务想要对主键索引进行更新、删除和新增时,都会把这个事务的事务 id 赋值给 trx_id 字段。注意事务 id 严格递增,且查询操作不会分配事务 id,即 trx_id = 0;
(2)roll_point:每次一个事务对主键索引进行更新时,都会把旧的版本写入到 undo 日志中,roll_point 相当于一个指针,通过它可以找到这条记录修改前的信息。
我们以可重复读隔离级别为例,为了尚未提交的更新结果对其他事务不可见,InnoDB 在创建视图时,有以下四部分组成:
• m_ids:表示生成视图时,当前系统中“活跃”的读写事务的事务 id 列表,这里的活跃大白话就是事务尚未提交;
• min_trx_id:表示在生成视图时,当前系统中活跃的读写事务中最小的事务 id,即 m_ids 中的最小值;
• max_trx_id:表示生成视图时系统应该分配给下一个事务的 id 值;
• creator_trx_id:表示生成该视图的事务 id。
概念比较多,举个例子,现在有事务 id 分别是 1、2、3 三个事务,1 和 2 事务尚未提交,3 事务已提交,这个时候如果来了一个新事务,那么它创建的视图对应这几个参数分别为:m_ids 包含 1、2,min_trx_id 为 1,max_trx_id 为 4。
关键的知识点来了,如何根据某个事务生成的视图,判断版本链上的某个版本对这个事务可见呢?
遵循下面步骤:
1、版本链上的不同版本 trx_id 值如果与这个视图的 creator_trx_id 值相同,说明当前事务在访问它自己修改过的记录,所以被访问的版本对当前事务可见。一家人还是认识一家人的~
2、版本链上的不同版本 trx_id 值小于这个视图的 min_trx_id 值,说明这个版本的事务在当前事务生成视图之前就已经提交了,所以被访问的版本对当前事务可见。
3、版本链上的不同版本的 trx_id 值大于或等于这个视图的 max_trx_id 值,说明这个版本的事务在当前事务之后才开启,所以被访问版本对当前事务不可见。
4、版本链上的不同版本的 trx_id 值在这个视图的 min_trx_id 和 max_trx_id 之间,需要进一步判断被访问版本 trx_id 值是不是在 m_ids 中,如果在,说明当前事务是活跃的,被访问版本对当前事务不可见。如果不在,说明被访问版本的事务已经提交了,被访问版本对当前事务可见。
比较绕是不是,千万别晕,兄弟呀~,大白话解释一下,设定某个事务生成的视图瞬间(也就是快照),这个事务的 id 为 creator_trx_id,那么有下面三种可能:
1、如果 creator_trx_id 落在绿色部分,表示被访问的版本是已提交的事务或者就是当前事务自己生成的,这个数据是可见的;
2、如果 creator_trx_id 落在红色部分,表示被访问的版本还未开启,数据不可见;
3、如果 creator_trx_id 落在黄色部分,包括两种情况:
若 creator_trx_id 在 m_ids 集合中,表示被访问的版本尚未提交,数据不可见;
若 creator_trx_id 不在 m_ids 集合中,表示被访问的版本已经已经提交了,数据可见。
知道了这个之后,我们就可以回答文章最开始那个问题了,在隔离级别为可重复读的情况下(这里的隐含条件就是可重复读隔离级别只会在第一次查询时生成一个视图,之后在这个事务中的所有查询操作都会重复使用这个视图)分析一波:
以文章开头的例子,设定事务 B 的事务 id=100,事务 C 的事务 id=200,当事务 B 尚未提交时,id=1 这条记录的版本链是这样的:
这个时候我们看一下事务 A 第一个 select 语句,注意查询操作的事务 trx_id=0,在执行 select 语句时会创建一个视图,这个视图的 m_ids={100},min_trx_id=100,max_trx_id=101,creator_trx_id=0。
然后在版本链中挑选可见的数据记录,从图中可以看到最新版本的 name 值是 B,最新版本的 trx_id 值为 100,在 m_ids 集合中,这个版本数据不可见,根据 roll_point 跳到下一个版本;
下一个版本的 name 值是 A,这个版本的 trx_id=99,小于 min_trx_id,这个版本数据是可见的,所以返回 name 为 A 的记录,即 V1 为 A。
我们继续,事务 B 这时进行了 commit 提交,此时事务 C 已经开启,那么事务 A 第二个 select 语句不会创建一个新的视图,而是重新利用第一次创建的视图。最新版本的 trx_id 为 100,在 m_ids 中,数据不可见,即 V2=A;
接下来,事务 C 进行了更新操作,此时版本链发生的改变如下:
事务 C 接着进行了 commit 提交,此时事务 A 第三次 select 语句也不会创建一个新的视图,最新版本的 trx_id 为 200,大于 max_trx_id,数据不可见,即 V3=A。
到这里,MVCC 就结束啦,留一个小问题,如果是读已提交隔离级别,那么文章开头的例子中 V1、V2、V3 的值又分别是什么呢?答案在最后哦。
最后,我们再来总结一下 MVCC 的作用,使用可重复读隔离级别的事务在查询时,仅会使用第一次 select 时生成的视图,相比于读已提交隔离级别每次查询都会生成一个新的视图,可重复读在查询时使用的视图版本不会那么新,因此有些已经提交的事务对行记录进行修改时对查询事务就不可见,进而避免了不可重复读现象的发生,同时也避免了脏读。
小问题答案:
读已提交隔离级别下,每次 select 查询都会生成一个新的视图,基于此,分析如下:
事务 A 第一个 select 语句,注意查询操作的事务 trx_id=0,在执行 select 语句时会创建一个视图,这个视图的 m_ids={100},min_trx_id=100,max_trx_id=101,creator_trx_id=0。
然后在版本链中挑选可见的数据记录,从图中可以看到最新版本的 name 值时 B,最新版本的 trx_id 值为 100,在 m_ids 集合中,这个版本数据不可见,根据 roll_point 跳到下一个版本;
下一个版本的 name 值是 A,这个版本的 trx_id=99,小于 min_trx_id,这个版本数据是可见的,所以返回 name 为 A 的记录,即 V1 为 A。
事务 B 这时进行了 commit 提交,此时事务 C 已经开启,那么事务 A 第二个 select 语句会创建一个新的视图,这个视图的 m_ids={200},min_trx_id=200,max_trx_id=201,creator_trx_id=0。版本链没有发生变化,最新版本 trx_id 值为 100,小于 min_trx_id,数据可见,即 V2=B;
事务 C 接着进行了 commit 提交,此时事务 A 第三次 select 语句会创建一个新的视图,这个视图的 m_ids={},min_trx_id 不存在,max_trx_id=201,creator_trx_id=0。在版本链中挑选可见的数据记录,从图中可以看到最新版本的 name 值为 C,最新版本的 trx_id 值为 200,小于 max_trx_id 且不在 m_ids 中,则数据可见,即 V3=C。
版权声明: 本文为 InfoQ 作者【京东科技开发者】的原创文章。
原文链接:【http://xie.infoq.cn/article/c91d300e0578c70f9f955c0c1】。文章转载请联系作者。
评论