Yujun's Blog

MVCC:优雅的并发解决方案

May 29, 2025 (1w ago)Interview

MVCC

记录下笔者第一次后端开发面试被问到的一个问题:Mysql是如何解决并发事务的脏读,不可重复读,幻读这些问题的呢?没有回答上来。遂做整理记录。

在深入理解MVCC之前,首先,我们需要认识到它究竟要解决什么问题。

数据库作为共享资源,当多个事务(Transaction)并发执行时,若不加以控制,便会引发一系列问题,破坏事务的隔离性(Isolation),进而影响数据的准确性。具体来说主要由以下三种情况:

  • 脏读(Dirty Read)​: 一个事务读取到了另一个事务尚未提交的数据。如果那个事务最终回滚,那么第一个事务读取到的就是“脏”数据。
    • case:事务A修改了某行数据但未提交,事务B读取了这行被修改的数据。随后事务A回滚,事务B读取的数据就成了无效的。
  • 不可重复读​(Non-Repeatable Read): 在同一个事务内,两次读取同一行数据,得到的结果却不一致。这是因为在两次读取之间,有其他事务修改了这行数据并提交了。
    • case:事务A读取某行数据,然后事务B修改了该行数据并提交。事务A再次读取该行数据,发现值变了。
  • 幻读(Phantom Read): 在同一个事务内,两次执行同样的范围查询,第二次查询的结果集却包含了第一次查询中未出现的行(或者少了某些行)。这是因为在两次查询之间,有其他事务插入了新的符合条件的数据行(或者删除了某些行)并提交了。
    • case:事务A按条件查询得到N行数据。事务B插入了一行符合该条件的新数据并提交。事务A再次按相同条件查询,发现多/少了一行,如同出现了“幻影”。

SQL标准 定义了四种隔离级别来应对这些问题,级别越高,并发性能越差,但数据一致性越好:

  • 读未提交RU
  • 读已提交RC
  • 可重复读RR
  • 串行化:Serializable

其中,Mysql 的默认事务隔离级别是 RR。 那么这里我们要思考两个问题:

  • 隔离级别这个词还是有些抽象,它怎么保证上述并发事务安全问题不出现的呢?
  • 为什么用RR,不用RC,不用串行化?

这里我们先来解读第一个问题:MySQL(特指InnoDB存储引擎)是如何在保证较高并发性的同时,实现RC和RR隔离级别的呢?答案就是——MVCC(Multi-Version Concurrency Control,多版本并发控制)。

这句话我们可以获得到一个信息:RC,RR是基于MVCC进行并发事务控制的。

刚一听,可能并不知道这是什么意思。可以先从熟悉的概念来理解。为了保证事务之间的隔离性,我们首先想到的应该是加锁,但是加锁之后事务之间的并发性能就会降低。因此,为了在隔离性与并发性之间寻求一个更优的平衡,MVCC应运而生,它是一种实现事务隔离级别的“近乎”无锁方式(主要是指读操作不加锁,或者说不加阻塞其他读写的锁)。

读者现在可能在脑海提到MVCC能够联想到无锁这个词,(或者更准确地说是“乐观锁”思想的体现,读操作不阻塞写操作)但是好像还是有些抽象是吧,没关系。对于一个复杂的系统,我们往往需要先解构其核心组件。

接着不妨让我们大脑里当提到MVCC大脑里能想到的词更多一些:Undo LogReadView隐藏列。读者看到这里可能会没什么感觉,没关系,我们只是先有个印象,顺便提一嘴,这三个东西就是著名的MVCC三剑客。接下来我们来详细的介绍一下MVCC的内部工作机制,看看这三位“剑客”是如何各显神通的。

隐藏列:你以为看到的就是全部?数据的隐形契约

InnoDB存储引擎为表中的每一行数据都添加了几个隐藏列(用户不可见,但在系统层面至关重要),它们分别是:

  • DB_TRX_ID:记录了创建这条记录或最后一次修改该记录的事务ID。
  • DB_ROLL_PTR:回滚指针。
  • DB_ROLL_ID:隐藏的行ID。

