很多做了几年开发的人被问到“RR 和 RC 到底有什么区别”时第一反应就是“RC 有不可重复读RR 没有”。但再往深挖一层——“那 ReadView 起了什么作用快照读和当前读又是怎么对应的”——能说清楚的人就少了很多。我当年也是调了大半个月的诡异事务问题才真正把这一串概念串起来。这篇文章就是想把 RR/RC、ReadView、快照读/当前读这四件事彻底讲透不讲那种“考试用的标准答案”而是讲清楚底层的数据结构、判断逻辑以及你在实际排查问题时怎么用这些知识。内容适合三类人正在学习数据库事务原理的开发者被线上数据不一致问题折磨的运维/后端同学以及想弄懂 MVCC 内部机制、准备面试或者做技术分享的人。读完之后你能自己推导出不同隔离级别下 SELECT 的可见性结果也能明白为什么 RR 下还会有“当前读”导致的幻读隐患更能在遇到版本链异常时知道往哪个方向排查。1. 先把 RR、RC 这两个隔离级别捋清楚1.1 隔离级别不是数据库自己发明的概念事务隔离级别是 SQL 标准里定义的一组约束核心目的就是解决并发事务下出现的三类问题脏读读到别人未提交的数据、不可重复读同一条数据在一个事务里每次读的结果不一样、幻读同一个查询条件在一个事务里查到多出来或消失的行。标准定义了四档读未提交RU最低档任何未提交的修改都能被其他事务读到脏读、不可重复读、幻读都可能发生。读已提交RC只能读到已提交事务的修改解决了脏读但同一事务内两次相同的查询可能返回不同结果因为期间有事务提交了。可重复读RR事务内第一次读取时确定一个“视图”之后读取都基于这个视图解决了不可重复读但标准定义下依然无法彻底解决幻读。串行化Serializable事务完全排队执行等价于所有事务一个接一个跑没有并发问题代价是性能断崖式下降。日常最常用的就是 RC 和 RR。很多数据库默认是 RC而传统关系型数据库默认是 RR。RR 比 RC 多付出的代价不只是概念上的“锁定更多行”而是背后的 ReadView 机制完全不同这一点后面细说。1.2 不要把“隔离级别”和“锁”混为一谈很多人一提到 RR 就只想到“锁更严”实际上隔离级别的区分在常见数据库的 InnoDB 存储引擎里主要靠两套机制配合MVCC多版本并发控制负责快照读的隔离锁记录锁、间隙锁、临键锁负责当前读的隔离。快照读和当前读是理解整个问题的钥匙。快照读就是普通 SELECT 不带任何加锁子句它不会阻塞别的事务读的是“历史版本”当前读则是带 FOR UPDATE、LOCK IN SHARE MODE 的查询以及 INSERT、UPDATE、DELETE这些必须要读“当前最新已提交版本”同时给相关行加锁。RC 和 RR 的差异其实就是这两种读法在不同隔离级别下的行为差异。RC 下每次快照读都创建一个新的 ReadView所以能看到其他事务刚提交的数据RR 下整个事务只用第一个 ReadView所以能保证重复读一致。而当前读则依赖锁机制RC 只锁命中的记录行RR 还会锁间隙防止其他事务在查询范围内插入新行。1.3 RR 和 RC 在实际使用中的表现差异举个很常见的例子。假设有一张订单表事务 A 执行了两条相同的查询RC 下如果事务 B 在 A 的两次查询之间提交了一条新订单A 第二次查询会看到这条新数据。这就叫不可重复读——同一条 SELECT 语句在一个事务里返回了不同的结果集。RR 下A 的第一次查询会生成一个 ReadView之后不管 B 提交了什么A 第二次、第三次查询都基于同一个 ReadView读到的还是第一次查询时的数据版本因此不会看到 B 的新订单。但是请注意如果你在 RR 下把查询改成当前读比如 SELECT ... FOR UPDATE情况就变了。当前读不走 ReadView它必须去读最新的已提交版本所以如果 B 此时先提交了A 的 FOR UPDATE 依然有可能看到 B 提交的新数据。为了保证这种情况下不出现幻读RR 才会引入间隙锁把 B 的插入操作给堵住。这就是为什么“RR 能彻底防幻读”这种说法是不够准确的——它防止的是快照读的幻读当前读的幻读要靠锁来防。2. ReadView 到底是个什么东西2.1 没有 ReadView 之前数据版本怎么存的要理解 ReadView先得知道数据行在底层是怎么保存多版本的。每行记录其实不只有业务字段还藏了几个隐式字段其中最关键的 row trx_id 记录着这行数据最近一次修改它的事务 ID。当事务修改某一行时不是直接把旧值覆盖掉而是会把旧值复制一份写到 undo log 里形成一条版本链。链表头是最新版本链表尾是最早的历史版本。想象一个简陋场景一行余额字段最初是 100。事务 10 把它改成 200事务 20 又把它改成 300。那么底层大概是这个结构300row trx_id20 - 200row trx_id10 - 100初始版本更早的 trx_id0每个新版本都指向前一个旧版本这条链就叫版本链。当某个事务执行标准 SELECT 时它不能随便挑一个版本读它必须能判断这条链上的哪些版本是“我能看见的”。ReadView 就是这个“判断标准”。2.2 ReadView 的四个核心字段ReadView 本质上是一个由数据库在某个时间点生成的“活跃事务快照”它记录了这个时间点哪些事务还在运行、哪些已经被分配了 ID 但尚未开始等等。不同教材命名不一致但核心就四个要素creator_trx_id创建当前 ReadView 的事务自己的 ID。自己改的数据肯定要能看到。m_ids创建 ReadView 时当前系统中“活跃事务”的 ID 列表。活跃指的是还没提交的事务。min_trx_idm_ids 里最小的那个事务 ID。max_trx_id创建 ReadView 时下一个要分配的事务 ID。换句话说凡是 ID 大于等于这个值的事务都是在 ReadView 创建之后才出现的可以认为是“来自未来的事务”。把这四个字段的含义想清楚可见性判断就成功了一大半。它们之间的关系可以用一句话概括在 ReadView 创建的那一刻数据库把所有还没提交的事务 ID 列了个名单并记住自己的 ID、最小活跃 ID 和未来 ID 起点。2.3 判断版本可见性的五步法则假设我们现在拿到版本链上的某个版本它的 row trx_id 是 trx_id要判断它对于当前事务是否可见过程如下如果 trx_id 等于 creator_trx_id说明这个版本是当前事务自己改的可见。如果 trx_id 小于 min_trx_id说明这个版本在 ReadView 创建之前就已经提交了可见。如果 trx_id 大于等于 max_trx_id说明这个版本是由 ReadView 创建之后的事务生成的不可见。如果 trx_id 介于 min_trx_id 和 max_trx_id 之间并且 trx_id 在 m_ids 列表里说明这个事务在当前 ReadView 创建时还没提交所以不可见。如果 trx_id 介于 min_trx_id 和 max_trx_id 之间但 trx_id 不在 m_ids 列表里说明它在 ReadView 创建时已经提交了可见。如果判断结果为“不可见”就沿着版本链再找下一个旧版本重复上述判断直到找到可见版本为止。这一步一定要多推演几遍。我见过的本地理解错误大多出在第 3 和第 4 步上。特别是“trx_id 在 m_ids 列表里但却是当前事务自己”的情况需要提前用第 1 步杀掉否则会被第 4 步误判为不可见然后读到一个旧版本造成自己更新后 SELECT 不到最新值的诡异问题。2.4 用生活化类比帮你记住可以把 ReadView 想象成一个“排队叫号系统”的现场截图。你在某个时刻来到银行工作人员拍了一张照片照片上记录了你自己手里的号creator_trx_id当前所有正在窗口办事还没结束的号m_ids当前最早喊到的号min_trx_id已经取号但还没轮到的最小的号max_trx_id之后你在等位时看到的业务进展都以这张照片为准。照片拍完之后新取号的人即使业务办完了对你的“视野”来说也是不存在的。这就是 RR 的实现核心它只拍一次照片。3. 快照读、当前读和隔离级别的对应关系3.1 快照读如何对应 ReadView快照读是数据库 MVCC 技术的主要消费者。普通 SELECT 语句不会加锁它沿着版本链走用 ReadView 对每个版本做可见性判断找到第一个可见版本返回。RC 和 RR 在快照读上的核心差异是 ReadView 的生成时机。RC每条普通 SELECT 语句开始执行时都会基于当前系统活跃事务重新生成一个 ReadView。也就是说如果别的事务在两次 SELECT 之间提交了第二次 SELECT 再看版本链时min_trx_id、m_ids 都变了原本不可见的版本可能变成了可见版本于是读到了新数据这就是不可重复读的根源。RR事务内第一条普通 SELECT 语句执行时生成一个 ReadView之后事务内所有快照读都复用这个 ReadView不再重新生成。不管其他事务提交了多少次看到的“照片”永远是第一次拍照时的样子所以可重复读。这里有个容易被忽略的细节在 RR 下如果你事务刚开始执行的是 UPDATE当前读而不是 SELECT那么当前读会读取最新版本并修改数据但此时并不会立刻创建 ReadView。之后再执行普通 SELECT创建 ReadView 时当前事务自己修改的版本因为 trx_id 等于 creator_trx_id 而可见所以你别以为“RR 只会照第一次 SELECT 的旧相片”事实上当前事务自己的修改永远可见。3.2 当前读如何绕过 ReadView当前读不查版本链它只读数据行当前最新的已提交版本而且必须加锁。为什么必须加锁因为如果不加锁两个事务同时对同一行做“读-改-写”后提交的事务可能会直接把前一个事务的修改覆盖。当前读对应的语句类型SELECT ... FOR UPDATE排他锁SELECT ... LOCK IN SHARE MODE共享锁不同数据库语法有差异INSERT、UPDATE、DELETE这些操作执行时数据库直接锁定相关记录保证在这条记录被锁定期间其他事务无法修改或删除。RC 下只用记录锁锁住命中的行RR 下除了记录锁还会对索引扫描范围内的间隙加间隙锁或临键锁防止其他事务往这个范围里插入新行从而在“当前读”的语境下阻止幻读。3.3 一张表看清楚对应关系我把常见的关系整理成一张对照表可以随时查阅读类型RC 下的行为RR 下的行为用的核心机制普通 SELECT快照读每条语句重新生成 ReadView事务内第一条 SELECT 生成 ReadView后续复用MVCC ReadViewSELECT ... FOR UPDATE当前读读取最新已提交版本只加记录锁读取最新已提交版本加记录锁间隙锁锁不读版本链INSERT / UPDATE / DELETE读取最新已提交版本先加锁再操作读取最新已提交版本先加锁再操作锁不读版本链能否防止不可重复读不能能快照读场景ReadView 复用机制能否防止幻读不能快照读能防当前读靠间隙锁防ReadView 间隙锁这套对应关系理解以后你就能解释很多现象的来龙去脉。比如为什么 RC 下长事务里多次 SELECT 看到的数据都是“最新”的为什么 RR 下事务开始后第一次 SELECT 的时间点那么重要以及为什么 RR 下当前读如果不走索引间隙锁可能锁住整个表。4. 实操亲自动手观察 ReadView 的行为4.1 准备一个最小实验环境光看理论没用建议你开两个数据库会话亲手验证。不限具体数据库只要支持 MVCC 和这两种隔离级别即可。操作如下准备一张单表 test_account字段就两个id 和 balance。插入一行数据id1balance100。开两个会话A 和 B分别设置隔离级别来对照观察。SQL 示意如下-- 会话 A SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; SELECT * FROM test_account WHERE id 1; -- 此时拿到 balance100 -- 会话 B SET TRANSACTION ISOLATION LEVEL REPEATABLE READ; START TRANSACTION; UPDATE test_account SET balance 200 WHERE id 1; COMMIT; -- 回到会话 A再次执行相同的 SELECT SELECT * FROM test_account WHERE id 1; -- 结果依然是 balance100这就是 RR 的快照读 -- 但如果把会话 A 的隔离级别改成 READ COMMITTED 重跑一遍第二次查询就会看到 200如果你想更直观地看到版本链和 ReadView 的影响可以在两个事务中交叉操作并在每次查询前后观察数据库中是否存在未提交事务。也可以用 EXPLAIN 或查看事务状态在部分数据库的 performance_schema 中能看到活动事务信息。4.2 制造一个“当前读”和“快照读”冲突的案例RR 下最容易踩坑的场景是“先快照读再当前读再快照读”。在可重复读级别下这个顺序会导致你先读到一个旧快照然后当前读读到最新并把最新行锁住修改提交后后续快照读看到自己修改过的新数据但其他事务的提交你依然看不到。具体操作-- 会话 ARR 模式 START TRANSACTION; SELECT * FROM test_account WHERE id 1; -- 读到 100 -- 会话 B 此时把 balance 改成 200 并提交 SELECT * FROM test_account WHERE id 1; -- 仍然读到 100 -- 会话 A 当前读 SELECT * FROM test_account WHERE id 1 FOR UPDATE; -- 此时会读到 200因为当前读忽略 ReadView读最新已提交版本 -- 紧接着会话 A 执行 UPDATE 把 balance 改成 300 UPDATE test_account SET balance 300 WHERE id 1; -- 再执行普通 SELECT SELECT * FROM test_account WHERE id 1; -- 读到 300因为这是自己改的这个实验充分说明了RR 下普通 SELECT 受 ReadView 约束当前读不受 ReadView 约束。如果业务代码里不小心把“先查后改”的读操作写成了普通查询然后在 RR 模式下又依赖这个“旧值”去执行更新非常容易造成数据覆盖或者死锁。4.3 如何动手“看到”ReadView有些数据库提供了命令或表来查看当前事务列表。以常见的 InnoDB 为例你可以查询 information_schema.innodb_trx 来观察活跃事务的 ID、开始时间等。不过它看不到每个 ReadView 的完整内部结构生产环境也不需要直接翻 ReadView。真正有用的做法是通过观察行为来反推。你可以用下面的手法把 ReadView 的行为“逼出来”事务 A 开启事务但不做任何 SELECT事务 B 提交一条修改事务 A 执行第一条 SELECT记录结果事务 B 再提交一条修改事务 A 执行第二条 SELECT观察结果是否变化。如果隔离级别是 RR结果不变如果改成 RC结果会变。这就是判断数据库当前生效隔离级别最朴素也最靠谱的办法。5. 常见问题与排查技巧实录5.1 为什么在 RC 下还会出现“本来不存在的行”RC 下同一个事务里两次 SELECT第二次多了一行很多人觉得奇怪明明没做过任何操作。原因就是 RC 每次都创建新的 ReadView第二次 SELECT 时另一个事务插入并提交的数据已经不在活跃名单里了因此变得可见。这本质就是不可重复读的表现但在数据量大的列表页里会表现出“明明刷新一下多了一条记录”的错觉。排查思路很简单检查隔离级别检查事务是否真正开启有些连接池自动提交导致 SELECT 之间没有事务包裹最后看是否有其他会话在频繁插入。5.2 RR 下为什么还是能看到“幻读”如果你在 RR 下用多个普通 SELECT 查同一个范围理论上结果集是固定的因为 ReadView 复用了。但很多人遇到了例外情况今天排查半天基本都是这些原因第一次查询之前事务里已经执行过当前读如 UPDATE此时 ReadView 没有被创建当前读锁定的范围会决定后续快照读的可见性吗不会改变快照读规则但如果你先当前读再快照读快照读创建时会把“已提交的其他事务版本”判为不可见因此还是没问题。更常见的原因是查询语句没有走同一个索引或者使用了不同的查询条件导致数据库分配了不同的锁范围。还有一个很容易踩的坑RR 下当前读如 SELECT ... FOR UPDATE不会使用 ReadView如果你用当前读查两次即使同一查询条件也可能因为另一个事务在两次查询之间提交了插入第二次查到了新行。如果没有间隙锁保护这就会幻读。换句话说快照读的 RR 是真的可重复读当前读的 RR 只是“加了锁的读最新”锁覆盖不到位就会有幻读。5.3 长事务为什么会让数据库疯涨这个问题几乎是所有 MVCC 数据库都会遇到的一个 RR 事务开启后如果长时间不结束它的 ReadView 就会一直保留意味着版本链上所有在它之后产生的旧版本都不能被清理。即使你只占用了 1 行数据版本链却可能累积了成千上万个旧版本导致 undo log 文件持续增大。我在一次性能排查中发现某个业务表只有几十万行但磁盘占用有几十 GB查到最后就是一个夜间的统计事务跑了 40 分钟期间业务一直在更新数据旧版本根本清不掉。解决方法不是调参数而是让应用程序缩短事务时间把大事务拆小。5.4 排查技巧用“两读一改”快速定位事务问题当业务反馈数据时对时错我的处理习惯是先确认事务隔离级别看是不是连接配置被改过。再确认是否同一个连接在没有提交的情况下反复执行 SELECT。接着做一次“两读一改”实验在两个会话里开同样的事务先分别读同一行然后其中一个事务修改并提交另一个事务再读观察结果是否符合隔离级别。如果结果出乎意料重点检查是不是有触发器、事件、中间件改了事务边界比如 ORM 框架默认隐式提交。这类问题 90% 都不是数据库实现有 bug而是应用根本没有按照预期开启或提交事务。6. 我核算之后沉淀下来的一点经验以前总觉得只要懂概念就行后来被吃了亏才知道RR/RC 和 ReadView 的关系光背结论是没用的。真的遇到线上数据错乱你必须能在脑子里快速“模拟”出当前事务的 ReadView 长什么样然后判断哪个版本可见。这需要大量的手动推演。我个人习惯是把每次排查到的诡异复现过程都贴到本地笔记里用简化版 SQL 以及“会话A/B”的方式记录下来。下次遇到类似问题直接翻笔记对照比重新分析一遍快得多。另外不要盲目把系统默认隔离级别从 RR 改成 RC总觉得 RC 并发高、锁竞争小。如果你的业务里存在比较复杂的读改写逻辑又依赖 RR 的快照读来保证一致性改成 RC 反而会出现意想不到的不可重复读问题。最后分享一个小技巧面试或者写方案时与其把所有概念都讲一遍不如只画一下两个事务的时序图说明哪些操作生成了新的 ReadView哪些操作走了当前读。这个图讲清楚了说明你是真懂了而不是背了定义。