一口气搞懂 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 ) ENGINEInnoDB;但 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 生成的时机不一样。1RRREPEATABLE READ可重复读下InnoDB 默认隔离级别就是 RR。在 RR 隔离级别下一个事务里第一次做快照读时创建 Read View后面的快照读都复用同一个 Read View也就是说同一个事务 REPEATABLE READ 隔离级别下的普通SELECT一直用的是同一份“世界观”。 这就保证了所谓的“可重复读”。简单的时间线示意一下事务 ARR事务 BRRT1: A 开启事务 T2: A 执行 SELECT快照读 —— 生成 Read View1看到了某个版本 T3: B 开启事务UPDATE 某行并 COMMIT T4: A 再次 SELECT快照读 —— 仍然用 Read View1看不到 B 的修改所以事务内多次读结果一致但会跟“当前真实数据”有偏差你会疑惑我明明刚改完怎么这个 select 还看不到这不是 bug是 MVCC 故意设计的。2RCREAD 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 ) ENGINEInnoDB; INSERT INTO account(id, balance) VALUES (1, 100);假设系统已经有事务 ID 1 的初始插入完成了现在开始时间线事务 10 开启执行START TRANSACTION; -- trx_id 10UPDATE account SET balance 200 WHERE id 1;-- 还没 commit此时版本链大致如下当前记录balance 200, DB_TRX_ID 10, DB_ROLL_PTR - v0undo v0balance 100, DB_TRX_ID 1, DB_ROLL_PTR NULL事务 20 开启在 RR 隔离级别下执行第一次 SELECTSTART TRANSACTION; -- trx_id 20SELECT * FROM account WHERE id 1;这时候生成一个 Read Viewm_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 v0DB_TRX_ID 11 min_trx_id(10)—— 老事务版本可见所以事务 20 读到的是balance 100也就是更新前的值。事务 10 这时候 commit 了COMMIT;事务 20 在 RR 下同一个事务里再次 SELECTSELECT * FROM account WHERE id 1;仍然用刚才那个 Read ViewRR 特性再判一遍当前版本DB_TRX_ID 10Read View 里m_ids记录的是视图生成时活跃事务当时 10 还活着所以这版仍不可见顺链到 undo v0DB_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 status1 WHERE id?, id); // 期望这里可以看到所有最新的状态 ListT list jdbcTemplate.query(SELECT * FROM t WHERE status1, ...); ... }结果在压测时发现一个诡异现象别的事务刚刚提交的一些记录在当前事务的 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 只管快照读的版本可见性不管写写冲突、不管间隙加锁。坑 4RC 环境下的“统计结果忽上忽下”这个是某次报表服务改隔离级别的时候遇到的。 为了让“改完数据马上能在读请求里看到”我们把某个服务的隔离级别改成了 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 隔离级别引发的奇奇怪怪问题也可以在评论区丢给我后面有机会挑典型场景再写一篇专门拆坑的。