这些隐藏列是MVCC机制能够作用在每一行数据上的物理基础。它们不参与业务逻辑,却是并发控制的幕后功臣。

Undo Log:回滚的后悔药,还是并发的时光机?

Undo Log (回滚日志),其名称似乎暗示了它的主要职责是“撤销”——事务失败时的“后悔药”。这固然是其重要功能之一,但在MVCC中,它还被用来存储数据行的历史版本。因此,它在MVCC中我们更常称它为 Undo Log 版本链

首先,这个链条长什么样?是不是只含有原始的数据? 如果只有原始数据,那么如何链起来?这时候就是前面隐藏字段发挥作用的时候了,除了每一行自己的数据,还有trx_id, roll_ptr。其中事务编号作为版本标识

这里要思考一个问题:为什么有些事务已经提交了,它修改的数据还是存在于版本链中,这样就引出了一个常见的面试题:UNDO Log 何时删除?

UNDO Log版本链不是立刻被删除,而是确保版本链数据不再被其他并行事务所引用后再进行删除。

那么,这条至关重要的版本链究竟是如何被构建起来的呢?我们日常对数据的操作无外乎增、删、改、查(CRUD)。SELECT(查)操作显然是只读的,它不改变数据,自然也不会催生新的数据版本。但对于INSERT(增)、DELETE(删)、UPDATE(改)这些写操作,它们是如何在MVCC的框架下影响版本链的呢?

一个直观的(但可能错误的)想法是,DELETE操作会直接从版本链中移除某个版本吗?如果这样,事务回滚(比如撤销一个DELETE)将如何实现?数据一旦被物理移除,就难以复原了。这显然不符合数据库对持久性和可恢复性的要求。那么,DELETE操作在MVCC中究竟意味着什么?

对于UPDATE操作,是简单地在最新版本上覆盖修改,还是会创建一个全新的版本?如果是创建新版本,旧版本如何处理?INSERT操作,作为版本的“创世纪”,它在版本链中又处于什么位置?

这些操作,每一个都必须以一种既能支持事务隔离(通过版本控制)又能保证事务原子性(通过回滚能力)的方式被精确地记录和管理。带着这些疑问,我们深入探究InnoDB是如何通过Undo Log来巧妙处理这些数据变更,并精心编织出版本链的:

  • 当一个事务执行INSERT操作时:

    • 这相当于在数据的历史中注入了一个全新的源头。新行的DB_TRX_ID会记录下当前这个“创世”事务的ID。
    • 它的Undo Log相对简单,因为“无中生有”的回滚意味着“从有归无”。Undo Log中主要记录了该新行的主键信息(或者唯一标识),以便在事务需要回滚时,系统能够精确地找到并移除这个刚刚被插入的记录。从版本链的角度看,这条新记录是它自身版本链的起点,其DB_ROLL_PTR通常为空或指向一个特殊标记。
  • 当一个事务执行DELETE操作时:

    • InnoDB并不会鲁莽地从物理存储上抹除这条数据。这样做不仅不利于回滚,也会破坏其他可能正在读取该数据旧版本的事务的一致性视图。
    • 相反,InnoDB采取了一种“逻辑删除”的策略:
      • 它会在该行记录的头部设置一个特殊的“删除标记位”(delete mark),表明此行已被逻辑删除。
      • 关键在于,该行被删除前的完整数据(“前映像”)会被完整地复制到Undo Log中。并且,当前这条被标记为删除的记录的DB_ROLL_PTR会指向这个存储在Undo Log中的“前映像”。
      • 这样,对于后续的查询,如果根据Read View判断这条被标记为删除的记录不可见(例如,删除它的事务尚未提交),系统就可以通过DB_ROLL_PTR找到它在被删除前的状态。同时,如果删除操作需要回滚,只需清除删除标记,并将Undo Log中的前映像恢复即可(逻辑上)。
  • 当一个事务执行UPDATE操作时:

    • 这在MVCC的视角下,可以被理解为一个优雅的“版本迭代”过程,而非简单的原地覆盖。它逻辑上等同于“逻辑删除旧版本”并“插入一个新版本”的原子组合,但实现上更为高效:
      1. 存档旧貌 (Copy-on-Write思想的体现):将要被修改的行的当前完整数据(即“前映像”)原封不动地复制到Undo Log中。这个Undo Log记录就代表了该行的“旧版本”。
      2. 塑造新颜 (In-Place Update):在数据页中,直接对原始记录行进行修改,使其内容更新为事务指定的新值。这条被修改后的记录就成为了该行的“新版本”。
      3. 更新签名:将这条在数据页中被修改的记录的DB_TRX_ID更新为当前执行UPDATE操作的事务ID。
      4. 连接历史:将这条在数据页中被修改的记录的DB_ROLL_PTR指向刚刚在Undo Log中创建的那个包含

