ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

可重复读隔离级别:MVCC与快照读的机制解析

可重复读隔离级别:MVCC与快照读的机制解析

“可重复读”这个隔离级别,我见过太多人第一反应是:简单,就是在一个事务里,同一行数据读多少次都是一样的。但真到了线上排查问题的时候,又会发现事情没那么简单——明明设置的是可重复读,事务里第二次查询却读到了别的事务刚提交的数据。于是有人开始怀疑数据库隔离级别是不是“名不副实”。

我在这类问题上踩过不少坑,最后发现,绝大多数争议都源于没搞清一个关键区分:可重复读说的是“快照读”,而不是“当前读”。也就是说,RR(Repeatable Read)能解决“重复读不一致”这个问题,靠的不是把数据锁死,而是通过一套多版本机制,让事务在第一次读取时“冻结”住一个一致性视图。这篇文章就把这套机制从头拆到尾,讲清楚RR到底是怎么做到可重复读的,以及哪些场景下你会感觉它“失效”了。

1. 可重复读要解决什么问题:两个读之间数据变了

先回到最原始的场景。有一个订单表,里面有一行数据:

idamountstatus
1100paid

现在有两个事务,A和B,都对这行数据感兴趣:

  • 事务A先执行SELECT amount FROM orders WHERE id = 1;,读到 amount = 100。
  • 事务B此时执行UPDATE orders SET amount = 200 WHERE id = 1;并提交。
  • 事务A再次执行同样的SELECT amount FROM orders WHERE id = 1;。

这时候问题来了:事务A第二次读到的是 100 还是 200?

  • 如果读到 200,那么同一事务内两次相同查询结果不一致,这叫不可重复读(Non-Repeatable Read)。
  • 如果读到 100,说明这个隔离级别下,事务A的视角没有被其他事务的提交所干扰,这就是可重复读。

RR这个隔离级别要解决的,就是“同一个事务里,多次读取同一行数据,结果必须保持一致”的问题。和它容易混淆的是另一个问题:幻读(Phantom Read)。幻读的关注点不是某一行的值变了,而是查询范围内“多了一行”或者“少了一行”——比如事务A先查所有status = 'paid'的订单,有 10 条,事务B插入第 11 条并提交,事务A再查变成了 11 条。严格地说,标准SQL里的RR只保证能解决不可重复读,不保证解决幻读。

但InnoDB的实现要更强一些:在快照读的视角下,幻读也被一并“屏蔽”了,因为事务A整个事务期间看到的都是同一个一致性视图。这就是很多人说“InnoDB的RR其实解决了幻读”的原因,但前提是你用的是快照读。

2. 为什么不靠加锁而是靠“读旧版本”:MVCC的取舍

如果只从“怎么让事务A第二次读还读到100”这个问题出发,一个很直观的做法是加锁:事务A第一次读取时,把那一行数据锁住,直到事务结束才释放。这样事务B在A提交之前都没法修改这行数据,A自然每次都能读到100。

这个方案在逻辑上完全成立,很多早期数据库也确实是这么做的。但它的代价非常明显:读事务会阻塞写事务,写事务也会阻塞读事务。在一个高并发系统里,如果事务A只是读个报表就要把一堆行锁到事务结束,其他事务对这几行的任何更新都得排队等待,系统的吞吐量会非常难看。更极端的情况是:一个事务先读了一部分数据,过一会儿还要读另一部分,中间如果长时间不提交,所有相关行的写入都会被卡住,这在业务上几乎不可接受。

于是MVCC(Multi-Version Concurrency Control,多版本并发控制)提供了另一条思路:读不阻塞写,写不阻塞读。事务A要读数据时,不需要去锁住当前值,而是可以走“历史版本”的路径——如果当前值被别的事务改了,那就沿着版本链往回找,找一个对A可见的版本。

一个简单的类比是“网页快照”:你在某个时刻打开了一个页面,页面内容在你打开的那一刻就被固定为你看到的版本。别人之后更新了页面,并不影响你当前看到的这个已打开的版本。只有你主动刷新,才会看到新内容。MVCC里的“快照”并不是真的把整张表拷贝一份,而是一种逻辑上的快照,靠的是版本链和可见性规则来构造出“事务开始那一刻的数据库状态”。

MVCC当然也不是没有成本。它要求每行数据保留历史版本,更新数据时不能直接覆盖,而是需要生成新版本,旧版本则留在undo log里等待合适的时机被清理。清理动作由后台的purge线程完成。如果一个大事务持续很久,它引用的旧版本就没法被及时清理,undo log会越积越多,磁盘占用和查询回链开销都会上升。这就是所谓的长事务问题。所以MVCC并不是免费的午餐,它是在并发度和资源消耗之间做了一次权衡,而这个权衡在绝大多数业务场景下是划算的。

