当前位置: 首页 > news >正文

读已提交和可重复读到底有啥不一样?为什么RC就不能解决不可重复读和幻读呢?

作者:小饼干
日期:2026-04-29
标签:MySQL, InnoDB, MVCC, 事务隔离, ReadView

前言

大家好,我是小饼干。今天我们来深入聊聊MySQL InnoDB中两个最核心的隔离级别:读已提交(Read Committed, RC)可重复读(Repeatable Read, RR)。很多人知道它们不同,但为什么不同,底层的MVCC机制是如何让它们产生差异的?这就是我们今天要解开的谜题。

我会用详细的图例和一步步的推导,带你彻底理解为什么RC不能解决不可重复读和幻读,而RR可以。

一、MVCC三大核心组件回顾

在深入之前,我们先快速回顾一下MVCC的三大核心组件:

1. 隐藏字段

每行数据都有三个隐藏字段:

  • DB_TRX_ID(6字节):最后一次插入/更新该行的事务ID
  • DB_ROLL_PTR(7字节):回滚指针,指向undo log中的旧版本
  • DB_ROW_ID(6字节):隐藏的自增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_id<read_view.min_trx_id:returntrue// 规则2:如果trx_id >= max_trx_id,说明数据在ReadView创建后才产生,不可见iftrx_id>=read_view.max_trx_id:returnfalse// 规则3:如果trx_id == creator_trx_id,是自己修改的,可见iftrx_id==read_view.creator_trx_id:returntrue// 规则4:如果trx_id在min_trx_id和max_trx_id之间// 检查是否在活跃事务列表中iftrx_idinread_view.m_ids:// 在活跃列表中,说明修改该数据的事务还未提交,不可见returnfalseelse:// 不在活跃列表中,说明该事务已提交,可见returntrue

三、RC vs RR:ReadView创建时机的根本差异

这才是问题的关键!两者的核心区别在于ReadView的创建时机

RC(读已提交)

  • 每次SELECT都会生成新的ReadView
  • 这意味着每次读取都是基于"当前时刻"的快照
  • 能看到本次SELECT执行前已提交的所有数据

RR(可重复读)

  • 只有第一次SELECT时生成ReadView
  • 后续所有SELECT都复用这个ReadView
  • 整个事务看到的数据都是"第一次SELECT时刻"的快照

我们来用图例一步步推导:

四、推导1:为什么RC能避免脏读,但不能避免不可重复读?

场景设定

假设我们有这样一个并发场景:

时间线: t1: 事务A开始,事务ID=100 t2: 事务B开始,事务ID=200 t3: 事务A查询数据(SELECT * FROM users WHERE id=1) t4: 事务B更新id=1的数据并提交(UPDATE users SET name='Bob' WHERE id=1) t5: 事务A再次查询相同数据

第一步:t3时刻事务A第一次查询(RC)

在RC级别下,事务A执行第一次SELECT时会生成ReadView1:

ReadView1: - m_ids = [100, 200] (事务A和B都未提交) - min_trx_id = 100 - max_trx_id = 201 - creator_trx_id = 100

假设数据原本由事务99插入:

  • 版本链:trx_id=99 → 数据
  • 根据规则1:99 < 100,可见

所以事务A看到的是事务99插入的原始数据。

第二步:t4时刻事务B更新并提交

事务B更新数据:

  • 新版本:trx_id=200 → 数据(name=‘Bob’)
  • 版本链变为:trx_id=200 → trx_id=99

事务B提交后,200不再在活跃事务列表中。

第三步:t5时刻事务A第二次查询(RC)

关键点来了!在RC级别下,第二次SELECT会生成新的ReadView2

ReadView2: - m_ids = [100] (只有事务A还在活跃) - min_trx_id = 100 - max_trx_id = 201 - creator_trx_id = 100

现在遍历版本链:

  1. 最新版本trx_id=200
  2. 判断200是否可见:
    • 200 >= 100 (满足规则2的trx_id >= max_trx_id? 不,200 < 201)
    • 200在[100, 201)之间
    • 200不在m_ids=[100]中
    • 根据规则4:200不在活跃列表中,可见!

结果:事务A第二次查询看到了事务B已提交的修改,不可重复读发生了

为什么能避免脏读?

如果事务B没有提交,那么:

  • ReadView2的m_ids中仍然包含200
  • 根据规则4,trx_id=200在活跃列表中,不可见
  • 所以看不到未提交的数据

五、推导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_id=99的数据。

第二步:t5时刻事务A第二次查询(RR)

关键区别:RR级别下复用第一次的ReadView1!

还是用ReadView1判断:

  • trx_id=200
  • 200在[100, 201)之间
  • 200在m_ids=[100, 200]中(事务B虽然已提交,但ReadView1记录的是生成时的状态)
  • 根据规则4:在活跃列表中,不可见!

继续向下找旧版本:

  • trx_id=99
  • 99 < 100,根据规则1可见

结果:事务A两次查询看到相同的数据,避免了不可重复读

六、可视化对比图

渲染错误:Mermaid 渲染失败: Parse error on line 3: ...ReadView1
m_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
  • 根据规则2:trx_id >= max_trx_id,不可见
  • 所以看不到新插入的记录

但是!RR不能完全避免幻读

