MySQL MVCC: 0 到 1 的系统理解其原理

发布于:2025-06-20 ⋅ 阅读:(19) ⋅ 点赞:(0)

        在数据库的世界里,并发控制 是一个永恒的话题。如何在多用户同时操作数据时,既保证数据的一致性,又能实现高性能?InnoDB 存储引擎给出了一个优雅的答案:MVCC(Multi-Version Concurrency Control,多版本并发控制)

        如果你曾对数据库的并发机制感到困惑,或者对 SELECT 语句为何不阻塞 UPDATE 感到好奇,那么这篇文章正是为你准备的。我们将从最基础的概念出发,一步步揭开 MVCC 的神秘面纱,直抵其底层实现原理,并结合实际案例和 SQL 语句,让你彻底掌握 MVCC 的精髓。

 


一、并发控制的痛点:为什么我们需要 MVCC?

想象一下,在一个电商平台,多个用户同时浏览商品、下单支付。如果没有合理的并发控制机制,就会出现各种问题:

  • 脏读(Dirty Read):一个事务读到了另一个未提交事务修改的数据。如果那个未提交事务回滚了,读到的就是“假”数据。
  • 不可重复读(Non-Repeatable Read):在同一个事务中,两次读取同一条记录,发现数据不一致,因为其他事务在这两次读取之间提交了修改。
  • 幻读(Phantom Read):在同一个事务中,两次执行相同的查询,发现行数不同,因为其他事务在这两次查询之间插入或删除了符合查询条件的记录。

        传统的并发控制,通常通过锁(Locking) 来解决这些问题。例如,当一个事务在修改数据时,就对数据加排他锁,其他事务想读或写就必须等待。虽然保证了数据一致性,但极大地牺牲了并发性能

MVCC 正是为了解决读写冲突的痛点而生: 它通过保存数据的多个历史版本,让读操作可以读取数据的旧版本(快照),而无需等待写操作释放锁。


二、MVCC 的核心基石:隐藏列与 Undo Log 版本链

MVCC 并非魔法,它的背后是一套精巧的设计。理解 MVCC,首先要了解 InnoDB 行格式中的几个隐藏列Undo Log 版本链

1. 隐藏列:行数据的“身份证”与“时光机”

InnoDB 为每行记录额外增加了几个隐藏列,它们是 MVCC 得以运行的“基础设施”:

  • DB_TRX_ID (Transaction ID)

    • 占用 6 字节。
    • 记录了最近一次修改该行数据的事务 ID。每当一个事务修改(INSERT / UPDATE / DELETE)了一行数据,它就会将自己的事务 ID 写入这行的 DB_TRX_ID 列。
    • 重要提示: DB_TRX_ID 的更新时机是在数据修改语句执行后立即更新,而非事务提交后。事务提交只是改变了该事务 ID 的状态(从活跃变为已提交),而不会改变行上的 DB_TRX_ID 值。
  • DB_ROLL_PTR (Roll Pointer)

    • 占用 7 字节。
    • 这是一个回滚指针,指向当前行的上一个版本在 Undo Log 段中的位置。
    • 每当一行数据被更新时,它的旧版本数据会被写入 Undo Log,并生成一条新的 Undo Log 记录。DB_ROLL_PTR 就指向这条新的 Undo Log 记录的地址。
  • DB_ROW_ID (Row ID)

    • 占用 6 字节。
    • 这是一个隐藏的行 ID。如果表没有定义主键,也没有定义任何非空唯一键,那么 InnoDB 会自动为每行数据生成一个 DB_ROW_ID 作为隐藏的聚集索引。有了主键或唯一键,这个列通常不直接参与 MVCC 逻辑。

2. Undo Log 版本链:数据的“历史记录”

正是通过 DB_TRX_IDDB_ROLL_PTR 这两个隐藏列,以及 Undo Log,InnoDB 构建了行的版本链

