事务隔离RC和RR的区别?从ReadView到间隙锁彻底搞懂幻读

发布时间:2026/7/29 18:51:28
事务隔离RC和RR的区别?从ReadView到间隙锁彻底搞懂幻读 大家好我是数据库小学妹 同一个事务里两次SELECT返回不同结果。隔离级别是RC事务还没提交数据自己变了。当时我查了半个小时确认不是代码逻辑的问题也没有其他线程在同一个事务里执行写入操作。最终定位到原因同一个事务内第一次SELECT生成ReadView A3秒后第二次SELECT生成了新的ReadView B中间有其他事务提交了数据ReadView B能看到这些变更而ReadView A不能。刚转行那会儿我以为隔离级别就是四个名字加张对比表背熟就行。RC能读到其他事务已提交的数据RR读不到RR解决了幻读。这些都是标准答案但标准答案解释不了生产环境的问题。后来翻InnoDB的存储引擎实现才发现RC和RR的差异远不是能不能看到其他事务的提交这么简单。它们在引擎层的ReadView生成策略完全不同所有上层的行为差异都源于此。ReadViewInnoDB实现多版本并发的核心结构InnoDB通过MVCC实现读写不阻塞核心数据结构就是ReadView。每行记录包含两个隐藏列trx_id记录最后修改这行的事务IDroll_pointer指向undo log中的旧版本。每次SELECT读取一行时InnoDB拿这行的trx_id和当前ReadView做比较通过可见性算法决定返回哪个版本的数据。ReadView结构包含四个关键字段。m_ids是当前所有活跃未提交事务的ID列表min_trx_id是m_ids中的最小值max_trx_id是系统下一个要分配的事务IDcreator_trx_id是创建这个ReadView的事务自身的ID。可见性判断分为四种情况当行的trx_idmin_trx_id时说明修改者是已经提交的老事务该版本对当前事务可见。当行的trx_id≥max_trx_id时说明修改者是尚未开始的未来事务该版本不可见需要沿roll_pointer回退到undo log中的旧版本继续判断。当行的trx_id存在于m_ids中时说明修改者还是活跃事务尚未提交同样不可见取undo旧版本。当行的trx_id不在m_ids中且大于等于min_trx_id时说明修改者已经提交该版本可见。这套可见性判断算法对RC和RR完全一致。真正决定两个隔离级别行为差异的是ReadView在什么时候生成、在什么时候复用。对比维度RC读已提交RR可重复读ReadView生成时机每次SELECT都生成新ReadView第一次SELECT生成后全程复用同一事务内多次SELECT结果集可能不同结果集始终相同是否出现不可重复读是否快照读层面是否出现幻读是快照读和当前读均可能快照读不会当前读仍可能间隙锁Gap Lock无有并发写入性能更高INSERT不被阻塞较低INSERT可能被间隙锁阻塞死锁风险低无间隙锁冲突较高间隙锁可能引发循环等待典型应用场景高并发写入、互联网业务需要事务内读一致性的场景RC每次SELECT都生成新的ReadViewRC级别下每次执行SELECT都会创建一个新的ReadView。新ReadView的m_ids反映的是执行SELECT这一刻所有活跃事务的快照这意味着每次SELECT看到的已提交事务集合都不一样。来看一个具体时序。事务A开启此时事务B正在执行中尚未提交事务A执行第一次SELECT生成ReadView1m_ids包含事务B的ID此时看不到事务B插入的数据。然后事务B提交事务A再次执行SELECT生成ReadView2ReadView2的m_ids不再包含事务B因此能看到事务B插入的rows 6和7。接着事务C插入row 3并提交事务A第三次执行SELECT生成ReadView3ReadView3看到row 3被删除后的状态。同一个事务A三次执行完全相同的SELECT语句三次返回的结果集不同。这就是开头那个生产问题的根因。当时一个报表查询事务在RC级别下执行了15秒中间有其他事务陆续提交了新订单数据同一事务内的后续聚合查询看到了不同的基础数据集最终报表的总计金额和明细对不上。RC的逻辑很直接只看别人已经提交的数据。代价是同一个事务内多次读取结果可能不一致这个现象叫做不可重复读。不过说句实在话不可重复读对大多数业务场景不是问题。一个订单详情查询在一个事务里执行两次即使第二次看到了新插入的订单行业务层最终使用的也是最后一次查询的结果。真正依赖可重复读保证的场景只有两种。报表生成需要在同一事务内多次执行SUM或COUNT等聚合函数要求每次聚合的基础数据集一致。批量状态机推进需要先SELECT确认待处理的数据集再逐条UPDATE状态变更要求SELECT和UPDATE操作的数据集相同。这两种场景显式使用SELECT FOR UPDATE加锁比依赖隔离级别的可重复读保证更可靠。RR第一次SELECT生成ReadView后全程复用RR级别的ReadView策略完全不同。事务内第一次执行SELECT时生成ReadView后续所有SELECT语句都复用这个ReadView不再创建新的。回到上面的时序。事务A开启并执行第一次SELECT生成ReadView1这个ReadView在整个事务A的生命周期内唯一。事务B插入新记录并提交事务A再次执行SELECT复用ReadView1结果集保持不变仍然是初始的5条记录。事务C删除一条记录并提交事务A第三次执行SELECT依然复用ReadView1结果集还是那5条。关键在于ReadView1在T1时刻生成时事务B和事务C都尚未提交它们的ID被记录在m_ids列表中。即使后续事务B和C执行了COMMIT事务A持有的ReadView1仍然依据生成时的m_ids列表进行可见性判断认为这两个事务的修改不可见。这就是RR级别下可重复读的底层实现。不是通过行锁把数据锁住不让其他事务修改而是通过ReadView的版本控制让本事务看不见其他事务的修改。其他事务在RR级别下照样可以执行INSERT、UPDATE、DELETE操作只是当前事务通过ReadView过滤掉了这些变更。但我刚理解时有个误区。以为RR的可重复读意味着其他事务不能修改我正在读的数据。实际上RR级别下的普通SELECT不加任何锁其他事务的写入操作完全不受影响只是本事务通过ReadView的版本判断看不到这些写入。可重复读是一种视觉隔离不是物理隔离。快照读与当前读RR的可重复读只覆盖一半InnoDB中的读操作分为两种类型。快照读是普通的SELECT语句不加锁通过ReadView结合undo log中保存的历史版本链构造出该事务视角下的数据快照。当前读包括SELECT FOR UPDATE、SELECT LOCK IN SHARE MODE、UPDATE、DELETE、INSERT这些操作读取的是磁盘上的最新数据版本完全绕过ReadView的可见性判断。修改数据时必须基于最新状态如果当前读也走快照机制就会基于过时的数据版本执行修改把其他事务已经提交的变更覆盖掉。两者的核心差异如下对比维度快照读当前读是否走ReadView可见性判断是否读取的数据版本ReadView时刻的历史版本磁盘上的最新版本是否加锁不加锁加排他锁或共享锁是否触发间隙锁否是RR级别下典型语句SELECTSELECT FOR UPDATE、UPDATE、DELETE、INSERTRR级别下是否可重复是多次执行结果相同否每次读取最新数据RR级别下同一个事务内的快照读和当前读可以返回完全不同的结果集。执行SELECT WHERE user_id等于100的快照读返回5条记录在此期间另一个事务插入了user_id等于100的新记录并提交再执行SELECT WHERE user_id等于100 FOR UPDATE的当前读返回6条记录。同一个事务同样的WHERE条件快照读5条当前读6条。当前读不走ReadView可见性判断直接读取每条记录的最新版本。我排查过一个线上的批量处理任务。该任务在RR级别下先通过普通SELECT确认需要处理的订单数量是10条然后执行UPDATE批量更新状态结果实际影响了12条记录。EXPLAIN分析UPDATE的执行计划发现UPDATE作为当前读操作会扫描到所有匹配WHERE条件的最新数据行包括中间其他事务插入的2条新记录。UPDATE对这12条记录逐一加排他锁并执行状态变更。快照读看到10条UPDATE实际修改12条这就是RR级别下幻读的具体表现。教科书上说RR解决了幻读这个表述只对了一半。RR通过ReadView机制解决了快照读层面的幻读保证同一个事务内多次普通SELECT返回相同结果集。但当前读层面的幻读ReadView无能为力因为当前读根本不经过ReadView。这里有个关键区别。RC不承诺可重复读每次SELECT拍新快照所以RC下不存在防幻读的需求——幻读就是RC的正常行为。RR承诺了快照读的可重复读但当前读打破了这个承诺所以RR必须额外引入间隙锁来补上当前读这块的幻读漏洞。当前读这块的幻读防护得靠间隙锁来补。间隙锁RR级别下当前读防幻读的核心机制当前读操作不仅会对匹配WHERE条件的索引记录加排他锁或共享锁还会对记录之间的间隙加锁。这里需要先澄清一个前提间隙锁只在非唯一索引上生效。如果WHERE条件命中主键或唯一索引的精确匹配InnoDB只加记录锁不加间隙锁。间隙锁针对的是非唯一索引的范围查询和等值查询匹配不到记录的场景。间隙是指B树索引中两个相邻记录之间的范围。假设orders表的id列是主键索引现有记录的id值分别为1、5、10、20。当执行WHERE id等于8 FOR UPDATE时id等于8的记录不存在但InnoDB仍然会执行加锁操作。它会在索引中id等于5和id等于10之间的间隙上加Gap Lock锁定范围为大于5且小于10的区间。其他事务尝试在这个区间内插入任何id值的新记录时都会被阻塞直到当前事务释放锁。间隙锁的加锁范围还会延伸到supremum伪记录这是InnoDB在每个索引页末尾设置的虚拟记录代表该页之后的无限范围。当WHERE条件没有精确匹配到任何现有记录时InnoDB会锁定从最后一个现有记录到supremum的整个范围。间隙锁与记录锁组合形成Next-Key Lock锁定范围是前开区间后闭区间。以id值1、5、10为例索引记录值Next-Key Lock锁定区间其他事务INSERT限制id1负无穷到1无法插入id≤1的记录id5大于1到5无法插入id在(1,5]区间的记录id10大于5到10无法插入id在(5,10]区间的记录supremum伪记录大于10到正无穷无法插入id10的记录多个Next-Key Lock组合在一起确保当前读扫描的整个范围区间内不会有其他事务插入新记录。但间隙锁的并发代价非常明显。间隙锁会阻塞所有试图在锁定区间内执行INSERT的事务。高并发写入场景下多个事务同时向同一个索引范围插入数据每个事务都需要等待前面事务释放间隙锁才能完成插入吞吐量直线下降。RC级别下没有间隙锁机制所有INSERT操作不会被阻塞写入并发度显著高于RR。这也是为什么在高并发写入为主的生产环境中越来越多的架构师建议将隔离级别从RR切换为RC。为什么生产环境越来越多人选择RCRC级别没有间隙锁INSERT操作不会被其他事务的当前读阻塞高并发写入场景下的吞吐量明显高于RR级别。大多数业务场景不需要同一个事务内多次读取结果一致这个保证电商订单查询、用户信息查询、商品详情读取等场景即使同一个事务内两次读取结果不同业务层最终使用的也是最后一次查询的结果不可重复读对这些场景没有实际影响。真正需要可重复读保证的场景显式使用SELECT FOR UPDATE或LOCK IN SHARE MODE加锁比依赖RR级别的隐式保证更精准、更可预测。只要binlog_format设置为ROWRC级别下的主从复制完全安全。ROW格式的binlog记录的是每行数据的实际变更内容包含变更前后的完整行镜像从库重放时直接应用这些变更不存在RC级别下因READ VIEW不同导致的主从不一致问题。而早期的STATEMENT格式binlog记录的是SQL语句本身从库重放SQL时会生成新的ReadView在RC级别下可能看到与主库执行时不同的数据版本导致主从数据不一致。那为什么MySQL默认的隔离级别是RR。这主要是历史原因。早期MySQL的binlog默认使用STATEMENT格式RC级别下主从复制不安全MySQL选择RR作为默认级别来规避这个问题。早期版本的InnoDB中undo log与业务数据共享同一个表空间RR级别的ReadView复用策略减少了undo版本的保留时间降低了存储膨胀风险。这两个历史限制在MySQL 8.0时代已经不复存在binlog默认格式改为ROWundo log也迁移到了独立的undo tablespace。Oracle默认使用RC是另一套架构逻辑。Oracle的Undo数据存储在独立的undo tablespace中有独立的redo日志保护undo的分配和回收机制比InnoDB更成熟。Oracle的架构从设计之初就面向企业级高并发场景RC级别没有间隙锁不会阻塞INSERT写入吞吐量更高。Oracle的一致性读实现基于rollback segmentReadView的生成和维护开销更低RC下的数据一致性风险更可控。两种数据库的不同默认选择反映的是不同的架构演进路径和设计取舍。Oracle从诞生起就面向企业高并发选择RC是自然的结果。MySQL从轻量级数据库起家早期受限于STATEMENT格式binlog和undo存储方式选择RR是历史的妥协。没有绝对的对错只有适合场景的选择。具体选型可参考下表决策因素选RC选RR写入并发量高并发INSERT/UPDATE为主读多写少并发压力小事务内读一致性不需要或通过显式加锁实现需要且不便改造代码死锁敏感度低不希望间隙锁引发死锁可接受偶发死锁主从复制架构ROW格式binlog任意格式团队经验熟悉显式加锁的最佳实践依赖数据库默认行为国内大厂的生产环境基本都转向了RC。阿里和字节的数据库架构都是RC配合ROW格式binlog保证主从复制安全在需要强一致性的业务场景中使用显式加锁。我之前负责的一个电商订单系统将隔离级别从RR改为RC后写入吞吐量提升了约30%间隙锁相关的死锁从每周2到3次降为零。改造过程中重新审查了所有依赖可重复读的业务逻辑最终发现只有2个场景真正需要这个保证改为显式加锁读后运行稳定。避坑清单从RR切换到RC前必须全面排查所有依赖可重复读保证的业务逻辑。重点关注同一事务内先执行普通SELECT确认数据范围再执行UPDATE或DELETE进行批量修改的场景。在RR级别下SELECT快照读和UPDATE当前读看到的数据范围是一致的因为ReadView保证了SELECT时点的数据快照在整个事务内不变。在RC级别下SELECT看到的是执行瞬间的快照UPDATE看到的是磁盘最新数据两者可能不同。依赖SELECT和UPDATE结果一致性的逻辑必须改为SELECT FOR UPDATE确保两次操作基于同一数据版本。RC级别下主从复制必须使用ROW格式的binlog。STATEMENT格式在RC级别下会导致主从不一致因为从库重放SQL时生成的ReadView与主库执行时不同。MySQL 8.0版本默认binlog_format已经是ROW但5.7版本仍需要手动配置binlog_format等于ROW。切换隔离级别前务必检查binlog格式否则可能引发隐蔽的主从数据不一致问题。面试时别只背RC能读到已提交数据RR读不到这种表层结论。说清楚RC每次SELECT生成新ReadViewRR第一次SELECT生成后全程复用面试官就知道你理解了底层机制。再补充一句RR的快照读防幻读靠ReadView可见性判断当前读防幻读靠间隙锁锁定索引间隙基本稳了。事务隔离这块刚转行时觉得特别玄乎四个级别加各种名词背都背不完。后来翻InnoDB源码笔记、看undo log的版本链结构、在测试环境搭建并发场景反复验证才发现底层原理没那么神秘。核心就是一个ReadView结构加一套可见性判断逻辑RC和RR的全部差异只是ReadView的生成时机不同。把底层机制搞清楚了上层的各种现象就都顺理成章了不可重复读、幻读、间隙锁、死锁全都能用ReadView和锁机制串起来。你的生产环境用的是RC还是RR有没有因为隔离级别的选择不当踩过坑来评论区聊聊你的实战经验。我是数据库小学妹咱们下篇见