1. 先把问题定义清楚:不可重复读到底是什么
“MVCC 如何解决不可重复读”这个问题,我在各种场合被问过无数次。面试问、同事问、群聊里也问,但有意思的是,很多人对“不可重复读”这个概念本身就没吃透,导致后面所有关于 MVCC 的讨论都是空中楼阁。
1.1 一个能复现不可重复读的小实验
先别扯理论,直接看现象。假设有张订单表,里面有一条数据:
CREATE TABLE `t_order` ( `id` int NOT NULL AUTO_INCREMENT, `amount` decimal(10,2) DEFAULT NULL, `status` tinyint DEFAULT NULL, PRIMARY KEY (`id`) ) ENGINE=InnoDB; INSERT INTO t_order (id, amount, status) VALUES (1, 100.00, 0);现在开两个会话模拟并发事务。事务 A 先读一次,事务 B 中途把这条数据改了并提交,事务 A 再读一次:
-- 事务A BEGIN; SELECT * FROM t_order WHERE id = 1; -- 读到 amount = 100.00, status = 0 -- 此时事务B执行并提交 BEGIN; UPDATE t_order SET amount = 200.00 WHERE id = 1; COMMIT; -- 事务A再次查询 SELECT * FROM t_order WHERE id = 1; -- 读到 amount = 200.00, status = 0看到没有?同一个事务里,同样的查询条件,两次读到的结果不一样。这就是不可重复读——事务内多次读取同一行数据,读到的是不同的值,可能是因为别的并发事务在这期间修改了这行并提交了。
这里要特别强调一个容易被忽略的细节:不可重复读的“值不同”,针对的是同一行数据。至于幻读,那是另一个问题,针对的是结果集的行数变化。这俩经常被混为一谈,但底层的解决机制并不完全相同,后文我会专门展开。
1.2 为什么需要隔离性:从并发事务的角度理解
你把多个事务放在一起并发执行,就会产生三种经典问题:脏读、不可重复读、幻读。它们本质上是“并发事务互相干扰”的不同表现形式:
- 脏读:读到别人未提交的数据。这个最严重,因为别人可能回滚,你读到的就是凭空捏造的“假数据”。
- 不可重复读:读到别人已提交的更新数据。同一个事务内,同一条数据前后不一致。
- 幻读:读到别人已提交的新插入数据。同一个事务内,同样条件的查询,结果集行数变多了。
MySQL 的默认隔离级别是 REPEATABLE READ(RR),就是为了干掉不可重复读。但注意,RR 并不能完全干掉幻读,只是 InnoDB 用了特定手段把幻读的概率降到了一个非常低的水平。这块等聊到当前读和间隙锁的时候再展开。
那 MVCC 凭什么能让一个事务“无视”其他事务提交的修改?这就要从它的底层机制说起了。
2. MVCC 的底层三件套:隐藏字段、undo log 版本链、ReadView
MVCC 的全称是 Multi-Version Concurrency Control,多版本并发控制。翻译成人话就是:同一行数据在数据库里同时存在多个版本,不同的事务看到不同的版本,从而实现读写互不阻塞。
这套机制在 InnoDB 里依赖三个核心组件:隐藏字段、undo log 版本链、ReadView。缺一个都不行。
2.1 隐藏字段:数据行上的“身份证”
InnoDB 的聚簇索引记录里,除了你定义的业务字段,还有几个隐藏列,其中两个和 MVCC 关系最大。
第一个是DB_TRX_ID,也叫事务ID列,记录的是最近一次修改(插入或更新)这行数据的事务ID。第二个是DB_ROLL_PTR,回滚指针,指向 undo log 中该行的上一个版本。
你可以把每一行数据想象成一个“档案袋”,档案袋上盖着一个章,写着最后一个动过它的人是谁(TRX_ID),还有一个箭头指向更早的档案(ROLL_PTR)。注意,如果恰好有个字段长度正好能利用上,InnoDB 还会加一个隐藏主键DB_ROW_ID,但 MVCC 的核心逻辑主要靠前两个字段。
每次有事务修改这行数据,InnoDB 不会原地覆盖旧值,而是把旧值先复制到 undo log 里,然后在新行上更新 TRX_ID 和 ROLL_PTR。这样一来,同一行数据的历史版本就串成了一条链表。
2.2 undo log 版本链:数据的时光机
undo log 就是那条链表本身。每次 UPDATE 操作,InnoDB 会把更新前的旧记录写入 undo log,新记录通过回滚指针指向旧记录,形成一个版本链。
举例说明。假设事务 ID=100 插入了一条订单记录(id=1, amount=100.00),此时版本链只有一环:
(id=1, amount=100.00, trx_id=100, roll_ptr=NULL)接着事务 ID=200 把它更新成amount=200.00,版本链变成:
(id=1, amount=200.00, trx_id=200, roll_ptr=0x01) ^ | v (id=1, amount=100.00, trx_id=100, roll_ptr=NULL)再后来事务 ID=300 又把它更新成amount=300.00,版本链变成:
(id=1, amount=300.00, trx_id=300, roll_ptr=0x02) ^ | v (id=1, amount=200.00, trx_id=200, roll_ptr=0x01) ^ | v (id=1, amount=100.00, trx_id=100, roll_ptr=NULL)每次更新,新版本在上,旧版本往下挂。这就是“多版本”的字面来源——一行数据,多个版本并存。
你可能要问:这样一直往链上挂,undo log 会不会无限膨胀?不会。purge 线程会定期清理那些“没有任何活跃事务再需要看到”的旧版本。清理的依据也跟 ReadView 有关,这个后面一起说。
2.3 ReadView:一致性快照的判定规则
有了版本链,还得有个规则告诉 InnoDB“当前这个事务应该看到版本链上的哪个版本”。这个规则就是 ReadView,翻译过来叫“读视图”,也就是一致性快照。
ReadView 本质上是一个数据结构,在事务执行快照读的那一刻生成。它主要包含四个核心部分:
| 字段 | 含义 |
|---|---|
| m_ids | 生成 ReadView 时,当前系统中所有**活跃(未提交)**读写事务的 ID 列表 |
| min_trx_id | m_ids 中最小的那个事务 ID |
| max_trx_id | 生成 ReadView 时,InnoDB 分配给下一个事务的 ID,注意它不是活跃事务中的最大值,而是“下一个待分配”的ID |
| creator_trx_id | 生成这个 ReadView 的事务自己的 ID |
有了这四个字段,InnoDB 在遍历版本链时,就能对每个版本中的DB_TRX_ID做出可见性判定。判定逻辑是这样的:
- 如果版本里的
trx_id等于creator_trx_id,说明这行是我自己改的,自己改的自己当然能看,可见。 - 如果版本里的
trx_id小于min_trx_id,说明这个版本在 ReadView 生成之前就已经提交了,可见。 - 如果版本里的
trx_id大于等于max_trx_id,说明这个版本是在 ReadView 生成之后才启动的事务改的,不可见。 - 如果版本里的
trx_id在[min_trx_id, max_trx_id)区间内,说明这个事务在 ReadView 生成时可能是活跃的。此时查一下m_ids:如果trx_id在m_ids里,说明事务还没提交,不可见;如果不在m_ids里,说明已经提交了,可见。
一句话总结这套规则:只看“我生成快照那一刻”,哪些事务已经尘埃落定,哪些还在活跃中。已经尘埃落定的版本我认,还在活跃中的版本我当你不存在。
这套判定逻辑是整个 MVCC 最绕的地方,但也是理解“为什么能解决不可重复读”的关键。我强烈建议你把这个判定逻辑手抄一遍,对照后面的例子跑一遍流程,比死记硬背强一百倍。
3. MVCC 如何化解不可重复读:快照读的判定流程
3.1 快照读的完整流程拆解
InnoDB 里的普通SELECT语句,走的是“快照读”路径,也就是不加锁的读取。所谓快照读,简单说就是:我给你一个历史快照,你从这个快照里拿数据,而不是直接拿当前最新的数据。这个快照,就是上文的 ReadView。
快照读的完整流程是这样的:
- 第一次执行普通
SELECT时,生成一个 ReadView(快照)。 - 遍历聚簇索引中目标行的版本链。
- 从版本链的头节点(最新版本)开始,用 ReadView 的可见性规则逐版本判断。
- 如果当前版本不可见,就顺着回滚指针
ROLL_PTR往下一个旧版本找。 - 找到第一个可见的版本,返回该版本的数据;如果整个版本链都不可见(极少数极端情况),返回“记录不存在”。
这套流程最关键的地方在第 1 步——“第一次执行 SELECT 时生成 ReadView”。在 REPEATABLE READ 隔离级别下,这个 ReadView 一旦生成,整个事务期间一直复用,后面再执行多少次 SELECT,都是拿同一个 ReadView 去看版本链。
这意味着什么呢?意味着快照定了,后面的读取统统以快照为准,天塌下来也是看快照。其他事务后续提交的新版本,在这个事务眼里根本不存在。
这就是 MVCC 能解不可重复读的核心原因:它通过“快照复用”把事务的读取视角锁定在了事务开始时的那个时间点。
3.2 一个完整的例子:从 ReadView 创建到读到旧版本
光说理论太抽象,我们把第 1.1 节的实验场景重新走一遍,这次只看 InnoDB 底层的版本链和 ReadView 长什么样。
初始状态:事务 ID=100 插入(id=1, amount=100.00)并提交。版本链:
版头: (id=1, amount=100.00, trx_id=100, roll_ptr=NULL)时间线如下:
T1:事务 A 开启。假设 InnoDB 分配事务 ID=200。
BEGIN; SELECT * FROM t_order WHERE id = 1;事务 A 第一次执行 SELECT,生成 ReadView。假设当前系统里没有其他活跃事务,那么m_ids = [],min_trx_id理论上就是max_trx_id的值,creator_trx_id = 200。
现在从头结点开始判断:trx_id=100,它小于min_trx_id,说明在快照生成前已提交,可见。于是事务 A 读到amount=100.00。
T2:事务 B 开启并修改。假设事务 ID=300。
BEGIN; UPDATE t_order SET amount = 200.00 WHERE id = 1; COMMIT;这个 UPDATE 执行时,版本链更新为:
版头: (id=1, amount=200.00, trx_id=300, roll_ptr=0x01) ^ | v (id=1, amount=100.00, trx_id=100, roll_ptr=NULL)T3:事务 A 再次查询。
SELECT * FROM t_order WHERE id = 1;因为事务 A 处于 RR 级别,ReadView 还是之前那个,没重新生成。从头结点开始判断:trx_id=300,它大于等于max_trx_id吗?取决于 T1 时max_trx_id的值。如果 T1 之后、T2 之前没有其他事务,那max_trx_id可能恰好是 300,于是trx_id=300 >= max_trx_id=300,不可见。于是顺着回滚指针找下一个版本。
下一个版本trx_id=100,小于min_trx_id,可见。于是事务 A 仍然读到amount=100.00。
两次查询结果一致,不可重复读问题被成功规避。这就是 MVCC 在 RR 级别下解决不可重复读的完整链路。
3.3 快照读解决的边界:为什么它 hold 不住写操作
MVCC 的快照读不是万能的。它解决的是“读-读”“读-写”并发场景下的隔离问题,但如果你的事务里包含写操作(UPDATE、DELETE、INSERT),那情况就复杂了。
举个例子。事务 A 用快照读读到amount=100.00,然后执行UPDATE t_order SET amount = amount + 10 WHERE id = 1。这个时候,UPDATE 走的不是快照读,而是当前读——它必须基于最新版本的数据来计算amount + 10,否则就可能把事务 B 刚提交的修改覆盖掉。
如果你在 RR 级别下执行这个 UPDATE,InnoDB 会对目标行加锁(具体是排他锁),然后读取最新已提交版本做修改,生成一个新版本挂到链上。这时的版本链会变成非常有趣的样子,可能同时在链上存在“事务 A 读到的旧版本”和“事务 A 自己生成的新版本”。
这引出很多经典问题,比如“为什么我在事务里先 SELECT 后 UPDATE,会导致锁定读?”、“MVCC 到底能不能解决并发扣减库存的问题?”。答案都是同一个:快照读只管读取的隔离性,写操作必须通过当前读+锁来解决冲突。MVCC 覆盖不了写写冲突,这是 InnoDB 把“多版本”和“锁”两套机制同时保留的原因。
4. 当前读与快照读的分野:select * for update 走不走 MVCC
最近有个热词很有意思:select * from for update读取了mvcc吗。这反应了很多人对“当前读”和“快照读”的边界感模糊。顺便说一句,这句话按语法补全应该是SELECT * FROM t_order WHERE id = 1 FOR UPDATE,这里FOR UPDATE就是典型的当前读。
4.1 什么是当前读
当前读,英文叫 Current Read,指的是读取数据最新已提交版本的读操作,并且读取时会加锁。常见的当前读语句包括:
-- 加排他锁 SELECT * FROM t_order WHERE id = 1 FOR UPDATE; -- 加共享锁 SELECT * FROM t_order WHERE id = 1 LOCK IN SHARE MODE; -- DML 操作前的隐式读取 UPDATE t_order SET amount = 200.00 WHERE id = 1; DELETE FROM t_order WHERE id = 1; -- 还有 INSERT 前的唯一性检查等这些操作有一个共同特点:它们必须拿到最新的数据,否则后续的写操作可能基于错误的旧值做计算,产生数据覆盖或逻辑错误。
4.2 FOR UPDATE 为什么不走 MVCC 快照路径
回到问题本身:FOR UPDATE读取的是 MVCC 吗?准确地说,它不读 MVCC 的历史版本链,它读的是最新已提交版本,走的是加锁读路径。
为什么?因为 MVCC 快照读的设计目的是“无锁读旧版本”,而 FOR UPDATE 的核心诉求是“加锁读最新版本,防止其他事务并发修改”。两者目标截然相反。
具体流程如下:
FOR UPDATE在索引上找到目标记录。- 对这条记录加排他锁(X 锁)。
- 读取该记录的最新已提交版本数据(注意:这个读取也会走版本链,但只认“最新的已提交版本”,不会去回溯到 ReadView 指定的旧版本)。
- 如果其他事务已经持有这行的锁,FOR UPDATE 会阻塞等待,直到锁释放。
来一个直观的实验。事务 A 开启,用普通SELECT读到amount=100.00,这时 InnoDB 生成 ReadView,锁定旧视角。事务 B 开启,执行SELECT * FROM t_order WHERE id = 1 FOR UPDATE,它直接读到amount=200.00(假设事务 B 之前在别的事务里更新过),然后修改并提交。事务 A 此时再用普通SELECT,因为 ReadView 复用,可能还是读到 100.00;但事务 A 如果改用FOR UPDATE,它必须看到最新数据——要不怎么修改?如果看不到最新数据,它基于旧值做的修改就可能产生覆盖更新。
所以结论很明确:FOR UPDATE 确实没有走 MVCC 的快照读逻辑,而是走了“当前读 + 行锁”的路径。它和 MVCC 最大区别就是:MVCC 通过版本链+ReadView 实现“读旧数据不加锁”,FOR UPDATE 则是通过加锁读取最新数据来实现强一致性。
4.3 当前读对不可重复读的真正解法:锁
MVCC 用快照解决了“快照读”场景下的不可重复读,那“当前读”场景呢?RR 级别下,当前读靠什么解决不可重复读?
答案是锁。
还是上面的例子。事务 A 执行SELECT * FROM t_order WHERE id = 1 FOR UPDATE,这一步会锁定 id=1 这一行。在事务 A 提交之前,事务 B 如果也想更新这行,会被阻塞。所以从“当前读”的视角来看,事务 A 两次 FOR UPDATE 读到的值必然是一样的,因为其他事务根本无法在这期间修改该行。
但在并发场景中,锁会带来性能损耗和死锁风险。MVCC 的价值就在于:如果你只需要读,不需要写,那完全不用加锁,读历史版本就行——这就是读写不阻塞的精髓。
我在实际业务里见过不少误用:有人觉得既然 MVCC 能解决不可重复读,那所有读操作都该用普通 SELECT;结果在需要“先查再改”的支付、库存场景里,因为读的是旧快照,导致更新覆盖或余额算错。这类问题不是 MVCC 的问题,是使用姿势的问题。判断标准很简单:读完之后下一步要不要写?要写,就走当前读;只读,就放心用快照读。
5. 实操验证与排查技巧:如何在 MySQL 中观察 MVCC 行为
5.1 复现实验:RR 和 RC 下的 ReadView 差异
这里强调一个关键点:MVCC 在 RC(READ COMMITTED)和 RR(REPEATABLE READ)下的表现完全不同,很多人在这上面踩坑。
RC 级别下,每次普通 SELECT 都会重新生成一个新的 ReadView。所以事务 B 提交后,事务 A 再执行 SELECT,用的是新 ReadView,此时会看到事务 B 刚提交的新版本。这就是 RC 下存在不可重复读的根本原因。
而 RR 级别下,ReadView 只在第一次 SELECT 时生成,后续复用。这就保证了事务内多次读取的一致性。
你可以用这个实验验证:
-- 会话A SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED; -- 或者 RR BEGIN; SELECT * FROM t_order WHERE id = 1; -- 第一次读,生成ReadView -- 会话B执行 UPDATE t_order SET amount = 200.00 WHERE id = 1; COMMIT; -- 会话A再次查询 SELECT * FROM t_order WHERE id = 1;在 RC 下,第二次查询会看到 200.00;在 RR 下,第二次查询还是看到 100.00。你亲手跑一遍,比看十篇博客都有用。
5.2 通过 performance_schema 观察事务与锁
如果只是查数据,你会觉得 MVCC 像个黑盒。想知道底层发生了什么,可以利用 MySQL 的系统库来观察。
比如用performance_schema.data_locks看当前有哪些锁:
SELECT * FROM performance_schema.data_locks\G或者用information_schema.innodb_trx看当前活跃事务及其事务 ID:
SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id FROM information_schema.innodb_trx\G事务 ID 是观察 MVCC 版本链的关键线索。根据trx_id,你可以在脑子里模拟 ReadView 的可见性判定:哪些事务在快照生成时是活跃的,哪些之后才提交,这都会影响你能读到哪个版本。
有一个排障技巧很实用:当遇到“普通 SELECT 查不到刚更新的数据”时,先确认当前事务是否在 RR 级别下开启过快照读。如果事务中间隔了很久才执行 SELECT,并且期间别的会话更新了数据,你查不到是正常的。此时要么先提交当前事务再查,要么改用当前读。
5.3 常见误区与面试追问
聊了这么多,顺手整理几个高频误区,都是我在带人和面试时反复见到的问题。
第一个误区:觉得 MVCC 能解决所有并发问题。实际上,MVCC 只解决读写并发的隔离问题,写写冲突还是要靠锁。如果你有并发扣减库存、防重复提交这类强一致需求,光靠 MVCC 远远不够。
第二个误区:把不可重复读和幻读说成一回事。不可重复读针对的是已存在行的值变化,幻读针对的是结果集新增行。MySQL 的 RR 级别下,MVCC 通过快照读规避了大部分幻读,但当前读场景下,InnoDB 靠的是间隙锁(Gap Lock)和临键锁(Next-Key Lock)来防止幻读。所以严格来说,RR 下的幻读问题并没有被彻底消灭,只是被大大限制了。
第三个误区:认为事务 A 的更新操作也会基于快照读的旧值。这是新手最常犯的错误。UPDATE 语句内部是当前读,读取的一定是最新已提交版本,不会因为事务内之前执行过快照读就把更新基于旧值。这个点如果理解不透,很容易写出有并发逻辑 bug 的业务代码。
第四个误区也是高频面试追问:快照读在 RR 下什么时候生成 ReadView?答案不是事务 BEGIN 时,而是第一次执行快照读时。很多文章一口咬定“RR 是事务开始就生成快照”,这是不严谨的。BEGIN 只是开启事务,真正生成 ReadView 的是第一条快照读语句。
6. 经验总结与补充
做后端这些年,MVCC 是我见过的最优雅的并发控制机制之一。它用空间换时间,用版本链换无锁读,让数据库在“读多写少”的主流业务场景下保持极高并发性能。如果让我用一句话概括它的核心思想,那就是:读的归读,写的归写,让每个事务都活在自己的时间线里。
但优雅归优雅,理解它必须建立在亲手复现实验的基础上。上面那些 SQL,建议你在本地 MySQL 环境里跑一遍,把普通 SELECT、FOR UPDATE、RR、RC 各种组合都试一遍,亲眼看看 ReadView 差异带来的结果差异。
最后分享一个小技巧:排查数据不一致问题时,别急着看事务代码,先确认两个事——当前隔离级别是什么,事务的第一次快照读发生在哪一行。这两个信息定位清楚,80% 的“查不到新数据”“数据怎么变了”问题都能立刻找到答案。这也是我在生产环境中排障时最常用的第一板斧。