Skip to content

MySQL 事务隔离级别与 MVCC 实现原理

提出问题

MySQL 的事务隔离级别是面试中几乎必问的题目,但很多人的理解停留在"背下来四种隔离级别分别解决了什么问题"的层面。面试官通常不会满足于此——他们会追问:RR 到底是怎么解决不可重复读的?RC 和 RR 的 MVCC 实现有什么区别?为什么说 InnoDB 的 RR 实际上解决了幻读?

这些问题背后其实只有一个核心:MVCC(多版本并发控制)。不理解 MVCC 的具体实现,面试中一追问就露馅。生产上,事务隔离级别选择不当导致的死锁、主从延迟、数据不一致问题比比皆是,选对隔离级别是 DBA 和高级开发的基本功。

分析问题

四种隔离级别及其解决的问题

SQL 标准定义了四种隔离级别,按从低到高排列:

隔离级别脏读不可重复读幻读
READ UNCOMMITTED可能可能可能
READ COMMITTED避免可能可能
REPEATABLE READ避免避免可能
SERIALIZABLE避免避免避免
  • 脏读 (Dirty Read):读到另一个事务未提交的数据。如果该事务回滚,读到的就是"脏数据"。
  • 不可重复读 (Non-Repeatable Read):同一事务内两次读取同一行数据,结果不一致(因为其他事务提交了 UPDATE)。
  • 幻读 (Phantom Read):同一事务内两次范围查询,结果集的行数不一致(因为其他事务提交了 INSERT 或 DELETE)。

需要注意的是,InnoDB 在 RR 级别下通过 Next-Key Lock 解决了幻读,所以实际上 InnoDB 的 RR 做到了 SQL 标准中 SERIALIZABLE 才有的幻读防护能力。

MVCC 核心原理:undo log 版本链与 ReadView

MVCC 的核心思想是:不直接加锁,而是通过数据行的多个版本来实现读写不互斥。每一行数据在 InnoDB 中实际上有两个隐藏列:

  • DB_TRX_ID:最近修改该行的事务 ID
  • DB_ROLL_PTR:指向 undo log 中上一个版本的指针

当一行数据被修改时,InnoDB 不会直接覆盖旧数据,而是将旧版本写入 undo log,然后通过 DB_ROLL_PTR 形成一条版本链。查询时,根据当前事务的 ReadView 判断哪个版本可见。

sql
-- 查看当前事务隔离级别
SELECT @@transaction_isolation;

-- 在 RR 下验证不可重复读被解决
-- 会话 A:
BEGIN;
SELECT balance FROM account WHERE id = 1;  -- 100

-- 会话 B:
UPDATE account SET balance = 200 WHERE id = 1;
COMMIT;

-- 会话 A 再次查询,RR 下仍然返回 100(MVCC 保护)
SELECT balance FROM account WHERE id = 1;  -- 100
COMMIT;

RR 和 RC 的 ReadView 生成时机差异

ReadView 是一个数据结构,包含三个关键字段:

  • low_limit_id(低水位):当前尚未分配的最小事务 ID(即 max_trx_id + 1
  • up_limit_id(高水位):当前活跃事务列表中最小的 trx_id
  • trx_ids(活跃事务数组):生成 ReadView 时所有未提交事务的 ID 集合

可见性判断的逻辑:

DB_TRX_ID < up_limit_id          → 该事务已提交,可见
DB_TRX_ID >= low_limit_id        → 在 ReadView 之后才启动,不可见
up_limit_id <= DB_TRX_ID < low_limit_id:
  → 在 trx_ids 中 → 不可见(事务未提交)
  → 不在 trx_ids 中 → 可见(事务已提交)

RR 和 RC 的关键区别就在这里

  • RR:ReadView 在事务第一次执行 SELECT(快照读)时生成,整个事务复用。因此同一事务内多次查询结果一致,不可重复读被解决。
  • RC:ReadView 在每次查询时重新生成。因此每次查询都能看到最新已提交的数据,不可重复读可能发生。
python
# 示意:ReadView 可见性判断伪代码
def is_visible(trx_id, read_view):
    if trx_id == current_trx_id:
        return True  # 自己的修改当然可见
    if trx_id < read_view.up_limit_id:
        return True  # 已提交的旧事务
    if trx_id >= read_view.low_limit_id:
        return False  # 未来事务,不可见
    if trx_id in read_view.trx_ids:
        return False  # 活跃事务,不可见
    return True  # 介于之间但已提交

快照读 vs 当前读

MVCC 只对快照读(普通的 SELECT)生效。以下操作属于当前读,会读取最新已提交的数据,并加锁:

sql
SELECT ... FOR UPDATE  -- 加行锁 + 间隙锁(RR 下)
SELECT ... LOCK IN SHARE MODE  -- 加共享锁
UPDATE ...  -- 先当前读,再修改
DELETE ...  -- 先当前读,再删除
INSERT ...  -- 插入

这就是为什么即使 RR 下,一次 SELECT ... FOR UPDATE 可能看到其他事务刚提交的新行——因为当前读不走 MVCC 的版本链,直接读最新数据。

总结

关键要点

  1. MVCC 让读写不互斥:读不加锁,通过 undo log 版本链 + ReadView 判断可见性,这是 InnoDB 高并发的核心基石
  2. RR 和 RC 的 ReadView 策略不同:RR 一次生成、整个事务复用;RC 每次查询重新生成。这是两者解决不可重复读能力差异的根本原因
  3. RR 下幻读的保护:快照读靠 MVCC 防止幻读,当前读(FOR UPDATE)靠 Next-Key Lock 防止幻读
  4. 生产建议:RC 适合高并发 OLTP 场景(锁竞争少),RR 适合报表类场景(需要一致性读)。但注意 RR 下间隙锁可能引发更多死锁

面试话术示例

"MVCC 本质上是通过 undo log 版本链实现的。每行数据有 DB_TRX_IDDB_ROLL_PTR 两个隐藏列,查询时通过 ReadView 判断哪个版本可见。RR 和 RC 的核心区别在于 ReadView 的生成时机——RR 在第一次 SELECT 时生成并复用,RC 每次查询都重新生成。这就是为什么 RR 能解决不可重复读,而 RC 不能。"

参考:MySQL 官方文档 - InnoDB Multi-Versioning;《MySQL 技术内幕:InnoDB 存储引擎》第 3 章

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。