3. InnoDB快照读的落地细节:隐藏列、undo版本链与ReadView判定

光说“多版本”比较抽象。InnoDB的实现里,每一行数据除了用户定义的字段外,还带了一组隐藏字段。这里最核心的有三个:

  • DB_TRX_ID:记录最近一次修改这行数据的事务ID。
  • DB_ROLL_PTR:回滚指针,指向undo log中的上一个版本。
  • DB_ROW_ID:如果没有主键,InnoDB会用它作为行标识;这个和本文主题关系不大,了解即可。

当执行一条UPDATE时,InnoDB并不会直接把这行的旧值删掉,而是先在undo log里写一份旧版本记录,然后把新值写入当前行,更新DB_TRX_ID为当前事务ID,再让DB_ROLL_PTR指向刚才写入的旧版本。这样一来,一行数据在逻辑上就串成了一条“版本链”:链头是最新值,越往回走版本越老。

有了版本链,接下来就要解决一个问题:一个事务读取时,到底应该选择链上哪一个版本?这就要引入ReadView(读视图)。

ReadView可以理解为某一次“看数据”的时刻,数据库为你生成的一个可见性判定快照。它主要记录这几样东西:

  • m_ids:生成ReadView时,当前系统中所有活跃(未提交)事务的ID列表。
  • min_trx_id:m_ids中最小的事务ID。
  • max_trx_id:下一个将被分配的事务ID,也就是当前系统里“未来事务”的边界。
  • creator_trx_id:创建这个ReadView的事务自己的ID。

当要判断一个数据版本对当前事务是否可见时,判定逻辑是这样的:

条件结论
版本的DB_TRX_ID等于creator_trx_id可见(自己改的,当然能看见)
版本的DB_TRX_ID小于min_trx_id可见(事务早于所有活跃事务,已提交)
版本的DB_TRX_ID大于等于max_trx_id不可见(这个版本是未来事务生成的)
版本的DB_TRX_ID在m_ids列表中不可见(这个事务还没提交)
版本的DB_TRX_ID不在m_ids中但小于max_trx_id可见(事务已提交)

沿着版本链从最新往最老找,找到第一个“可见”的版本,就是这次读取要返回的数据。如果当前最新版本不可见,就顺着roll_pointer跳到上一个版本继续判断,直到找到可见版本为止。

理解了ReadView,就很容易回答标题里那个核心问题:为什么RR读可以解决可重复读?

关键在ReadView的生成时机。RR隔离级别下,事务第一次执行快照读时会生成一个ReadView,之后整个事务期间都复用这个ReadView。也就是说,后面每一次快照读,都沿用“第一次读取那一刻”的可见性判定条件。其他事务之后提交的更新,在版本链里虽然存在,但按照这个旧ReadView的规则会被判定为不可见,于是事务内反复读取同一行,结果始终保持一致。

而RC(读已提交)隔离级别为什么做不到可重复读?因为RC每次执行快照读都会生成一个新的ReadView,前一次读到100、中间别人提交了200,下一次查询用新的ReadView就能看到200,结果自然就变了。

用户视角最直观的理解是:RR把自己的事务“冻结”在了第一次读的时刻,RC则在每个读语句的时刻重新“解冻”一次。这个差异,就是可重复读能不能成立的分水岭。

4. RR下快照读“看起来失效”的三种真实场景

理论部分讲透了,接下来看实操中特别容易让人误会的几个场景。我在实际排查问题、以及看很多人贴出来的“BUG复现”时,发现大部分RR“失效”的证据,都是下面这三种情况。

4.1 当前读:SELECT FOR UPDATE / UPDATE / DELETE

快照读是一致性读,但还有一种读叫当前读。SELECT ... FOR UPDATE、UPDATE、DELETE都属于当前读,它们不走版本链,而是直接读取记录的最新已提交版本,并且对读取的记录加锁。

举个经典的例子:

  1. 事务A执行SELECT amount FROM orders WHERE id = 1;,读到 100(快照读,生成ReadView)。
  2. 事务B执行UPDATE orders SET amount = 200 WHERE id = 1;并提交。
  3. 事务A执行UPDATE orders SET amount = amount + 10 WHERE id = 1;。这是一条当前读,它读到的 amount 已经是 200,更新后变成 210。
  4. 事务A再执行SELECT amount FROM orders WHERE id = 1;,此时读到 210。