通过DB_ROLL_PTR,这条被修改的行就与它在Undo Log中的前一个版本连接起来了。如果这条记录之前也被修改过,那么Undo Log中的旧版本也会有一个DB_ROLL_PTR指向更早的版本,如此便形成了一个基于Undo Log的版本链:当前数据行 -> Undo Log中的版本1 -> Undo Log中的版本2 ... 直到最初的版本。这个链条是MVCC能够提供数据“快照”的基础。

Read View:事务的视界,一致性的快照规则集

Read View (一致性读视图),可以被理解为事务在读取数据时戴上的一副特殊“眼镜”,或者更精确地说,是一套定义了“何为可见”的规则集**。它决定了在特定的事务和特定的时间点,该事务能“看到”数据库中的哪个版本的数据。它不是一个物理存在的数据拷贝,而是一个逻辑上的判断标准。**

这套“规则集”的生成时机,直接影响了事务的隔离级别:

  • 在RC隔离级别下:每一次独立的SELECT查询都会重新生成一次ReadView。这意味着,在同一个事务内部,后执行的SELECT可能会因为ReadView的更新看到i和先执行SELECT不同的数据景象。即出现了不可重复读现象。
  • 在RR隔离级别下:ReadView仅在事务中首次执行SELECT语句时创建一次,并且在整个事务的生命周期内,所有的SELECT操作都会复用这个初创的Read View。这就是实现可重复读的核心。

ReadView ,听起来高级唬人,但其实就是一个数据结构。包括以下四个字段:

  • m_ids
  • min_trx_id
  • max_trx_id
  • creator_trx_id :创建这个ReadView的事务的自身ID。

​至此,MVCC的三大支柱——作为数据标记的隐藏列,作为历史档案的Undo Log,以及作为可见性裁决者的Read View——各就各位。它们共同构成了MVCC的骨架,接下来我们将看到这些组件如何协同运作,执行精密的“可见性算法”。

Read View (一致性读视图) 是MVCC的“照相机”或者说“滤镜”。当一个事务需要读取数据时(特指SELECT操作,且不是锁定读如 SELECT ... FOR UPDATE),它并不是直接去读取最新的数据,而是根据一个“规则集”——即Read View——来判断哪个版本的数据对它而言是可见的。

这个Read View在特定的时机创建:

  • Read Committed (RC) 隔离级别下,每次执行SELECT语句时都会创建一个新的Read View。这意味着在同一个事务中,不同的SELECT语句可能会看到不同的数据快照。
  • Repeatable Read (RR) 隔离级别下,仅在事务中的第一个SELECT语句执行时创建一个Read View,后续该事务中所有的SELECT操作都会复用这个Read View。这保证了事务内多次读取同一数据时结果的一致性。

