别人都 commit 了,为什么我的 select 还是旧值?图解 MySQL MVCC

先还原一个我刚工作时真踩过的坑。

账户表 t_account 里有一行 id=1, balance=90。我在事务 A 里做了一次查询,这期间同事的事务 B 把余额改成了 70 并且已经 commit。我在同一个事务 A 里又查了一次------结果还是 90。

当时我第一反应是:是不是他没提交成功?还是我事务隔离级别配错了?更诡异的是,我把语句换成 select ... for update 再查,70 又出来了。

同一条记录、同一个时刻,两种查法给出两个答案。这背后就是 InnoDB 并发控制的核心------MVCC(多版本并发控制)。

一句话先给结论:InnoDB 在后台给每行数据偷偷存了一串历史版本,你的普通 select 读到的不是"最新数据",而是"对你这个事务可见的那个版本"。 这篇就把这套机制拆开讲。

读和写,本来是要打架的

先想一个问题:数据库最怕并发读写冲突。事务 A 正在读一行,事务 B 同时要改它,怎么办?

最笨的办法是加锁------A 读的时候锁住这行,B 想改?等着。写阻塞读、读阻塞写,并发一上来整个库都在排队,吞吐直接垮掉。

InnoDB 换了个思路:既然读和写抢的是"同一份数据",那我就多存几份。 写操作去生成新版本,读操作安心读它该读的旧版本,各读各的,谁也不阻塞谁。这就是 MVCC 的核心思想------用空间和历史版本,换无锁的并发读。

它靠三样东西落地:行里的三个隐藏字段、undo log 串起来的版本链、以及决定"你能看到哪一版"的 ReadView。

每行都藏着三个你没建过的字段

你 create table 时只写了业务列,但 InnoDB 会偷偷给每一行再加上几个隐藏列,和 MVCC 相关的是这三个:

  • DB_TRX_ID(6 字节):最后一次插入或修改这行的事务 ID。删除在 InnoDB 内部也算一次修改,只是打了个删除标记。
  • DB_ROLL_PTR(7 字节) :回滚指针,指向 undo log 里这行的上一个版本。
  • DB_ROW_ID(6 字节):如果表没有显式主键,InnoDB 用它自动生成一个行 ID 当聚簇索引。有主键就没它什么事。

真正撑起 MVCC 的是前两个。

undo log 版本链:一行数据的"前世今生"

每当一行被 update 或 delete,InnoDB 都会在 undo log 里记下修改前的旧值,并让当前行的 DB_ROLL_PTR 指向这个旧版本。旧版本自己也带着指针,再指向更老的版本......这么一路串下去,就形成了一条版本链。

拿这行余额数据来说,它被一连串事务改过,每改一次 InnoDB 就在 undo log 留一个旧版本、再用回滚指针串起来,最后形成这样一条链(具体是哪些事务、按什么顺序改的,下一节对着时间线细讲):

最新的数据在数据页里,历史版本在 undo log 里,每个版本都盖着一个"是谁改的"的戳------也就是它的 trx_id。这条链,就是 MVCC 判断可见性时要一路翻回去的"档案"。

顺带一提,undo log 其实分两种:insert 产生的 undo log 只用于事务回滚,事务一提交就能立刻回收;而 update/delete 产生的 undo log 不能随便删,因为版本链还要靠它,得等所有事务都不需要这些历史版本了,才由后台 purge 线程清理。这个区别后面还会提到。

ReadView:你手里的一张"能见度名单"

光有版本链还不够,得有个东西告诉事务"链上哪些版本你能看、哪些不能看"。这就是 ReadView(一致性视图)。

它在事务执行快照读(也就是普通 select)的那一刻生成,里面有四个关键字段:

  • creator_trx_id:创建这个 ReadView 的事务自己的 ID
  • m_ids:生成视图那一刻,系统里所有**还活跃(已开启但没提交)**的事务 ID 列表
  • min_trx_id:m_ids 里最小的那个 ID
  • max_trx_id:生成视图时,系统下一个将要分配的事务 ID(当前最大 ID + 1)

拿到一个版本的 trx_id,InnoDB 按下面这套规则判断它对你可不可见:

