1 前言

MVCC,全称是 Multi-Version Concurrency Control,即事务多版本并发控制。

如果事务之间没做隔离,那么它们并发时会引起一系列问题,所以需要对进行事务隔离,并且有四种不同的隔离级别。

MVCC就是InnoDB引擎解决事务并发问题的实现方式,也就是InnoDB事务隔离的实现方式。

MVCC的原理是为每个事务建立一个数据镜像,然后它们在各自的数据镜像里完成任务,以保证事务的隔离性、一致性。通过数据镜像,MVCC避免多事务读写时需要加锁的问题,实现了一致性,又保证了运行效率。

要注意的是,多事务对同一记录的并发写写操作,依然是要事务相互阻塞的,因为会加上排它锁。

2 并发引起的问题及四种隔离级别

2.1 三种事务并发场景

场景 问题
读读 没有任何问题
读写 可能会造成事务隔离性问题,产生脏读、不可重复读或幻读
写写 可能会造成更新丢失问题等问题

2.2 并发引起的问题

读写和写写可能出现的问题归纳起来可以分为四类,更新丢失、脏读、不可重复读和幻读。

问题 描述说明
更新丢失 当两个或多个事务选择同一行,然后基于最初选定的值执行更新该行操作
由于每个事务都不知道其他事务的存在,就会发生丢失更新问题
脏读 事务A读取了事务B更新的数据
然后事务B回滚操作
那么事务A读取到的数据是脏数据
不可重复读 事务A多次读取同一数据
事务B在事务A多次读取的过程中,对数据作了更新并提交,
于是事务A多次读取同一数据的结果不一致
幻读 事务A先是确认了某个记录不存在
此时事务B插入了该记录
之后事务A执行插入该记录是发现报错,数据已存在
面对这个现象,事务A如同产生了幻觉一样

2.3 四种隔离级别

解决上述事务并发带来的问题的方法,就是做好并发事务之间的隔离。但隔离的越是彻底,所要付出的代价也越大,有时甚至大到不值得。比如串行化所有事务进行彻底隔离,所有的问题都消失了,但是效率极其低下,现实应该很少项目会用这种方式。

有四种隔离级别,不同程度的解决事务并发带来的问题。

隔离级别 描述说明
读未提交
(READ-UNCOMMITTED)
事务最低的隔离级别
它充许另外一个事务可以看到这个事务未提交的数据
写数据会锁住相应行
读已提交
(READ-COMMITTED)
保证一个事务修改的数据提交后才能被另外一个事务读取
即另外一个事务不能读取该事务未提交的数据
可重复读
(REPEATABLE-READ)
保证一个事务相同条件下前后两次获取的数据是一致的
串行化
(SERIALIZABLE)
读写数据都会锁住整张表
事务被处理为顺序执行

各个隔离解决能解决的问题:

隔离级别 更新丢失 脏读 不可重复读 幻读
读未提交(READ-UNCOMMITTED) 不可能 可能 可能 可能
读已提交(READ-COMMITTED) 不可能 不可能 可能 可能
可重复读(REPEATABLE-READ) 不可能 不可能 不可能 可能
串行化(SERIALIZABLE) 不可能 不可能 不可能 不可能

3 MVCC概述

MVCC就是InnoDB解决上述并发问题的实现。

可以认为MVCC是⾏级锁的⼀个变种, 但是它在很多情况下避免了加锁操作, 因此开销更低。 虽然实现机制有所不同, 但⼤都实现了⾮阻塞的读操作, 写操作也只锁定必要的⾏。

MVCC的实现, 是通过保存数据在某个时间点的快照来实现的。 也就是说, 不管需要执⾏多长时间, 每个事务看到的数据都是⼀致的。 根据事务开始的时间不同, 每个事务对同⼀张表, 同⼀时刻看到的数据可能是不⼀样的。

可以理解为,在创建数据快照之后,以此快照为起点,不同的事务进入不同的平行世界,这样也就彼此隔离了。

一个行记录可以有多个版本,每个版本通过DB_TRX_ID隐式字段标记所属的事务ID,然后每个事务只能看到属于自己的记录,这样就实现平行世界。

undo日志记录历史操作,在回滚时候可以通过它找到原始数据。

4 MVCC几个核心概念

这几个概念是理解MVCC的运作机制的前提。

4.1 隐式字段

每张表的每行数据记录除了我们显式定义的字段外,其实还有其他隐藏字段,其中就有MVCC相关的4个字段。

