事务隔离RC和RR的区别?从ReadView到间隙锁彻底搞懂幻读
大家好,我是数据库小学妹 👋
同一个事务里,两次SELECT返回不同结果。隔离级别是RC,事务还没提交,数据自己变了。当时我查了半个小时,确认不是代码逻辑的问题,也没有其他线程在同一个事务里执行写入操作。最终定位到原因:同一个事务内,第一次SELECT生成ReadView A,3秒后第二次SELECT生成了新的ReadView B,中间有其他事务提交了数据,ReadView B能看到这些变更而ReadView A不能。
刚转行那会儿我以为隔离级别就是四个名字加张对比表,背熟就行。RC能读到其他事务已提交的数据,RR读不到,RR解决了幻读。这些都是标准答案,但标准答案解释不了生产环境的问题。后来翻InnoDB的存储引擎实现才发现,RC和RR的差异远不是"能不能看到其他事务的提交"这么简单。它们在引擎层的ReadView生成策略完全不同,所有上层的行为差异都源于此。
ReadView:InnoDB实现多版本并发的核心结构
InnoDB通过MVCC实现读写不阻塞,核心数据结构就是ReadView。每行记录包含两个隐藏列,trx_id记录最后修改这行的事务ID,roll_pointer指向undo log中的旧版本。每次SELECT读取一行时,InnoDB拿这行的trx_id和当前ReadView做比较,通过可见性算法决定返回哪个版本的数据。
ReadView结构包含四个关键字段。m_ids是当前所有活跃未提交事务的ID列表,min_trx_id是m_ids中的最小值,max_trx_id是系统下一个要分配的事务ID,creator_trx_id是创建这个ReadView的事务自身的ID。
可见性判断分为四种情况:
- 当行的trx_id<min_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都生成新的ReadView
RC级别下,每次执行SELECT都会创建一个新的ReadView。新ReadView的m_ids反映的是执行SELECT这一刻所有活跃事务的快照,这意味着每次SELECT看到的已提交事务集合都不一样。来看一个具体时序。事务A开启,此时事务B正在执行中尚未提交,事务A执行第一次SELECT生成ReadView1,m_ids包含事务B的ID,此时看不到事务B插入的数据。然后事务B提交,事务A再次执行SELECT生成ReadView2,ReadView2的m_ids不再包含事务B,因此能看到事务B插入的rows 6和7。接着事务C插入row 3并提交,事务A第三次执行SELECT生成ReadView3,ReadView3看到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级别下) |
| 典型语句 | SELECT | SELECT FOR UPDATE、UPDATE、DELETE、INSERT |
| RR级别下是否可重复 | 是,多次执行结果相同 | 否,每次读取最新数据 |
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限制 |
|---|---|---|
| id=1 | 负无穷到1 | 无法插入id≤1的记录 |
| id=5 | 大于1到5 | 无法插入id在(1,5]区间的记录 |
| id=10 | 大于5到10 | 无法插入id在(5,10]区间的记录 |
| supremum伪记录 | 大于10到正无穷 | 无法插入id>10的记录 |
多个Next-Key Lock组合在一起,确保当前读扫描的整个范围区间内不会有其他事务插入新记录。
但间隙锁的并发代价非常明显。间隙锁会阻塞所有试图在锁定区间内执行INSERT的事务。高并发写入场景下,多个事务同时向同一个索引范围插入数据,每个事务都需要等待前面事务释放间隙锁才能完成插入,吞吐量直线下降。RC级别下没有间隙锁机制,所有INSERT操作不会被阻塞,写入并发度显著高于RR。这也是为什么在高并发写入为主的生产环境中,越来越多的架构师建议将隔离级别从RR切换为RC。
为什么生产环境越来越多人选择RC
RC级别没有间隙锁,INSERT操作不会被其他事务的当前读阻塞,高并发写入场景下的吞吐量明显高于RR级别。大多数业务场景不需要"同一个事务内多次读取结果一致"这个保证,电商订单查询、用户信息查询、商品详情读取等场景,即使同一个事务内两次读取结果不同,业务层最终使用的也是最后一次查询的结果,不可重复读对这些场景没有实际影响。真正需要可重复读保证的场景,显式使用SELECT FOR UPDATE或LOCK IN SHARE MODE加锁比依赖RR级别的隐式保证更精准、更可预测。
只要binlog_format设置为ROW,RC级别下的主从复制完全安全。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默认格式改为ROW,undo log也迁移到了独立的undo tablespace。
Oracle默认使用RC是另一套架构逻辑。Oracle的Undo数据存储在独立的undo tablespace中,有独立的redo日志保护,undo的分配和回收机制比InnoDB更成熟。Oracle的架构从设计之初就面向企业级高并发场景,RC级别没有间隙锁不会阻塞INSERT,写入吞吐量更高。Oracle的一致性读实现基于rollback segment,ReadView的生成和维护开销更低,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生成新ReadView,RR第一次SELECT生成后全程复用",面试官就知道你理解了底层机制。再补充一句"RR的快照读防幻读靠ReadView可见性判断,当前读防幻读靠间隙锁锁定索引间隙",基本稳了。
事务隔离这块刚转行时觉得特别玄乎,四个级别加各种名词,背都背不完。后来翻InnoDB源码笔记、看undo log的版本链结构、在测试环境搭建并发场景反复验证,才发现底层原理没那么神秘。核心就是一个ReadView结构加一套可见性判断逻辑,RC和RR的全部差异只是ReadView的生成时机不同。把底层机制搞清楚了,上层的各种现象就都顺理成章了,不可重复读、幻读、间隙锁、死锁,全都能用ReadView和锁机制串起来。
你的生产环境用的是RC还是RR,有没有因为隔离级别的选择不当踩过坑,来评论区聊聊你的实战经验。
我是数据库小学妹,咱们下篇见 👋
