mvcc多版本并发控制

MySQL 中 InnoDB 存储引擎实现 MVCC

一、MVCC 的含义

MVCC,全称是 Multi-Version Concurrency Control,即多版本并发控制

它是一种数据库管理技术,用于高效地处理多用户环境下的读-写和写-写冲突,从而实现非阻塞的读操作,并提高数据库的并发性能。

核心思想
为每一行数据维护多个版本(通常是快照)。当一个事务需要读取数据时,数据库会呈现一个在事务开始时刻就已经存在的、一致的数据库快照。这个快照是基于某个时间点的版本,而不会看到在其之后其他事务的修改(取决于隔离级别)。这样,读操作就不会被写操作阻塞,反之亦然。

解决的问题

  • 读-写阻塞:在没有 MVCC 的情况下,为了避免“脏读”、“不可重复读”等问题,通常需要通过加锁(如 SELECT ... FOR UPDATE)来实现,这会导致读操作等待写操作释放锁,或者写操作等待读操作释放锁,严重降低并发性。
  • 提升性能:MVCC 使得大部分普通的 SELECT 查询(快照读)无需申请锁,极大地提升了数据库的读并发能力和整体性能。

主要支持的隔离级别
MVCC 最常用于 READ COMMITTEDREPEATABLE READ 这两个隔离级别。InnoDB 的 RR 级别很大程度上借助 MVCC 避免了幻读问题。


二、MySQL InnoDB 的 MVCC 实现机制

InnoDB 通过以下三个核心组件来实现 MVCC:

1. 隐藏字段

InnoDB 为每一行数据(聚簇索引记录)添加了三个系统隐藏字段:

  • DB_TRX_ID (6字节)事务ID。表示最后一次插入或更新该行的事务ID。删除在 InnoDB 内部也被视为一次更新。
  • DB_ROLL_PTR (7字节)回滚指针。指向该行数据的前一个版本(存储在 Undo Log 中)的指针。通过它可以将数据回溯到之前的快照。
  • DB_ROW_ID (6字节)行ID。单调递增的ID。如果表没有定义主键,InnoDB 会自动生成一个聚簇索引基于此字段。

2. Undo Log (回滚日志)

Undo Log 保存了数据被修改前的多个版本。当一行数据被更新时,旧版本的数据不会立即被删除,而是会被拷贝到 Undo Log 中,形成一个版本链。新版本的 DB_ROLL_PTR 字段就指向这个旧的版本。

  • 这个版本链的头是最新的记录,通过 DB_ROLL_PTR 可以不断回溯到更早的记录。
  • Undo Log 的另一个作用是用于事务回滚。

3. Read View (读视图)

这是决定一个事务能看到哪个版本数据的关键。当一个事务执行快照读(普通的 SELECT)时,InnoDB 会为该事务生成一个Read View,它就像是给数据库拍了一个“快照”,但这个快照并非真实的数据拷贝,而是一个基于特定规则的视图。

Read View 主要包含以下内容:

  • m_ids:生成 Read View 时,系统中活跃(已启动但未提交)的读写事务ID列表。
  • min_trx_idm_ids 中的最小值。
  • max_trx_id:生成 Read View 时,系统应该分配给下一个事务的ID。(注意:它并不是 m_ids 中的最大值,而是已分配的最大ID+1)。
  • creator_trx_id:创建该 Read View 的当前事务自己的ID(只有写操作的事务才有ID,只读事务的ID为0)。

三、可见性判断规则

