MySQL InnoDB可重复读原理:快照读与MVCC如何解决不可重复读
先说结论很多人把“RR读可以解决可重复读”当绕口令背其实这句话拆开是两层意思。第一层RR是MySQL InnoDB默认隔离级别全称REPEATABLE READ中文就叫“可重复读”第二层RR能解决“不可重复读”这个问题靠的并不是行锁而是快照读配合MVCC。如果你被这个命名绕晕过或者在面试、排障时对“为什么两个查询结果一样、更新后却变了一行”这类现象说不清原理这篇文章就是帮你把整条链路理顺的。下面所有内容都以MySQL InnoDB引擎为准其他数据库实现略有差异但思路大体相同。1. 先把概念拧对RR隔离级别、可重复读问题和两种读模式1.1 四个隔离级别防的是哪三种“读异常”数据库事务的隔离级别标准SQL里一共四级读未提交RU、读已提交RC、可重复读RR、串行化Serializable。每一级都在防三类“读异常”脏读、不可重复读、幻读。隔离级别脏读不可重复读幻读读未提交RU可能可能可能读已提交RC不会可能可能可重复读RR不会不会标准SQL下仍可能InnoDB下基本避免串行化Serializable不会不会不会我说一下这三者的区别别靠背靠理解。脏读是事务A读到了事务B还没提交的修改B万一回滚A拿到的是假数据。不可重复读是事务A在同一个事务里两次读同一行第一次读到的是100第二次读到的是80因为这个过程中事务B提交了对这行数据的修改。幻读则是事务A在同一个事务里执行同一个范围查询第一次查出10条第二次查出11条中间被事务B插入了一行“冒出来”的新记录。注意看术语的微妙之处不可重复读是“同一行值变了”幻读是“结果集多了一行”。一个是UPDATE造成的一个是INSERT造成的这是两个不同维度的东西。很多人面试时把这两个混在一起说一开口就知道没踩过线上坑。1.2 不可重复读到底怎么个“不可重复”举个最直观的例子。你在一个转账系统里开了一个事务第一步查询账户余额查到100元。此时另一个事务给这个账户打了一笔80元款项并且提交了数据库里的真实余额已经变成180元。你还没来得及做后续操作事务中第二次又查了一次余额发现变成180元。两次查询结果不一样这叫不可重复读。它带来的实际问题是你的事务可能在同一份数据上做了两次判断第一次和第二次结论不同业务规则就崩了。比如先用余额判断是否够支付第二次读到的余额又不同可能多扣、漏扣、重复扣。“可重复读”这个词本身又是RR隔离级别的名字所以“RR读可以解决可重复读”这种说法说白了就是设置成RR隔离级别之后就能保证事务内多次读取结果一致把不可重复读这个现象解决掉。名字和问题同名纯粹是历史命名造成的巧合理解成“锁”就够了不用在字面意思上较劲。1.3 关键铺垫快照读与当前读想要真正搞懂RR为什么能解决可重复读光知道隔离级别定义没有用必须理解InnoDB把读操作分成了两类。快照读普通的SELECT语句不加任何显式锁。它读取的是事务启动时或第一次查询时生成的一份“历史快照”不关心其他事务是不是刚提交了修改。当前读SELECT ... FOR UPDATE、SELECT ... LOCK IN SHARE MODE、UPDATE、DELETE等操作。它永远读取最新已提交版本并且会对读取的记录加锁防止别的并发事务同时修改。这个区分是理解整个MVCC的钥匙。RR解决可重复读本质上靠的是快照读而当前读有自己的一套加锁逻辑用来解决另一部分问题。两个机制配合才形成InnoDB里完整的可重复读保证。记住一句话读的不锁写的不等读写不互相阻塞。这是InnoDB高并发性能的根基而MVCC就是实现这种并发控制的核心技术。2. 快照读的核心MVCC版本链与Read View2.1 每行数据背后都藏着一条“历史版本链表”MVCC全称是Multiversion Concurrency Control多版本并发控制。它的核心思想是一行数据被更新时不直接物理覆盖旧值而是把旧值放到undo log里新值留在当前记录中。InnoDB的聚簇索引记录中隐藏着三个关键字段DB_TRX_ID最近一次修改该行的事务ID。DB_ROLL_PTR回滚指针指向undo log中该行的上一个历史版本。DB_ROW_ID行ID在没有主键时自动生成实际使用中通常涉及隐藏主键。举个例子。有一行数据id1初始版本balance100由事务ID88写入。事务A把它改成120事务B再改成150。此时当前记录里存的是“150”以及最新修改事务ID同时通过回滚指针连着一个链表150版本 - 120版本 - 100版本。这条路就是版本链。这个设计很聪明。正常情况下读当前最新值就好代价极低需要回滚或者需要读取历史快照时就沿版本链往回走找到某个指定的历史版本。InnoDB不会为每一行保存全部历史版本无限膨胀老版本依赖系统的purge线程清理。如果事务长期不提交或者单个事务修改了大量数据版本链会变得很长查询性能会明显下降。这一点后面讲到排查经验时还要再提。2.2 Read View一张“谁能被看见”的快照名单版本链有了怎么决定“我能看见哪个版本”靠的是Read View读视图。它本质上是事务做快照读时对当前数据库状态拍的一张“合影”。一个Read View里面至少有四个关键属性m_ids创建Read View那一刻所有还未提交的事务ID列表。min_trx_idm_ids中的最小值。max_trx_id系统下一个要分配的事务ID也就是当前已分配过的事务ID最大值再加1。creator_trx_id创建这个Read View的事务自己的ID。后续判断某一行版本是否可见规则可以浓缩成一张表条件结果行版本的trx_id等于creator_trx_id可见自己改的当然要看见行版本trx_id小于min_trx_id可见说明在快照生成前已经提交了行版本trx_id大于等于max_trx_id不可见说明事务在快照生成之后才启动trx_id在min_trx_id和max_trx_id之间且在m_ids中不可见生成快照时它还没提交trx_id在min_trx_id和max_trx_id之间但不在m_ids中可见生成快照前已提交可以给小白一个通俗版本我拍了一张快照快照里记录的“已提交数据”我都能看到快照之后才发生的事我一律当作没发生如果中途有人改了某一行我就沿着版本链往回走找到符合快照状态的旧版本。这个判断逻辑不是读了所有版本再慢慢挑而是先从当前最新版本开始拿trx_id逐条和Read View比对不满足条件就沿回滚指针找下一个历史版本直到找到可见版本或者版本链结束。2.3 实例推演为什么第二次普通SELECT还读旧值用一个具体的执行序列来说明。假设有一张账户表id1这行初始balance100最后写入事务ID是88已提交。现在有两个事务T1和T2并发执行。事务T1先开启并执行第一次SELECT此时系统里有一个未提交事务T3事务ID101用来保证m_ids非空。T1自己的事务ID为100Read View创建时m_ids[101]min_trx_id101max_trx_id102creator_trx_id100。T1第一次读id1当前版本trx_id88。88小于min_trx_id101可见读到100。接着事务T2开启被分配到事务ID102它执行UPDATE把balance改成120并提交。T2修改后id1当前最新版本trx_id102版本链上是102 - 88。T1再次执行普通SELECT。判断102trx_id102大于等于Read View中的max_trx_id102不可见。于是沿版本链找到88版本88小于min_trx_id可见。T1读到的仍然是balance100。这就叫可重复读。同一个事务里第二次、第三次、第十次读id1结果都是100不管T2有没有提交。因为T1的Read View在第一次快照读时已经固定T2的事务ID在快照的可见范围之外被直接从可见名单里排除掉了。注意一个细节我这里的事务ID是简化模型真实MySQL的事务ID分配和只读事务处理会更复杂但可见性判断的主线逻辑就是这样的。2.4 最容易搞错的生成时机不是BEGIN时生成很多资料和面试题会给一个粗犷的说法RR是从事务一开始就固定的快照事务开启之后所有读都看到同一份数据。这个说法在大多数场景下好用但严格说有一处不准确Read View不是在BEGIN时刻创建而是在第一次执行快照读时创建。举一个坑过很多人的例子。T1执行BEGIN但什么都没查。此时T2开启事务更新了一行并提交。T1这时才执行第一次SELECT。因为T1之前没有生成Read View现在才生成那么T2已经提交的这条更新就会出现在T1的快照范围内。T1第一次读就能看到新值。所以更严谨的表述是可重复读的“第一次”是从事务内第一次快照读开始算的不是从BEGIN开始算。这也符合InnoDB的实现直觉——Read View的生成要消耗资源没必要在事务什么都没做的时候就拍快照只有真正要读数据了才需要记录当时的可见状态。这个点在实际业务场景里也很有意思。有些同学以为开了RR就一定不会读到“开启事务后”的提交如果程序故意在事务前段摸鱼后段才开始查询就会产生“为什么我第一次查就查到了别人刚提交的数据”的困惑。理解了Read View惰性创建机制这个困惑自然消失。3. 光有快照不够当前读场景和InnoDB如何防幻读3.1 为什么UPDATE不能基于旧快照看到这里你可能会想既然快照读能一直读旧版本那更新的时候也基于旧版本不就行了从业务一致性角度好像说得通但技术上绝对不能这么做。假设T1在RR下读到balance100然后执行UPDATE SET balance balance 50。如果基于旧快照100来更新会把120覆盖成150等于把T2刚提交的20元更新给吞掉了。这属于丢失更新比不可重复读更严重。所以InnoDB规定写操作必须走当前读。UPDATE在执行时先去读取该行最新已提交版本也就是120然后在这个版本基础上做加法得到170再加锁写入。T2提交的修改不会被覆盖。这也解释了为什么在RR下会出现一个看起来矛盾的现象同一事务里SELECT查询看到100UPDATE之后马上SELECT看到的却是150或170。因为SELECT走快照读UPDATE走当前读两个读用的不是同一套可见性来源。这不是数据库出了问题而是机制本身就是这么设计的。你只需要记住业务逻辑如果依赖“先查后改”一定要想清楚自己用的是快照读还是当前读。3.2 当前读操作与加锁行为当前读不光是读最新值同时还要加锁。这些锁同样用于防止其他事务在当前操作期间修改数据。不同语句对应的锁行为大致如下SELECT ... FOR UPDATE对命中的索引记录加排他锁X锁。SELECT ... LOCK IN SHARE MODE加共享锁S锁。UPDATE / DELETE先定位到需要修改的记录对记录加排他锁然后更新或删除。INSERT插入过程中也需要对相关间隙加插入意图锁并检查是否存在冲突的锁。如果当前读命中的是普通索引或范围查询InnoDB还会给这个范围前后的“间隙”加锁。这个细节非常重要下一节专门说。3.3 RR下的幻读问题间隙锁和next-key lock标准SQL里RR隔离级别是不解决幻读的因为理论上一个事务的两次范围查询如果使用的是快照读即便其他事务插入了新行两次快照结果也一致不会产生幻读。但一旦涉及当前读情况就变了。举个例子。T1事务中执行SELECT * FROM orders WHERE amount 100 FOR UPDATE查到两条记录。在T1提交之前T2往orders表插入了一条amount150的新订单。如果Oracle或某些数据库T2的插入可能直接成功T1再执行同样的FOR UPDATE查询结果集可能变成三条这就是当前读维度下的幻读。InnoDB的应对策略是next-key lock。它可以理解为两个锁合成一个记录锁锁住索引记录本身。间隙锁锁住记录之间的间隙。当RR下执行当前读范围查询时InnoDB不只锁住命中的记录还会把记录前后的间隙锁上。T2要插入amount150的那条记录发现插入位置落在某个间隙锁范围内插入操作必须等待T1提交这样T1的当前读永远不会看到“多出来的行”。这里要特别提醒一个常见坑间隙锁要发挥防幻读作用查询条件必须走索引。如果查询走不到索引InnoDB只能锁全表的记录和间隙等于把整个表的插入操作都挡住了。后果是并发性能严重下降甚至出现莫名其妙的死锁。经验丰富的DBA看到某条SQL大量引起锁等待第一反应就是检查执行计划是不是全表扫描。3.4 混合场景快照读 当前读结果不一致把快照读和当前读放在一个事务里你会看到一种很“诡异”的场景。T1开启事务先做一次普通SELECT范围查询返回1条记录。T2插入一条符合条件的新记录并提交。T1再做一次普通SELECT依然返回1条记录没问题快照读保证了可重复读。但如果T1接下来执行一次SELECT ... FOR UPDATE由于当前读读最新版本并且范围间隙被加上了锁——当然因为这个锁在T1第一次当前读时才加T2可能已经插入了。这时候T1的FOR UPDATE可能查出2条记录。这种现象让很多人困惑一个事务里两次查询结果不一致准确说是两次不同读模式的查询结果不一致。快照读读的是旧快照当前读读的是最新数据加锁结果。二者本来就不是同一个东西。所以RR下的“可重复读”从来不是无条件的。它保证的是同一事务内快照读之间一致、同一事务内当前读之间一致。快照读和当前读之间本身就可能出现结果差异。这一点在设计和排查时一定要想清楚否则会把业务问题误判成数据库故障。4. 实操验证与排查手册4.1 先确认数据库当前隔离级别理论说完了落地验证才是真功夫。第一步查隔离级别。MySQL 5.7用这条命令SELECT global.tx_isolation; SELECT session.tx_isolation;MySQL 8.0把系统变量改名了SELECT global.transaction_isolation; SELECT session.transaction_isolation;我实测在MySQL 8.0.33版本上tx_isolation已经废弃执行会报警告。重新连接之后默认结果一般是REPEATABLE-READ这就是InnoDB的默认隔离级别。如果你想临时切到RC可以执行SET SESSION transaction_isolation READ-COMMITTED;注意这只对当前会话生效。全局生效需要SET GLOBAL而已经存在的连接不会感知全局变化新连接才会生效。4.2 用两个终端复现“可重复读”和当前读差异纸上得来终觉浅我建议你亲自动手把这个实验跑一遍全程不需要看任何监控工具两张表足够了。建表CREATE TABLE t_account ( id INT PRIMARY KEY, balance INT ) ENGINEInnoDB; INSERT INTO t_account VALUES (1, 100);终端1开启事务并查一次BEGIN; SELECT * FROM t_account WHERE id 1;终端2开启事务更新并提交BEGIN; UPDATE t_account SET balance 120 WHERE id 1; COMMIT;回到终端1再查一次SELECT * FROM t_account WHERE id 1;你看到的依然是我写进去的100。这就是RR下快照读的可重复读效果。接下来看当前读。继续在终端1执行UPDATE t_account SET balance balance 50 WHERE id 1; SELECT * FROM t_account WHERE id 1;第一次SELECT查到100UPDATE之后SELECT却变成170。原因就是UPDATE走了当前读先读到最新已提交版本120然后加50写回170。这个过程完美展示了快照读和当前读的差异。做完实验记得提交终端1的事务。如果实验中发现第二个终端UPDATE卡住不动大概率是第一个终端的事务还没提交记录锁被持有。这是验证锁机制的好机会顺便能感受到为什么RR下长事务会导致严重的锁等待。4.3 排查事务和锁的新工具日常排障中光会查隔离级别不够还要会看事务和锁的状态。最常用的三张内部表值得记下来information_schema.innodb_trx查看当前所有活跃事务包括事务ID、开始时间、状态、是否在等待锁。performance_schema.data_locks查看当前持有的锁包括锁类型、锁模式、锁对象。performance_schema.data_lock_waits查看锁等待关系哪个事务在被哪个事务阻塞。我强烈建议直接用performance_schema下的两张新表而不是老掉牙的SHOW ENGINE INNODB STATUS打印的锁片段。老命令信息虽然全但格式混乱还要靠肉眼在长文本里找锁信息排查效率很低。一个实用查询找出当前谁在锁等待以及谁阻塞了它。SELECT w.REQUESTING_ENGINE_TRANSACTION_ID AS waiting_trx, b.BLOCKING_ENGINE_TRANSACTION_ID AS blocking_trx, w.OBJECT_NAME, w.LOCK_TYPE, w.LOCK_MODE FROM performance_schema.data_lock_waits w JOIN performance_schema.data_lock_waits b ON w.OBJECT_NAME b.OBJECT_NAME AND w.REQUESTING_ENGINE_LOCK_ID b.BLOCKING_ENGINE_LOCK_ID;实际使用中我会再加一个条件把waiting_trx关联到innodb_trx上拿到具体的SQL字段快速定位是哪条语句卡住了。这套组合拳比单纯看慢日志更直接能看到整个死锁和锁等待的现场。4.4 几个高频坑与业务取舍下面这些经验都是我踩过坑之后沉淀下来的每条都对应过真实线上问题。第一个坑长事务。RR下只要事务不提交它的Read View就不会释放undo log中的历史版本就清不掉。如果一个事务长时间开着隔一段时间做一次查询那它启动点之后的所有已提交更新都得靠版本链往前翻查询性能会越来越差。更糟的是大量旧版本堆积会让undo表空间疯狂膨胀磁盘空间报警。业务代码里一定要杜绝“连接池里拿了个连接开了事务又长时间不提交”的用法。第二个坑连接池复用导致的事务边界失控。很多框架里end和connection.commit不在一个明显的位置写不好就出现一个请求处理完了事务还挂着连接归还给连接池后下一个请求顺手接着上一个事务继续跑。这种问题最难查因为现象是“偶尔读到脏数据”“偶尔锁等待”。规避方法在代码里明确规定事务边界使用框架提供的事务模板而不要手写BEGIN和COMMIT裸奔。第三个坑大范围FOR UPDATE引发全表间隙锁。业务里要对一批数据加锁不少同学图省事直接写一个没有索引条件的查询加FOR UPDATE结果把整张表的写操作全堵住了。排查慢事务时看到几百毫秒的执行时间被拉成几十秒十有八九是这个原因。优化方向给WHERE条件设计合适的索引让锁粒度降下来或者在业务上减少单次锁定的数据量。第四个坑业务选型RR还是RC。RR的优点是强一致缺点是间隙锁多死锁概率高RC的优点是并发高锁少但是不可重复读需要业务自己兜。近几年很多互联网高并发场景倾向于把隔离级别改成RC配合ROW格式的binlog继续保证主从一致性。这个选择没有绝对对错取决于业务。对账、报表这类对同一批数据反复统计的场景老实待在RR下更安全面向用户的高频短事务业务RC往往能换来明显更好的并发表现。5. 我的一些实战体会我把这套原理彻底吃透是在一次线上“金额对不上”的事故之后。当时业务方用的RR事务里先做了普通SELECT再执行UPDATE结果更新之后发现最终金额不是自己预期的那一个。排查到最后发现就是快照读和当前读混用造成的代码里以为SELECT到的值是UPDATE的基准实际上UPDATE读到的却是最新已提交值。从那以后我养成了一个习惯写事务代码之前先问自己三个问题。这个事务里有哪些读操作是快照读还是当前读有没有在事务里先查后改并且查询结果直接影响后续写入如果两个并发事务同时执行这段逻辑会不会因为间隙锁死锁或者因为快照读产生不一致想清楚这三个问题大多数事务相关的坑都能在设计阶段避开。另外排查MVCC问题时不要只靠直觉猜直接查information_schema.innodb_trx和performance_schema.data_locks把事务列表和锁图先拉出来再结合慢日志定位SQL通常能很快锁定问题。重要的事说三遍读机制、写机制、锁机制要分开看别混在一起分析。最后分享一个小技巧。当你需要向别人解释RR和可重复读时用“会议室的白板”打比方每个事务拿到了一张白板照片照片是什么时候拍的之后就只认照片上的内容写操作则是直接冲回会议室看最新的白板还要拿一只笔“占座位”防止别人再往上写。照片和现场不一致不是因为白板坏了是因为你选择了不同的观察方式。这个类比我屡试不爽新同学基本听完就通了。