简介

MVCC (multiversion concurrency control),多版本并发控制,主要是通过在每一行记录中增加三个字段,与 undo log 中相关记录配合使用,同时加上可见性算法,使得各个事务可以在不加锁的情况下能够同时地读取到某行记录上的准确值(这个值对不同的事务而言可能是不同的)。使用 MVCC,在不加锁的情况下也能读取到准确的数据,大大提高了并发效率。

什么是MVCC

指多版本并发控制,让普通的select语句直接读取指定版本的值,避免加锁,来提高并发请求时的性能,配合行锁机制,在并发请求下,提高了MYSQL的性能

MVCC解决了什么问题

  1. 做到了读不影响写,写不影响读,提高了并发性能
  2. 提供了一致性读的功能,避免不可重复读和快照读的幻读 (间隙锁解决当前读的幻读)

什么时候会用到MVCC

在RC和RR隔离级别下,innodb通过快照读方式读取数据时使用

MVCC实现原理

通过保存数据在某个时间点的快照来实现,具有以下两个特点
不管执行多长时间,同一个事务在执行的过程中看到的数据是一致的
根据事务的开始时间不同,不同事务的对同一张表,同一时刻看到的数据可能是不一样的

快照读与当前读

读操作可以分成两类,快照读与当前读

  • 快照读
    • 读取的是记录数据的可见版本(可能是过期的数据),不用加锁
    • 简单select使用该读取方式
  • 当前读
    • 读取的是记录数据的最新版本,并且当前读返回的记录都会加上锁,保证其他事务不会再并发的修改这条记录
    • select … lock in share mode
    • select … for update
    • insert
    • update
    • delete
    • 以上查询将使用当前读

快照读的幻读 -> mvcc 解决
当前读的幻读 -> gap 锁解决

MVCC如何解决快照读幻读和不可重复读

关键在于创建ReadView的时机

在RC隔离级别下,单个事务每次执行SELECT语句时都会创建ReadView,所以两个相同条件的查询可能由于随着时间的推移,ReadView更新后可以看到更多已提交的数据,导致不可重复读和幻读

在RR隔离级别下,单个事务只会在第一次执行SELECT查询时创建ReadView, 所以整个事务期间可以看到的数据都是相同的,不会出现不可重复读和幻读 思考一下表述

事务

提到 MVCC,必须提到事务。关于事务,有四个特性,即我们常说的 ACID。

  • 原子性(Atomicity):表示事务要么全部执行,要么全部不执行,这是一个不可分割的最小单元
  • 一致性(Consistency):表示事务总是从一个一致的状态转移到另一个一致的状态
  • 隔离性(Isolation):表示各个事务之间相关隔离,互不影响
  • 持久性(Durability):指一个事务一旦被提交,它对数据库的改变就是永久性的,即使后续数据库发生故障也不会有影响

而事务隔离性又分为四种级别:读未提交(read uncommitted)、读提交(read committed)、可重复读(repeatable read)、串行化(serializable)。

  • 读未提交:指一个事务还没有提交,它本身所做的修改就能被其他事务所看到。在这种情况下,会产生脏读、幻读和不可重复读的问题。
  • 读提交:指一个事务提交之后,它本身所做的修改就能被其他事务所看到。在这种情况下,解决了「读未提交」的脏读问题,但是仍然会产生幻读和不可重复读的问题。
  • 可重复读:指在同一个事务之中,读到的数据是一致的。这种隔离级别下,可以解决脏读和不可重复读的问题,但是仍然存在幻读的问题。
  • 串行化:指多个事务中,如果读写锁冲突时,后访问的事务必须等前一个事务执行完成后才能继续执行。这种隔离级别最高,也解决了脏读、幻读和不可重复读的问题。但是其也大大限制了并发的程度。

关于这四种隔离级别的差异,可以通过以下例子(例子来源于:林晓斌:MySQL实战45讲)来加以说明。

假设存在一张表,里面只有一个字段和一条记录,值是 1,现在发生以下的操作

时刻 事务A 事务B
t1 启动事务,查询得到值 1
t2 启动事务
t3 查询得到值 1
t4 将 1 改成 2
t5 查询得到值 V1
t6 提交事务
t7 查询得到值 V2
t8 提交事务
t9 查询得到值 V3

针对不同的隔离级别,V1、V2、V3 读到的值不同。

