ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

MySQL可重复读隔离级别:MVCC、ReadView与间隙锁原理详解

MySQL可重复读隔离级别:MVCC、ReadView与间隙锁原理详解

1. 先厘清一个关键概念:可重复读是目标,不是问题

我见过太多人把“可重复读”当成一个bug来讨论,张口就是“数据库的RR隔离级别怎么解决可重复读”。这个说法本身就把概念搞拧了。可重复读不是数据库要解决的问题,而是数据库要提供的保证。真正要被“解决”的,是并发场景下的不可重复读(Non-Repeatable Read)——也就是同一个事务里,同一条SELECT语句在后半段查出和前半段不一样的结果。

为什么会有这个问题?因为默认情况下,数据库是允许事务并行的。事务A读某一行,事务B同时把这行改了并提交,A再读一次,发现值和刚才不一样了。在账务、订单、对账这类系统里,这种“同一个事务两次读到不同值”的情况一旦发生,业务逻辑直接就崩了。比如你在一个事务里先查询订单金额,然后基于这个金额做后续判断,结果中间另一个事务把金额改了,你第二次查询拿到新值,最终写库的数据和你最初看到的对不上账,这就属于典型的逻辑一致性破坏。

而数据库的隔离级别,本质上就是一套“什么时候允许事务之间互相影响”的游戏规则。四种标准隔离级别里,读未提交允许看到别人没提交的脏数据,读已提交保证只看到已提交数据但允许两次读不一致,可重复读把两次读不一致这个问题彻底堵死,串行化则更进一步连并发插入都给你串行执行。

MySQL InnoDB默认用的是RR,也就是可重复读。很多人只知道“RR就是不会不可重复读”,但再往下问一句“它到底怎么做到的”,就卡住了。这篇文章我想把这条链路完整捋一遍:MVCC怎么支撑快照读、ReadView为什么是核心、间隙锁又是怎么收拾幻读的,以及不同数据库的RR为什么行为不一样。搞懂这些,你不仅面试能答得有条理,业务上选隔离级别时也知道自己在做什么取舍。

2. 理解RR之前,必须先理解MVCC

2.1 快照读为什么能看到“过去的自己”

在InnoDB里,普通的SELECT语句(不带FOR UPDATE、不带LOCK IN SHARE MODE的查询)走的是快照读。什么叫快照读?翻译成大白话就是:你这一刻发起查询时,数据库给你“拍了一张照片”,你之后的每一次查询,都基于这张照片来读数据,照片里是什么,你就看到什么。

这里最核心的机制是undo log版本链。InnoDB里每一行数据都有两个隐藏字段:事务ID(DB_TRX_ID)和回滚指针(DB_ROLL_PTR)。当一行数据被修改时,老版本的数据不会直接被覆盖删除,而是连同当时的事务ID一起被记录到undo log里,新版本的数据通过回滚指针指向老版本。于是这一行的所有历史版本就串成了一条链表,从最新到最旧,一层一层往回追溯。

这个设计很多人第一次接触时会觉得绕,我打个比方。你想查自己十年前的照片,不需要时光倒流,只需要打开相册,翻到标记着“201X年”的那一本。相册里的照片不会因为你现在胖了二十斤就突然变成最新的样子,它们永远定格在拍摄那一刻。undo log版本链就是这本相册,每一条历史记录都是一张老照片,最新的修改则是最近刚冲洗出来的一张,挂在链表的最前面。

那什么时候读新照片,什么时候读老照片?这就要看ReadView了。

2.2 ReadView是RR的定海神针

ReadView是快照读的核心数据结构,它记录了事务开启瞬间的“活跃事务列表”。具体来说,一个ReadView会保存四个关键信息:

  • m_ids:当前系统中所有活跃(还没提交)事务的ID列表
  • min_trx_id:活跃事务中最小的ID
  • max_trx_id:下一个即将分配的事务ID(不是当前最大活跃ID,而是水位线)
  • creator_trx_id:创建这个ReadView的事务自身的ID

