☰
MySQL为何默认RR?从MVCC到间隙锁解析事务隔离级别的设计取舍
2026/10/20 16:34:55 网站建设 项目流程

1. 事务隔离级别到底是什么

1.1 从一个并发异常说起

前几年我接手过一个订单系统的慢查询排查,表面现象是某个统计接口偶尔返回重复数据,DBA翻了几轮慢日志都没发现异常。最后定位到问题出在业务代码里两个事务交叉执行时,一个事务读到了另一个事务尚未提交的修改。当时我的第一反应是检查代码里的@Transactional配置,结果发现默认的隔离级别就是MySQL的REPEATABLE READ(简称RR),那为什么还会读到未提交的数据?查了一圈才发现,那个接口走的是READ COMMITTED(RC)连接的只读库,压根不是默认配置。这件事之后我才认真研究了一个问题:为什么MySQL要把默认事务隔离级别定成RR,而不是像其它数据库那样选RC?

很多开发者对事务隔离级别的理解停留在课本上的定义:READ UNCOMMITTED、READ COMMITTED、REPEATABLE READ、SERIALIZABLE。但真正落到MySQL上,RR并不只是"可重复读"三个字那么简单。它牵扯到MVCC实现、锁机制、binlog格式、主从复制架构,甚至数据库历史版本的选择。这篇文章我想从一个实战者的角度,把"为什么MySQL默认RR"这件事拆开讲清楚,顺便聊聊RR在实际业务里有哪些坑、真要切RC的话该怎么做。

1.2 四种隔离级别的定义与关系

先回顾一下SQL标准里定义的四种隔离级别,它们主要解决三个并发问题:脏读(读到别人未提交的数据)、不可重复读(同一查询在事务内两次结果不一致)、幻读(同一查询在事务内两次结果集行数不一致)。四种级别对这三个问题的解决程度如下表:

隔离级别脏读不可重复读幻读
READ UNCOMMITTED可能可能可能
READ COMMITTED不可能可能可能
REPEATABLE READ不可能不可能可能
SERIALIZABLE不可能不可能不可能

按这个标准看,RR依然存在幻读可能。但MySQL的InnoDB在RR隔离级别下,通过MVCC加间隙锁(Gap Lock)的组合,实际上把标准意义下的幻读也挡住了大部分。所以MySQL的RR严格来说比SQL标准里的RR更强,这也是很多人的误区来源。

顺带说一句,SQL标准只是定义了最低要求,并没有规定数据库必须怎么实现。Oracle、PostgreSQL默认用RC,SQL Server默认用READ COMMITTED,而MySQL默认RR,这就是各家实现路线差异的结果。搞清楚MySQL为什么这么选,才是理解InnoDB并发控制的关键。

2. 为什么偏偏是RR:历史与生态的答案

2.1 从MySQL早期版本看RR的由来

MySQL的事务能力是InnoDB引擎带来的,但InnoDB成为默认引擎之前,MySQL主流的MyISAM引擎压根不支持事务。在MyISAM时代,表锁是唯一的并发控制手段,读写互相阻塞,业务对事务的需求只能靠应用层自己保证。后来InnoDB引入MVCC之后,MySQL才真正拥有了行级锁和事务能力。

InnoDB的MVCC设计目标是"读写互不阻塞",也就是读操作不加锁,靠undo log构建历史版本;写操作加行锁。这套设计天然适合实现可重复读:只要事务开始后,每次快照读都基于同一个一致性视图,那么两次读到的结果一定一样。MSQL开发团队在设计InnoDB时,把一致性读(Consistent Read)作为MVCC的核心能力,而RR恰好能最大程度发挥一致性读的价值——事务内读到的数据版本完全一致。

如果选RC的话,InnoDB的MVCC在每条语句执行时都会重新生成一个新的一致性视图,也就是说每个语句看到的可能是不同的数据快照。这在实现上并不难,难的是它改写了InnoDB对"快照"的定义,并且会影响复制环境下binlog的记录方式。可以说,MySQL选择RR不是因为它比RC更优秀,而是因为RR与InnoDB MVCC的历史实现、binlog复制机制结合得最顺。

2.2 binlog格式与主从复制:RR的关键价值