在「读未提交」的隔离级别下,由于 t4 时刻事务 B 将值改成了 2,虽然 B 还没提交事务,但是此时的修改对其他事务是可见的,所以 V1、V2、V3 查询到的值都是 2。

在「读提交」的隔离级别下,t4 时刻修改了值,但是在 t5 时刻,事务 B 还没有提交,此时事务 A 读取到的值还是老的值,所以 V1 是 1,而在 t7 时刻,由于事务 B 已经在 t6 时刻提交了,此时事务 B 所做的修改对其他的事务都可见,所以事务 A 在 t7 时刻能看到事务 B 的修改,此时 V2 的值为 2,当然 V3 的值也为 2。

在「可重复读」的隔离级别下,遵循 “事务在执行期间看到的数据必须是前后一致” 的要求,所以无论事务 B 是否修改值,也无论事务 B 是否提交,事务 A 在没提交前读到的值都是相同的,即 V1 和 V2 的值都是 1,当 A 事务提交后,再次查询时,事务 B 的修改就能被 A 看到了,所以 V3 的值为 2。

在「串行化」的隔离级别下,当事务 B 在 t4 时刻执行更新时,由于与事务 A 操作的是同一行,且出现读写冲突,此时事务 B 被会阻塞,等待事务 A 执行完毕后,再执行事务 B,所以 V1 和 V2 的值是 1,V3 的值是 2。

MVCC

更新操作

在数据库表的记录中,每一个记录都会添加三个字段:

  • DB_TRX_ID:6个字节,表示最近一次修改本记录的事务ID
  • DB_ROLL_PTR :7 个字节,回滚指针,指向回滚段中的 undo log record,用于找出这个记录的上个修改版本的数据。
  • DB_ROW_ID:6 个字节,一个单调递增的 ID,确定表中记录的唯一性。

当对某个记录进行更新时,会将当前记录写入 undo log 中,并更新当前记录中 DB_ROLL_PTR 字段值,使其指向刚才的 undo log record,然后更新当前记录相关字段值,同时更新 DB_TRX_ID 字段,记录执行更新操作的事务 ID。简略的更新过程大致如下所示

Mysql(十三) MVCC 多版本并发控制 - 图1

查询操作

由上面的更新操作可以得知,数据库表记录始终记录着最新的更新结果,那对于「可重复读」和「读提交」的隔离级别的事务,它是如何保证在开启本事务后,其他事务对记录进行了更新操作,而本事务仍然能够读取到准确的值(不是表记录的最新值,而是历史版本的值)的?从更新操作中可以得知,通过循环遍历 DB_ROLL_PTR 可以拿到当前记录的历史版本(当然,只是活跃的事务,如果当前记录没有相关事务在操作,则会清理 undo log,就不能拿到历史版本数据了) 。但是这么多历史版本的数据,究竟哪个版本的数据才是当前事务所要的呢?这时就要判断当前版本的数据是否对当前事务可见了。

在开启事务时,会将当前活跃的事务(已经开启了事务,但是还没有提交)的事务 ID 放在一个数组里面,同时记录数组里面最小的事务 ID 为「低水位」,记录当前系统已经创建的事务ID 的最大值加一为「高水位」。这三者组成了一个事务的一致性视图(read-view)。当事务要查询某个记录的数据时,实际上就是拿该记录的事务ID(包括历史版本的事务ID)和这个一致性视图进行比较,直到某个版本的数据是可见的为止。其查询过程如下:

  • 读取的记录的事务ID小于低水位,说明这个版本的数据在开启本事务前已经提交,是可见的,直接返回这个数据
  • 读取的记录的事务ID大于高水位,说明这个版本的数据在开启本事务后提交的,不可见,从记录中取出 DB_ROLL_PTR 指向的记录并读取其事务 ID,开始下一轮的判断
  • 读取的记录的事务ID介于低水位和高水位中间,此时判断事务ID是否在一致性视图的事务数组中:
    • 如果不在,说明这个版本的数据在开启本事务前已经提交,是可见的,直接返回这个数据
    • 如果在,说明这个版本的数据是由开启事务后的其他活跃事务提交的,对本事务是不可见的,因此需要从记录中取出 DB_ROLL_PTR 指向的记录并读取其事务 ID,开始下一轮的判断

其判断过程的流程图大致如下所示
Mysql(十三) MVCC 多版本并发控制 - 图2

