1. 先厘清一个关键概念可重复读是目标不是问题我见过太多人把“可重复读”当成一个bug来讨论张口就是“数据库的RR隔离级别怎么解决可重复读”。这个说法本身就把概念搞拧了。可重复读不是数据库要解决的问题而是数据库要提供的保证。真正要被“解决”的是并发场景下的不可重复读Non-Repeatable Read——也就是同一个事务里同一条SELECT语句在后半段查出和前半段不一样的结果。为什么会有这个问题因为默认情况下数据库是允许事务并行的。事务A读某一行事务B同时把这行改了并提交A再读一次发现值和刚才不一样了。在账务、订单、对账这类系统里这种“同一个事务两次读到不同值”的情况一旦发生业务逻辑直接就崩了。比如你在一个事务里先查询订单金额然后基于这个金额做后续判断结果中间另一个事务把金额改了你第二次查询拿到新值最终写库的数据和你最初看到的对不上账这就属于典型的逻辑一致性破坏。而数据库的隔离级别本质上就是一套“什么时候允许事务之间互相影响”的游戏规则。四种标准隔离级别里读未提交允许看到别人没提交的脏数据读已提交保证只看到已提交数据但允许两次读不一致可重复读把两次读不一致这个问题彻底堵死串行化则更进一步连并发插入都给你串行执行。MySQL InnoDB默认用的是RR也就是可重复读。很多人只知道“RR就是不会不可重复读”但再往下问一句“它到底怎么做到的”就卡住了。这篇文章我想把这条链路完整捋一遍MVCC怎么支撑快照读、ReadView为什么是核心、间隙锁又是怎么收拾幻读的以及不同数据库的RR为什么行为不一样。搞懂这些你不仅面试能答得有条理业务上选隔离级别时也知道自己在做什么取舍。2. 理解RR之前必须先理解MVCC2.1 快照读为什么能看到“过去的自己”在InnoDB里普通的SELECT语句不带FOR UPDATE、不带LOCK IN SHARE MODE的查询走的是快照读。什么叫快照读翻译成大白话就是你这一刻发起查询时数据库给你“拍了一张照片”你之后的每一次查询都基于这张照片来读数据照片里是什么你就看到什么。这里最核心的机制是undo log版本链。InnoDB里每一行数据都有两个隐藏字段事务IDDB_TRX_ID和回滚指针DB_ROLL_PTR。当一行数据被修改时老版本的数据不会直接被覆盖删除而是连同当时的事务ID一起被记录到undo log里新版本的数据通过回滚指针指向老版本。于是这一行的所有历史版本就串成了一条链表从最新到最旧一层一层往回追溯。这个设计很多人第一次接触时会觉得绕我打个比方。你想查自己十年前的照片不需要时光倒流只需要打开相册翻到标记着“201X年”的那一本。相册里的照片不会因为你现在胖了二十斤就突然变成最新的样子它们永远定格在拍摄那一刻。undo log版本链就是这本相册每一条历史记录都是一张老照片最新的修改则是最近刚冲洗出来的一张挂在链表的最前面。那什么时候读新照片什么时候读老照片这就要看ReadView了。2.2 ReadView是RR的定海神针ReadView是快照读的核心数据结构它记录了事务开启瞬间的“活跃事务列表”。具体来说一个ReadView会保存四个关键信息m_ids当前系统中所有活跃还没提交事务的ID列表min_trx_id活跃事务中最小的IDmax_trx_id下一个即将分配的事务ID不是当前最大活跃ID而是水位线creator_trx_id创建这个ReadView的事务自身的ID有了这个视图数据库在遍历版本链时就能用一套非常明确的规则来判断当前事务能看到哪个版本。判断规则是这样的如果版本的事务ID等于creator_trx_id说明这个版本是自己修改的当然能看。如果版本的事务ID小于min_trx_id说明这个事务在当前视图创建之前就已经提交了数据可见。如果版本的事务ID大于等于max_trx_id说明这个事务是在视图创建之后才启动的它的修改不可见。如果版本的事务ID落在m_ids列表里说明这个事务还没提交属于活跃状态不可见。从链表头部开始从新往旧逐个版本做这个判断遇到第一个可见版本就返回。这个过程就是MVCC的“M”Multi-Version发挥作用的时刻——同一行物理数据有多个版本不同事务通过不同的ReadView看到不同版本互不干扰。这里要注意ReadView的创建时机直接决定了隔离级别的行为。这正是下一节要展开的关键点。3. RR解决不可重复读的完整链路3.1 第一次读建立视图在RR隔离级别下事务的ReadView不是在BEGIN那一刻创建的而是在第一次执行快照读时创建的。这一点特别重要很多讲MySQL原理的文章容易在这带过但恰恰是这个时间点决定了RR和RC的差异。来看一个具体的执行流程。假设事务T1执行了这样一段SQLSTART 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两次查询拿到的都是同一个旧版本这就是可重复读的全部秘密。我把这个过程拆成三个步骤事务第一次快照读时生成ReadView并保存到事务对象里。后续所有的快照读操作直接复用这个ReadView不再生成新的。由于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: amount20, trx_idT1 版本2: amount10, trx_id老事务 版本1: amount10, 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-2T2已提交读到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向status1的范围里插入一条新数据并提交事务A再执行同样语句发现结果集多了一行。这就发生了幻读。InnoDB解决这个问题的办法是间隙锁Gap Lock和临键锁Next-Key Lock。间隙锁锁住的是索引记录之间的“空隙”不允许其他事务在这个空隙里插入新记录。临键锁是记录锁和间隙锁的组合锁住的是“从某个索引值到下一个索引值之间”的整个区间。拿上面的SQL举例。假设orders表里status是普通索引注意这里强调索引字段很关键status1的记录索引值分布在索引B树里。事务A执行FOR UPDATE时不仅锁住了匹配到的记录还会用临键锁锁定这些记录所在的索引区间。事务B往这个区间里插入status1的新行时会被锁阻塞必须等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行status1 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标准的RRPostgreSQL用纯MVCC实现了一个阉割版RROracle干脆不叫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%的隔离级别问题都能定位到根因。