这是整个问题最核心的技术点。MySQL的主从复制主要依赖binlog,binlog有几种格式:STATEMENT(记录SQL语句)、ROW(记录行变更)、MIXED(混合模式)。在MySQL 5.0时代,默认binlog格式其实没有统一,很多旧版本默认用STATEMENT格式,因为STATEMENT格式日志量小,可读性强,也方便做审计。

STATEMENT格式下,从库执行的是主库传过来的SQL语句。想象这样一个场景:事务T1在主库执行了一条DELETE FROM users WHERE age > 30,删除了10行数据。如果这个事务在RR隔离级别下执行,间隙锁会锁住所有符合条件以及可能符合条件的数据区间,保证执行期间没有其他事务插入新行。这样主库生成的DELETE语句,拿到从库重新执行时,删除的行数完全一致。如果换成RC,由于RC下间隙锁会被禁用,T1执行期间,其他事务完全可能往表里插入一条age=35的新记录,导致主库执行DELETE时只删了10行,而从库重放同一条SQL时却删了11行——主从数据不一致。

这个场景不是理论推演,是早期MySQL复制经常出现的经典坑。为了避免STATEMENT格式下主从不一致,MySQL干脆把默认隔离级别设成RR,因为RR+间隙锁能保证绝大多数STATEMENT格式的binlog在从库重放时结果一致。可以说,RR是STATEMENT格式binlog的"保底方案"。

到了MySQL 5.7之后,ROW格式成为默认设置(5.7.7后的默认binlog_format是ROW),ROW格式记录的是每行变更前后的值,从库重放不依赖SQL语义,RC下主从复制也不会出现数据不一致。但历史包袱已经形成,RR的默认地位一直延续到现在。即便是在ROW格式下,很多DBA依然不敢轻易把隔离级别切到RC,一方面是因为大量存量应用依赖RR的可重复读语义,另一方面是切换涉及锁行为变化,需要充分验证。

2.3 对比RC:RC在MySQL中的真实处境

RC最大的优点是减少了间隙锁的使用,从而降低了死锁概率、提升了并发写入能力。这个优点在互联网高并发场景下非常明显。但RC在MySQL里也有个容易踩的坑:RC下InnoDB的MVCC快照是语句级快照,不是事务级快照。一个事务内每条SELECT都会拿最新的已提交版本,这会导致同一个长事务里前两次读到的数据不一样——也就是教科书说的不可重复读。

另外,RC下binlog如果用STATEMENT格式,会出现上面说的主从不一致问题,所以如果业务非要切RC,最好把binlog格式改成ROW或MIXED(实际上ROW更稳)。由于这些限制,很多从Oracle迁移过来的团队,都习惯把接口层按"语句提交"的语义去设计业务,到了MySQL发现默认是RR,长事务里数据看起来"永远不变",反而容易写出在RC下会出bug的逻辑。

我个人的看法是:RR和RC没有绝对的优劣,选哪个取决于业务模型和历史包袱。但如果你没搞懂MySQL为什么默认RR,你就很难判断该不该切换到RC。这也是我写这篇文章想解决的痛点。

3. RR背后的实现机制:MVCC与锁的协作

3.1 快照读与当前读

理解RR的实现,必须先分清InnoDB的两种读方式:

  • 快照读(Snapshot Read):普通的SELECT语句,不加FOR UPDATE或LOCK IN SHARE MODE。快照读不阻塞,直接读undo log版本链,返回事务开始时的可见版本。
  • 当前读(Current Read):SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE,以及INSERT、UPDATE、DELETE语句。当前读必须读最新已提交版本,并且对涉及行加锁。

RR级别下,快照读在事务内第一次执行时生成一个ReadView,之后整个事务期间都复用这个ReadView。这就是"可重复读"的来源——两次快照读看到的是同一个版本链视图。而当前读每次都会读取最新数据,并根据锁策略加锁。

举一个实际例子:事务A先执行SELECT * FROM account WHERE id=1,读取到余额为100。此时事务B提交了一笔余额增加到200的修改。事务A再执行同样的SELECT,快照读依然看到100。但如果事务A执行UPDATE account SET balance=balance+50 WHERE id=1,它走的是当前读,读到的是最新值200,再进行加50的操作,最终变成250。这个行为会让很多初学者困惑:不是RR可重复读吗?为什么update看到的值不一样?因为可重复读承诺的是快照读的一致性,不承诺当前读的一致性。

