一口气搞懂 MySQL MVCC:从隐藏字段到生产“背刺”的那些坑
我直接开干,不啰嗦背景,不讲 ACID 那些教科书话,咱就盯着一个点聊:MySQL 里的 MVCC 到底是个啥,底层咋实现,生产环境里它怎么背刺过我。
整篇文章会有点长,我尽量用“人话”说清楚,顺手把我踩过的几个大坑拎出来,很多问题,说白了都是对 MVCC 理解不透彻导致的。
MVCC 在 MySQL 里到底干啥用的
先说结论:
在 InnoDB 里,MVCC 负责普通SELECT(快照读)的“读什么版本的数据”这个问题,目标只有一个:
在读多写多的场景里,让“读”和“写”别老互相上锁卡着。
感受一下下面两个场景:
- 有个长事务在跑各种
SELECT,另一个事务在疯狂UPDATE、 你希望:
- 写的事务可以顺利提交
- 读的事务能看到一个自洽的历史画面,别一会儿多一条记录、一会儿少一条,自己都说不清刚才看到啥
MVCC 就是干这个的。它不负责锁,锁是行锁、间隙锁那一挂的事;
它只负责一个问题:给你一个“看起来稳定”的世界观。
这个“世界观”怎么构出来的?
靠三样东西:
- 行记录上的隐藏字段
- undo 日志(回滚日志)
- Read View(读视图)
下面一个个拆。
InnoDB 行记录里那些你看不到的字段
你在建表的时候写的字段是这样的:
CREATE TABLE user (
id BIGINT PRIMARY KEY,
name VARCHAR(50),
balance INT
) ENGINE=InnoDB;但 InnoDB 真正存的时候,每一行后面还悄悄藏了几个字段(简化说法):
DB_TRX_ID:最近一次修改这行的事务 IDDB_ROLL_PTR:指向 undo 日志的指针(上一版本在哪里)DB_ROW_ID:如果你没定义主键,InnoDB 自己搞一个自增的隐藏 row id
你可以把一行记录想象成这样一坨:
(id, name, balance, DB_TRX_ID, DB_ROLL_PTR, DB_ROW_ID)重点是前两个:
DB_TRX_ID告诉你:这个版本是谁改的DB_ROLL_PTR告诉你:上一版本在哪
有这俩字段,才有可能把“多个版本”串成一条链。
undo 日志:版本链是怎么长出来的
很多人一听 undo,脑子里第一反应是“回滚用的”。
其实在 InnoDB 里,MVCC 用的历史版本,全部躺在 undo 里。
undo 大致分两种:
- insert undo:插入新行,用来回滚;事务提交后基本就没用了,很快就清理掉
- update undo:更新/删除用的,既服务回滚,也服务 MVCC 的历史版本
你可以这么理解一个 UPDATE 的过程:
UPDATE user SET balance = balance + 100 WHERE id = 1;InnoDB 干的事大致是:
- 把当前行拷贝一份写到 undo 里,记上当前旧值、旧
DB_TRX_ID等 - 把主记录上的
balance改成新值 - 把主记录上的
DB_TRX_ID改成当前事务 ID - 把主记录上的
DB_ROLL_PTR指向刚刚那条 undo 记录
这样一来,这条记录在物理上变成一条版本链:
当前记录(最新版本)
|
v
undo 版本(上一个)
|
v
更老的 undo 版本
|
v
...普通 SELECT(快照读)其实读的不是“最新记录”,而是顺着这条链往后翻,翻到一个“按规则可见”的版本。
这也是为啥:
- undo 清不掉,表数据明明不大,undo 表空间狂涨;
- 一个表被频繁更新,又有长事务,版本链会越来越长,某个 select 会莫名其妙变慢。
这两件事我后面单独说坑。
Read View:这个事务眼里世界长啥样
版本链有了,还少个东西:啥叫“对我可见”?
InnoDB 的做法是:每次做快照读的时候,生成一个 Read View(读视图)。
你可以粗暴理解成这个事务眼里的“活着的事务列表 + 水位线”。
Read View 里主要关心这么几项(简化说法):
m_ids:生成视图时,系统里所有活跃(未提交)的事务 ID 列表min_trx_id:上面这个列表里最小的事务 ID(低水位)max_trx_id:当时系统分配给下一个事务的 ID(高水位)creator_trx_id:当前这个事务自己的 ID
然后,InnoDB 就用这几个东西去判断一条记录的某个版本能不能被你看见。
判断规则粗暴版(记住这个就够用了):
假设某个版本的 DB_TRX_ID = X,当前事务的 Read View 里有 min_trx_id, max_trx_id, m_ids, creator_trx_id:
- 如果
X == creator_trx_id:
这是你自己改出来的,必须能看到 - 如果
X < min_trx_id:
太老了,在你视图生成之前就已经提交了,可以看 - 如果
X >= max_trx_id:
太新了,在你视图之后才开始的事务写的,看不到 如果
min_trx_id <= X < max_trx_id:- 如果
X在m_ids里:
说明这个事务当时还是活跃的,没提交,看不到 - 不在
m_ids:
说明当时已经提交了,可以看
- 如果
如果最新版本看不到,就顺着 DB_ROLL_PTR 去上一版本,重复上述步骤,
直到找到一个可见的版本,或者链走完(说明对你来说这行压根不存在)。
你可以想象成:当前事务拿着一张“世界事务快照名单”,对着每个版本的事务 ID 做筛选。
RC 和 RR 两个隔离级别下,MVCC 的行为差异(这是坑源头之一)
很多线上问题,根本原因其实就一句话:
你以为它一直用的是“同一张快照”,结果它每次查询都换了一张。
关键点在于:Read View 生成的时机不一样。
1)RR(REPEATABLE READ,可重复读)下
InnoDB 默认隔离级别就是 RR。
在 RR 隔离级别下:
- 一个事务里,第一次做快照读时创建 Read View
- 后面的快照读都复用同一个 Read View
也就是说,同一个事务 REPEATABLE READ 隔离级别下的普通 SELECT,一直用的是同一份“世界观”。
这就保证了所谓的“可重复读”。
简单的时间线示意一下:
- 事务 A(RR)
- 事务 B(RR)
T1: A 开启事务
T2: A 执行 SELECT(快照读) —— 生成 Read View1,看到了某个版本
T3: B 开启事务,UPDATE 某行并 COMMIT
T4: A 再次 SELECT(快照读) —— 仍然用 Read View1,看不到 B 的修改所以:
- 事务内多次读,结果一致
- 但会跟“当前真实数据”有偏差,你会疑惑:我明明刚改完,怎么这个 select 还看不到
这不是 bug,是 MVCC 故意设计的。
2)RC(READ COMMITTED,读已提交)下
RC 的逻辑是:
- 事务里每一次快照读都会重新生成 Read View
这就意味着:
T1: A 开启事务
T2: A 执行 SELECT —— 生成 Read View1,看到了老版本
T3: B 开启事务,UPDATE 并 COMMIT
T4: A 再次 SELECT —— 生成 Read View2,这次能看到 B 的新版本于是就有了所谓的“不可重复读”。
但是很多业务觉得 RC 比 RR“更符合直觉”:
- 我更新完提交了,别人马上就能读到
- 不会出现“明明提交了,别人事务里还看不到”的情况
所以你会看到有的人把 MySQL 的隔离级别从 RR 改成 RC,一不小心又引出一堆新的坑。
一个简单例子,走一遍 MVCC 的“选版本”过程
我随手造个例子,不搞太多字段,就一条记录:
CREATE TABLE account (
id BIGINT PRIMARY KEY,
balance INT
) ENGINE=InnoDB;
INSERT INTO account(id, balance) VALUES (1, 100);假设系统已经有事务 ID 1 的初始插入完成了,现在开始:
时间线:
事务 10 开启,执行:
START TRANSACTION; -- trx_id = 10 UPDATE account SET balance = 200 WHERE id = 1; -- 还没 commit此时版本链大致如下:
- 当前记录:
balance = 200, DB_TRX_ID = 10, DB_ROLL_PTR -> v0 - undo v0:
balance = 100, DB_TRX_ID = 1, DB_ROLL_PTR = NULL
- 当前记录:
事务 20 开启,在 RR 隔离级别下,执行第一次 SELECT:
START TRANSACTION; -- trx_id = 20 SELECT * FROM account WHERE id = 1;这时候生成一个 Read View:
m_ids = [10, 20](10、20 在跑)min_trx_id = 10max_trx_id = 21creator_trx_id = 20
现在事务 20 看这条记录:
当前版本
DB_TRX_ID = 10- 在
m_ids里,而且不是自己 —— 说明事务 10 还活着,版本不可见
- 在
顺着
DB_ROLL_PTR去 undo v0:DB_TRX_ID = 11 < min_trx_id(10)—— 老事务版本,可见
所以事务 20 读到的是
balance = 100,也就是更新前的值。事务 10 这时候 commit 了:
COMMIT;事务 20 在 RR 下,同一个事务里再次 SELECT:
SELECT * FROM account WHERE id = 1;仍然用刚才那个 Read View(RR 特性),再判一遍:
当前版本
DB_TRX_ID = 10- Read View 里
m_ids记录的是视图生成时活跃事务,当时 10 还活着,所以这版仍不可见
- Read View 里
- 顺链到 undo v0:
DB_TRX_ID = 1 < min_trx_id(10)—— 可见
结果还是
balance = 100。
这就是 RR 下“可重复读”的本质:
只要你事务不结束,你看到的数据就固定在第一次快照读那一张“世界相片”上,不会更新。
如果同样的过程发生在 RC 下,因为第二次 SELECT 会重新创建 Read View,那事务 10 的修改就会被看见,这里就不重复推演了。
生产环境里我遇到的几个 MVCC 坑
上面都是理论,下面聊点实战里真遇到的坑,很多人都是在这些地方被干懵的。
坑 1:线上表查着查着变慢,后台 undo 表空间猛涨
有一次一个账务类系统,业务反馈一个简单的查询:
SELECT * FROM orders WHERE user_id = ? AND status = 1 ORDER BY create_time DESC LIMIT 20;平时 10ms 左右,某天开始慢慢爬升到 100ms+,然后越来越慢。
服务器 CPU、IO 压力看着都还行,explain 也没啥问题(走了索引)。
最后抓了半天,发现两个问题:
innodb_undo_tablespaces里的空间在持续增长- 有个连接挂了个长事务,一直没提交
具体表现是information_schema.innodb_trx里能看到一个活了几十分钟的事务
结合 MVCC 原理就很清楚了:
- 这个长事务刚开始时创建了一个 Read View
- 后面其他事务不停对订单表做 UPDATE / DELETE
- 但 这些更新产生的 undo 版本,对这个长事务来说可能仍然“有用”
- purge 线程不能清掉这些 undo,版本链越拉越长
- 当前某些查询要找到一个可见版本,得在链上一路往后翻
版本越多,快照读越慢
解决方式也很土:
- 先让那个长事务正常结束(或者干脆 kill)
- 观察一段时间 undo 空间,确认 purge 慢慢回收掉一部分
- 再根据业务改代码,避免无意义的长事务
这里有点反直觉——只是一个普通 SELECT 没有 commit,就能把整个表的 undo 空间拖死。
这就是 MVCC 带来的副作用之一。
坑 2:你以为“我刚更新,马上能查到”,结果 RR 下查不到
这个坑非常常见。
当时有个服务逻辑很简单:
- 事务里先执行一个 UPDATE
- 紧接着在同一个事务里
SELECT ...,期待能读到“我刚刚更新后的数据 + 别人最新提交的数据”
代码差不多这样:
@Transactional
public void doSomething(Long id) {
jdbcTemplate.update("UPDATE t SET status=1 WHERE id=?", id);
// 期望这里可以看到所有最新的状态
List<T> list = jdbcTemplate.query("SELECT * FROM t WHERE status=1", ...);
...
}结果在压测时发现一个诡异现象:
- 别的事务刚刚提交的一些记录,在当前事务的 select 里看不到
- 但在另一个新的连接里执行同样的 select,又能看到
当时业务直接怀疑 MySQL 有缓存……
实际上就是上面讲的:RR 下,第一次快照读的视图就冻住了。
稍微回忆下过程:
这个 @Transactional 方法一开始执行时:
- 第一个 SQL 是 UPDATE —— 这是当前读(加锁),不生成 Read View
紧接着的 SELECT 是这个事务的第一次快照读:
- 这时候会生成 Read View,并固定下来
- 后面只要是快照读(普通 SELECT),看世界都用这张 View
就导致:
- 刚刚在事务外提交的更新可能不被看到
- 刚刚在本事务里做的 UPDATE 自己是能看到的(当前读+自已事务的版本)
当时我们最后给出的方案是:
- 对业务做约束:
事务里别混合复杂的“统计类查询 + 修改”,要么拆分事务,要么隔离级别切到 RC 或者:
- 某些读必须看到最新数据,就改用
SELECT ... LOCK IN SHARE MODE或FOR UPDATE这种当前读(加锁),
但这又会引入锁竞争。
- 某些读必须看到最新数据,就改用
坑 3:“幻读”你以为 MVCC 能解决,其实要靠间隙锁配合
这个坑很细,很多人被“网上资料”误导。
网上很多说法是:InnoDB 通过 MVCC 解决了可重复读和幻读。
实际情况是:
- MVCC 解决的是快照读场景的“不可重复读”和“部分幻读感知”
- 真正避免当前读场景下的幻读(比如
SELECT ... FOR UPDATE),靠的是行锁 + 间隙锁组合
举个非常典型的业务写法:
START TRANSACTION;
SELECT * FROM coupon WHERE user_id = 123 AND status = 'unused';
-- 根据查询结果决定是否 INSERT 一条新记录
INSERT INTO coupon (user_id, status, ...) VALUES (...);
COMMIT;你要保证的是:同一个用户在某个时间段只能有一条 unused 的记录。
很多人天真地以为:在 RR + MVCC 下,这个 SELECT 是可重复读的,就不会有并发问题。
结果压测一跑:
- 两个事务几乎同时进来
- 都看不到别人的 INSERT(各自的快照里对方没提交)
- 于是都认为“没有 unused”,都 INSERT 成功
- 最终一人拿两张券
这就是幻读的典型表现。
在 InnoDB 里,想解决这种问题,要明确用当前读 + 合理的索引 + 间隙锁。类似:
START TRANSACTION;
-- 用 FOR UPDATE 显式加锁,让 InnoDB 对满足条件的记录区间加行锁/间隙锁
SELECT * FROM coupon
WHERE user_id = 123 AND status = 'unused'
FOR UPDATE;
-- 根据结果决定是否 insert
...
COMMIT;这里就不是 MVCC 能解决的问题了,MVCC 只管快照读的版本可见性,不管写写冲突、不管间隙加锁。
坑 4:RC 环境下的“统计结果忽上忽下”
这个是某次报表服务改隔离级别的时候遇到的。
为了让“改完数据马上能在读请求里看到”,我们把某个服务的隔离级别改成了 RC。
改完没几天,运营那边问:为啥同一秒钟刷的报表统计数,会出现前后查询不一致?
比如:
- 一次查出来订单数 1001
- 紧接着再查一次变 998
- 过 1 秒又变 1005…
看上去好像“数据自己在跳”,心理压力很大。
其实 MVCC 视角看就很正常:
- RC 下,每次快照读都是重新生成 Read View
- 这两次查询之间,可能有其他事务提交了 insert / delete
- 所以每次看到的都是“那一刻已提交”的数据状态
这就会带来一种“滑动的世界线”的感觉
在统计类业务里,容易让人觉得“不可信”。
我们最后的做法:
- 用于“强一致统计”的逻辑,改回 RR 隔离级别或者给查询加事务边界,用一个固定 Read View 完成整个批量统计
- 用于纯在线展示的、对一致性要求不敏感的查询,才跑在 RC 上
MVCC 本身的几个限制,别指望太多
MVCC 不是万能药,也有它的天生短板,这些你不提前心里有数,容易写出“以为没问题其实漏洞百出”的逻辑。
几个我常给同事强调的点:
MVCC 只解决读写并发,不解决写写冲突
两个事务同时更新同一行:
T1: UPDATE account SET balance = balance + 100 WHERE id = 1; T2: UPDATE account SET balance = balance + 200 WHERE id = 1;这时候靠的是行锁,不是 MVCC。
MVCC 维护的多版本只是让快照读还能继续,但写写冲突还是要排队。快照读不加锁,但不是“读到的一定就是最新的”
- RR 下可能落后真实数据好几轮提交
- RC 下也只保证看到的是“某一刻之前已提交的”,期间别人还在提交
你要的是“我一定看到当前最新”,就别指望快照读,要用当前读(锁)。
长事务 + 高频更新 = undo 撑爆 + 查询变慢
这个前面已经举过例子。
只要有事务没结束,它视图时间点之后产生的所有版本,都有可能被它需要,purge 不敢乱删。MVCC 对“范围级别的一致性”依赖 Read View + 间隙锁
快照读层面确实能保证某个事务内两次同样的 SELECT 返回同样的版本集合(RR)。
但你要的是“边界不被别人插入新数据破坏”,那就得靠间隙锁,不是 MVCC。
回到原点:一句话总结 MySQL 里的 MVCC 实现
把前面的碎碎念压缩成一个稍微长一点的句子:
在 InnoDB 里,MVCC 是通过在每行记录上加隐藏字段记录最近修改事务 ID 和回滚指针,所有历史版本存在 undo 日志中;普通 SELECT(快照读)时,InnoDB 生成一个 Read View,里面记录当时系统里活跃事务的 ID 和高低水位,然后顺着版本链往回翻,对每个版本的事务 ID 做可见性判断,找到对当前视图可见的那个版本返回;RR 和 RC 的差异就在于 Read View 是“事务级”还是“语句级”。
你理解了这句话里的每个点,基本就能把 MVCC 玩明白。
收个尾:怎么把 MVCC 这玩意用舒服?
给个比较接地气的建议清单,都是踩过坑换来的:
- 事务边界别乱画
减少那种“在大事务里混合一堆读写、还长时间不提交”的写法 明确区分两类读:
- 对一致性特别敏感(资金、状态机、幂等控制)的,用当前读、必要时手动加锁
- 对实时性要求高但对一点点抖动无所谓的,用快照读
清楚自己的隔离级别:
- RR 下:可重复读 + 可能看不到别人刚提交的更新
- RC 下:每次查询都看一眼最新已提交世界,别指望“事务内读值不变”
- 遇到那种“记录莫名重复插入/扣减次数不准”的场景,不要先怀疑 MySQL,
优先怀疑自己是不是误用了快照读去做强一致判断
就先聊到这,MySQL 里 MVCC 其实没那么玄乎,搞清楚“隐藏字段 + undo 版本链 + Read View”这三件事,再反过来回头看你线上那些诡异现象,八成都能对得上。
如果你们线上也遇到过什么因 MVCC + 隔离级别引发的奇奇怪怪问题,也可以在评论区丢给我,后面有机会挑典型场景再写一篇专门拆坑的。
想持续看这类偏“实战拆解”的内容,可以关注我的公众号 @耕云躬行录,
也欢迎来博客翻一翻我平时的运维和排障记录:
- 公众号:耕云躬行录
- 个人博客:躬行笔记