☰
MySQL事务隔离与MVCC:从ReadView到间隙锁的并发控制全解析
2026/10/13 7:54:14 网站建设 项目流程

1. 为什么要搞懂事务隔离和MVCC

先回答一个很多开发者问过我的问题:隔离级别和MVCC到底值不值得花时间啃?我的答案是,值得,而且越早啃越省事。

MySQL的InnoDB引擎是默认存储引擎,是不是单行读还是批量导入,最终都会落在事务和并发控制这层。搞懂MVCC,不仅仅是应付面试,更多时候是线上出问题了(比如死锁、脏读、可重复读失效),你得能快速定位到是哪儿出了问题。没有这套底层认知,你就只能靠"重启试试""加索引试试"这种碰运气的办法。

这篇我不会按教科书那套讲法讲,而是从实际使用场景出发,把事务隔离级别、ReadView机制、undo log版本链、当前读快照读这些概念串成一个完整链路。看完你至少能做到:能说清楚RR级别下为什么不会幻读、RC和RR在底层有什么区别、什么时候读不到刚提交的数据、死锁日志该怎么看。

2. 事务隔离级别的本质

2.1 隔离级别到底隔离了什么

事务的ACID大家应该都熟悉,其中I(Isolation)就是隔离性。并发事务同时执行时,隔离性决定了你的事务能看到多少别人的操作。太低会出乱子,太高会让并发性能大幅下降,这是个取舍问题。

要理解隔离级别,先得搞清楚它要解决的三类问题:

  • 脏读:读到别的事务还没提交的数据。对方如果回滚了,你就等于读了一个假数据。
  • 不可重复读:同一条数据,事务内前后读了两次,结果不一样(其他事务修改并提交了)。
  • 幻读:同一个查询条件,事务内前后查了两次,结果集的行数不一样(其他事务插入或删除了记录)。

MySQL的InnoDB提供了四种隔离级别:

隔离级别脏读不可重复读幻读
READ UNCOMMITTED可能可能可能
READ COMMITTED不可能可能可能
REPEATABLE READ不可能不可能可能(InnoDB实际解决)
SERIALIZABLE不可能不可能不可能

2.2 REPEATABLE READ下的幻读到底是解决了还是没解决

有个很容易踩坑的知识点:MySQL官方文档说RR级别存在幻读可能,但InnoDB在大多数场景下通过MVCC加间隙锁把幻读解决了。这个"大多数场景"很微妙。

快照读(普通的SELECT)在RR下永远不会幻读,因为MVCC通过ReadView保证了每次读到的都是事务开始时的快照。但如果你的场景里出现了当前读(加锁的SELECT ... FOR UPDATE、UPDATE、DELETE),幻读就可能钻空子。

举个例子:

-- 事务A START TRANSACTION; SELECT * FROM user WHERE id > 100 FOR UPDATE; -- 此时锁住了id > 100的间隙 -- 事务B INSERT INTO user(id, name) VALUES(105, 'new'); -- 阻塞,直到事务A提交

如果事务A的查询条件没有建立索引,InnoDB的间隙锁锁的是整个索引范围,还能防住。但如果条件没走索引,间隙锁锁的是主键索引的所有间隙,效果差异很大,这也是为什么建议大范围更新前检查执行计划。

我之前遇到过这样一个线上问题:某表在RR级别下,一个事务内先查了一次快照,然后另一个事务插入了一条符合条件的新数据并提交,第一个事务再执行当前读,居然读到了这条新数据。表面上是幻读,但严格来说是快照读和当前读在RR下读的不是一个版本链。所以排查问题的时候,先确认你执行的是不是当前读。

2.3 实际开发中隔离级别怎么选

默认的REPEATABLE READ(RR)对大部分业务是够用的,因为InnoDB在MVCC上做的优化让RR的并发性能损失没那么大。但有一个场景我建议切换到READ COMMITTED(RC):高并发下的热点行更新。

