首先复习一下MVCC原理,详细内容可参考
【MySQL笔记】正确的理解MySQL的MVCC及实现原理_mysqlmvcc实现原理-CSDN博客

1.疑问点一

关于上述blog中 RR 是如何在 RC 级的基础上解决不可重复读的 的解释,个人认为比较抽象,这里记录个人理解

Read View源码中大致包含以下内容(具体名称可能与上述博客有出入,代指含义基本相同上述博客中的字段名是作者自己取的是🐶)

  • m_ids:创建 Read View 时,系统内活跃(尚未提交)的事务 ID 集合。
  • min_trx_idm_ids 中的最小值。
  • max_trx_id:创建 Read View 时,系统应该分配给下一个事务的 ID 值(即当前最大事务 ID + 1)。
  • creator_trx_id:创建该 Read View 的事务自身的 ID(对于只读事务,可能为 0)。

==RR级别下假设的事务执行流程==:

  1. 事务开始: 假设事务 T1 (ID=100) 开始
  2. 第一次 SELECT (创建 Read View)
    • T1 执行 SELECT * FROM t WHERE id = 1;
    • MySQL 为 T1 创建 Read View RV1
      • m_ids = 活跃事务列表,比如 50, 80 (假设活跃事务ID为50和80)
      • min_trx_id = 50
      • max_trx_id = 101 (下一个事务ID,最新事务为T1 ID=100)
      • creator_trx_id = 100
    • 根据 RV1 和行 id=1 的版本链,找到对 T1 可见的版本(假设是事务 40 提交的版本 V1),返回 V1 的数据。
  3. 并发修改:此时另一个事务 T2 (ID=80) 更新id=1 这行数据(生成新版本 V2DB_TRX_ID=80)并提交
  4. 第二次 SELECT (复用 Read View):
    • T1 再次执行 SELECT * FROM t WHERE id = 1;
    • 关键点:T1 复用第一次查询时创建的 Read View RV1。RV1 的 m_ids 仍然记录的是创建时的活跃事务 50-80(尽管 T2=80 现在已经提交了)
    • 遍历行 id=1 的版本链:
      • 最新版本是 V2 (DB_TRX_ID=80)
      • 检查 V2 对 RV1 是否可见:
        • 80min_trx_id (50) 和 max_trx_id (101) 之间
        • 80 RV1 的 m_ids (包含 80) 中 -> 不可见
      • 继续查找上一个版本 V1 (DB_TRX_ID=40)
      • 检查 V1
        • 40 小于 min_trx_id (50) -> 可见

image-20250714205416443

==总结==:本质上是保证了在当前事务内,每次读取某条记录得到的结果永远一致,由此引出了疑问点二

2.疑问点二ß

从上面的例子来看,事务T1两次读取的都是V1版本的快照,但是事务T2已经将数据修改为另一个版本,这样数据有差异会不会引起什么问题

1. RR 的核心目标:事务内部的“时间凝固”

  • 设计意图: RR 隔离级别保证:在同一个事务内部,多次读取同一行数据,看到的结果是绝对一致的(可重复读)。即使外部世界(其他事务)已经修改并提交了数据。
  • 实现方式: 如前所述,通过在整个事务中复用第一次 SELECT 时创建的 Read View,使得该事务永远只能看到这个“快照时间点”之前已提交的数据(以及它自身所做的修改)。对于该事务来说,时间仿佛在它第一次读数据时就凝固了。
  • 疑问: 在 T1 的视角里,它两次读取 id=1 看到的都是 V1(事务40提交的版本)。T2 提交的 V2 版本对于 T1 来说“不存在”或者说“尚未发生”**。T1 的世界观停留在它开始读取的那个瞬间。这就是“可重复读”的含义。

    2. 为什么这种“差异”在 RR 下是可以接受的/设计的?

  • 业务场景需求: 很多业务逻辑需要在事务执行期间看到一个稳定的、不受外部干扰的数据视图。例如:

    • 金融对账: 在计算账户余额、生成报表时,需要基于某个固定时间点的数据快照进行计算。如果在计算过程中基础数据(如账户余额)被其他事务修改,会导致结果混乱且不可审计。
    • 一致性决策: 一个事务可能需要基于之前查询的结果集做后续操作。如果两次查询结果不一致(不可重复读),可能导致逻辑错误。RR 保证了决策依据的稳定性。
    • 长事务分析: 分析型查询可能需要扫描大量数据,耗时较长。RR 保证在整个扫描过程中看到的是同一份数据快照,结果具有内部一致性。
  • 隔离性的代价: 数据库的隔离级别本质上是在并发性能和数据一致性/时效性之间做权衡。RR 牺牲了在事务执行期间看到其他事务最新提交数据的时效性,换取了事务内部读取数据的强一致性(可重复读)
  • “过时”≠“错误”: T1 看到的 V1 版本,在它第一次读取时是真实且已提交的状态。RR 只是保证了 T1 在整个生命周期内看到的都是这个曾经真实存在过的、符合其时间点一致性的状态。这不是一个“错误”的数据,而是一个“历史”的数据。

