第四篇:事务隔离级别与MVCC——InnoDB的并发控制
第一篇MySQL索引底层——B树为什么是首选第二篇联合索引与最左前缀原则——从Explain看索引命中前言在前三篇文章中我们从B树一路讲到覆盖索引和索引下推这些都是如何让查询更快的问题。但从这一篇开始我们要解决一个更深层的问题多个事务同时操作同一行数据时怎么保证不出错这就涉及到MySQL最核心的并发控制机制——事务隔离级别与MVCC多版本并发控制。面试中面试官会追着问“什么是MVCC它怎么实现可重复读RR下幻读被解决了吗”。如果只回答MVCC就是多版本面试官会继续追问到你说出Undo Log和ReadView的具体工作流程。本文帮你准备好这个问题的完整答案。本文核心问题事务的四大特性ACID分别解决了什么问题四种事务隔离级别各自能防止什么问题脏读、不可重复读、幻读分别是什么MySQL InnoDB是如何解决的MVCC的核心原理是什么Undo Log和ReadView是怎么配合工作的为什么RR可重复读下大事务会导致Undo Log膨胀RR下能解决幻读吗什么情况下解决不了MVCC和锁机制分别解决什么问题它们的边界在哪读完本文你将对事务和MVCC拥有从原理到面试的完整理解。一、事务的基本概念疑问什么是事务为什么需要事务回答事务是一组要么全部成功、要么全部失败的操作集合。它的核心价值是让数据库在并发操作和故障场景下依然保持数据的一致性。1.1 事务的四大特性ACID特性含义反例原子性Atomicity事务中的操作要么全部成功要么全部回滚转账扣钱成功加钱失败钱凭空消失一致性Consistency事务执行前后数据满足所有约束转账后总金额对不上隔离性Isolation并发事务之间互不干扰同时转账导致金额错乱持久性Durability事务提交后数据永久保存即使数据库宕机提交成功后重启机器数据丢了ACID的核心是C一致性AID都是为C服务的。1.2 并发事务带来的问题当多个事务同时运行时如果没有合适的并发控制会出现三类典型问题场景假设账户余额 1000 元 事务A查询余额快照读 事务B扣款100元写操作 事务C存钱200元写操作问题描述举例脏读读到其他事务未提交的数据事务B扣款100但未提交事务A查到余额900事务B回滚后余额实际仍是1000不可重复读同一事务中两次读同一行得到不同结果事务A先查到余额1000事务B提交扣款100后余额变为900事务A再次查询看到900幻读同一事务中按条件两次查询行数不同事务A查出10条订单事务C插入1条新订单并提交事务A再次用同样条件查出11条二、四种事务隔离级别疑问怎么解决并发问题不同隔离级别解决到什么程度回答MySQL提供四种隔离级别每种级别在并发性能和数据一致性之间做不同的取舍。隔离级别脏读不可重复读幻读实现方式READ UNCOMMITTED❌❌❌几乎不加锁读最新值READ COMMITTEDRC✅ 解决❌❌每次读生成新的ReadViewREPEATABLE READRR✅ 解决✅ 解决 部分解决事务开始时生成一个ReadView配合临键锁SERIALIZABLE✅ 解决✅ 解决✅ 解决读写串行执行所有行加锁MySQL InnoDB默认隔离级别是RR。2.1 READ UNCOMMITTED读未提交最低的隔离级别当前事务可以读到其他事务还没提交的中间版本。几乎没有实际生产环境会使用只在极端压测场景中如果只写不读可以开启这个级别。脏读不可接受。2.2 READ COMMITTED读已提交读操作总是读取其他事务提交后的最新版本。每次读都生成一个新的ReadView看到的版本可能不同因此会出现不可重复读。RC下的读被称为一致性读但这里的一致性只指每次读看到的是已提交的版本而不是同一个事务中多次读取结果一致。2.3 REPEATABLE READ可重复读InnoDB默认级别。事务开始时生成一个ReadView后续所有读都用这个ReadView确保同一个事务内多次读看到的数据版本一致。可重复读通过MVCC保证但幻读问题在纯MVCC层面不能完全解决——临键锁Next-Key Lock作为补充在大部分场景下可以闭合这个缝隙。2.4 SERIALIZABLE可串行化最高隔离级别。读操作自动加共享锁写操作互斥锁所有事务串行执行。完全杜绝任何并发问题但并发性能和吞吐量极差只用于数据正确性是压倒一切需求的核心场景如银行核心账务系统。三、MVCC的底层实现——Undo Log ReadView疑问InnoDB怎么实现同一行数据在不同事务眼中看到不同的值回答通过MVCC机制。两个核心组件——Undo Log记录行的历史版本ReadView决定当前事务能看到哪些版本。3.1 三个隐藏字段InnoDB的每行数据都会自动添加三个隐藏字段┌──────────┬──────────────────────────────────────────────┐ │ DB_ROW_ID│ 如果表没有主键InnoDB用它作为隐式主键 │ ├──────────┼──────────────────────────────────────────────┤ │DB_TRX_ID │ 最后一次修改本行的事务ID核心 │ ├──────────┼──────────────────────────────────────────────┤ │DB_ROLL_PTR│ 指向Undo Log中本行的上一个版本版本链 │ └──────────┴──────────────────────────────────────────────┘3.2 Undo Log与版本链每当一个事务修改数据时修改前的旧值会被写入Undo Log新数据上的DB_TRX_ID更新为当前事务IDDB_ROLL_PTR指向Undo Log中的旧版本。多次更新形成一条版本链当前版本聚簇索引中的数据页 DB_TRX_ID 105 DB_ROLL_PTR → [Undo Log版本2: DB_TRX_ID103] DB_ROLL_PTR → [Undo Log版本1: DB_TRX_ID100] DB_ROLL_PTR → NULL最老版本3.3 ReadView——决定可见性ReadView是当前活跃事务的快照它记录了当前系统中所有未提交的事务列表。ReadView的核心字段字段含义m_ids当前所有未提交事务的ID列表min_trx_id未提交事务中最小的IDmax_trx_id下一个即将分配的事务IDcreator_trx_id当前事务的ID3.4 可见性判断规则读取一行数据时用这行数据上的DB_TRX_ID与ReadView做比较判断该版本是否对当前事务可见规则一DB_TRX_ID 当前事务ID → 可见看到自己刚修改的版本 规则二DB_TRX_ID min_trx_id → 可见此版本在事务启动前已提交 规则三DB_TRX_ID ≥ max_trx_id → 不可见此版本创建的事务在当前事务之后启动 规则四min_trx_id ≤ DB_TRX_ID max_trx_id → 如果DB_TRX_ID在m_ids列表中 → 不可见修改它的事务还未提交 如果DB_TRX_ID不在m_ids列表中 → 可见修改它的事务已提交如果版本不可见沿着DB_ROLL_PTR指针向前找历史版本重复上述判断直到找到可见版本或到达版本链末端。3.5 用一张图理解MVCC事务Atrx_id100启动读取一行数据 ReadView_Am_ids[101, 102, 103], min101, max104 数据当前版本DB_TRX_ID105 → 105 ≥ max(104) → 不可见 → 往前找历史版本 Undo版本1DB_TRX_ID103 → 103 在 [101~104) 之间且在m_ids中 → 不可见 → 继续往前找 Undo版本2DB_TRX_ID100 → 100 min(101) → 可见 → 返回这个旧版本事务A读到了事务ID100的版本之前已提交看不到事务ID103和105的修改。四、RC和RR的区别——ReadView的生成时机疑问RC和RR都是基于MVCC核心区别在哪回答生成ReadView的时机不同。RC每次读都生成新的ReadViewRR在事务开始时只生成一次ReadView。4.1 在RC下-- 事务ARR隔离级别事务开始SELECT*FROMuserWHEREid1;— 读操作1生成ReadView看不到事务B的修改-- 事务BUPDATE id1提交SELECT*FROMuserWHEREid1;— 读操作2生成新的ReadView看到事务B提交后的最新版本RC下读操作看到的是提交后的最新值但同一事务中两次读可能不同。它只保证读到了已提交的数据不保证两次读到相同的数据。4.2 在RR下-- 事务ARR隔离级别事务开始SELECT*FROMuserWHEREid1;— 读操作1生成ReadView-- 事务BUPDATE id1提交SELECT*FROMuserWHEREid1;— 读操作2使用同一个ReadView仍然看不到事务B的修改RR下事务A始终用同一个ReadView做可见性判断事务B的修改对事务A不可见——实现了可重复读。4.3 RR为什么能解决不可重复读RR下的事务A ReadView事务启动时生成 → 版本可见性在事务启动时就确定了 → 即使其他事务修改并提交这个ReadView不变 → 事务A一直读到启动时的版本 → 两次读结果一致可重复读五、大事务与Undo Log膨胀疑问为什么RR下大事务会导致Undo Log膨胀回答RR隔离级别下ReadView在事务启动时就生成并一直持有。只要事务未结束所有在这个ReadView之前的Undo Log版本都必须保留——因为当前事务可能随时跳回到版本链中查看旧版本。长事务持续占用Undo Log空间甚至导致Undo Log溢出事务被迫回滚。5.1 膨胀机制事务A耗时10分钟 ReadViewmin100 事务B修改1万行已提交 每修改一行旧版本被写入Undo Log 事务A还没结束 → 事务A的min是100 → 事务B的DB_TRX_ID105 → 从事务A的视角105 100不在可见范围 → 事务B写的这些Undo Log版本必须保留事务A可能要读 → 如果后续事务继续修改更多数据留存的历史版本链不断增长 → 10分钟后事务A提交 → Undo Log才可以被回收5.2 避免方案方案做法拆分大事务一个事务不要太长控制在秒级避免RR下大事务RR隔离级别对大事务最不友好监控Undo Log大小超过阈值主动告警找出未提交事务六、RR下的幻读问题疑问RR下能解决幻读吗什么情况下解决不了回答RR下通过临键锁解决当前读的幻读但快照读仍然可能产生幻读。6.1 快照读下的幻读-- 事务ARRSELECT*FROMuserWHEREage18;-- 查到10条-- 事务BINSERT一条新用户age20提交SELECT*FROMuserWHEREage18;-- 还是10条使用同一个ReadView快照读-- 但如果你用当前读SELECT*FROMuserWHEREage18FORUPDATE;-- 查到11条FOR UPDATE是当前读读最新数据6.2 MVCC不能解决幻读的根本原因MVCC基于版本链判断每条已有数据的可见性但新插入的行不属于任何旧版本——它是一个全新的DB_TRX_ID没有指向旧版本的DB_ROLL_PTR链条。MVCC可以通过版本号判断旧数据是否对当前事务可见但无法阻止新数据在另一个事务中创造出来。6.3 临键锁——锁住缝隙阻止幻读InnoDB在RR下使用临键锁阻止幻读。临键锁不只是锁住存在的行还锁住索引记录之间的间隙——防止其他事务在这个间隙中插入新数据。快照读不受影响不产生幻读当前读FOR UPDATE或UPDATE WHERE被临键锁保护。七、MVCC和锁机制的关系疑问MVCC和锁分别解决什么问题它们在InnoDB中如何分工回答MVCC解决读-写并发——读操作不阻塞写写操作不阻塞读。锁解决写-写并发——两个写操作不能同时改同一行。场景用什么原理读读无冲突各自快照读写MVCC快照读读旧版本写新版本写写行锁后写的事务等待MVCC和锁是InnoDB并发控制的两个互补手段——MVCC让读操作几乎不阻塞无锁快照锁保证写操作的串行执行两写不能同时进行。总结ACID的核心是CAID都是为一致性服务的手段四种隔离级别逐级加强RC解决脏读RR解决不可重复读Serializable解决幻读但串行化成本高MVCC的核心Undo Log维护版本链ReadView决定版本可见性。每个事务根据自己的时间窗口看到对应的旧版本RC和RR的本质区别RC每次读生成新的ReadViewRR事务开始只生成一次。RR的ReadView在事务期间不变实现了可重复读大事务导致Undo Log膨胀RR下事务持有ReadView期间所有旧版本必须保留长期占用Undo空间RR下幻读部分被解决快照读不产生幻读MVCC当前读通过临键锁阻止幻读MVCC和锁分工MVCC解决读-写并发读不阻塞写锁解决写-写并发两写互斥下一篇预告MySQL锁机制——从行锁到间隙锁。拆解InnoDB的锁体系行锁、间隙锁、临键锁、意向锁、自增锁分别锁什么范围、在什么情况下加锁、以及死锁的排查与预防。