原因很简单:RR下的间隙锁范围更大,锁冲突概率更高,很容易出现死锁。RC下没有间隙锁,只有行锁,冲突面和等待时间都会降下来。很多大流量系统(比如秒杀、库存扣减)都主动切到RC,再用分布式锁或者乐观锁去保证业务一致性。

不过要注意RC有它的代价——不可重复读。典型场景就是"统计类SQL":事务内多次做SUM查询,如果期间有别的事务提交了数据,每次查的结果可能都不一样。业务方必须能接受这种不一致,否则就得考虑对汇总表加锁或干脆用SERIALIZABLE。

3. MVCC底层机制全拆解

3.1 一条记录怎么变成多个版本

MVCC(Multi-Version Concurrency Control),叫多版本并发控制。核心思想不是加锁去阻塞读和写,而是每条记录维护多个历史版本,让读写不互相等待。读读随便并发,读写大概率也并发,只有写写才需要真正的锁。

InnoDB在聚簇索引(主键索引)的记录里隐藏了三个核心字段:

  • DB_TRX_ID:最后一次修改这条记录的事务ID。
  • DB_ROLL_PTR:回滚指针,指向undo log里上一个版本的位置。
  • DB_ROW_ID:没有主键时内部生成的自增行ID(有主键则无该字段)。

你每修改一条记录,InnoDB不会直接覆盖旧值,而是先把旧值写进undo log,然后把DB_ROLL_PTR指向这个旧版本,最后更新DB_TRX_ID和新的数据值。这样一来,同一条逻辑记录就在undo log里串成了一条版本链表。

从一个实际更新来感受下这个过程:

-- 假设初始记录:id=1, name='A', trx_id=100 -- 事务200执行:UPDATE user SET name='B' WHERE id=1; -- 发生的事: 1. 先把id=1的旧值(name='A', trx_id=100)copy到undo log 2. 更新聚簇索引记录:name='B',DB_TRX_ID=200 3. DB_ROLL_PTR指向undo log中的旧版本

此时如果要回滚,InnoDB顺着DB_ROLL_PTR链路找到最老的版本,直接恢复数据即可。不需要额外存一份"变更前快照",这就是undo log承担的责任。

3.2 ReadView是快照读的"透视镜"

每个事务开始快照读时,会生成一个ReadView,它决定了你在这个事务里能看到哪个版本的数据。ReadView的核心字段就四个:

  • m_ids:生成ReadView时,当前系统内活跃事务ID的列表。
  • min_trx_id:m_ids里最小的事务ID。
  • max_trx_id:生成ReadView时,系统分配给下一个事务的ID(注意,这不是活跃事务的最大ID,是"未来事务"的起点)。
  • creator_trx_id:创建这个ReadView的事务自己的ID。

判断一条记录对当前事务是否可见,逻辑如下:

条件可见性结论
DB_TRX_ID == creator_trx_id可见(自己改的当然能看见)
DB_TRX_ID < min_trx_id可见(修改记录的事务已提交)
DB_TRX_ID >= max_trx_id不可见(记录是未来事务改的)
DB_TRX_ID在m_ids中不可见(修改事务仍在活跃,未提交)
DB_TRX_ID不在m_ids中可见(修改事务已提交)

如果当前版本的记录不可见,就顺着DB_ROLL_PTR往下找下一个版本,继续用同样的规则判断,直到找到可见版本。找不到那就返回空。

用生活化的例子,ReadView就像你在餐厅点菜时拿到的号码牌:你进场时已经在场的客人(活跃事务)的账单状态你看不到;你进场之后才来的客人,他们点的菜端上来之前你也看不见;只有你进场之前就已经结账离开的客人,他们点什么你完全看得见。

3.3 RC和RR的核心差异:ReadView的创建时机