当一行数据被修改时,InnoDB 的操作流程是:

  1. 将当前行记录的旧版本数据复制一份到 Undo Log 中。
  2. 在 Undo Log 中,这条记录会包含上一个版本的 DB_ROLL_PTR 值,从而将自身与更早的版本连接起来。
  3. 在数据行本身,更新 DB_TRX_ID 为当前事务的 ID,并更新 DB_ROLL_PTR 指向新生成的 Undo Log 记录的地址。
  4. 数据行本身的最新内容被修改。

这样,一行数据的每一次修改,都会在 Undo Log 中留下一个“足迹”,并且这些足迹通过 DB_ROLL_PTR 逆向连接,形成一条指向过去的链条,这就是Undo Log 版本链

示例:一个数据的版本链演变

假设 products 表中有一条记录 id=1, name='Laptop', price=80 (初始事务 T0 插入)。

UPDATE 1:事务 A (T100) 将 price 改为 90

-- 事务 A (ID: T100)
START TRANSACTION;
UPDATE products SET price = 90 WHERE id = 1;
COMMIT;

此时,数据行和 Undo Log 的逻辑状态(U1U2 代表的是 Undo Log 记录的逻辑地址或标识符。):

  • 当前数据行: id=1, name='Laptop', price=90, DB_TRX_ID=T100, DB_ROLL_PTR -> U1
  • Undo Log (U1): id=1, name='Laptop', price=80, DB_TRX_ID=T0, DB_ROLL_PTR=NULL

 

UPDATE 2:事务 B (T200) 将 price 改为 95

-- 事务 B (ID: T200)
START TRANSACTION;
UPDATE products SET price = 95 WHERE id = 1;
COMMIT;

此时,数据行和 Undo Log 的逻辑状态:

  • 当前数据行: id=1, name='Laptop', price=95, DB_TRX_ID=T200, DB_ROLL_PTR -> U2
  • Undo Log (U2): id=1, name='Laptop', price=90, DB_TRX_ID=T100, DB_ROLL_PTR -> U1
  • Undo Log (U1): id=1, name='Laptop', price=80, DB_TRX_ID=T0, DB_ROLL_PTR=NULL

这就形成了一个完整的版本链:price=95 (T200) -> price=90 (T100) -> price=80 (T0)

 


三、读视图 (Read View):MVCC 的“时间旅行”机制

光有版本链还不够,数据库怎么知道哪个事务应该看到哪个版本的数据呢?这就需要读视图 (Read View)

当一个事务执行快照读(即普通的 SELECT 语句,不带 FOR UPDATELOCK IN SHARE MODE)时,InnoDB 会为它创建一个读视图。这个读视图就相当于给当前事务拍了一张“照片”,决定了它在整个事务生命周期内(针对 REPEATABLE READ)或每次快照读时(针对 READ COMMITTED)能看到哪些数据。

一个读视图主要包含以下信息:

  • m_ids 当前系统中所有活跃的(即还未提交的)事务 ID 列表
  • min_trx_id m_ids 列表中最小的事务 ID。
  • max_trx_id 创建读视图时,系统下一个要分配的事务 ID
  • creator_trx_id 创建这个读视图的事务本身的 ID。

四、MVCC 的核心算法:可见性判断

有了版本链和读视图,MVCC 的可见性判断逻辑就变得清晰了。当一个快照读事务要读取一行数据时,它会按照以下步骤判断数据行的哪个版本是可见的:

  1. 获取最新版本: 从数据页上获取当前行的最新版本

  2. 检查 DB_TRX_ID 获取到最新版本后,检查这个版本的 DB_TRX_ID

  3. 判断可见性:

    • 如果该行的 DB_TRX_ID == creator_trx_id 如果是当前事务自己修改的,可见
    • 如果该行的 DB_TRX_ID < min_trx_id 如果该版本是在读视图创建之前就已经提交的事务修改的,可见
    • 如果该行的 DB_TRX_ID >= max_trx_id 如果该版本是在读视图创建之后才启动的事务修改的,不可见,回溯到 Undo Log 版本链中的上一个版本
    • 如果该行的 DB_TRX_ID 存在于 m_ids 列表中(活跃事务列表): 如果该版本是由一个当前正在活跃(未提交)的事务修改的,不可见,回溯到 Undo Log 版本链中的上一个版本
    • 如果该行的 DB_TRX_ID 不存在于 m_ids 列表中(即在读视图创建时已提交): 可见
  4. 回溯: 如果当前版本不可见,则沿着 DB_ROLL_PTR 回溯到 Undo Log 版本链中的上一个版本,重复步骤 2 和 3,直到找到一个可见版本,或者版本链回溯到末尾(表示该行在读视图创建后才插入,对当前事务不可见)。


