硬核图解:MySQL 事务、MVCC 与锁机制的底层原理

40 阅读6分钟

在后端面试中,MySQL 的事务隔离级别、MVCC(多版本并发控制)以及锁机制是由于关联性极强而经常被“一锅端”的话题。

很多开发者知道 RR(可重复读)是 MySQL 的默认隔离级别,也知道它能解决“不可重复读”,但你是否深入思考过:

  • Undo Log 到底长什么样?它是怎么把数据串起来的?
  • Read View 是如何像裁判一样决定你能看到哪条数据的?
  • 幻读 到底是被 MVCC 解决的,还是被锁解决的?

本文将结合四张关键架构图,带你从宏观到微观,彻底搞懂 InnoDB 引擎的并发控制秘密。

1. 宏观视角:事务隔离级别与 Read View

数据库需要在“数据一致性”和“并发性能”之间做平衡,这就诞生了四种隔离级别。在 InnoDB 中,最常用的两个级别是 RC (读已提交)RR (可重复读) 。它们都利用了 MVCC 机制来实现“读写不冲突”,但效果却截然不同。

图1

RC 与 RR 的核心区别:Read View 生成时机

从图中我们可以清晰地看到两者的分水岭:

  • 读已提交 (Read Committed, RC):

    • 机制: 事务中的每次 SQL 查询(Select),都会重新生成一个新的 Read View。
    • 结果: 只要别的事务提交了修改,我下一次查询重新生成视图时就能看到。这就是为什么它会出现“不可重复读”。
  • 可重复读 (Repeatable Read, RR):

    • 机制: 事务开启后的第一次 SQL 查询生成 Read View,之后整个事务期间复用这个视图。
    • 结果: 就像拍了一张照片,不管外面世界(其他事务)怎么翻江倒海,我看这张照片的内容永远不变。这就实现了“可重复读”。

思考: 既然是看“照片”(快照),那这些旧版本的数据存在哪里呢?

2. 底层基石:Undo Log 与版本链

InnoDB 中的数据行并不是二维表那么简单,每一行数据背后都拖着一条长长的“历史尾巴”,这就存储在 Undo Log 中。

图2

隐藏字段与版本链

如图所示,数据库中的每一行记录除了我们定义的业务字段(如 id, name, age)外,还有两个关键的隐藏字段

  1. DB_TRX_ID (事务ID): 记录最后一次修改该行数据的事务 ID。
  2. DB_ROLL_PTR (回滚指针): 指向 Undo Log 中上一个版本的地址。

链表的形成

当我们执行 UPDATE 操作时,InnoDB 不会直接覆盖旧数据,而是:

  1. 把旧数据拷贝到 Undo Log 中。
  2. 更新当前数据,并将 DB_ROLL_PTR 指向 Undo Log 中的旧数据。
  3. 如果是多次更新,就会像图中一样,形成一个 当前数据 -> 历史版本1 -> 历史版本2 ...版本链

注意: DELETE 操作本质上也是一种 Update(只是打上了删除标记),也会记录在 Undo Log 中。

3. 核心算法:Read View 可见性规则

现在我们有了版本链,也有了Read View(快照),那么事务在读取数据时,到底该选链条上的哪一个版本呢?这就需要一套严密的可见性算法。

图3Read View 里的关键信息

Read View 本质上记录了生成快照那一刻,系统里“活跃中”(未提交) 的事务名单(图中绿色框部分):

  • m_ids: 活跃事务 ID 列表(例如 [100, 150, 160])。
  • min_trx_id: 活跃列表中最小的 ID(水位下限)。
  • max_trx_id: 预分配的下一个事务 ID(水位上限)。
  • creator_trx_id: 当前事务自己的 ID。

裁判算法 (右侧流程图)

当事务读取某行数据时,会拿着该行数据的 trx_id 按照图中的流程图进行比对:

  1. 是自己改的吗? (trx_id == creator_trx_id) 👉 可见
  2. 是已经提交的旧事务吗? (trx_id < min_trx_id) 👉 说明在我生成快照前它就完事了,可见
  3. 是未来的新事务吗? (trx_id >= max_trx_id) 👉 说明是我生成快照后才开启的事务,不可见
  4. 在活跃名单里吗? (trx_id in m_ids) 👉 说明我生成快照时它还没提交,不可见

如果当前版本不可见,就顺着 Undo Log 的链表找下一个版本,直到找到可见的为止。这就是 MVCC (多版本并发控制) 的真面目。

终极防线:锁机制与幻读

MVCC 解决了“快照读”的并发问题,但如果是“当前读”(比如 select for update, update),我们必须读取最新的数据。这时,InnoDB 必须动用来保证数据一致性。

图4

锁的粒度:表锁 vs 行锁

表级锁 (Table Lock): 锁定整张表。

触发场景: 当执行 update/delete 等语句没有命中索引时,InnoDB 无法定位具体行,只能锁住整张表(或所有行),导致并发性能大降。此外,DDL 操作(如 ALTER TABLE)也会涉及表锁。

行级锁 (Row Lock): 仅锁定需要操作的数据行。这是 InnoDB 相比 MyISAM 的最大优势,支持高并发。

行级锁的三种算法

在行级锁的范畴内,为了解决复杂的并发问题(特别是幻读),InnoDB 设计了三种具体的锁定算法(参考图 4 右侧):

  1. 记录锁 (Record Lock):

    • 含义: 仅仅锁住索引记录本身(如 id=1)。
    • 场景: 精确查询主键或唯一索引时使用。
  2. 间隙锁 (Gap Lock):

    • 含义: 锁住索引记录之间的“空隙”(如 id 6 到 15 之间的空白区域),不包含记录本身
    • 作用: 它的目的不是锁数据,而是 “占坑” ,防止别的事务往这个缝隙里 INSERT 新数据。这是解决幻读的关键。
  3. 临键锁 (Next-Key Lock):

    • 含义: 记录锁 + 间隙锁 的组合(左开右闭区间,如 (15, 20])。
    • 默认行为: 在 RR(可重复读)隔离级别下,InnoDB 默认使用 Next-Key Lock 进行加锁,既锁住数据,又锁住间隙。

幻读的解决

在 RR 隔离级别下,InnoDB 使用 Next-Key Lock 来解决幻读:

  • 当你执行 select * from table where id > 15 for update 时;
  • 数据库不仅会锁住 id=20 这一行;
  • 还会加上间隙锁,锁住 (15, 20] 以及 (20, +∞) 的范围。
  • 这样,其他事务想在这个范围内 INSERT 数据就会被阻塞,从而彻底杜绝了幻读。

总结

通过这四张图,我们理清了 MySQL 并发控制的一条完整逻辑链:

  1. Undo Log 提供了数据的历史版本,是实现多版本的物理基础。
  2. Read View 是事务开启时的一张快照,定义了“我能看到什么”。
  3. MVCC 结合前两者,实现了高效的无锁读,区分了 RC 和 RR 级别。
  4. Lock (特别是 Gap Lock) 则在需要强一致性的场景下,筑起了最后一道防线,解决了幻读问题。

希望这篇文章能帮你建立起立体的数据库事务认知!