详解——MVCC 在MySQL InnoDB 的实现

第一次看 MVCC,容易卡在一堆词上:undo logRead View、隐藏字段、可见性、当前读、快照读。词都认识,连起来就容易乱。问题通常不在概念太难,而在于资料喜欢把好几层东西揉在一起讲。

本次只整理了 MySQL InnoDB。因为在大多数业务系统里,真正落地碰到的不是抽象的 MVCC,而是 InnoDB 这一套事务语义。之后,再看 Spring @Transactional、锁等待、重复读、长事务。

概括

MVCC 不是“给每行加个版本号”这么简单,但可以先这样记:
InnoDB 会保留一条记录的历史版本线索,普通 select 不是直接读当前最新值,而是按当前事务的可见性规则,找到自己应该看到的那个版本。

这里有三样东西最重要:

  • 当前记录上的隐藏字段
  • undo log 里的旧版本信息
  • 一次普通查询创建出来的 Read View

这三样东西是分工关系,不是一件东西的三个名字。

请添加图片描述
先看当前这行记录。它可以近似理解成下面这样:

id = 1
amount = 300
DB_TRX_ID = 13
DB_ROLL_PTR = undo#72

DB_TRX_ID 表示“当前这一版,最后是谁改出来的”。你先把它理解成事务内部编号就行。
DB_ROLL_PTR 表示“上一版去哪里找”。它指向的是一条 undo log 记录,不是 redo log

所以 InnoDB 的版本不是靠一个单独的 version 字段管理的,而是靠“当前记录 + 回滚指针 + undo 链”管理的。当前记录放最新值,undo log 里保存更旧的值,顺着指针就能一路往前追。

普通 select 到底在干什么

普通 selectInnoDBRCRR 下,默认走的是快照读。它不先抢当前记录的锁,也不是一上来就只读最新值。它会先拿一份 Read View,再判断当前版本对自己可不可见。

执行逻辑可以压成四步:

  1. 创建或复用当前事务要用的 Read View
  2. 先看当前记录上的 DB_TRX_ID 是否可见
  3. 可见就直接返回当前版本
  4. 不可见就顺着 DB_ROLL_PTRundo log 找更老版本,直到找到第一个可见版本

这里最容易误会的一点是:Read View 不是“快照结果集”,它不是把查询结果整包存起来。它更像一套判断规则,用来决定你这次普通读取允许看到哪些事务产生出来的版本。

请添加图片描述

普通 selectupdateselect ... for update 经常被放在一起讲,但底层目标完全不同。

普通 select 的目标是“找到当前事务应该看到的版本”,所以它走快照读,要用 Read View

updateselect ... for update 的目标是“处理当前最新记录,并进入锁控制”。它们走的是当前读,不靠 Read View 去选历史版本。如果目标记录正被别的事务改着而且还没提交,它们就会等。

所以很多业务里看到的现象其实很正常:

  • 普通 select 不容易被并发写堵住
  • update 容易遇到锁等待
  • select ... for update 会等待别的事务释放锁

这不是 MVCC 失效,而是三类 SQL 走的就不是同一条路径。

RRRC 的差别,到底差在哪

很多文章会把 RRRC 讲得很抽象。换成业务开发能直接用的话,就是下面这一句:

  • RR:同一事务里的普通 select,通常复用第一次普通读取创建的 Read View
  • RC:每次普通 select,都重新创建新的 Read View

注意,这里说的是普通 select。不是说查询用 RR,增删改用 RC。隔离级别是事务级设置,不是按 SQL 类型切换。

请添加图片描述

如果放进同一个事务里看,差别会非常直接。

RR 下,第一次普通 select 建好视图,后面同事务里的普通 select 继续用这份视图,所以前后看到的数据范围更稳定。

RC 下,每次普通 select 都新建一份视图,所以你第二次查的时候,别人刚提交的新数据就可能已经进来了。

这也是为什么很多人会说:RR 更像事务级稳定读取,RC 更像语句级稳定读取。这个说法不算完整定义,但对理解日常业务现象很有帮助。

undo log 会不会一直堆到最早版本

不会。

一行记录持续更新,确实会不断生成新的 undo 记录,也确实会形成一条越来越长的历史链。但“链会变长”和“旧版本永远删不掉”不是一回事。

旧版本能不能清理,不看这行以后还会不会继续更新,而看当前系统里还有没有活跃事务可能读到这些旧版本。

只要满足两个条件,后台清理线程就可以逐步回收旧版本:

  • 这些旧版本对应的事务已经结束,不再需要回滚
  • 没有任何活跃的 Read View 还可能访问到它们

所以真正导致 undo 堆积的,通常不是热点更新本身,而是长事务、长查询、长时间不提交的读事务。这类问题在线上比“单行频繁更新”更值得警惕。

Spring @Transactional 怎么对上

很多人会说“同一个方法里两次查询结果一样,两个方法各查一次结果可能不一样”,这个方向没错,但前提要补全。

更准确的说法是:

  • 两次查询要处在同一个真实事务里
  • 隔离级别要看是 RR 还是 RC
  • 两次都得是普通 select,也就是快照读

如果这些条件成立,那么:

  • RR 下,同一事务里的普通 select 通常复用同一份 Read View
  • RC 下,同一事务里的普通 select 每次重新建 Read View

如果中间掺了 updateselect ... for update,那又是另一条路径,因为它们处理的是当前最新记录,不是普通快照读。

总结

  • DB_TRX_ID:当前这一版记录最后由哪个事务改出来
  • DB_ROLL_PTR:上一版记录去哪找
  • undo log:旧值存放位置,用于回滚和快照读回溯
  • Read View:普通 select 的可见性规则
  • 普通 select:快照读,读自己应该看到的版本
  • update / select ... for update:当前读,处理当前最新记录并参与加锁
  • RR:普通 select 复用事务里的快照
  • RC:普通 select 每次生成新的快照
Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