五、MVCC 在不同隔离级别下的应用与案例

MVCC 主要在 READ COMMITTEDREPEATABLE READ 隔离级别下发挥作用。它们的主要区别在于读视图的生成时机。

1. READ COMMITTED (RC) 隔离级别
  • 读视图生成时机: 每一次快照读SELECT 语句)都会生成一个新的读视图。
  • 效果: 事务可以读到其他事务已提交的最新修改。
  • 解决了: 脏读
  • 存在问题: 不可重复读(同一个事务内,两次读取同一行数据可能不同)。

示例:RC 隔离级别下的不可重复读

 假设 student 表初始数据:id=1, age=22, name='Saul'

事务101 事务102 事务103 事务104
开启事务 开启事务 开启事务 开启事务
修改id为1的数据行,将age修改为25
修改id为1的数据行,将name修改为:Jaclal
提交事务 修改id为1的数据行,将age修改为18
查询id为1的记录
提交事务
查询id为1的记录
提交事务

        在上述事务操作时序中,我们可以清晰地看到事务是如何并发执行并修改同一条记录的。在所有事务完成提交后,该记录最终的状态是 id=1, age=18, name='Jaclal'。然而,为了实现 MVCC (多版本并发控制),每一次对记录的修改都不会直接覆盖原始数据,而是会生成一个新的记录版本,并将旧版本的数据放入 Undo Log 中。通过 DB_ROLL_PTR 这个指针,这些旧版本数据在 Undo Log 中形成了一条完整的链条。这个 Undo Log 链正是数据库能够回溯历史版本、实现快照读以及事务回滚的关键所在。正是这些环环相扣的版本,构成了在不同时间点下事务可见性的基础。

                                

分析事务 104 第一次 SELECT 语句(以开启事务后第一行为T1):

当事务 104 执行第一条 SELECT * FROM student WHERE id=1; 语句时,它会生成一个 ReadView。我们来看一下此时的事务状态:

  • 事务 101: 在 T3 时刻已经提交。因此,事务 101 不在活跃事务列表中。
  • 事务 102: 在 T2 时刻修改数据,但尚未提交 (它的提交操作在 T5 时刻)。因此,在事务 104 执行第一次查询的 T4 时刻,事务 102 处于活跃状态。
  • 事务 103: 在 T3 时刻修改数据,但尚未提交 (它的提交操作在 T10 时刻)。因此,在事务 104 执行第一次查询的 T4 时刻,事务 103 处于活跃状态。
  • 事务 104: 自身正在执行查询,所以它也是活跃的。

基于以上分析,事务 104 在执行第一次 SELECT 语句时生成的 ReadView 状态如下:

  • m_ids: m_ids 列表中包含了所有在 ReadView 创建时仍处于活跃状态的事务 ID。根据时序,此时活跃的事务有 102、103 以及事务自身 104。因此,m_ids = {102, 103, 104}
  • min_trx_id: 活跃事务 m_ids 列表中最小的事务 ID 是 102
  • max_trx_id: 当前系统中下一个即将分配的事务 ID,假设为 105
  • creator_trx_id: 创建这个 ReadView 的事务 ID,即事务自身 104

因此,事务 104 第一次执行 SELECT 语句时,其 ReadView 的状态正是如下图中所示的 m_ids: {102, 103, 104}, min_trx_id: 102, max_trx_id: 105, creator_trx_id: 104。 这个 ReadView 将用于判断它能够看到哪些历史版本的数据。

 