一个Read View主要包含以下几个关键信息,这些信息是在它被创建的那个精确时刻,对系统状态的“快照”:

  1. m_ids: 一个列表,包含了在创建Read View时,当前系统中所有活跃的(即已开始但尚未提交或回滚)事务的ID
  2. min_trx_id: m_ids列表中的最小事务ID。如果m_ids为空(即创建Read View时没有其他活跃事务),则min_trx_id等于max_trx_id
  3. max_trx_id: 系统下一个将要分配的事务ID。也就是说,任何事务ID大于等于max_trx_id的事务,在当前Read View创建时尚未开始。
  4. creator_trx_id: 创建这个Read View的事务本身的ID

有了这三个“剑客”——隐藏列作为数据载体,Undo Log作为历史版本库,Read View作为可见性判断规则——MVCC的舞台就搭建完毕了。

2. MVCC的“可见性”算法

现在,当一个事务(我们称之为事务S,其Read View为RV_S)尝试读取某一行数据时,它并不是简单地抓取物理存储上的最新数据。

第一次检查:是不是我自己改的? 如果这行数据最后是被我自己修改的,那么我自然能看到自己的修改,无论我是否已经提交,我自己的修改对我自己而言总是立即可见的。

如果不是自己改的,那么我们就要看修改这个数据的事务,与我创建 Read View 的那一刻,是什么关系。

第二次检查:修改者是不是在我 “拍照”之前就已经提交了(或者回滚了,总之已经结束了) 它是历史的一部分,对我来说是稳定且可见的。

第三次检查:修改者是不是在我 “拍照”之后才开始的?

对我而言,它的修改发生在“未来”,我当然不应该看到。我需要通过

现在,当一个事务(我们称之为事务S,其Read View为RV_S)尝试读取某一行数据时,(当然这一时刻可能还有其他尚未提交的并行事务。)InnoDB会拿到该行数据的最新版本(存储在数据页中),并提取其DB_TRX_ID(我们称之为row_trx_id)。然后,InnoDB将row_trx_id与RV_S中的min_trx_idmax_trx_idm_ids以及creator_trx_id进行比较,遵循一套严谨的逻辑来判断此版本是否可见:

  1. 规则一:自身修改可见
  • 如果 row_trx_id == RV_S.creator_trx_id
    • 可见。这很好理解,事务自己对数据的修改,对自己当然是可见的。就是我们自己的事务先执行修改再执行查询。
  1. 规则二:历史已提交事务修改可见
  • 如果 row_trx_id < RV_S.min_trx_id
    • 可见。这表示,在事务S创建它的Read View RV_S时,修改这条记录的事务(row_trx_id)早已经提交了(因为它的ID小于当时所有活跃事务中的最小ID)。如果不小于,说明开启ReadView时,修改此数据的事务还没有提交,不可见。进入下一条规则的判断。
  1. 规则三:未来事务修改不可见
  • 如果 row_trx_id >= RV_S.max_trx_id
    • 不可见。这表示,修改这条记录的事务是在事务S创建Read View RV_S之后才开始的。对于事务S而言,这些修改发生在“未来”,自然是看不到的。此时,InnoDB需要通过当前记录的DB_ROLL_PTR去Undo Log中查找上一个版本,然后对上一个版本重复整套可见性判断流程(即本行流程中止)。注意,如果小于的话,并不能确定是否可见,还要进行下一条规则,这条规则类似于fail-fast。
  1. 规则四:并发事务按提交状态决定可见性
  • 如果 RV_S.min_trx_id <= row_trx_id < RV_S.max_trx_id
    • 这意味着修改该行的事务(row_trx_id)在事务S创建Read View时可能处于活跃状态。此时需要进一步判断:
    • 4a. 如果 row_trx_id 存在于 RV_S.m_ids 列表中
      • 不可见。这表示,在事务S创建Read View时,修改该行的事务(row_trx_id)还是活跃的(未提交)。因此,其所做的修改对事务S来说是不可见的(避免脏读)。同样,InnoDB需要通过DB_ROLL_PTR去Undo Log中查找上一个版本。
    • 4b. 如果 row_trx_id 不存在于 RV_S.m_ids 列表中
      • 可见。这表示,虽然修改该行的事务(row_trx_id)在事务S的min_trx_idmax_trx_id之间,但它在事务S创建Read View时已经提交了(因为它不在活跃事务列表m_ids中)。

