什么是MVCC

MVCC全称Multi-Version Concurrency Control,即多版本并发控制。MVCC是一种无锁并发控制机制,通过保存数据的多个版本来实现读写操作的并行执行,从而提高数据库性能。

MVCC机制具有以下优点:

  • 提高并发性能:读操作不会阻塞写操作,写操作也不会阻塞读操作,有效地提高数据库的并发性能。
  • 降低死锁风险:由于无需使用显式锁来进行并发控制,MVCC可以降低死锁的风险。

当前读和快照读

当前读

当前读可以直接读取最新的数据版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。

当前读是一种加锁操作,是悲观锁的实现。

一致性读

默认隔离级别下,MySQL使用一致性读(Consistent Read) 来实现当前读。

在事务开始时,MySQL会创建一个**一致性视图(Consistent View)**,该视图反映了事务开始时刻数据库的快照。在事务执行期间,无论其他事务对数据进行了何种修改,事务始终使用一致性视图来读取数据。这样可以保证在同一个事务内多次查询返回的结果是一致的,从而实现了当前读。

锁定读

锁定读(Locking Read) 是一种特殊情况下的当前读方式,在某些场景下使用。

当使用锁定读时,MySQL会在执行读取操作前获取共享锁或排他锁,以确保数据的一致性。共享锁允许多个事务同时读取同一数据,而排他锁则阻止其他事务读取或写入该数据。

锁定读适用于需要严格控制并发访问的场景,但由于加锁带来的性能开销较大,建议仅在必要时使用。

快照读

快照读是在读取数据时读取一个一致性视图中的数据,MySQL使用 MVCC 机制来支持快照读。

具体而言,每个事务在开始时会创建一个一致性视图,该视图反映了事务开始时刻数据库的快照。这个一致性视图会记录当前事务开始时已经提交的数据版本。

当执行查询操作时,MySQL会根据事务的一致性视图来决定可见的数据版本。只有那些在事务开始之前已经提交的数据版本才是可见的,未提交的数据或在事务开始后修改的数据则对当前事务不可见。

像不加锁的 select 操作就是快照读,即不加锁的非阻塞读。

快照读可能读到的并不一定是数据的最新版本,而有可能是之前的历史版本。

注意:快照读的前提是隔离级别不是串行级别,在串行级别下,事务之间完全串行执行,快照读会退化为当前读

MVCC主要就是为了实现读-写冲突不加锁,而这个读指的就是快照读,是乐观锁的实现。

MVCC原理解析

隐式字段

MySQL的行数据包含了一些隐式字段供内部使用,一般不会显示给用户:

  • DB_ROW_ID隐含的自增ID(隐藏主键),用于唯一标识表中的每一行数据,如果数据表没有主键,InnoDB会自动以DB_ROW_ID产生一个聚簇索引。
  • DB_TRX_ID当前行数据所属的事务ID。每个事务在数据库中都有一个唯一的事务ID。通过 DB_TRX_ID 字段,可以追踪行数据和事务的所属关系。
  • DB_ROLL_PTR:该字段存储了回滚指针(Roll Pointer),它指向用于回滚事务的Undo日志记录。

Undo Log

Undo日志(Undo Log)是MySQL中的一种重要的事务日志,是MVCC得以实现的核心所在。Undo日志的作用主要有两个方面:

  • 事务回滚:当事务需要回滚时,MySQL可以通过Undo日志中的旧值将数据还原到事务开始之前的状态,保证了事务回滚的一致性。
  • MVCC实现:MVCC 是InnoDB存储引擎的核心特性之一。通过使用Undo日志,MySQL可以为每个事务提供独立的事务视图,使得事务读取数据时能看到一致且符合隔离级别要求的数据版本。

在InnoDB存储引擎中,Undo日志分为插入Undo日志更新Undo日志

  • Insert Undo Log:在插入操作中生成的Undo日志。由于插入操作的记录只对当前事务可见,对其他事务不可见,因此在事务提交后可以直接删除,无需进行purge操作。
  • Update Undo Log:在更新或删除操作中生成的Undo日志。更新Undo日志可能需要提供MVCC机制,因此不能在事务提交时就立即删除。相反,它们会在提交时放入Undo日志链表中,并等待purge线程进行最终的删除。删除操作只是设置一下老记录的 DELETED_BIT,并不真正将过时的记录删除,为了节省磁盘空间,InnoDB有专门的purge线程来清理 DELETED_BIT 为true的记录。
    查询操作不会修改任何记录,因此不需要记录相应的Undo Log。

不同事务或者相同事务对同一记录行的修改,会使该记录行的 undo log 成为一条链表,链首就是最新的记录,链尾就是最早的旧记录。

1
2
3
4
5
6
-- 事务A
insert into user_info(id, name) values(1,'soria');
-- 事务B
update user_info set name='SG' where id=1;
-- 事务C
update user_info set name='Soria' where id=1;

在事务A执行后,事务B对该记录进行了修改。

在事务B修改时,数据库会先对该行添加一个排他锁,然后将该行数据复制到Undo Log中,作为旧记录。然后再修改该行,并修改隐藏字段DB_TRX_ID为事务B的ID,然后将回滚指针指向Undo Log的旧记录,即上个版本。事务提交完成后,释放锁。

在事务C修改时,操作同上。复制时发现该行已经有Undo Log了,那么会将最新的旧记录(即当前的记录)插入到已有的Undo Log前面,类似于链表的头插法。