有了这个视图,数据库在遍历版本链时,就能用一套非常明确的规则来判断当前事务能看到哪个版本。判断规则是这样的:

  1. 如果版本的事务ID等于creator_trx_id,说明这个版本是自己修改的,当然能看。
  2. 如果版本的事务ID小于min_trx_id,说明这个事务在当前视图创建之前就已经提交了,数据可见。
  3. 如果版本的事务ID大于等于max_trx_id,说明这个事务是在视图创建之后才启动的,它的修改不可见。
  4. 如果版本的事务ID落在m_ids列表里,说明这个事务还没提交,属于活跃状态,不可见。

从链表头部开始,从新往旧逐个版本做这个判断,遇到第一个可见版本就返回。这个过程就是MVCC的“M”(Multi-Version)发挥作用的时刻——同一行物理数据有多个版本,不同事务通过不同的ReadView看到不同版本,互不干扰。

这里要注意,ReadView的创建时机直接决定了隔离级别的行为。这正是下一节要展开的关键点。

3. RR解决不可重复读的完整链路

3.1 第一次读:建立视图

在RR隔离级别下,事务的ReadView不是在BEGIN那一刻创建的,而是在第一次执行快照读时创建的。这一点特别重要,很多讲MySQL原理的文章容易在这带过,但恰恰是这个时间点,决定了RR和RC的差异。

来看一个具体的执行流程。假设事务T1执行了这样一段SQL:

START TRANSACTION; SELECT amount FROM account WHERE id = 1; -- 第一次快照读,此刻创建ReadView -- 此时另一事务T2提交了 UPDATE account SET amount = 100 WHERE id = 1; SELECT amount FROM account WHERE id = 1; -- 第二次快照读 COMMIT;

第一次SELECT执行时,数据库生成一个ReadView,假设此刻活跃事务列表里只有T1自己。第二次SELECT执行时,T2已经提交了,但InnoDB并不会重新生成ReadView,而是继续复用第一次生成的那个视图。T2的事务ID不在这个ReadView的可见范围内,因此对T1来说,T2的修改就是“不存在”的。T1两次查询拿到的都是同一个旧版本,这就是可重复读的全部秘密。

我把这个过程拆成三个步骤:

  1. 事务第一次快照读时,生成ReadView并保存到事务对象里。
  2. 后续所有的快照读操作,直接复用这个ReadView,不再生成新的。
  3. 由于ReadView固定,事务能看到的版本集合固定,读到的数据天然一致。

这就是RR解决不可重复读的原理,本质上就是把“判断哪些数据可见”的规则在事务第一次读取的那一刻冻结了。之后不管其他事务提交了多少新数据,对当前事务来说都等于没发生。

3.2 后续读:复用同一视图

关键点在于“复用”这两个字。为什么复用同一个ReadView就能保证一致?因为可见性判断是看事务ID和数据版本的关系,而这个关系是用ReadView里的快照信息去比对的。只要这个快照信息不变,数据版本的选择逻辑就不变。版本链上即使挂上了更新的版本(由其他事务写入),也会因为事务ID不符合可见条件而被跳过。

这里还有一个容易忽略的细节:RR下,事务未提交前的自身修改是可见的。因为你自己的事务ID在ReadView的creator_trx_id里,判断规则第一条就保证了自见性。很多刚接触MVCC的人会有疑问:“事务内自己改的数据,第二次查询为啥能看到?”就是因为creator_trx_id机制在起作用。这个设计是必须的,否则事务内先UPDATE再SELECT,永远看不到自己的修改,业务逻辑就全乱了。

再举个例子帮助理解。假设表里有一行数据,初始金额是10。T1开启事务后第一次查询,创建ReadView;然后T1自己执行UPDATE把金额改成20;此时版本链变成这样:

版本3: amount=20, trx_id=T1 版本2: amount=10, trx_id=老事务 版本1: amount=10, trx_id=更老事务

T1再SELECT,从链表头部开始判断,版本3的事务ID等于creator_trx_id,可见,于是读到20。版本2、版本1根本不会去看。这个行为在RC下也同样成立,因为RC的ReadView虽然每次重建,但自己的修改永远可见。所以RR与RC的差别,不在自见性,而在别人提交的数据对自己是否可见。