文字版整理一下,一共五种情况:

  1. trx_id == creator_trx_id:这是我自己改的,当然可见;
  2. trx_id < min_trx_id:改这行的事务在我建视图之前就提交了,可见;
  3. trx_id >= max_trx_id:这是我建视图之后才冒出来的事务,不可见;
  4. min_trx_id <= trx_id < max_trx_id:落在区间里,再看它在不在 m_ids 名单上------在 ,说明建视图时它还没提交,不可见;不在,说明那会儿已经提交了,可见;
  5. 当前版本不可见?那就顺着 DB_ROLL_PTR 翻到上一个历史版本,重复上面的判断,直到找到第一个可见版本为止。

拿一个完整例子走一遍

规则干巴巴的,我们把开头那个场景的时间线补全,你就彻底明白了。

  • 一开始,id=1 这行 balance=100,由早已提交的事务写入;
  • 事务 100 把它改成 80,提交;事务 200 又改成 90,提交。此时版本链是:90(trx200) → 80(trx100) → 100(更早),数据页里的当前值是 90;
  • 事务 300 开启,把余额改成 70,改完但还没提交;
  • 这时事务 A 开启并执行第一次普通 select ,生成 ReadView。此刻 100、200 都已提交,唯一还活跃的写事务就是没提交的 300,于是 A 的视图里 m_ids = {300},min_trx_id 也是 300。

A 第一次查询,沿版本链判断:

  • 当前版本 70,trx_id=300,在 m_ids 活跃名单里 → 不可见;
  • 顺着指针翻到 90,trx_id=200,比 min_trx_id 还小(早就提交了)→ 可见,A 读到 90。

接着事务 300 commit ,余额 70 正式落库。A 在同一个事务里第二次普通 select:

  • 注意,RR 级别下 A 复用的还是第一次那张 ReadView,旧名单里 300 依然"被记着没提交";
  • 于是当前版本 70 照样不可见,翻到 90 才可见------A 读到的还是 90,哪怕 300 早就提交了。

这就是开头那个现象的答案。那为什么 for update 又能读到 70?因为它根本不走 MVCC,这个下面说。

RC 和 RR 的差别,就差在 ReadView 什么时候建

四种隔离级别里,和 MVCC 关系最大的是 RC(读已提交)和 RR(可重复读),它俩底层用的是完全相同的一套版本链和判断规则,唯一的区别是 ReadView 的生成时机:

  • RC(读已提交) :每次 select 都重新生成一张 ReadView。所以上面例子里,事务 300 一提交,A 第二次 select 用的是新视图,名单里没有 300 了,立刻就能读到 70;
  • RR(可重复读,MySQL 默认) :只在事务里第一次快照读时生成一张 ReadView,之后整条事务期间一直复用它,所以你反复查都是同一个结果。

一句话:RC 每次查都"刷新一下世界",RR 给你定格在第一次查询的那个瞬间。

快照读 vs 当前读:为什么 for update 能读到新值

这是另一个特别容易混的点。InnoDB 的读分两种:

  • 快照读 :普通 select,走 MVCC,读的是 ReadView 决定的历史版本,不加锁;
  • 当前读 :select ... for update、select ... lock in share mode、以及 insert / update / delete,读的是最新版本,并且要加锁。

所以 A 用 for update 查时,它绕过了那张定格的 ReadView,直接读最新已提交数据,70 自然就出来了。MVCC"读不阻塞写"的红利,只对快照读成立。

四个不那么显而易见的点

讲到这儿,面试基本能答了。但下面这几个坑,是真写过、真查过问题才会注意到的:

1. RR 下,你自己 update 之后再 select,能立刻看到自己的新值。

很多人以为可重复读就是"一条道读到黑,全是旧值"。不是。可见性规则第一条就是"trx_id == creator_trx_id 时可见"------新版本是你自己这个事务改的,盖的是你自己的戳,无论 ReadView 多旧都对你可见。所以"可重复读"锁得住别人,锁不住你自己。

2. ReadView 不是 begin 那一刻建的,而是第一条快照读才建。

你 begin; 之后什么都不查,视图并不会建立。普通事务里,它在你执行第一条普通 select 时才生成;如果你想在事务一开始就把快照定格住,得显式用 start transaction with consistent snapshot。这也是为什么有时候你以为自己"早就在事务里了",结果一查还是能看到别人刚提交的数据。