这是面试也常考的,也是理解MVCC的最关键差异。

  • READ COMMITTED级别下,每个快照读语句执行时都会生成一个新的ReadView。正因为每次快照都重新"拍一张照片",所以事务内两次相同的SELECT可能看到不同的结果——这就是不可重复读。
  • REPEATABLE READ级别下,事务内第一次执行快照读时生成ReadView,之后整个事务内所有的快照读都复用这一个ReadView,不再更新。于是你始终看到的是同一个版本——这就是可重复读。

这里有一个值得注意的细节:RR下如果事务一开始没有执行快照读,而是在执行了当前读(比如UPDATE),InnoDB会为这个当前读生成一个ReadView,后续的快照读也会沿用这个ReadView。我实习时遇到过一次问题,就是因为先做了一次UPDATE才做SELECT,结果跟预期完全不一样,后来追踪发现就是这个原因。

再说清楚一点:快照读本身是不加锁的MVCC读,当前读是加锁的读取。前者的可见性是靠ReadView决定的,后者是直接读最新版本并且加锁。

4. undo log版本链与回滚实操

4.1 undo log的两种类型

undo log其实分两种:

  • INSERT undo log:插入操作产生的。因为插入的记录对别的事务不可见(未提交前),回滚时直接删除这条记录的聚簇索引即可。这种undo log在事务提交后就可以直接清理,不需要参与MVCC版本链判断。
  • UPDATE undo log:更新和删除操作产生的。删除本质上也是一种更新(打删除标记)。这种undo log要保留到"没有任何事务的ReadView还需要看到它"时才能被purge线程清理。

我一直提醒团队里新人:大事务是undo log膨胀的第一元凶。一个事务里UPDATE几百万行,事务迟迟不提交,这些版本的undo log就全部不能清理。线上表现就是ibdata1文件疯狂涨,甚至磁盘被打满。所以OLTP系统里,大事务一定要拆批,控制在几千行一个事务提交一次。

4.2 长事务和死锁的联动

长事务本身不只是日志膨胀的问题,还会加剧死锁。因为MVCC下,写操作的锁要一直持有到事务提交才能释放,长事务意味着锁持有时间拉长,两个事务互相等待的概率直线上升。

排查长事务可以直接用:

SELECT * FROM information_schema.INNODB_TRX\G

重点看这几列:

  • trx_id:事务ID,看是否过于偏大,偏大说明是较新的事务。
  • trx_started:事务开始时间,和当前时间差超过几秒就要警惕。
  • trx_rows_locked:锁了多少行,数值异常大说明扫描了很多行。
  • trx_query:当前正在执行的SQL,判断事务挂在哪条语句上。

如果某个连接长时间不提交,trx_query可能显示为空,因为事务已经执行完但没提交。这种情况光靠这条SQL看不出业务逻辑,需要去应用端查这个连接在干什么。

4.3 手动模拟一个版本链,直观感受MVCC

想彻底理解版本链,我建议你自己动手模拟一次:

-- 会话1 START TRANSACTION; UPDATE user SET name='B' WHERE id=1; -- 先不提交 -- 会话2 START TRANSACTION; UPDATE user SET name='C' WHERE id=1;

此时如果会话2执行更新,它会被阻塞,因为会话1持有id=1的行锁。但如果你把两句话的顺序反过来,先会话2更新并提交,再会话1更新并提交,最后这条记录只有一个版本链,前一个版本的undo log很快就会被清理。

更直观的方式是:

-- 查询最近的事务ID分配情况 SELECT TRX_ID, TRX_STARTED FROM information_schema.INNODB_TRX;

然后结合SHOW ENGINE INNODB STATUS的TRANSACTIONS段落,可以看到每个事务的undo log条目数和锁等待。

建议把两三个客户端开起来,交叉执行上面这些SQL,实际体验比对着一堆文档想象要深得多。

5. 当前读与快照读,什么时候用哪个

5.1 两种读的边界

