MVCC 在 MySQL 的 InnoDB 中的实现

在 InnoDB 中,会在每行数据后添加两个额外的隐藏的值来实现 MVCC,这两个值一个记录这行数据何时被创建,另外一个记录这行数据何时过期(或者被删除)。 在实际操作中,存储的并不是时间,而是事务的版本号,每开启一个新事务,事务的版本号就会递增。 在可重读 Repeatable reads 事务隔离级别下:

  • SELECT 时,读取创建版本号 <= 当前事务版本号,删除版本号为空或> 当前事务版本号。
  • INSERT 时,保存当前事务版本号为行的创建版本号
  • DELETE 时,保存当前事务版本号为行的删除版本号
  • UPDATE 时,插入一条新纪录,保存当前事务版本号为行创建版本号,同时保存当前事务版本号到原来删除的行

通过 MVCC,虽然每行记录都需要额外的存储空间,更多的行检查工作以及一些额外的维护工作,但可以减少锁的使用,大多数读操作都不用加锁,读数据操作很简单,性能很好,并且也能保证只会读取到符合标准的行,也只锁住必要行。
我们不管从数据库方面的教课书中学到,还是从网络上看到,大都是上文中事务的四种隔离级别这一模块列出的意思,RR 级别是可重复读的,但无法解决幻读,而只有在 Serializable 级别才能解决幻读。于是我就加了一个事务 C 来展示效果。在事务 C 中添加了一条 teacher_id=1 的数据 commit,RR 级别中应该会有幻读现象,事务 A 在查询 teacher_id=1 的数据时会读到事务 C 新加的数据。但是测试后发现,在 MySQL 中是不存在这种情况的,在事务 C 提交后,事务 A 还是不会读到这条数据。可见在 MySQL 的 RR 级别中,是解决了幻读的读问题的。参见下图
image.png
innodb_lock_1
读问题解决了,根据 MVCC 的定义,并发提交数据时会出现冲突,那么冲突时如何解决呢?我们再来看看 InnoDB 中 RR 级别对于写数据的处理。

“读” 与 “读” 的区别

可能有读者会疑惑,事务的隔离级别其实都是对于读数据的定义,但到了这里,就被拆成了读和写两个模块来讲解。这主要是因为 MySQL 中的读,和事务隔离级别中的读,是不一样的。
我们且看,在 RR 级别中,通过 MVCC 机制,虽然让数据变得可重复读,但我们读到的数据可能是历史数据,是不及时的数据,不是数据库当前的数据!这在一些对于数据的时效特别敏感的业务中,就很可能出问题。
对于这种读取历史数据的方式,我们叫它快照读 (snapshot read),而读取数据库当前版本数据的方式,叫当前读 (current read)。很显然,在 MVCC 中:

  • 快照读:就是 select
    • select * from table ….;
  • 当前读:特殊的读操作,插入 / 更新 / 删除操作,属于当前读,处理的都是当前的数据,需要加锁。
    • select * from table where ? lock in share mode;
    • select * from table where ? for update;
    • insert;
    • update ;
    • delete;

事务的隔离级别实际上都是定义了当前读的级别,MySQL 为了减少锁处理(包括等待其它锁)的时间,提升并发能力,引入了快照读的概念,使得 select 不用加锁。而 update、insert 这些 “当前读”,就需要另外的模块来解决了。

写(” 当前读”)

事务的隔离级别中虽然只定义了读数据的要求,实际上这也可以说是写数据的要求。上文的 “读”,实际是讲的快照读;而这里说的 “写” 就是当前读了。
为了解决当前读中的幻读问题,MySQL 事务使用了 Next-Key 锁。

Next-Key 锁

Next-Key 锁是行锁和 GAP(间隙锁)的合并,行锁上文已经介绍了,接下来说下 GAP 间隙锁。
行锁可以防止不同事务版本的数据修改提交时造成数据冲突的情况。但如何避免别的事务插入数据就成了问题。我们可以看看 RR 级别和 RC 级别的对比
RC 级别:

事务 A 事务 B
begin; begin;
select id,class_name,teacher_id from class_teacher where teacher_id=30;

| | | | | id | class_name | teacher_id | | | 2 | 初三二班 | 30 | | | update class_teacher set class_name=’ 初三四班 ‘ where teacher_id=30; |
| | | |
| insert into class_teacher values (null,’ 初三二班 ‘,30);
commit; | | | | select id,class_name,teacher_id from class_teacher where teacher_id=30;

| | | | | id | class_name | teacher_id | | | 2 | 初三四班 | 30 | | | 10 | 初三二班 | 30 | |

RR 级别:

事务 A 事务 B
begin; begin;
select id,class_name,teacher_id from class_teacher where teacher_id=30;
id class_name teacher_id
2 初三二班 30
update class_teacher set class_name=’ 初三四班 ‘ where teacher_id=30;

