在后端面试中,MySQL 的事务隔离级别、MVCC(多版本并发控制)以及锁机制是由于关联性极强而经常被“一锅端”的话题。
很多开发者知道 RR(可重复读)是 MySQL 的默认隔离级别,也知道它能解决“不可重复读”,但你是否深入思考过:
- Undo Log 到底长什么样?它是怎么把数据串起来的?
- Read View 是如何像裁判一样决定你能看到哪条数据的?
- 幻读 到底是被 MVCC 解决的,还是被锁解决的?
本文将结合四张关键架构图,带你从宏观到微观,彻底搞懂 InnoDB 引擎的并发控制秘密。
1. 宏观视角:事务隔离级别与 Read View
数据库需要在“数据一致性”和“并发性能”之间做平衡,这就诞生了四种隔离级别。在 InnoDB 中,最常用的两个级别是 RC (读已提交) 和 RR (可重复读) 。它们都利用了 MVCC 机制来实现“读写不冲突”,但效果却截然不同。
RC 与 RR 的核心区别:Read View 生成时机
从图中我们可以清晰地看到两者的分水岭:
-
读已提交 (Read Committed, RC):
- 机制: 事务中的每次 SQL 查询(Select),都会重新生成一个新的 Read View。
- 结果: 只要别的事务提交了修改,我下一次查询重新生成视图时就能看到。这就是为什么它会出现“不可重复读”。
-
可重复读 (Repeatable Read, RR):
- 机制: 事务开启后的第一次 SQL 查询生成 Read View,之后整个事务期间复用这个视图。
- 结果: 就像拍了一张照片,不管外面世界(其他事务)怎么翻江倒海,我看这张照片的内容永远不变。这就实现了“可重复读”。
思考: 既然是看“照片”(快照),那这些旧版本的数据存在哪里呢?
2. 底层基石:Undo Log 与版本链
InnoDB 中的数据行并不是二维表那么简单,每一行数据背后都拖着一条长长的“历史尾巴”,这就存储在 Undo Log 中。
隐藏字段与版本链
如图所示,数据库中的每一行记录除了我们定义的业务字段(如 id, name, age)外,还有两个关键的隐藏字段:
- DB_TRX_ID (事务ID): 记录最后一次修改该行数据的事务 ID。
- DB_ROLL_PTR (回滚指针): 指向 Undo Log 中上一个版本的地址。
链表的形成
当我们执行 UPDATE 操作时,InnoDB 不会直接覆盖旧数据,而是:
- 把旧数据拷贝到 Undo Log 中。
- 更新当前数据,并将
DB_ROLL_PTR指向 Undo Log 中的旧数据。 - 如果是多次更新,就会像图中一样,形成一个
当前数据 -> 历史版本1 -> 历史版本2 ...的版本链。
注意: DELETE 操作本质上也是一种 Update(只是打上了删除标记),也会记录在 Undo Log 中。
3. 核心算法:Read View 可见性规则
现在我们有了版本链,也有了Read View(快照),那么事务在读取数据时,到底该选链条上的哪一个版本呢?这就需要一套严密的可见性算法。
Read View 里的关键信息
Read View 本质上记录了生成快照那一刻,系统里“活跃中”(未提交) 的事务名单(图中绿色框部分):
- m_ids: 活跃事务 ID 列表(例如 [100, 150, 160])。
- min_trx_id: 活跃列表中最小的 ID(水位下限)。
- max_trx_id: 预分配的下一个事务 ID(水位上限)。
- creator_trx_id: 当前事务自己的 ID。
裁判算法 (右侧流程图)
当事务读取某行数据时,会拿着该行数据的 trx_id 按照图中的流程图进行比对:
- 是自己改的吗? (
trx_id == creator_trx_id) 👉 可见。 - 是已经提交的旧事务吗? (
trx_id < min_trx_id) 👉 说明在我生成快照前它就完事了,可见。 - 是未来的新事务吗? (
trx_id >= max_trx_id) 👉 说明是我生成快照后才开启的事务,不可见。 - 在活跃名单里吗? (
trx_id in m_ids) 👉 说明我生成快照时它还没提交,不可见。
如果当前版本不可见,就顺着 Undo Log 的链表找下一个版本,直到找到可见的为止。这就是 MVCC (多版本并发控制) 的真面目。
终极防线:锁机制与幻读
MVCC 解决了“快照读”的并发问题,但如果是“当前读”(比如 select for update, update),我们必须读取最新的数据。这时,InnoDB 必须动用锁来保证数据一致性。
锁的粒度:表锁 vs 行锁
表级锁 (Table Lock): 锁定整张表。
触发场景: 当执行 update/delete 等语句没有命中索引时,InnoDB 无法定位具体行,只能锁住整张表(或所有行),导致并发性能大降。此外,DDL 操作(如 ALTER TABLE)也会涉及表锁。
行级锁 (Row Lock): 仅锁定需要操作的数据行。这是 InnoDB 相比 MyISAM 的最大优势,支持高并发。
行级锁的三种算法
在行级锁的范畴内,为了解决复杂的并发问题(特别是幻读),InnoDB 设计了三种具体的锁定算法(参考图 4 右侧):
-
记录锁 (Record Lock):
- 含义: 仅仅锁住索引记录本身(如
id=1)。 - 场景: 精确查询主键或唯一索引时使用。
- 含义: 仅仅锁住索引记录本身(如
-
间隙锁 (Gap Lock):
- 含义: 锁住索引记录之间的“空隙”(如
id6 到 15 之间的空白区域),不包含记录本身。 - 作用: 它的目的不是锁数据,而是 “占坑” ,防止别的事务往这个缝隙里
INSERT新数据。这是解决幻读的关键。
- 含义: 锁住索引记录之间的“空隙”(如
-
临键锁 (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 并发控制的一条完整逻辑链:
- Undo Log 提供了数据的历史版本,是实现多版本的物理基础。
- Read View 是事务开启时的一张快照,定义了“我能看到什么”。
- MVCC 结合前两者,实现了高效的无锁读,区分了 RC 和 RR 级别。
- Lock (特别是 Gap Lock) 则在需要强一致性的场景下,筑起了最后一道防线,解决了幻读问题。
希望这篇文章能帮你建立起立体的数据库事务认知!