MySQL数据库 (十五) MySQL事务(下),MVCC机制,事务ID,Read view,undo日志

目录

[一、事务隔离级别 ------ 一致性的正确理解](#一、事务隔离级别 —— 一致性的正确理解)

一致性

总结:

二、MVCC机制

数据库的三种并发场景:

[MVCC 是什么?](#MVCC 是什么?)

事务ID

3个隐藏字段

undo日志

模拟MVCC

[Read View](#Read View)

[Read view 实验](#Read view 实验)

[三、RR vs RC 的本质区别](#三、RR vs RC 的本质区别)

四、总结


上篇文章我们已经验证了事务的持久性,原子性,以及隔离性,下面我们来讲解验证事务的一致性。

一、事务隔离级别 ------ 一致性的正确理解

一致性

一致性是事务 ACID 特性中最核心的目标,原子性、隔离性和持久性都是为了保证一致性而存在的。

首先,我们要明确一致性的定义。**事务执行的结果必须让数据库从一个一致性状态,转变到另一个一致性状态。也就是说,数据库的任何数据变化,都必须符合业务规则和完整性约束,不会出现逻辑上矛盾的状态。**比如转账业务中,A 账户扣钱、B 账户加钱,两者的总额必须保持不变,不能出现 A 扣了钱但 B 没收到,或者两者总额发生变化的情况,这就是一致性的体现。如果系统运行过程中发生中断,某个事务没有完成就被强制终止,而它对数据库的部分修改已经被写入,此时数据库就会处于不一致的状态,这是必须避免的。

一致性的实现,需要技术和业务逻辑两方面的支撑。MySQL 提供的技术支持,主要通过原子性、隔离性和持久性来保证。**原子性确保事务要么全部成功提交,要么全部失败回滚,不会留下中间状态的数据;隔离性确保并发事务之间不会互相干扰,避免脏读、不可重复读、幻读等问题导致的数据不一致;持久性确保提交后的修改永久生效,不会因为系统故障而丢失。这三个技术特性共同构成了一致性的底层保障。**但一致性的根本规则,还是由用户的业务逻辑决定的,数据库无法自动识别业务规则,比如转账业务的总额不变、订单状态的流转规则等,都需要用户在业务代码中实现,数据库只能提供技术层面的支持,无法替代业务逻辑的校验。

总结:

最后,我们来总结一下事务 ACID 特性之间的关系。一致性是事务的最终目标,原子性、隔离性和持久性都是为了实现一致性而存在的手段,三者共同围绕一致性展开。原子性保证事务的修改要么全做、要么全不做,避免中间状态;隔离性保证并发事务的执行互不干扰,避免互相破坏数据一致性;持久性保证提交后的修改不会丢失,确保一致性状态永久保留。了解了这些特性和隔离级别的知识,在 MySQL 事务的实际使用中就不会出现问题,这些也是面试中高频考察的内容,掌握它们能帮你更深入地理解事务的本质,也能在面试中展现出更扎实的功底。

二、MVCC机制

RC (读提交) 和 RR (可重复读) 这两个隔离级别,它们的隔离性是怎么实现的?两者的区别又是怎么实现的?

先明确 RC 和 RR 是什么。RC 就是读提交(Read Committed)隔离级别,特点是事务只能读到其他事务已经提交的数据,解决了脏读,但会出现不可重复读。RR 就是可重复读 (Repeatable Read)隔离级别,是 MySQL 的默认隔离级别,解决了脏读和不可重复读,MySQL 还通过额外的锁机制解决了幻读。我们接下来讲的 MVCC,就是 MySQL 实现 RC 和 RR 隔离性的核心机制之一。

数据库的三种并发场景:

首先我们要搞懂数据库里常见的三种并发场景,这是理解 MVCC 的基础。

第一种是读读场景,也就是多个事务同时执行 select 查询操作。这种场景不存在任何安全问题,也不需要任何并发控制,因为读操作不会修改数据,多个事务同时读同一份数据不会产生冲突。

第二种是读写场景,也就是一个事务在写数据 (update/insert/delete),另一个事务在读数据。这种场景会有线程安全问题,也是事务隔离性问题的主要来源,脏读、不可重复读、幻读这些问题,都是在读写并发的场景下出现的。我们之前讲的所有隔离级别问题,也都是围绕读写并发展开的。

第三种是写写场景,也就是多个事务同时修改同一份数据。这种场景会出现更新丢失问题,比如两个事务同时修改同一行数据,后提交的事务会覆盖先提交的事务的修改,导致更新丢失。MVCC 无法解决写写冲突,这种场景需要通过加锁来解决。

MVCC 是什么?

MVCC 的全称是多版本并发控制 ,它是一种专门用来解决读写冲突 的无锁并发控制机制。 它的核心原理是为事务分配一个单向增长的事务 ID,每次对数据的修改,都会保存一个版本,并且把版本和事务 ID 关联起来。当事务执行读操作时,只会读取该事务开始前数据库的快照版本,而不会直接读取正在被修改的数据。

这种机制带来了两个关键好处。第一,在并发读写数据时,读操作不会阻塞写操作,写操作也不会阻塞读操作,大幅提高了数据库的并发读写性能,避免了传统读写锁中 "读阻塞写、写阻塞读" 的性能瓶颈。第二,它可以解决脏读、不可重复读、幻读这些事务隔离问题,通过版本控制,让不同事务看到不同的数据快照,实现隔离性。但要注意,MVCC 无法解决写写冲突,这类场景还是需要通过加锁来实现。

现在我们就能把 RC、RR 和 MVCC 联系起来了。RC 和 RR 都是通过 MVCC 来实现隔离性的, 它们的核心区别在于事务读取数据快照的时机不同。 在 RC 隔离级别下,事务的每次读操作,都会读取当前最新的已提交数据快照。也就是说,事务内的两次相同查询,中间如果有其他事务提交了修改,第二次查询就会读到新的版本,这就导致了不可重复读现象。 而在 RR 隔离级别下,事务只会在第一次读操作时创建数据快照,之后的所有读操作,都只会读取这个初始快照的版本,不会再去读取其他事务提交的新数据。这样一来,同一个事务内的多次读取结果始终一致,就解决了不可重复读问题。这也是为什么 MySQL 默认的 RR 级别,在读写并发场景下,select和写操作不会冲突,因为读操作读的是快照版本,不会阻塞写操作,而写操作也不会阻塞读操作。

事务ID

什么是事务ID?

我们接着来讲 MVCC 里的核心概念 ------ 事务 ID,它是理解多版本并发控制的基础。

首先,我们来明确什么是事务 ID。每个事务在 MySQL 中被开启时,都会被分配一个唯一的、单向递增的事务 ID,这个 ID 是事务的身份标识,也是 MVCC 实现版本控制的核心依据。事务 ID 是严格递增的,所以它的大小直接反映了事务的先后顺序:**事务 ID 越小,代表事务开启得越早;事务 ID 越大,代表事务开启得越晚。**MySQL 正是通过比较事务 ID 的大小,来判断数据版本的可见性,从而实现不同隔离级别下的隔离效果。

接下来我们看事务 ID 的作用和背后的管理逻辑。MySQL 需要同时处理大量并发事务,每个事务都有自己完整的生命周期,从开启、执行到提交或回滚,都需要数据库进行管理。为了实现这种管理,MySQL 内部会为每个事务创建对应的结构体对象,里面包含了事务 ID、事务状态、隔离级别、关联的数据版本信息等关键数据。事务 ID 就是这个结构体中最核心的字段之一,它就像事务的 "身份证号",不仅能区分不同的事务,还能帮 MySQL 快速判断事务的先后顺序和数据版本的可见性,是 MVCC 实现的基础支撑。

最后,我们把事务 ID 和之前讲的 MVCC 联系起来。**在 MVCC 中,每一次数据修改都会生成一个新的版本,每个版本都会记录修改它的事务 ID。当一个事务读取数据时,MySQL 会根据当前事务的 ID,结合隔离级别规则,判断哪些版本的数据对它是可见的。**比如在可重复读隔离级别下,事务只会读取事务 ID 小于自身 ID 的已提交数据版本,这样就能保证事务在整个生命周期内看到的数据始终一致,避免了不可重复读问题。所以,事务 ID 不仅是事务的标识,更是 MVCC 实现版本控制和隔离性的关键。


我们先说3个隐藏字段

3个隐藏字段

首先是 DB_TRX_ID 字段,它是一个 6 字节的字段,用来记录最近修改或插入这条记录的事务 ID。每当有事务修改或插入这条数据时,都会把自己的事务 ID 写入这个字段。这个字段的核心作用,就是让 MySQL 知道这条数据的 "最新版本" 是由哪个事务修改的,在 MVCC 的可见性判断中,这个字段是关键依据,用来对比当前事务 ID,判断这条数据版本是否对当前事务可见。

接下来是 DB_ROLL_PTR 字段,它是一个 7 字节的回滚指针,指向这条记录的上一个版本。每当数据被修改时,旧版本的数据会被复制到 undo 日志中,而当前记录的 DB_ROLL_PTR 就会指向这个旧版本。通过这个指针,所有版本的数据会形成一个版本链,从最新版本一直链到最原始的版本。当事务需要读取历史版本的数据时,就可以通过这个指针回溯到对应的版本,实现多版本的访问,同时也为事务回滚提供了支持。

然后是DB_ROW_ID 字段,它是一个 6 字节的隐藏自增 ID,也叫隐藏主键。如果数据表没有显式定义主键,InnoDB 会自动用这个字段生成一个聚簇索引,用来组织数据。它的作用主要是为了保证表的存储结构正常,和 MVCC 的版本控制没有直接关系,但它是 InnoDB 存储引擎的基础字段之一。

除了这三个主要字段,还有一个补充的删除标记字段。当记录被更新或删除时,它并不会立刻被物理删除,而是修改这个删除标记字段的值,标记这条记录为 "已删除"。真正的删除操作会在后续的 purge 线程中执行,这样既保证了事务回滚时可以通过版本链恢复数据,也保证了其他事务还能读到旧版本的数据,是 MVCC 实现多版本控制的重要辅助机制。

下面我们用实验来理解这些隐藏字段在实际场景中的表现:

首先,我们创建了一张没有显式主键的 student 表,包含 name 和 age 两个字段。接着我们向表中插入一条数据,此时数据库的自动提交模式是开启的,也就是 autocommit 为 ON,所以这条 insert 语句本身就是一个独立的事务,执行完成后会自动提交。当这条数据被插入后,它的三个隐藏字段也被自动初始化了。


我们来看这条数据的隐藏字段状态。首先是 DB_ROW_ID,因为这张表没有显式主键,InnoDB 会自动为它分配一个自增的隐藏主键,这里第一条数据的 DB_ROW_ID 就是 1,后续插入的数据会依次递增。然后是 DB_TRX_ID,也就是创建这条记录的事务 ID,因为这条 insert 语句是一个独立的事务,它会被分配一个事务 ID,这里我们暂时看不到具体数值,所以先标记为 null,但它实际是存在的。最后是 DB_ROLL_PTR,也就是回滚指针,因为这是这条数据的第一个版本,还没有任何修改记录,所以它的回滚指针指向 null,代表没有上一个历史版本。
这个实验也验证了隐藏字段的初始化规则。 当一条新数据被插入时,DB_ROW_ID 会自动生成自增值,DB_TRX_ID 会被写入当前事务的 ID,DB_ROLL_PTR 则初始化为 null,因为它还没有历史版本。 只有当后续有事务修改这条数据时,DB_TRX_ID 才会更新为新的事务 ID,DB_ROLL_PTR 才会指向旧版本的数据,从而形成版本链。这些字段在表结构中是看不到的,但它们是 MVCC 实现多版本控制的基础,每一次数据修改都会更新这些字段,为事务提供不同版本的数据快照。

undo日志

我们接着来讲 undo 日志,先结合 MySQL 的运行机制,再讲它的核心作用。

首先,我们要先理解 MySQL 的运行方式。MySQL 是以服务进程的形式运行在内存中的,我们之前讲的索引、事务、隔离性、日志等所有机制,都是在 MySQL 内部的缓冲区中完成的。数据会先在内存中被处理、修改和判断,然后再在合适的时机,把修改后的数据刷新到磁盘中持久化保存。

undo 日志简单来说就是 MySQL 中一段用来保存数据修改前状态的内存缓冲区。每当我们修改一条数据时,MySQL 会先把这条数据修改前的旧版本保存到 undo 日志中。如果事务执行失败或者需要回滚,MySQL 就可以根据 undo 日志里的旧版本,把数据恢复到修改前的状态,以此保证事务的原子性。 并且undo 日志和我们之前讲的隐藏字段是紧密关联的。数据的 DB_ROLL_PTR 回滚指针,就是指向 undo 日志中保存的旧版本数据。通过这个指针,多个版本的数据会形成一条版本链,从最新版本一直链到最原始的版本。MVCC 机制就是通过这个版本链,让不同事务能读到自己可见的数据版本,从而实现读写不阻塞的并发控制。

简单来说,undo 日志是 MVCC 实现多版本的 "数据仓库",它保存了所有数据的历史版本,既支持事务回滚,也为并发读操作提供了不同版本的数据快照。

模拟MVCC

下面我们来模拟一下 MVCC,通过这个模拟 MVCC 的实验把版本链的形成过程讲清楚,让我们直观看到 undo 日志、隐藏字段和多版本数据是怎么配合工作的。

开始:

首先,我们从初始状态开始。student 表里有一条数据,name 是 "张三",age 是 28。它的隐藏字段里,DB_TRX_ID是创建这条记录的事务 ID,这里我们标记为 9;DB_ROW_ID是 1,也就是隐藏主键;DB_ROLL_PTR是 null,因为它还没有任何修改,没有历史版本。这是数据的第一个版本,也是版本链的起点。

接下来,事务 10 对这条数据进行修改,把 name 从 "张三" 改成 "李四":

整个修改过程是在加锁 的环境中进行的,事务 10 首先给这条记录加上锁,防止其他事务同时修改。修 改之前,MySQL 会先把当前这条记录的旧版本复制到 undo 日志中,这样 undo 日志里就有了 "张三" 这条数据的副本。然后,MySQL 修改原始记录,把 name 改成 "李四",同时更新它的隐藏字段:DB_TRX_ID改成当前事务的 ID 10,DB_ROLL_PTR指向 undo 日志中旧版本数据的地址,也就是 0xaa,这样就形成了一条从新版本指向旧版本的链。修改完成后,事务 10 提交,释放锁。此时,最新的记录是 "李四" 这条,它的回滚指针指向 undo 日志里的 "张三" 版本,版本链的长度从 1 变成了 2。

现在又有一个事务 11,对 student 表中记录进行修改 (update) : 将 age(28) 改为 age (38) :

然后事务 11 对这条数据进行第二次修改,把 age 从 28 改成 38。和事务 10 的流程一样,事务 11 首先给这条记录加锁,然后把当前的最新版本(也就是 name 为 "李四"、age 为 28 的记录)复制到 undo 日志中,采用头插法插入到 undo 日志里。接着,MySQL 修改原始记录,把 age 改成 38,同时更新隐藏字段:DB_TRX_ID改成当前事务的 ID 11,DB_ROLL_PTR指向 undo 日志中刚复制的旧版本地址 0xbb。事务 11 提交并释放锁后,最新的记录是 name 为 "李四"、age 为 38 的版本,它的回滚指针指向事务 10 修改后的版本,而事务 10 修改后的版本又指向最初的 "张三" 版本,这样就形成了一条包含三个版本的完整版本链。

通过这个过程,我们可以看到,**每次修改都会生成一个新的数据版本,旧版本被保存在 undo 日志中,通过 DB_ROLL_PTR 回滚指针串联起来,形成一个基于链表的历史版本链。所谓的事务回滚,就是把 undo 日志里保存的旧版本数据,覆盖回当前记录,恢复到修改前的状态。**而提交后的事务,undo 日志里的旧版本会被后续的 purge 线程清理掉,所以提交后就无法再回滚了。这些串联起来的版本,就是 MVCC 中不同事务可以读取的 "快照",每个事务会根据自己的 Read View,在版本链中找到自己可见的那个版本,从而实现读写不阻塞的并发控制。

思考:


那么,如何保证,不同的事务,看到不同的内容呢?也就是如何如何实现隔离级别?

上面这幅图我们提炼为一下几点:

1. 先讲 insert、update、delete

我们先把这三个操作和版本链的关系结合在一起讲 :

首先是 insert 操作。因为插入数据之前,表里没有这条记录,所以它一开始就没有历史版本。但为了支持事务回滚,MySQL 还是会把插入的数据保存到 undo 日志里,这样如果事务回滚,就能根据 undo 日志把这条新插入的数据删掉。当事务提交后,这条数据的 undo 日志记录就会被清理掉,所以 insert 本身不会形成长版本链,也不会产生多个历史版本。

然后是 update 和 delete 操作。这两个操作会直接修改已有的数据,所以会生成旧版本数据,这些旧版本会被保存到 undo 日志里,再通过 DB_ROLL_PTR 回滚指针串联起来,形成版本链。delete 操作也不是物理删除,而是把记录标记为删除,旧版本同样会被保留在版本链中,供回滚和快照读使用。所以真正能形成完整版本链的是 update 和 delete 操作,insert 只是为了回滚保存了初始记录,不会形成多版本链。

2. select 操作:当前读 vs 快照读

select 操作本身不会修改数据,所以它不会生成新的版本,它的关键问题在于:读的是最新版本,还是历史版本?这就引出了两种读方式。

第一种是当前读,也就是读取数据的最新版本。这种读操作需要加锁,比如 select ... lock in share mode (共享锁) 或者 select ... for update (排他锁),它会直接读取数据的当前最新状态,和写操作是互斥的,会阻塞其他写操作,也会被写操作阻塞,本质上就是串行化的读操作,性能比较低。

第二种是快照读,也就是读取数据的历史版本,也就是 MVCC 里的 "快照"。这种读操作不需要加锁,直接通过版本链读取事务可见的历史版本,不会阻塞写操作,也不会被写操作阻塞,能大幅提升并发性能,这就是 MVCC 的核心意义所在。

**那 select 什么时候用当前读,什么时候用快照读?是由隔离级别决定的。**隔离级别规定了事务之间能看到哪些数据修改,而当前读和快照读,就是实现这些隔离规则的两种方式。

3. 为什么要有隔离级别?

我们再把隔离级别的本质讲透。事务是原子性的,有明确的生命周期:从 begin 开始,执行增删改查,最后 commit 提交。多个事务并发执行时,它们的操作会交织在一起,有的事务先开始,有的后开始,有的已经提交,有的还在执行中。隔离级别要解决的核心问题,就是规定 "先开始的事务,能不能看到后开始的事务做的修改",以此保证每个事务看到的内容是它 "应该看到的内容",避免脏读、不可重复读、幻读这些问题。

而不同隔离级别下,select 采用的读方式不同,实现的效果也不同。**比如读提交和可重复读,都是通过快照读来实现隔离性,只是快照读的时机和规则不同;**串行化则是通过当前读加锁,强制事务串行执行,实现最高隔离级别。

Read View

讲完前面的铺垫,我们再来看 Read View,它是实现快照读和隔离级别的核心机制。

Read View 就是事务进行快照读时生成的 "读视图"。在事务执行快照读的那一刻,MySQL 会生成一个数据库当前状态的快照,这个快照里记录了系统中所有当前活跃的事务 ID,用来判断当前事务能看到版本链里的哪个数据版本。简单来说,Read View 就是事务用来判断 "这条数据我能不能看" 的判断条件,它会对比数据版本链中每个记录的 DB_TRX_ID,判断这个版本是否对当前事务可见。

下面是 ReadView 结构,但为了减少负担,我们简化一下:

我们来看简化后的 Read View 结构,核心的几个成员变量如下:

  1. 第一个是 m_low_limit_id,也叫高水位。它的值是 Read View 生成时,系统尚未分配的下一个事务 ID,也就是目前已出现过的事务 ID 的最大值 + 1。它的作用是:事务 ID 大于等于m_low_limit_id的事务,都不可见,因为这些事务是在 Read View 生成之后才开启的,当前事务看不到它们的修改。
  2. 第二个是 m_up_limit_id,也叫低水位。它的值是m_ids列表中事务 ID 的最小值。它的作用是:事务 ID 小于m_up_limit_id的事务,都可见,因为这些事务在 Read View 生成之前就已经提交了,当前事务可以看到它们的修改。
  3. 第三个是 m_creator_trx_id,也就是创建这个 Read View 的事务 ID。它记录了当前执行快照读的事务的 ID,用来判断数据版本是不是当前事务自己修改的。
  4. 第四个是 m_ids,也就是创建视图时的活跃事务 ID 列表。这个列表里保存了 Read View 生成那一刻,系统中所有还没提交的活跃事务的 ID。这些事务的修改,对当前事务来说是不可见的,因为它们还没提交,可能会回滚。

简单来说,Read View 就是通过这几个变量,给版本链里的每个数据版本做 "可见性判断",决定当前事务能看到哪个版本的数据,从而实现不同隔离级别下的快照读效果。
这里我们再梳理一下:

1. 版本链、快照、Read View 到底是什么

我们可以把数据库里的一行数据,想象成一本不断被修改的日记:

  • 每次修改(update/delete),都会把修改前的内容复制到后面的 "附页" 里,这些附页按修改时间串起来,就是版本链。它记录了这行数据从诞生到现在的所有历史版本。
  • 当我们要 "快照读" 的时候,不是直接去翻最新的日记内容,而是根据规则,从版本链里挑出一个 "对当前事务可见的版本",这个被挑出来的版本,就是我们读到的快照
  • Read View,就是我们用来挑版本的 "选书规则手册",它规定了哪些附页我们能看,哪些不能看。
    2. 为什么光有版本链还不够?必须要 Read View?

版本链里已经有所有历史版本了,但关键问题是:不同的事务,能看到的版本是不一样的

比如有两个并发事务:

  • 事务 A 8 点开启,事务 B 9 点开启。
  • 事务 A 在 8 点修改了日记,事务 B 在 9 点修改了日记。
  • 事务 C 10 点开启,要读这本日记。

版本链里有三个版本:原始版、A 修改版、B 修改版。

  • 如果隔离级别是可重复读,事务 C 的 Read View 会规定:只能看到 8 点前提交的修改,A 和 B 的修改都看不到,所以它只能读到原始版。
  • 如果隔离级别是读提交,事务 C 的 Read View 会规定:能看到 9 点前提交的修改,所以它能看到 A 修改版,看不到 B 修改版。

所以,同一条版本链,不同的 Read View 会选出不同的快照。Read View 的作用,就是给每个事务定制一套 "可见性规则",让它在版本链里找到属于自己的那个版本。
3. 再把 Read View 的工作流程讲直白点

当事务要执行快照读时,MySQL 会做三件事:

  1. 生成一个 Read View,里面记录了:当前所有活跃的事务 ID、事务 ID 的上下限、自己的事务 ID。
  2. 拿着这个 Read View,去遍历版本链里的每个版本。
  3. 对每个版本,用 Read View 里的规则判断:这个版本的事务 ID,我能不能看见?如果能,就直接返回这个版本作为快照;如果不能,就顺着版本链往前找,直到找到一个可见的版本为止。

举个例子,版本链里有三个版本:事务 9(原始版)→ 事务 10(修改版)→ 事务 11(最新版)。

  • 一个 Read View 里的活跃事务列表包含 10 和 11,说明这两个事务还没提交。
  • 它判断事务 11 的版本:事务 ID 在活跃列表里,不能看。
  • 再判断事务 10 的版本:事务 ID 也在活跃列表里,不能看。
  • 最后判断事务 9 的版本:事务 ID 小于低水位,能看,所以就把这个版本作为快照返回。
    4. 我们可以再用一个更通俗的类比帮你彻底理解

把版本链想象成一个 "朋友圈历史动态",每一条修改都是一条新动态,旧动态不会被删除,只是被压到了下面。

  • 现在我们要发一个新动态,这就是 "事务开启"。
  • 发动态的那一刻,我们设置 "谁能看这条动态" 的权限,这个权限列表,就是Read View
  • 权限列表里规定:哪些人能看到我们的动态,哪些人看不到。
  • 别人刷朋友圈(快照读)的时候,就拿着自己的权限列表(Read View),去我们的动态链里找,找到第一个自己有权限看的动态,刷出来的就是他看到的 "快照"。

不同的人(不同的事务),拿着不同的权限列表(不同的 Read View),刷到的朋友圈(读到的快照)是不一样的,哪怕你们刷的是同一个人的朋友圈(同一条版本链)。


那当前快照读,应不应该读到当前版本记录?

首先,我们先把这张图的结构对应起来。上面的部分,是我们之前讲过的版本链,最新版本是事务 11 修改的 "李四 38 岁",它的回滚指针指向事务 10 修改的 "李四 28 岁",再往前指向事务 9 创建的 "张三 28 岁"。下面的时间轴,就是 Read View 的判断规则,它会根据事务 ID 的大小和状态,划分出 "应该看到、正在操作、快照后新来" 三个区间,每个区间的可见性规则都不同。

我们先看时间轴上的三个区间和对应的规则。第一个区间是 "已经提交的事务",也就是事务 ID 小于 m_up_limit_id,或者等于当前事务 IDm_creator_trx_id。根据 Read View 的规则,这部分的修改是可见的,因为它们要么在 Read View 生成前就已经提交,要么就是当前事务自己做的修改,都应该被看到。第二个区间是 "正在操作的事务",也就是事务 ID 在m_up_limit_id 和 m_low_limit_id 之间,并且存在于活跃事务列表 m_ids 中。这些事务在 Read View 生成时还没提交,所以它们的修改是不可见的。第三个区间是 "快照后新来的事务",也就是事务 ID 大于等于 m_low_limit_id,这些事务是在 Read View 生成之后才开启的,所以它们的修改也不可见。
对应的源码策略:


接下来,我们看 MySQL 源码里的判断逻辑,它和时间轴上的规则是完全对应的。第一步,判断数据版本的 DB_TRX_ID 是否小于 m_up_limit_id,或者等于当前事务的 IDm_creator_trx_id。如果满足条件,直接返回 true,说明这个版本可见,可以直接读取。第二步,如果 DB_TRX_ID 大于等于 m_low_limit_id,直接返回 false,说明这个版本是快照后新来的事务修改的,不可见。第三步,如果 m_ids 列表为空,说明 Read View 生成时没有活跃事务,直接返回 true,所有小于 m_low_limit_id 的事务都已提交,都可见。第四步,用二分查找判断 DB_TRX_ID 是否在 m_ids 列表中,如果在列表中,说明它是活跃事务的修改,不可见;如果不在,说明它是已提交事务的修改,可见。

现在,我们把这个判断逻辑套用到我们的版本链上,走一遍完整流程。当事务进行快照读时,会生成 Read View,然后从最新版本开始判断。首先判断事务 11 的 DB_TRX_ID,如果它大于等于 m_low_limit_id,或者在 m_ids 活跃列表中,说明不可见,就顺着回滚指针找下一个版本,也就是事务 10 的版本。再判断事务 10 的 DB_TRX_ID,如果也在活跃列表中,继续往前找,直到找到事务 9 的版本。事务 9 的 DB_TRX_ID 小于 m_up_limit_id,符合条件,就返回这个版本作为快照读的结果。

最后,我们要纠正一个误区:Read View 不是事务一开始就生成的,而是在事务首次进行快照读操作的时候才会创建。在可重复读隔离级别下,一个事务只会创建一次 Read View,之后的所有快照读都会复用这个视图,所以整个事务内看到的快照是固定的,保证了可重复读;而在读提交隔离级别下,事务的每次快照读都会重新生成一个新的 Read View,所以每次都能看到最新的已提交数据,这就是两个隔离级别实现差异的核心原因。

Read view 实验

我们接着用这个完整的 Read View 实验,把判断逻辑从头到尾走一遍:

首先,我们先还原实验的初始状态和事务操作。一开始,student 表里只有一条数据:name 是 "张三",age 是 28,它的隐藏字段里DB_TRX_ID为 null,DB_ROW_ID是 1,DB_ROLL_PTR为 null。接着,四个事务按顺序开启:事务 1(id=1)、事务 2(id=2)、事务 3(id=3)、事务 4 (id=4)。事务 4 开启后,修改了这条数据,把 name 改成 "李四",并在事务 2 执行快照读之前就提交了事务。所以,事务 4 的修改已经生效,版本链的最新版本是 DB_TRX_ID=4 的 "李四 28",它的回滚指针指向 undo 日志里 DB_TRX_ID=null 的原始版本 "张三 28"。

接下来,事务 2 执行快照读,MySQL 会在这一刻为它生成 Read View。我们来看这个 Read View 的关键参数:m_ids 列表是 1,3,也就是 Read View 生成时,系统中活跃的事务 ID;up_limit_id是 1,也就是活跃事务列表中最小的事务 ID;low_limit_id是 5,也就是 Read View 生成时,系统尚未分配的下一个事务 ID,等于当前已出现的最大事务 ID+1;creator_trx_id是 2,也就是事务 2 自己的 ID。

现在,事务 2 拿着这个 Read View,开始从版本链的最新版本(DB_TRX_ID=4)开始判断可见性。第一步,判断DB_TRX_ID=4是否小于up_limit_id=1?显然不满足,所以进入下一个判断。第二步,判断DB_TRX_ID=4是否大于等于low_limit_id=5?4 小于 5,也不满足,进入下一个判断。第三步,判断DB_TRX_ID=4是否在活跃事务列表m_ids=1,3中?显然不在,说明事务 4 在 Read View 生成前就已经提交了,它的修改对事务 2 是可见的。

所以,事务 2 最终读到的,就是事务 4 提交后的最新版本 "李四 28",而不是 undo 日志里的原始版本。这也符合读提交隔离级别的效果,因为事务 2 在 Read View 生成时,能看到所有已经提交的事务的修改,包括事务 4 的修改。

我们再梳理一下这个实验的核心逻辑:Read View 的可见性判断,本质上就是通过对比DB_TRX_ID 和 Read View 的四个参数,来确定数据版本的可见性。在这个实验中,事务 4 的 ID 既不在活跃列表里,也没有超过low_limit_id,同时也大于up_limit_id,所以它的修改被判定为可见,事务 2 读到了最新的已提交数据。这也解释了为什么读提交隔离级别下,每次快照读都会生成新的 Read View,从而能看到最新的提交数据。

三、RR vs RC 的本质区别

RC (读提交) 和 RR (可重复读) 的本质区别,这也是 MVCC 机制最核心的差异点 : Read View 的生成时机不同,直接导致了两种隔离级别下快照读的结果不同

我们先从 RR (可重复读) 隔离级别开始讲,也就是 MySQL 的默认级别。在 RR 级别下,一个事务里,对某条记录的第一次快照读操作,才会创建一个 Read View。这个 Read View 一旦生成,就会记录下当前系统中所有活跃事务的状态,作为整个事务的 "基准快照"。之后,这个事务里的所有快照读,都会复用这同一个 Read View,再也不会生成新的。这就意味着,在这个 Read View 创建之后,其他事务提交的任何修改,都不会被当前事务看到。比如,事务 A 在第一次查询时生成了 Read View,此时事务 B 还没提交;之后事务 B 提交了修改,事务 A 再用同一个 Read View 去读,依然会认为事务 B 是活跃状态,它的修改不可见,只能读到自己创建 Read View 时的那个数据版本。这就保证了同一个事务内,多次读取同一数据的结果始终一致,解决了不可重复读问题。

接下来是 RC (读提交) 隔离级别。在 RC 级别下,规则完全不同:事务里的每一次快照读操作,都会重新生成一个新的 Read View。每次查询时,Read View 都会重新记录当前系统中所有活跃事务的状态。这就导致了,同一个事务内,两次查询之间如果有其他事务提交了修改,第二次查询生成的新 Read View,就会把已提交的事务从活跃列表中剔除,从而能看到它的修改结果。比如,事务 A 第一次查询时,事务 B 还没提交;事务 B 提交后,事务 A 第二次查询生成新的 Read View,就会发现事务 B 已经提交,它的修改对自己是可见的,于是就读到了最新数据。这就是为什么 RC 级别下会出现不可重复读的原因,因为每次快照读都在刷新自己的 "基准快照",数据视图不是固定的。

总结一下两者的核心差异,就是 Read View 的生成时机。RR 级别下,一个事务只生成一次 Read View,之后的所有快照读都复用它,所以数据视图是固定的,不会被其他事务的提交影响;而 RC 级别下,每次快照读都会生成新的 Read View,数据视图是动态更新的,所以能看到其他事务最新提交的数据,也就无法避免不可重复读问题。这也是为什么 RR 级别是 MySQL 的默认选择,它通过 MVCC 实现了无锁的一致性读取,在性能和数据一致性之间取得了完美平衡。

四、总结

本文深入讲解了MySQL事务一致性及MVCC机制。一致性是ACID的核心目标,通过原子性、隔离性和持久性共同保障。重点剖析了MVCC如何实现RC和RR隔离级别:通过事务ID、隐藏字段(DB_TRX_ID、DB_ROLL_PTR等)和undo日志构建版本链,结合ReadView机制实现读写并发控制。ReadView包含高低水位和活跃事务列表,通过对比事务ID判断数据版本的可见性:RR级别事务首次快照读创建ReadView并复用,保证可重复读;RC级别每次快照读新建ReadView,实现读提交。实验演示了版本链形成和ReadView判断过程,解释了两者隔离性差异的本质原因。MVCC通过无锁机制大幅提升并发性能,是MySQL实现高效事务处理的核心技术。

谢谢大家的观看!