3. 这种“差异”何时会成为问题?如何解决?

虽然 RR 解决了不可重复读,但刻意维持旧视图的行为确实可能在特定场景下引发问题或需要额外注意:

  1. 写操作基于“过时”视图 (Lost Update / Write Skew):

    • 场景: T1 读取 V1(余额=100),T2 也读取 V1(余额=100)。T2 基于 V1 计算并更新余额为 80(生成 V2)提交。T1 也基于它读到的 V1(100)计算并尝试更新余额为 120。
    • 问题: 如果直接让 T1 的更新覆盖 T2 的更新(变成 120),那么 T2 的更新就丢失了(Lost Update)。数据库应该期望最终余额是 80 还是 120?逻辑上都不对。
    • ==RR 的解决机制 (Next-Key Lock / 行锁):==
      • MySQL InnoDB 的 RR 级别不仅依赖 MVCC 的快照读,还对写操作(UPDATE, DELETE)和显式加锁读(SELECT ... FOR UPDATE/SHARE) 使用 锁机制 (Next-Key Locking)
      • 当 T1 执行 UPDATE t SET balance = 120 WHERE id = 1; 时:
        • 不会直接去修改它看到的 V1 版本。
        • 它会尝试获取行 id=1 的排他锁 (X Lock)
        • 如果 T2 已经提交(锁已释放),T1 能获得锁。
        • 但关键点:在修改之前,InnoDB 会执行 “当前读” (Current Read)。它会读取该行的最新已提交版本 (V2, balance=80),而不是 T1 Read View 中的 V1!(这是 MVCC 快照读和写操作的“当前读”的重要区别!)。
        • T1 的 UPDATE 操作会基于这个最新提交的 V2 (80) 来计算新值。假设业务逻辑是 balance = balance - 20,那么:
          • 如果基于 V1(100) 计算:100 - 20 = 80 (错误,因为实际最新是80)。
          • 但引擎基于当前读到的 V2(80) 计算:80 - 20 = 60。
        • 然后生成新版本 V3 (trx_id=100, balance=60)。
      • 结果: T2 的更新(80)没有被覆盖,T1 的更新是基于最新状态(80)计算的(60)。避免了更新丢失。最终结果符合逻辑(先减到80,再减到60)。
      • 结论: RR 级别下,写操作总是基于最新的已提交数据,不受本事务 Read View 快照的影响。这解决了基于“过时”视图写数据可能导致的问题。快照读看到的“旧”数据和写操作需要的“新”数据通过锁和“当前读”机制协调。
  2. 需要读取最新数据:

    • 场景: 如果业务逻辑要求在一个事务内必须读取到其他事务提交的最新数据(例如实时监控、某些类型的通知)。
    • 解决方案:
      • 降低隔离级别到 RC: 每次 SELECT 都会看到最新提交的数据。但会面临不可重复读和幻读。
      • 在 RR 中使用 FOR UPDATE / LOCK IN SHARE MODE 这些加锁读会执行“当前读”,直接获取最新已提交版本并加锁,绕过 Read View 快照。
      • 使用 SELECT ... FOR UPDATE SELECT * FROM t WHERE id = 1 FOR UPDATE; (获取排他锁,执行当前读,看到 V2)。
      • 使用 SELECT ... LOCK IN SHARE MODE SELECT * FROM t WHERE id = 1 LOCK IN SHARE MODE; (获取共享锁,执行当前读,看到 V2)。
      • 执行写操作 (UPDATE/DELETE): 如前所述,写操作本身就会触发“当前读”。

==总结==:T1内部在进行select操作时,采用的时快照读,这个快照始终是V1版本数据,但是如果T1内部进行了update操作,在进行类似balance = balance-20的操作时,采取的时当前读,只会有一个事务拿到该记录的排他锁,若T2优先获得排他锁,T1更新的必然是T2操作完之后的数据。因此不存在更新操作冲突