3.2 间隙锁与临键锁:RR如何防幻读

前面说了,RR级别下InnoDB能防住大部分幻读,靠的就是间隙锁。间隙锁锁的是一个范围区间,而非具体的行。比如执行SELECT * FROM account WHERE balance BETWEEN 100 AND 200 FOR UPDATE,InnoDB不仅锁住已命中的行,还会锁住balance在100到200之间的所有间隙(包括不存在的行),以此阻止其他事务在这个区间插入新行。

间隙锁和行锁合在一起叫临键锁(Next-Key Lock)。以索引记录为例,临键锁锁住的是"索引记录本身 + 该记录之前的间隙"。这个设计保证了在RR下,一个当前读扫过的区间,在事务提交前不会被插入任何新数据——从这里可以看出,RR防幻读依赖的是当前读加锁,而不是快照读。如果业务全用快照读,不加任何锁,那RR下依然可能看到"同一查询第二次多出一行"的幻读现象(严格说,这属于"快照读的幻读"),只是大多数场景没人care。

这里有个很重要的结论:RR的间隙锁,是MySQL为了解决STATEMENT格式binlog复制一致性而付出的代价。间隙锁最大的副作用是死锁风险升高。两条事务如果以不同顺序锁定了相交的间隙区间,很容易互相等待,特别是删除、更新大范围数据时。

3.3 一致性非锁定读与undo log

InnoDB的MVCC依赖undo log,它记录了每一行的历史版本。当一个事务需要读取某个行时,如果发现该行当前版本对当前事务不可见(比如是别的事务刚修改且未提交的),就沿着undo log版本链往回找,找到第一个对当前事务可见的版本。这就是一致性非锁定读(Consistent Nonlocking Read)。

RR下,事务第一次快照读产生的ReadView包含了"活跃事务ID列表"和"最小活跃事务ID""最大事务ID"等关键字段。后续所有快照读都基于这个ReadView判断可见性。所以即使期间其他事务提交了大量数据,RR事务依然只认自己启动那一刻的快照。

RC下则不同,每条SELECT语句都会重新生成一个新的ReadView,所以RC能看到事务启动之后其他事务提交的数据变化。这个差异是MVCC实现里最本质的分水岭。补一句在真实业务中的感受:长事务在RR下会持有旧快照,如果这个事务还要去更新这些行,就可能出现更新丢失或因为版本冲突导致的锁等待。这也是很多"分页查询+批量更新"场景在RR下比RC更容易出问题的原因。

4. RR带来哪些坑?实战中的经验

4.1 死锁风险与间隙锁范围

我自己在生产环境踩过最典型的死锁场景,就是两条不同路径的更新操作命中了同一个间隙。比如订单表有user_id和status两个索引,事务A执行UPDATE order SET status=1 WHERE user_id=100,事务B执行UPDATE order SET status=2 WHERE user_id=200,如果这两条记录的索引范围在间隙上重叠,RR会让事务A和事务B互相等待对方释放间隙锁,最终InnoDB检测到死锁后回滚其中一方。

这种死锁在RC下几乎不会发生,因为RC禁用了间隙锁,只加行锁。解决节奏一般是两种:一是把SQL改成更细的删除/更新条件,尽量缩小范围;二是干脆把隔离级别改成RC,让间隙锁彻底消失。

如果线上订单量很大,排查死锁最有效的手段是打开innodb_print_all_deadlocks=ON,把死锁信息打到错误日志,再结合SHOW ENGINE INNODB STATUS分析锁等待的持有链。我看到过很多次,所谓死锁其实就是两个UPDATE语句交叉更新了同一批数据,加个ORDER BY让所有事务按统一顺序更新,就能极大降低死锁概率。

4.2 索引失效导致锁升级

RR下另一个隐患是索引失效会让锁从行锁升级为表锁。准确说不是"表锁",而是锁住整个索引的所有范围。看这个SQL:

UPDATE user SET vip=1 WHERE update_time > '2024-01-01'