关于DB_ROLL_PTR与Undo Log的配合工作,具体流程如下:

  1. 在更新或删除操作之前,MySQL会将旧值写入Undo Log中。
  2. 当事务需要回滚时,MySQL会根据事务的Undo日志记录,通过DB_ROLL_PTR找到对应的Undo Log。
  3. 根据Undo Log中记录的旧值,MySQL将旧值恢复到相应的数据行中,实现数据的回滚操作。

通过DB_ROLL_PTR和Undo Log的配合工作,MySQL能够有效地管理事务的一致性和隔离性。Undo Log的使用也使得MySQL能够支持MVCC,从而提供了高并发环境下的读取一致性和事务隔离性。

版本链

每次更新操作所记录的Undo Log会连接成一个链表,称之为版本链。版本链的头节点代表当前记录的最新值。此外,每个版本还包含生成该版本的事务ID。

Read View

即一致性视图,是用来判断版本链中的哪个版本对当前事务是可见的。

每个事务开启时,都会被分配一个ID,这个D是递增的。事务执行快照读时,会生成数据库系统当前的一个快照,记录并维护系统当前活跃事务的ID。即事务进行快照读时生成的读视图(Read View)。

Read View只针对RC和RR级别

  • RU: 在RU隔离级别下会发生脏读。这意味着不需要通过 Read View 来限制访问范围,事务可以自由地读取其他事务的未提交数据。由于没有对可见性进行严格控制,因此不需要创建或使用 Read View。
  • S:在S隔离级别下,事务具有最高的隔离性,确保每次读取都能看到一致的快照。为了实现这种隔离级别,MySQL使用锁机制来保证事务之间的串行执行。由于事务按顺序执行,并且不允许并发操作,所以不需要使用 Read View 进行可见性判断。

Read View可见性原则

Read View 遵循一个可见性原则,将要被修改的数据的DB_TRX_ID取出来,与系统当前其他活跃事务的ID去对比。

如果DB_TRX_ID跟Read View的属性做了某些比较,不符合可见性,那就通过DB_ROLL_PTR回滚指针去取出Undo Log中的DB_TRX_ID再比较。即遍历链表的DB_TRX_ID,直到找到满足特定条件的DB_TRX_ID,那么这个DB_TRX_ID所在的记录就是当前事务能看见的最新老版本。

Read View会维护以下字段:

  • m_ids:Read View创建时其他未提交的活跃事务ID列表。创建Read View时,将当前未提交事务ID记录下来,后续即使它们修改了记录行的值,对于当前事务也是不可见的。m_ids不包括当前事务自己和已提交的事务(正在内存中)。
  • m_creator_trx_id:创建该Read View的事务ID。
  • m_low_limit_id:目前出现过的最大的事务 ID+1,即下一个将被分配的事务 ID。大于等于这个 ID 的数据版本均不可见。
  • m_up_limit_id:活跃事务列表m_ids中最小的事务ID,如果m_ids为空,则 m_low_limit_id = m_up_limit_id小于这个 ID 的数据版本均可见

Read View的可见性判断如下:

  1. 如果被访问版本的DB_TRX_ID属性值与Read View中的m_creator_trx_id值相同,表示当前事务正在访问自己所修改的记录,因此该版本被当前事务访问。
  2. 如果被访问版本的DB_TRX_ID属性值小于 Read View 中的m_up_limit_id值,说明生成该版本的事务在当前事务生成Read View之前已经提交,因此该版本被当前事务访问。
  3. 如果被访问版本的DB_TRX_ID属性值大于或等于Read View中的m_low_limit_id值,说明生成该版本的事务在当前事务生成Read View之后才提交,因此该版本不能被当前事务访问。
  4. 如果被访问版本的DB_TRX_ID属性值位于Read View的m_up_limit_idm_low_limit_id之间(包括边界),则需要进一步检查DB_TRX_ID是否在m_ids列表中。如果在列表中,说明在创建ReadView时生成该版本的事务仍处于活跃状态,因此该版本不能被访问;如果不在列表中,说明在创建Read View时生成该版本的事务已经提交,因此该版本可以被访问。

事务可见性示意图

RC 和 RR 下的 Read View

RC 和 RR 下生成Read View的时机是有所差异的:

  • RC:每次SELECT数据前都生成一个ReadView。
  • RR:只在第一次读取数据时生成一个Read View,后面会复用第一次生成的。

正因为RC 和 RR生成 Read View 的时机不同,导致两个级别下看到的数据会不一致。

现有如下三个事务以及执行顺序:
原始数据为:id=1, name='soria'

事务A 事务B 事务C
t1 begin
t2 begin begin
t3 update user_info set name=’SG’ where id=1
t4 update user_info set name=’sg’ where id=1 select * from user_info where id=1
t5 commit update user_info set name=’SORIA’ where id=1
t6 update user_info set name=’soRIA’ where id=1 select * from user_info where id=1
t7 commit
t8 select * from user_info where id=1
t9 commit
在RC级别下,每次SELECT都会生成一个Read View,因此事务C查询到的结果依次为soriasgsoRIA。由于RR级别会复用第一次生成的Read View,因此三次查询到的结果都为soria

RR级别只能防止部分幻读。 MVCC解决的只是RR级别下快照读的幻读问题,而当前读的幻读问题则是通过临键锁来解决的。也就是说RR级别下是通过MVCC+临键锁来解决大部分幻读问题的。