3.3 为什么RC做不到这一点

把RC拿来对比,差异一下就清楚了。RC(读已提交)隔离级别下,每一次快照读都会重新生成一个新的ReadView。也就是说,我上一个SELECT用的是视图A,下一个SELECT可能因为别的事务已经提交了,重新生成视图B。两次SELECT看到的活跃事务列表可能完全不同,于是读到的数据版本也可能不同。

还是刚才那个例子:

START TRANSACTION; SELECT amount FROM account WHERE id = 1; -- 读到旧值10,生成ReadView-1 -- 此时T2提交了 UPDATE account SET amount = 100 WHERE id = 1; SELECT amount FROM account WHERE id = 1; -- 重新生成ReadView-2,T2已提交,读到100 COMMIT;

RC下第二次SELECT会重新生成ReadView,而T2已经提交,T2的事务ID对新的ReadView来说是已提交状态,所以会看到T2修改后的值。这就是不可重复读的由来。

所以一句话总结:RR和RC在快照读上的实现差异,就在于ReadView是“创建一个+持续复用”还是“每条SELECT都新建”。这不是什么神秘的高级技巧,纯粹是可见性规则被冻结的时机不同。

4. 当前读与间隙锁:RR如何对付幻读

4.1 快照读天然防幻读

聊完不可重复读,还得聊幻读。幻读和不可重复读的区别在于:不可重复读是同一行数据变了(行内容变化),幻读是结果集的行数变了(有新的行出现)。比如事务A按条件查了一批数据,事务B往这个条件范围内插入了一行新数据并提交,A再按同一条件查询,多了一行,这就是幻读。

在RR隔离级别下,快照读是天然防幻读的。因为ReadView创建之后被冻结,其他事务新插入的行,其事务ID对当前ReadView来说是不可见的。无论插入多少新行,A的快照里都不包含它们。这一点,和防不可重复读用的是同一套机制,没什么额外的魔法。

但是,这里有个但是。如果你的SQL走的是当前读,情况就不一样了。

4.2 当前读必须靠锁

当前读包括SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE。这些操作要求读取的是数据库的最新已提交版本,而不是快照版本。因为你要对数据做修改,总不能基于一张旧照片去改已经变了的行吧。所以当前读不能用ReadView判断可见性,它必须实时读取最新数据。

问题来了:如果当前读不加控制,事务A用SELECT * FROM orders WHERE status = 1 FOR UPDATE锁住了范围查询结果,事务B向status=1的范围里插入一条新数据并提交,事务A再执行同样语句,发现结果集多了一行。这就发生了幻读。

InnoDB解决这个问题的办法是间隙锁(Gap Lock)和临键锁(Next-Key Lock)。间隙锁锁住的是索引记录之间的“空隙”,不允许其他事务在这个空隙里插入新记录。临键锁是记录锁和间隙锁的组合,锁住的是“从某个索引值到下一个索引值之间”的整个区间。

拿上面的SQL举例。假设orders表里status是普通索引(注意,这里强调索引字段很关键),status=1的记录索引值分布在索引B+树里。事务A执行FOR UPDATE时,不仅锁住了匹配到的记录,还会用临键锁锁定这些记录所在的索引区间。事务B往这个区间里插入status=1的新行时,会被锁阻塞,必须等A提交或回滚才能继续。

这里有个十分容易踩坑的点:如果WHERE条件里的字段没有索引,InnoDB会退化成锁全表。因为无法通过索引确定要锁的区间,数据库只能把所有记录连同所有间隙都锁上。表现出来就是,一个简单的UPDATE语句,把整张表的插入操作都阻塞了。线上出现过不少这类事故,排查方向就是看执行计划有没有走索引。

4.3 间隙锁引发“死锁高发”的业务代价

RR的间隙锁虽然解决了当前读的幻读问题,但并非常白拿的。间隙锁最大的代价是两个:

第一,并发度下降。间隙锁的粒度比记录锁大得多,它锁定的是一个区间而不是一条记录。两个事务插入到同一个区间时可能相互阻塞,即使插入的是完全不冲突的数据。业务高峰期,这种锁等待会直接拉高响应时间。

