InnoDB学习(五)之MVCC多版本并发控制
MVCC多版本并发控制,是一种数据库管理系统并发控制的方法。MVCC多版本并发控制下,数据库中的数据会有多个版本,分别对应不同的事务,从而达到事务之间并发数据的隔离。MVCC最大的优势是读不加锁,读写不冲突,在读多写少场景中,读写不冲突可以大幅提升数据库的并发性能。
MVCC多版本并发控制
在MYSQL中,MyISAM存储引擎使用的是表锁,InnoDB存储引擎使用的是行锁。而InnoDB的事务分为四个隔离级别,其中默认的隔离级别是可重复读,可重复读要求两个并行的事务之间数据的修改互不影响,通过添加行锁的方式虽然可以实现两个事务之间数据据的修改互不影响,但是者两个事务之间存在锁等待的情况,影响数据库效率。所以InnoDB的可重复读没有采用行锁,而是使用了更为强大的MVCC。
MVCC只有在可重复读和读已提交的隔离级别下生效,其它两个隔离级别和MVCC不兼容,因为读未提交总是读最新的数据行,和事务版本无关,串行化则是会对所有读取的行加锁。由于可重复读的情况比较复杂,并且是MySQL的默认隔离级别,所以本文会用可重复读来讲解MVCC的原理。
可重复读
数据库有四种隔离级别:读未提交/读已提交/可重复读/串行化,可重复度是MySQL的默认事务隔离级别,它确保同一事务的多个实例在并发读取数据时,会看到一致的数据行。
数据行的一致性包含两部分:
- 情况1:已有数据的内容变更,在同一个事务中多次查询,查询结果应该相同,如果在当前事务中进行了修改,查询结果应该和当前事务中的修改结果相同;
- 情况2:数据行的增减,同一个事务只能查看到事务开启之前数据库中数据,或者由事务本身新增/删除的结果集,无法看到开启事务期间其它事务新增或删除的结果集;
InnoDB默认的隔离级别是可重复读,可以解决以上两种情况的数据行一致性问题。其中解决情况1中的数据行一致性问题就是通过MVCC多版本并发控制实现的。
InnoDB用过Gap锁实现情况2中的数据行一致性问题,不过本文不会对Gap锁进行介绍。
MVCC的作用
MVCC可以确保同一个事务,在事务起始到结束读到的某一个数据是一致的,并且多个事务之间互不阻塞。我们以一张用户表为例,说明MVCC版本控制的作用。
首先我们需要创建用户表,并向其中插入一条用户数据,SQL语句如下:
create table user_info
(
age int ,
name varchar(255)
);
insert into user_info(age,name) value (23,"张三");
假设有A,B,C三个事务,这三个事务中在不同时刻对读取了插入用户的信息,并对用户信息进行了修改,时间线如下:
- T1时刻,事务A开始,事务A读取
age=23
的用户,该用户的name
为张三
; - T2时刻,事务B开始,事务B读取
age=23
的用户,该用户的name
为张三
; - T3时刻,事务A修改
age=23
的用户,把name
修改为李四
; - T4时刻,事务A读取
age=23
的用户,该用户的name
为李四
,事务A提交事务; - T5时刻,事务B读取
age=23
的用户,该用户的name
为张三
,事务B提交事务; - T6时刻,事务C开始,事务C读取
age=23
的用户,该用户的name
为李四
,事务C提交事务;
MVCC的作用可以在T5时刻体现出来,此时事务A已经提交,并且修改age=23
的用户的name
为李四
,但是事务B看不到这次修改,事务B看到的age=23
的用户的name
为张三
。这是因为在可重复度的隔离级别下,InnoDB事务读取到的数据是快照读
,即事务B开始时为数据生成一个快照,事务B读到的数据始终都是这个快照,与快照读
对应的是当前读
:
- 当前读:读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁;
- 快照读:MVCC使用的就是快照读,在事务启动时为数据生成快照,快照读可以避免了加锁操作,提升数据库性能;
MVCC原理
MVCC的目的就是多版本并发控制,在InnoDB中引入MVCC就是为了解决读写冲突,MVCC主要包含三部分内容:数据库中的3个隐藏字段、UndoLog日志 、ReadView读视图,这三部分在MVCC中的作用分别如下所示:
- 隐藏字段:为数据添加额外的版本信息,是MVCC版本控制的基石;
- UndoLog:存储了多个版本的数据,不同版本数据隐藏字段的内容不同;
- ReadView:判断当前事务应该读取哪个版本的数据;
隐藏字段
隐藏字段意味着我们通过SQL语句查找不到这些字段,但是这些字段在数据库中实际存在并占用了存储空间。为了实现MVCC版本控制,InnoDB为每一行数据添加了以下3个隐藏字段:
DB_TRX_ID
:6字节,最后修改本记录的事务ID;DB_ROLL_PTR
:7字节,回滚指针,指向这条记录的上一个版本(存储于Rollback Segment);DB_ROW_ID
:6字节,隐藏主键,如果数据表没有显式主键,InnoDB用DB_ROW_ID构建聚簇索引;
我们使用以下SQL创建用户表,并向表中插入一条数据,新表会默认包含三个隐藏字段,表结构如下表所示。
create table user_info
(
age int,
name varchar(255)
);
insert into user_info(age,name) value (23,"张三");
|age|name|DB_TRX_ID|DB_ROLL_PTR|DB_ROW_ID|
|–|–|–|–||
|23|张三|1|0x222333|1|
UndoLog日志
我在另外一篇文章中介绍过UndoLog日志,从名字也可以看出来,UndoLog日志主要用于回滚事务。但是InnoDB中的MVCC的快照读也使用了UndoLog。UndoLog可以分为两大类:
- Insert UndoLog:事务中的Insert语句对应的UndoLog,只在事务回滚时需要,所以事务提交后可以被立即丢弃;
- Update UndoLog:事务在进行Update或Delete时产生的UndoLog; 不仅在事务回滚时需要,在快照读时也需要;所以不能随便删除,只有在快照读或事务回滚不涉及该日志时,对应的日志才会被Purge线程统一清除;
Purge线程:InnoDB中,被删除的数据不会直接删除,而是先标记为删除,无用的Update UndoLog也不会立即删除。这些数据都是通过InnoDB中的后台任务Purge线程进行删除的。
下文中我们以上文中的用户表以及数据为例,解释Update UndoLog的工作流程,如下为起始时user_info
表空间的数据状态:
-
T1时刻,事务A开始,事务Id为2,事务A读取
age=23
的用户,该用户的name
为张三
;此时没有修改数据库数据,没有生成UndoLog,表空间无变化; -
T2时刻,事务B开始,事务Id为3,事务B读取
age=23
的用户,该用户的name
为张三
;此时没有修改数据库数据,没有生成UndoLog,表空间无变化; -
T3时刻,事务A修改
age=23
的用户,把name
修改为李四
;此时由于事务A尚未提交,所以会给事务A生成一条UndoLog,UndoLog中存储了事务A修改前的数据,表空间中最新数据中的回滚指针指向这条日志; -
T4时刻,事务A读取
age=23
的用户,由于表数据中的记录的事务ID和事务A的事务ID一致,所以事务A会读取到表数据中的记录,读取到用户的name
为李四
,事务A提交事务; -
T5时刻,事务B读取
age=23
的用户,由于表空间中数据不满足可见性条件(下一节具体介绍),所以事务B会查找表数据的UndoLog,UndoLog中的数据满足可见性条件,所以查询到UndoLog中的用户,用户的name
为张三
,事务B提交事务; -
T6时刻,事务C开始,事务ID为3,事务C读取
age=23
的用户,由于事务C开始时事务A已经提交,所以事务C可以查询到已提交的数据,事务C读取到用户的name
为李四
; -
T7时刻,事务C开始,事务ID为3,事务C修改
age=23
的用户,把name
修改为王五
;此时由于事务C尚未提交,所以会给事务C生成一条UndoLog,UndoLog中存储了事务C修改前的数据;
从上面的例子可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的UndoLog成为一条记录版本线性链表,UndoLog的链首就是最新的旧记录,链尾就是最早的旧记录(UndoLog的节点可能会被Purge线程清除掉)
UndoLog是为回滚而用,具体内容就是复制事务前的数据库记录行到UndoBuffer,在适合的时间把UndoBuffer中的内容刷新到磁盘。UndoBuffer与RedoBuffer一样,也是环形缓冲,但当缓冲满的时候,UndoBuffer中的内容会也会被刷新到磁盘;与RedoLog不同的是,磁盘上不存在单独的UndoLog文件,所有的UndoLog均存放在主ibd数据文件中(表空间),即使客户端设置了每表一个数据文件也是如此。
ReadView读视图
ReadView就是事务进行快照读操作的时候生产的读视图,在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)
所以我们知道ReadView主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个ReadView读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的UndoLog里面的某个版本的数据。
ReadView遵循一个可见性算法,主要是将要被修改的数据的最新记录中的DB_TRX_ID(即当前事务ID)取出来,与系统当前其他活跃事务的ID去对比(由ReadView维护),如果DB_TRX_ID跟ReadView的属性做了某些比较,不符合可见性,那就通过DB_ROLL_PTR回滚指针去取出UndoLog中的DB_TRX_ID再比较,即遍历链表的DB_TRX_ID(从链首到链尾,即从最近的一次修改查起),直到找到满足特定条件的DB_TRX_ID, 那么这个DB_TRX_ID所在的旧记录就是当前事务能看见的最新老版本。
ReadView判断可见性的原理如下,在InnoDB中,创建一个新事务之后,当新事务读取数据时,数据库为该事务生成一个ReadView读视图,InnoDB会将当前系统中的活跃事务列表创建一个副本保存到ReadView。当用户在这个事务中要读取某行记录的时候,InnoDB会将该行当前的版本号与该ReadView进行比较。具体的算法如下:
- 设该行的当前事务ID为cur_trx_id,ReadView中最早的事务ID为min_trx_id, 最迟的事务ID为max_trx_id;
- 如果cur_trx_id < min_trx_id,那么表明该行记录所在的事务已经在本次新事务创建之前就提交了,所以该行记录的当前值是可见的。跳到步骤6.
- 如果cur_trx_id > max_trx_id,那么表明该行记录所在的事务在本次新事务创建之后才开启,所以该行记录的当前值不可见.跳到步骤5;
- 如果min_trx_id<= cur_trx_id <= max_trx_id, 那么表明该行记录所在事务在本次新事务创建的时候处于活动状态,从min_trx_id到max_trx_id进行遍历,如果cur_trx_id等于他们之中的某个事务id的话,那么不可见。跳到步骤5;
- 从该行记录的DB_ROLL_PTR指针所指向的回滚段中取出最新的UndoLog的版本号,将它赋值该cur_trx_id,然后跳到步骤2;
- 将该可见行的值返回;
总结一下:MVCC版本控制中,以事务第一次快照读为分界线,事务后续只能查找到第一次快照读及之前提交的数据版本,之后提交的数据版本不可见。
读已提交和可重复度
读已提交和可重复度隔离级别下的InnoDB快照读有什么不同?答案是:ReadView生成时机的不同,从而造成读已提交和可重复度级别下快照读的结果的不同:
- 可重复读隔离级别下,事务第一次快照读会生成ReadView时,ReadView会记录此时所有其他活动事务的快照,这些事务的修改对于当前事务都是不可见的。而早于ReadView创建的事务所做的修改均是可见;
- 读已提交隔离级别下的,事务每次快照读都会新生成一个快照和ReadView, 这就是我们在RC级别下的事务中可以看到别的事务提交的更新的原因;
总之在读已提交隔离级别下,是每个快照读都会生成并获取最新的ReadView;而在可重复读隔离级别下,则是同一个事务中的第一个快照读才会创建ReadView, 之后的快照读获取的都是同一个ReadView。
MVCC与幻读
幻读是指,同一个事务里面连续执行两次同样的SQL语句,可能导致不同结果的问题,第二次SQL语句可能会返回之前不存在的行。举例说明:T1时刻事务A和事务B同时开启,分别进行了快照读,然后事务A向数据库中插入一条新的记录,如果事务B可以读到这条记录,就出现了”幻读”,因为B第一次快照读没有读到这条数据。
MVCC是否可以解决幻读问题呢?答案是有的情况下可以解决,有的情况下不可以解决。如果事务B中的读是快照读,那么MVCC版本控制可以解决幻读问题;如果事务B中使用的是当前读,那么MVCC无法解决幻读问题。
- 快照读是基于MVCC和UndoLog来实现的,适用于简单Select语句;
- 当前读是基于Gap锁来实现的,适用于Insert,Update,Delete,Select … For Update, Select … Lock In Share Mode语句,以及加锁了的Select语句;
事实上,MVCC对于所有的当前读都无效,比如事务A修改数据之后,事务B去Update对应的数据,Update语句筛选条件针对的是数据库中当前的数据,而不是快照数据。
我是御狐神,欢迎大家关注我的微信公众号:wzm2zsd
参考文档
MySQL之MVCC与幻读
正确的理解MySQL的MVCC及实现原理
MySQL数据库事务各隔离级别加锁情况–read committed && MVCC
本文最先发布至微信公众号,版权所有,禁止转载!