如果只看步骤1和4,你可能会说:RR不是可重复读吗?事务A怎么会读到210?问题出在步骤3。UPDATE是当前读,它读的是最新版本,而且会让事务A的ReadView刷新到最新状态。步骤4的快照读基于这个被刷新的ReadView,自然能看到210。严格地说,这已经偏离了“第一次读取那一刻”的快照,所以表现上就是“RR失效”。

这不是RR被突破了,而是你让当前读和快照读在同一个事务里混用了。在RR的语义里,可重复读保证的是“纯快照读”路径下的一致,当前读参与进来之后,隔离级别的行为就会发生变化。

4.2 自己修改自己:先UPDATE再SELECT

和4.1类似,如果事务A先执行UPDATE orders SET amount = 1000 WHERE id = 1;,再执行SELECT amount FROM orders WHERE id = 1;,读到的一定是1000。这看起来像是“自己破坏了自己的可重复读”,但其实是符合预期的——自己的修改当然要对自己可见,否则事务就没法做后续逻辑了。很多新手在这里犯迷糊,本质上还是没分清当前读和快照读。

4.3 事务边界并不是BEGIN就确定的

还有一个非常隐蔽的点:RR的ReadView不是在执行BEGIN或START TRANSACTION时生成的,而是在执行第一条一致性读语句时才生成。如果事务开启后,你首先执行的是当前读语句(比如UPDATE或SELECT FOR UPDATE),情况就会更复杂。

比如:

  1. 事务A执行START TRANSACTION;
  2. 事务A立刻执行SELECT amount FROM orders WHERE id = 1 FOR UPDATE;,读到 100。
  3. 事务B执行UPDATE orders SET amount = 200 WHERE id = 1;,此时会被阻塞,因为步骤2已经加了锁。
  4. 事务B提交后(实际上是事务A提交后才可能继续),事务A再执行普通SELECT,读到 100。

这个场景里可重复读反而没什么问题,因为事务A用的是当前读。但如果代码里先做了一次普通的SELECT(生成ReadView),又执行了一次UPDATE(当前读,刷新ReadView),后续快照读就会被新的ReadView支配。这类“事务内先读后写再读”的路径,是业务代码里最容易埋雷的地方。

另一个容易被忽略的是autocommit的开关状态。如果autocommit=1,那么每条语句执行完成后事务就提交了,根本不存在“事务内重复读”。所有对隔离级别的讨论,都要先确认自己确实在同一个事务里。很多人在客户端工具里“一个事务内两次查询”,实际上因为工具自动提交,两次查询根本不属于同一个事务。

4.4 开发中最常见的三个误用

结合上面的分析,开发中最常见的三个误用大概是这样的:

  • 为了做“先查再改”的逻辑,把快照读和当前读放在同一个事务里,结果数据表现与预期不符。
  • 使用ORM框架时,某些更新操作或SELECT ... FOR UPDATE被隐式嵌入,开发人员并没有意识到自己触发的是当前读。
  • 应用层的“事务”是业务逻辑上的多次SQL,但连接池或框架在中间隐式提交了事务,导致实际执行时会话可能来回切换。

这些误用排查起来都不难,难的是先建立正确的认知模型。只要你脑子里有“快照读生成并复用ReadView、当前读读取最新版本并加锁”这两条铁律,绝大多数所谓“RR失效”都能当场定位出原因。

5. 拿RC做对照:为什么读已提交做不到可重复读

既然RR能解决可重复读,那RC为什么不能?从ReadView的角度看,答案已经很清楚:RC每次SELECT都重新生成ReadView,RR只在第一次SELECT时生成ReadView并持续复用。

具体到执行过程:

  • RC事务里,第一条SELECT生成ReadView,看到某一行是100。
  • 其他事务提交了更新,把这一行改成200。
  • 事务A的第二条SELECT重新生成ReadView,这个新ReadView能看到200,于是第二条SELECT返回200。
  • 结果:事务A前后两次查询不一致——不可重复读。

对比下面这个表格会更直观:

操作顺序RR事务A读到什么RC事务A读到什么
事务A第一次SELECT100100
事务B UPDATE并提交--
事务A第二次SELECT100200
事务A第三次SELECT100200

在业务上,RC的“每次读都是最新已提交状态”适合对实时性要求高的场景,比如读最近状态的接口。但在做报表、对账、统计这类需要多次读取并计算的任务时,RC很容易出问题:前一条SQL查出一个汇总值,另一条SQL查出明细,两条SQL发生在不同时刻,汇总和明细对不上;或者在一个事务里先读订单表再读支付表,两批数据的时间点不一致,导致计算出来的结果逻辑上矛盾。

