读已提交和可重复读到底有啥不一样?为什么RC就不能解决不可重复读和幻读呢?
作者小饼干日期2026-04-29标签MySQL, InnoDB, MVCC, 事务隔离, ReadView前言大家好我是小饼干。今天我们来深入聊聊MySQL InnoDB中两个最核心的隔离级别读已提交Read Committed, RC和可重复读Repeatable Read, RR。很多人知道它们不同但为什么不同底层的MVCC机制是如何让它们产生差异的这就是我们今天要解开的谜题。我会用详细的图例和一步步的推导带你彻底理解为什么RC不能解决不可重复读和幻读而RR可以。一、MVCC三大核心组件回顾在深入之前我们先快速回顾一下MVCC的三大核心组件1. 隐藏字段每行数据都有三个隐藏字段DB_TRX_ID6字节最后一次插入/更新该行的事务IDDB_ROLL_PTR7字节回滚指针指向undo log中的旧版本DB_ROW_ID6字节隐藏的自增ID当没有主键时使用2. Undo Log版本链每次数据被修改时旧的数据不会被直接删除而是写入undo log并通过DB_ROLL_PTR形成链表结构。这就是版本链。3. ReadView读视图这是MVCC的灵魂决定了你能看到什么。ReadView包含m_ids生成ReadView时活跃的事务ID列表未提交的事务min_trx_id活跃事务ID中的最小值max_trx_id下一个将要分配的事务ID预分配值creator_trx_id创建该ReadView的事务自身ID二、可见性判断算法核心ReadView的核心作用是判断某个版本的数据对当前事务是否可见。算法如下functionchanges_visible(trx_id,read_view):// 规则1如果trx_id min_trx_id说明数据在ReadView创建前已提交可见iftrx_idread_view.min_trx_id:returntrue// 规则2如果trx_id max_trx_id说明数据在ReadView创建后才产生不可见iftrx_idread_view.max_trx_id:returnfalse// 规则3如果trx_id creator_trx_id是自己修改的可见iftrx_idread_view.creator_trx_id:returntrue// 规则4如果trx_id在min_trx_id和max_trx_id之间// 检查是否在活跃事务列表中iftrx_idinread_view.m_ids:// 在活跃列表中说明修改该数据的事务还未提交不可见returnfalseelse:// 不在活跃列表中说明该事务已提交可见returntrue三、RC vs RRReadView创建时机的根本差异这才是问题的关键两者的核心区别在于ReadView的创建时机RC读已提交每次SELECT都会生成新的ReadView这意味着每次读取都是基于当前时刻的快照能看到本次SELECT执行前已提交的所有数据RR可重复读只有第一次SELECT时生成ReadView后续所有SELECT都复用这个ReadView整个事务看到的数据都是第一次SELECT时刻的快照我们来用图例一步步推导四、推导1为什么RC能避免脏读但不能避免不可重复读场景设定假设我们有这样一个并发场景时间线 t1: 事务A开始事务ID100 t2: 事务B开始事务ID200 t3: 事务A查询数据SELECT * FROM users WHERE id1 t4: 事务B更新id1的数据并提交UPDATE users SET nameBob WHERE id1 t5: 事务A再次查询相同数据第一步t3时刻事务A第一次查询RC在RC级别下事务A执行第一次SELECT时会生成ReadView1ReadView1: - m_ids [100, 200] (事务A和B都未提交) - min_trx_id 100 - max_trx_id 201 - creator_trx_id 100假设数据原本由事务99插入版本链trx_id99 → 数据根据规则199 100可见所以事务A看到的是事务99插入的原始数据。第二步t4时刻事务B更新并提交事务B更新数据新版本trx_id200 → 数据name‘Bob’版本链变为trx_id200 → trx_id99事务B提交后200不再在活跃事务列表中。第三步t5时刻事务A第二次查询RC关键点来了在RC级别下第二次SELECT会生成新的ReadView2ReadView2: - m_ids [100] (只有事务A还在活跃) - min_trx_id 100 - max_trx_id 201 - creator_trx_id 100现在遍历版本链最新版本trx_id200判断200是否可见200 100 (满足规则2的trx_id max_trx_id? 不200 201)200在[100, 201)之间200不在m_ids[100]中根据规则4200不在活跃列表中可见结果事务A第二次查询看到了事务B已提交的修改不可重复读发生了为什么能避免脏读如果事务B没有提交那么ReadView2的m_ids中仍然包含200根据规则4trx_id200在活跃列表中不可见所以看不到未提交的数据五、推导2为什么RR能避免不可重复读同样的场景但在RR级别下第一步t3时刻事务A第一次查询RR生成ReadView1和RC相同ReadView1: - m_ids [100, 200] - min_trx_id 100 - max_trx_id 201 - creator_trx_id 100看到trx_id99的数据。第二步t5时刻事务A第二次查询RR关键区别RR级别下复用第一次的ReadView1还是用ReadView1判断trx_id200200在[100, 201)之间200在m_ids[100, 200]中事务B虽然已提交但ReadView1记录的是生成时的状态根据规则4在活跃列表中不可见继续向下找旧版本trx_id9999 100根据规则1可见结果事务A两次查询看到相同的数据避免了不可重复读六、可视化对比图渲染错误:Mermaid 渲染失败: Parse error on line 3: ...ReadView1m_ids[100,200]] B -----------------------^ Expecting SQE, DOUBLECIRCLEEND, PE, -), STADIUMEND, SUBROUTINEEND, PIPE, CYLINDEREND, DIAMOND_STOP, TAGEND, TRAPEND, INVTRAPEND, UNICODE_TEXT, TEXT, TAGSTART, got SQS七、关于幻读的深入分析幻读是什么不可重复读同一记录多次读取值不同幻读同一条件多次查询返回的记录数不同多了新插入的记录为什么RR能避免大部分幻读同样的原理对于SELECT查询RR复用第一次的ReadView新插入的记录其trx_id肯定大于等于ReadView的max_trx_id根据规则2trx_id max_trx_id不可见所以看不到新插入的记录但是RR不能完全避免幻读这里有个关键点当混用快照读和当前读时幻读仍可能发生。场景混用导致幻读-- 事务ARR级别BEGIN;-- 快照读看不到id5的记录SELECT*FROMusersWHEREid5;-- 空结果-- 事务B插入id5并提交-- INSERT INTO users(id, name) VALUES(5, Alice); COMMIT;-- 当前读可以看到最新数据SELECT*FROMusersWHEREid5FORUPDATE;-- 能看到id5-- 或者执行UPDATEUPDATE会先当前读UPDATEusersSETnameBobWHEREid5;-- 能成功更新-- 再次快照读现在能看到id5了因为是自己修改的SELECT*FROMusersWHEREid5;-- 能看到为什么最后能看到了因为UPDATE执行前会先当前读获取最新数据更新后新版本的trx_id100事务A自己的ID根据规则3trx_id creator_trx_id可见如何彻底避免幻读在事务的第一个查询就使用当前读加锁-- 使用间隙锁锁定范围SELECT*FROMusersWHEREageBETWEEN20AND30FORUPDATE;-- 或者使用LOCK IN SHARE MODESELECT*FROMusersWHEREageBETWEEN20AND30LOCKINSHAREMODE;这样会加Next-Key Lock记录锁间隙锁阻止其他事务在锁定范围内插入数据。八、源码层面的验证从我们收集的资料中可以看到InnoDB源码中确实是这样实现的// 简化的ReadView判断逻辑 bool changes_visible(trx_id_t id, const ReadView* view) { // 规则1小于最小活跃事务ID if (id view-up_limit_id()) { return true; } // 规则2大于等于预分配的下一个事务ID if (id view-low_limit_id()) { return false; } // 规则3是自己创建的数据 if (id view-creator_trx_id()) { return true; } // 规则4在中间范围 return !binary_search(view-ids().begin(), view-ids().end(), id); }九、实际应用建议什么时候用RC读多写少对数据实时性要求高可以接受不可重复读的业务场景想要减少锁等待提高并发性能什么时候用RR需要保证事务内数据一致性财务、订单等关键业务涉及多个相关查询需要结果一致的场景最佳实践// 只读事务使用RR readOnlyTransactional(isolationIsolation.REPEATABLE_READ,readOnlytrue)publicReportgenerateFinancialReport(){// 多个查询保证一致性returnreportService.generate();}// 更新操作注意使用合适的锁Transactional(isolationIsolation.REPEATABLE_READ)publicvoidtransfer(Longfrom,Longto,BigDecimalamount){// 使用当前读锁定账户AccountfromAccaccountRepo.findByIdForUpdate(from);AccounttoAccaccountRepo.findByIdForUpdate(to);// 执行业务逻辑fromAcc.withdraw(amount);toAcc.deposit(amount);}十、总结让我们回到最初的问题读已提交和可重复读到底有啥不一样核心答案就是ReadView的创建时机不同。RC每次SELECT新建ReadView能看到本次查询前已提交的所有数据RR第一次SELECT时创建ReadView后续复用看到的是第一次查询时的数据快照这就导致了RC能避免脏读因为未提交的事务ID在ReadView的活跃列表中RC不能避免不可重复读因为每次新ReadView能看到期间已提交的修改RR能避免不可重复读因为复用同一个ReadView看不到期间已提交的修改RR能避免大部分幻读同样的原理看不到期间新插入的记录RR不能完全避免幻读当混用快照读和当前读时可能发生希望这篇详细的推导能帮助你深入理解MVCC的精髓参考资料MySQL InnoDB的可重复读是多种实现的混合体MySQL MVCC机制深度解析深入理解 InnoDB 的 MVCC:原理、Read View 与可见性判断如果你对数据库并发控制有更多疑问欢迎在评论区留言讨论