判断可见性:事务 104 第一次 SELECT 如何读取数据

InnoDBMVCC 机制下,当事务 104 执行 SELECT 语句时,它会结合自身生成的 ReadView  和当前记录的数据以及 Undo Log 链 来判断哪个版本的数据是可见的。这个过程遵循以下可见性判断规则:

  1. 如果该行的 DB_TRX_ID == creator_trx_id 是当前事务自己修改的,可见
  2. 如果该行的 DB_TRX_ID < min_trx_id 该版本是在读视图创建之前就已经提交的事务修改的,可见
  3. 如果该行的 DB_TRX_ID >= max_trx_id 该版本是在读视图创建之后才启动的事务修改的,不可见,回溯到 Undo Log 版本链中的上一个版本
  4. 如果该行的 DB_TRX_ID 存在于 m_ids 列表中(活跃事务列表): 该版本是由一个当前正在活跃(未提交)的事务修改的,不可见,回溯到 Undo Log 版本链中的上一个版本
  5. 如果该行的 DB_TRX_ID 不存在于 m_ids 列表中(即在读视图创建时已提交): 可见

        现在,我们结合事务 104 的 ReadView (m_ids: {102, 103, 104}, min_trx_id: 102, max_trx_id: 105, creator_trx_id: 104) 和 Undo Log 链 ,来判断事务 104 在执行第一次 SELECT 查询时,最终会读到哪条数据:

  1. 检查当前行(最新版本):

    • 数据: id=1, age=18, name='Jaclal', DB_TRX_ID=103
    • 判断: 该行 DB_TRX_ID103
      • 103 == creator_trx_id (104)?否。
      • 103 < min_trx_id (102)?否。
      • 103 >= max_trx_id (105)?否。
      • 103 存在于 m_ids 列表 {102, 103, 104} 中?是。
    • 结果: 由于 DB_TRX_ID=103 存在于 m_ids 活跃事务列表中,表示该版本是由一个活跃事务(事务 103)修改的,对事务 104 不可见。需要回溯到 Undo Log 链中的上一个版本,即 U3.
  2. 检查 Undo Log 版本 U3

    • 数据: id=1, age=25, name='Jaclal', DB_TRX_ID=102
    • 判断: 该版本 DB_TRX_ID102
      • 102 == creator_trx_id (104)?否。
      • 102 < min_trx_id (102)?否。
      • 102 >= max_trx_id (105)?否。
      • 102 存在于 m_ids 列表 {102, 103, 104} 中?是。
    • 结果: 由于 DB_TRX_ID=102 存在于 m_ids 活跃事务列表中,表示该版本是由一个活跃事务(事务 102)修改的,对事务 104 不可见。需要回溯到 Undo Log 链中的上一个版本,即 U2.
  3. 检查 Undo Log 版本 U2

    • 数据: id=1, age=25, name='Saul', DB_TRX_ID=101
    • 判断: 该版本 DB_TRX_ID101
      • 101 == creator_trx_id (104)?否。
      • 101 < min_trx_id (102)是。
    • 结果: 由于 DB_TRX_ID=101 小于 ReadViewmin_trx_id (102),表示该版本是由一个在 ReadView 创建之前就已经提交的事务(事务 101)修改的,对事务 104 可见。

结论:

根据上述可见性判断过程,当事务 104 执行第一次 SELECT 查询时,它会回溯 Undo Log 链,最终发现 Undo Log 中的 U2 版本 (id=1, age=25, name='Saul', DB_TRX_ID=101) 是对它可见的。因此,事务 104 的第一次查询将读取到 id=1, age=25, name='Saul' 这条数据。


分析事务 104 第二次 SELECT 语句(以开启事务后第一行为T1):