这里有个关键点:当混用快照读和当前读时,幻读仍可能发生。

场景:混用导致幻读
-- 事务A(RR级别)BEGIN;-- 快照读:看不到id=5的记录SELECT*FROMusersWHEREid=5;-- 空结果-- 事务B插入id=5并提交-- INSERT INTO users(id, name) VALUES(5, 'Alice'); COMMIT;-- 当前读:可以看到最新数据SELECT*FROMusersWHEREid=5FORUPDATE;-- 能看到id=5!-- 或者执行UPDATE(UPDATE会先当前读)UPDATEusersSETname='Bob'WHEREid=5;-- 能成功更新!-- 再次快照读:现在能看到id=5了(因为是自己修改的)SELECT*FROMusersWHEREid=5;-- 能看到!

为什么最后能看到了?因为:

  1. UPDATE执行前会先当前读获取最新数据
  2. 更新后,新版本的trx_id=100(事务A自己的ID)
  3. 根据规则3:trx_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 + readOnly@Transactional(isolation=Isolation.REPEATABLE_READ,readOnly=true)publicReportgenerateFinancialReport(){// 多个查询保证一致性returnreportService.generate();}// 更新操作注意使用合适的锁@Transactional(isolation=Isolation.REPEATABLE_READ)publicvoidtransfer(Longfrom,Longto,BigDecimalamount){// 使用当前读锁定账户AccountfromAcc=accountRepo.findByIdForUpdate(from);AccounttoAcc=accountRepo.findByIdForUpdate(to);// 执行业务逻辑fromAcc.withdraw(amount);toAcc.deposit(amount);}

十、总结

让我们回到最初的问题:读已提交和可重复读到底有啥不一样?

核心答案就是:ReadView的创建时机不同

  • RC:每次SELECT新建ReadView,能看到本次查询前已提交的所有数据
  • RR:第一次SELECT时创建ReadView,后续复用,看到的是第一次查询时的数据快照

这就导致了:

  1. RC能避免脏读:因为未提交的事务ID在ReadView的活跃列表中
  2. RC不能避免不可重复读:因为每次新ReadView能看到期间已提交的修改
  3. RR能避免不可重复读:因为复用同一个ReadView,看不到期间已提交的修改
  4. RR能避免大部分幻读:同样的原理,看不到期间新插入的记录
  5. RR不能完全避免幻读:当混用快照读和当前读时可能发生

希望这篇详细的推导能帮助你深入理解MVCC的精髓!


参考资料

  1. MySQL InnoDB的可重复读是多种实现的混合体
  2. MySQL MVCC机制深度解析
  3. 深入理解 InnoDB 的 MVCC:原理、Read View 与可见性判断

如果你对数据库并发控制有更多疑问,欢迎在评论区留言讨论!

http://www.jsqmd.com/news/722263/

相关文章:

  • AI Agent如何重构跨境物流的决策?
  • Umi-OCR终极指南:免费开源离线OCR工具,5分钟开启高效文字识别之旅
  • 算法训练营第二十天|150. 逆波兰表达式求值
  • 优化.NET依赖注入中的设置缓存
  • 九部门联合布局:开启3.5万亿的“超级物联网”计划
  • 别再死记硬背了!一张图看懂AXI4握手时序,附赠读/写通道依赖关系速查表
  • 物联网电主轴智能运维系统【附代码】
  • Moneta Markets亿汇:美元走强日元宽幅震荡
  • 医疗电子PCB设计:挑战、标准与关键技术解析
  • LwIP(轻量级IP协议栈)概述
  • 机器学习中的特征工程与TensorFlow模型
  • 增程式PHEV能量管理仿真——从规则策略到优化算法
  • 卡梅德生物技术快报|杂交瘤测序实战:SP2/0 假轻链酶切去除与序列验证代码
  • 2026年最新英语作文批改手机APP 帮学生快速提分的实用神器
  • 别再全网乱搜了!RAS官方模板下载与IROS/ICRA投稿避坑全指南(附会议排名)
  • 2026年Q2广州白云区搬家公司实测排行一览 - 优质品牌商家
  • 【本地部署】2026年Hermes Agent/OpenClaw7分钟超简易搭建流程
  • 时间戳处理:从Pandas到BigQuery的无缝转换
  • PHP应用容器化迁移至统信UOS与openEuler(国产操作系统适配终极手册)
  • Horos:如何免费获得专业级macOS医疗影像处理能力
  • 《Windows Internals》读书笔记 10.3.7:UBPM 的任务触发与状态管理
  • 别再只会用runOnUiThread了!Android子线程更新UI的5种正确姿势(附Handler/LiveData对比)
  • 指纹锁核心技术拆解与场景适配全推荐 - 优质品牌商家
  • wireshark学习-ARP
  • CANoe Analysis功能区保姆级教程:从Trace窗口到Graphics,手把手教你高效分析总线数据
  • “给我发个元红包“:一条群消息背后的 AI 安全危机
  • 深入探讨Rust中指针的安全性
  • 魔兽争霸3终极兼容性修复指南:5分钟解决所有现代系统运行问题
  • 从零到部署:用Uvicorn和Docker打包你的FastAPI应用(附Nginx配置)
  • 语音AI技术解析:从核心技术到产业落地