侧边栏壁纸
博主头像
ProSayJ 博主等级

Talk is cheap. Show me the code.

  • 累计撰写 41 篇文章
  • 累计创建 16 个标签
  • 累计收到 0 条评论

目 录CONTENT

文章目录

30-MVCC

YangJian
2025-06-28 / 0 评论 / 0 点赞 / 17 阅读 / 0 字

MySQL 通过 MVCC(多版本并发控制)方案增强了 RR 级别的并发安全性, 最大限度的控制在 RR 事务隔离级别下的 幻读问题,但是没有解决“当前读场景下的幻读问题

InnoDB 使用 MVCC 实现了在 RR 隔离级别下快照读的可重复性,从而避免了幻读的发生。但在 当前读(如 UPDATE、DELETE、SELECT xx for update ) 场景下,幻读问题仍需通过加锁机制(如 Next-Key Lock)来解决。

事务隔离级别

解决脏读

解决脏写

解决不可重复读

解决幻读

读未提交(RU)

读已提交(RC)

✔️

可重复读(RR)默认

✔️

✔️

只是快照读,没有完全解决幻读问题

串行化

✔️

✔️

✔️

✔️

在 MySQL 中,默认的事务隔离级别是 REPEATABLE READ (RR),这意味着它可以避免大多数并发问题,包括脏读、不可重复读、幻读

MySQL中的RR级别与SQL标准中的RR级别有一些区别,最主要的区别是 MySQL 的 RR级别避免了幻读。在 SQL 标准中,RR级别仍然可能发生幻读但在 MySQL 中通过其 MVCC(多版本并发控制)机制,使用快照读的方式有效地避免了幻读问题,但是当前读并没有解决幻读问题哈.

1. 查看、 修改 MySQL 事务隔离级别:

你可以通过如下命令来查看 修改事务的隔离级别:

mysql> SELECT @@global.transaction_isolation, @@session.transaction_isolation; ## MySQL 8.0 查看 MySQL 当前的事务隔离级别配置
+--------------------------------+---------------------------------+
| @@global.transaction_isolation | @@session.transaction_isolation |
+--------------------------------+---------------------------------+
| REPEATABLE-READ                | REPEATABLE-READ                 |
+--------------------------------+---------------------------------+
1 row in set (0.00 sec)
 
mysql> SHOW VARIABLES LIKE 'transaction_isolation'; ## MySQL 8.0 查看 MySQL 的默认事务隔离级别配置(可能与全局隔离级别不同)
+-----------------------+-----------------+
| Variable_name         | Value           |
+-----------------------+-----------------+
| transaction_isolation | REPEATABLE-READ |
+-----------------------+-----------------+
1 row in set (0.01 sec)
 
mysql> 
SET [GLOBAL|SESSION] TRANSACTION ISOLATION LEVEL <level>;
其中 <level> 可以是以下几种:
1. REPEATABLE READ
2. READ COMMITTED
3. READ UNCOMMITTED
4. SERIALIZABLE


-- 设置当前会话事务隔离级别为 "READ COMMITTED"
SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

-- 设置当前会话事务隔离级别为 "READ COMMITTED"
SET GLOBAL TRANSACTION ISOLATION LEVEL READ COMMITTED;

通常来说,MySQL的默认 REPEATABLE READ 级别已经能提供很好的并发控制,如果没有特别需求,通常不需要修改这个设置。



2. MySQL的MVCC机制:

MVCC(多版本并发控制) 是 MySQL 用来处理并发事务问题的一种机制,能在不加锁的情况下确保事务的隔离性。通过使用多个版本的数据,MySQL 在 REPEATABLE READ 级别下,避免了幻读和不可重复读问题。通过为每个事务生成一个唯一的版本号,MySQL 能够确保每个事务看到的数据版本是一致的。

总的来说,MySQL的 REPEATABLE READ 隔离级别结合 MVCC 机制,是一个性能和安全性之间的平衡点。它能有效地避免很多并发问题,在大多数场景下可以满足需求,而不需要频繁修改事务隔离级别。

3. @Transactional 注解

Spring 的 @Transactional 注解 中,isolation 参数允许开发者指定事务的隔离级别。例如:

@Transactional(isolation = Isolation.READ_COMMITTED)
public void someTransactionalMethod() {
    // 业务逻辑
}

3.1. isolation 参数支持的值:

Isolation 级别

等价于 SQL 级别

特点

Isolation.DEFAULT

使用数据库默认级别(MySQL 默认是 REPEATABLE READ)

一般情况下不需要手动修改

Isolation.READ_UNCOMMITTED

READ UNCOMMITTED

允许脏读,通常没人会用

Isolation.READ_COMMITTED

READ COMMITTED

允许不可重复读,但不会发生脏读

Isolation.REPEATABLE_READ

REPEATABLE READ

避免脏读、不可重复读,并且 MySQL 还能“防止”幻读,不是避免

Isolation.SERIALIZABLE

SERIALIZABLE

事务串行化执行,性能很差,慎用

3.2. 🙋‍♂️🙋‍♂️🙋‍♂️ 什么时候可能需要 READ COMMITTED 级别?

  • 默认 REPEATABLE READ 级别下,同一个事务中多次查询 不会看到别的事务已经提交的数据,如果你 业务上必须要查询到最新的已提交数据,就可以考虑 使用 READ COMMITTED

例如:

银行账户余额查询(实时反映最新余额)

订单状态实时更新(希望查询到最新已提交的状态)

3.3. 🙋‍♂️🙋‍♂️🙋‍♂️ 为什么通常不建议修改事务隔离级别?

  1. MySQL 默认 REPEATABLE READ 已经能避免大多数问题(脏读、不可重复读、幻读)。

  2. 低隔离级别(如 READ UNCOMMITTED)可能会导致数据不一致。

  3. 高隔离级别(如 SERIALIZABLE)性能损耗大,通常不适用于高并发场景。


4. MVCC

