前几天线上有个业务反馈,一条很普通的UPDATE语句偶发卡顿,最后报锁等待超时。排查下来不是慢SQL,也不是索引失效,而是两个事务同时更新同一行,写锁互相排队。这里就牵出一个几乎所有MySQL开发者都会遇到、但真正讲透的人不多的核心机制:MVCC多版本并发控制。
MVCC(Multi-Version Concurrency Control,多版本并发控制)是InnoDB实现隔离级别的底层基础,也是面试题里出现频率最高的知识点之一。很多人能背出“读不阻塞写、写不阻塞读”这句话,但问到ReadView是怎么生成的、为什么RR(可重复读)和RC(读已提交)表现不一样、MVCC和锁到底是什么关系的时候,就开始含糊。这篇文章不打算只讲概念,我会从一条数据行的物理结构开始,一步步拆解版本链、ReadView的判定逻辑、和next-key lock的配合方式,再结合线上排查和面试里常见的追问,把整条链路疏通。
1. 先搞清MVCC解决的问题:读写互杀不是数据库该有的样子
1.1 锁方案的两难:安全但串行化太慢
并发控制最朴素的办法就是加锁。一个事务读某行时加共享锁(S锁),写某行时加排他锁(X锁),锁之间互不兼容就排队等。这个方案能保证数据安全,但代价非常大。
| 锁类型 | S锁(共享锁) | X锁(排他锁) |
|---|---|---|
| S锁 | 兼容 | 冲突 |
| X锁 | 冲突 | 冲突 |
看上面这张表就能明白:只要有一个事务在写某条记录,所有想读这条记录的事务都会卡住。反过来,大量读事务持锁时,写事务也进不来。
在典型的互联网业务里,读多写少是常态,读请求比例经常占到90%以上。如果一个写操作会把大量读请求全部堵死,数据库的并发能力会直线下降。这是锁方案天然存在的两难:锁用少了,脏读、不可重复读、幻读都会冒出来;锁用多了,吞吐量难看,用户体验更差。
1.2 MVCC的思路:每行数据留多个版本
MVCC换了一个角度:既然“读”和“写”之间互相阻塞是主要矛盾,那能不能让读操作不拿锁,直接去读一个符合当前事务隔离级别要求的“历史版本”?
这就是“多版本”的含义。InnoDB在更新一条记录时,并不会把老数据直接抹掉,而是把更新前的旧值记录在undo log里,聚簇索引上的记录本身变成一个“新版本”,旧版本通过回滚指针串成链。某个事务读取时,通过ReadView判断:哪个版本对这个事务是可见的,就读哪个版本。写事务要修改记录时只需要跟正在写同一行的其他写事务竞争,读事务完全不用参与锁竞争。
所以MVCC并不是替代所有锁,它真正解决的是“读写并发”问题,让读不阻塞写、写不阻塞读。两个事务同时写同一条记录时,依然要靠X锁互斥。
1.3 快照读与当前读:两条完全不同的读取路径
很多初学者搞不清MVCC的使用边界,根源在于没区分快照读(consistent read)和当前读(locking read)。
- 快照读:普通的SELECT语句,在RC和RR隔离级别下不加任何锁,直接通过ReadView读取可见版本,所以执行效率很高,也不需要等待其他事务释放锁。
- 当前读:SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE,以及UPDATE、DELETE、INSERT操作,都必须读记录的最新已提交版本,然后对记录加锁。
当前读不能走MVCC的历史版本,因为写操作必须基于最新数据做修改。比如两个事务同时把账户余额从100改成200,如果各改各的历史版本,最后必然丢更新。所以当前读之间必须用锁互斥。
理解这两条路径非常重要。后面说“MVCC解决了幻读”时,一定得限定在快照读场景;当前读场景下幻读要靠间隙锁解决,否则就会得出错误结论。
1.4 为什么MVCC主要服务于RC和RR两个隔离级别
MySQL InnoDB的隔离级别有四种,但MVCC并不是在四个级别下都在工作:
- READ UNCOMMITTED(读未提交):直接读最新版本,连已提交都不用等,压根不需要ReadView。
- READ COMMITTED(读已提交):每条快照读SQL生成一个新的ReadView,是MVCC的典型应用场景。
- REPEATABLE READ(可重复读):整个事务只在第一条快照读时生成一次ReadView,后续全部复用,也是MVCC的典型应用场景,还是InnoDB默认隔离级别。
- SERIALIZABLE(串行化):所有读操作都会被强制转成当前读,通过锁来串行执行,MVCC基本退场。
所以平时聊MVCC,默认就是在RC和RR两个隔离级别下面聊。脱离隔离级别去讨论MVCC,很多结论都是不成立的。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 数据行上的三个“隐形标签”:隐藏列与undo log版本链
2.1 每行自带的档案:DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID
MVCC看起来玄乎,最终还是要落到物理存储上。InnoDB的聚簇索引记录和普通表记录不一样,除了用户定义的数据列之外,每行还隐藏着几个数据库自己用的列:
- DB_ROW_ID:6字节,行ID。如果表没有主键也没有非空唯一索引,InnoDB会用它来生成聚簇索引,一般不需要关注。
- DB_TRX_ID:6字节,记录最后一次修改(INSERT、UPDATE、DELETE)这行数据的事务ID。事务ID是MySQL内部按顺序分配的数字,值越大表示事务开始得越晚。
- DB_ROLL_PTR:7字节,回滚指针。它指向这条记录在undo log里对应的历史版本,负责把一系列版本串成一个单向链表。
这三个隐藏列平时用普通SELECT查不出来,但它们就是MVCC判断版本可见性的核心依据。如果脑海里能浮现每条记录上都挂着这三个字段,理解后面内容会顺畅很多。
2.2 undo log如何把历史版本串成链
拿更新操作举例。假设有一张user表,id=1的这行数据name='Tom'。执行一条UPDATE语句把name改成'Jack'时,InnoDB内部做了