我在实际排查中遇到过类似的线上问题:一个统计任务,在同一事务里先查了订单总额,又查了退款总额,在RC下由于两次查询之间恰好有其他事务提交了新的退款数据,导致“订单总额 - 退款总额”算出来的结果和单独查“实际应结算金额”不一致。业务方一度以为是数据写错了,后来确认是隔离级别的问题。这就是RC的“每次刷新视图”在长事务多语句场景下的固有弱点,而RR恰好用“复用ReadView”的机制把这个问题从根上解决了。

6. 应用侧该怎么做:选型、验证与常见误用

6.1 隔离级别怎么选

RR和RC并没有绝对的好坏,只有适不适合。

  • 如果你的业务大量依赖“在同一个事务里多次读同一数据而且要一致”,比如统计报表、对账、库存预占后的复核、订单状态流转的校验,RR是一个更安全的选择。
  • 如果你的场景是“读最新已提交状态”就可以,同时当前读的锁等待比较严重,可以评估是否切换到RC来降低长事务持锁时间。代价就是放弃“同一事务内完全一致”的保证,需要在应用层处理可能出现的前后不一致。
  • 一个千万级以上的并发系统里,如果大量事务都依赖RR的快照一致性,长事务会拖住undo log的清理,所以也不是所有地方都适合RR。最合理的做法是业务按场景拆分,该用RR的地方明确设置,该用RC的地方单独指定,避免“全局一刀切”。

6.2 用两句话概括RR的核心规则

我在给团队做培训的时候,总结了两句话,基本能覆盖日常80%的疑问:

  • RR下,快照读以事务内第一次快照读生成的ReadView为准,之后不变,因此可以重复读。
  • 如果中间混入了当前读(UPDATE、DELETE、SELECT FOR UPDATE),当前读会刷新ReadView,后续快照读将基于新视图,可重复读的“纯度”就会被打破。

6.3 一个简单可复现的验证步骤

如果你想亲眼验证上面的结论,可以按下面的步骤操作。准备两个会话,一张表,一行数据即可。

  1. 建表并插入数据:
CREATE TABLE orders ( id INT PRIMARY KEY, amount INT ); INSERT INTO orders (id, amount) VALUES (1, 100);
  1. 会话A开启事务,第一次SELECT:
START TRANSACTION; SELECT amount FROM orders WHERE id = 1; -- 结果:100
  1. 会话B更新并提交:
UPDATE orders SET amount = 200 WHERE id = 1; COMMIT;
  1. 回到会话A,第二次SELECT:
SELECT amount FROM orders WHERE id = 1; -- RR下结果:100 -- RC下结果:200
  1. 在会话A继续执行UPDATE,再SELECT:
UPDATE orders SET amount = amount + 10 WHERE id = 1; SELECT amount FROM orders WHERE id = 1; -- 结果:210

做完这组实验,你对RR的行为会有一个非常直观的体感。步骤4和步骤5形成的鲜明对比,比看任何文档都更有说服力。

注意:如果所有语句都在autocommit模式,以上实验会失败。必须在会话A中显式启动事务,并把autocommit关掉或者用START TRANSACTION包裹。

6.4 RR下还容易踩的坑:长事务导致的undo堆积

既然说到应用侧,最后提醒一个隐蔽的坑:RR的快照要依赖旧版本数据,而旧版本数据是保存在undo log里的。事务A在RR下一直不提交,它引用的历史版本就不能被purge线程清理。大量运行中的长事务会让undo log膨胀,磁盘占用飙升,甚至出现版本链过长、查询性能下降的问题。

结合这一点,应用侧应当注意:

  • 查完数据后尽快提交,避免把事务挂在那边长时间不结束。
  • 避免在业务逻辑里把多个不相干的操作塞进同一个事务,尤其不要再放什么外部RPC调用、等待人工确认之类的步骤。
  • 监控长事务和undo log的增长情况,一旦发现异常,优先定位到具体连接和具体SQL。

我个人在实际操作中的体会是,RR本身并不是一个复杂的概念,复杂的是它和底层存储引擎之间的配合方式。只要抓住“快照读复用ReadView”这条主线,再看网上那些关于RR和幻读的讨论,基本就能一眼分辨对方说的到底是快照读还是当前读。

最后再分享一个小技巧:排查这类问题时,不要急着去翻官方文档里那些长篇大论的隔离级别定义,先打开SQL日志,把事务里每一条语句按“快照读”和“当前读”分个类,你会发现80%的疑惑瞬间就消失了。剩下的20%,往往出在事务到底是在哪个时间点真正“开始”的——也就是你在BEGIN之后第一条语句到底是什么。搞清楚这两个维度,RR这个隔离级别在你手上基本就没有秘密了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进