资讯动态

MySQL MVCC原理详解:从版本链到ReadView的并发控制机制

发布时间:2026/10/15 5:11:57 来源:尧图企业网站定制
1. 从并发写冲突说起MVCC到底解决了什么1.1 锁并发与读写互斥的代价如果你已经用了一段时间MySQL肯定遇到过这样的场景两个会话同时改一行数据结果一个被阻塞另外一个要等锁释放才能继续。这就是典型的锁冲突。早期的数据库引擎处理并发基本思路就是把所有可能互相干扰的操作全部用锁串行化。读要加读锁写要加写锁读写之间还得互斥。这么做的优点是好理解缺点的代价在当今的高并发场景下几乎无法接受——读操作完全被写操作拖垮写操作稍微频繁一点整个系统的吞吐量断崖式下降。为什么读写互斥这么伤人想象一个电商库存表用户在下单的同时后台统计报表也在持续扫描这张表。如果读写完全互斥那么每次库存变更的瞬间所有正在执行的查询都得停下来等锁哪怕只是毫秒级别在每秒几千个订单的系统里也会累积成明显的延迟。更麻烦的是读多写少的场景本来应该是数据库最擅长的锁机制却硬生生把它变成了一个串行通道。MVCCMulti-Version Concurrency Control多版本并发控制就是为了打破这个僵局而生的。它的核心思路说起来其实很朴素写操作不再原地覆盖旧数据而是生成一个新版本挂上去读操作可以选择读适合自己的那个版本而不是被锁挡住只能等。这样读和写在绝大多数情况下互不干扰各看各的版本谁也不用等谁。1.2 MVCC和锁并发的分工区别需要澄清一点MVCC并不是要完全替代锁机制。InnoDB的实际做法是两者配合MVCC负责搞定“快照读”这一侧——也就是普通的SELECT语句锁机制负责搞定“当前读”这一侧——也就是UPDATE、DELETE、INSERT以及加了FOR UPDATE或LOCK IN SHARE MODE的SELECT。当前读必须看到最新已经提交的数据而且要防止并发修改因此不能走MVCC的版本快照必须加锁。理解这个分工是你进阶MVCC的第一道坎。很多人一听说InnoDB用MVCC大大提升了并发能力就以为锁不重要了甚至以为MVCC可以解决所有并发问题。实际上不是这样。MVCC解决的是“读写并发”的阻塞问题它让读不挡写、写不挡读但“写写并发”的冲突MVCC管不了还得靠行锁来解决。举个例子。两个事务同时更新同一行数据MVCC不会让这两个更新自动排队而是靠行锁让后到的那个事务等待。只不过等锁的时候这个事务如果还有别的快照查询在跑是不会被自己尚未获得的写锁卡住的。这一点在实际系统里非常关键——事务A更新行1、事务B更新行2然后A又想更新行2、B又想更新行1这时候会发生死锁MySQL会检测到并回滚其中一个。这类问题MVCC无能为力只能从业务层面规范更新顺序。所以你现在脑海里的MVCC画像应该是这样的它是一个“读多写少场景下的并发加速器”而不是一个“万能并发方案”。它的存在让普通查询几乎不感知行锁的存在让读写能够并行推进。接下来要聊的版本链和ReadView就是它实现这一目标的两根核心支柱。2. 版本链与ReadViewMVCC的两根支柱2.1 隐藏列里藏了什么MVCC说穿了就是“给数据行保留多个历史版本”。那么历史版本存在哪里怎么知道哪个版本属于哪个事务这就要讲到InnoDB在每个数据行上附加的隐藏列。InnoDB聚簇索引的每条记录里除了我们业务上定义的字段之外还隐藏着几个数据库维护的列MySQL 5.7及之后的主流版本里主要关注三样隐藏列名称作用DB_ROW_ID行ID如果表没有主键InnoDB会用它作为聚簇索引的隐式主键DB_TRX_ID事务ID记录最近一次插入或更新该行的事务标识DB_ROLL_PTR回滚指针指向该行在undo log中的上一个版本记录DB_TRX_ID和DB_ROLL_PTR是最核心的两个。每次更新一条记录时InnoDB并不会直接修改原记录然后丢掉旧值而是把旧值复制到undo log里再把新值写入当前记录同时把DB_ROLL_PTR指向undo log中的那个旧版本DB_TRX_ID改成当前事务的ID。这样一行数据就构成了以“最新值 → 旧值 → 更旧的值”为方向的链表链条的头部就是聚簇索引行本身链条的尾部是某个最早的undo记录。这就是版本链version chain的真相。你可能听说过“undo log是逻辑日志、用于回滚”它的用途远不止回滚。事务回滚时确实要沿着这条链恢复旧值但并发读取时确定可见版本也需要沿着这条链回溯。一个undo log记录同时承载了回滚和MVCC读取两种使命这也是为什么长事务会撑大undo log的一个深层原因。2.2 undo log如何构建出版本链看一个具体场景。假设有一张测试表t_userid为1的那行数据内容是nameA。一个事务T1把它改成nameB流程是事务T1获得trx_id假设是100。把修改前的记录(nameA, trx_id50, roll_ptr指向更早版本)完整写入undo log。在聚簇索引行上写入新值nameB, trx_id100, roll_ptr指向刚写入的那条undo记录。版本链变成当前行(B, trx_id100) → undo记录(A, trx_id50) → 更早的undo记录。如果T1还没提交另一个事务T2发来一条普通的SELECT它需要判断nameB这个版本对自己是否可见。判断的依据不是什么时间戳而是ReadView。注意MVCC里没有“时间”的概念只有“事务ID”的概念。事务ID的递增顺序代表了事务开始的先后所以判定可见性完全基于事务ID的集合运算。undo log里的版本记录会什么时候被清理取决于两个条件一是这个版本已经没有任何活跃事务需要看见它二是系统不再需要对它进行回滚。InnoDB后台有purge线程专门干这个活。理解这一点对你日后诊断undo膨胀很有帮助。2.3 ReadView的判断规则ReadView可以理解成“在某个时间点上一个事务对当前数据库全局事务状态的快照”。它内部记住四个关键信息m_ids生成ReadView时系统中所有尚未提交的活跃事务ID列表。min_trx_idm_ids中的最小值。max_trx_id生成ReadView时InnoDB将分配给下一个事务的ID也就是当前已出现过的事务ID最大值加1。creator_trx_id生成这个ReadView的事务自己的ID。当InnoDB需要判断版本链上某个版本的可见性时它取出该版本的trx_id按照下面这套规则打表条件结论trx_id creator_trx_id可见自己修改的版本当然能看见trx_id min_trx_id可见说明该版本在ReadView生成前就早已提交trx_id max_trx_id不可见说明该版本是ReadView生成之后才产生的事务min_trx_id trx_id max_trx_id 且 trx_id在m_ids中不可见生成ReadView时该事务还活跃min_trx_id trx_id max_trx_id 且 trx_id不在m_ids中可见事务虽然落在区间内但已经提交如果当前版本不可见就沿着DB_ROLL_PTR找到上一个版本再重复比较直到找到可见版本或链表走完。这套规则本身不复杂但它是理解RR和RC隔离级别差异的钥匙。因为不同隔离级别下ReadView的生成时机不同。我在实际排障中经常看到有人把“快照读”理解成“在事务开始时把整张表的数据复制一份”。这个理解是错的。快照读根本不是物理拷贝它是基于ReadView加版本链的动态判定。每次读的时候是在当前数据页的基础上沿着版本链筛出“逻辑上属于我这个快照”的版本。所以快照读也不会读到已经被其他事务更新但尚未提交的脏数据因为那些更新事务的trx_id还在m_ids列表中。3. 不同隔离级别下MVCC的行为差异3.1 RC级别每次快照都是全新的READ COMMITTEDRC隔离级别下MVCC的行为特征是事务内每次执行SELECT语句时都会生成一个新的ReadView。这意味着一个事务内在不同时刻执行同一条SELECT可能看到不同的数据。这个特性既带来了好处也埋了坑。好处是它更接近“读已提交”的直觉语义——每次查询都能看到最新已提交的数据适合对一致性要求不极端、但对数据新鲜度敏感的业务。坑在于如果你在一个事务里多次读取同一批数据做统计可能会发现前后两次结果不一致这就是RC下的“不可重复读”。举个例子。会话A开启事务执行SELECT count(*) FROM t_order WHERE status1得到数量100。然后会话B提交了一个事务插入了一条新的status1的记录。会话A再次执行同样的SELECT得到101。两次读取结果不同。这在RC级别是完全允许的因为每次SELECT都重新生成了ReadViewB提交的事务ID在新ReadView里已经属于“已提交区间”所以新记录对A可见。很多初学者在RC下遇到这种“自己事务里两次读不一致”的情况会很困惑以为数据错了其实这是MVCCRC的正确表现。如果你需要一致性就应该考虑RR级别或在整个事务里用当前读锁定数据。3.2 RR级别首读即定格REPEATABLE READRR是MySQL InnoDB的默认隔离级别它的MVCC行为不同事务内第一次执行SELECT时才生成ReadView之后的快照读全部复用这个ReadView。只要你的快照第一个ReadView里看不到的事务ID产生的版本后续即使该事务提交了你依然看不到。还用刚才订单表的例子。会话A开启事务第一次SELECT得到数量100。这次SELECT生成ReadView里面包含了会话B的事务ID假设B是活跃的。会话B提交插入新记录后会话A再次SELECT仍然得到100。为什么因为复用的是同一个ReadViewB的trx_id依然被判定为“不可见”。这就是RR级别下“可重复读”的底层逻辑不是锁住了数据而是锁住了你的“视野”。RR的语义在理解上有时候很反直觉。很多人以为RR是把行数据“冻结”了其实不是。数据行本身在物理上可能已经被其他事务改了好几版只是你的ReadView把这些新版本全部过滤掉了。你的快照读看到的是“基于首次读那一刻活跃事务状态推导出的数据视图”。有意思的是RR级别下有一种情况会导致快照读也能看到新提交的数据如果快照读还没发生事务先执行了当前读比如UPDATE那么当前读会生成一个新的ReadView并加锁。此后的快照读如果还没建立过老ReadView就会复用当前读生成的那个新ReadView。这种细节的区分是理解后续“半一致性读”和具体业务异常的关键。3.3 快照读与当前读的博弈既然讲到这里就得把快照读和当前读的分界说清楚。快照读就是不带FOR UPDATE、不带LOCK IN SHARE MODE的普通SELECT。当前读包括UPDATE、DELETE、INSERT以及SELECT...FOR UPDATE和SELECT...LOCK IN SHARE MODE。当前读总是读取“最新已提交版本”并且对读取到的记录加锁。当前读是不会走ReadView判断的——它必须拿到最新数据否则更新就会基于过期数据做决策产生丢失更新问题。这里有一个非常经典的业务陷阱我经常在学员的代码里看到。假设账户余额表有一行数据余额是100。业务逻辑是这样的1. 事务A开始 2. SELECT balance FROM t_account WHERE id1; -- 快照读看到100 3. 假设业务代码计算出新余额 100 50 150 4. UPDATE t_account SET balance150 WHERE id1; -- 当前读会加行锁 5. 事务A提交看起来没问题如果事务A执行第2步之后、第4步之前事务B也读了同一行余额并且把余额加到了200并提交那么事务A在第4步执行UPDATE时InnoDB会加锁并读到最新值200。如果程序还是用第2步查到的100去计算就会把200覆盖成150A实际上丢了它的更新。正确做法是在第2步直接使用SELECT ... FOR UPDATE走当前读并加锁把事务B挡在外面。这说明什么业务的一致性并不仅仅取决于隔离级别还取决于你选择了快照读还是当前读。MVCC能减少锁竞争但如果你在高一致性场景下错误地使用快照读它会悄悄给你返回一个“看起来合理但不是最新”的数据最终导致更新覆盖。这是MVCC实践中最多人踩的坑之一。4. 写操作如何与MVCC共存4.1 锁定读与排他锁的层次刚才说了当前读的本质是“读最新版本加锁”。对于UPDATE和DELETEInnoDB会先定位到满足条件的记录然后对这些记录加排他锁X锁。如果索引条件能命中二级索引InnoDB会先对二级索引记录加锁再回表对聚簇索引记录加锁。MVCC在这里还有一层功劳如果UPDATE语句的WHERE条件在二级索引上但实际要更新的字段不在二级索引里InnoDB需要回表。回表后的聚簇索引行可能已经存在多个版本这个时候当前读会读取可见的最新的聚簇索引版本并对其进行锁定。注意这个“最新”是指当前可见的最新已提交版本而不是版本链上物理上的最新未提交版本——如果那个未提交版本的事务还没提交InnoDB会等待它的锁释放。很多人对锁的理解停留在“锁住一整行”。其实InnoDB的行锁是锁在索引记录上的聚簇索引记录和二级索引记录分别有锁。这也是为什么如果表没有合适的索引UPDATE会退化为锁住大量记录甚至全表——因为InnoDB必须扫描所有二级索引或聚簇索引来确认哪些记录被命中扫描过程中碰到的记录都会被加上锁。4.2 更新、删除对版本链的影响UPDATE和DELETE操作会在版本链头部插入新版本吗严格来说它们是“基于当前版本生成新版本”。UPDATE的本质是把旧的聚簇索引记录标记删除在undo log中新增一条更新前版本记录再插入一条新的聚簇索引记录。DELETE类似只是新版本记录上的delete flag会置为1。这个“新记录插入 旧记录进undo”的过程对性能有深远影响。如果一张表频繁更新聚簇索引页里会累积大量标记为删除的旧记录页面可能出现碎片。范围查询扫描这些页时要跳过delete flag为1的记录IO和CPU开销都会上升。所以一个频繁写操作的表定期用OPTIMIZE TABLE或重建表来整理碎片是有实际收益的不只是洁癖问题。版本链增长的另一个直接后果是某个被频繁更新的热点行在最极端情况下版本链可能拉出几十乃至上百个版本。当其他事务执行快照读时要沿着版本链一个一个比较trx_id一个不可见就跳到下一个。虽然InnoDB对版本链遍历做了一定优化比如经purge后截断已提交且不再需要的部分但长时间不提交的长事务会让旧版本的保留时间无限拉长遍历成本也会显著上升。4.3 主键冲突时隐藏的版本机制INSERT操作看起来和MVCC关系不大其实也有隐藏的版本机制。最典型的情况是主键冲突。假设表里已经存在主键id1的记录版本链上有v1已提交和v2未提交由活跃事务T100写入。这时有一个新事务T101要插入id1的记录它会被主键冲突检测拦下。InnoDB会先尝试对那条已有记录的聚簇索引加锁如果T100未提交持有锁T101就等待如果T100提交了T101会继续判断。更有意思的是INSERT语句的冲突检测涉及“重复键检查”这个检查在MVCC的帮助下会读取“当前最新已提交版本”而不是被无谓的历史版本阻塞。这也是为什么即便MVCC让读不阻塞写写与写之间的冲突依然存在你不能指望MVCC来解决主键冲突出导致的锁等待。在实际业务里如果你发现某张表的主键冲突异常频繁通常不是因为MVCC性能不行而是业务设计问题——比如用一个全局序列作为主键多个会话生成相同值。排查的方向应该放在ID生成策略和批量导入的去重逻辑上而不是数据库引擎层面。5. 实战视角MVCC现场的问题排查与调优经验5.1 长事务导致undo膨胀与版本链拖长MVCC最经典的生产事故就是长事务把undo log撑到巨大。怎么发生的事务不提交它生成的ReadView会一直保留意味着ReadView生成之后所有旧版本都不能被purge线程清理。这些旧版本会一直挂在版本链里占用undo表空间。我遇到过一个具体案例某个系统里有个定期同步任务代码里开了事务后不显式提交而是依赖框架在方法结束后自动提交。因为同步数据量大方法执行了几十分钟这期间表里的核心记录被其他会话更新了无数次。事务结束时一个原本几十MB的undo表空间暴涨到几个GB磁盘告警直接把服务打挂。排查这类问题的第一步就是找出长事务。下面这条SQL经常被我用来查活跃事务SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started;然后通过trx_mysql_thread_id找到对应连接的会话看它到底在干什么。你可能会发现某一连接长时间停留在同一个SQL上或者处于Sleep状态但事务没提交。应用层的修复方向通常是缩短事务持续时间、避免在一次事务里做大量无关操作、确保异常路径上的事务回滚逻辑正确。如果你发现undo表空间已经膨胀并且所有相关事务都已结束可以通过重建undo表空间来回收。但注意一定要先确认没有活跃长事务否则回收操作本身会被卡住或失败。5.2 RR级别下快照读导致的数据“看不见”另一个常见陷阱是RR级别的快照读让两个事务互相看不到对方写入的数据从而产生逻辑上的不一致。典型场景是经典的双写操作事务AUPDATE t SET balance balance 100 WHERE id1; 事务BUPDATE t SET balance balance * 1.1 WHERE id1;如果两个事务并发执行A先拿到行锁更新余额为200并提交B等待锁释放后再更新B读到的是200乘以1.1后变成220没问题。但如果业务改成这样事务ASELECT balance FROM t WHERE id1; -- 快照读得到100 事务BUPDATE t SET balance balance 100 WHERE id1; -- 提交 事务AUPDATE t SET balance 150 WHERE id1; -- 覆盖了B的修改问题就出现了。A的快照读读到100之后B提交把余额改成200A再执行UPDATE时覆盖成了150。这就是我前面提到的“基于过期快照做更新”的典型丢失更新问题。这种问题怎么解三个方向业务代码里强制使用当前读SELECT ... FOR UPDATE做余额读取先锁行再计算。UPDATE语句直接写表达式让数据库原子地完成计算UPDATE t SET balance balance 100 WHERE id1靠行锁天然串行。用乐观锁比如在表里加version字段更新时带版本条件更新失败则重试。我个人的实践偏好是方案2它最少改动、最可靠。前提是你的业务计算逻辑可以直接用SQL表达式表达出来。如果计算逻辑复杂必须取回应用层那就用方案1。方案3适合跨系统、跨应用的场景比如两个微服务同时改一份数据DB层面的行锁可能不够乐观锁能更早发现问题。5.3 从执行计划反向验证MVCC行为进阶玩家值得掌握的一个技巧是通过EXPLAIN和实际读取行数的对比反向推断MVCC是否在按预期工作。比如执行EXPLAIN SELECT * FROM t_user WHERE id BETWEEN 1 AND 1000;正常情况下这应该是一个范围扫描预估扫描行数接近1000。如果实际响应特别慢但你确认有索引、数据量也不大那就要怀疑是不是版本链过长导致每条记录都要回溯多个版本。你可以用下面这个方法粗略评估在同一张表上分别在一个短事务和一个长事务里执行同样的SELECT观察耗时差异。如果长事务明显更慢基本可以断定是版本链遍历开销。此时优先检查长事务是否必要其次考虑应用是否可以在低峰期集中处理长任务。另一个观察技巧是通过performance_schema里的事务事件来分析事务的持续时间。开启performance_schema后可以查询events_transactions_current看哪些事务占用了大量时间的锁等待和IO。再结合information_schema.innodb_trx基本可以定位到具体事务。值得一提的是MySQL 8.0之后引入了对undo的自动截断和回收机制undo表空间膨胀问题比5.7时代缓和了不少。但MySQL 8.0对历史版本的清理逻辑仍然依赖purge线程长事务依然会导致history list length历史链表长度持续上涨。你在监控面板上看到history list长度异常偏高别慌先查长事务再决定是否需要处理。5.4 常见的MVCC实践清单最后分享一份我平时排查MVCC相关问题时用的检查清单按优先级从上到下是否有长事务未提交查information_schema.innodb_trx。事务里是否误用了快照读去做需要最新数据的决策改当前读。事务里是否存在多个写操作打乱更新顺序可能造成死锁统一更新顺序。undo表空间是否在持续增长检查实例的history list length指标。表的碎片率是否偏高频繁UPDATE的表定期整理。业务是否把复杂读取和在线交易混在同一个事务里考虑拆分或降级隔离级别。关于第4点我看过不少运维手册建议把隔离级别直接降到RC来缩短快照维持时间这确实能有效降低undo膨胀风险但前提是业务能接受不可重复读。对很多互联网业务RC配合当前读足够用但对财务、对账这类需要强一致的场景RR加谨慎的读写设计更稳妥。没有银弹只有适不适合。MVCC原本是为了让数据库在高并发下依然保持优雅但用不好它也会反噬你。从隐藏列到版本链从ReadView到隔离级别的差异每一层机制都在回答同一个问题这个数据行现在这个读者应该看到哪个版本搞懂这个问题你在数据库并发这条路上的进阶就算真正迈过了门槛。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