做后端开发和数据库运维的同学,面试也好、日常排查也好,大概率都跟MVCC这三个字母打过照面。MySQL的MVCC(Multi-Version Concurrency Control,多版本并发控制)是InnoDB存储引擎用来处理读写并发互不阻塞的核心机制,也是理解事务隔离级别、锁行为、undo log清理策略的总入口。这篇博文我会从一个实践者的角度,把MVCC的核心原理、ReadView可见性判断、RR与RC的差异,以及本地可复现的验证实验一次讲透,适合对InnoDB有一定基础但总觉得“好像懂了又说不太清”的人,也适合准备数据库面试的朋友当复习提纲。
我会按照“问题从哪来 → 底层机制长什么样 → 隔离级别怎么配合 → 实际动手验证 → 常见误区和排查技巧”这条线来讲,尽量不绕弯子,尽量把关键结论都落到可验证的SQL上。
1. 并发控制的前置问题:为什么数据库非要搞出MVCC
1.1 没有MVCC时,事务并发会踩哪些坑
两个事务同时操作同一行数据时,最自然的做法是用锁把这一行锁住,一个事务读,另一个事务就必须等着。但仔细想想,这种做法对“读多写少”的业务很不友好。一个普通电商详情页,可能一秒有上千次查询,期间只有几次价格调整,如果每次查询都被写操作堵住,页面延迟马上就会被打上去。
数据库事务要解决的并发问题主要有三个:脏读(读到别人未提交的数据)、不可重复读(同一事务内两次读同一行,结果不一样)、幻读(同一事务内两次范围查询,行数不一样)。在读已提交(RC)和可重复读(RR)这两个隔离级别下,脏读和不可重复读也是不能出现的。如果全靠加锁去保证,要么是读的时候也加共享锁,导致读写互相阻塞;要么是让写操作排队等读完成,并发度会低得没法看。
MVCC的思路不是“堵”,而是“绕”。它把每一行数据的多个历史版本都保存下来,读操作直接读取符合自己事务视角的旧版本,写操作往版本链上追加新版本。读写各走各的版本,互不等待。这就好比同一个文档,别人在改最新稿,你手里是一份改了也不会影响别人的快照稿,大家各看各的。
1.2 锁和MVCC的分工并不重叠
MVCC不是用来替代锁的。InnoDB的并发控制实际上是“MVCC + 锁”的组合拳:
- 读读并发:任何时候都不冲突,MVCC管不到也不需要管。
- 读写并发:查询走快照读,读旧版本,不需要等写锁释放;写操作继续往版本链上追加新版本。这部分是MVCC的核心价值。
- 写写并发:MVCC解决不了,两个事务同时改一行,必须靠行锁互斥。后到的事务只能等先到的事务提交或回滚。
很多初学者以为MVCC能解决一切并发冲突,这是后面要重点纠正的误区。简单记一句话:MVCC解决读写阻塞,行锁解决写写互斥,间隙锁和Next-Key Lock解决幻读。各管一段,谁也替代不了谁。
1.3 用协作办公类比理解“多版本”思想
拿多人协作编辑一份在线文档举例。最常见的三种策略是:实时协同编辑很多人同时落笔,那就要做非常细粒度的冲突合并;悲观排他编辑,一个人打开编辑时其他人只能只读甚至完全进不去,安全但低效;再来就是“分支快照”模式,我把文档复制出一个分支来写,改完再合并回去,其他人不受影响,但合并时会发现自己这版可能落后了。
InnoDB的MVCC更像是第三种思路的数据库版本。事务开始后,它通过ReadView拿到一个“我当前应该看到哪个版本”的视角,读数据时沿着版本链找到符合视角的版本返回,而不是傻等别人写完。等写事务提交后,版本链上新增的版本也不会立刻销毁,而是保留一段时间,给那些“视角还停留在过去”的事务继续读。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. MVCC的三个底层支柱:隐藏列、undo log、ReadView
2.1 每行数据上那三列看不见的元数据
InnoDB的聚簇索引记录里,除了业务字段,还固定藏着几个隐藏列,平时SELECT不会返回,但它们是MVCC的地基:
- DB_TRX_ID:最近一次对该行执行写操作的事务ID。
- DB_ROLL_PTR:回滚指针,指向该行在undo log中的上一个版本,版本链就是靠它串起来的。
- DB_ROW_ID:隐藏主键。如果表没有显式主键,InnoDB会用它来生成聚簇索引;有主键时这个字段不会额外占用可见空间。
举个例子。初始时事务T1插入了一行数据,balance=100,这行上的DB_TRX_ID记着T1的ID。接着事务T2执行了UPDATE,把balance改成200,InnoDB不会直接覆盖物理行,而是先把当前行的旧版本(100)写到undo log,再把新值(200)写到当前行,同时把DB_TRX_ID改成T2的ID,DB_ROLL_PTR指向刚刚写入的旧版本。此时版本链是:
| 版本位置 | balance | 写入事务 | 说明 |
|---|---|---|---|
| 当前行 | 200 | T2 | 最新版本,物理存储在聚簇索引 |
| undo log record | 100 | T1 | 旧版本,通过DB_ROLL_PTR关联 |
如果T3又把这行改成300,情况会继续向上叠加:当前行持有300和T3的ID,回滚指针指向200那个undo记录,而200的undo记录又通过它自己的回滚指针指向100的undo记录。一条朝旧版本方向延伸的链表就这样形成了。读取的时候如果发现当前版本不合适,就往回找,直到找到自己可见的版本。
这里有个概念要提前说清:INSERT操作产生的undo log在事务提交后通常可以立刻清理,因为新插入的行对别的事务来说从来就是不可见的,不需要保留旧版本供回滚读取;真正需要长期保留的是UPDATE和DELETE产生的undo log,因为它