如果update_time上没有索引,InnoDB需要全表扫描找到需要更新的行,扫描过程中会对所有扫描到的记录加锁,包括不符合条件的记录(因为当前读要判断)。最终实际上就是给整个表的所有行都加了锁。这时候并发其他事务的插入、更新都会被阻塞,系统并发能力一落千丈。

线上排查时,直接用EXPLAIN看执行计划里key是不是NULL。如果是NULL,先补索引再谈隔离级别的事。尤其是RR下,索引失效不仅仅是性能问题,更是锁范围失控的导火索。

4.3 复制的坑:binlog_format与隔离级别的关系

再说回复制,虽然5.7之后默认ROW格式减少了复制不一致的风险,但很多老系统依然沿用STATEMENT或MIXED格式,或者某些管理工具会主动指定binlog_format。在这种环境下如果贸然把隔离级别改成RC,就要面对主从数据不一致的隐患。

我见过一个事故:某团队把MySQL 5.6实例的隔离级别从RR改成RC,跑了一段时间后发现从库数据比主库多了几千行。追查原因是binlog格式还是STATEMENT,某条DELETE语句在RC下主库执行时,由于间隙锁缺失,执行期间有新的符合条件数据插入,导致主从删除行数不一致。从那以后我但凡听到有人要切RC,第一句话就是:先确认binlog_format,建议切换之前做一轮全量对比,并且把MIXED格式也一并升级成ROW。

只要用ROW格式,RC的主从一致性问题基本不存在。但ROW格式也有代价:日志量更大,特别是大批量更新时,binlog体积可能比语句级格式大几十倍,对磁盘、网络和复制延迟都有影响。这些都需要在架构上提前做好评估。

5. 如果我想用RC怎么办:切换方案与权衡

5.1 修改默认隔离级别的方法

MySQL的隔离级别可以在三个层面设置:

  • 全局级:SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;
  • 会话级:SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;
  • 单事务级:START TRANSACTION WITH CONSISTENT SNAPSHOT;后面配合SET TRANSACTION ISOLATION LEVEL使用,或者直接在连接串里指定transactionIsolation=READ-COMMITTED(JDBC)。

如果是统一修改线上默认配置,建议加在my.cnf的[mysqld]段:

transaction-isolation = READ-COMMITTED

改完后需要重启实例才能对所有新连接生效。注意这个参数对已有连接没有影响,所以灰度发布时,可以分批重启应用服务器,让新的数据库连接池里的连接逐步切到新配置。

这里额外提醒一点:修改隔离级别之前,先检查一下当前连接池的空闲连接。如果连接池里有老连接,即使数据库全局改了,这些老连接仍然沿用老的会话级隔离级别。用JDBC的话,务必在数据源配置里显式指定ISOLATION_READ_COMMITTED,避免连接复用导致配置不生效。

5.2 RC与RR在性能、一致性上的实测对比

我拿一个实际的报表场景做过对比测试。表结构是用户的积分明细,数据量一千万,并发写多读多。在RR下,一条UPDATE语句如果命中索引范围较大,死锁日志经常一堆;改成RC后,同样压测工具、同样并发数,吞吐量提升约20%,死锁次数直接归零。原因是RC去掉间隙锁后,锁粒度更小,锁等待更少。

读性能方面,RC和RR在快照读上的差异很小,因为MVCC读本身不加锁,两者最大的差异集中在写事务和混合负载。如果你的业务以读为主,几乎感受不到差别。如果以写为主,RC的收益会很明显。

但一致性语义上,RC做了很大的让步:一个事务内两次相同的SELECT结果可能不一样。这对需要"事务内一致性视图"的业务(如统计、对账、报表生成)是致命的。不可重复读可能导致报表数字前后对不上,一旦出现账不平,排查成本远高于那点性能收益。

所以我的建议是:不要盲目跟风切RC。只有业务确实要高频更新小范围数据、死锁率已经高到不能接受、对事务内读一致性要求不高时,RC才是合理选择。

5.3 从RR迁移到RC的注意事项

如果最终决定迁移,我的经验是列一个逐项确认清单:

  1. 检查所有事务内有没有依赖快照读一致性的逻辑,比如先查总数再分页查明细,最后加总和——这类逻辑在RC下会算出不同结果。
  2. 检查有没有在REPEATABLE READ下依赖幻读防护的SQL,比如INSERT ... SELECT、UPDATE ... WHERE id IN (SELECT ...)。RC下这些操作可能因为缺乏间隙锁而写入重复数据。
  3. 检查binlog格式,建议直接改成ROW。
  4. 先在预发环境做一次全量回归,重点对比死锁日志、慢查询、主从延迟。
  5. 灰度发布时按核心链路逐步放开,别一口气全量切换。

