一句话精华: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 的秘密武器主要有三个:
- 隐藏字段(Hidden Columns)
- Undo Log(回滚日志)
- 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:数据的"时光机"
当你执行 UPDATE 或 DELETE 时,旧数据并不会立刻消失,而是被放进了 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 对比:
- 如果是你自己改的 (
DB_TRX_ID == creator_trx_id) 👉 可见。 - 如果是已提交的老事务改的 (
DB_TRX_ID < min_trx_id) 👉 可见。 - 如果是未来的事务改的 (
DB_TRX_ID >= max_trx_id) 👉 不可见(那是还没出生的事务干的)。 - 如果事务 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]
💡 图解说明:
- 当前数据的事务 ID 是 102。
- 事务 A 的 Read View 发现 102 在活跃列表
m_ids中(还没提交)。 - 所以事务 A 认为 102 改的数据是"脏"的,不能看。
- 事务 A 顺着指针找到 V1(ID=99)。
- 99 小于最小活跃 ID 102,说明 99 早就提交了。
- 所以事务 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。
后果:
- 这个事务的 Read View 里的
min_trx_id可能会很老。 - 为了保证这个老爷爷事务能通过 Undo Log 回溯到它年轻时的样子,MySQL 不敢清理 Undo Log 中的老旧版本。
- 结果: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(间隙锁)来解决幻读。
💡 总结与进阶
📝 知识脉络回顾
- MVCC 是为了解决 读写并发冲突,让读写互不阻塞。
- 三个核心:隐藏字段(打标签)、Undo Log(存历史)、Read View(做判断)。
- 可见性规则:通过对比事务 ID,判断版本是否可见,不可见就找上一个。
- 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