穿越时空的魔法:彻底搞懂 MySQL MVCC 多版本并发控制

29 阅读7分钟

一句话精华:MVCC 就是数据库世界的"平行宇宙",让读写操作互不干扰,带你体验数据的时间旅行。


📖 引言:图书馆里的"撕书大战"

想象一下,你正在图书馆津津有味地读一本侦探小说,读到第 50 页时,突然冲过来一个人,把你手里这本书的第 51 页撕掉,换上了一页新的(剧情完全变了),甚至把整本书拿走烧了。

你会怎么想?"这破图书馆,我不待了!"

在数据库的世界里,如果没有 MVCC(Multi-Version Concurrency Control,多版本并发控制),情况就是这样。

  • 你(读事务) 正在查询一张表的数据。
  • 别人(写事务) 正在修改这张表的数据。

如果没有 MVCC,为了保证你读到的数据不出错,数据库只能加锁——你看书的时候,别人不能改;别人改书的时候,你不能看。这就像图书馆每次只能进一个人,效率低到令人发指。

但是,MySQL(特别是 InnoDB 引擎)想了个绝招:每个人看到的书都是"影分身"。无论别人怎么改,你依旧能看到你开始阅读那一刻的内容。

这就是 MVCC,一种让数据库性能起飞的"黑科技"。今天,我们就来扒开它的底裤,看看它是如何实现"读写不冲突"的。


🌟 基础篇:什么是 MVCC?

1. 核心概念

MVCC 听起来很高大上,其实说白了就是 "保留数据的历史版本"

当你要修改一行数据时,数据库不是直接覆盖掉原数据,而是像 Git 版本控制一样,保留了老版本,生成了新版本。

  • 读的人:看老版本(快照)。
  • 写的人:操作新版本。

2. 它是为了解决什么问题?

在 MVCC 出现之前,数据库并发控制主要靠 锁(Lock)

  • 读锁:我看的时候,你不能改。
  • 写锁:我改的时候,你不能看。

这导致了 "读写互斥",并发性能极差。

MVCC 的出现,完美解决了这个问题:读不阻塞写,写不阻塞读。 它主要用于 RC(Read Committed,读已提交)RR(Repeatable Read,可重复读) 这两个隔离级别。

3. 简单示例

假设有一个账户表 account,余额是 100。

-- 事务 A (你)
START TRANSACTION;
SELECT balance FROM account WHERE id = 1; -- 看到 100

此时,事务 B 进来了:

-- 事务 B (别人)
START TRANSACTION;
UPDATE account SET balance = 200 WHERE id = 1; -- 改成 200
COMMIT;

如果没有 MVCC,事务 A 再读可能就变成了 200(不可重复读),或者被事务 B 阻塞。 但在 MVCC 的加持下,事务 A 再读,依然是 100!仿佛时间静止了。


🔍 深入篇:MVCC 的工作原理

InnoDB 实现 MVCC 的秘密武器主要有三个:

  1. 隐藏字段(Hidden Columns)
  2. Undo Log(回滚日志)
  3. Read View(读视图)

1. 隐藏字段:数据的"身份证"

在 InnoDB 表中,每一行数据除了你定义的列(如 name, age)之外,还有几个你看不到的隐藏列:

  • DB_TRX_ID(6字节):最近修改事务 ID。 谁最后一次改了这行数据?这个字段记录了那个事务的 ID。
  • DB_ROLL_PTR(7字节):回滚指针。 指向这条记录的上一个版本(存储在 Undo Log 里)。通过它,能顺藤摸瓜找到祖宗十八代。
  • DB_ROW_ID(6字节):隐藏主键(如果你没定义主键,MySQL 会自动生成这个)。

2. Undo Log:数据的"时光机"

当你执行 UPDATEDELETE 时,旧数据并不会立刻消失,而是被放进了 Undo Log 中。 DB_ROLL_PTR 指针就把这些新老版本串成了一个 链表,我们称之为 版本链

3. Read View:你的"有色眼镜"

这是 MVCC 最核心的部分。Read View 决定了你能看到版本链里的哪一个版本

当一个事务进行查询时,会生成一个 Read View,它包含以下关键信息:

  • m_ids:生成 Read View 时,当前系统中 活跃(未提交) 的事务 ID 列表。
  • min_trx_id:m_ids 里最小的 ID。
  • max_trx_id:生成 Read View 时,系统分配给下一个事务的 ID。
  • creator_trx_id:生成该 Read View 的事务本身的 ID。

可见性算法(Can I see it?) 当你去读一行数据时,拿着这行数据的 DB_TRX_ID(最后修改它的事务 ID),去跟你的 Read View 对比:

  1. 如果是你自己改的 (DB_TRX_ID == creator_trx_id) 👉 可见
  2. 如果是已提交的老事务改的 (DB_TRX_ID < min_trx_id) 👉 可见
  3. 如果是未来的事务改的 (DB_TRX_ID >= max_trx_id) 👉 不可见(那是还没出生的事务干的)。
  4. 如果事务 ID 落在 min 和 max 之间
    • 如果在 m_ids 列表里(说明是活跃事务,还没提交) 👉 不可见
    • 如果不在 m_ids 列表里(说明已经提交了) 👉 可见