insert into class_teacher values (null,’ 初三二班 ‘,30);
waiting….
select id,class_name,teacher_id from class_teacher where teacher_id=30;
id class_name teacher_id
2 初三四班 30
commit; 事务 Acommit 后,事务 B 的 insert 执行。

通过对比我们可以发现,在 RC 级别中,事务 A 修改了所有 teacher_id=30 的数据,但是当事务 Binsert 进新数据后,事务 A 发现莫名其妙多了一行 teacher_id=30 的数据,而且没有被之前的 update 语句所修改,这就是 “当前读” 的幻读。
RR 级别中,事务 A 在 update 后加锁,事务 B 无法插入新数据,这样事务 A 在 update 前后读的数据保持一致,避免了幻读。这个锁,就是 Gap 锁。
MySQL 是这么实现的:
在 class_teacher 这张表中,teacher_id 是个索引,那么它就会维护一套 B + 树的数据关系,为了简化,我们用链表结构来表达(实际上是个树形结构,但原理相同)
image.png
innodb_lock_2
如图所示,InnoDB 使用的是聚集索引,teacher_id 身为二级索引,就要维护一个索引字段和主键 id 的树状结构(这里用链表形式表现),并保持顺序排列。
Innodb 将这段数据分成几个个区间

  • (negative infinity, 5],
  • (5,30],
  • (30,positive infinity);

update class_teacher set class_name=‘初三四班’ where teacher_id=30; 不仅用行锁,锁住了相应的数据行;同时也在两边的区间,(5,30] 和(30,positive infinity),都加入了 gap 锁。这样事务 B 就无法在这个两个区间 insert 进新数据。
受限于这种实现方式,Innodb 很多时候会锁住不需要锁的区间。如下所示:

事务 A 事务 B 事务 C
begin; begin; begin;
select id,class_name,teacher_id from class_teacher;
id class_name teacher_id
1 初三一班 5
2 初三二班 30
update class_teacher set class_name=’ 初一一班 ‘ where teacher_id=20;
insert into class_teacher values (null,’ 初三五班 ‘,10);
waiting …..
insert into class_teacher values (null,’ 初三五班 ‘,40);
commit; 事务 A commit 之后,这条语句才插入成功 commit;
commit;

update 的 teacher_id=20 是在 (5,30] 区间,即使没有修改任何数据,Innodb 也会在这个区间加 gap 锁,而其它区间不会影响,事务 C 正常插入。
如果使用的是没有索引的字段,比如 update class_teacher set teacher_id=7 where class_name=‘初三八班(即使没有匹配到任何数据)’, 那么会给全表加入 gap 锁。同时,它不能像上文中行锁一样经过 MySQL Server 过滤自动解除不满足条件的锁,因为没有索引,则这些字段也就没有排序,也就没有区间。除非该事务提交,否则其它事务无法插入任何数据。
行锁防止别的事务修改或删除,GAP 锁防止别的事务新增,行锁和 GAP 锁结合形成的的 Next-Key 锁共同解决了 RR 级别在写数据时的幻读问题。

Serializable

这个级别很简单,读加共享锁,写加排他锁,读写互斥。使用的悲观锁的理论,实现简单,数据更加安全,但是并发能力非常差。如果你的业务并发的特别少或者没有并发,同时又要求数据及时可靠的话,可以使用这种模式。
这里要吐槽一句,不要看到 select 就说不会加锁了,在 Serializable 这个级别,还是会加锁的!

二、多版本并发控制解决了哪些问题

1. 读写之间阻塞的问题

