事务的隔离级别里要解决脏读、不可重复读、幻读这三个问题,最直接的办法是加锁,让读和写互斥,但加锁的代价很大,一个事务在读某行数据的时候会把写操作挡住,写的时候也会把读挡住,而实际的业务里读操作远远多于写操作,并发性能会被压得很低。
MVCC( Multi-Version Concurrency Control,多版本并发控制 ) 走的是另一条路,它不去阻止并发,而是让每个事务读到一份属于自己的数据版本。同一行数据在数据库里保留多个历史版本,事务读的时候只挑自己能看的那个,读操作不加锁,写操作也不会影响正在读的事务。
为什么需要 MVCC
加锁方案的代价
假设不用 MVCC,只靠锁来保证隔离性,那么一条普通的 SELECT 也要给读到的行加锁,防止别的事务在这期间把它改掉。问题是读操作会因此阻塞写操作,写操作也会阻塞读操作,两个本来可以同时进行的事情被迫排队。
更麻烦的是长事务,一个事务如果跑了几秒钟还没提交,它读过的那些行在这几秒内都改不了,线上很容易出现大面积等待。
MVCC 的思路
既然冲突的根源是"同一份数据被读写双方同时盯着",那就让读的人不看那一份数据。写操作产生新版本的时候不动老版本,读操作按自己的需要去读老版本,两边各读各的,自然就不冲突了。
这个思路要落地需要回答三个问题:
- 老版本存在哪里
- 怎么知道哪一行有哪些版本
- 一个事务应该读哪个版本
前两个靠隐藏字段和 undo log 解决,第三个靠 ReadView 解决。
快照读与当前读
需要注意的是 MVCC 只覆盖一部分读操作,SQL 语句要分成两类看:
| 类型 | 包含的语句 | 读的是 | 走不走 MVCC |
|---|---|---|---|
| 快照读 | 普通的 SELECT |
ReadView 判定出来的历史版本 | 走 |
| 当前读 | SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT |
最新的版本,并加锁 | 不走 |
UPDATE 和 DELETE 之所以算当前读,是因为它们要先读到最新的值才能在上面做修改,如果读的是历史版本,就会把别人刚提交的修改覆盖掉。
版本链
三个隐藏字段
InnoDB 的每一行记录除了我们自己定义的字段之外,还会额外存三个隐藏字段:
| 字段 | 长度 | 含义 |
|---|---|---|
DB_TRX_ID |
6 字节 | 最后一次插入或更新这一行的事务 ID |
DB_ROLL_PTR |
7 字节 | 回滚指针,指向 undo log 里这一行的上一个版本 |
DB_ROW_ID |
6 字节 | 行 ID,只有在表没有主键也没有唯一索引时才会生成 |
其中 DB_TRX_ID 和 DB_ROLL_PTR 是 MVCC 的基础,前者标记版本属于哪个事务,后者负责把版本串起来。

undo log 里存了什么
undo log 本来是为回滚准备的日志,事务回滚的时候要用它把数据恢复回去,但 MVCC 顺便复用了它。每次对某行做修改,旧值都会被写进 undo log,新值与旧值之间用 DB_ROLL_PTR 连起来,一条记录的所有历史版本就形成了一条链表,最新的版本在表里,越旧的越往 undo log 深处走。
undo log 分两种,行为不一样:
insert undo log:INSERT产生的,只在事务回滚时有用,事务一提交就可以删掉,因为它产生的行本来就只对当前事务可见update undo log:UPDATE和DELETE产生的,是版本链的来源,不能提交后就删,只要还有事务可能用到这些旧版本就得留着
DELETE在 InnoDB 里并不真的删行,只是在记录上打一个删除标记,真正的清理由后台的purge线程去做,判断依据就是"再没有事务需要看这个版本了"。
ReadView
ReadView 是事务在执行快照读的时候生成的一个结构,里面记录了生成的那一刻数据库里的事务状态,可见性判断完全依赖它。
四个字段
| 字段 | 含义 |
|---|---|
m_ids |
生成 ReadView 时,所有活跃(已启动但未提交)的事务 ID 列表 |
min_trx_id |
m_ids 里最小的事务 ID |
max_trx_id |
系统下一个要分配的事务 ID |
creator_trx_id |
创建这个 ReadView 的事务自己的 ID |