当一个事务要访问某行数据时,InnoDB 会遍历该行的版本链,并将链中每个版本的 DB_TRX_ID 与当前事务的 Read View 进行比对,以决定该版本是否可见。判断规则如下:

  1. 如果 DB_TRX_ID 等于 creator_trx_id,说明该版本是由当前事务自己修改的,可见
  2. 如果 DB_TRX_ID 小于 min_trx_id,说明该版本在生成 Read View 时已经提交可见
  3. 如果 DB_TRX_ID 大于等于 max_trx_id,说明该版本是在生成 Read View 之后才开启的事务修改的,不可见
  4. 如果 DB_TRX_IDmin_trx_idmax_trx_id 之间(即 min_trx_id <= DB_TRX_ID < max_trx_id),则需要检查 DB_TRX_ID 是否在 m_ids(活跃事务列表)中:
    • 如果在,说明修改该版本的事务在生成 Read View 时还未提交,该版本不可见
    • 如果不在,说明修改该版本的事务在生成 Read View 时已经提交,该版本可见

如果某个版本对当前事务不可见,则沿着 DB_ROLL_PTR 回滚指针找到上一个版本,重复上述判断规则,直到找到第一个可见的版本为止。


四、举例说明

假设我们有一张简单的表 accounts

id name balance (DB_TRX_ID) (DB_ROLL_PTR)
1 Alice 1000 ... ...

事务执行序列如下:

时间点 事务A (Trx-ID=10) 事务B (Trx-ID=20) 事务C (Trx-ID=30,当前查询事务)
T1 BEGIN;
T2 UPDATE accounts SET balance=900 WHERE id=1;
T3 BEGIN;
T4 UPDATE accounts SET balance=800 WHERE id=1;
T5 COMMIT; (提交)
T6 BEGIN; (开始一个快照读)
T7 SELECT balance FROM accounts WHERE id=1;

此时,id=1 这行数据的版本链如下(从新到旧):

  • Version Nbalance=800 (由 Trx-ID=20 修改,DB_ROLL_PTR 指向 Version M)
  • Version Mbalance=900 (由 Trx-ID=10 修改,DB_ROLL_PTR 指向 Version C)
  • Version Cbalance=1000 (创建该行数据的事务,假设已提交)

在 T7 时刻,事务C (Trx-ID=30) 执行 SELECT,会生成一个 Read View:

  • 假设此时系统中只有事务B (20) 是活跃的,事务A (10) 已提交。
  • 那么 Read View 的内容大约是:
    • m_ids: [20]
    • min_trx_id: 20
    • max_trx_id: 31 (下一个事务ID)
    • creator_trx_id: 30 (事务C自己的ID)

现在,事务C 开始遍历版本链,判断哪个版本对它可见:

  1. 首先访问最新的 Version N (balance=800, trx_id=20)

    • 规则1:20 != 30,不是自己修改的。
    • 规则2:20 >= min_trx_id (20),不小于。
    • 规则3:20 < max_trx_id (31),不大于等于。
    • 规则4:20m_ids=[20] 中?在! 说明修改它的事务(20)在生成ReadView时还未提交。
    • 结论:Version N 不可见。 继续沿着指针找上一个版本。
  2. 访问 Version M (balance=900, trx_id=10)

    • 规则1:10 != 30,不是自己修改的。
    • 规则2:10 < min_trx_id (20)?是!
    • 结论:Version M 可见! (因为修改它的事务10在ReadView生成前已经提交了)

所以,事务C 最终读到的结果是 balance=900

哪些版本对事务C不可见?

  • Version N (balance=800):因为修改它的事务B (20) 在事务C生成ReadView时还是活跃的,未提交。根据规则,不能看到未提交事务的修改(避免了脏读)。
  • 所有在 ReadView 生成之后才产生的新版本(本例中没有),根据规则3都不可见。

关键点:

  • REPEATABLE READ 隔离级别下,事务只在第一次执行快照读时生成ReadView,后续的所有读操作都复用这个ReadView。因此,它每次读到的都是同一个快照,实现了可重复读。
  • READ COMMITTED 隔离级别下,事务在每次执行快照读时都会生成一个新的ReadView。这样,它每次都能看到最新已提交的数据。
posted @ 2025-08-30 16:35  adragon  阅读(11)  评论(0)    收藏  举报