很多人分不清当前读和快照读,但这两者的语义完全不同。

  • 快照读:普通SELECT,不加锁,读的是ReadView可见的版本。不阻塞任何写操作,写操作也不阻塞它。
  • 当前读:SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE、INSERT在特定条件下。读的是最新已提交的数据版本,并且对记录加锁。

举例说明:

-- 会话1 START TRANSACTION; SELECT * FROM user WHERE id=1; -- 得到 name='B',假设这是旧版本 -- 会话2 START TRANSACTION; UPDATE user SET name='D' WHERE id=1; COMMIT; -- 会话1再次执行快照读 SELECT * FROM user WHERE id=1; -- RR下还是name='B',RC下就变成name='D'

但如果会话1改成当前读:

SELECT * FROM user WHERE id=1 FOR UPDATE; -- 无论RR还是RC,都会读到name='D'

这就是为什么很多"读不到刚提交数据"的问题,最后都发现是快照读和当前读混用了。

5.2 UPDATE为什么需要先读最新版本

当前读必须读最新版本,是因为写操作要在最新数据上做修改。如果允许基于旧版本做修改,那后提交的事务覆盖先提交事务的修改,就会出现"丢失更新"问题。

这就是MVCC加锁配合的真正意义:MVCC管读的可见性,锁管写的串行化。两者结合,在保证多版本的前提下,不让写操作损坏数据。

5.3 实际业务中怎么分配读写模式

大体原则:

  • 报表类查询,数据量大,读多写少,优先用快照读,不加锁,性能最好。
  • 订单扣减库存这类操作,必须在最新数据上判断,用当前读。
  • 先查后改的复合操作(比如先查余额再扣款),尽量把"查和改"合并成一条UPDATE条件语句,减少竞态窗口。
  • 对于"读后写"的并发控制,最简单有效的方式是版本号字段(乐观锁):每次更新时检查版本号是否匹配,匹配才更新。
UPDATE account SET balance = balance - 100, version = version + 1 WHERE id = 1 AND version = 5;

返回影响行数为1则更新成功,为0则版本冲突,重试即可。这种方式比间隙锁可控得多。

6. 间隙锁与RR下幻读的真实防线

6.1 什么是间隙锁

间隙锁锁的不是具体某条记录,而是两个索引记录之间的"空隙"。比如表里现在主键id有1和5两条记录,那(1,5)这个区间就是一个间隙。事务在RR下对id=3这条不存在的记录加锁时,实际上锁的是(1,5)这个间隙,任何尝试往这个间隙里插入数据的操作都会被阻塞。

间隙锁和行锁配合,构成了Next-Key Lock(临键锁),它锁的是"记录本身+前面的间隙"。这样设计的目的就是防止幻读:你第一次查id > 1的记录时,锁住了(1,正无穷)这个范围,别人就没法在这个范围里插入新行。

6.2 间隙锁的边界条件真的很多

间隙锁并不是灵丹妙药,它的生效条件很苛刻:

  • 查询条件必须有索引,否则InnoDB会锁全表的所有间隙,性能灾难。
  • 如果查询条件是普通索引(非唯一),锁的范围会变成一个"排他区间",包含相邻的范围,比单一条件锁的多。
  • 唯一索引的等值查询,在找到目标记录后会把对应间隙锁升级为行锁,没有幻读问题,不需要间隙锁。

我之前排查过一个死锁案例:两条UPDATE语句,一条走唯一索引,一条走普通索引,update了同一批数据,但因为加锁顺序不同,直接死锁。日志里可以清楚看到一条holding间隙锁,另一条waiting行锁,交叉等待。

6.3 RR必用的场景和推荐做法

如果你的业务确实存在先查询一个范围,然后根据查询结果做插入的场景,又不想出现幻读,RR默认就会帮你把间隙锁加上。这种场景下没必要手动处理,直接用事务加当前读即可。

但如果是高并发系统,我建议优先考虑RC+乐观锁,不要依赖间隙锁做并发控制。原因很实际:间隙锁的并发度太低了,锁范围大,冲突概率高,死锁率也高。很多阿里系的数据库中间件默认把隔离级别切到RC,不是没有原因的。