当事务 104 执行第二条 SELECT * FROM student WHERE id=1; 语句时,它依然会生成一个新的 ReadView。我们来看一下此时的事务状态:

  • 事务 101: 在 T3 时刻已经提交。因此,事务 101 不在活跃事务列表中。
  • 事务 102: 在 T2 时刻修改数据,在 T5 时刻提交。因此,在事务 104 执行第二次查询的 T8 时刻,事务 102 不在活跃事务列表中。
  • 事务 103: 在 T3 时刻修改数据,它的提交操作在 T10 时刻。因此,在事务 104 执行第二次查询的 T8 时刻,事务 103 处于活跃状态。
  • 事务 104: 自身正在执行查询,所以它也是活跃的。

基于以上分析,事务 104 在执行第一次 SELECT 语句时生成的 ReadView 状态如下:

  • m_ids: m_ids 列表中包含了所有在 ReadView 创建时仍处于活跃状态的事务 ID。根据时序,此时活跃的事务有 103 以及事务自身 104。因此,m_ids = {103, 104}
  • min_trx_id: 活跃事务 m_ids 列表中最小的事务 ID 是 103
  • max_trx_id: 当前系统中下一个即将分配的事务 ID,假设为 105
  • creator_trx_id: 创建这个 ReadView 的事务 ID,即事务自身 104

因此,事务 104 第二次执行 SELECT 语句时,其 ReadView 的状态正是如下图中所示的 m_ids: {103, 104}, min_trx_id: 103, max_trx_id: 105, creator_trx_id: 104。 这个 ReadView 将用于判断它能够看到哪些历史版本的数据。

 

同样,我们来分析判断过程:

  1. 检查当前行(最新版本):

    • 数据: id=1, age=18, name='Jaclal', DB_TRX_ID=103
    • 判断: 该行 DB_TRX_ID103
      • 103 == creator_trx_id (104)?否。
      • 103 < min_trx_id (103)?否。
      • 103 >= max_trx_id (105)?否。
      • 103 存在于 m_ids 列表 {103, 104} 中?是。
    • 结果: 由于 DB_TRX_ID=103 存在于 m_ids 活跃事务列表中,表示该版本是由一个活跃事务(事务 103)修改的,对事务 104 不可见。需要沿着 DB_ROLL_PTR 回溯到 Undo Log 链中的上一个版本,即 U3.
  2. 检查 Undo Log 版本 U3

    • 数据: id=1, age=25, name='Jaclal', DB_TRX_ID=102
    • 判断: 该版本 DB_TRX_ID102
      • 102 == creator_trx_id (104)?否。
      • 102 < min_trx_id (103)是。
    • 结果: 由于 DB_TRX_ID=102 小于 ReadViewmin_trx_id (103),表示该版本是由一个在 ReadView 创建之前就已经提交的事务(事务 102)修改的,对事务 104 可见。

结论:

根据上述可见性判断过程,当事务 104 执行第二次 SELECT 查询时,它会回溯 Undo Log 链,最终发现 Undo Log 中的 U3 版本 (id=1, age=25, name='Jaclal', DB_TRX_ID=102) 是对它可见的。因此,事务 104 的第二次查询将读取到 id=1, age=25, name='Jaclal' 这条数据。

 


2. REPEATABLE READ (RR) 隔离级别
  • 读视图生成时机: 事务中第一次快照读时生成读视图,此后整个事务生命周期都使用这个固定的读视图
  • 效果: 事务在整个生命周期内,看到的都是它第一次快照读时的数据快照,即便其他事务修改并提交了数据,当前事务也“看不到”。
  • 解决了: 脏读不可重复读
  • 存在问题: 幻读(虽然通过 MVCC 解决了大部分幻读,但对于 INSERT 操作,RR 结合间隙锁来彻底解决幻读)。

数据读取行为:REPEATABLE READ 隔离级别下,事务 104 两次 SELECT 查询的数据读取行为与 READ COMMITTED 隔离级别的主要区别在于 ReadView 的复用机制。由于 ReadView 在事务首次快照读时即已固定,因此后续的快照读会基于相同的活跃事务列表和事务 ID 范围进行可见性判断。

