一说到MySQL的并发控制绕不开的就是MVCC这个词。我早年刚接触数据库的时候对“多版本并发控制”这几个字完全无感觉得它就是一堆概念堆出来的名词。直到有一次线上业务出现严重的读写锁竞争某核心表的写入请求把读请求全堵住了我才真正静下心把InnoDB的MVCC机制从头到尾啃了一遍也才明白为什么那么多团队在高并发场景下首选MySQL作为存储底座。这篇内容我不会去写教科书式的概念复述而是按我自己的理解脉络把MVCC的核心组件、工作原理、隔离级别下的表现以及我在实际排查问题中踩过的坑完整地过一遍。适合正准备深入理解InnoDB事务机制的开发者也适合面试前想把这部分知识彻底理顺的朋友。1. MVCC机制的设计思路数据库并发控制的破解之法1.1 数据库并发面临的核心矛盾先把问题摆出来。数据库要支持多个事务同时读写数据就得保证事务的隔离性否则就会出现脏读、不可重复读、幻读这些问题。最朴素的做法就是加锁读之前加共享锁写之前加排他锁读写互斥大家老老实实排队。这种做法能保证一致性但并发性能非常差。因为真实业务场景里读请求往往占绝大多数写请求只占一小部分读写互斥意味着读操作时刻可能被写操作阻塞系统的吞吐量根本起不来。MVCC解决的就是这个矛盾。它的核心思想是读操作不去读正在被修改的数据而是去读这个数据在某个历史时刻的快照版本。写操作修改数据时不直接覆盖旧数据而是生成一个新版本。这样读和写各看各的版本互不干扰读不需要等待写释放锁写也不需要等待读完成。我把这理解为数据库世界里的“时间旅行”——每个事务都能看到自己这个时间点上合法的数据状态而不必关心其他事务此时在做什么。1.2 版本链与快照的思维模型要理解MVCC脑子里首先要建立一个版本链的模型。你可以把每一行数据想象成一个文件柜里的文件夹文件夹里不是只有一张纸而是一叠纸每一张纸记录着这行数据在一个历史时刻的内容。每次UPDATE操作不是把旧的纸撕掉重写而是在最上面放一张新纸记录新值同时写一个指针指向上一次的旧纸。这叠纸按时间顺序从新到旧串起来就是数据的版本链。每个版本都记得自己是被哪个事务生成的也记得前一个版本是谁。当一个读操作发起时它要判断在这个时间点我能看见哪些版本不能看见哪些版本。眼睛一闭把不该看的版本过滤掉剩下的那个版本就是我应该读到的数据。这个过程在InnoDB里做得很精巧而且性能损耗极低靠的是一套设计非常克制的元数据结构和一次短暂的可见性判断。1.3 为什么InnoDB能靠这套机制撑起高并发我最初对MVCC的误解是读历史版本是不是要去翻磁盘上的旧日志那性能不就更差了吗后来看清楚了InnoDB的实现才明白它根本不需要去“翻日志”构造历史版本。每一行数据在内存中是完整的同一行数据的不同版本其实是通过Undo Log串联起来的而当前最新的版本就直接存在于聚簇索引的记录里。读快照的时候读到的不是最新版本就在版本链里顺着回滚指针往前找找到符合可见性判断的那个版本为止。更关键的一点是MVCC让普通的SELECT查询完全不需要加锁直接读快照。只有在执行当前读比如SELECT ... FOR UPDATE、UPDATE、DELETE的时候才需要走加锁逻辑。所以在典型的读多写少业务中大量的读请求是无锁运行的并发能力自然就上去了。我自己在调优线上数据库时最直观的感受就是开启InnoDB默认的REPEATABLE READ隔离级别配合MVCC后最核心的读写互斥问题基本消失了剩下的并发冲突集中在写写之间这才是数据库本来的并发压力所在。2. 解开MVCC的三把钥匙隐藏字段、Undo Log与ReadView2.1 聚簇索引里的三个隐藏字段MVCC判断数据版本可见性靠的是每行记录上额外保存的隐藏字段。InnoDB在聚簇索引的每条记录中维护了三个重要字段DB_TRX_ID、DB_ROLL_PTR、DB_ROW_ID。DB_TRX_ID长度6字节记录的是最近一次修改插入或更新这条记录的事务ID。事务ID在MySQL里是按事务创建顺序递增分配的所以比较两个事务ID的大小就能知道事务的先后关系。DB_ROLL_PTR长度7字节是指向Undo Log记录的回滚指针。它指向的是这条记录上一个版本在Undo Log中的位置。沿着这个指针往前走就能找到旧版本数据。DB_ROW_ID长度6字节是行ID。如果表没有定义主键InnoDB会隐式地用一个自增的行ID作为聚簇索引的键这个字段主要服务于聚簇索引的构建。这三个字段加起来大约是20字节。这意味着即使你表里的真实数据每一行只有几十字节InnoDB也会额外消耗一部分存储空间来维护版本信息。这一点在大表设计时要心里有数我见过很多人在估算表大小时漏掉了这部分开销结果磁盘规划出了问题。2.2 Undo Log里到底记录了什么东西Undo Log是MVCC版本链的物理载体。每当事务要修改一行数据时InnoDB先把旧值写入Undo Log然后再修改当前记录并让DB_ROLL_PTR指向这条Undo Log。这样多个版本的记录就通过回滚指针连接成了一条链。这里要注意Undo Log记录的不是整行数据的物理备份而是一个逻辑上的反操作。INSERT对应一条DELETE类型的Undo记录UPDATE对应一条记录旧值的Undo记录。这样设计的好处是节约空间坏处是读取历史版本时需要做一定的逻辑处理才能还原出完整旧行。InnoDB在判断可见性时是不需要频繁做这个还原动作的只有当确定某个版本对当前事务可见才会根据Undo Log去构造完整的行数据。版本链的增长跟更新频率直接相关。一个被高频UPDATE的行它的版本链可以拉得很长。每次快照读如果都从最新版本开始往旧版本方向遍历链条越长定位可见版本的成本就越高。这也是我在后面讲长事务问题时需要重点展开的内容。2.3 ReadView事务的“透视镜”光有版本链还不够每个事务在读取数据时需要一个规则来判断哪个版本对自己可见。这个规则就是ReadView也就是一致性视图。ReadView在创建时会记录四个关键信息当前活跃的读写事务ID列表m_ids列表中最小的那个事务ID min_trx_id整个系统已分配过的最大事务ID加1称为max_trx_id以及创建这个ReadView的事务自己的ID creator_trx_id。可见性判断的逻辑我用一段伪代码来说明当遍历版本链时对每个版本的DB_TRX_ID进行判断 如果 DB_TRX_ID creator_trx_id 说明这个版本是当前事务自己修改的可见。 如果 DB_TRX_ID min_trx_id 说明这个版本在ReadView创建之前就已经提交了可见。 如果 DB_TRX_ID max_trx_id 说明这个版本是在ReadView创建之后才启动的事务生成的不可见。 如果 min_trx_id DB_TRX_ID max_trx_id 判断 DB_TRX_ID 是否在 m_ids 活跃事务列表中 在列表中说明该事务尚未提交不可见。 不在列表中说明该事务已提交可见。遍历的起点是当前最新版本如果最新版本不可见就顺着DB_ROLL_PTR找上一个版本直到找到一个可见的版本为止。如果整个版本链都不满足就说明这行数据对当前事务来说还不存在。这个判断逻辑我建议你记在自己的笔记本上几乎我遇到的每个数据库面试官都会就这套规则往深里问什么时候用小于、什么时候用大于等于、活跃事务列表怎么维护。把这四条规则吃透MVCC一半的知识就打通了。3. 不同隔离级别下MVCC的具体表现3.1 快照读与当前读的分工InnoDB的读操作分成两类。快照读就是我前面讲的普通SELECT查询直接基于ReadView读取可见版本全程不加锁。当前读则要读取数据的最新版本并且对读到的记录加锁防止其他事务并发修改。常见的当前读包括SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE以及UPDATE和DELETE操作内部隐含的读取。这两类读在隔离级别下的表现差异很大。MVCC主要影响的是快照读而当前读走的是锁机制。理解这个区分很重要因为很多人在讨论“RR级别下能不能避免幻读”时争论不休实际上是把两类读混在一起讨论了。快照读在RR级别下确实能通过MVCC避免幻读但当前读要防止幻读还得靠间隙锁配合。3.2 READ COMMITTED语句级快照在READ COMMITTED读已提交级别下每次执行快照读查询的瞬间InnoDB都会新建一个ReadView。也就是说一个事务里先后执行两条SELECT中间只要有其他事务提交了新数据第二条SELECT就能看到这条新数据。我用一个典型场景来说。假设表里有一行记录id1value为100。事务A开启后先做一次SELECT此时ReadView生成读到value100。事务B执行UPDATE把value改成200并提交。随后事务A在同一事务里再做一次SELECT因为这是新的一条语句InnoDB会重新生成ReadViewB的提交事务已经不在活跃列表里了所以A能看到200。这就是读已提交的语义每次读都能看到最新已提交的数据但这也意味着同一个事务里两次相同的SELECT可能读到不同的结果也就是所谓的不可重复读。在实际业务里如果你的事务需要保持对某一数据的前后一致视角就不要把隔离级别设为RC否则只能在应用层做额外处理。3.3 REPEATABLE READ事务级快照REPEATABLE READ可重复读是InnoDB的默认隔离级别。在这个级别下同一个事务里第一次执行快照读时创建的ReadView会被一直复用后面的所有快照读都使用这同一个ReadView不再重新生成。这是很大的一点差别。继续用上面那个例子事务A开启后第一次SELECT生成ReadView执行时发现事务B还在活跃于是读到value100。事务B随后修改为200并提交。事务A再次SELECT时由于ReadView还是刚开始那个活跃事务列表仍然包含着事务B至少那个时刻的事务B在列表中所以判断来看仍然认为B未提交继续读到100。这就实现了可重复读。MVCC在RR级别下能有效解决快照读的幻读问题。第一次查询时生成的ReadView固定下来后续即使其他事务插入了新行这些新行的事务ID大于ReadView的max_trx_id或者仍处于活跃状态遍历版本链都不可见于是当前事务看起来数据集合始终不变。但要特别注意如果业务中混杂了当前读操作SELECT ... FOR UPDATE还是会读到其他事务刚插入的数据这时就需要InnoDB的间隙锁来限制插入行为。这也是MySQL官方文档里说RR级别通过MVCC加间隙锁来防止幻读的根本原因。3.4 两个级别之间的选型权衡读完这套机制就知道同一套底层结构只通过ReadView创建时机的调整就让两种隔离级别产生不同语义这是非常优雅的设计。实际选型时我的经验是系统默认用RR没问题但并不是说任何业务都必须锁死RR。有些明确知道自身读取偏好的业务比如报表系统通常只需要读到已提交的历史数据用RC反而可以减少间隙锁带来的锁竞争开销。不过RC也有自己的问题比如binlog格式需要调整为ROW格式才能保证主从一致性这一点在实施时要考虑清楚。4. 线上围绕MVCC踩过的坑与问题排查4.1 长事务引发的版本链恶性膨胀MVCC最常让人栽跟头的地方就是长事务。事务迟迟不提交它创建的ReadView就会一直存活也就意味着ReadView创建之前所有已提交版本都不能被回收。Undo Log里那些旧版本数据必须一直保留因为还有事务可能需要读到它们。我遇到过一台MySQL实例某个统计业务开启了一个只读事务在应用层却忘记提交连接导致事务一直挂着。原本几十分钟就能完成的purge线程回收Undo Log的动作因为存在这个长事务几乎陷入停滞。Undo Log不断膨胀最终占用了数十GB的磁盘空间而且数据库的整体性能明显下降因为版本链过长普通读取也需要遍历好几个版本才能找到可见的那一个。排查方法也比较直接。打开information_schema库里的INNODB_TRX表可以看到当前所有未结束的事务及其开始时间、状态、操作的线程ID。如果发现某个事务运行时间远超正常业务耗时就应该马上警觉看看是不是代码里漏了commit或者是连接池把事务状态带到了下个请求上。4.2 快照太旧导致的数据统计偏差另一个让我印象深刻的坑是依赖MVCC做统计查询时因为快照读的特性读到的数据可能不是当前最新状态。这不算Bug但业务方如果不理解很容易闹出“数据怎么没了”的误判。有一次某运营活动凌晨跑定时统计任务统计结果出来之后白天的实时查询页面显示的却是完全不同的数据。排查发现统计任务的连接池里某个连接被复用而连接的事务隔离级别被改成了RR事务没有显式提交导致后续的查询全部复用了一个极其陈旧的ReadView看到的数据还是几天前的。因为连接池里这个连接非常稳定所以每次统计都走同一条老路径。后来我们在代码规范里加了一条统计类查询尽量使用RC隔离级别或者只读事务必须显式快速提交不让快照被长时间复用。这个调整之后类似的诡异问题再没出现过。4.3 RR级别下间隙锁引发的死锁很多人都知道死锁排查要去看INNODB_TRX和INNODB_LOCK_WAITS表但不知道MVCC在RR级别下死锁有一个主战场——间隙锁。RR级别下执行当前读时InnoDB为了阻止幻读除了锁定命中的记录行还可能在记录前后的间隙上添加间隙锁。两个事务各自在相邻范围上加了间隙锁再去插入或修改对方范围内的数据就可能互相等待形成死锁。比如事务A锁住了id范围为(10,20)的间隙事务B锁住了id范围为(20,30)的间隙A想插入id25的记录B想插入id15的记录两边同时操作死锁就出现了。遇到这种情况优先看MySQL返回的错误码1213死锁对应的日志里面会列出参与死锁的两个事务分别持有和等待哪些锁。常规解法是检查业务是否能接受降低到RC隔离级别来消除间隙锁不能的话就规范更新操作顺序尽量按固定顺序访问行数据同时给高频更新操作涉及的字段建立合适的索引缩小锁的范围。4.4 常见问题排查速查表根据我的经验把MVCC相关的线上问题整理成一张表格排查时按图索骥会快很多现象可能原因定位手段解决方向磁盘空间暴涨Undo文件持续增大存在长事务ReadView未释放查询INNODB_TRX找长事务提交或终止长事务优化业务逻辑查询突然变慢越是老数据越慢版本链过长遍历成本高看事务运行时长、更新频率控制事务长度拆分热点行更新同一事务两次查询结果不同隔离级别为RC每次新建ReadView查看事务隔离级别配置改用RR级别或应用层锁定并发插入频繁死锁RR下间隙锁冲突查看死锁日志调整隔离级别或锁顺序业务代码连着连着数据“消失”连接池复用未提交事务检查连接归还前的事务状态强制清空事务状态规范事务边界5. 围绕MVCC的一些调优心得与监控建议5.1 热点行更新对版本链的影响MVCC虽然解决了读写互斥但版本链的维护成本在热点行更新场景下不可忽视。如果一个订单表里某些行被极其频繁地更新这些行对应的Undo Log版本链会非常深。每个读操作要顺着版本链遍历定位到正确的历史版本这个过程的CPU开销会逐渐累积。我在维护某订单系统时就观察过这个现象核心订单表某几行的版本链动辄几十个版本同表其他行的链只有两三个版本前者的平均查询耗时明显高于后者。后来通过业务上对更新频率做削峰、引入异步批量更新手段缓解了这个问题。如果你的业务里同一行数据每秒要被更新十几次甚至几十次就要考虑设计上有没有办法拆分热点。5.2 Undo Log相关参数与purge线程调优MySQL提供了不少参数来管理Undo Log的存储和回收。历史版本总是要清理的这个清理动作由purge线程完成。当系统并发大、更新频繁时如果purge线程的清理速度跟不上Undo Log的生成速度Undo文件就会持续膨胀磁盘占用不断增加。常见的调整手段包括增加innodb_purge_threads数量让更多线程参与历史版本清理合理规划innodb_undo_tablespaces把Undo Log分散到多个表空间文件避免单个文件文件过大。还有一个容易被忽略的参数是innodb_max_purge_lag它的作用是限制purge线程落后时的新事务写入速率避免Undo Log无限增长。不过这个参数要慎用设置不当会影响写入性能我通常只在紧急场景临时打开事后及时恢复。5.3 监控指标与日常巡检建议监控MVCC相关的指标关键看几个方向INNODB_TRX表里活跃事务的数量和持续时间超过阈值就要告警Undo Log文件的大小增长趋势增长速率异常说明历史版本清理受阻information_schema里INNODB_METRICS中的trx_active_transactions、undo_log_truncate_count等指标能反映事务并发和Undo清理的情况。我日常巡检的SQL大致是这样用的-- 查看当前活跃事务 SELECT trx_id, trx_state, trx_started, trx_rows_modified FROM information_schema.innodb_trx WHERE trx_started NOW() - INTERVAL 30 SECOND; -- 查看Undo文件状态 SELECT TABLESPACE_NAME, FILE_NAME, FREE_EXTENTS, TOTAL_EXTENTS FROM information_schema.FILES WHERE TABLESPACE_NAME LIKE %undo%;这些语句不复杂但能快速定位大部分MVCC相关的隐患。我建议把这套巡检做成定时任务比出了问题再人工排查要省心得多。6. 从MVCC延伸出的思考与个人体会我对MVCC的感受是它不是单独一个功能点而是整个InnoDB并发控制体系的基石。理解MVCC等于同时理解了事务隔离级别、锁机制、Undo Log、purge线程这些外围组件。很多面试题看起来在问MVCC实际考察的是你对整个数据库事务体系有没有一个完整的认知框架。实际项目中处理过几次和MVCC相关的疑难杂症之后我的体会是MVCC本身工作得非常可靠出问题的地方几乎都在外围事务边界管理不当、连接池使用不规范、隔离级别配置不匹配业务场景、长事务无人治理。代码里每一处没有及时提交的事务、每一个被复用的数据库连接在MVCC的世界里都有可能变成一个看不见的版本黑洞慢慢拖垮实例的性能。所以在设计数据库访问层的时候我会格外强调几条实践业务代码必须写清楚事务边界try-finally里保证commit或rollback连接池归还连接前一定要清理事务状态尽量缩短事务运行时间不在事务里执行外部接口调用对只读统计类查询单独设置隔离级别。最后分享一个小的诊断技巧当线上数据库出现同样的慢查询但时快时慢时不要只盯着执行计划看先查一下活跃事务列表看看是不是某个长事务拖住了历史版本清理。我遇到过好几次看似复杂的性能抖动最后原因都出在一个开着事务忘了关的应用程序连接上。数据库报错的日志不会告诉你这里埋着版本链问题你只能靠自己对MVCC运行机制的理解去推断排查方向。把这套机制吃透解决起这类问题来会顺手很多。
阅读完成 · 觉得有帮助?