MVCC 在 MySQL InnoDB 中的实现主要是为了提高数据库并发性能,用更好的方式去处理读-写冲突,做到即使有读写冲突时,也能做到不加锁,非阻塞并发读。

本文将从以下几点进行讨论:

  1. 前置知识介绍;
  2. MVCC的原理;
  3. MVCC相关的问题

1. 前置知识介绍

1.1 快照读和当前读

当前读:像 select lock in share mode (共享锁), select for update; update; insert; delete (排他锁)这些操作都是一种当前读。就是它读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。

快照读: 不加锁的select操作就是快照读,即不加锁的非阻塞读。是基于提高并发性能的考虑,快照读的实现是基于多版本并发控制,即MVCC,可以认为MVCC是行锁的一个变种,但它在很多情况下,避免了加锁操作,降低了开销;既然是基于多版本,即快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。

注意:快照读的前提是隔离级别不是串行级别,串行级别下的快照读会退化成当前读。

1.2 当前读,快照读和MVCC的关系

准确的说,MVCC多版本并发控制指的是 “维持一个数据的多个版本,使得读写操作没有冲突” 这么一个概念。仅仅是一个理想概念。

而在MySQL中,实现这么一个MVCC理想概念,我们就需要MySQL提供具体的功能去实现它,而快照读就是MySQL为我们实现MVCC理想模型的其中一个具体非阻塞读功能。

要说的再细致一些,快照读本身也是一个抽象概念,再深入研究。MVCC模型在MySQL中的具体实现则是由 4个隐式字段undo日志Read View 等去完成的,具体可以看下面的MVCC实现原理。

1.3 MVCC的具体作用

多版本并发控制(MVCC)是一种用来解决读-写冲突的无锁并发控制,也就是为事务分配单向增长的时间戳,为每个修改保存一个版本,版本与事务时间戳关联,读操作只读该事务开始前的数据库的快照。 所以MVCC可以为数据库解决以下问题。

在并发读写数据库时,可以做到在读操作时不用阻塞写操作,写操作也不用阻塞读操作,提高了数据库并发读写的性能。同时还可以解决脏读,不可重复读等事务隔离问题,但不能解决更新丢失问题。

2. MVCC的实现原理

MVCC的实现依赖于三个概念,分别是:隐式字段、UndoLog、ReadView。

2.1 隐式字段

每行记录除了我们自定义的字段外,还有数据库隐式定义的DB_TRX_ID,DB_ROLL_PTR,DB_ROW_ID等字段。

  • DB_ROW_ID:隐含的自增ID(隐藏主键),如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引;
  • DB_TRX_ID: 最近修改(修改/插入)事务ID:记录创建这条记录/最后一次修改该记录的事务ID;
  • DB_ROLL_PTR :回滚指针,指向这条记录的上一个版本(存储于rollback segment里);
  • DELETED_BIT:记录被更新或删除并不代表真的删除,而是删除flag变了。

2.2 UndoLog

undo log主要分为3种:

  • Insert undo log :插入一条记录时,至少要把这条记录的主键值记下来,之后回滚的时候只需要把这个主键值对应的记录删掉就好了。
  • Update undo log:修改一条记录时,至少要把修改这条记录前的旧值都记录下来,这样之后回滚时再把这条记录更新为旧值就好了。
  • Delete undo log:删除一条记录时,至少要把这条记录中的内容都记下来,这样之后回滚时再把由这些内容组成的记录插入到表中就好了。

对MVCC有帮助的实质是update undo log ,undo log实际上就是存在rollback segment中旧记录链。如下图所示:

Pasted image 20240618223504

从上面,我们就可以看出,不同事务或者相同事务的对同一记录的修改,会导致该记录的undo log成为一条记录版本线性表,即链表,undo log的链首就是最新的旧记录,链尾就是最早的旧记录。

当然就像之前说的该undo log的节点可能是会purge线程清除掉,向图中的第一条insert undo log,其实在事务提交之后可能就被删除丢失了,不过这里为了演示,所以还放在这里。

2.3 Read View

Read View就是事务进行快照读操作的时候生产的读视图(Read View),在该事务执行的快照读的那一刻,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID(当每个事务开启时,都会被分配一个ID, 这个ID是递增的,所以最新的事务,ID值越大)。

所以我们知道 Read View主要是用来做可见性判断的, 即当我们某个事务执行快照读的时候,对该记录创建一个Read View读视图,把它比作条件用来判断当前事务能够看到哪个版本的数据,即可能是当前最新的数据,也有可能是该行记录的undo log里面的某个版本的数据。

现在先介绍一下Read View中的几个重要的字段:

字段 介绍
m_ids 当前活跃的事务ID集合
min_trx_id 最小的活跃事务ID
max_trx_id 预分配事务ID(当前最大事务ID + 1)
creator_trx_id ReadView创建者事务ID

2.4 版本链数据访问规则

条件 是否能访问该版本 原因
trx_id == creator_trx_id 可以访问 此记录是自己修改的,可以访问
trx_id < min_trx_id 可以访问 此版本的事务已经修改完提交了
trx_id > max_trx_id 不可以访问 说明修改此条数据的事务是生成ReadView后才开启的
min_trx_id <= trx_id <= max_trx_id
且 trx_id不在m_ids中
可以访问 首先说明修改此行的事务在生成ReadView的时候是活跃或已提交状态。
然后检查现在是否活跃,若不活跃,说明已提交,可以访问
其中,trx_id指的是当前事务的ID;

注意:不同隔离级别下,ReadView生成的时机不同。

  • Read Committed:每次执行快照读时都生成一次ReadView;
  • Repeatable Read:同一个事物中,只在第一次快照读生成ReadView,后面的快照读都是用第一次生成的ReadView。

当当前版本经过以上的规则验证,如果无法访问的话,则通过隐式字段DB_ROLL_PTR继续验证下一个快照。

3. MVCC的相关问题

3.1 在RC和RR级别下,InnoDB的快照读有什么区别?

正是Read View生成时机的不同,从而造成RC,RR级别下快照读的结果的不同

总之在RC隔离级别下,是每个快照读都会生成并获取最新的Read View;而在RR隔离级别下,则是同一个事务中的第一个快照读才会创建Read View, 之后的快照读获取的都是同一个Read View。从而实现了RR级别可以解决不可重复读的问题。

3.2 MVCC可以解决幻读问题吗?

MVCC不能解决在RR级别下的幻读问题。但是可以通过当前读用加锁的方式实现避免幻读。如:

1
2
3
begin;

select * from tb_records where id = 2 for update;

此时,就会产生一个间隙锁。这样就防止其他事务插入间隙,造成幻读的问题。

4. 引用

  1. 【MySQL笔记】正确的理解MySQL的MVCC及实现原理
  2. MVCC能否解决幻读?