MVCC如何解决不可重复读?从快照读到ReadView的完整原理
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) ) ENGINEInnoDB; 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 READRR就是为了干掉不可重复读。但注意RR 并不能完全干掉幻读只是 InnoDB 用了特定手段把幻读的概率降到了一个非常低的水平。这块等聊到当前读和间隙锁的时候再展开。那 MVCC 凭什么能让一个事务“无视”其他事务提交的修改这就要从它的底层机制说起了。2. MVCC 的底层三件套隐藏字段、undo log 版本链、ReadViewMVCC 的全称是 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新记录通过回滚指针指向旧记录形成一个版本链。举例说明。假设事务 ID100 插入了一条订单记录(id1, amount100.00)此时版本链只有一环(id1, amount100.00, trx_id100, roll_ptrNULL)接着事务 ID200 把它更新成amount200.00版本链变成(id1, amount200.00, trx_id200, roll_ptr0x01) ^ | v (id1, amount100.00, trx_id100, roll_ptrNULL)再后来事务 ID300 又把它更新成amount300.00版本链变成(id1, amount300.00, trx_id300, roll_ptr0x02) ^ | v (id1, amount200.00, trx_id200, roll_ptr0x01) ^ | v (id1, amount100.00, trx_id100, roll_ptrNULL)每次更新新版本在上旧版本往下挂。这就是“多版本”的字面来源——一行数据多个版本并存。你可能要问这样一直往链上挂undo log 会不会无限膨胀不会。purge 线程会定期清理那些“没有任何活跃事务再需要看到”的旧版本。清理的依据也跟 ReadView 有关这个后面一起说。2.3 ReadView一致性快照的判定规则有了版本链还得有个规则告诉 InnoDB“当前这个事务应该看到版本链上的哪个版本”。这个规则就是 ReadView翻译过来叫“读视图”也就是一致性快照。ReadView 本质上是一个数据结构在事务执行快照读的那一刻生成。它主要包含四个核心部分字段含义m_ids生成 ReadView 时当前系统中所有**活跃未提交**读写事务的 ID 列表min_trx_idm_ids 中最小的那个事务 IDmax_trx_id生成 ReadView 时InnoDB 分配给下一个事务的 ID注意它不是活跃事务中的最大值而是“下一个待分配”的IDcreator_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 长什么样。初始状态事务 ID100 插入(id1, amount100.00)并提交。版本链版头: (id1, amount100.00, trx_id100, roll_ptrNULL)时间线如下T1事务 A 开启。假设 InnoDB 分配事务 ID200。BEGIN; SELECT * FROM t_order WHERE id 1;事务 A 第一次执行 SELECT生成 ReadView。假设当前系统里没有其他活跃事务那么m_ids []min_trx_id理论上就是max_trx_id的值creator_trx_id 200。现在从头结点开始判断trx_id100它小于min_trx_id说明在快照生成前已提交可见。于是事务 A 读到amount100.00。T2事务 B 开启并修改。假设事务 ID300。BEGIN; UPDATE t_order SET amount 200.00 WHERE id 1; COMMIT;这个 UPDATE 执行时版本链更新为版头: (id1, amount200.00, trx_id300, roll_ptr0x01) ^ | v (id1, amount100.00, trx_id100, roll_ptrNULL)T3事务 A 再次查询。SELECT * FROM t_order WHERE id 1;因为事务 A 处于 RR 级别ReadView 还是之前那个没重新生成。从头结点开始判断trx_id300它大于等于max_trx_id吗取决于 T1 时max_trx_id的值。如果 T1 之后、T2 之前没有其他事务那max_trx_id可能恰好是 300于是trx_id300 max_trx_id300不可见。于是顺着回滚指针找下一个版本。下一个版本trx_id100小于min_trx_id可见。于是事务 A 仍然读到amount100.00。两次查询结果一致不可重复读问题被成功规避。这就是 MVCC 在 RR 级别下解决不可重复读的完整链路。3.3 快照读解决的边界为什么它 hold 不住写操作MVCC 的快照读不是万能的。它解决的是“读-读”“读-写”并发场景下的隔离问题但如果你的事务里包含写操作UPDATE、DELETE、INSERT那情况就复杂了。举个例子。事务 A 用快照读读到amount100.00然后执行UPDATE t_order SET amount amount 10 WHERE id 1。这个时候UPDATE 走的不是快照读而是当前读——它必须基于最新版本的数据来计算amount 10否则就可能把事务 B 刚提交的修改覆盖掉。如果你在 RR 级别下执行这个 UPDATEInnoDB 会对目标行加锁具体是排他锁然后读取最新已提交版本做修改生成一个新版本挂到链上。这时的版本链会变成非常有趣的样子可能同时在链上存在“事务 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读到amount100.00这时 InnoDB 生成 ReadView锁定旧视角。事务 B 开启执行SELECT * FROM t_order WHERE id 1 FOR UPDATE它直接读到amount200.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这一步会锁定 id1 这一行。在事务 A 提交之前事务 B 如果也想更新这行会被阻塞。所以从“当前读”的视角来看事务 A 两次 FOR UPDATE 读到的值必然是一样的因为其他事务根本无法在这期间修改该行。但在并发场景中锁会带来性能损耗和死锁风险。MVCC 的价值就在于如果你只需要读不需要写那完全不用加锁读历史版本就行——这就是读写不阻塞的精髓。我在实际业务里见过不少误用有人觉得既然 MVCC 能解决不可重复读那所有读操作都该用普通 SELECT结果在需要“先查再改”的支付、库存场景里因为读的是旧快照导致更新覆盖或余额算错。这类问题不是 MVCC 的问题是使用姿势的问题。判断标准很简单读完之后下一步要不要写要写就走当前读只读就放心用快照读。5. 实操验证与排查技巧如何在 MySQL 中观察 MVCC 行为5.1 复现实验RR 和 RC 下的 ReadView 差异这里强调一个关键点MVCC 在 RCREAD COMMITTED和 RRREPEATABLE 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看当前活跃事务及其事务 IDSELECT 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% 的“查不到新数据”“数据怎么变了”问题都能立刻找到答案。这也是我在生产环境中排障时最常用的第一板斧。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →