
1. 事务隔离级别到底是什么1.1 从一个并发异常说起前几年我接手过一个订单系统的慢查询排查表面现象是某个统计接口偶尔返回重复数据DBA翻了几轮慢日志都没发现异常。最后定位到问题出在业务代码里两个事务交叉执行时一个事务读到了另一个事务尚未提交的修改。当时我的第一反应是检查代码里的Transactional配置结果发现默认的隔离级别就是MySQL的REPEATABLE READ简称RR那为什么还会读到未提交的数据查了一圈才发现那个接口走的是READ COMMITTEDRC连接的只读库压根不是默认配置。这件事之后我才认真研究了一个问题为什么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默认用RCSQL 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的主从复制主要依赖binlogbinlog有几种格式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执行期间其他事务完全可能往表里插入一条age35的新记录导致主库执行DELETE时只删了10行而从库重放同一条SQL时却删了11行——主从数据不一致。这个场景不是理论推演是早期MySQL复制经常出现的经典坑。为了避免STATEMENT格式下主从不一致MySQL干脆把默认隔离级别设成RR因为RR间隙锁能保证绝大多数STATEMENT格式的binlog在从库重放时结果一致。可以说RR是STATEMENT格式binlog的保底方案。到了MySQL 5.7之后ROW格式成为默认设置5.7.7后的默认binlog_format是ROWROW格式记录的是每行变更前后的值从库重放不依赖SQL语义RC下主从复制也不会出现数据不一致。但历史包袱已经形成RR的默认地位一直延续到现在。即便是在ROW格式下很多DBA依然不敢轻易把隔离级别切到RC一方面是因为大量存量应用依赖RR的可重复读语义另一方面是切换涉及锁行为变化需要充分验证。2.3 对比RCRC在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 ReadSELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE以及INSERT、UPDATE、DELETE语句。当前读必须读最新已提交版本并且对涉及行加锁。RR级别下快照读在事务内第一次执行时生成一个ReadView之后整个事务期间都复用这个ReadView。这就是可重复读的来源——两次快照读看到的是同一个版本链视图。而当前读每次都会读取最新数据并根据锁策略加锁。举一个实际例子事务A先执行SELECT * FROM account WHERE id1读取到余额为100。此时事务B提交了一笔余额增加到200的修改。事务A再执行同样的SELECT快照读依然看到100。但如果事务A执行UPDATE account SET balancebalance50 WHERE id1它走的是当前读读到的是最新值200再进行加50的操作最终变成250。这个行为会让很多初学者困惑不是RR可重复读吗为什么update看到的值不一样因为可重复读承诺的是快照读的一致性不承诺当前读的一致性。3.2 间隙锁与临键锁RR如何防幻读前面说了RR级别下InnoDB能防住大部分幻读靠的就是间隙锁。间隙锁锁的是一个范围区间而非具体的行。比如执行SELECT * FROM account WHERE balance BETWEEN 100 AND 200 FOR UPDATEInnoDB不仅锁住已命中的行还会锁住balance在100到200之间的所有间隙包括不存在的行以此阻止其他事务在这个区间插入新行。间隙锁和行锁合在一起叫临键锁Next-Key Lock。以索引记录为例临键锁锁住的是索引记录本身 该记录之前的间隙。这个设计保证了在RR下一个当前读扫过的区间在事务提交前不会被插入任何新数据——从这里可以看出RR防幻读依赖的是当前读加锁而不是快照读。如果业务全用快照读不加任何锁那RR下依然可能看到同一查询第二次多出一行的幻读现象严格说这属于快照读的幻读只是大多数场景没人care。这里有个很重要的结论RR的间隙锁是MySQL为了解决STATEMENT格式binlog复制一致性而付出的代价。间隙锁最大的副作用是死锁风险升高。两条事务如果以不同顺序锁定了相交的间隙区间很容易互相等待特别是删除、更新大范围数据时。3.3 一致性非锁定读与undo logInnoDB的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 status1 WHERE user_id100事务B执行UPDATE order SET status2 WHERE user_id200如果这两条记录的索引范围在间隙上重叠RR会让事务A和事务B互相等待对方释放间隙锁最终InnoDB检测到死锁后回滚其中一方。这种死锁在RC下几乎不会发生因为RC禁用了间隙锁只加行锁。解决节奏一般是两种一是把SQL改成更细的删除/更新条件尽量缩小范围二是干脆把隔离级别改成RC让间隙锁彻底消失。如果线上订单量很大排查死锁最有效的手段是打开innodb_print_all_deadlocksON把死锁信息打到错误日志再结合SHOW ENGINE INNODB STATUS分析锁等待的持有链。我看到过很多次所谓死锁其实就是两个UPDATE语句交叉更新了同一批数据加个ORDER BY让所有事务按统一顺序更新就能极大降低死锁概率。4.2 索引失效导致锁升级RR下另一个隐患是索引失效会让锁从行锁升级为表锁。准确说不是表锁而是锁住整个索引的所有范围。看这个SQLUPDATE user SET vip1 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使用或者直接在连接串里指定transactionIsolationREAD-COMMITTEDJDBC。如果是统一修改线上默认配置建议加在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的注意事项如果最终决定迁移我的经验是列一个逐项确认清单检查所有事务内有没有依赖快照读一致性的逻辑比如先查总数再分页查明细最后加总和——这类逻辑在RC下会算出不同结果。检查有没有在REPEATABLE READ下依赖幻读防护的SQL比如INSERT ... SELECT、UPDATE ... WHERE id IN (SELECT ...)。RC下这些操作可能因为缺乏间隙锁而写入重复数据。检查binlog格式建议直接改成ROW。先在预发环境做一次全量回归重点对比死锁日志、慢查询、主从延迟。灰度发布时按核心链路逐步放开别一口气全量切换。迁移过程中最容易忽略的一个点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自然能少踩很多坑。