关于判断数据可见性,除了上述用高水位、低水位和事务视图数组结合判断之外,可以简化成以下规则判断:

  • 对于当前事务中的数据,可见
  • 对于其他事务中的数据
    • 如果版本未提交,不可见
    • 如果版本已经提交,且是在创建本事务视图后提交的,不可见
    • 如果版本已经提交,且是在创建本事务视图前提交的,可见

例子

现在用一个例子(此例子来自:林晓斌:MySQL实战45讲)来对上述查找过程进行说明。假设在「可重复读」的隔离级别下,有以下的表结构和数据。

  1. mysql> CREATE TABLE `t` (
  2. `id` int(11) NOT NULL,
  3. `k` int(11) DEFAULT NULL,
  4. PRIMARY KEY (`id`)
  5. ) ENGINE=InnoDB;
  6. insert into t(id, k) values(1,1),(2,2);

假设进行以下的操作(事务C 的 update 操作完即自动提交事务),在进行以下操作前,假设当前活跃的事务 ID 为 99,记录(1,1)的 DB_TRX_ID 值是 90。则事务 A 的视图数组是 [99, 100],事务 B 的视图数组是 [99, 100, 101],事务 C 的视图数组是 [99, 100, 101, 102]

事务A(事务ID:100) 事务B(事务ID:101) 事务C(事务ID:102)
start transaction with consistent snapshot;
start transaction with consistent snapshot;
update t set k = k + 1 where id = 1;
update t set k = k + 1 where id = 1;
select k from t where id = 1;
select k from t where id = 1;
commit;
commit;

注:

  • 单条update语句就是一个事务,对单条记录操作是串行进行的,因为会自动加锁
  • begin/start transaction 命令并不是一个事务的起点,在执行到它们之后的第一个操作 InnoDB 表的语句,事务才真正启动。如果你想要马上启动一个事务,可以使用 start transaction with consistent snapshot 这个命令

当事务 A 执行查询语句时,其查询数据逻辑图(此图来自:林晓斌:MySQL实战45讲)如下所示

Mysql(十三) MVCC 多版本并发控制 - 图3

其查找过程如下,首先,获取记录的事务ID(101),比高水位大,不可见,所以取出记录的上一个历史版本,获取其事务ID(102),比高水位大,不可见,再获取记录的上一个历史版本,获取其事务ID(90),比低水位小,可见,所以返回这个记录中的 k 字段的值 1。

当然,也可以用简化版本来判断。过程如下,首先,获取记录(1,3),还没有提交,不可见,取出上一个历史版本(1,2),(1,2)已经提交,但是在本事务视图创建后提交的,不可见,继续取出上一个历史版本(1,1),(1,1)已经提交,且是在本事务视图创建前提交的,可见,所以最终返回 k 的值是 1。

此处需要额外关注的是,事务 B 的更新操作,是在当前记录的最新值上更新的,并不是在历史数据上更新的,否则会丢失事务 B 的更新操作。其实,更新数据都是先读后写的,而且这个读,是读的当前值,称为“当前读”。

如果是在「读提交」的隔离级别下,处理逻辑类似,只是生成一致性视图的情况不同:

  • 在「可重复度」隔离级别下,只需要在事务开始的时间创建一致性视图,之后事务里的其他查询都共用这个一致性视图
  • 在「读提交」隔离级别下,每一个语句执行前都会重新算出一个新的视图

所以上述例子,如果是在「读提交」隔离级别下,事务 A 在执行查询语句时,会创建新的一致性视图,此时一致性视图中的活跃事务ID数组是 [99, 100, 101],其查找过程如下,读取当前记录事务 ID(101),在视图数组中,不可见,取出上一个历史版本记录,读取事务ID(102),介于低水位和高水位之间,且不在视图数组中,可见,所以返回记录的 k 值 2。

其他

  • 四种隔离级别,只有「读提交」和「可重复读」两个隔离级别能够使用 MVCC,因此也只有这两个隔离级别会创建一致性视图(read-view)。「读提交」隔离级别下每次都是读取的最新记录;「串行化」隔离级别,则是用加锁方式来实现并发的,也不用 MVCC ,所以也不用创建一致性视图。关于「可重复读」和「读提交」两个隔离级别下一致性视图的差别,主要体现在:「可重复读」隔离级别下的一致性视图是在启动事务时创建的,创建后,本事务共用一个视图;而「可读提交」隔离级别下的一致性视图是在执行 SQL 时创建的,每一个 SQL 都会单独创建一个视图,并不会共用。