通过 MVCC 可以让读写互相不阻塞,即读不阻塞写,写不阻塞读,这样就可以提升事务并发处理能力。
提高并发的演进思路:

  • 普通锁,只能串行执行;
  • 读写锁,可以实现读读并发;
  • 数据多版本并发控制,可以实现读写并发。

    2. 降低了死锁的概率

    因为 InnoDB 的 MVCC 采用了乐观锁的方式,读取数据时并不需要加锁,对于写操作,也只锁定必要的行。

    3. 解决一致性读的问题

    一致性读也被称为快照读,当我们查询数据库在某个时间点的快照时,只能看到这个时间点之前事务提交更新的结果,而不能看到这个时间点之后事务提交的更新结果。

    三、快照读与当前读

    快照读(SnapShot Read) 是一种一致性不加锁的读,是 InnoDB 并发如此之高的核心原因之一
    这里的一致性是指,事务读取到的数据,要么是事务开始前就已经存在的数据,要么是事务自身插入或者修改过的数据
    不加锁的简单的 SELECT 都属于快照读,例如:
    **SELECT** * **FROM** t **WHERE** id=1
    快照读 相对应的则是 当前读当前读就是读取最新数据,而不是历史版本的数据。加锁的 SELECT 就属于当前读,例如:
    SELECT FROM t WHERE id=1 LOCK IN SHARE MODE; SELECT FROM t WHERE id=1 FOR UPDATE;

    四、InnoDB 的 MVCC 是如何工作的

    1. InnoDB 是如何存储记录的多个版本的

    事务版本号

    每开启一个事务,我们都会从数据库中获得一个事务 ID(也就是事务版本号),这个事务 ID 是自增长的,通过 ID 大小,我们就可以判断事务的时间顺序。

    行记录的隐藏列

    InnoDB 的叶子段存储了数据页,数据页中保存了行记录,而在行记录中有一些重要的隐藏字段:

  • DB_ROW_ID:6-byte,隐藏的行 ID,用来生成默认聚簇索引。如果我们创建数据表的时候没有指定聚簇索引,这时 InnoDB 就会用这个隐藏 ID 来创建聚集索引。采用聚簇索引的方式可以提升数据的查找效率。

  • DB_TRX_ID:6-byte,操作这个数据的事务 ID,也就是最后一个对该数据进行插入或更新的事务 ID。
  • DB_ROLL_PTR:7-byte,回滚指针,也就是指向这个记录的 Undo Log 信息。

image.png

Undo Log

InnoDB 将行记录快照保存在了 Undo Log 里,我们可以在回滚段中找到它们,如下图所示:
image.png
从图中能看到回滚指针将数据行的所有快照记录都通过链表的结构串联了起来,每个快照的记录都保存了当时的 db_trx_id,也是那个时间点操作这个数据的事务 ID。这样如果我们想要找历史快照,就可以通过遍历回滚指针的方式进行查找。

2. 在 可重复读(REPEATABLE READ) 隔离级别下, InnoDB 的 MVCC 是如何工作的

查询(SELECT)

InnoDB 会根据以下两个条件检查每行记录:

  1. InnoDB 只查找版本早于当前事务版本的数据行(也就是,行的系统版本号小于或等于事务的系统版本号),这样可以确保事务读取的行,要么是在事务开始前已经存在的,要么是事务自身插入或者修改过的
  2. 行的删除版本要么未定义,要么大于当前事务版本号。这可以确保事务读取到的行,在事务开始之前未被删除

只有符合上述两个条件的记录,才能返回作为查询结果。

插入(INSERT)

InnoDB 为新插入的每一行保存当前系统版本号作为行版本号。

删除(DELETE)

InnoDB 为删除的每一行保存当前系统版本号作为行删除标识。
删除在内部被视为更新,行中的一个特殊位会被设置为已删除。

更新(UPDATE)

InnoDB 为插入一行新记录,保存当前系统版本号作为行版本号,同时保存当前系统版本号到原来的行作为行删除标识。

五、总结

多版本并发控制(MVCC) 在一定程度上实现了读写并发,它只在 可重复读(REPEATABLE READ)提交读(READ COMMITTED) 两个隔离级别下工作。其他两个隔离级别都和 MVCC 不兼容,因为 未提交读(READ UNCOMMITTED),总是读取最新的数据行,而不是符合当前事务版本的数据行。而 可串行化(SERIALIZABLE) 则会对所有读取的行都加锁。
行锁,并发,事务回滚等多种特性都和 MVCC 相关。

删除标记位

关于删除标志的保存位置,最近学习 《MySQL 是怎样运行的》,有了新的发现:
每一条记录除了三个隐藏字段外,还包含额外字段,不同行格式的额外字段有一些差异,但是删除标志都是在额外字段的记录头信息中,这里以 COMPACT 行格式 为例:
MVCC - 图5
图中记录额外信息的三个字段分别为:变长字段长度列表、NULL 值列表、记录头信息。
记录头信息 是由固定的 5 个字节组成。5 个字节也就是 40 个二进制位,不同的位代表不同的意思,删除标志位就在 记录头信息 中,如图:
MVCC - 图6
这些二进制位代表的详细信息如下表:
名称大小(单位:bit)描述预留位 11 没有使用预留位 21 没有使用 delete_mask1 标记该记录是否被删除 min_rec_mask1B + 树的每层非叶子节点中的最小记录都会添加该标记 n_owned4 表示当前记录拥有的记录数 heap_no13 表示当前记录在记录堆的位置信息 record_type3 表示当前记录的类型,0 表示普通记录,1 表示 B + 树非叶子节点记录,2 表示最小记录,3 表示最大记录 next_record16 表示下一条记录的相对位置

参考文档

Innodb 中的事务隔离级别和锁的关系

MySQL 的多版本并发控制 (MVCC) 是什么?