具体到本例:

  • 事务 104 第一次 SELECTReadView (m_ids: {102, 103, 104}, min_trx_id: 102, max_trx_id: 105, creator_trx_id: 104) 会导致其回溯到 Undo Log 链中的 U2 版本,读取到 id=1, age=25, name='Saul'
  • 事务 104 第二次 SELECT 即使在两次查询之间有其他事务(如事务 102)提交了其修改,事务 104 仍然复用第一次查询时生成的 ReadView (m_ids: {102, 103, 104}, min_trx_id: 102, max_trx_id: 105, creator_trx_id: 104)。因此,它将再次回溯到 Undo Log 链中的 U2 版本,仍然读取到 id=1, age=25, name='Saul'

核心差异总结:

REPEATABLE READ 隔离级别通过其“事务级快照”的 ReadView 策略,确保了在一个事务的生命周期内,对于同一条记录的多次快照读都能得到一致的结果,从而有效地避免了 READ COMMITTED 隔离级别下可能出现的不可重复读问题。它隔离了其他事务在当前事务启动后提交的修改。


六、当前读 (Current Read):获取最新数据并加锁

MVCC 主要解决的是快照读的并发问题。但是,有时我们确实需要读取最新的数据,并且要防止其他事务立即修改它。这时就需要用到当前读

当前读会读取最新的数据,并对读取到的记录加锁。

主要有以下几种情况属于当前读:

  • 所有 DML 语句:INSERTUPDATEDELETE
    • 为了修改数据,必然需要先获取最新的数据。这些操作都会对相关行加排他锁。
  • SELECT ... FOR UPDATE
    • 读取最新数据,并对读取到的行加排他锁(X 锁)。其他事务不能读取(会阻塞)、不能修改这些被锁定的行。通常用于悲观锁定。
  • SELECT ... LOCK IN SHARE MODE
    • 读取最新数据,并对读取到的行加共享锁(S 锁)。其他事务可以读取这些行(也能加共享锁),但不能修改这些行。

示例:SELECT ... FOR UPDATE 的应用

假设在 accounts 表中 id=101, balance=1000

会话 A (转账前的余额检查与锁定)

START TRANSACTION;

-- 读取账户余额并加排他锁,确保在我检查并扣款期间,没有其他事务能修改这个账户
SELECT balance FROM accounts WHERE account_id = 101 FOR UPDATE;
-- 结果:balance = 1000。此时 account_id = 101 的行被会话 A 加上了排他锁。

-- 假设业务逻辑判断余额足够,进行扣款
UPDATE accounts SET balance = balance - 200 WHERE account_id = 101;
-- 此时 account_id = 101 的 balance 在会话 A 的事务中是 800。

COMMIT;

并发会话 B 尝试操作:

-- 会话 B
START TRANSACTION;

-- 尝试读取 (快照读)
SELECT balance FROM accounts WHERE account_id = 101;
-- 结果:如果隔离级别是 RR,会读到 1000(会话 A 之前的版本)。

-- 尝试更新 (当前读,会加锁)
UPDATE accounts SET balance = balance + 50 WHERE account_id = 101;
-- 这条语句会因为 account_id = 101 被会话 A 持有的排他锁而**阻塞**,直到会话 A 提交。

七、总结

        MVCC 是 InnoDB 实现高并发和高可用性的基石之一。通过巧妙地利用 Undo Log 版本链读视图,它允许读操作不阻塞写操作,写操作不阻塞读操作,从而在保证数据一致性的同时,大幅提升了数据库的并发处理能力。

  • 快照读 (Snapshot Read):普通的 SELECT,利用 MVCC 读取历史版本,不加锁,不阻塞。
  • 当前读 (Current Read)INSERT/UPDATE/DELETESELECT ... FOR UPDATE/LOCK IN SHARE MODE,读取最新版本,并加锁,保证数据强一致性。

        理解 MVCC 的原理,有助于我们更深入地优化 SQL 查询、选择合适的隔离级别,并在设计高并发系统时做出更明智的决策。

        希望通过这篇博客,你对 MVCC 会有了从 0 到 1 的系统认知!


网站公告

今日签到

点亮在社区的每一天
去签到