目录
7.1 MVCC 主要作用体现在读已提交和可重复读两种隔离级别下
MVCC 英文全称叫 "Multi Version Concurrency Control",翻译过来就是 "多版本并发控制"。在 MySQL 众多存储引擎中只有 InnoDB 存储引擎中实现了 MVCC 机制。
首先我们要清楚,在 InnoDB 存储引擎下,假设事务A我们对一行数据进行修改操作,是会对这一行数据进行加写锁的(也就是我们所说的行锁);如果此时事务B来查询这一行数据,它就要加读锁,读锁与写锁冲突,所以事务B加锁不成功,它就必须等待事务A操作执行完毕释放写锁之后才能去进行读操作。
而有了 MVCC 的加入,我们的事务B再去查询该行数据时,就不需要等待事务A释放锁可以直接查询,查询方式是快照读(下面会解释到),而且查询到的是事务A修改数据之前当前行的数据,提高了数据库的并发效率。
总之一句话:MVCC 是通过数据行的多版本管理来实现数据库的并发控制,提高数据库的并发性能。
我们姑且把刚才的事务A的查询操作理解为写操作,事务B的查询操作理解为读操作;在 MVCC 下,这里的读指的是快照读。了解在 Linux 操作系统和 Git 代码管理的大致应该清楚,我们可以通过 Linux 操作系统的快照将系统回溯到之前的某个版本,Git 也可以通过回溯版本返回至之前的某个代码版本。MVCC 中的快照与这两者大致意思相近,可以类比理解。
数据在修改之前和修改之后版本是不一样的,我们读取别人正在操作的数据时,可以读取该数据操作之前的快照,就可以避免读写锁互斥导致阻塞等待这一现象。
当前读就很好理解了,没有 MVCC 时,数据库是靠加锁来避免数据安全性问题,加的锁都是悲观锁。共享锁,排它锁都属于是当前读的一种范畴。
我们去读取数据,读取到的一定是当前数据,没有数据版本这一说法。假设要去读一个正在被修改的数据,是会阻塞的,只有别人修改完,才能去执行当前读这一操作,也可以理解为同步读。
MVCC 实现原理主要依赖于三部分,隐藏字段,undo log版本链,readView。
对于 InnoDB 存储引擎的表来说,它的聚簇索引记录(理解为每行数据即可)中都会有两个必要的隐藏字段 trx_id(事务id) 和 roll_pointer(回滚指针)。没有主键的表会有第三个额外的隐藏主键字段。隐藏字段的主要作用就是对每次数据操作进行标记区分并记录操作之前的数据的地址。
我就以下面这幅图来给各位解析一下 trx_id 和 roll_pointer.
trx_id:每次一个事务对聚簇索引的记录做改动,都会把该事务的事务id赋值给隐藏字段。
roll_pointer:每次对聚簇索引的记录做改动时,都会把旧的版本写入到 undo 日志中去,然后这个隐藏列相当于一个指针,可以通过它来找到该记录修改之前的数据。
如上图,假设与四个事务A,B,C,D。事务A插入数据,事务B,C,D均对插入的数据做了修改。四个事务在对数据进行增删改查的时候,数据库就会给这四个事务的隐藏字段 trx_id 以自增的方式赋值,这里 假设分别赋值为 1,2,3,4。
roll_pointer 回滚指针则是指向当前数据修改之前的数据值,倘若事务回滚,就会返回到之前的数据。
如上所示四个事务进行的四次数据更新操作,每次数据操作之后,数据库都会把操作之前的旧值存放到 undo 日志中记录下来,随着更新次数的增多,每次记录都会由隐藏字段中的 roll_pointer 指针连接起来形成链表,所形成的链表我们就称之为版本链,链表的头节点就是当前数据最新的节点。
刚才我们说到了版本链,既然一条数据经历了多次操作,有那么多个版本,我们在查询数据并进行操作的时候,是怎么知道该选择哪个版本的数据的呢?一定是查询操作最新的吗?这是不一定的。查询操作哪个版本的数据取决于我们的第三个重要元素 readView。
readView 就是事务在使用 MVCC 机制对数据库中的数据操作时产生的读视图。当事务开启之后,会生成数据库当前系统的一个快照,InnoDB 会为每个事务构建一个数组,用来记录并维护当前系统活跃事务的id (这里的活跃指代的是事务正在操作数据但是没有进行提交)。
readView 是MVCC 三个中最重要的组成部分,也是面试 MVCC 时经常问道的一个点。
readView 的核心原理主要体现在 READ COMMITTD(读已提交)和 REPEATABLE READ(可重复读) 两种隔离级别上。
READ COMMITTD:在每次进行 SELECT 查询操作的时候都会去生成一个 readView;
REPEATABLE READ:每开启一个事物才会生成一个 readView,一个事务的所有SQL语句共享一个 readView。
readView 有多个属性,m_ids 就可以理解为生成的数组记录,如下图所示,基于以下几种属性, 一共有四种可能情况。
情况一 trx_id == creator_trx_id:说明这条记录就是当前事务插入所形成的,自己插入的数据自己肯定可以访问;
情况二 trx_id < min_trx_id: min_trx_id表示的是正在活跃的事务最小的 id,而所有活跃的事物都是未提交的,所以就可以查询得到,不会出现读未提交的情况;
情况三 trx_id > max_trx_id: max_trx_id表示要分配给下一个事务的 id,二我们要查询的数据的 id 却比待分配的事物的 id 还要大,这是不可能查得到的。
情况四 min_trx_id <= trx_id <= max_trx_id:如果 trx_id 是在m_ids 中,则不可以访问这个版本,因为在此区间内则说明此当前事务正在进行中还没提交,不能访问其他事务未提交的数据,否则可能会产生脏读。如果不在m_ids 中,说明当前事务已经是 commit 提交过了的,则可以访问。
数据库有四种隔离级别。它们的隔离级别由低到高,并发能力由高到低。
读未提交:解决了脏写问题;
读已提交:解决了脏写,脏读问题;
可重复读:解决了脏写,脏读,不可重复读;
串行化: 解决了脏写,脏读,不可重复读,幻读所有问题;
原因:通过上面 MVCC 的简单解释不难看出,MVCC 的核心主要是版本链,而在读未提交的隔离级别下,我们的事务可以读取到其他事务未提交的数据,也就是当前数据的最新状态,不存在版本这一说法,并发效率最高,数据准确度最差;而在串行化隔离级别下,它是加锁不允许两个事务操作同一份数据,也不存在版本链这一说法,并发效率最低,数据准确度最可靠;
在 MVCC 读已提交的隔离级别下,它生成 readView 的时机是每进行一次 SELECT 查询就生成一个 readView,如果一个事务中进行了多次 SELECT 查询操作,它就会生成多个 readView 。
在 MVCC 中 可读已提交的隔离级别下,它生成 readView 的时机是每开启一个事务才会生成一个 readView,如果一个事务中有多个 SELECT 查询操作们还是只会生成一个 readView ,一个事务的所有查询操作共享一个 readView 。
一定一定要区分好读已提交和可重复读隔离级别下 readView 的生成时机,这是这两种隔离级别原理最最最核心的因素。再次强调!!!
如下所示是 student 学生表中的一条数据
(1) 假设现在有事务A和事务B两个事务并发操作该行数据。事务A执行了两次查询操作,事务B执行了一次更新操作;
(2)事务A执行第一次查询操作,先生成 readView ,我们姑且称之为 readView_1,还未开始查询操作,事务B率先执行了更新操作,将数据进行了修改并提交,事务B结束,此时事务A第一次查询开始,但由于事务A已经生成了 readView_1 ,所以它不会读取到事务B修改过后的数据,读取到的是 readView_1 中事务B修改之前的数据,解决了脏读的问题;
(3)然后,事务A进行第二次查询操作。注意!!!这里它又生成了一个 readView,我们称之为 readView_2,此时的 readView_2 中的数据是已经被事务B修改后的数据了,事务A再次进行查询,发现查询到的数据和刚操第一次查询到的不一样了,就产生了不可重复读的问题。
(4)所以说,MVCC 在读已提交隔离级别下只解决了脏读的问题,没有解决不可重复读的问题。
仍以上面的学生表举例,如下所示
(1)假设现在事务A与事务B并发操作来查询 student 表。事务A 执行SELECT查询操作,执行查询操作之前会生成一个 readView,我们姑且称之为 readView_1 ,事务A从始至终使用的都是 readView_1;
(2)此时事务B来修改 student 数据,可重复读隔离级别中一个事务生成一个 readView ,所以事务B也生成了一个 readView ,我们称之为 readView_2,然后事务B率先修改完毕并提交;
(3)事务A在事务B提交之后才进行的查询,按道理来说因为事务B修改了数据,我们会产生不可重复读,但是因为事务A从始至终都是用的 readView_1 ,所以 事务A在进行查询操作的时候,查询到的其实还是事务B修改之前的数据,由此就解决了不可重复读;而且即便事务A后续进行了多次 SELECT 查询操作,仍然使用最开始生成的 readView,解决了 不可重复读的问题。
(4)总结:其实归根结底,读已提交,可重复读两种隔离级别最关键的因素就是 readView 生成的时机不同,造就了它们不同的隔离级别。
刚才我已经解释过了 MVCC 中是如何解决不可重复读问题的,在 InnoDB 存储引擎中,幻读的问题也得到了解决,解决的方式是利用间隙锁;
还以下面这幅图举例说明
假设事务A与事务B并发执行,事务A要查询 id > 1的用户数据,那么在查询之前,数据库会对 id = 1 之后的区间加上间隙锁,也就是说在事务A执行期间,其他线程不可以在 id > 1 之后插入数据;当有其他操作想要插入数据时,会阻塞等待,只有事务A执行完毕释放了间隙锁,其他线程或者说事务才能进行插入操作,由此就避免了幻读的产生。