版本穿梭的逻辑: 如果根据上述规则,当前被检查的数据版本对事务S不可见,InnoDB就会沿着该行记录的DB_ROLL_PTR,回溯到Undo Log中存储的上一个版本。然后,对这个旧版本再次应用上述一整套可见性判断规则。这个过程会一直持续,直到找到一个可见的版本,或者版本链的尽头(通常是最初插入的版本,如果连最初版本都不可见,那这条记录对事务S来说就如同不存在)。我喜欢称之为“版本穿梭”,事务S在数据的历史长河中寻找属于它的那个“时间切片”。

3. MVCC如何解决并发问题

现在,让我们将这些机制与最初的并发问题联系起来,看看MVCC是如何优雅地应对的。

  • 解决脏读 (在Read Committed 和 Repeatable Read级别下):

    • 假设事务T1修改了数据但尚未提交,其DB_TRX_ID(设为t1_id)被记录在该行上。
    • 事务T2开始读取数据,它创建了自己的Read View (RV_T2)。在RV_T2创建时,事务T1是活跃的,所以t1_id会存在于RV_T2的m_ids列表中。
    • 当T2读取到该行时,发现row_trx_idt1_id。根据可见性规则4a (t1_idm_ids中),T1修改的这个版本对T2是不可见的。T2会通过DB_ROLL_PTR去Undo Log中查找更早的、已提交的版本。
    • 因此,T2绝不会读取到T1未提交的“脏”数据。
  • 解决不可重复读 (在Repeatable Read级别下):

    • 关键在于:在RR隔离级别下,事务T1在第一次执行SELECT时创建Read View (RV_T1),并且整个事务期间都使用这同一个RV_T1
    • 假设T1在t时刻读取了某行数据(此时RV_T1已创建)。之后,事务T2修改了该行数据并提交了。T2的事务ID(设为t2_id)必然大于等于RV_T1的min_trx_id
    • 当T1在t'时刻(t' > t)再次读取该行数据时,它仍然使用固定的RV_T1。
      • 如果事务T2是在RV_T1创建之后才开始的,则t2_id >= RV_T1.max_trx_id。根据规则3,T2修改的版本对T1不可见。
      • 如果事务T2是在RV_T1创建时活跃的,但之后提交了。对于RV_T1来说,t2_id当时在m_ids中。即使T2后来提交了,T1用的还是旧的RV_T1,所以根据规则4a,T2的修改版本对T1依然不可见。
      • 如果事务T2是在RV_T1创建前就已提交,那么t2_id < RV_T1.min_trx_id,T1在第一次读时就应该看到T2的版本了(规则2),后续再读自然也是这个版本。
    • 由于RV_T1是固定的,它“冻结”了T1所能看到的数据世界。任何在RV_T1创建之后发生并提交的修改,或者在RV_T1创建时仍未提交的修改,对T1来说,其最新版本都因不符合可见性规则而被“过滤”掉了。T1会一直追溯到符合其RV_T1可见性规则的那个历史版本。
    • 因此,T1总是能看到其Read View创建时刻的数据快照,后续其他事务的提交对它“不可见”(它会看到这些修改发生前的版本),从而保证了可重复读。
    • 对比Read Committed:RC隔离级别下,每次SELECT都会重新创建Read View。所以,如果T2在T1两次SELECT之间提交,T1第二次SELECT时会用新的Read View。在这个新的Read View看来,T2的事务ID(t2_id)可能已经不在新的m_ids中(因为T2已提交)且小于新的max_trx_id,因此T2提交的修改就变得可见了,导致不可重复读。
  • (部分)解决幻读 (在Repeatable Read级别下):

    • 对于普通的SELECT(也称为快照读),MVCC确实能在很大程度上避免幻读。因为事务T1的Read View (RV_T1)是固定的。如果在RV_T1创建之后,其他事务T2插入了新的符合T1查询条件的行并提交,这些新行的DB_TRX_ID(设为t2_id)会大于等于RV_T1.max_trx_id。根据规则3,这些新行对T1是不可见的。
    • 但是,MVCC并不能完全解决所有幻读场景,尤其是在涉及“当前读”(Current Read)操作时。 当前读(如SELECT ... FOR UPDATE, SELECT ... LOCK IN SHARE MODE, INSERT, UPDATE, DELETE)会读取数据库中最新的、已提交的版本,并且通常会加锁。
      • 例如,事务T1执行了一个范围查询(快照读),没有发现某条记录。然后事务T2插入了符合该范围的新记录并提交。如果事务T1接下来执行一个UPDATE操作,其WHERE条件恰好覆盖了T2新插入的记录,那么这个UPDATE(当前读)就可能会作用于T2新插入的行。如果T1随后再次执行相同的范围查询(快照读),它仍然看不到T2的行(因为Read View没变),但它刚刚却更新了它!或者,如果T1先执行一个SELECT COUNT(*),然后T2插入,T1再执行一个UPDATE尝试更新这些行,最后再SELECT COUNT(*),可能会发现计数没变,但某些行却被更新了,或者更新失败(如果UPDATEWHERE条件精确匹配,而T2插入的行不完全匹配但可能受影响)。
    • 为了在RR级别下彻底解决幻读,InnoDB引入了Next-Key Locks (临键锁)。这是一种由记录锁(Record Lock)和间隙锁(Gap Lock)组合而成的锁。当进行范围查询或执行当前读操作时,Next-Key Lock不仅会锁定实际存在的记录,还会锁定这些记录之间的“间隙”,阻止其他事务在这些间隙中插入新的、可能导致幻读的数据。所以,RR级别下幻读的完全解决是MVCC和锁机制协同工作的结果,而非单靠MVCC。

