十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

MySQL MVCC 核心机制剖析:从 ReadView 到隔离级别的实战指南

MySQL MVCC 核心机制剖析:从 ReadView 到隔离级别的实战指南 做 MySQL 的同学多版本并发控制MVCC是你绕不开的一道坎。不管你是面试被问到 InnoDB 怎么解决不可重复读还是线上遇到RR 隔离级别下明明查不到这条数据update 却报锁等待超时背后都是 MVCC 在起作用。这篇博文我不打算给你念官方文档而是从一个写 SQL、调慢查询的工程师视角把 MVCC 这套机制掰开揉碎讲清楚包括它的核心组件、ReadView 的生成时机、RC 和 RR 隔离级别下的差异以及常见的几个坑和面试深挖点。这篇文章适合正在学 MySQL 高级特性的人也适合准备面试的开发以及那些被隔离级别、当前读、快照读绕晕了的运维同学。读完你至少能搞明白三件事第一MVCC 到底是靠什么实现多版本快照的第二为什么 RR 级别下同一个 select 查出来永远一模一样第三为什么select ... for update和普通select的行为完全不像一家人。1. 从并发问题说起为什么需要 MVCC先聊一个很基础的场景。你和同事同时改同一行数据你的事务先把它改成 B同事的事务还没提交把它改成 C。这时候你俩都没提交谁都不能覆盖谁数据库得想办法把这两个版本同时留着。如果没有并发控制要么互相覆盖要么直接互斥锁死。1.1 并发事务的三大难题数据库并发场景下最典型的问题有三个脏读、不可重复读、幻读。脏读最容易理解就是一个事务读到了另一个事务未提交的数据。比如转账场景里你改余额还没提交别人就把这笔钱拿去下单了一旦你回滚账就平不了。不可重复读是同一个事务里两次 select 同一行数据结果不一样。原因很简单中间有人提交了 update把值改了。幻读则是同一个事务里两次 select 按同一个条件查出来的行数不一样中间有人 insert 了新行。这三个问题的根源本质上就是读写并发带来的冲突。你要让读写互不干扰同时数据还不会错乱最粗暴的方案是全部加锁读读互相等待、读写互相阻塞。这种方案在低并发下没问题但稍微上点量数据库就直接变成排队系统了。1.2 隔离级别的进化与 MVCC 的定位SQL 标准定义了四个隔离级别读未提交Read Uncommitted、读已提交Read Committed、可重复读Repeatable Read、串行化Serializable。四个级别的强度从低到高并发能力从高到低。MVCC 在里面的角色很关键它不是某个隔离级别本身而是一种底层的并发控制实现机制。InnoDB 在默认隔离级别 RR 下主要就是靠 MVCC 来实现读不阻塞写、写不阻塞读的效果。读操作读的是一个一致性快照写操作照常写最新版本两边各玩各的互不干扰。注意MVCC 是 InnoDB 存储引擎的特性MyISAM 不支持事务也没有 MVCC。你在 MySQL 里开了事务正在跑结果查出来 MyISAM 表压根不受保护那感觉是真的酸爽。如果没有 MVCCInnoDB 只能靠锁来保证隔离性那 RR 隔离级别下并发读的性能会惨不忍睹。MVCC 的本质就是拿多版本数据换并发读性能用一段 undo log 的空间换来了读写不互斥的体验。2. MVCC 的三大核心组件MVCC 听起来玄乎其实底层就三板斧隐藏字段、undo log 版本链、ReadView 规则。理解了这三个东西你就理解了 MVCC 的全部。2.1 隐藏字段每一行数据背后的身份证InnoDB 的聚簇索引记录里除了我们自己定义的列还隐藏着三个字段DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID。DB_TRX_ID 记录的是最近一次修改这条记录的事务 ID6 字节。每次一个事务对这条数据做 update、delete都会把这个事务 ID 写进去。DB_ROLL_PTR 是指向 undo log 的指针7 字节通过它就能把这条记录的历史版本串起来。DB_ROW_ID 是隐式自增主键6 字节只有表里没有主键也没有唯一键时才会出现平时我们基本不用管它。这三个隐藏字段是 MVCC 的地基。每次修改数据旧版本不会真的从物理存储上被覆盖而是通过 DB_ROLL_PTR 挂到 undo log 里形成一个历史版本链表。当前记录永远是最新值而链上挂着的都是历史快照。2.2 undo log 版本链数据的前世今生undo log 有两种insert undo log 和 update undo log。insert 的 undo log 在事务提交后基本就没用了可以直接清理。update undo log 则是 MVCC 的核心它记录了修改前的旧值用于事务回滚和一致性读取。我们举个例子。假设一张表只有主键 id 和字段 age初始数据是(1, 20)。事务 A 把它改成 30事务 B 又改成 40。这时候物理存储上记录是(1, 40)DB_TRX_ID 是事务 B 的 IDDB_ROLL_PTR 指向事务 B 的 undo logB 的 undo log 里藏着(1, 30)以及事务 A 的 IDA 的 undo log 里则藏着(1, 20)。这条链就是版本链。版本链从新到旧排列最新的版本在记录本身最旧的版本在链尾。读取数据时就拿着 ReadView 里的规则沿着版本链从头往后找找到第一个对当前事务可见的版本。2.3 ReadView事务的时间快照ReadView 是判断版本可见性的核心数据结构它不涉及具体的物理存储而是一套事务活跃状态的快照。ReadView 里主要有四个字段m_ids生成 ReadView 时当前未提交的事务 ID 列表。min_trx_idm_ids 里的最小值也就是最小的活跃事务 ID。max_trx_id生成 ReadView 时InnoDB 分配出的下一个事务 ID注意它不是活跃事务里的最大值而是还没被分配的下一个 ID。creator_trx_id创建这个 ReadView 的事务自己的 ID。判断一条记录是否可见的规则就是拿记录的 DB_TRX_ID 和上面四个字段做比较。我把规则翻译成人话如果记录的 DB_TRX_ID 小于 min_trx_id说明这个版本在 ReadView 生成之前就已经提交了可见。如果记录的 DB_TRX_ID 大于等于 max_trx_id说明这个版本是在 ReadView 生成之后才产生的不可见。如果 DB_TRX_ID 在 [min_trx_id, max_trx_id) 范围内就要分情况如果这个事务 ID 在 m_ids 里说明事务还没提交不可见如果不在 m_ids 里说明事务已经提交了可见。如果 DB_TRX_ID 恰好等于 creator_trx_id那就是自己改的数据肯定可见。这条规则就是 MVCC 的灵魂。什么脏读、不可重复读、幻读本质上都是这套可见性规则在不同生成时机下的表现差异。3. MVCC 的完整工作流程快照读如何生成 ReadViewReadView 的生成时机直接决定了隔离级别的表现。这也是面试里最爱挖坑的地方。3.1 RC 隔离级别下 ReadView 的生成时机在 RC读已提交隔离级别下每次执行普通 select 时都会生成一个新的 ReadView。也就是说同一个事务里第一次 select 和第二次 select 各自生成自己的 ReadView互不共享。这样带来的直接后果是如果第一次 select 之后另一个事务提交了一条 update那么第二次 select 时新的 ReadView 里已经看不到那个事务的 ID 了自然就能读到新提交的数据。这就是不可重复读的根源。RC 级别适合对一致性要求不高的场景比如报表查询、后台运营系统的一些明细页只要保证每次读到的都是已提交数据就行不要求同一事务内多次查询结果完全一致。3.2 RR 隔离级别下 ReadView 的复用与一致性快照RR可重复读隔离级别下的规则就严格多了。它只在第一次执行快照读时生成 ReadView后面同一个事务里所有的普通 select 都复用这一个 ReadView不会重新生成。复用的结果是即使别的事务提交了新数据在已生成的 ReadView 里那些事务 ID 仍然被标记为活跃状态所以读不到。这就是可重复读的实现原理。同一个事务里不管执行多少次 select看到的快照永远等同于第一次 select 时的数据库状态。RR 是 InnoDB 的默认隔离级别MySQL 官方之所以选它除了历史原因也是为了配合主从复制。binlog 在 STATEMENT 格式下如果是 RC 级别容易出现主从数据不一致的问题而 RR 级别配合 MVCC 能规避大部分风险。3.3 当前读与锁机制为什么select for update不走 MVCC到这里就有个很经典的疑问了既然 MVCC 能让读不加锁那select ... for update怎么还会锁行因为普通 select 是快照读读的是历史版本不需要加锁。而select ... for update、update、delete这些操作走的是当前读它们必须读到最新已提交版本并且要加锁防止其他事务并发修改。当前读的实现方式和快照读完全不同。它不生成 ReadView也不沿版本链找版本而是直接锁住符合条件的索引记录读取最新版本。select ... for update加的是 X 锁select ... lock in share mode加的是 S 锁update和delete内部也会先做一次当前读拿到最新版本后加锁修改。所以select * from for update 读取了 MVCC 吗这个问题的答案是for update本身不走 MVCC 的快照读逻辑它走的是当前读加锁逻辑。MVCC 保护的只是普通 select 这类快照读场景。如果你在 RR 级别下先快照读了一条数据再去update同一行此时 update 读到的是最新已提交版本两者可能出现不一致这也是很多并发 bug 的源头。4. 实操验证一条 SQL 在 MVCC 下的完整旅程理论说了一堆不上手验证总感觉不放心。我来模拟一个最经典的测试场景搭两个连接验证 RR 级别下的可重复读以及 RC 级别下的不可重复读。4.1 准备测试环境我这里用的是 MySQL 8.0隔离级别默认 RR。先建一张简单表CREATE TABLE user ( id int NOT NULL AUTO_INCREMENT, name varchar(50) DEFAULT NULL, age int DEFAULT NULL, PRIMARY KEY (id) ) ENGINEInnoDB; INSERT INTO user (name, age) VALUES (张三, 20);先看下当前隔离级别SELECT transaction_isolation; -- 输出REPEATABLE-READ4.2 模拟 RR 隔离级别下的可重复读打开两个会话 A 和 B按以下顺序操作A 开启事务执行select * from user where id 1查到张三 20 岁。B 开启事务执行update user set age 30 where id 1提交。A 再次执行select * from user where id 1结果还是 20 岁。这就是 RR 级别下 MVCC 复用 ReadView 的效果。A 的第一次 select 生成了快照B 提交之后的数据对 A 不可见因为 B 的事务 ID 仍然存在于 A 的 ReadView 的 m_ids 中。如果此时 A 执行select * from user where id 1 for update结果就会变成 30 岁。原因就是当前读不走快照直接读最新版本并且会给这条记录加 X 锁。这个特性也能解释一个经典问题RR 级别下先快照读再当前读读到两种不同的数据并不违反 MySQL 的可重复读定义。4.3 模拟 RC 隔离级别下的不可重复读把隔离级别改成 RCSET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;重复上面的操作A 开启事务执行 select查到 20 岁。B 更新并提交age 变成 30。A 再次 select结果是 30 岁。原因在于 RC 级别下每次 select 都生成新的 ReadViewB 提交后它的活跃状态消失了新 ReadView 里不再包含 B 的事务 ID所以新版本对 A 可见。这个差异在实际开发中会直接影响业务逻辑。如果同一个事务里的多个查询依赖相同的结果做判断一定要确认当前隔离级别。线上默认是 RR但如果你在连接池配置里改成 RC那事务内一致性就会悄然变化。5. 常见问题与面试深坑整理关于 MVCC网上讨论最多、面试最爱考的问题无非这么几个。我把高频问题的答案整理出来同时附上一些我踩过的坑。5.1 MVCC 能解决幻读吗这是一个特别容易答错的题。在 RR 隔离级别下MVCC 解决的是快照读场景下的幻读。也就是说你同一个事务里多次执行普通 select结果集始终是第一次快照的样子确实看不到新插入的行。但如果你的事务里出现了当前读MVCC 就不顶用了。比如你先执行select * from user where age 10 for update其他事务往表里插入了一条 age30 的新记录你再执行同样的for update就会查出两条记录。这时候就得靠间隙锁和临键锁Next-Key Lock来帮忙锁住范围防止幻读。所以严谨的说法是RR 级别下MVCC 解决了快照读的幻读当前读的幻读则在很大程度上依赖锁机制。InnoDB 正是通过MVCC 临键锁的组合拳实现了 SQL 标准里 RR 级别难以做到的幻读防护。5.2 为什么 update 不生成新版本链时反而会加锁很多人写 update 语句时会在大事务里操作结果发现明明只是更新了一行却把整个范围的行都锁住了。根源在于 update 语句的执行计划。比如update user set age age 1 where name like 张%如果 name 字段没有索引InnoDB 只能走聚簇索引全表扫描。执行过程中会锁住扫描到的每一行然后判断条件是否匹配匹配才更新不匹配也会通过半一致性读释放锁。但即便如此扫描期间锁的范围依然比预期大很多。MVCC 在这里的参与方式是update 需要修改数据前先通过当前读获取最新版本然后把旧版本写入 undo log形成新的版本链。版本链越长undo 越多回滚和清理的成本就越高所以别在循环里反复更新同一行很容易把版本链堆得很长。5.3 MVCC 与索引、undo 表空间的关系MVCC 的版本链依赖 undo log而 undo log 在 MySQL 8.0 中是放在独立的 undo 表空间里事务提交后由后台 purge 线程异步清理。还有一个细节值得注意二级索引上并没有 DB_TRX_ID 字段InnoDB 对二级索引的可见性判断是通过回表到聚簇索引完成的。你执行一个走二级索引的条件查询先按索引找到主键再回表拿聚簇索引上的 DB_TRX_ID 去判断可见性。这也意味着如果大量历史版本存在回表查询的代价会变高。另外长事务会导致 undo log 无法及时清理undo 表空间膨胀进而引发磁盘空间告警。我踩过最疼的一次坑就是业务里有个定时任务不小心开启了事务逻辑处理了一个多小时没提交结果 undo 表空间涨到几十个 G磁盘直接满了。排查思路很清晰的information_schema.innodb_trx看长事务配合sys.innodb_lock_waits看锁等待定位到具体 SQL。5.4 高频面试问答速查表我把几个高频问题的简短答案整理成一张表面试前可以快速过一遍问题核心答案MVCC 是什么的缩写Multi-Version Concurrency Control多版本并发控制InnoDB 的 MVCC 依靠什么实现隐藏字段、undo log 版本链、ReadViewDB_TRX_ID 记录什么最近一次修改该记录的事务 ID快照读和当前读的区别快照读走 MVCC 不加锁当前读加锁读最新版本RR 级别如何保证可重复读事务第一次快照读时生成 ReadView后续复用RC 级别为何不可重复读每次快照读都生成新的 ReadViewMVCC 能否彻底解决幻读依赖快照读可以当前读还得靠临键锁undo log 会一直保留吗不会事务结束后由 purge 线程异步清理MVCC 适用于哪些引擎仅 InnoDBMyISAM 无事务无 MVCC另一个常见的追问是MVCC 会不会拖慢查询性能单次查询的版本链遍历其实很短因为大多数情况下 ReadView 下第一个版本就直接满足了可见性条件不会真的沿着 undo log 一路走到链表尽头。真正影响性能的是长事务导致 undo 膨胀以及大量更新同一行导致版本链过长。所以性能问题的核心不是 MVCC 本身而是事务设计。还有一个值得说的点是MySQL 8.0 对 undo log 的清理机制做了优化支持了 DDL 原子化purge 线程的数量和调度也比 5.7 更智能。如果你还在用 5.7遇到 undo 清理不及时的问题可以看看 innodb_purge_threads 和 innodb_max_purge_lag 的配置。最后再送大家一个实际工作中总结的排查思路。当发现某个事务里 select 查不到数据但 update 又锁等待时第一步不是去改 SQL而是先确认隔离级别和事务里是否混用了快照读和当前读。多数场景下把逻辑改成只有快照读或者统一全走当前读问题就能暴露得更清楚。我个人习惯在代码层面固定事务的读写模式需要一致性快照的业务绝不在事务里混入 for update需要锁保护的资源绝不做先快照后 update 这种操作。这个习惯帮我挡掉了不少线上事故。
返回列表