首页 / 资讯中心 / 文章详情

MySQL MVCC底层原理与实战:从Read View到undo log全解析

MySQL MVCC底层原理与实战:从Read View到undo log全解析 ★ FEATURED ARTICLE
做MySQL开发这些年每次遇到并发更新、线上死锁或者间隙锁导致的锁等待最后绕不开的就是同一个概念MVCC多版本并发控制。很多同学面试的时候能把MVCC背得很溜隐藏字段、undo log、Read View一套一套的但真到了线上事务一直报锁等待超时或者某条慢查询把undo log撑爆了又完全不知道从哪下手。这篇文章我就把自己这些年对MVCC的理解重新梳理一遍从底层原理讲到实际操作尽量用大白话把这块讲透。MVCC说白了是InnoDB用来处理读写并发冲突的一套机制核心目标就一句话让读和写尽量互相不阻塞。传统思路是加锁你写的时候别人不能读读的时候别人不能写并发一高就堵成一锅粥。MVCC换个角度通过保留数据的历史版本让读操作去读旧版本快照写操作去写新版本两边各干各的互不打扰。这篇内容适合谁写过业务SQL、被死锁坑过、想搞懂隔离级别差异的开发者还有准备面试想系统梳理一遍的同学。只要你用过MySQL、写过事务、见过锁等待或者慢查询下面的内容应该对你有用。我尽量不堆术语但该讲的原理一个都不会少。1. 从一个经典并发场景说起MVCC到底解决了什么问题1.1 读写冲突是怎么产生的先看一个最典型的业务场景一个账户表某个用户余额是100现在有两个事务同时在操作。事务A要读余额然后加上10事务B要读余额然后减掉20。如果两个事务都是先读后写问题就来了如果完全没有控制可能出现两人都读到100然后各自写回110和80最后账户余额变成最后一次写入的值明明做了两笔操作有一笔白做了。这就是丢失更新。再换一种情况事务A要批量统计所有账户的总余额事务B同时在往账户表里插数据或者改数据。如果不做任何隔离A统计的过程中B把某条数据改了A前半段读到的是旧值后半段读到的是新值统计结果就不一致这叫不可重复读。如果B插入了几条新数据A统计出来的总行数前后对不上这叫幻读。这些问题在数据库里必须解决不然业务数据全是乱的。解决办法无非两大类要么靠锁让并发操作排队执行要么靠版本让每个操作看到的数据是某个时间点的快照。MVCC走的就是第二条路。1.2 传统加锁方案的代价如果完全靠锁来解决这些问题最直接的办法就是读写互斥任何人在读一条数据的时候其他人不能写任何人在写的时候其他人不能读。这个方案在理论上完全正确在现实里完全不可用。为什么因为实际业务中读操作占了绝大多数比如一个商品详情页每秒几千次查询但真正下单修改库存的只有几十次。如果让几千次查询都去跟几十次写入抢一把锁那么一个慢事务就能把整个系统的读流量全堵死。数据库业界对这个问题探索了很久。早期的很多数据库确实倾向于通过加锁实现隔离但带来的问题就是并发能力上不去。InnoDB的方案是在锁的基础上引入了多版本写操作不需要直接阻塞读操作读操作读的是写入之前的历史版本。这样锁的压力大大减轻只有真正需要强一致的场景才走加锁路线。1.3 MVCC的核心思路以版本换并发MVCC的全称是Multi-Version Concurrency Control翻译过来就是多版本并发控制。核心思路可以理解成数据库里一行数据不是只有一个值而是可能有多个历史版本并存。你更新一条记录的时候旧的值并不会立刻被删掉而是被保留下来作为历史版本新的值作为最新版本写入。读操作如果早于这次更新开始它就可以顺着版本链找到自己应该看到的那一版数据。这个思路用生活类比来解释最好懂就像一本共享文档每个人打开的时候会自动生成一个自己的快照副本。你在自己副本上编辑别人在他自己副本上编辑互不影响。最后谁先保存谁后保存系统有裁决机制。数据库的MVCC没有这么宽松但基本精神是一致的——每个人看到的都是一个一致性的版本。至于这个一致性版本怎么确定就得靠下面要讲的三大部件了。2. MVCC的三大支柱隐藏字段、undo log与版本链2.1 隐藏字段每行数据都带着“户口本”InnoDB的每行记录除了用户定义的字段外还偷偷维护了几个隐藏字段。这几个字段平时你看不到但它们才是MVCC运转的基础。第一个是DB_TRX_ID占用6字节记录最后一条修改这条记录的事务ID。事务ID是InnoDB按顺序分配的全局递增。第二个是DB_ROLL_PTR占用7字节也叫回滚指针它指向这条记录的undo log通过它可以找到上一个版本的数据。第三个是DB_ROW_ID占用6字节只有在表中没有定义主键的时候才会使用它是一个自增的行ID用来标识行。我们平时建表都会指定主键所以前两个字段是更核心的。你可以这么理解DB_TRX_ID告诉我们“这条记录是哪个事务改的”DB_ROLL_PTR告诉我们“改之前它长什么样”。有了这两个字段任何一行数据都能回溯到修改前的样子这就是版本链的起点。2.2 undo log与版本链旧版本是怎么串起来的undo log是回滚日志分为两种insert undo log只在事务回滚时需要事务提交之后就没用了update undo log则要承担MVCC的历史版本查询功能所以即使事务提交了只要还有别的事务可能用到这个历史版本它就不能立即被清理。每次执行UPDATE操作InnoDB并不是把旧值直接覆盖掉而是先写一条undo log记录修改前的旧值然后把数据页上的DB_TRX_ID改成当前事务IDDB_ROLL_PTR指向刚写的这条undo log。这样新版本在最上面旧版本在undo log里通过回滚指针一条一条串起来形成一个链表这就是版本链。版本链的最顶端是当前数据页上的最新值往下依次是各次更新前的旧值。链上有多少个节点取决于这条数据被更新过多少次以及这些历史版本是否还有事务需要引用。这个链条就是MVCC做可见性判断的素材库。2.3 Read View事务的“时间快照”光有版本链还不够还得有一个判断标准我这个事务到底应该看到版本链上的哪一版数据这个标准就是Read View翻译成中文叫读视图或者一致性视图。Read View是事务执行快照读操作时生成的一个数据结构里面记录了生成瞬间的一些关键信息。简化说主要包含四个部分一是当前系统里所有未提交事务的ID列表简称m_ids二是这个列表里最小的那个ID简称为min_trx_id三是系统当前已经分配过的最大事务ID加1这个值叫max_trx_id注意它不是当前最大的事务ID而是下一个将要分配的事务ID四是创建这个Read View的事务自己的ID叫creator_trx_id。这几个值怎么理解可以类比成你点了一份外卖下单时间就是Read View生成的时间。min_trx_id相当于在你下单之前就有的一个最小订单号max_trx_id相当于你下单之后才可能产生的下一个订单号。系统里哪些订单是在你下单前就存在且可能还没做完的哪些订单是肯定在你下单之后才会出现的一眼就能分清楚。2.4 可见性判断规则一条记录的“身世”审查有了Read View接下来就是对版本链上的每个版本做审查。判断一条记录是否对当前事务可见规则是这样的取出这条记录的DB_TRX_ID然后分情况讨论。如果DB_TRX_ID小于min_trx_id说明这条修改是在Read View生成之前就提交了的肯定是可见的。如果DB_TRX_ID大于等于max_trx_id说明这条修改是在Read View生成之后才开始的事务做的肯定不可见。如果DB_TRX_ID落在min_trx_id和max_trx_id之间就要看这个事务ID是否在m_ids列表里。如果不在列表里说明Read View生成的时候这个事务已经提交了可见。如果在列表里说明Read View生成的时候这个事务还没提交那就不可见。判断为不可见怎么办很简单顺着DB_ROLL_PTR指向的undo log往下一个版本走再用上面的规则重新判断。一直走到能看的版本为止。如果整条版本链都找不到一个可见的版本说明这条记录在当前事务的快照里还不存在那就是没查到。这个过程可以用一个简单的流程描述当前版本DB_TRX_ID min_trx_id - 可见 当前版本DB_TRX_ID max_trx_id - 不可见取链表下一版 min_trx_id DB_TRX_ID max_trx_id - 是否在活跃事务列表m_ids中 - 不在则可见 - 在则不可见取链表下一版这套规则就是MVCC所有行为的核心逻辑。后面讲快照读、当前读、隔离级别差异全都建立在它的基础上。3. 快照读与当前读两种读模式的底层差异3.1 快照读普通SELECT的“无锁”秘密很多初学者以为普通SELECT语句在InnoDB里也是加锁的这是一个很大的误解。实际上在默认的REPEATABLE READ隔离级别下普通SELECT走的是快照读本质上是无锁的一致性读。它执行的时候会生成或者复用Read View然后顺着版本链找到在这个视图下可见的版本直接返回数据。快照读是MVCC消费量最大的场景也正是因为快照读的存在在线业务里大量的查询操作可以完全不受写事务影响。比如一个商品详情页在秒杀期间库存一直在更新但用户看到的详情快照始终是一致的不会出现一会儿显示有货一会儿显示无货的情况。特别要注意的是快照读的“快照”在事务内部是持续有效的。在REPEATABLE READ隔离级别下同一个事务里的第一条普通SELECT会生成一个Read View之后这个事务里的所有普通SELECT都复用这个Read View也就是说你在同一个事务里多次查询看到的数据永远是第一次查询之前的状态。这就保证了不可重复读问题在快照读层面被解决了。3.2 当前读UPDATE、DELETE与加锁SELECT的真实行为快照读解决了大多数查询场景但有一个问题快照读读到的可能是旧数据不能用它来做更新依据。比如你想根据当前的最新库存来判断能不能减库存如果用快照读读到的是一个两秒前的旧库存判断就会出错。所以InnoDB引入了当前读。当前读的SQL包括UPDATE、DELETE以及显式加锁的SELECT比如SELECT ... FOR UPDATE和SELECT ... LOCK IN SHARE MODE。当前读读到的永远是最新版的记录也就是版本链顶端的那个版本而且在读的过程中会对记录加上相应的锁。UPDATE和DELETE会加排他锁FOR SHARE加共享锁FOR UPDATE加排他锁。当前读为什么必须加锁因为它要基于最新数据做修改如果两个事务同时读到最新数据然后再写就可能丢失更新。加锁保证了同一时间只有一个事务能对这条记录做当前读并修改其他事务要么等锁要么读到旧版本而已。3.3 从一次UPDATE看版本链的新增过程把快照读和当前读串起来看一次UPDATE的完整过程是这样的事务T1执行UPDATE时它先做当前读锁定目标记录并拿到最新的数据然后生成一条undo log把旧值连同旧的DB_TRX_ID一起记录下来接着更新数据页上的值把DB_TRX_ID改成T1的事务IDDB_ROLL_PTR指向新写的undo log。这时候如果有另一个事务T0在T1更新前就生成了Read View它再来查询这条记录会发现顶端的DB_TRX_ID是T1的而且T1可能还没提交于是顺着回滚指针找到undo log里的旧版本再判断旧版本的DB_TRX_ID是否可见。如果可见T0就读到了旧值。这个过程完美诠释了MVCC的设计哲学写操作只修改最新版本读操作按快照回溯版本两者之间不需要互相等待。在很多业务里这就是性能提升最核心的来源。4. 隔离级别与MVCC的联动RC和RR的差异4.1 Read View的创建时机两种隔离级别的关键区别MVCC在不同隔离级别下的行为差异最根本的就体现在Read View的创建时机上。这也是面试里最高频的一个考点。在READ COMMITTEDRC隔离级别下事务里每一条普通SELECT语句都会生成一个新的Read View。也就是说每次查询都重新获取当前所有活跃事务的最新快照。所以RC下一个事务里前后两次SELECT可能因为中间别的事务提交了而看到不同的数据这就是不可重复读在RC下的正常表现。在REPEATABLE READRR隔离级别下事务里第一条普通SELECT生成Read View之后整个事务内的所有普通SELECT都复用同一个Read View不重新生成。所以后续查询看到的数据始终和第一次查询一致快照读层面的不可重复读问题被直接消除了。这里有个容易混淆的点很多人以为RR下当前读也复用Read View其实不对。当前读每次都是读最新版本并加锁不受Read View控制。RR之所以能解决当前读场景下的幻读靠的是另一套机制——间隙锁这个下面单独说。4.2 为什么RR下快照读没有幻读而当前读还有幻读的定义是同一个事务里两次查询结果集的行数或者内容发生了变化。在RR隔离级别下快照读的Read View是固定的两次查询用的都是同一套可见性判断标准所以不管别的事务插入了多少新记录对当前事务来说那些新事务的ID都在可见性判断之外唯一的后果就是完全看不到。快照读层面的幻读自然就不存在了。但是当前读就不同了。UPDATE和SELECT ... FOR UPDATE每次都读最新的数据所以如果另一个事务插入了一条满足条件的新记录当前事务再做当前读时这条新记录就会出现在结果集里。如果没有额外的机制RR下当前读仍然可能幻读。那InnoDB怎么解决当前读的幻读靠的是间隙锁和next-key锁。当你在RR下执行一个有范围条件的当前读InnoDB不仅会锁住匹配的记录还会在记录之间的间隙以及记录前后的间隙上加锁阻止其他事务往这些间隙里插入记录。这样新的记录插不进来当前读的结果集就不会变。简单说MVCC负责快照读的隔离间隙锁负责当前读的隔离两者分工明确。4.3 间隙锁存在的意义MVCC管不到的那部分既然MVCC已经这么强大了为什么还要间隙锁因为MVCC只对快照读有效它解决的是“读旧版本”的问题。但在数据库事务里有大量的操作必须基于最新状态比如余额变更、库存扣减、防并发重复插入等。这些操作不能用快照必须上锁。间隙锁就是为这种场景兜底的。它锁的是记录之间的范围而不是具体的某条记录所以叫间隙锁。当它跟记录锁组合成next-key锁时既锁住记录本身又锁住记录前的间隙能有效防止其他事务在范围内插入新记录。代价就是并发度下降不同事务的插入操作可能互相阻塞。这块也是线上死锁的高发区。多个事务持有不同间隙的锁又在等待对方间隙的锁就形成循环等待。排查的时候经常能在SHOW ENGINE INNODB STATUS里看到复杂的锁等待链背后往往就是间隙锁和插入意向锁在互相较劲。5. 实战排查与避坑经验5.1 长事务为什么可怕undo log膨胀与历史版本堆积MVCC能让你读到历史版本但历史版本不是凭空存在的它们都存在undo log里。只要有事务的Read View还没释放版本链上那些旧版本就不能被清理。这个清理动作由后台的purge线程负责它能清理的只是“所有活跃事务都看不到”的旧版本。问题就出在长事务上。如果一个事务开启了但迟迟不提交它持有的Read View会让大量历史版本保持“存活”状态。此时业务还在持续更新数据版本链越拉越长undo log不断膨胀最终导致两个严重的后果一是undo log占用的存储空间暴涨甚至撑爆磁盘二是查询一条记录时要沿着版本链回溯很多个版本才能找到可见版本查询性能急剧下降。这就是我在线上实际遇到过的情况一个统计任务在凌晨跑了一个多小时事务一直没提交白天业务一更新undo log表空间直接涨了几十倍。所以监控长事务是DBA的必修课。一个实用的方法是定期检查information_schema.innodb_trx表把长时间不结束的事务找出来。5.2 通过information_schema快速定位活跃事务实战中我最常用的排查手段就是下面这几条SQL。先看当前有哪些事务在跑运行了多久SELECT trx_id, trx_state, trx_started, trx_mysql_thread_id, trx_query FROM information_schema.innodb_trx ORDER BY trx_started;trx_started字段记录了事务的开始时间如果发现某个事务已经跑了很久大概率就是长事务。这时候可以进一步通过几秒间隔的查询对比trx_started确认这个事务是否真的处于长时间挂起状态。如果确认是就需要联系业务方评估是否可以提交或回滚。再看undo log的膨胀情况可以结合信息SELECT trx_id, trx_rows_modified, trx_undo_log_size FROM information_schema.innodb_trx ORDER BY trx_undo_log_size DESC;trx_undo_log_size直接反映了事务产生的undo量数值异常大就说明这个事务修改了很多行或者持有Read View很久了。结合这两个方面基本能把问题事务定位出来。另外percona或者MySQL 8.0里还可以通过performance_schema的events_transactions_current表查看更详细的事务执行状态。不过我的经验是线上最优先的还是innodb_trx它直观、快而且不依赖额外的开关配置。5.3 几个常见的MVCC误区和真实案例第一个误区是“数据库里存了多份数据副本”。说这话的人不在少数。MVCC的多版本不是把完整的数据文件复制多份而只是通过undo log记录了修改前的旧值数据页里永远只有一份最新数据其他版本都在日志里按需拼接。第二个误区是“MVCC可以替代锁”。实际上MVCC只解决了读写互相阻塞的问题写和写之间仍然要靠锁来串行而且像当前读、唯一性检查、外键约束等场景都必须依赖锁。MVCC和锁是配合关系不是替代关系。第三个误区来自真实案例某业务在RC隔离级别下一个事务里先查询再更新更新完之后又查一次发现结果比第一次多了一条。业务方以为数据库出了问题实际上只是因为RC下每次快照读都会新建Read View别的事务提交的插入数据自然就被看到了。这是RC隔离级别的正常行为不是BUG业务方需要根据场景决定是否升级到RR。还有一个经典案例是关于快照读的“过期数据”问题事务A先读了库存为100事务B把库存改成50并提交事务A接着执行UPDATE set 库存库存-10结果库存变成了40而不是90。原因就是UPDATE走当前读读到的是B提交后的50所以减10是40。很多人在写库存类业务时因为没搞清快照读和当前读的差异导致扣减逻辑出错。正确的做法是凡是判断后修改的操作必须用当前读绝不能让快照读的结果作为修改依据。5.4 监控与容量规划建议最后聊一下怎么根据MVCC的原理做容量规划和监控。我个人的经验是两件事一定要做。一是给undo log设置合理的容量上限。MySQL 8.0里undo log默认是独立的表空间可以设置自动扩容但一定要配置初始大小和最大限制防止异常情况下无限膨胀。在MySQL 5.7及更早版本里undo位于共享表空间膨胀之后很难收缩建议生产环境直接升级到8.0并启用独立undo表空间。二是建立长事务监控告警。事务持续超过某个阈值比如30秒或者60秒就应该触发告警。告警规则可以和业务挂钩比如交易类业务事务的容忍时间更短报表类业务可以适当放宽。一旦告警先定位是哪个应用、哪个SQL产生的长事务再决定是回滚还是等待。参考阈值虽然没有统一的绝对标准但在多数交易场景下超过30秒的写事务就值得警惕了。另外补充一个真实经验如果线上出现了版本链太长导致的慢查询除了杀掉长事务之外还有一个立竿见影的临时手段——把查询条件改成走主键定位减少扫描范围降低需要回溯的版本数量。当然治本方案永远是避免长事务业务侧的代码要及时提交事务不要在事务里做耗时的外部调用、批量大循环等操作。我个人在排查MVCC相关问题时最大的体会是原理不复杂但一旦理解不到位很容易在隔离级别、锁、快照读这些概念之间绕晕。写代码前想清楚这条SQL到底是快照读还是当前读在哪个隔离级别下执行事务预期持续多久就能规避掉绝大多数并发问题。最后再分享一个小技巧如果实在不确定某条SQL在当前事务里会看到什么数据不要猜直接在测试环境开两个会话模拟一遍实际结果永远比记忆里的理论靠谱。
阅读完成 · 觉得有帮助?
咨询建站