运维知识
悠悠
2026年5月5日

一口气搞懂 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:最近一次修改这行的事务 ID
  • DB_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 干的事大致是:

  1. 把当前行拷贝一份写到 undo 里,记上当前旧值、旧 DB_TRX_ID
  2. 把主记录上的 balance 改成新值
  3. 把主记录上的 DB_TRX_ID 改成当前事务 ID
  4. 把主记录上的 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

  1. 如果 X == creator_trx_id
    这是你自己改出来的,必须能看到
  2. 如果 X < min_trx_id
    太老了,在你视图生成之前就已经提交了,可以看
  3. 如果 X >= max_trx_id
    太新了,在你视图之后才开始的事务写的,看不到
  4. 如果 min_trx_id <= X < max_trx_id

    • 如果 Xm_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 的初始插入完成了,现在开始:

时间线:

  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
  2. 事务 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 = 10
    • max_trx_id = 21
    • creator_trx_id = 20

    现在事务 20 看这条记录:

    • 当前版本 DB_TRX_ID = 10

      • m_ids 里,而且不是自己 —— 说明事务 10 还活着,版本不可见
    • 顺着 DB_ROLL_PTR 去 undo v0:DB_TRX_ID = 1

      • 1 < min_trx_id(10) —— 老事务版本,可见

    所以事务 20 读到的是 balance = 100,也就是更新前的值

  3. 事务 10 这时候 commit 了:

    COMMIT;
  4. 事务 20 在 RR 下,同一个事务里再次 SELECT

    SELECT * FROM account WHERE id = 1;

    仍然用刚才那个 Read View(RR 特性),再判一遍:

    • 当前版本 DB_TRX_ID = 10

      • Read View 里 m_ids 记录的是视图生成时活跃事务,当时 10 还活着,所以这版仍不可见
    • 顺链到 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 也没啥问题(走了索引)。

最后抓了半天,发现两个问题:

  1. innodb_undo_tablespaces 里的空间在持续增长
  2. 有个连接挂了个长事务,一直没提交
    具体表现是 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 MODEFOR 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 不是万能药,也有它的天生短板,这些你不提前心里有数,容易写出“以为没问题其实漏洞百出”的逻辑。

几个我常给同事强调的点:

  1. MVCC 只解决读写并发,不解决写写冲突

    两个事务同时更新同一行:

    T1: UPDATE account SET balance = balance + 100 WHERE id = 1;
    T2: UPDATE account SET balance = balance + 200 WHERE id = 1;

    这时候靠的是行锁,不是 MVCC。
    MVCC 维护的多版本只是让快照读还能继续,但写写冲突还是要排队。

  2. 快照读不加锁,但不是“读到的一定就是最新的”

    • RR 下可能落后真实数据好几轮提交
    • RC 下也只保证看到的是“某一刻之前已提交的”,期间别人还在提交

    你要的是“我一定看到当前最新”,就别指望快照读,要用当前读(锁)。

  3. 长事务 + 高频更新 = undo 撑爆 + 查询变慢

    这个前面已经举过例子。
    只要有事务没结束,它视图时间点之后产生的所有版本,都有可能被它需要,purge 不敢乱删。

  4. 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 + 隔离级别引发的奇奇怪怪问题,也可以在评论区丢给我,后面有机会挑典型场景再写一篇专门拆坑的。

想持续看这类偏“实战拆解”的内容,可以关注我的公众号 @耕云躬行录
也欢迎来博客翻一翻我平时的运维和排障记录:

文章目录

博主介绍

热爱技术的云计算运维工程师,Python全栈工程师,分享开发经验与生活感悟。
欢迎关注我的微信公众号@运维躬行录,领取海量学习资料

微信二维码