资讯详情

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

📅 2026/10/19 15:08:10 | 华诺云谱 👁 阅读
可重复读隔离级别:MVCC与快照读的机制解析
“可重复读”这个隔离级别我见过太多人第一反应是简单就是在一个事务里同一行数据读多少次都是一样的。但真到了线上排查问题的时候又会发现事情没那么简单——明明设置的是可重复读事务里第二次查询却读到了别的事务刚提交的数据。于是有人开始怀疑数据库隔离级别是不是“名不副实”。我在这类问题上踩过不少坑最后发现绝大多数争议都源于没搞清一个关键区分可重复读说的是“快照读”而不是“当前读”。也就是说RRRepeatable Read能解决“重复读不一致”这个问题靠的不是把数据锁死而是通过一套多版本机制让事务在第一次读取时“冻结”住一个一致性视图。这篇文章就把这套机制从头拆到尾讲清楚RR到底是怎么做到可重复读的以及哪些场景下你会感觉它“失效”了。1. 可重复读要解决什么问题两个读之间数据变了先回到最原始的场景。有一个订单表里面有一行数据idamountstatus1100paid现在有两个事务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只是读个报表就要把一堆行锁到事务结束其他事务对这几行的任何更新都得排队等待系统的吞吐量会非常难看。更极端的情况是一个事务先读了一部分数据过一会儿还要读另一部分中间如果长时间不提交所有相关行的写入都会被卡住这在业务上几乎不可接受。于是MVCCMulti-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_idm_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都属于当前读它们不走版本链而是直接读取记录的最新已提交版本并且对读取的记录加锁。举个经典的例子事务A执行SELECT amount FROM orders WHERE id 1;读到 100快照读生成ReadView。事务B执行UPDATE orders SET amount 200 WHERE id 1;并提交。事务A执行UPDATE orders SET amount amount 10 WHERE id 1;。这是一条当前读它读到的 amount 已经是 200更新后变成 210。事务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情况就会更复杂。比如事务A执行START TRANSACTION;事务A立刻执行SELECT amount FROM orders WHERE id 1 FOR UPDATE;读到 100。事务B执行UPDATE orders SET amount 200 WHERE id 1;此时会被阻塞因为步骤2已经加了锁。事务B提交后实际上是事务A提交后才可能继续事务A再执行普通SELECT读到 100。这个场景里可重复读反而没什么问题因为事务A用的是当前读。但如果代码里先做了一次普通的SELECT生成ReadView又执行了一次UPDATE当前读刷新ReadView后续快照读就会被新的ReadView支配。这类“事务内先读后写再读”的路径是业务代码里最容易埋雷的地方。另一个容易被忽略的是autocommit的开关状态。如果autocommit1那么每条语句执行完成后事务就提交了根本不存在“事务内重复读”。所有对隔离级别的讨论都要先确认自己确实在同一个事务里。很多人在客户端工具里“一个事务内两次查询”实际上因为工具自动提交两次查询根本不属于同一个事务。4.4 开发中最常见的三个误用结合上面的分析开发中最常见的三个误用大概是这样的为了做“先查再改”的逻辑把快照读和当前读放在同一个事务里结果数据表现与预期不符。使用ORM框架时某些更新操作或SELECT ... FOR UPDATE被隐式嵌入开发人员并没有意识到自己触发的是当前读。应用层的“事务”是业务逻辑上的多次SQL但连接池或框架在中间隐式提交了事务导致实际执行时会话可能来回切换。这些误用排查起来都不难难的是先建立正确的认知模型。只要你脑子里有“快照读生成并复用ReadView、当前读读取最新版本并加锁”这两条铁律绝大多数所谓“RR失效”都能当场定位出原因。5. 拿RC做对照为什么读已提交做不到可重复读既然RR能解决可重复读那RC为什么不能从ReadView的角度看答案已经很清楚RC每次SELECT都重新生成ReadViewRR只在第一次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 一个简单可复现的验证步骤如果你想亲眼验证上面的结论可以按下面的步骤操作。准备两个会话一张表一行数据即可。建表并插入数据CREATE TABLE orders ( id INT PRIMARY KEY, amount INT ); INSERT INTO orders (id, amount) VALUES (1, 100);会话A开启事务第一次SELECTSTART TRANSACTION; SELECT amount FROM orders WHERE id 1; -- 结果100会话B更新并提交UPDATE orders SET amount 200 WHERE id 1; COMMIT;回到会话A第二次SELECTSELECT amount FROM orders WHERE id 1; -- RR下结果100 -- RC下结果200在会话A继续执行UPDATE再SELECTUPDATE 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这个隔离级别在你手上基本就没有秘密了。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