3. 事务 ID 也不是 begin 就分配的,纯只读事务压根不分配。

InnoDB 对确定只读的事务做了优化,不给它分配事务 ID,省掉一套开销;只有当事务第一次做增删改、或发起 for update 这类锁定读时,才去申请一个全局严格递增的 trx_id。所以别想当然地认为"谁先 begin 谁的 trx_id 就小"------一个开了半天但只做查询的事务,可能根本没有 ID。

4. RR 并没有靠 MVCC "完全"消灭幻读。

幻读指的是同一个事务里两次查同一范围,中途别人插进一条新行,第二次莫名多出一条。快照读下,新插入行的 trx_id 对你那张旧 ReadView 不可见,所以 MVCC 确实让你"看不见幻影";但当前读 要读最新数据,这时候靠的是 next-key lock(记录锁 + 间隙锁) ,把记录和记录之间的缝隙一并锁住,让别的事务压根插不进来。所以准确说法是:RR 下 InnoDB 靠 MVCC 管快照读、靠 next-key lock 管当前读,两手一起才基本消除了幻读。

代价:一个长事务,能把 undo log 撑爆

MVCC 不是免费的。历史版本必须一直保留,直到系统里最老的那张 ReadView 都不再需要它,purge 线程才敢清理。

这就带来一个经典生产事故:某个事务开着忘了提交(常见于长事务、或连接池里泄漏的空闲连接),它手里的 ReadView 一直不释放,导致它之后产生的所有 undo log 历史版本都没法 purge。版本链越堆越长,回滚表空间(ibdata 或独立 undo 表空间)持续膨胀,磁盘告警,查询翻版本链也越来越慢。

平时可以用这条 SQL 揪出存活超过 60 秒的长事务:

sql 复制代码
SELECT trx_id, trx_state, trx_started,
       TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec
FROM information_schema.innodb_trx
WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 60;

再配合观察 Innodb_history_list_length 这个指标:如果它只涨不跌、持续走高,基本就是 purge 跟不上、版本链在堆积的信号,多半背后藏着一个没提交的长事务。写代码时记得事务尽量短、别在事务里塞 RPC 和人工等待,能帮你避开这个坑。

写在最后

回头串一下 MVCC 这台机器是怎么转的:

  • 隐藏字段记录每行最后是谁改的、上一版在哪;
  • undo log 版本链保存一行的所有历史版本;
  • ReadView 拿着一张活跃事务名单,决定当前事务能看到链上的哪一版;
  • RC 每次 select 重建视图、RR 只建一次复用,这就是两种隔离级别的全部底层差异;
  • 快照读靠 MVCC 不加锁,当前读读最新并加锁,RR 再用 next-key lock 补上幻读。

理解了这套"多版本 + 可见性规则",你就明白 InnoDB 凭什么能在高并发下做到读写互不阻塞,也能解释那些"明明提交了我却查不到"的诡异现象了。

你在线上有没有被长事务撑大 undo 表空间、或者被 RR 的可见性坑过?当时是怎么发现和处理的?评论区聊聊。

相关推荐
毕业设计7033 小时前
(免费领源码)django空气净化器销售系统09218- java、PHP、python、C#、小程序、大数据、单片机、网络工程等)
python·scrapy·mysql·pycharm·django·flask·pandas
CJi0NG4 小时前
【自用】MySQL-事务
数据库·mysql
VX_bysjlw9854 小时前
数码设备销售网站设计与实现39138-计算机毕设原创(免费领源码+带部署教程)
java·vue.js·spring boot·mysql·tomcat·mybatis·idea
PHP实战开发录5 小时前
MySQL数字排序为什么乱
数据库·mysql·php
imDwAaY9 小时前
一条 SQL 发出去之后,MySQL 到底做了什么?
sql·mysql
March.s10 小时前
MySQL 数据库原理与实战:从数据管理到 LAMP 建站全解
数据库·mysql
悟道子HD11 小时前
网络安全基本功——MySQL基础
android·mysql·web安全
晚风叙码11 小时前
MySQL 增删改查(CRUD)完全指南:从入门到面试
数据库·mysql·面试
程序员Sunday1 天前
MySQL 为什么使用 B+ 树索引?把范围查询、回表和覆盖索引连起来
数据库·mysql