下面是一个排查死锁的快速SQL,记录一下备用:

SHOW ENGINE INNODB STATUS\G -- 看LATEST DETECTED DEADLOCK段落 -- 重点关注: -- 1. 两个事务各自的SQL -- 2. 各自持有的锁和等待的锁 -- 3. lock_type和index name

死锁日志里最容易忽略的是table handle和index name。如果发现锁的是二级索引而不是主键,大概率是SQL没有走索引或者索引设计不合理,加索引往往比改代码更有效。

7. 常见问题排查实录

7.1 问题1:RR下读到了"自己修改"的旧数据

现象:事务A里先UPDATE了某条记录,然后同事务里SELECT,发现还是旧值。

原因:这个现象的根本原因往往不是MVCC,而是UPDATE的WHERE条件没走索引,锁全表但更新到了目标记录。然后SELECT用的快照读,ReadView生成在UPDATE之前,所以看到的是旧版本。

解决:确认UPDATE执行计划,确保索引命中;或者SELECT也改成当前读,强制读取最新版本。

7.2 问题2:一条SQL扫描了太多行,但锁却很少

现象:慢SQL日志里Rows_examined非常高,但INNODB_TRX里trx_rows_locked很小。

原因:InnoDB对扫描过的每一行都会加锁,但innodb行锁是"不到提交不释放",如果SQL走的是覆盖索引,可能在索引层面扫描大量记录,但实际加锁落地到主键上的行数少,所以锁数量看着少,但CPU和内存的消耗其实很大。

解决:优化索引,尽量让SQL定位到精确的记录范围,减少扫描行数。例如大范围查询改成基于主键的分批查询。

7.3 问题3:长事务导致undo膨胀

现象:磁盘空间被打满,ibdata1或者undo文件异常增长。

原因:长事务持有旧版本的ReadView,导致undo log版本链无法清理。MySQL的purge线程必须等到所有活跃事务的ReadView都"看不到"这些旧版本后才能清理。

解决:找到长事务并处理掉,然后在应用层加事务超时控制。最直接的方式是:

SELECT trx_id, trx_started, TIMESTAMPDIFF(SECOND, trx_started, NOW()) AS duration_sec FROM information_schema.INNODB_TRX WHERE TIMESTAMPDIFF(SECOND, trx_started, NOW()) > 5;

发现超时事务,直接kill连接,别犹豫。业务代码里也要加事务超时保护,避免连接池里的连接长期挂着未提交事务。

7.4 问题4:RC下同一个事务内SUM结果不一致

现象:统计SQL在事务里跑了两遍,数值对不上。

原因:RC下每个快照读都生成新的ReadView,不可重复读在RC下是允许的,甚至可能读到中间状态。

解决:如果统计结果必须一致,事务改成RR级别;或者把统计SQL放到一个事务的最开始(保证读的是同一个ReadView);再或者让统计SQL走当前读加锁(但不推荐,并发性能太差)。

8. 我的实操心得

文章最后分享两个实际经验。

第一,排错MVCC问题,先不要急着看代码,先看binlog和慢查询日志,搞清楚出问题的时候对应的SQL走了什么索引、事务执行了多长时间。极多"事务隔离"问题最终都落在索引没走对或者长事务上。

第二,在自己的学习环境里,多开几个客户端,实践是最好的老师。创建一个简单的user表,插几行数据,然后按上面说的步骤模拟脏读、不可重复读、幻读、死锁。每个现象都亲手复现一遍,远比背十篇讲义有用。我面试别人时,比较看重的是他能不能用自己的话解释清快照读和当前读的区别,这比背出四个隔离级别的定义更能反映水平。

希望这篇能帮你在MySQL并发这条路上少踩几个坑。如果你在复现的过程中遇到什么奇怪的现象,也欢迎一起交流讨论。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询