max_trx_id 这里容易记错,它不是 m_ids 里的最大值,而是下一个待分配的 ID ,所以它一定比 m_ids 里最大的那个还要大。因为事务 ID 是递增分配的,凡是 trx_id >= max_trx_id 的版本,都说明它是在这个 ReadView 生成之后才出现的事务写的。
可见性判断
拿到版本链上的一个版本,设它的事务 ID 为 trx_id,规则是四条:
text
1. trx_id == creator_trx_id
→ 可见(这个版本就是自己改的)
2. trx_id < min_trx_id
→ 可见(ReadView 生成之前就已经提交了)
3. trx_id >= max_trx_id
→ 不可见(ReadView 生成之后才开启的事务,当时它还不存在)
4. min_trx_id <= trx_id < max_trx_id
若 trx_id 在 m_ids 里 → 不可见(生成 ReadView 时它还没提交)
若 trx_id 不在 m_ids 里 → 可见(生成 ReadView 时它已经提交了)

对照着图里面的顺序来看会更好理解一点
第 1 条是为了让自己能看到自己的修改,否则事务里改完再读会读不到。第 2、3 条是两个边界,分别对应"早就结束了"和"还没出生"。第 4 条是中间地带,事务已经启动但状态不定,所以要去 m_ids 里查一下当时它提交没有。
如果当前版本不可见,就顺着 DB_ROLL_PTR 找上一个版本,用同样的规则再判一次,一直找到可见的为止。
走一遍完整的例子
text
事务 98 把 name 改成 '王五'
事务 100 把 name 改成 '李四'
事务 102 把 name 改成 '张三'
表里的当前记录: name='张三' DB_TRX_ID=102
↓ DB_ROLL_PTR
undo log: name='李四' DB_TRX_ID=100
↓ DB_ROLL_PTR
undo log: name='王五' DB_TRX_ID=98
用上面那条版本链。现在事务 103 执行第一次 SELECT,生成 ReadView,此刻活跃的事务是 100 和 102,系统下一个要分配的 ID 是 104:
text
m_ids = [100, 102]
min_trx_id = 100
max_trx_id = 104
creator_trx_id = 103
从最新的版本开始判断:
text
版本 trx_id=102:
落在 [100, 104) 区间内,去 m_ids 里查,102 在 → 不可见
版本 trx_id=100:
落在 [100, 104) 区间内,去 m_ids 里查,100 在 → 不可见
版本 trx_id=98:
98 < min_trx_id(100),走第 2 条 → 可见
所以事务 103 读到的是 '王五',也就是事务 98 提交的那个版本。事务 100 和 102 虽然对数据的修改已经写进了表里,但在这个事务眼里它们根本不存在。
RC 与 RR 的区别
前面说 ReadView 里记的是"生成那一刻"的事务状态,那么什么时候生成 ReadView,就直接决定了这个事务能看到什么。InnoDB 的两种隔离级别差别只在这里:
| 隔离级别 | ReadView 的生成时机 | 结果 |
|---|---|---|
Read Committed |
每次 SELECT 都重新生成一个 |
两次 SELECT 之间别人提交的数据能看到,出现不可重复读 |
Repeatable Read |
只在事务里第一次 SELECT 时生成,之后复用 |
整个事务看的是同一份快照,两次读结果一致 |
可见性判断的算法、版本链的结构,这些在两种级别下都是一样的,唯一的变量就是快照什么时候拍。RC 每次拍新的,所以别人提交的变更会被它看见,RR 只拍一次,所以整个事务被冻结在那一刻。
MySQL 的默认隔离级别是 Repeatable Read,这也是为什么日常写业务的时候很少遇到不可重复读的问题。
MVCC 处理不了的情况
MVCC 只是解决并发读写的其中一环,有两件事它管不了。
当前读不走 MVCC
UPDATE、DELETE、SELECT ... FOR UPDATE 这类语句读的是最新版本,加的是锁,和版本链没有关系。所以一个事务里如果混着快照读和当前读,看到的数据可能对不上:前面的 SELECT 读的是历史版本,后面的 UPDATE 却是在最新版本上改。
幻读
在 RR 级别下,快照读确实不会幻读,因为整个事务用的都是同一个 ReadView,别的事务插入的行不在这个快照里。但如果事务里做了当前读,比如先 SELECT 查一遍有没有某条记录,再用 SELECT ... FOR UPDATE 去确认,后一条是当前读,能读到别的事务新插入并提交的行,幻读照样会出现。
InnoDB 在这里靠的是 Next-Key Lock,也就是记录锁加上间隙锁,把查询范围内的区间一起锁住,让别的事务没法在这个区间里插入新行。这部分是纯锁机制,和 MVCC 无关。