多版本并发控制(MVCC)
概述
MVCC(Multi-Version Concurrency Control
,即多版本并发控制)是一种用于管理数据库并发访问的技术,旨在提升多个事务同时执行的效率和一致性。其基本思想是通过数据的多版本管理及事务访问控制,从而确保读操作无阻塞地进行。
多版本管理是指数据库将数据分为多个历史版本(亦称为”快照“),并通过特定的数据结构进行记录以实现数据的管理。而事务访问控制是指事务在访问数据时,数据库会根据特定的策略或规则来决定适合当前事务使用的数据版本。
MVCC没有正式的标准,各个数据库可能有不同的实现,甚至可能不采用此技术。在MySQL中,只有InnoDB存储引擎使用了MVCC技术,因为其它存储引擎不支持事务。针对InnoDB存储引擎,只有Read Committed
(读已提交)和Repeatable Read
(可重复读)两种隔离级别的实现依赖于MVCC机制,它们二者在事务访问控制上存在差异。在Read Uncommited
(读未提交)隔离级别下,事务总是获取到最新版本的记录;而在Serializable
(串行化)隔离级别下,事务通过锁机制实现顺序执行,从而保证了数据访问的一致性。
快照读与当前读
在介绍MVCC的实现原理之前,我们先来介绍两个重要概念:快照读和当前读。
快照读通常也称为”一致性非锁定读“,是一种基于MVCC机制(无锁机制)的读取方式。当一个事务执行快照读操作时,它总是能立即获得数据,而无需阻塞等待。由于数据是多版本的,因此快照读可能会访问到数据的旧版本(历史版本),而非最新版本。在MySQL中,SELECT查询语句默认采用快照读方式。
当前读通常也称为“锁定读”,是一种基于锁机制的读取方式。当一个事务执行当前读操作时,它可能会受到其他当前读事务的影响,从而进入阻塞等待状态。当前读访问的数据肯定是当前时刻的最新版本,而非旧版本。在MySQL中,加锁的SELECT查询语句均采用当前读方式,其中包括S锁(for share
)和X锁(for update
)。此外,写操作(UPDATE、DELETE、INSERT语句)也可以视为采用了当前读。
MVCC实现原理
对于InnoDB存储引擎,MVCC的实现基于三个核心部分:隐藏字段、Undo Log与ReadView。其中,Undo Log负责数据的多版本存储,而隐藏字段和ReadView则控制事务访问数据的可见性。下文将对这三部分内容进行详细介绍。
隐藏字段
在InnoDB存储引擎的聚簇索引中,每条记录的真实数据部分都会包含三个隐藏列,如下所示。
trx_id
: 最近一次插入或更新当前记录的事务id,删除操作的内部实现会被视为更新。roll_pointer
: 回滚指针, 指向当前记录关联的最新一条Undo Log。row_id
: 隐藏主键,只有在表中不存在PRIMARY KEY
与Unique
约束的字段时才会自动生成。
其中,trx_id
与roll_pointer
与MVCC机制密切相关。
Undo Log
根据《Undo日志》章节所述,InnoDB存储引擎在修改(包括新增)任一聚簇索引记录之前,都会预先生成一条Undo Log,这条Undo Log反映了该记录在未修改时的数据状态,即上一个数据版本。此外,聚簇索引记录内部也包含一个名为roll_pointer
的隐藏列,其专门用于保存当前记录关联的最新一条Undo Log的偏移量,也就是最近一次旧版本。
值得说明的是,除了Insert Undo外,其它类型的Undo Log也都包含trx_id
和roll_pointer
属性。随着InnoDB对聚簇索引记录所作的改动次数增加,多个Undo Log会通过roll_pointer
属性相连,形成一个版本链。具体来说,InnoDB在生成新的Undo Log时,不仅会存储当前记录修改前的用户数据,还会保存旧的roll_pointer
和trx_id
隐藏列的值。只有在新的Undo Log成功创建后,InnoDB才会更新当前记录的trx_id
为执行当前操作的事务id,并且使roll_pointer
指向新的Undo Log。
TIP
InnoDB在Undo Log中不会冗余存储记录的所有数据,而是仅保存必要的数据变更信息。因此,上图中的Undo Log表示是以一种简化和直观的方式设计的,旨在更好地阐释旧版本数据的概念。
Insert操作生成的Undo Log中不包含
roll_pointer
属性,原因是当前记录没有更早的版本。insert undo所占用的存储空间会在事务提交后由系统释放,但新增记录的roll_pointer
值不会被清除,且在后续的改动过程中,该值仍会在版本链中保留。Undo Log的生成过程是线程安全的,这是因为它只会在写操作(INSERT、UPDATE、DELETE)时被创建,而InnoDB通过锁机制确保不会有多个事务同时对同一记录进行写操作。
ReadView
ReadView是一种允许事务依据特定的可见性规则来访问数据的视图技术,它确定了哪些数据版本对该事务是可见的。这意味着,当事务访问特定记录时,ReadView能够确保它获取到的是正确的数据版本。
ReadView本质上是一个C++层面的类,它主要包含以下几个字段。
m_ids
: ReadView对象创建时其它未提交的活跃事务id列表,该列表不包括已提交事务和当前事务的id。m_creator_trx_id
: 创建当前ReadView对象的事务id,即当前事务id。m_low_limit_id
: 当前已分配的最大事务id加,即下一个将要分配的事务id,该字段的值通常由一个全局变量进行维护。 m_up_limit_id
: 活跃事务id列表m_ids
中最小的事务id,如果m_ids
列表为空,则该值为字段m_low_limit_id
的值。
执行SELECT查询的事务通过ReadView对象来控制对记录不同数据版本的访问。每个记录版本都包含一个trx_id
值,而InnoDB则会利用该trx_id
值以及ReadView对象中有关事务id的字段值来判断该版本是否对当前事务可见,具体判断步骤如下所示。如果最新版本对当前事务不可见,那么InnoDB会顺着版本链继续向下搜索,直到找到一个可见的版本;如果全部版本都不可见,那么这条记录将被排除。
- 如果版本的
trx_id
小于m_up_limit_id
,则说明生成该版本的事务在ReadView创建之前就已经提交了,所以该版本对于当前事务是可见的。 - 如果版本的
trx_id
等于m_creator_trx_id
,则说明该版本是由当前事务生成的,所以该版本对于当前事务是可见的。 - 如果版本的
trx_id
大于等于m_low_limit_id
,则说明生成该版本的事务是在ReadView创建之后开启的,所以该版本对于当前事务是不可见的。 - 如果版本的
trx_id
不在m_ids
中,则说明生成该版本的事务在ReadView创建之前就已经提交了,所以该版本对于当前事务是可见的;相反,如果版本的trx_id
存在于m_ids
中,则该版本不可见。值得说明的是,经过以上步骤,当前trx_id
的值必定位于区间[m_up_limit_id,m_low_limit_id)
内。此外,由于m_ids
是有序的,因此该步骤将使用二分查找算法,以提高搜索效率。
在InnoDB中,RC
(Read Commited
)和RR
(Repeatable Read
)两种事务隔离级别的实现都依赖于MVCC机制,而它们二者的关键区别体现在ReadView对象的创建时机上。在RC
隔离级别下,事务会在每次执行SELECT查询时创建一个新的ReadView对象。而在RR
隔离级别下,事务仅在进行第一次SELECT查询时创建一个ReadView对象,并且所有后续的SELECT查询都将使用这个初始创建的ReadView。
示例
数据环境
以下是一个示例,旨在更直观且简洁地阐述MVCC机制的实现原理。示例的数据环境初始化SQL如下,其中fg_user是用于演示的表。我们假设在该示例的数据初始化过程中,执行插入(INSERT)操作的事务id为
drop database if exists fatgod;
create database if not exists fatgod;
use fatgod;
create table if not exists fg_user
(
`id` int primary key auto_increment comment '主键',
`username` varchar(20) not null comment '用户名',
`email` varchar(30) not null comment '邮箱号',
`phone` varchar(30) not null comment '手机号'
);
insert into fg_user(username, email, phone)
values ('fatgod', 'y13511320621@163.com', '13511320621'),
('fatbaby', 'w17816568782@163.com', '17816568782');
RR
在当前数据库的事务隔离级别为RR
(可重复读)的前提下,现有事务A(id为
在事务A和事务B完成上述操作后,fg_user表中总共包含三条记录,id分别为
当事务A第一次执行SELECT查询时,它会创建一个ReadView对象,该对象的m_ids为[102]
,m_creator_trx_id为101
,m_low_limit_id为103
,m_up_limit_id为102
。此时,fg_user表的聚簇索引内存在三条记录,它们的id分别为
- id为
的记录:仅包含username为fatgod的版本。对于该版本来说,它的trx_id值( )小于ReadView对象的m_up_limit_id值( ),所以该版本对于当前事务A是可见的。 - id为
的记录:共包含两个版本,username分别为fatdemon和fatbaby。对于第一个版本(username为fatdemon)来说,它的trx_id值( )等于ReadView对象的m_creator_trx_id值( ),所以第一个版本对于当前事务A是可见的。 - id为
的记录:仅包含username为fatghost的版本。对于该版本来说,它的trx_id值( )位于ReadView对象的m_ids列表( )中,所以该版本对于当前事务A是不可见的。
当事务A第二次执行SELECT查询时,由于当前的事务隔离级别为RR
,因此它将复用第一次SELECT查询时创建的ReadView对象,即该ReadView对象的m_ids为[102]
,m_creator_trx_id为101
,m_low_limit_id为103
,m_up_limit_id为102
。此时,fg_user表的聚簇索引内也存在三条记录,它们的id分别为
- id为
的记录:共包含两个版本,username分别为fatbuddha和fatgod。对于第一个版本(username为fatbuddha)来说,它的trx_id值( )位于ReadView对象的m_ids列表中( ),所以第一个版本对于当前事务A是不可见的。而对于第二个版本(username为fatgod)来说,它对于当前事务A是可见的,这一结论已在第一次查询的分析探讨中得到验证。 - id为
的记录:共包含两个版本,username分别为fatdemon和fatbaby。对于第一个版本(username为fatdemon)来说,它对于当前事务A是可见的,这一结论已在第一次查询的分析探讨中得到验证。 - id为
的记录:仅包含username为fatghost的版本。对于该版本来说,它对于当前事务A是不可见的,这一结论已在第一次查询的分析探讨中得到验证。
基于上述分析,我们可以得出结论:在事务隔离级别为RR
的前提下,事务A的第一次SELECT查询将返回两条记录,第一条记录的id为
以下是事务B执行流程所对应的具体命令。从中可以看出,当前事务隔离级别确实为RR
(可重复读)。
RC
在演示以下操作前,我们需要将当前数据库的事务隔离级别更改为RC
(读已提交),具体命令如下。
set global transaction_isolation = 'Read-Committed';
在当前数据库的事务隔离级别为RC
(读已提交)的前提下,现有事务C(id为
在事务C和事务D完成上述操作后,fg_user表中总共包含四条记录,id分别为
当事务C第一次执行SELECT查询时,它会创建一个ReadView对象,该对象的m_ids为[202]
,m_creator_trx_id为201
,m_low_limit_id为203
,m_up_limit_id为202
。此时,fg_user表的聚簇索引内存在四条记录,它们的id分别为
- id为
的记录:共包含两个版本,username分别为fatdemon和fatbaby。对于第一个版本(username为fatdemon)来说,它的trx_id值( )小于ReadView对象的m_up_limit_id值( ),所以第一个版本对于当前事务C是可见的。 - id为
的记录:共包含两个版本,username分别为fatleader和fatghost。对于第一个版本(username为fatleader)来说,它的trx_id值( )等于ReadView对象的m_creator_trx_id值( ),所以第一个版本对于当前事务C是可见的。 - id为
的记录:仅包含username为fatelder的版本。对于该版本来说,它的trx_id值( )位于ReadView对象的m_ids列表( )中,所以该版本对于当前事务C是不可见的。
当事务C第二次执行SELECT查询时,由于当前的事务隔离级别为RC
,因此它将创新一个全新的ReadView对象,该对象的m_ids为[]
,m_creator_trx_id为201
,m_low_limit_id为203
,m_up_limit_id为203
。此时,fg_user表的聚簇索引内也存在四条记录,它们的id分别为
- id为
的记录:共包含三个版本,username分别为fatceo、fatbuddha和fatgod。对于第一个版本(username为fatceo)来说,它的trx_id值( )小于ReadView对象的m_up_limit_id值( ),所以第一个版本对于当前事务C是可见的。 - id为
的记录:共包含两个版本,username分别为fatleader和fatghost。对于第一个版本(username为fatleader)来说,它的trx_id值( )等于ReadView对象的m_creator_trx_id值( ),所以第一个版本对于当前事务C是可见的。 - id为
的记录:仅包含username为fatelder的版本。对于该版本来说,它的trx_id值( )小于ReadView对象的m_up_limit_id值( ),所以该版本对于当前事务C是可见的。
基于上述分析,我们可以得出结论:在事务隔离级别为RC
的前提下,事务C的第一次SELECT查询将返回两条记录,第一条记录的id为
以下是事务D执行流程所对应的具体命令。从中可以看出,当前事务隔离级别确实为RC
(读已提交)。
MVCC的一致性问题
从上述内容中可知,在数据库设置为RC
或RR
事务隔离级别的情况下,当事务执行默认的SELECT查询,即不使用X锁也不使用S锁时,InnoDB会借助MVCC机制来确保查询操作既不会被阻塞也不会干扰其它事务运行。这两个隔离级别的主要差别在于事务查询时ReadView对象的创建时机不同,该差异直接影响了事务在各自隔离级别下对数据版本的访问模式,进而可能引发不同的一致性问题。以下将对RC
和RR
隔离级别进行分类讨论,分析它们各自可能产生的一致性问题。
RC
: 在事务访问数据版本时,MVCC通过可见性判断算法,有效解决了读未提交的问题。不过,由于每次查询都会生成一个新的ReadView对象,这可能会导致不可重复读以及幻读问题的发生。RR
: 在事务访问数据版本时,MVCC通过复用首次查询时创建的ReadView对象,有效解决了不可重复读问题,并显著降低了幻读问题的发生概率。然而,由于ReadView无法阻止当前事务修改其它事务新增的记录,因此幻读问题仍然无法彻底消除。
接下来,我们将例举了一个幻读问题发生在RR
事务隔离级别下的示例。我们需要将当前数据库的事务隔离级别更改回RR
(可重复读),具体命令如下。
set global transaction_isolation = 'Repeatable-Read';
在当前数据库的事务隔离级别为RR
(可重复读)的前提下,现有事务M和事务N对fg_user表进行了若干SQL操作,具体操作流程如下所示。
以下是事务M执行流程所对应的具体命令。可以看到,在同一个事务内,两次完全相同的查询返回了不同的数据结果,且第二次查询结果包含了第一次查询结果中未出现的数据。造成这一现象的根本原因在于当前事务修改了其他事务已提交的新记录,使其从不可见变为了可见。
以下是事务N执行流程所对应的具体命令。从中可以看出,当前事务隔离级别确实为RR
(可重复读)。
二级索引与MVCC
二级索引(非聚簇索引)中的记录不包含用于多版本并发控制(MVCC)的隐藏属性trx_id
和roll_pointer
,所以无法构成版本链。不过,二级索引列的值仍可能被多个事务修改,这引出了一个问题:当前事务在访问这些记录时,如何判断哪个版本是可见的。在介绍可见性判断方法之前,我们需要先了解以下两个关键知识点。
- 当普通索引列的数据被修改时,相关的记录在二级索引结构中会被标记为删除,并生成新的记录。这种处理方式与主键修改时聚簇索引的变化相似,均采用了“假删除”策略。由于二级索引中的记录未被真正删除,因此其他事务仍可能访问到这些假删除的记录。
- 二级索引页的页头
Page Header
部分包含一个名为PAGE_MAX_TRX_ID
的属性,该属性记录了对当前二级索引页进行写操作的最大事务id。
当事务执行查询时,由优化器决定是否利用二级索引。同时,二级索引中记录的可见性判断依赖于ReadView对象,具体步骤如下。
检查ReadView对象中的
m_up_limit_id
字段是否大于记录所在页的PAGE_MAX_TRX_ID
属性。如果条件成立,则说明该页的所有记录对于当前事务是可见的,因此该二级索引记录也是可见的;否则,进入下一步。通过记录的主键值进行回表操作,在聚簇索引中找到对应记录。随后,在聚簇索引记录的版本链中利用可见性判断算法搜索第一个对当前事务可见的版本。如果找不到一个可见的版本,则说明该二级索引记录对于当前事务是不可见的;否则,进入下一步。
核对第一个可见版本对应的二级索引列值是否与该二级索引记录的索引列值相同。如果相同,则说明该二级索引记录对于当前事务是可见的;反之,则不可见。