如果不可见怎么办?顺着 DB_ROLL_PTR 回滚指针,去 Undo Log 找上一个版本,继续套用这个规则,直到找到可见的版本为止。

💡 原理图解:版本链与可见性

graph TD
    subgraph Row_Data [当前行数据]
        New[Current Row: value=200<br>TRX_ID=102<br>ROLL_PTR=Wait for it...]
    end

    subgraph Undo_Log [Undo Log 版本链]
        V1[Old Version 1: value=100<br>TRX_ID=99<br>ROLL_PTR=...]
        V2[Old Version 2: value=50<br>TRX_ID=88<br>ROLL_PTR=NULL]
    end

    Row_Data -->|DB_ROLL_PTR| V1
    V1 -->|DB_ROLL_PTR| V2

    subgraph Read_View [事务A 的 Read View]
        RV[m_ids=[102, 103]<br>min_trx_id=102<br>max_trx_id=104]
    end

    style New fill:#ff9999,stroke:#333,stroke-width:2px
    style V1 fill:#99ff99,stroke:#333,stroke-width:2px
    style RV fill:#ffffcc,stroke:#333,stroke-dasharray: 5 5

    Note1[事务A访问当前行] --> Check1{TRX_ID=102 <br>在 m_ids 中吗?}
    Check1 -- 是(活跃事务) --> Result1[❌ 不可见]
    Result1 --> Next[找上一个版本 V1]
    Next --> Check2{TRX_ID=99 <br> < min_trx_id(102)?}
    Check2 -- 是(已提交) --> Result2[✅ 可见: value=100]

💡 图解说明

  1. 当前数据的事务 ID 是 102。
  2. 事务 A 的 Read View 发现 102 在活跃列表 m_ids 中(还没提交)。
  3. 所以事务 A 认为 102 改的数据是"脏"的,不能看。
  4. 事务 A 顺着指针找到 V1(ID=99)。
  5. 99 小于最小活跃 ID 102,说明 99 早就提交了。
  6. 所以事务 A 最终读到了 100

⚡ 实战篇:RC 与 RR 的区别 & 避坑指南

1. RC 与 RR 的本质区别

很多面试官爱问:"Read Committed 和 Repeatable Read 在 MVCC 上有啥区别?" 答案很简单:生成 Read View 的时机不同

隔离级别Read View 生成时机效果
Read Committed (RC)每次 SELECT 都会生成新的 Read View能读到别人刚提交的数据(不可重复读)
Repeatable Read (RR)第一次 SELECT 生成 Read View,之后复用就像拍了张照片,永远看到的是那一刻的样子(可重复读)

2. 真实场景:长事务的危害

在 MVCC 机制下,长事务(Long Transaction)是系统的隐形杀手

场景: 有人开启了一个事务,只读了一条数据,然后去喝咖啡了,一直没 Commit。

后果

  1. 这个事务的 Read View 里的 min_trx_id 可能会很老。
  2. 为了保证这个老爷爷事务能通过 Undo Log 回溯到它年轻时的样子,MySQL 不敢清理 Undo Log 中的老旧版本。
  3. 结果:Undo Log 无限膨胀,占用大量磁盘空间,导致查询性能下降。

👉 最佳实践

  • 监控 information_schema.innodb_trx,及时杀掉长时间未提交的空闲事务。
  • SET GLOBAL innodb_undo_log_truncate = ON; (MySQL 5.7+ 支持在线收缩 Undo Log)。

3. MVCC 能解决幻读吗?

这是一个经典陷阱题。 答案是:不仅靠 MVCC,还得靠锁。

  • 快照读(普通的 Select):靠 MVCC 解决幻读。我看不到你新插入的行,因为版本号不对。
  • 当前读(Select ... for update / Update / Delete):MVCC 没辙了,必须读最新数据。这时靠 Next-Key Lock(间隙锁)来解决幻读。

💡 总结与进阶

📝 知识脉络回顾

  1. MVCC 是为了解决 读写并发冲突,让读写互不阻塞。
  2. 三个核心:隐藏字段(打标签)、Undo Log(存历史)、Read View(做判断)。
  3. 可见性规则:通过对比事务 ID,判断版本是否可见,不可见就找上一个。
  4. RC vs RR:区别在于 Read View 是"一次性"的还是"永久"的。

🧠 记忆口诀

写新读旧不加锁,Undo Log 串成河。 Read View 只有这四个,小于最小已通过。 大于最大看不见,活跃中间也不妥。

🚀 延伸学习

  • 了解一下 PostgreSQL 的 MVCC 实现(它不适用 Undo Log,而是直接把老版本留在表里,需要 VACUUM 清理)。
  • 深入研究 InnoDB 的锁机制(Record Lock, Gap Lock, Next-Key Lock)。

参考资料

  • MySQL 8.0 Reference Manual: InnoDB Multi-Versioning