在了解MySql的MVCC实现之前,需要先了解什么是快照读和当前读,以便于后续讲解
快照读:就是单纯的 SELECT 语句,不包括下面这两类语句:
SELECT ... FOR UPDATE
SELECT ... LOCK IN SHARE MODE
在RC事务隔离级别下,快照读和当前读显示数据是一样的
在RR事务隔离级别下,快照读有可能读取到的不是最新数据
当前读:每次都获取最新数据,但是获取最新数据时会自动加锁,读的过程中不允许写入数据。
此时有人可能会问,什么时候是快照读,什么时候是当前读?
普通的SELECT语句一般查询都是快照读,直接读取快照中的数据,在事务创建时,只有第一次执行SELECT语句才会生成快照,并不是事务创建后立马生成快照,在InnoDB引擎中默认的RR事务级别下,第二次执行SELECT语句会直接读取第一次SELECT生成的快照,所以说快照读有可能读取到的不是最新数据。
在执行以下SQL语句时,会进行当前读
select … lock in share mode 、
select … for update、
update 、delete 、insert
我们假设一个场景:
当前DB已有id 5, 10, 15三条数据。
事务A查询id < 10的数据,可以查出一行记录id = 5
事务B插入id = 6的数据
事务A再查询id < 10的数据,可以查出一行记录id = 5,查不出id = 6的数据(读场景,解决了幻读)
事务A可以执行更新/删除操作,然后查询id < 10 的数据,会发现可以查询到5和6两条数据(更新和删除操作进行了当前读,快照中的数据发生了变更)
换句话说:MySQL innodb 在 RR 隔离下一样会出现幻读,next-key lock 和 MVCC 只解决了部分幻读的场景。
故而:在标准的RR下,并没有彻底解决幻读问题,但是在Mysql的innodb引擎中使用 Next-Key lock加锁,读数据期间不允许写操作,从而彻底解决了幻读问题
了解了快照读和当前读以后,我们再继续看一下MVCC的具体实现机制
MVCC是Multi-Version Concurrency Control(多版本并发控制)的缩写,MVCC没有统一的实现标准,不同的存储引擎对MVCC的实现方式是不同的,典型的有乐观并发控制和悲观并发控制。InnoDB对MVCC的实现采用的是乐观并发控制。
《高性能MySQL》这个书中介绍InnoDB-MVCC实现方式:
InnoDB的MVCC,是通过在每行记录后保存两个隐藏的列来实现的(用户不可见)。一个列保存行创建的时间,一个列保存行过期(删除)的时间,这里所说的时间并不是传统意义上的时间,而是系统版本号
- SELECT
InnoDB会根据以下两个条件检查每行记录:
(1)InnoDB只查找版本早于当前事务版本的数据行(行的系统版本号小于或者等于事务的系统版本号),这样可以确保事务读取到的行,要么是在事务开始之前已经存在的,要么是事务自身插入或者修改过的;
(2)行的删除版本要么未定义,要么大于当前事务版本号。可以确保事务读取到的行,在事务开启之前未被删除。- INSERT
InnoDB为新插入的每一行保存当前系统版本号作为行版本号。- DELETE
InnoDB为删除的每一行保存当前系统版本号作为行删除标识。- UPDATE
InnoDB将更新后的列作为新的行插入数据表,并保存当前系统版本号作为该行的行版本号,同时保存当前系统版本号到原来的行作为行删除标识。
读完后,我们假设场景:在RR隔离级别下我开启了一个事务(事务版本号:1),并且插入了一条id = 10的数据行,这条数据的行版本号应该是创建它的事务版本号1,此刻(上一条事务未提交)我新开启一个事务(事务版本号2),对全表进行SELECT,按照上面的逻辑当前事务可以查询到版本小于当前事务版本的数据行,那就是说可以查询到id = 10的数据。但是,按照RR隔离级别的约定,版本号为2的事务并不被允许读取到这行数据,这就产生了矛盾。说到这里,大家也都应该能体会到我说的问题所在了。
接下来我们看看MySql官方文档说法:明确指出InnoDB为每一行数据都添加了三个隐藏字段,而删除标记有没有开辟特有的字段并未显式的说明,只说了在“特殊位置”被标记删除。也就是说,InnoDB 的叶子段存储了数据页,除了用户定义的字段以外还有三个隐式的字段。简单的结构如下:
DB_ROW_ID:6-byte,隐藏的行 ID,用来生成默认聚簇索引。如果我们创建数据表的时候没有指定聚簇索引,这时 InnoDB 就会用这个隐藏 ID 来创建聚集索引。采用聚簇索引的方式可以提升数据的查找效率。
DB_TRX_ID:6-byte,操作这个数据的事务 ID,也就是最后一个对该数据进行插入或更新的事务 ID。
DB_ROLL_PTR:7-byte,回滚指针,也就是指向这个记录的 Undo Log 信息。
什么是回滚段?
回滚段是一个保存每条数据行之前版本日志的地方。回滚段中的撤销日志分为插入和更新撤销日志,插入撤销日志仅仅在事务回滚时需要,事务一提交就可以丢弃,更新撤销日志也用于一致性读取,但是只有在InnoDB没有分配快照的情况下,才可以丢弃这些快照,在一致性读取中可能需要更新撤销日志中的信息来构建数据库行的早期版本。
回滚段如何构造?
当一个事务更新一条记录时,会将更新后的记录作为新的一行插入,将旧的行构建为undo_log记录在回滚段中,并将新数据行的回滚字段DB_ROLL_PTR指向这个undo_log,当多个事务更新同一条事务时,undo_log会形成链式结构。
我们开始具体讨论InnoDB-MVCC是如何实现多版本控制的。多版本实际上就是不同的事务都有着自己可视的数据版本,不同的事务数据版本是不同的。InnoDB-MVCC通过快照读的方式构建事务自己的可视版本,简单来说就是在事务操作之前获取当前的数据快照,这个快照所“呈现”的数据就是我当前事务的可视版本。那么快照该如何构建呢?继续往下看。
READ_VIEW
read_view是MySQL底层实现的一个结构体,是和SQL语句绑定的,在每个SQL语句执行前申请或获取。可以将其理解为构造快照的前提或者依据,一个快照所呈现的数据是什么样子(版本)的基本依赖于read_view中所存储的数据。
READ_VIEW底层实现
read_view是MySQL底层使用C++代码实现的一个结构体,构建当前可视版本(快照)主要用到的变量有low_limit_id、up_limit_id、trx_ids以及creator_trx_id:
low_limit_id:表示创建read_view时,当前事务活跃读写链表中最大的事务ID
up_limit_id:表示创建read_view时,当前事务活跃读写链表中最小的事务ID
trx_ids:创建read_view时,活跃事务链表里所有的事务ID
creator_trx_id:当前read_view所属事务的事务版本号
什么是当前事务活跃读写链表呢?可以将其理解为一个事务池,事务池中所存储的是当前所有正在运行(已开启但未提交)的事务。MySQL将当前所有活跃的事务保存在information_schema.innodb_trx表中,如下图所示:
MVCC会根据read_view中所保存的信息来构建当前事务可视版本。
对于小于或者等于RC的隔离级别,事务开启后,每次执行SQL语句都会申请一个read_view,然后在执行完这个SQL语句后,调用read_view_close_for_mysql将read view从事务中删除。每次在执行SQL语句之前都会判断trx->read_view为空(理论下必为空),然后重新申请一个read_view(这就是为什么RC隔离级别下会产生不可重复读的原因)。
对于RR隔离级别,当申请一个read_view后,事务未提交不会删除,整个事务将不再申请新的read_view,保证事务中所使用的read_view都是同一个,从而实现可重复读的隔离级别。
MVCC-SELECT可见范围(总结)
了解了这么多,我们再回过头来总结一下MVCC的SELECT规则。因为除了上面所提到了部分内容,官方文档中也没有很详细的介绍MVCC的具体操作,我看了很多网上的总结,有人总结了三条,也有人总结了四条,但通过分析以后,本文总结六条供大家参考:
(1):DB_TRX_ID >= view->low_limit_id的记录不可见。DB_TRX_ID >= view->low_limit_id的记录必为当前事务开启之后开启的事务更新或插入的,所以不可见;
(2):DB_TRX_ID位于[view->up_limit_id,view->low_limit_id)区间时,如果存在于trx_ids集合中,则不可见。如果DB_TRX_ID存在于这个集合中,说明该记录的修改或创建者(事务)在当前事务开启时并未提交,所以不可见;
(3):DB_TRX_ID up_limit_id的记录可见。DB_TRX_ID up_limit_id,说明该记录的修改或创建者(事务)在当前事务开启之前已经提交,所以可见;
(4):DB_TRX_ID = creator_trx_id的记录可见。DB_TRX_ID = creator_trx_id说明该记录的修改或创建者(事务)是当前事务,所以可见;
(5):DB_TRX_ID != creator_trx_id的被标记删除记录可见。所有被删除且已提交的事务将被真正删除(删除但未提交只是标记删除),所以不会查询到,标记删除的记录除自身删除的以外,当前事务可见,DB_TRX_ID = creator_trx_id为自身删除所以不可见,其余皆可见;
(6):以上对于view不可见的记录,需要通过记录的DB_ROLL_PTR指针遍历回滚段中的undo_log构造当前view可见版本数据。不可见的记录只是说明该记录的当前版本不可见,但是它之前的某一版本是当前事务可见的,所以应当构建出该数据当前事务的可见版本。