字段 名称 大小 功能
DB_TRX_ID 事务ID 6byte 存储最新更新这条行记录的事务ID
DB_ROLL_PTR 回滚指针 7byte 指向当前记录项的rollback segment的undo log记录
找之前版本的数据就是通过这个指针
DB_ROW_ID 隐藏的自增ID 6byte 它是InnoDB表的隐式自增主键,如果表没有设置主键,InnoDB将以它为主键创建聚簇索引
DELETED_FLAG 删除标识 1byte 用于标识该记录是否被删除
如果标示删除,在commit的时候会执行真的删除操作

例如:

name sex age DB_ROW_ID DB_TRX_ID DB_ROLL_PTR DELETED_FLAG
张三 1 56 1000 31234 0x12345678 0

这条记录中,DB_ROW_ID是数据库默认生成的该行唯一隐式主键,DB_TRX_ID是该记录可被看见及操作的事务ID,DB_ROLL_PTR执行该记录的上一个办法的undo log地址,DELETED_FLAG标示为不删除。

4.2 undo日志

MVCC中事务中对行做的更变操作都会记录在undo日志中。

undo日志主要记录的是数据的逻辑变化,为了在发生错误时回滚之前的操作,需要将之前的操作都记录下来,然后在发生错误时才可以回滚。

当一个事务需要读到老版本的数据,可以顺着 undo日志版本链去寻找。

4.2.1 存储位置

undo日志默认被记录到系统表空间(ibdata)中,但从5.6开始,也可以使用独立的Undo表空间

4.2.2 日志分类

类别 说明
insert undo log 事务对insert新记录时产生的undo log
只在事务回滚时需要,,并且在事务提交后就可以立即丢弃
update undo log 事务对记录进行delete和update操作时产生的undo log
在事务回滚时需要,一致性读也需要,所以不能随便删除
当数据库所使用的快照中不涉及该日志记录,对应的undo日志会被purge线程删除

4.2.3 构建版本链

每行记录要被修改时,会先拷贝一份到undo日志,再把undo日志的地址写入DB_ROLL_PTR字段。多次修改之后就形成了该行的历史版本链。

4.3 读视图

视图读是一个快照,记录系统当前活跃事务的ID。

4.3.1 作用

  • 主要是用来做可见性判断的
  • 当执行快照读时,需要用读视图提供的信息来判数据对当前事务是否可见

4.3.2 生成时机

在不同的隔离级别中,读视图生成的时机不一样,导致同样的操作在不用的级别可见数据不一样。

级别 生成方式
读已提交
(READ-COMMITTED)
事务中每条select语句都会创建一个读视图(read view)
可重复读
(REPEATABLE-READ)
事务在begin/start transaction之后的第一条select读操作后,会创建一个读视图(read view)

4.3.3 全局属性

读视图有3个重要属性:

属性 说明
trx_list 读视图生成时系统正活跃的事务ID
up_limit_id 读视图生成时系统正活跃的最小的事务ID
low_limit_id 读视图生成时系统系统尚未分配的下一个事务ID,也就是目前已出现过的事务ID的最大值+1
不是 trx_list最大的事务ID+1

4.3.4 可见算法

读视图就是为了可见性判断,是DB_TRX_ID 与读视图的属性进行比较。

比较情况 说明
DB_TRX_ID < up_limit_id 说明该版本的记录在当前事务之前生成,所以该记录对当前事务可见
DB_TRX_ID >= low_limit_id 说明该版本的记录在生成 读视图 之后生成,所以该记录对当前事务不可见
up_limit_id < DB_TRX_ID < low_limit_id 如果 DB_TRX_ID 不在 trx_list 中,说明在生成读视图时,那个事务已经完成commit,所以可见
如果 DB_TRX_ID 在 trx_list 中,说明在生成读视图时,那个事务还在活动,未commit,所以不可见

4.4 当前读与快照读

读类型 说明
快照读 快照读顾名思义就是读取快照里的数据,MVCC里当前事务的快照数据
利用读视图的可见算法
简单的select操作就是快照读,读取属于当前事务的快照数据
MVCC的快照读就实现了不用加锁解决读写冲突
当前读 读取是最新版本的记录,而不管这个最新版本是哪个事务修改的
select lock in share mode
select for update
update
insert
delete
等等这些操作都是一种当前读,它们操作的是最新版本的记录