MySQL 是如何做到 RR 级别避免幻读(注意,避免幻读,不是解决幻读)的?使用了 MVCC 多版本并发控制技术

这就涉及到 MVCC(多版本并发控制) 机制了!MySQL 通过多个版本的快照 来确保事务读取到的是 自己事务开始时的数据,从而避免 不可重复读和幻读,即使别的事务插入了新数据,也不会影响当前事务的查询结果。

接下来我们就详细聊聊 MySQL 的 MVCC 机制,彻底理解它是如何实现 升级版的 REPEATABLE READ 🚀 🚀 🚀 🚀

在正式理解 MVCC(多版本并发控制) 机制之前,先要搞清楚 Undo Log 版本链 是什么,因为 MVCC 是依赖 Undo Log 版本链来实现的,这部分内容是理解 MVCC 的前奏。

4.1. Undo Log 版本链

回顾一下 undo log 的内存块的数据结构:

简单来说,每条行数据都有两个隐藏字段:行数据隐藏字段

  1. trx_id(事务 ID):表示最近一次修改该数据的事务 ID。

  2. roll_pointer(回滚指针):指向该数据 被当前事务修改前 生成的 Undo Log 记录。


假设现在有一个事务 A(ID=50),它插入了一条数据。那么,这条数据的隐藏字段如下:

  • trx_id = 50(表示是事务 A 插入的)

  • roll_pointer 指向空的 Undo Log(因为这是一个新插入的数据,之前并不存在)。

接下来,我们将深入解析 Undo Log 版本链的作用,以及它如何支撑 MySQL 的 MVCC 机制。


假设此时有 事务 B(ID=58) 对这条数据进行了修改,将数据值从 值 A 变更为 值 B

在更新数据之前,MySQL 会先生成一条 Undo Log 记录,用于保存修改前的数据。然后,roll_pointer 会指向这条 Undo Log 记录,形成一个 版本链

