聊聊MySQL的MVCC为什么高并发下读不卡写答案都藏在这套机制里做后端开发的人几乎都绕不开MySQL的并发控制这个话题。面试被问、线上排查被坑、优化SQL时也经常要回头翻翻原理。有人把MVCC背得滚瓜烂熟但真遇到“为什么这个事务读到的是旧数据”“为什么RR级别下幻读没彻底消失”这类问题又讲不清楚。我尽量用大白话把MVCC这套机制从头到尾捋一遍把每条版本链怎么产生、ReadView怎么判断可见性、不同隔离级别下行为为何不同都落到具体的执行细节上。这篇文章适合刚接触事务隔离级别的新手也适合写SQL多年但没系统整理过InnoDB原理的人。看完你可以直接拿这些知识去解释线上现象也能在下次技术分享时把来龙去脉讲透。MySQL的InnoDB引擎用MVCC解决了一个核心矛盾读操作不想加锁又必须保证读到一致的数据快照。如果读也加锁、写也加锁并发量一大系统就直接卡死。MVCC的思路不是去锁住数据而是给每一行数据维护多个历史版本让读操作按需读取某个时间点的快照。这个思想很像源码管理里的分支——你拉了一个分支后面别人怎么提交你的工作副本都不受影响。但光有版本还不够还得有办法判断一个版本对于当前事务到底是否可见。这就引出了隐藏字段、undo log、ReadView的联动配合。下面从设计思路开始一层层拆开看。1. 先搞清楚MVCC到底解决了什么问题1.1 并发的核心矛盾读与写的互相阻塞假设一个订单表两个事务同时对同一条记录操作。事务A要把库存从100改成80事务B此时要读取这条库存做展示。如果没有任何并发控制机制B可能读到一半的脏数据读到库存是100还是80完全取决于A是否已经提交。数据库必须保证读操作不能读到未提交的中间状态这是事务隔离的基本底线。传统方案是加锁。写事务锁住记录读事务也锁住记录互斥等待。这样一致性没问题但性能会非常差。一个简单的查询都要等写锁释放高并发场景下基本没法用。MVCC选择了另一个方向读操作不等待写操作写操作也不阻塞读操作。InnoDB通过快照让读请求访问特定时间点的数据版本这才能在互联网业务的高并发下撑住局面。有人会问MySQL的默认隔离级别是Repeatable Read可重复读它能做到在一个事务里多次读取同一数据结果一致靠的就是MVCC。每次SELECT看到的都是事务开始时的快照而不是当前最新值。这个特性对报表统计、订单核对这类需求非常关键——你不想在一次统计过程中另一笔并发交易的提交导致前后两次SUM结果不一致。1.2 MVCC不是一种具体技术而是一种设计思路MVCC全称Multi-Version Concurrency Control也就是多版本并发控制。不同数据库的实现细节各不相同但核心套路是相同的为数据行保留多个版本读写操作通过版本可见性判断来决定使用哪个版本。PostgreSQL的MVCC把旧版本放在堆表里MySQL的InnoDB则把旧版本放在undo log中两者各有取舍。了解MVCC的设计思路时我建议你抓住三个关键词版本、可见性、快照。版本靠隐藏列和undo log构建可见性靠ReadView判断快照是事务视角下的一套完整的、一致的视图。这几个概念串起来之后MVCC就不再是抽象名词而是一条清晰的数据流转链路。1.3 为什么并发读撑不住时你该想到MVCC还有一类场景也适合从MVCC角度理解大量长事务导致undo log膨胀。如果你有个事务打开很久不提交同时又不断有别的连接在更新同一批数据InnoDB需要保留足够的undo log让这个长事务能读到老快照。一旦事务一直不结束这些历史版本就不能被purge磁盘占用持续上涨。这个问题的根因就在MVCC的版本保留机制上理解了原理你就知道线上为什么强调“事务要短小精悍”。2. MVCC的底层支撑隐藏字段、undo log和版本链2.1 每行记录背后的三个隐藏字段InnoDB对表中每行记录额外维护了三个隐藏列平时SELECT不会显示但它们承担着版本追踪的职责。DB_TRX_ID最近一次更新该行的事务ID。事务ID是InnoDB按启动顺序分配的、严格递增的编号。DB_ROLL_PTR回滚指针指向该行的undo log记录通过这个指针可以把各个历史版本串成一条链表。DB_ROW_ID如果没有指定主键InnoDB会生成一个隐藏主键这个字段用于标识行。这三个字段中前两个是MVCC的核心。当你UPDATE某一行时InnoDB不会直接在原记录上覆盖旧值而是做一次“变更记录”先把当前行的旧值写入undo log。在当前行上更新DB_TRX_ID为新事务ID。更新DB_ROLL_PTR让它指向刚才写入的undo log。当前行的数据变成新值。此时如果另一个事务来读取沿着DB_ROLL_PTR找到的旧版本就是更新前的数据。版本链的形态大致如下最新版本(trx_id102, value80) ↑ DB_ROLL_PTR undo版本(trx_id100, value100) ↑ DB_ROLL_PTR undo版本(trx_id95, value120)这就像是一个单向链表链头是最新值链尾是最早的历史版本。版本链越长说明这行数据被更新越频繁。每次读操作遍历版本链时都靠ReadView提供的可见性规则选择第一个满足条件的版本。2.2 undo log的类型和生命周期InnoDB的undo log分为两部分insert undo log和update undo log。前者在INSERT插入新行时生成主要用于事务回滚后者在UPDATE或DELETE时生成既用于回滚也用于MVCC版本读取。DELETE在InnoDB中比较特殊它并不是物理删除而是给记录打上删除标记同时写入一条update类undo log。至于什么时候真正从索引中移除要等purge线程判断没有任何事务需要访问这个老版本后才能进行。这解释了一个常见的疑问为什么大量UPDATE和DELETE的表数据文件不缩小因为旧版本还在undo log里占着空间。只有当所有可能看到旧版本的事务结束后这些数据才会被清理。要是系统里有几个长时间不提交的事务它们就像锚点一样把undo log钉在那里磁盘空间只增不减。下面用一个简单的流程图说明UPDATE操作的版本链构建过程事务AUPDATE t SET namea2 WHERE id1; ① 锁定记录 ② 将当前行的旧值(namea1)写入undo log ③ 更新记录行的DB_TRX_ID101 ④ 更新记录行的DB_ROLL_PTR指向undo log ⑤ 完成如果事务A尚未提交此时事务B发起SELECT它会发现最新版本的DB_TRX_ID101对B不可见B的快照时间点早于101于是沿着ROLL_PTR找到旧版本DB_TRX_ID99可见从而读取到namea1。读操作完全不需要等待事务A提交或回滚并发度由此提升。2.3 版本链为什么能保证一致性读不产生脏读一致性读也就是普通SELECT它的目标就是“读到的数据在逻辑上是一致的不包含正在提交或未提交的数据”。MVCC通过版本链和ReadView的组合来保证这一点。当一行数据被多个事务连续更新后版本链上每个版本都忠实记录了事务ID。当前事务只要用ReadView判断这些事务ID哪个可见、哪个不可见就能找到最合适的版本。这里有个细节值得注意普通SELECT在MVCC下是“无锁读取”它不阻塞别人同时修改这条记录也不需要等待别人释放锁。因此读多写少的业务才能跑得这么顺畅。我现在甚至可以说没有MVCCMySQL根本支撑不了互联网高并发事务场景——所有读都要排锁的话整个系统会像单车道一样堵死。3. ReadView确定版本可见性的核心规则3.1 ReadView里装了什么ReadView是事务进行快照读时生成的一个视图InnoDB用它来判定版本链上哪个版本对当前事务可见。它的四个关键属性在源码层面分别对应m_low_limit_id、m_up_limit_id、m_creator_trx_id、m_ids。从逻辑上看可以这样理解ReadView的成员trx_ids创建ReadView时当前系统中活跃未提交事务的ID列表。low_limit_id创建ReadView时系统已分配的最大事务ID加1。大于等于这个值的事务ID一定是在ReadView之后才启动的对当前事务不可见。up_limit_idtrx_ids中最小的那个事务ID。小于这个值的事务ID大概率是已经提交的对当前事务可见。creator_trx_id创建当前ReadView的事务ID。每次普通SELECT执行时InnoDB都会根据当前系统状态生成或复用ReadView具体有两种模式。在READ COMMITTED隔离级别下每条SELECT语句都会重新生成一个新的ReadView因此这个事务的两次查询可能看到不同的结果。在REPEATABLE READ级别下事务内第一次SELECT时生成ReadView后续所有SELECT都复用同一个所以多次读取结果保持一致。如果不好理解可以把ReadView理解成一张“当时正在执行的转账清单”。你在某个时刻去看账户余额系统给你拍了一张快照后来别人转账了也不会影响你已经拿到手的快照。RR级别把这张快照用到事务结束RC级别则每次读都重新拍一张。3.2 可见性判断的五条规则有了ReadView之后判断一个版本是否可见就变成了机械步骤逻辑如下如果版本的事务ID等于creator_trx_id说明是当前事务自己修改的数据可见。如果版本的事务ID小于up_limit_id说明该事务在当前ReadView生成之前就已经提交可见。如果版本的事务ID大于等于low_limit_id说明该事务在ReadView生成之后才启动不可见。如果版本的事务ID在up_limit_id和low_limit_id之间就需要查看它是否在trx_ids活跃事务列表中。不在列表中说明已经提交可见在列表中说明尚未提交不可见。遍历版本链时从最新版本开始逐个判断一旦找到一个可见版本就返回不再继续向前找。为什么RR级别下有时还能看到“自己的修改”因为规则1直接把自己修改的版本判定为可见。这也是很多开发者在长事务里发现“别人看不到我改的数据但我自己能马上看到”的原因。自己写的数据在任何版本判读下都优先可见。3.3 通过一个具体案例看懂ReadView判断过程假设有事务101、102、103、104依次启动事务101UPDATE t SET moneymoney100 WHERE id1; -- 未提交 事务102UPDATE t SET moneymoney50 WHERE id1; -- 未提交 事务103SELECT * FROM t WHERE id1; -- 当前事务事务103创建ReadView时活跃事务列表trx_ids是[101, 102]up_limit_id101low_limit_id105假设105是系统中下一个待分配的事务ID。此时id1的记录版本链是最新版本trx_id102, money250 中间版本trx_id101, money200 最旧版本trx_id88, money150事务103的读取过程如下判断最新版本trx_id102。它不是103自己102不小于up_limit_id(101)也不小于low_limit_id(105)它在trx_ids列表中所以不可见。沿着版本链找到trx_id101。同样在trx_ids列表中不可见。继续找到trx_id88。88小于up_limit_id(101)而且不在trx_ids中说明早已提交可见。于是事务103读到的是money150即101和102都没有提交当前快照依然是最早提交的那个版本。如果过一会儿事务101提交了事务102也提交了事务103再查询时由于RR级别复用同一份ReadView读到的还是150。而换成RC级别每次SELECT都会新建ReadView第二次查询就能读到最新的250。这里最容易被绕晕的就是“小于low_limit_id但是大于up_limit_id”的事务ID怎么判断。简单记它是否在活跃列表里活跃就是没提交没提交就不可见不在活跃列表里说明提交已经完成可见。3.4 为什么RC级别下每次SELECT的结果可能不同RC级别事务中的数据读取是语句级别的快照。比如一个财务系统的汇总查询里第一条SELECT统计了未付款订单金额第二条SELECT统计已付款订单金额这两条语句之间如果有一个并发事务提交了新的付款单那么第二次统计的结果就会包含它。这在RC下是允许的它把隔离性放低了一些换取了更少的历史版本堆积。实际业务中如果你用RC级别做账务统计建议把所有统计逻辑放到一条SQL里或者干脆升级到RR级别以免结果“对不上”。这也是我在实际踩坑后得到的经验。4. 一致性读与锁定读MVCC只负责不锁的那一路4.1 普通SELECT走快照读UPDATE/DELETE走当前读MVCC覆盖的是普通SELECT也就是快照读。它不关心当前最新值只需要从版本链中找到符合快照条件的版本。而UPDATE、DELETE以及SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE走的是当前读必须读到数据的最新版本然后对数据加锁。为什么要区分这两种读因为写操作必须基于最新值进行修改如果基于旧快照去更新就会造成更新丢失。比如事务A读到库存为10事务B读到库存也为10两个都试图改成9。如果都基于快照更新库存最终是9但两个事务都认为自己成功扣减了一次——这显然不对。当前读加锁保证了只有一个事务能基于最新值完成修改另一个必须排队等待。这个设计也解释了为什么高并发下单场景中如果只有普通SELECT经常出现超卖。解决办法很简单把“扣减库存”改成SELECT ... FOR UPDATE或者直接UPDATE语句让判断库存和扣减库存成为一个加锁的当前读操作。4.2 当前读如何和版本链配合当前读不会遍历版本链找可见版本而是直接读取记录最新版本。如果最新版本上存在未提交事务的写锁当前读就会被阻塞进入等待状态。InnoDB的锁等待有超时时间默认是50秒超时后报锁等待超时错误。线上如果出现“Lock wait timeout exceeded”大概率是某个事务长时间持锁未提交。从MVCC角度看当前读和快照读互为补充写操作之间靠锁隔离写操作与读操作之间靠MVCC隔离。这个组合既保证了数据一致性也照顾了并发性能。4.3 快照读在RR级别下依然能看到最新数据的情况很多人会问RR级别下我开了事务只做SELECT然后别人提交了新数据为什么我可以用FOR UPDATE读到因为你用了当前读。FOR UPDATE强制读取最新版本它会绕过ReadView的版本可见性判断。这也是MVCC设计上一个重要的边界快照读和当前读在同一个事务中混用可能引起理解上的混乱。比如先用普通SELECT读到了旧值再用FOR UPDATE读到了新值同一事务内两次结果不一致。这不算bug但使用时一定要清楚自己用的是哪条路径。5. 隔离级别与MVCCRC和RR的关键差异都在ReadView5.1 READ COMMITTED语句级快照每次SELECT重新生成ReadViewRC隔离级别下MVCC的可见性以语句为单位。每执行一条普通SELECT都会生成一个新的ReadView基于当前最新系统事务状态来做版本判断。这带来了两个直接后果同一个事务中的不同SELECT可能读到不同的已提交数据。不需要为整个事务保留完整的旧版本历史版本可以被更快地清理。在RC级别不可重复读是允许的。如果你依赖“同事务多次读取相同结果”RC就不合适。具体业务中RC级别通常用于对一致性要求不苛刻、但并发量很大的场景。很多系统默认使用RC以减小锁冲突和长事务对undo log的拖累这也是某些公司在优化高并发库时常做的一个调整。5.2 REPEATABLE READ事务级快照ReadView复用RR级别下InnoDB在事务第一次执行快照读时生成ReadView之后复用同一份。这个ReadView里的trx_ids等属性不再变化所以后续查询永远按照同一个判断基线读取版本实现了可重复读。这个特性让RR成为了很多报表统计类业务的默认选择。第一次查询订单总金额时别人提交的新订单不会影响后续统计整个事务看到的是一份稳定的数据快照。要注意RR下有一个著名的幻读问题。MVCC能挡掉快照读层面的幻读——你两次SELECT同一范围的记录结果集相同。但如果你在事务中执行了当前读如UPDATE或SELECT ... FOR UPDATE或者做了INSERT操作那么就可能插入或看到之前快照中不存在的记录造成幻读。5.3 RR级别下如何彻底避免幻读InnoDB处理RR下幻读的方式不是靠MVCC而是靠间隙锁和next-key lock。RR级别不仅锁住命中的索引记录还会锁住索引记录之间的间隙防止其他事务往这个范围内插入新记录。这块逻辑属于锁机制范畴和MVCC刚好互补。实际排查中如果一个在高并发下出现重复插入可能是间隙锁没生效也可能是你的查询条件无法使用索引导致锁退化到全表扫描。理解MVCC只能帮你解释快照读现象真正要解决写冲突还得结合索引和锁来分析。5.4 不同隔离级别下MVCC表现对比从实操角度看RC和RR最直观的差异可以用一个场景来说明同一个事务里两次SUM查询RC下可能两次结果不一样RR下完全一致。这是因为RC每次查询都新建ReadView而RR复用旧视图。这个差异直接影响数据报表、对账逻辑和缓存更新策略做业务架构设计时一定要先确认你的数据库默认隔离级别是什么。6. 实操排查MVCC引发的三类典型问题6.1 问题一RR级别下修改数据后别的事务读不到新值这个现象常见于以下操作序列事务A读取数据生成报表事务B更新并提交数据事务A再次读取数据发现还是旧值。如果业务逻辑要求事务A必须感知到B的提交需要在A中重新开启一个事务或把读取改成当前读。这在消息处理、任务轮询类系统中很常见。我遇到过几次类似问题都是因为长连接中复用了同一个事务却希望读到最新状态。解决方式是调整业务事务边界或者在代码中显式提交并开启新事务而不是动隔离级别。6.2 问题二undo log膨胀导致磁盘迅速增长当你发现一个库的磁盘占用不断上升又没写入太多新数据多半是undo log清理不掉。排查步骤如下查看是否存在长时间未提交的事务。查看information_schema.innodb_trx表确认trx_start_time是否很久远。如果发现长事务及时提交或杀掉让purge线程能够清理历史版本。优化应用端事务边界避免在事务中执行远程API调用、大查询、批量循环等耗时操作。这个问题的根本原因是只要还有事务需要旧版本版本链和undo log就不能断。MVCC在提供隔离性的同时把历史数据清理的压力交给了事务生命周期的管理。所以长事务不只是业务逻辑问题还是存储引擎的资源控制问题。6.3 问题三历史版本堆积导致SQL变慢如果版本链太长快照读遍历undo log的次数就会增加。一条记录被更新了上百次而每次读都需要从最新版本一路回退性能损耗非常明显。这种情况下即使有索引查询速度也可能变慢。处理建议如下检查业务中是否存在大量重复UPDATE同一行的操作。用UPDATE ... RETURNING或批量合并写来减少版本数。如果确实历史数据不需要可以提前结束相关的旧事务加速purge。本质上这些问题的根因都在MVCC设计的一个交换读取不阻塞写入但代价是历史版本必须保留到不再有事务需要为止。所以监测长事务和undo log使用率应作为MySQL日常运维的常规动作。6.4 排查工具和常用SQL排查MVCC相关问题时我惯用的SQL包括-- 查看当前未提交事务 SELECT * FROM information_schema.innodb_trx\G -- 查看undo log相关状态MySQL 5.6 SHOW ENGINE INNODB STATUS\G -- 查看锁等待情况 SELECT * FROM sys.innodb_lock_waits;如果发现innoDB status中history list length持续暴涨说明旧版本堆积严重。此时重点检查长事务。我个人经验是只要应用层能把单个事务的执行时间控制在几十毫秒以内MVCC相关的问题就基本不会出现。大多数疑难杂症都是从“一个事务里处理了太多事情”开始的。7. MVCC的应用边界和设计取舍7.1 MVCC不能替代锁机制MVCC擅长处理读多写少的场景但不是万能钥匙。对于热点行并发修改比如库存秒杀、账户余额扣减MVCC并不能解决写冲突最终还是要靠锁和事务排队。这也是为什么很多秒杀系统最终会选择RedisLua或数据库行锁配合队列削峰而不是单纯依赖数据库快照读。一定要记住MVCC优化的是“读不阻塞写、写不阻塞读”但写与写、当前读与写之间的冲突还是要靠锁来仲裁。在架构设计时先判断业务冲突的频度再决定是走MVCC路径还是上锁。7.2 长事务的代价和防范长事务是MVCC最容易忽视的漏洞。它会让undo log持续累积、版本链变长、purge线程无法推进甚至导致一张明明很小的表写入性能越来越差。核心原因很简单InnoDB不能清理任何一个仍然可能被某个活跃事务读取的版本。作为开发规范我通常要求团队做到不在事务内执行外部接口调用或耗时IO操作。事务只包含必要的数据库操作。为一个事务设置执行时间监控和告警。定期扫描innodb_trx控制最长运行时间。很多MVCC问题看起来是存储引擎的事实际是应用层事务设计的问题。7.3 从MVCC视角理解InnoDB为什么选择RR做默认InnoDB把默认隔离级别设置为RR很多人以为只是传统习惯。其实RR配合MVCC加next-key lock在数据一致性上比RC更严格同时利用ReadView复用提高了长事务内快照的稳定性。这更贴合传统金融、报表等业务的需求。而RC级别虽然相比RR能减少锁冲突和undo积压但需要开发者在事务边界上格外谨慎。调隔离级别时建议先做一轮事务行为测试确认业务是否依赖同事务内多次读取一致确认写冲突比例确认undo膨胀风险。不要只凭性能高低选级别。8. 一次完整模拟用两个事务现场还原MVCC全流程8.1 场景设定和脚本这里用两个事务模拟一个最经典的读不阻塞写场景。-- 初始化数据 CREATE TABLE account ( id INT PRIMARY KEY, balance INT ); INSERT INTO account VALUES (1, 1000);事务A执行更新但不提交事务B执行普通SELECT看B能否读到旧值并且不被阻塞。8.2 执行序列和结果-- 事务A START TRANSACTION; UPDATE account SET balance balance - 100 WHERE id 1; -- 此时不提交 -- 另一个连接事务B START TRANSACTION; SELECT balance FROM account WHERE id 1; -- 结果1000事务B读到的是事务A修改前的值而且查询语句没有任何等待。这是因为B的ReadView把事务A标记为未提交状态所以沿着版本链找到了旧版本。如果把B的SELECT改成SELECT ... FOR UPDATEB就会被阻塞直到A提交或回滚。这个实验强烈建议亲自动手跑一次观察两个连接的行为差异。纸上谈兵很容易理解偏差实际看到阻塞等待和版本回退带来的结果印象才深刻。8.3 与标准SQL隔离级别语义的对应从MVCC角度看RC隔离级别每次SELECT创建新的ReadViewRR隔离级别复用第一次的ReadView。这直接对应了不可重复读与可重复读在底层实现上的差异。SQL标准只是定义了隔离级别该有什么行为MVCC则给了InnoDB一种具体的实现路径。理解这条路径你在日后看其他数据库的并发控制时也能更快抓住重点。9. 常见问题速查问题原因分析处理建议同一事务里两次SELECT结果不一致隔离级别是RC每次SELECT重建ReadView改用RR级别或把需要一致的查询合并自己更新后立即SELECT能看到新值版本判断规则中creator_trx_id可见正常现象无需处理DELETE后数据文件不缩小undo log和标记删除记录未清理检查长事务观察history list length快照读不阻塞但FOR UPDATE被阻塞当前读会加锁并读取最新版本从锁等待超时和事务生命周期排查查询性能随更新次数下降版本链过长遍历undo log开销变大缩短事务、减少单行高频更新、关注purgeRR下发生幻读当前读或INSERT绕过了MVCC快照用next-key lock或串行化隔离级别兜底这张表是我在实际工作中沉淀下来的排查指引不能覆盖所有极端情况但遇到问题先按这几条定位能找到大多数根因。很多时候不是MVCC本身出错了而是隔离级别语义和业务预期没有对齐。10. 个人经验理解MVCC要注意的三个坑第一不要只背ReadView判断规则而不动手模拟。数据库并发行为非常依赖现场状态事务ID的分配、活跃事务列表的生成时机都和环境相关。我自己最初理解时总觉得规则已经懂了但在项目里碰到具体案例还是分析错了版本链走向。后来干脆用两个终端开两个MySQL连接一步步执行SQL把事务状态打印出来对照才真正掌握了。第二把MVCC和锁机制分开理解再组合使用。很多人把“MVCC无锁读”误当成整个事务完全没有锁导致写冲突时一脸懵。MVCC负责读快照锁负责写互斥它们是两条并行逻辑最终一起保障事务隔离性。看问题的时候先判断走的是快照读还是当前读再决定用哪套理论解释。第三线上排查时优先看事务持续时间。几乎所有MVCC相关的性能问题追溯到最后都指向一个长事务。它让历史版本不能清理、undo膨胀、版本链过长、锁持有时间变长。养成定期监控最长事务的习惯比临时排查各种奇怪现象都有效。我把数据库连接池的超时时间、事务内耗时监控写成了基础监控项线上这类问题明显减少了很多。MVCC不是一个需要死记硬背的考点它是InnoDB为了并发与一致性的权衡做出的核心设计。当你下次再遇到“读旧数据”“undo log暴涨”这类现象时不妨先画出这条记录的版本链再问一句当前事务的ReadView边界在哪里答案自然就浮出来了。