第二,死锁概率上升。间隙锁导致的死锁非常经典。事务A先锁了某个区间的间隙,事务B锁了另一个区间并试图申请A持有的间隙——双方互相等待,死锁发生。InnoDB的死锁检测机制会牺牲一个事务回滚来打破僵局,但被牺牲的那个事务的SQL会直接报错,业务上需要做重试。

我见过一个典型的死锁场景:

-- 事务A UPDATE stock SET quantity = quantity - 1 WHERE product_id = 5;
-- 事务B UPDATE stock SET quantity = quantity - 1 WHERE product_id = 6;

看起来锁的是不同行的记录锁,不会冲突?但如果在product_id这个普通索引上操作,两个事务可能要同时申请两个相邻区间之间的间隙锁,进而互相等待。这种问题在RR下出现的概率比RC高很多,这也是为什么很多对并发要求高的系统宁可用RC不用RR。

4.3.x 快照读与当前读并存时的行为

需要额外强调一个很隐蔽的场景:同一个RR事务里,先快照读,再当前读,结果可能不一致。

举例:

START TRANSACTION; SELECT * FROM orders WHERE status = 1; -- 快照读,看到2行 -- 另一个事务提交了一次插入操作,新增1行status=1 UPDATE orders SET remark = 'x' WHERE status = 1; -- 当前读,能看到最新的3行 SELECT * FROM orders WHERE status = 1; -- 快照读,还是2行

UPDATE执行的是当前读,它看到了最新数据并把3行都更新了。后续快照读因为ReadView没变,仍然只看到2行。同一个事务里,两个SELECT查出来的行数不一样。严格从SQL标准来说,这是不是“幻读”?业界有争议,因为标准定义里幻读是针对同一条SQL语句的,而这里一个事务出现了不同SQL语句的结果不一致。但从业务角度,这种情况确实会造成困惑,需要清楚了解它的存在。

从事务角度怎么理解?UPDATE的当前读会产生写锁,并且更新时InnoDB会把当前读到的行重新写回版本链,但这些行的事务ID变成当前事务ID。之后快照读时,因为有“自见性”规则,当前事务自己修改过的行是可见的。所以,如果事务内做了一次当前写,后续快照读会读到被自己改过的行,和其他事务插入/修改的行仍不可见。这是RR的一种“写操作影响后续快照读”的表现。

5. 不同数据库的RR有“物理差异”

5.1 一个容易被忽略的数据库差异

很多人学的是MySQL的RR,就以为所有数据库的RR行为都一样。实际上,不同数据库厂商对可重复读的实现差异极大,尤其是在幻读上。

最典型的是PostgreSQL。PostgreSQL的RR实现的是快照隔离(Snapshot Isolation),它用MVCC解决不可重复读,但不解决幻读。换句话说,在PostgreSQL的RR隔离级别下,一个事务里执行两次相同条件的范围查询,其他事务插入的新行可能出现在第二次结果集里。这还不算bug,而是PostgreSQL刻意为之——甚至在官方文档里明确说明了这一点。

为什么PostgreSQL敢这么做?因为它的默认隔离级别就是RR,而且它的MVCC实现里没有间隙锁这套东西。它认为快照隔离已经足够满足绝大多数业务的一致性需求,而完全防幻读需要付出锁代价,不值。

再者,Oracle也有类似的差异。Oracle只提供两种标准隔离级别:已提交读和串行化。它的串行化实际上也是基于快照隔离实现的。所以当你从MySQL切到PostgreSQL或Oracle,还抱着“RR绝对防幻读”的心态写业务逻辑,就可能踩坑。

差异的本质,是不同数据库对“可重复读”这个词的解释不同。MySQL用MVCC+间隙锁实现了近似SQL标准的RR;PostgreSQL用纯MVCC实现了一个阉割版RR;Oracle干脆不叫RR,直接用串行化做快照隔离。这提醒我们,隔离级别不是普适的语义,它是和具体数据库绑定的行为约定。

