Skip to content

1. MVCC 的底层实现原理

MVCC 的实现主要依赖于以下几个核心概念:隐藏字段undo log版本链ReadView

1.1 隐藏字段

对于 InnoDB 存储引擎,每一行数据记录中除了我们定义的字段外,还包含了几个隐藏字段,其中最重要的两个是:

  • trx_id (事务 ID):记录了最近一次修改(插入或更新)该行数据的事务的 ID。这个 ID 是自增长的。
  • roll_pointer (回滚指针):指向该行上一个版本的 undo log 记录。通过这个指针,可以将一行数据的多个版本串联起来。

还有一个隐藏字段 row_id,它是在没有主键和非空唯一索引时,InnoDB 自动生成的单调递增的行 ID,但它与 MVCC 的直接关系不大。

1.2 undo log

undo log 即回滚日志,它在 MVCC 中扮演着至关重要的角色,主要有两个作用:

  1. 事务回滚:当事务需要回滚时,可以利用 undo log 将数据恢复到修改前的状态,保证原子性。
  2. 构建版本链undo log 存储了数据的历史版本,MVCC 利用它来实现快照读。

undo log 主要分为两种:

  • insert undo log: 在事务插入新记录时产生,仅在事务回滚时需要,事务提交后即可丢弃。
  • update undo log: 在事务更新或删除记录时产生,不仅用于事务回滚,也用于快照读。只有在没有事务需要通过它来构建历史版本时,才会被清理。

1.3 版本链

当多个事务对同一行数据进行修改时,会产生多个版本。这些版本通过 roll_pointer(回滚指针)连接成一个链表,这个链表就被称为版本链。链表的头部是当前最新的数据版本,链表中的每个版本都记录了修改它的 trx_id

ReadView 是 MVCC 实现可见性判断的核心。当一个事务执行快照读(普通的 SELECT 语句)时,数据库会为其生成一个 ReadView。这个 ReadView 记录并维护了在生成这个 ReadView 的时刻,系统中所有活跃(即未提交)的事务 ID 列表。可以理解为,ReadView 保存了当前事务不应该看到的其他事务 ID 列表。

ReadView 包含以下几个重要属性:

  • creator_trx_id:创建这个 ReadView 的事务的 ID。
  • trx_ids:一个列表,记录了在生成 ReadView 时,系统中所有活跃(未提交)的读写事务 ID。
  • up_limit_id:活跃事务列表 trx_ids 中最小的事务 ID。如果 trx_ids 为空,则等于 low_limit_id
  • low_limit_id:生成 ReadView 时,系统下一个将要分配的事务 ID,即当前已出现的最大事务 ID + 1。

可见性判断规则: 当一个事务去访问某个版本链中的数据时,会用其持有的 ReadView 与该数据版本的 trx_id 进行比较,判断该版本是否可见:

  1. 如果 trx_id 等于 creator_trx_id:说明这个版本是当前事务自己修改的,可见
  2. 如果 trx_id 小于 up_limit_id:说明修改这个版本的事务在当前事务生成 ReadView 之前已经提交了,因此该版本可见
  3. 如果 trx_id 大于或等于 low_limit_id:说明修改这个版本的事务是在当前事务生成 ReadView 之后才开启的,因此该版本不可见
  4. 如果 trx_idup_limit_idlow_limit_id 之间:需要进一步判断 trx_id 是否存在于 trx_ids 列表中。
    • 如果存在,说明在生成 ReadView 时,修改该版本的事务还是活跃的(未提交),因此该版本不可见
    • 如果不存在,说明在生成 ReadView 时,修改该版本的事务已经提交了,因此该版本可见

如果一个版本经过上述判断后被认为是不可见的,事务就会通过 roll_pointer 顺着版本链继续寻找上一个版本,然后重复进行可见性判断,直到找到一个可见的版本为止。

2. MVCC 在不同隔离级别下的工作方式

MVCC 在读已提交 (RC)可重复读 (RR) 级别下的核心区别在于生成 ReadView 的时机

  • 读已提交 (RC):事务中的每一次快照读(SELECT 语句)都会生成一个新的、最新的 ReadView。这意味着,在一个事务中多次查询,可能会看到其他已提交事务所做的修改,从而导致不可重复读。
  • 可重复读 (RR):事务中的第一次快照读会生成一个 ReadView,并且在整个事务期间都复用这个 ReadView。因此,无论其他事务如何修改并提交,该事务看到的数据版本都是一致的,从而避免了不可重复读。

3. MVCC 能否解决幻读?

这是一个非常有争议的问题,严谨的结论是:MVCC 在一定程度上避免了快照读的幻读,但不能完全解决幻读问题。RR 级别下彻底解决幻读,需要依靠锁(临键锁 Gap Lock + Record Lock)机制。

3.1 为什么说 MVCC 不能完全解决幻读?

  1. 事务 A (RR 级别) 执行 SELECT * FROM user WHERE id = 5;,因为 ReadView 的缘故,查询结果为空。
  2. 事务 B 插入一条 id = 5 的记录并提交
  3. 事务 A 再次执行 SELECT * FROM user WHERE id = 5;,由于复用旧的 ReadView,查询结果依然为空。从这个角度看,似乎没有幻读,因为两次读取结果一致,这是 MVCC 保证的“可重复读”。

但是,如果事务 A接下来执行插入操作:INSERT INTO user (id, name) VALUES (5, '田七'); 此时数据库会报错,提示主键冲突。这就产生了“幻觉”:我明明查询不到 id=5 的记录,但插入时却告诉我记录已存在。这就是幻读

这个例子清晰地表明,单纯依靠 MVCC 机制,事务无法感知到其他事务已经提交的插入操作,导致在执行写入(当前读)时出现问题。

3.2 快照读 vs. 当前读

  • 快照读 (Snapshot Read):普通的 SELECT 语句,不加锁,读取的是符合可见性规则的历史版本数据。
  • 当前读 (Current Read):会读取记录的最新版本,并对读取的记录加锁,以保证其他事务不能并发修改。以下语句都是当前读:
    • SELECT ... FOR UPDATE (加排他锁)
    • SELECT ... LOCK IN SHARE MODE (加共享锁)
    • INSERT, UPDATE, DELETE (隐式加锁)

MVCC 只对快照读有效。对于当前读,为了避免幻读,MySQL 的 InnoDB 引擎在 RR 级别下引入了Next-Key Lock(临键锁),它锁住了记录本身及其之前的间隙,从而防止其他事务在这个范围内插入新的记录。