ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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

做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改成当前事务ID,DB_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的事务ID,DB_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 COMMITTED(RC)隔离级别下,事务里每一条普通SELECT语句都会生成一个新的Read View。也就是说,每次查询都重新获取当前所有活跃事务的最新快照。所以RC下,一个事务里前后两次SELECT,可能因为中间别的事务提交了而看到不同的数据,这就是不可重复读在RC下的正常表现。

在REPEATABLE READ(RR)隔离级别下,事务里第一条普通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在当前事务里会看到什么数据,不要猜,直接在测试环境开两个会话模拟一遍,实际结果永远比记忆里的理论靠谱。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进