在理解了MySQL的锁和隔离级别之后,我意识到要真正掌握并发控制,MVCC是必须攻克的一关。我之前的认知停留在“它通过读快照来避免加锁”,但“快照”具体是什么、如何工作,我一直很模糊。
读操作和 写操作本质上是不冲突的,用户只是想看到数据某个瞬间的状态,并不关心这个状态是不是最新的。但它们却被一个粗暴的锁机制强行耦合在一起,导致了不必要的等待。​所以:针对并发读写的问题,我们能否避开锁机制,找到另外一种更轻量的解决方案呢?

一、 我理解的MVCC要解决的核心问题

我知道锁机制是可靠的,但它的缺点是性能开销。特别是读操作和写操作之间会相互阻塞,这在高并发读的场景下会成为瓶颈。

MVCC的思路完全不同。它的核心是为每一行数据维护多个版本。当一个事务需要读取数据时,数据库会提供一个该事务所能“看到”的、一致的版本。这样读操作就不需要等待写锁的释放,从而极大提升了并发性能。

二、 数据的版本从哪里来:Undo Log

我首先解决的问题是:这些“多个版本”的数据物理上存在哪里?

答案是我之前只知道用于回滚的 Undo Log(回滚日志)。原来它才是MVCC的基石。每次对一条记录进行修改(INSERT, UPDATE, DELETE)时,InnoDB都会将修改前的数据拷贝到Undo Log中。

更重要的是,每条记录都有一个隐藏的DB_ROLL_PTR指针。这个指针指向了这条记录的上一个版本在Undo Log中的位置。通过这个指针,一条记录的所有旧版本被串联成一条版本链。链头是最新数据,链尾是最旧的数据。

所以,当需要找到一个历史版本时,InnoDB就是顺着这条版本链去遍历的。Undo Log就是MVCC那个“多版本”的物理存储。

三、 如何决定看到哪个版本:ReadView的裁决机制

有了版本链,最关键的问题来了:当前事务应该看到版本链中的哪个版本?

这个裁决者就是 ReadView(读视图)。这是我花最多时间才彻底弄明白的部分。它的创建时机直接决定了数据库的隔离级别(RC或RR)。

  1. ReadView的创建
    • 在可重复读(RR) 隔离级别下,我的事务在第一次执行快照读(普通的SELECT)时,会创建一个ReadView。

• 在读已提交(RC) 隔离级别下,我的每一次快照读都会创建一个新的ReadView。

  1. ReadView的内容(快照的本质)
    当我(事务T103)在某个时刻T0创建一个ReadView(RV1)时,RV1会瞬间记录下数据库系统的当前状态:
    • m_ids: 一个列表,记录了T0时刻所有活跃(未提交)事务的ID。这是一个静态快照,一旦创建,内容就固定了。

• min_trx_id: m_ids中的最小值。

• max_trx_id: 系统在T0时刻下一个将要分配的事务ID。

• creator_trx_id: 创建这个ReadView的事务ID(就是我自己的ID,T103)。

  1. 最关键的可见性判断规则
    当我需要读取一行数据时,我会拿到它的版本链,并从最新版本开始,用我的RV1去判断每个版本的可见性。规则是针对每个版本的DB_TRX_ID(创建该版本的事务ID):

  2. DB_TRX_ID == creator_trx_id:这个版本是我自己修改的,可见。

  3. DB_TRX_ID < min_trx_id:这个版本的事务在RV1创建前肯定已提交,可见。

  4. DB_TRX_ID >= max_trx_id:这个版本是由在RV1创建之后才启动的事务创建的,不可见。

  5. min_trx_id <= DB_TRX_ID < max_trx_id:这是最需要理解的情况。它说明该版本的事务在RV1创建时可能活跃,也可能已提交。判断方法是:
    ◦ 如果 DB_TRX_ID 在 RV1.m_ids 列表中:说明在T0时刻,该事务还未提交,其修改不可见。

    ◦ 如果 DB_TRX_ID 不在 RV1.m_ids 列表中:说明在T0时刻,该事务已经提交,其修改可见。

我的核心领悟:
我最初的困惑在于第4点。一个事务ID在[min_trx_id, max_trx_id)范围内,怎么还可能已经提交?
关键在于:m_ids是T0时刻的静态快照。在T0之后,列表里的事务完全可以提交并从全局活跃列表中移除,但这绝不会更新已经创建好的RV1.m_ids。
因此,“在区间内但不在m_ids中” 这个状态,唯一地标识了那些“在ReadView创建瞬间已经提交”的事务。这是实现RC和RR级别区别的基石。

如果某个版本对当前事务不可见,就顺着Undo Log版本链找到上一个版本,重复上述判断规则,直到找到第一个可见的版本。

四、 一个简化的例子

假设:

  1. 事务T100(很老的事务)已提交。

  2. 事务T101启动,修改了数据,未提交。

  3. 事务T102启动,修改了数据并提交。

  4. 我(事务T103)启动,并执行SELECT(创建ReadView RV1)。
    ◦ 此时系统活跃事务为[T101, T103],所以:

    ◦ RV1.m_ids = [101, 103] (静态快照)

    ◦ min_trx_id = 101

    ◦ max_trx_id = 104

我查询T101修改的数据:
• 最新版本DB_TRX_ID=101。

• 101在[101, 104)内,且在m_ids中 -> 在RV1创建时未提交 -> 对我不可见。

• 我读到的是T100的版本。

• 结论:即使T101之后提交了,我也看不到。这就是“可重复读”。

我查询T102修改的数据:
• 最新版本DB_TRX_ID=102。

• 102在[101, 104)内,但不在m_ids中 -> 在RV1创建时已提交 -> 对我可见。

• 我读到了T102提交的数据。

五、 总结

通过这次学习,我终于把这几部分串起来了:

  1. Undo Log 提供了多版本数据的物理存储和能力(版本链)。
  2. ReadView 提供了一套可见性规则,其核心是通过一个静态事务快照(m_ids)来推断数据版本在快照创建时刻的提交状态。
  3. 创建时机:RR和RC的根本区别在于ReadView是事务开始时创建一次还是每次语句执行都创建。

弄懂这些之后,我对数据库如何在高并发下保证隔离性同时又提供高性能有了更深的理解。这比我之前单纯理解锁机制要深刻得多。