特例:在两次快照读之间存在当前读,ReadView会重新生成,导致产生幻读。 通过这番剖析,MVCC的内部逻辑应该清晰多了。它并非魔法,而是一套设计精巧的规则和数据结构,以版本控制为核心,在保证数据一致性的前提下,最大化地提升了并发读的性能。这对于追求系统性和逻辑性的INTJ来说,无疑是一种优雅的工程实现。

MVCC真的能完全解决幻读吗?

最后,我们常常听到网上有人说MVCC解决了幻读,或者是没有解决幻读,众说纷纷。准确的说法是MVCC 部分解决幻读的(针对快照读)

InnoDB 如何在RR级别彻底解决幻读:Next-Key Locks

所以当面试中被问到Mysql是如何解决幻读的? 这里要准确的说:Innodb 通过MVCC和Next-Key Locks一起实现了幻读的解决。

MVCC本身不能完全解决幻读,特别是在当前读场景下。RR级别下幻读的完全解决是MVCC和锁机制(Next-Key Locks)协同工作的结果。

MVCC擅长快照读的幻读避免 -> 当前读绕过MVCC的Read View去读最新数据 -> 如果没有额外机制,当前读可能操作到其他事务新插入的、对本事务快照读不可见的行,导致幻读 -> Next-Key Locks在当前读时锁定范围和间隙 -> 阻止其他事务插入新行 -> 从而保证当前读操作时数据范围的稳定性,防止了幻读的发生。

因此,MVCC和Next-Key Locks就像是InnoDB在RR级别下对抗幻读的“倚天剑”和“屠龙刀”,两者缺一不可,共同维护了事务的隔离性和数据的一致性。

Comments