此时,这条数据的隐藏字段变为:

  • trx_id = 58(表示最近修改该数据的事务 ID 是 58)

  • roll_pointer 指向 Undo Log(Undo Log 记录着事务 B 修改前的数据,即 值 A

这种 Undo Log 版本链 的机制,使得即使有多个事务并发执行,每个事务都能按照自己的可见性规则,读取符合自己视图的数据,从而实现 MVCC 机制的隔离效果。

接下来,我们将继续深入讲解 MVCC 是如何利用 Undo Log 版本链来确保数据的隔离性


事务 C(ID=69) 对这条数据再次进行修改,将 值 B 改为 值 C

在这个过程中,MySQL 会生成一条新的 Undo Log 记录,用于保存修改前的 值 B。此时,roll_pointer 会更新为指向这条新的 Undo Log,而原先的 Undo Log(存储 值 A)仍然存在,并形成一条 版本链

此时,这条数据的隐藏字段变为:

  • trx_id = 69(表示最近修改该数据的事务 ID 是 69)

  • roll_pointer 指向最新的 Undo Log(Undo Log 记录着事务 B 修改前的数据,即 值 B

而之前事务 B 生成的 Undo Log 仍然存在,它的 roll_pointer 依然指向更早的 Undo Log(即记录事务 A 插入的 值 A)。

现在,整个 Undo Log 版本链 变成了 值 C → 值 B → 值 A,这条链条串联起了所有版本的变更历史。

这种 版本链 机制,正是 MVCC 隔离事务的核心,它允许不同事务在不同的时间点读取符合自己可见性规则的数据,而不会受到其他事务未提交修改的影响。接下来,我们将深入探讨 MVCC 是如何利用这条版本链来确保事务的隔离性

4.2. 🙋‍♂️🙋‍♂️🙋‍♂️MySQL 中 的事务ID是严格递增的吗?事务ID是如何生成的,实际的事务ID长的什么样子?

在 MySQL 的 InnoDB 存储引擎 中,事务ID(Transaction ID) 是用于标识一个事务的唯一标识符。它在 多版本并发控制(MVCC) 中扮演了重要角色,用于决定哪些数据对于事务是可见的。

事务ID的生成方式

  1. 事务ID是严格递增的吗?

是的,事务ID 是严格递增的。每当一个新的事务被创建时,InnoDB 会生成一个 比上一个事务ID大1 的事务ID。这样,事务ID 的值会随着事务的创建按顺序递增。

  1. 事务ID如何生成?

事务ID 是由 InnoDB 的内部事务管理器 在创建新事务时生成的。

事务ID 是在 每个新的事务 启动时递增的整数值。具体来说,事务ID 是 全局唯一且递增的,它保证了不同事务的唯一性。

事务ID的生成方式类似于使用 全局递增计数器,每次启动新事务时,计数器自增生成下一个事务ID。这个事务ID 是事务的唯一标识。

  1. 事务ID的实际样子

事务ID 是一个 64位整数,通常是无符号整数(unsigned integer)。它的值会从 1 开始递增。

例如:

第一个事务ID: 1

第二个事务ID: 2

第三个事务ID: 3

如此类推…

  1. 事务ID 在 MVCC 中的作用

MVCC(多版本并发控制) 中,事务ID 用来标记数据的版本。

每个数据页(行)都会存储其 创建事务ID删除事务ID

当一个事务读取数据时,InnoDB 会根据 事务ID 来判断该数据是否对当前事务可见:

创建事务ID:标记该行数据是由哪个事务创建的。

删除事务ID:如果该行数据被删除,删除事务ID 会标记删除操作的事务ID。

基于这些信息,InnoDB 确定在执行查询时,哪些数据是对当前事务可见的,哪些是不可见的。

事务ID的生命周期

  1. 事务开始时

每个事务的创建都会分配一个递增的事务ID。当事务开始时,它会获取一个全新的事务ID,随后该事务ID 会在事务的生命周期内使用。

  1. 事务提交时

事务提交时,InnoDB 会将事务ID 用于对数据进行版本标记。具体来说,会在相关的 数据页 中记录该事务ID。

  1. 事务提交后的数据可见性

其他事务的 ReadView 会根据事务ID 判断数据的可见性。如果事务ID 在当前事务的 ReadView 范围内,那么这个事务的数据会被认为是可见的。

  1. 事务回滚时

如果事务回滚,事务ID 不会被重用,回滚的事务ID 仍然会被记录在系统中,用于管理数据的版本和历史。

两个线程并发,一个执行读操作,一个执行写操作时

  • 读取操作(如 SELECT)不一定会直接生成事务ID,只有当这个读取操作在一个事务上下文中执行时,才会生成事务ID

  • 写入操作(如 INSERT, UPDATE, DELETE)也不会立即生成事务ID,只有当写操作被包含在一个事务中时,这个事务才会生成事务ID。

总结

事务ID是递增的,且每个新事务的事务ID 会比上一个事务ID 增加1。

事务ID的实际样子64位无符号整数

• 每个事务在开始时分配一个递增的事务ID,该ID用于 标记数据的版本,并决定哪些数据对当前事务可见。

事务ID在MVCC中用于数据的版本控制,确保不同事务对数据的修改不会相互干扰,且保证事务隔离性。

4.3. 🙋‍♂️🙋‍♂️🙋‍♂️事务管理器维护的单调递增的序列是全局唯一的,不会溢出吗?mysql是如何解决事务ID 溢出的问题的?

在 MySQL 中,事务ID(Transaction ID) 是一个全局唯一且单调递增的整数,它是用来标识每个事务的。

事务ID 的基本特性

  • 事务ID 是全局唯一的:每一个新创建的事务都会获取一个递增的事务ID,这个 ID 会在整个 MySQL 实例中保持唯一性。

  • 事务ID 是单调递增的:每一个新的事务ID 都比前一个事务ID 大1。它们是递增的整数,用来标记事务的创建顺序。

  • 事务ID 长度:事务ID 是一个 64 位无符号整数,即 unsigned BIGINT,它的值范围从 0 到 18,446,744,073,709,551,615。



事务ID 的溢出问题

事务ID 是单调递增的,因此有一个上限,即 64 位无符号整数的最大值,这个最大值是 18,446,744,073,709,551,615(大约 18.4 亿亿)。那么,问题就来了:如果系统持续运行很长时间,或者在高并发的情况下,事务ID 会一直递增,最终会达到这个最大值。达到最大值时,事务ID 会 溢出,即从头重新开始。

MySQL 如何解决事务ID 溢出的问题

MySQL 采用了一种非常巧妙的方式来解决事务ID 溢出的问题,它并不会直接在事务ID 到达最大值时让系统崩溃或者无法使用。具体来说:

  1. 事务ID 的重用机制

MySQL 会通过 事务ID 的环形重用(circular reuse)机制来处理事务ID 的溢出。当事务ID 达到其最大值时,系统会重用最小的事务ID(即从 1 开始)。但是,在重用事务ID 之前,系统会进行一些检查,确保重用的事务ID 不会造成数据一致性问题。

  1. 事务ID 的最小有效事务ID(Min-Transaction ID)

MySQL 会维护一个 最小有效事务ID,即系统当前不再使用的最小事务ID。MySQL 在重用事务ID 时,会确保只有在最小有效事务ID 之后,才会重新使用这些事务ID。这可以避免重用事务ID 时与旧事务产生冲突。

换句话说,MySQL 不会立即重用所有已使用过的事务ID,它会根据事务的 提交时间 来判断事务ID 是否可以重用。只有在这些事务ID 所标记的数据已经不再活跃或者已经被回收时,才会允许重用。

  1. InnoDB 使用 trx_id

InnoDB 存储引擎在管理事务时使用的 事务ID (trx_id) 是全局递增的。即使事务ID 达到最大值,它仍然会继续递增,但是在重用时会通过垃圾回收机制(比如回滚的事务、已提交的事务等)保证数据的一致性和正确性。

事务ID 的溢出机制基于一个循环的方式,但是需要 确保旧事务不会影响新事务的正确性。具体来说,InnoDB 会在事务ID溢出时做以下处理:

  • 确保事务ID 和数据行的创建/删除时间之间的一致性。

  • 通过 日志和回滚信息 确保已提交的数据不会受到影响。

如何避免事务ID 溢出对性能的影响

虽然事务ID 的上限非常大(64位无符号整数),但在极端的高并发环境下,事务ID 溢出的风险依然存在。为了避免性能下降和事务ID 溢出带来的问题,MySQL 采取了以下措施:

  1. 事务管理器优化

事务ID 的生成非常高效,通过简单的递增操作和内存中的计数器来生成。事务管理器尽量减少锁的使用,保证高并发下的事务ID 生成不会造成瓶颈。

  1. 清理过期事务ID

在事务提交和回滚后,InnoDB 会清理旧的事务ID,释放资源,确保重用旧事务ID 时不会影响数据的正确性。

  1. 高并发控制

在高并发的环境下,MySQL 会通过优化锁策略、减少不必要的等待等方式来确保事务ID 的生成不会成为性能瓶颈。

总结

  • 事务ID 是递增的 64 位无符号整数,并且是 全局唯一 的。

  • 事务ID 溢出是有可能发生的,但 MySQL 通过 事务ID 的重用机制最小有效事务ID 的管理 来确保溢出不会导致数据不一致。

  • 事务ID 的溢出会在事务ID 达到最大值后,重用最小的事务ID,但会通过检查已提交事务和活跃事务来确保不会影响数据一致性。

总的来说,MySQL 在设计事务ID 的生成和溢出机制时考虑到了并发控制和数据一致性问题,因此不会直接因为事务ID 溢出而导致数据库错误。

4.4. ReadView机制

接着我们之前讨论的undo log多版本链条,继续聊一下基于这个机制的 ReadView。通过理解这个ReadView的工作原理,接下来讨论RC和RR隔离级别下的MVCC多版本并发控制机制就会变得更加清晰。

4.4.1. 核心结构

ReadView本质是一个事务快照,记录了事务启动时数据库的活跃状态

简单来说,ReadView机制是在执行事务时为每个事务创建的,它包含了四个核心元素:

  1. m_ids:记录当前在MySQL中执行但尚未提交的事务ID列表, 即:生成ReadView时系统中所有活跃(未提交)事务的ID列表

  2. min_trx_id:m_ids中最小的事务ID,用来表示最早开始的未提交事务。

  3. max_trx_id:下一个即将生成的事务ID,它代表事务ID的上限。

  4. creator_trx_id:当前事务的事务ID,用来标识当前事务。

属性名

含义

m_ids

当前活跃(未提交)的事务ID列表。ReadView生成时,所有正在运行的事务的集合。

min_trx_id

m_ids中的最小事务ID。若某个事务ID小于此值,说明该事务在ReadView生成前已提交。

max_trx_id

下一个将被分配的事务ID(即当前最大事务ID +1)。所有大于等于此值的事务ID均属于“未来事务”。

creator_trx_id

创建该ReadView的事务自身的事务ID。若事务自身修改了数据,需特殊处理可见性。

4.4.2. 数据可见性判断规则

可见性判断顺序为:自身事务 → 最小事务 ID → 最大事务 ID → 活跃事务列表

ReadView(示例):
{
m_ids = {45,59} //快照未提交事务列表
min_trx_id = 45; // 事务最小值
max_trx_id = 60; //下一个将要生成的事务ID
creator_trx_id = 45;//当前 ReadView 的创建者
}
  1. 检查是否由当前事务自身修改(creator_trx_id

  • 若版本链中记录的 trx_id == creator_trx_id(当前事务 ID),说明该版本由当前事务自身生成,直接可见 [35]。

  1. 判断事务 ID 是否小于 min_trx_id

  • trx_id < min_trx_id,说明生成该版本的事务在 ReadView 创建时已提交,因此该版本对当前事务可见 [13]。

  1. 判断事务 ID 是否大于等于 max_trx_id

  • trx_id >= max_trx_id,说明生成该版本(trx_id)的事务在 ReadView 创建时尚未启动,因此该版本不可见 [36]。

creator_trx_id 就是当前事务的 trx_id的前提下:

这种情况不存在哈,在 InnoDB 的 MVCC 机制中,生成 ReadView 的那一刻,creator_trx_id(即当前事务的 trx_id)永远是 < max_trx_id 的。

  1. 检查事务 ID 是否在活跃事务列表 m_ids

  • trx_idm_ids 列表中(如示例中的 45、59),说明生成该版本的事务在 ReadView 创建时仍活跃未提交,该版本不可见 [13]。

  • trx_id 不在 m_ids 中,说明该事务在 ReadView 创建时已提交,版本可见 [36]。

4.4.3. 👍👍🙋‍♂️🙋‍♂️🙋‍♂️不可见的情况下,读取的是哪里的数据呢?

在 MySQL 的 MVCC(多版本并发控制)机制中,当当前数据页中的最新版本对事务不可见时,会通过 Undo Log 版本链回溯历史版本,寻找符合可见性规则的数据。具体读取逻辑如下:


数据读取的层次与规则

  1. 优先读取 Buffer Pool 中的最新版本

  • 事务首先会访问 Buffer Pool 中的数据页,获取该行记录的最新版本(即当前已提交或未提交的最新修改)。

  • 若该版本的 trx_id 符合 ReadView 可见性规则(如事务已提交且不在活跃列表),则直接返回此版本 [9]。

  1. 若最新版本不可见,则遍历 Undo Log 版本链

  • 若最新版本的 trx_id 不可见(如属于活跃事务或未提交事务),则通过行记录中的 roll_pointer 指针,依次遍历 Undo Log 中的历史版本。

  • 对每个历史版本的 trx_id 依次进行 ReadView 可见性判断,直到找到第一个可见的版本为止 [39]。

  • 直到找到第一个可见的版本”是指从最新版本开始,沿 Undo Log 版本链按时间倒序依次检查每个历史版本的可见性,一旦发现某个版本符合 ReadView 的可见性规则,则立即停止遍历并返回该版本数据。以下是具体逻辑的解析:

“第一个可见的版本”的判断逻辑

1️⃣ 从最新版本开始遍历

  • 优先访问 Buffer Pool 中的最新数据(即事务 B 写入的 50,trx_id=59)。

  • 若最新版本不可见,则通过 DB_ROLL_PTR 依次访问 undo log 中的历史版本 [24]。

2️⃣ 逐层应用 ReadView 规则

对每个版本的 trx_id 进行判断:

  • (1) 本事务修改的版本
    trx_id = creator_trx_id(如事务 A 的 ID 45):直接可见2

  • (2) 已提交的旧事务版本
    trx_id < min_trx_id(如原始值 trx_id=32 < 45):可见24

  • (3) 未提交或活跃事务的版本
    trx_id ∈ m_ids(如事务 B 的 trx_id=59m_ids=[45,59] 中):不可见2

  • (4) 未来事务的版本
    trx_id ≥ max_trx_id(如 max_trx_id=60):不可见2

  • (5) 介于 min 和 max 之间但不在 m_ids 中的版本
    trx_id ∈ (min_trx_id, max_trx_id) 且不在 m_ids 中:可见(表示该事务已提交)2

3️⃣ 终止条件

  • 找到第一个满足可见性条件的版本时停止。对每个版本的 对每个版本的 trx_id 依次应用 ReadView 的可见性规则(顺序:自身事务 → min_trx_idmax_trx_idm_ids)。只要有一个版本满足可见性条件,立即终止遍历,无需继续检查更早的版本。只要有一个版本满足可见性条件,立即终止遍历,无需继续检查更早的版本25

  • 若遍历完所有版本仍不可见,则返回空值4


示例说明

假设版本链为:

当前版本(trx_id=59) → 上一版本(trx_id=45) → 初始版本(trx_id=30)

事务的 ReadView 为 {m_ids={45,59}, min_trx_id=45, max_trx_id=60, creator_trx_id=45}

(1) 检查当前版本(trx_id=59)

  • trx_id=59m_ids 中 → 不可见,继续回溯。

(2) 已提交的旧事务版本(trx_id=45)

  • trx_id=45 等于 creator_trx_id可见(当前事务自身修改),终止遍历,返回此版本数据14


关键特性

(1) 效率优化

  • 由于版本链从新到旧遍历,找到第一个可见版本后即可停止,避免全链扫描,减少性能消耗15

(2) 数据一致性

  • 即使后续存在更旧但可见的版本(如 trx_id=30),事务也只会返回第一个满足条件的版本(如 trx_id=45),确保读取的是“最新已提交”的数据24

(3) 终止条件例外

  • 若所有版本均不可见(例如所有 trx_id 均大于等于 max_trx_id 或属于活跃事务),则返回空值,表示该行对当前事务不可见45


总结

“第一个可见的版本”是 MVCC 实现高效多版本读取的核心机制,通过倒序遍历版本链并优先返回最新可见版本,既保证了事务隔离性,又避免了不必要的性能开销。

  1. 版本链的终止条件

  • 若遍历完所有历史版本仍未找到可见的数据,则返回空值(表示该行对当前事务不可见)。


关键结论

  • Buffer Pool 与 Undo Log 的协作:Buffer Pool 存储最新数据,但不可见时会通过 Undo Log 版本链读取历史版本 [39]。

  • 可见性判断顺序:最新版本 → 历史版本,直到找到第一个符合 ReadView 规则的版本。

  • 性能优化:Undo Log 中的历史版本通过指针高效链接,避免全量遍历 [3]。


补充说明

  • Undo Log 的作用:不仅用于事务回滚,还支撑 MVCC 的多版本读取 [3]。

  • 隔离级别的影响:在 REPEATABLE READ 隔离级别下,事务首次读取时会生成快照(ReadView),后续读取均基于此快照判断版本可见性,从而避免不可重复读和幻读 [9]。

为了帮助理解,我们来看一个例子:

假设数据库里有一行数据,这条数据早些时候就被事务32插入了,且事务ID为32,初始值为 100。我们以这个数据为例,接下来的讲解会基于这个假设进行。

接下来,两个事务同时执行了。

  • 事务A(id=45)和事务B(id=59)并发发生。

  • 事务B 打算更新这行数据,而事务A则需要读取这行数据的值。

此时,两个事务的状态如下图所示。

当事务A开始时,它会生成一个ReadView。这个ReadView包含了事务A和事务B的两个事务ID(分别是45和59)。其中,min_trx_id为45,max_trx_id为60,creator_trx_id则是45,代表事务A本身。

{
    m_ids = {45,59} //快照未提交事务列表
    min_trx_id = 45; // 事务最小值
    max_trx_id = 60; //下一个将要生成的事务ID
    creator_trx_id = 45;//当前 ReadView 的创建者
}

此时,事务A第一次查询这行数据时,会检查该行数据的trx_id是否小于ReadView中的 min_trx_id [4.4.2数据可见性判断规则]。在此情况下,数据的trx_id为32,显然小于ReadView中的min_trx_id(即45)。这意味着,在事务A开始之前,修改这行数据的事务(事务ID为32)已经提交了,因此事务A可以正常读取到这行数据,如下图所示。

随后,事务B对这行数据进行了更新,将其值更改为 50。此时,这行数据的txr_id被设置为事务B的ID(即59),并且roll_pointer指向了更新前的undo log。完成修改后,事务B提交了变更,如下所示。

此时,当事务A再次执行查询时,它会发现数据行的txr_id是59。这个 txr_id 大于ReadView中的min_trx_id(45)且小于max_trx_id(60)。这表明,更新这条数据的事务与事务A几乎在同一时间并发执行。接下来,事务A会查看该txr_id是否出现在ReadView的m_ids列表中。

果然,m_ids中包含了事务A和事务B的ID(45和59),这证实了更新数据的事务确实是在事务A启动时并发执行并且已提交。因此,事务A不能读取这行数据,既然这行数据不能查询,事务A该查什么呢?

其实很简单,事务A可以顺着这条数据的roll_pointer沿着 undo log日志链条往下查找。最终会找到最近的一个undo log记录,它的 trx_id是32。此时,发现trx_id=32小于ReadView中的min_trx_id(45),这意味着这个undo log版本是在事务A开启之前就已经执行并提交了。

因此,事务A可以查询这个undo log中记录的值。这就是undo log多版本链条的作用——它可以保存数据的快照链条,使得事务A能够读取到事务开始时的快照值。

是不是觉得这一切都很神奇?在多个事务并发执行的情况下,事务B更新的值通过ReadView+undo log日志链条机制,确保事务A不会读取到并发事务B修改的值,而是始终读取到事务A开始前的最早值。

接下来,假设事务A对这行数据进行了更新,将其值修改为“150”,同时把trx_id更新为45,并保存事务B修改的值的快照 50。如下图所示。

当事务A查询这条数据时,它会发现数据行的trx_id=45与自己在ReadView中的creator_trx_id(也为45)相同。这意味着,这行数据是事务A自己修改的,既然是自己修改的值,当然可以读取到它!如下图所示。

接下来,事务A执行过程中,事务C(事务ID: 78)被开启,并且它更新了这行数据的值为值C。事务C完成了更新操作并提交了。如下图所示。

当事务A再次查询时,会发现数据的trx_id是78,这个值大于自己ReadView中的max_trx_id(60)。这意味着什么呢?

这表明,在事务A开始之后,另一个事务(即事务C)已经更新了这条数据,并且提交了。由于事务A无法看到自己事务启动后其他事务所做的修改,因此它不能读取到事务C所做的更改。如下图所示。

此时,事务A会沿着undo log的多版本链条向下查找,首先会找到自己之前修改的那个版本。由于这个版本的trx_id是45,恰好与事务A的ReadView中的creator_trx_id相同,因此事务A会直接读取到自己修改过的那个版本(150)。如下图所示。

通过一系列的图示,是否已经彻底理解了ReadView的工作机制呢🤔🤔🤔🤔?

在MySQL的多版本并发控制(MVCC)机制中,结合了undo log的多版本链条和每个事务开启时生成的ReadView,查询时会根据ReadView来判断应该读取哪个数据版本。通过这种机制,可以确保事务只会看到以下两种数据:

  1. 事务开启前已提交的其他事务的数据;

  2. 自己事务修改后的数据。

例如,假如有其他事务在你的事务开启前就已经开始,并在你的事务开启后提交了更新,你是无法读取到这些更新的;同样,如果你的事务开启后有其他事务进行更新,你也无法看到这些更新。

这就是MySQL如何实现数据隔离的关键,能够确保并发事务的操作互不干扰。下面,我们将深入讨论 ReadView在 RC 和 RR 隔离级别下的应用,进一步加深理解。

在 MySQL 的 InnoDB 存储引擎中,ReadView 是事务隔离级别为 可重复读(Repeatable Read) 时,确保 多版本并发控制(MVCC) 的一个关键机制。它帮助事务在读取数据时能够隔离其他事务的未提交修改,保证事务内部的一致性。



5. 🙋‍♂️🙋‍♂️🙋‍♂️ReadView 生成的时机

是的,在 多个事务并发执行 的情况下,每个事务都会生成一个 自己的 ReadView。每个事务生成的 ReadView 用来保证该事务读取到的数据是 一致的快照,并且这个快照对于其他事务的修改是不可见的,直到事务提交为止。具体来说,ReadView 的生成时机是在以下几种情况下:

  1. 事务开始时

  • 每个事务在开始执行时,InnoDB 会为该事务生成一个新的 ReadView。这些 ReadView 会各自保持独立。

  • 这个 ReadView 用来保证该事务在整个生命周期内读取的数据视图一致,该 ReadView 包含了当前 系统快照 的信息,也就是说,在该事务内部执行的所有查询会看到相同的快照,即使其他事务在执行期间对数据进行了更新。具体包括当前事务的 ID、可见的事务 ID 列表以及哪些事务对当前事务是 可见 的。

  1. SELECT 语句执行时(特别是在 可重复读 隔离级别下):

  • 当事务执行 SELECT 语句时,InnoDB 会根据事务的 ReadView 以及 当前的数据版本 来决定哪些行对该事务是可见的。

  • 通过 ReadView,事务只会看到其他 已提交事务 对数据的修改,而不会看到其他 未提交事务 的数据更改。

ReadView 生成过程的关键细节:

  • ReadView 中包含的信息:ReadView 记录了当前事务视图中的 可见事务不可见事务。具体来说:

  • min_trx_id:当前事务的最小事务 ID。

  • max_trx_id:当前事务的最大事务 ID。

  • trx_ids:表示哪些事务的修改对当前事务是可见的。也就是说,只有 已提交事务 的修改对当前事务可见。

  • uncommitted:当前事务执行期间,其他事务的未提交修改是不可见的,只有 提交事务 的修改是可见的。

  • 事务视图的作用:在 可重复读 隔离级别下,生成的 ReadView 保证了即使其他事务提交了数据更新,当前事务也只会读取到事务开始时的快照数据,而不会看到其他事务的变更。这就防止了 不可重复读脏读 问题。

如何运作:

  1. 生成事务的 ReadView

  • 当事务执行查询操作时,InnoDB 会根据 ReadView 来过滤出在该事务开始时已经提交的行版本。ReadView 会帮助确定哪些数据版本可以被当前事务读取,哪些是不可见的。

  1. 事务执行期间的操作

  • 如果某个事务执行 UPDATE 或 DELETE 等修改操作,InnoDB 会将相应的行版本标记为 未提交,这些行在其他事务中是不可见的,直到提交。

  • 只有在当前事务视图中 可见的行 会被返回或更新。

总结:

ReadView 是在 事务开始时生成 的,并且它在事务执行期间 保持不变。它的作用是 定义该事务的可见数据快照,确保 可重复读 隔离级别下,事务能够始终读取到相同的数据,即使其他事务提交了更新。ReadView 是 MVCC(多版本并发控制)机制的一部分,确保了事务隔离性,并防止脏读和不可重复读。

6. 🙋‍♂️🙋‍♂️🙋‍♂️ Read Committed隔离级别是如何基于ReadView机制实现的?

我们来探讨一下,基于之前讨论的ReadView机制,如何实现Read Committed隔离级别。首先,简要回顾一下Read Committed隔离级别,我们也可以称它为RC隔离级别。

RC隔离级别的意思是,当一个事务正在执行时,如果其他事务已经提交了对数据的修改,那么该事务能够读取到其他事务提交的数据。因此,在RC级别下,可能会发生不可重复读的问题,甚至幻读的现象也可能发生。

而ReadView机制,正是建立在undo log版本链条上的一种读视图机制。简单来说,事务会生成一个ReadView,如果是当前事务更新的数据,那么它能够读取这些数据;或者是生成ReadView之前,已经提交的事务所做的修改,也能被当前事务读取。

但如果在生成ReadView时,已有其他事务活跃并且修改了数据,且该事务在当前事务生成ReadView后提交了,那么当前事务是无法读取这些数据的。同样,当前事务生成ReadView之后,如果有新的事务修改并提交数据,当前事务也无法读取这些更新。

我们刚才提到的这一系列原理,正是ReadView机制的运作方式。接下来,我们就来看看,如何通过ReadView机制实现RC隔离级别。

关键的点在于,当事务处于RC隔离级别时,每次进行查询时,它都会重新生成一个新的ReadView。

这一点非常关键。接下来,我们将通过图示,详细展示RC隔离级别是如何通过这一机制实现的。

首先,假设数据库里有一条数据,是事务ID为50的事务在之前插入的,而现在有两个活跃的事务,一个是事务A(ID为60),另一个是事务B(ID为70)。如下图所示。

目前的情况是,事务B发起了一次更新操作,修改了该条数据的值为“值B”。因此,数据行的trx_id将更新为事务B的ID(即70)。同时,会生成一条undo log,用于记录数据更新之前的状态,这条undo log会通过roll_pointer字段指向。下图展示了这一过程:

此时,事务A发起查询操作时,会生成一个新的ReadView。这个ReadView的具体内容如下:

  • min_trx_id 为 60(表示事务A自己开启的事务ID)

  • max_trx_id 为 71(表示下一个事务的ID,或者是事务ID的最大值)

  • creator_trx_id 为 60(即事务A的ID)

因此,事务A的ReadView就像上图所示。

在事务A发起查询时,它会发现当前数据的 trx_id 为 70,恰好位于事务A的 ReadView 的活跃事务范围内(min_trx_id=60 和 max_trx_id=71)。这意味着,事务B在事务A生成 ReadView 之前就修改了这条数据,但事务B尚未提交。因此,在 ReadView 的活跃事务列表中,包含了事务A(ID=60)和事务B(ID=70)两个事务ID。

根据 ReadView 机制,事务A无法读取到事务B修改的数据,因为事务B的修改尚未提交。此时,事务A会沿着 undo log 版本链条继续查找,最终找到原始值,该值的 trx_id 是 50。因为 trx_id=50 小于 ReadView 中的 min_trx_id=60,说明这个数据是在事务A生成 ReadView 之前已经被事务50插入并提交了。因此,事务A能够查询到该原始值,如下图所示。

现在假设事务B已经提交了。提交后,事务B不再是活跃事务,因此事务A下次发起查询时,可以读取到事务B修改过的值。

这时,事务A要读取事务B提交后的数据,只需要在下一次查询时重新生成一个 ReadView。生成新的 ReadView 时,事务A会发现数据库里只剩下它自己作为活跃事务,事务B的 id=70 不会出现在 ReadView 的活跃事务列表 (m_ids) 中了。因此,min_trx_id 依然是60,max_trx_id 是71,但活跃事务列表 m_ids 里只包含事务A(ID=60)。

由于事务B已经提交,事务A的 ReadView 会允许它读取到事务B修改后的数据。因此,事务A能够成功查询到事务B修改的值,如下图所示。

此时,事务A基于新生成的 ReadView 进行查询。虽然这条数据的 trx_id=70 仍然处于 ReadView 的 min_trx_id 和 max_trx_id 范围内,但由于事务B已经提交并且不再是活跃事务,它的事务ID不再出现在 m_ids 活跃事务列表中。

因此,在事务A生成本次 ReadView 时,事务B已经不再是活跃事务,这意味着事务A可以读取到事务B提交后的数据。所以,事务A查询时就会成功读取到事务B修改后的值B。

至此,RC隔离级别的实现原理应该已经很清晰了。其关键点在于每次查询时都会生成一个新的 ReadView,这使得如果在事务A查询之前,其他事务已经提交了数据修改,ReadView 中的 m_ids 列表就不会包含这些已经提交的事务。由于 m_ids 不包含已经提交的事务,事务A在查询时就能够读取到这些已提交事务修改的值。

这个机制的核心就在于基于 ReadView 实现了对不同事务提交与并发执行的数据的可见性控制。换句话说,ReadView 使得事务能够知道它可以读取哪些数据,而哪些数据是不可见的。

从本质上来说,RC和RR隔离级别是通过 undo log 的多版本链条以及 ReadView 机制来实现的,这构成了数据库的MVCC(多版本并发控制)机制。该机制的作用是协调并发执行的多个事务之间的读写操作,确保它们在修改同一数据时,能正确地控制各个事务之间的可见性,避免冲突,确保事务的隔离性。

这套机制非常关键,它帮助数据库在高并发环境下,能够高效且一致地处理多事务并发执行时的复杂性。

7. 🙋‍♂️🙋‍♂️🙋‍♂️ REPEATABLE READ隔离级别是如何基于ReadView机制实现的?

避免不可重复读和幻读(没有彻底解决幻读)

我们继续探讨一下 MySQL 中最强大的 RR(可重复读) 隔离级别,它是如何避免 不可重复读幻读 的问题的。

大家可能已经知道,MySQL 是通过 undo log 版本链条ReadView 机制 来实现多个事务并发时的隔离性,确保它们在操作同一数据时互不干扰。

之前我们探讨,基于 ReadView 机制实现的 RC(读已提交) 隔离级别,每次查询都会生成一个新的 ReadView。这样,若在当前查询之前有其他事务提交了数据,你就能看到这些已提交的数据。

RR(可重复读) 隔离级别又是如何工作的呢?在这个级别下,事务对同一条数据的多次读取结果是 一致的。即使其他事务修改了数据并提交,你也无法看到这些更改的值,这就有效避免了不可重复读的问题。

此外,如果其他事务插入了新的数据,RR 隔离级别下你是看不到这些新插入的数据的,从而避免了幻读问题。

那么,RR 隔离级别 是如何实现这一切的呢?接下来我们将通过一个例子来详细解读。

此时,事务A发起了第一次查询,这时会生成一个新的 ReadView。在这个 ReadView 中:

  • creator_trx_id 为 60,表示事务A的事务ID。

  • min_trx_id 也是 60,表示当前事务A的最小事务ID。

  • max_trx_id 为 71,表示系统中当前最新的事务ID(即事务B的事务ID 70 加上 1)。

  • m_ids 为 [60, 70],表示当前活跃的事务ID,包括事务A和事务B。

此时,事务A的 ReadView 如下所示:

此时,事务A基于这个 ReadView 去查询数据,发现该数据的 trx_id 为 50,trx_id 小于 ReadView 中的 min_trx_id(即 60)。这意味着,在事务A发起查询之前,事务ID为 50 的事务已经插入并提交了这条数据。因此,事务A能够读取到该数据的原始值,如下图所示。

接着,事务B更新了这条数据,将其值修改为“值B”。此时,数据的 trx_id 被更新为 70,并且事务B生成了一个 undo log。关键的是,事务B已经提交,意味着事务B已完成并生效,如下图所示。

此时,大家可以思考一个问题:ReadView 中的 m_ids 还会是 60 和 70 吗?

答案是 会的。因为一旦生成了 ReadView,它就不会发生变化。虽然事务B已经提交并结束,但事务A的 ReadView 中仍然会包含事务A(60)和事务B(70)这两个事务ID。

这意味着,在事务A查询时,事务B正处于运行状态。这就是 ReadView 的机制所在,它反映了事务A查询时的数据隔离状态。

接下来,事务A进行查询时,会发现数据的 trx_id 已经变成了 70。这个值既在 ReadViewmin_trx_idmax_trx_id 的范围内,也包含在 m_ids 列表中。

这说明什么呢?这说明,至少在事务A发起查询时,事务B(ID为70) 仍在运行,且已经更新了这条数据。因此,事务A是无法看到事务B更新后的值的。

于是,事务A会继续沿着 undo log 的历史版本链查找数据的旧版本,直到找到事务A可以读取的数据版本,如下图所示。

接着,安装 4.4.3👍👍🙋‍♂️🙋‍♂️🙋‍♂️不可见的情况下,读取的是哪里的数据呢? 中的“逐层应用 ReadView ”这个规则,事务A 顺着 undo log 的指针找到了一条数据,trx_id 为 50。

  1. 本事务修改的版本 trx_id=creator_trx_id?(50 = 60)? fasle,不可见

  2. 已提交的旧事务版本 trx_id < min_trx_id (50 < 60) true,命中 读数据为原始值,结束

  3. 未提交或活跃事务的版本 trx_id ∈ m_ids ,不可见

  4. 未来事务的版本 trx_id >= max_trx_id,不可见

  5. 介于 min 和 max 之间但不在 m_ids 中的版本 trx_id ∈ (min_trx_id, max_trx_id),可见

此时,事务A查询到的就是这条数据的 原始值,如图所示。

是不是觉得一下子就解决了 不可重复读 的问题?

事务A多次读取同一条数据时,每次都会读到相同的值,除非事务A自己修改了这条数据。其他事务的修改对事务A是不可见的,因为事务A的 ReadView 在整个查询过程中始终不变,确保事务A看到的值是稳定一致的。

无论其他事务如何修改数据,事务A始终基于同一个 ReadView,确保读取到的数据不会改变。

接下来,我们来看看 幻读 问题是如何解决的。假设此时事务A执行了查询:select * from x where id>10,在这个查询中,事务A可能只会读取到一条数据,而且读取到的是该数据的原始值,原因之前已经解释过了。如下图:

现在有一个事务C插入了一条数据,并且已经提交

接着,事务A再次执行查询,发现符合条件的数据有两条:一条是原始值的那条数据,另一条是事务C插入的新数据。然而,事务C插入的这条数据的 trx_id 为 80,且 80 大于事务A ReadView 中的 max_trx_id

这意味着,事务C的插入是在事务A发起查询之后才发生的,因此事务A无法查询到这条新插入的数据。

因此,事务A本次查询仍然只能读取到原始值那条数据,如下图所示。(两次遍历,txr_id = 70 的不满足 ReadView 条件)

可以看到,事务A在这里根本不会发生幻读问题。每次基于相同的查询条件进行范围查询时,事务A读取的数据始终是一样的,不会读到其他事务插入的新数据。这一切都是通过 ReadView 机制实现的!

至此,我们已经完整讲解了如何通过 ReadView 机制实现 RR(可重复读) 隔离级别,并避免了 不可重复读幻读 问题。下面,我们将对多个事务的并发隔离机制进行总结。

8. 👋👋👋 回顾、总结、预告... ...

今天,我们来简单梳理一下 MySQL 中多事务并发运行的隔离原理。其实,这套隔离原理的核心就是 MVCC(多版本并发控制机制),专门用于控制多个事务并发运行时,它们之间如何相互影响。

8.1. 问题回顾

在多个事务并发执行的情况下,可能会出现以下几个问题:

  1. 脏写:两个事务同时更新同一条数据,其中一个事务回滚,导致另一个事务的更新丢失。

  2. 脏读:一个事务读取到另一个事务未提交的修改数据,若后者回滚,前者读取到的数据就不存在了。

  3. 不可重复读:多次读取相同数据时,另一个事务对数据的修改导致读到的值不同。

  4. 幻读:范围查询时,其他事务插入的新数据使查询结果发生变化。

8.2. 隔离级别与问题解决

为了解决这些问题,数据库定义了不同的事务隔离级别:

  1. RU(读未提交):可以读取到其他事务未提交的数据,只能避免脏写问题。

  2. RC(读已提交):可以读取到已经提交的事务数据,避免脏写和脏读问题。

  3. RR(可重复读):不会读取到其他已提交事务修改的数据,避免脏写、脏读和不可重复读问题。

  4. 串行化:强制事务串行执行,避免所有问题。



8.3. MySQL的MVCC实现

在 MySQL 中,MVCC 是通过 undo log 的多版本链条ReadView 机制 来实现的。默认的 RR(可重复读) 隔离级别就是基于这套机制的,通过 MVCC 实现了以下效果:

  • 避免 脏写脏读

  • 避免 不可重复读

  • 还能解决 幻读 问题。

因此,在大多数情况下,使用默认的 RR 隔离级别 就足够了。

8.4. 锁机制的引入

在多事务并发的场景下,如果多个事务同时更新同一条数据,就需要 加锁 来保证数据一致性。锁机制是解决并发冲突的关键。

接下来,我们将深入探讨 MySQL 的 锁机制,它涉及多个层级的加锁策略以及如何在复杂的事务环境中确保数据的一致性。锁机制非常复杂,我们会分多个篇幅详细解读,之后会结合实际案例进行讲解。

0

评论区