5.2 何时敢用RR,何时要退避三舍

在MySQL里选RR还是RC,我个人的判断标准是看业务对“一致读”和“并发锁”的权衡。

用RR比较合适的场景:

  • 需要事务内多次查询结果保持一致,比如报表统计、对账、导出任务。
  • 有明确的间隔性业务规则,比如不允许同区间插入数据的场景。
  • 业务并发量不高,死锁风险和锁等待可以接受。

用RC比较合适的场景:

  • 高并发OLTP系统,比如订单、秒杀、库存扣减。
  • 锁等待已经出现明显瓶颈,想降低间隙锁导致的阻塞和死锁概率。
  • 业务能接受同一事务内两次读可能不一致,用应用层逻辑或唯一索引来处理并发冲突。

我自己在实际项目里,默认SQL是RC,只有碰到明确需要“对账一致读”的场景才临时切到RR。这种选择不是立场问题,是代价问题。RR给你的一致性是锁和MVCC共同换来的,使用前要先想清楚。

6. 面试与实战中的高频追问

6.1 面试官常问的三个问题

第一个问题必然是这个:“RR和RC在MVCC层面的核心区别是什么?”回答要点就四个字:ReadView的创建时机。RR只创建一次并复用,RC每次快照读都新建。面试官如果继续追问“为什么复用就能保证可重复读”,你再把可见性判断规则梳理一遍,点明“可见性快照冻结”这个本质,基本就稳了。

第二个问题是:“RR怎么防幻读?”这个必须分清两条路径。快照读靠MVCC的ReadView天然防幻读;当前读靠间隙锁/临键锁防幻读。面试官有时会挖坑,问“RR是不是完全防幻读”,你要能指出“当事务内先当前读再快照读时,结果集可能不一致”这个边界情况。能答到这一层,说明你不是背概念,是真的理解。

第三个问题:“为什么InnoDB把默认隔离级别设为RR而不是RC?”这个是历史原因加二进制日志(binlog)兼容性考虑的组合。早期MySQL主从复制在RC + STATEMENT格式binlog下,日志里的SQL执行顺序可能会导致主从数据不一致,RR + 当前读的加锁行为能让基于语句的复制更安全。现在有了ROW格式binlog,这个问题弱化了,但默认值没有变。

6.2 实战排查技巧

实战中遇到“同一个事务两次查询结果不一样”,不要急着怀疑数据库是不是坏了。按这个思路排查:

第一,确认你跑的查询是不是快照读。有些ORM框架会在事务里执行SELECT ... FOR UPDATE,这在RR下是当前读,结果会变很正常。

第二,确认事务内是否有写操作。写操作会触发当前读并更新版本链,后续快照读会看到自己修改后的版本,但看不到别人提交的版本。表现行为会比较奇怪,需要结合事务内操作路径分析。

第三,确认数据库类型。如果你连的是PostgreSQL,那么“RR防幻读”这个预期从开始就是错的。当你还在用MySQL的思路去分析PostgreSQL的行为,排查方向就跑偏了。

第四,检查执行计划,看走了哪些索引。间隙锁的行为严重依赖索引,比如WHERE条件用的是普通索引和主键索引,锁定的区间范围完全不同。用EXPLAIN看执行计划,能快速定位是不是间隙锁范围过大。

最后分享一个我常用的排查手法:用performance_schema里的锁等待信息和引擎状态命令去看事务的锁持有情况。线上遇到死锁,优先看死锁日志(SHOW ENGINE INNODB STATUS),里面会打印出事务互相等待的锁对象,一眼就能看到是哪两个区间在竞争。很多锁问题,光靠看代码是看不出来的,必须结合运行时状态去还原。

我个人在实际操作中的体会是,把MVCC、ReadView、间隙锁这三样东西放在一张图里去理解,比死背概念强十倍。每次被一个奇怪的并发问题卡住时,问自己三个问题:这条SQL是快照读还是当前读?ReadView是什么时候创建的?这个索引区间上有哪些锁?三个问题想透了,99%的隔离级别问题都能定位到根因。

RELATED READING

延伸阅读

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