迁移过程中最容易忽略的一个点:RC下UPDATE一个不存在的行,不会像RR那样产生间隙锁,这样不同事务可能同时"成功"更新不存在的数据,从而产生逻辑上的丢失更新。如果业务有这种"先查再更"的场景,需要加SELECT ... FOR UPDATE显式锁行,或者改造为基于版本号的乐观锁。

6. 常见问题速查与面试追问

6.1 RR真的能完全防幻读吗

不能完全防。前面提到,快照读下,RR只是通过ReadView保证每次读到的数据版本一致,它并不保证新插入的行不出现。如果事务A在RR下启动快照后,事务B插入了一行新记录并提交,事务A再用普通的SELECT查询,是看不到这行新记录的——这其实已经不是严格意义的幻读,因为"A看不到新行"反而是防住了。真正的问题是如果事务A改用当前读(比如SELECT ... FOR UPDATE),它看到的会是最新数据,包括B刚插入的行,这时候如果A还想插入一条和B冲突的数据,就可能触发唯一键冲突,这在业务上会造成一种"我明明查过没有,插入时却报冲突"的现象。所以严谨的说法是:RR在当前读下,通过间隙锁阻止了间隔内插入,但间隙锁并不能锁住一个完全不存在的索引键末尾,某些边界条件下依然会出现幻读。这也是为什么SERIALIZABLE完全加表锁/范围锁的原因。

6.2 RR下为什么还要加间隙锁

答案在前面已经提到:早期默认STATEMENT格式binlog需要从库重放语句,为了保证执行结果一致,必须防止在语句执行期间有其他事务插入新行。间隙锁就是这个需求的副产品。如果只用ROW格式binlog,间隙锁的复制一致性作用就大大削弱,这也是为什么很多人在纯ROW格式环境下会考虑切RC。

6.3 为什么有人说RC更适合互联网业务

互联网业务高并发、短事务、写多读多,目标是尽量降低锁冲突、提高吞吐。RC减少间隙锁,确实更贴近这类需求。同时,互联网业务对"事务内可重复读"的需求不如金融、对账那么强,大部分业务即使出现两次读结果不同,也不会导致严重事故。再加上现代MySQL默认ROW格式binlog解决了复制一致性担忧,RC的论据越来越充分。

但要注意一点:RC下事务备忘录性质减弱,一个事务内多语句的一致性视图被切割成一条条语句级别视图,这在复杂业务逻辑里(比如先读取余额、再计算基金净值、最后更新多张表)会引入很多难以察觉的bug。所以即便RC在其他公司用的好,迁移到你的业务前也必须做事务边界审查。

7. 写在最后的几点实操心得

MySQL把默认隔离级别定为RR,本身就是一整套工程取舍的结果,跟"谁的隔离级别更高级"没有关系。我这些年处理过的事务问题中,八成以上不是隔离级别本身的错,而是开发人员根本不知道自己的SQL到底在用什么锁、读什么版本。很多人抱怨RR死锁多,其实改成RC只是把问题藏起来,SQL本身的锁范围失控一样会造成阻塞。

如果你目前在纠结要不要换隔离级别,我给一个最朴素的建议:先打开innodb_status_output_locks和innodb_print_all_deadlocks,用几周时间收集真实业务的死锁与锁等待日志,把每次死锁对应的SQL和事务执行顺序理清楚。大多数情况下,调整SQL顺序、缩小事务范围、补上缺失索引,比直接切换隔离级别更有效。等把这些硬功夫做到位,仍然发现RC的收益明显,再切换也不迟。

MySQL的事务和锁机制,永远要把"隔离级别的语义"和"InnoDB的具体实现"放在一起理解。默认RR这个设计,背后是MVCC一致性读、间隙锁、binlog复制三个齿轮咬合在一起的结果。把这个跷跷板想清楚,你写出来的SQL自然能少踩很多坑。

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

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

立即咨询