5 MVCC的实现

通过在增删改查中运用隐式字段undo日志读视图读视图可见算法就实现了无行锁解决并发读写操作的隔离问题。

5.1 SELECT

  • InnoDB只查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的(运用了读视图可见算法
  • 行的删除版本要么未定义,要么大于当前事务版本号。这可以确保事务读取到的行,在事务开始之前未被删除
  • 只有符合上述两个条件的记录, 才能返回作为查询结果

5.2 INSERT

  • InnoDB为新插入的每一行的DB_TRX_ID隐式字段保存当前事务ID作为行版本号
  • undo日志中插入一条 insert undo log,回滚时依据该日志撤除该记录,然后把这个日志地址写入新行的 DB_ROLL_PTR

5.3 DELETE

  • 对该行记录加排它锁(commit的时候才释放)
  • InnoDB把该行的DB_TRX_ID隐式字段保存当前事务ID作为行版本号
  • undo日志中插入一条 update undo log,并把日志地址写入DB_ROLL_PTR
  • 标记DELETED_FLAG 为删除的状态(实际上并没有删除掉,commit的时候才真的删除)

5.4 UPDATE

  • 对该行记录加排它锁(commit的时候才释放)
  • undo日志中插入一条 update undo log,把当前记录的数据拷贝该undo日志
  • 然后更新该被修改行的 DB_TRX_ID 为当前事务ID,并把DB_ROLL_PTR值指向上一步新插入的update undo log
  • 如果多次update该行数据,反复执行上面操作,就形成了该行记录的版本链

6 案例分析

MVCC为默认级别,假设有如下表person:

name sex age DB_ROW_ID DB_TRX_ID DB_ROLL_PTR
张三 1 52 1 9000 0x12345678
李四 1 48 2 9000 0x12349999

现有2个并发事务

时间线 事务10000 事务10001
T0 begin; begin;
T1 select * from person;
T2 update person set age = 60 where name = ‘张三’;
T3 select * from person;
T4 update person set age = 70 where name = ‘张三’;
T5 commit;
T6 select * from person for update
T7 rollback;

T1时刻

事务10000生成读视图,执行一个快照读。因为表中所有记录的DB_TRX_ID都小于读视图中的up_limit_id,因此该读取操作可以看到所有的数据。

1
2
3
4
5
6
7
8
mysql> select * from person;
+--------+-----+-----+
| name | sex | age |
+--------+-----+-----+
| 张三 | 1 | 52 |
| 李四 | 1 | 48 |
+--------+-----+-----+
2 rows in set (0.00 sec)

T2时刻

事务10000执行一个update操作,修改了张三的年龄。

DB_ROW_ID为1的那行记录的DB_TRX_ID更改为当前事务ID,DB_ROLL_PTR指向了update过程中生成的undo日志,它存放着修改之前的数据。

name sex age DB_ROW_ID DB_TRX_ID DB_ROLL_PTR
张三 1 60 1 10000 0x13445675
李四 1 48 2 9000 0x12349999

T3时刻

事务10001生成读视图,执行一个快照读。依据可见算法,它看到的还是T0时刻的数据。被修改的张三的老版数据,是在unlog日志的版本链中找到并呈现给事务10001。

name sex age DB_ROW_ID DB_TRX_ID DB_ROLL_PTR
张三 1 52 1 9000 0x12345678
李四 1 48 2 9000 0x12349999

此时刻两个事务在读写并发场景,由于有了MVCC,没有发生相互阻塞,也保证了并发事务的隔离性。

T4时刻

事务10001执行一个update操作,也修改了张三的年龄。此时会发生阻塞,因为张三那行的排它锁并没有被释放。

T5时刻

事务10000进行commit操作,张三行的排它锁被释放。

T6时刻

事务10001执行select操作,注意这不是快照读,而是一个当前读,它要求去读取最新版本的记录,而不管这个最新版本是哪个事务修改的。所以我们看到的是张三行在另外一个已经提交事务中修改后的新数据。

1
2
3
4
5
6
7
8
mysql> select * from person for update;
+--------+-----+-----+
| name | sex | age |
+--------+-----+-----+
| 张三 | 1 | 60 |
| 李四 | 1 | 48 |
+--------+-----+-----+
2 rows in set (0.00 sec)

7 小结

MVCC本质上就是一个复杂些的乐观锁。

另外,MVCC无法解决幻读问题,需要用到next-key锁