🔥小叶-duck:个人主页
❄️个人专栏:《Data-Structure-Learning》《C++入门到进阶&自我学习过程记录》
《Linux系统从入门到实践》《Linux网络从入门到实践》
《Qt 方寸极境》 《MySQL》
✨未择之路,不须回头
已择之路,纵是荆棘遍野,亦作花海遨游
目录
前言
一、事务隔离级别:解决并发读写的核心
1.1 初次如何理解隔离性
1.2 并发事务带来的 3 个核心问题
1.3 MySQL 的 4 种隔离级别
1.4 隔离级别的查看与设置
1.5 4 种隔离级别实操演示
1.5.1 读未提交(read uncommitted)
1.5.2 读已提交(read committed)
1.5.2.1 不可重复读现象是否是问题?
1.5.3 可重复读(repeatable read)
1.5.4 串行化(serializable)
1.5.5 隔离级别实操总结
二、一致性:事务的最终归宿
三、进阶原理:MVCC 多版本并发控制
3.1 MVCC 解决了什么问题?
3.2 MVCC 三大基础前置知识点:隐式字段、undo log、read view
3.2.1 InnoDB 行记录的 3 个隐藏字段
3.2.2 undo 日志
3.2.3 Read View(读视图)
3.3 版本可见性判断规则
3.4 当前读 vs 快照读
3.5 RR 与 RC 的本质区别
四、面试高频考点总结
结束语
前言
当多个事务并发读写同一份数据时,仅仅依靠事务基础特性远远不够,还会出现脏读、不可重复读、幻读等数据读取异常。MySQL 通过事务隔离级别平衡并发性能与数据一致性,而 InnoDB 能够实现高并发读写,核心依靠 MVCC 多版本并发控制机制。 本篇承接上文事务基础内容,逐层拆解四类并发问题、四种隔离级别,结合实操演示区分快照读与当前读,最终剖析 RC、RR 隔离级别下 MVCC 的实现差异,理清面试高频底层原理。
一、事务隔离级别:解决并发读写的核心
隔离性是 ACID 中最复杂的特性,MySQL 通过 4 种隔离级别,让我们可以在「数据安全性」和「并发性能」之间做平衡。
1.1 初次如何理解隔离性
- MySQL 服务可能会同时被多个客户端进程 (线程) 访问,访问的方式以事务方式进行
- 一个事务可能由多条 SQL 构成,也就意味着,任何一个事务,都有执行前,执行中,执行后的阶段。而所谓的原子性,其实就是让用户层,要么看到执行前,要么看到执行后。执行中出现问题,可以随时回滚。所以单个事务,对用户表现出来的特性,就是原子性。
- 但,毕竟所有事务都要有个执行过程,那么在多个事务各自执行多个 SQL 的时候,就还是有可能会出现互相影响的情况。比如:多个事务同时访问同一张表,甚至同一行数据。
- 就如同你妈妈给你说:你要么别学,要学就学到最好。至于你怎么学,中间有什么困难,你妈妈不关心。那么你的学习,对你妈妈来讲,就是原子的。那么你学习过程中,很容易受别人干扰,此时,就需要将你的学习隔离开,保证你的学习环境是健康的。
- 数据库中,为了保证事务执行过程中尽量不受干扰,就有了一个重要特征:隔离性
- 数据库中,允许事务受不同程度的干扰,就有了一种重要特征:隔离级别
1.2 并发事务带来的 3 个核心问题
在讲解隔离级别之前,我们先搞懂并发事务会带来的 3 个经典问题,这也是面试必考点:
| 问题名称 | 定义说明 |
|---|---|
| 脏读 | 一个事务读到了另一个事务未提交的修改数据。如果另一个事务回滚,读到的数据就是无效的脏数据。 |
| 不可重复读 | 同一个事务内,多次执行相同的 select 语句,读到的结果不一样。核心原因是其他事务对数据做了修改 / 删除并提交。 |
| 幻读 | 同一个事务内,多次执行相同的 select 语句,第二次读到了第一次没有的新增记录,就像出现了幻觉。核心原因是其他事务做了新增并提交。 |
1.3 MySQL 的 4 种隔离级别
MySQL 提供了 4 种隔离级别,从低到高分别是:读未提交(read uncommitted)、读已提交(read committed)、可重复读(repeatable read)、串行化(serializable)。
其中,可重复读(repeatable read,简称 RR)是 MySQL 的默认隔离级别。
4 种隔离级别对问题的解决能力:
| 隔离级别 | 脏读 | 不可重复读 | 幻读 | 加锁读 |
|---|---|---|---|---|
| 读未提交(read uncommitted) | 会发生 | 会发生 | 会发生 | 不加锁 |
| 读已提交(read committed) | 不会发生 | 会发生 | 会发生 | 不加锁 |
| 可重复读(repeatable read) | 不会发生 | 不会发生 | 不会发生(MySQL 通过Next-Key锁解决) | 不加锁 |
| 串行化(serializable) | 不会发生 | 不会发生 | 不会发生 | 全加锁 |
1.4 隔离级别的查看与设置
-- 查看全局隔离级别 select @@global.tx_isolation; -- 查看当前会话的隔离级别 select @@session.tx_isolation; select @@tx_isolation; -- 默认查看当前会话 -- 设置当前会话的隔离级别 set session transaction isolation level 隔离级别名称; -- 设置全局的隔离级别(重启客户端后生效) set global transaction isolation level 隔离级别名称; -- 示例:设置全局隔离级别为读未提交 set global transaction isolation level read uncommitted; -- 示例:设置当前会话隔离级别为串行化 set session transaction isolation level serializable;1.5 4 种隔离级别实操演示
1.5.1读未提交(read uncommitted)
这是最低的隔离级别,几乎没有隔离性,会出现脏读,生产环境严禁使用。
--几乎没有加锁,虽然效率高,但是问题太多,严重不建议采用 --终端A -- 设置隔离级别为 读未提交 mysql> set global transaction isolation level read uncommitted; Query OK, 0 rows affected (0.00 sec) --重启客户端 mysql> select @@tx_isolation; +----------------+ | @@tx_isolation | +----------------+ | READ-UNCOMMITTED | +----------------+ 1 row in set, 1 warning (0.00 sec) mysql> select * from account; +----+------+--------+ | id | name | blance | +----+------+--------+ | 1 | 张三 | 100.00 | | 2 | 李四 | 10000.00 | +----+------+--------+ 2 rows in set (0.00 sec) mysql> begin; --开启事务 Query OK, 0 rows affected (0.00 sec) mysql> update account set blance=123.0 where id=1; --更新指定行 Query OK, 1 row affected (0.05 sec) Rows matched: 1 Changed: 1 Warnings: 0 --没有commit哦!!! --终端B mysql> begin; mysql> select * from account; +----+------+---------+ | id | name | blance | +----+------+---------+ | 1 | 张三 | 123.00 | --读到终端A更新但是未commit的数据[insert,delete同样] | 2 | 李四 | 10000.00| +----+------+--------+ 2 rows in set (0.00 sec) --一个事务在执行中,读到另一个执行中事务的更新(或其他操作)但是未commit的数据,这种现象叫做脏读(dirty read)核心问题:脏读,读到了其他事务未提交的数据,一旦其他事务回滚,数据就失效了。
1.5.2读已提交(read committed)
这是大多数数据库(Oracle 、SQL Server)的默认隔离级别,解决了脏读,但会出现不可重复读。
-- 终端A mysql> set global transaction isolation level read committed; Query OK, 0 rows affected (0.00 sec) --重启客户端 mysql> select * from account; --查看当前数据 +----+------+--------+ | id | name | blance | +----+------+--------+ | 1 | 张三 | 123.00 | | 2 | 李四 | 10000.00 | +----+------+--------+ 2 rows in set (0.00 sec) mysql> begin; --手动开启事务,同步的开始终端B事务 Query OK, 0 rows affected (0.00 sec) mysql> update account set blance=321.0 where id=1; --更新张三数据 Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0 --切换终端到终端B,查看数据。 mysql> commit; --commit提交! Query OK, 0 rows affected (0.01 sec) --切换终端到终端B,再次查看数据。 ---------------------------------------------------------------------- --终端B mysql> begin; --手动开启事务,和终端A一前一后 Query OK, 0 rows affected (0.00 sec) mysql> select * from account; --终端A commit之前,看不到 +----+------+--------+ | id | name | blance | +----+------+--------+ | 1 | 张三 | 123.00 | --老的值 | 2 | 李四 | 10000.00 | +----+------+--------+ 2 rows in set (0.00 sec) --终端A commit之后,看到了! --but,此时还在当前事务中,并未commit,那么就造成了,同一个事务内,同样的读取,在不同的时间段(依旧还在事务操作中!),读取到了不同的值,这种现象叫做不可重复读(non repeatable read)!!(这个是问题吗??) mysql> select *from account; +----+------+--------+ | id | name | blance | +----+------+--------+ | 1 | 张三 | 321.00 | --新的值 | 2 | 李四 | 10000.00 | +----+------+--------+ 2 rows in set (0.00 sec)核心问题:不可重复读,同一个事务内,相同的查询语句,两次读到的结果不一样。
1.5.2.1 不可重复读现象是否是问题?
1.5.3可重复读(repeatable read)
MySQL 默认隔离级别,解决了脏读和不可重复读,同时通过Next-Key 锁(间隙锁 + 行锁)解决了幻读问题。
可重复读验证:
--终端A mysql> set global transaction isolation level repeatable read; --设置全局隔离级别 Query OK, 0 rows affected (0.01 sec) --关闭终端重启 mysql> select @@tx_isolation; +----------------+ | @@tx_isolation | +----------------+ | REPEATABLE-READ | --隔离级别RR +----------------+ 1 row in set, 1 warning (0.00 sec) mysql> select *from account; --查看当前数据 +----+------+--------+ | id | name | blance | +----+------+--------+ | 1 | 张三 | 321.00 | | 2 | 李四 | 10000.00 | +----+------+--------+ 2 rows in set (0.00 sec) mysql> begin; --开启事务,同步的,终端B也开始事务 Query OK, 0 rows affected (0.00 sec) mysql> update account set blance=4321.0 where id=1; --更新数据 Query OK, 1 row affected (0.00 sec) Rows matched: 1 Changed: 1 Warnings: 0 -- 不commit --切换到终端B,查看另一个事务是否能看到 --终端B mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> select * from account; --终端A中事务 commit之前,查看当前表中数据,数据未更新 +----+------+--------+ | id | name | blance | +----+------+--------+ | 1 | 张三 | 321.00 | | 2 | 李四 | 10000.00 | +----+------+--------+ 2 rows in set (0.00 sec) mysql> select * from account; --终端A中事务 commit 之后,查看当前表中数据,数据未更新 +----+------+--------+ | id | name | blance | +----+------+--------+ | 1 | 张三 | 321.00 | | 2 | 李四 | 10000.00 | +----+------+--------+ 2 rows in set (0.00 sec) --可以看到,在终端B中,事务无论什么时候进行查找,看到的结果都是一致的,这叫做可重复读! mysql> commit; --结束事务 Query OK, 0 rows affected (0.00 sec) mysql> select * from account; --再次查看,看到最新的更新数据 +----+------+--------+ | id | name | blance | +----+------+--------+ | 1 | 张三 | 4321.00 | | 2 | 李四 | 10000.00 | +----+------+--------+ 2 rows in set (0.00 sec)幻读验证:
--终端A mysql> select *from account; +----+------+--------+ | id | name | blance | +----+------+--------+ | 1 | 张三 | 321.00 | | 2 | 李四 | 10000.00 | +----+------+--------+ 2 rows in set (0.00 sec) mysql> begin; --开启事务,终端B同步开启 Query OK, 0 rows affected (0.00 sec) mysql> insert into account (id,name,blance) values(3, '王五', 5432.0); Query OK, 1 row affected (0.00 sec) -- 不commit --切换终端到终端B,查看数据。 mysql> select * from account; +----+------+--------+ | id | name | blance | +----+------+--------+ | 1 | 张三 | 4321.00 | | 2 | 李四 | 10000.00 | | 3 | 王五 | 5432.00 | +----+------+--------+ 3 rows in set (0.00 sec) --终端B mysql> begin; --开启事务 Query OK, 0 rows affected (0.00 sec) mysql> select * from account; --终端A commit前 查看 +----+------+--------+ | id | name | blance | +----+------+--------+ | 1 | 张三 | 4321.00 | | 2 | 李四 | 10000.00 | +----+------+--------+ 2 rows in set (0.00 sec) mysql> select * from account; --终端A commit后 查看 +----+------+--------+ | id | name | blance | +----+------+--------+ | 1 | 张三 | 4321.00 | | 2 | 李四 | 10000.00 | +----+------+--------+ 2 rows in set (0.00 sec) mysql> commit; --结束事务 Query OK, 0 rows affected (0.00 sec) mysql> select * from account; --看到更新 +----+------+--------+ | id | name | blance | +----+------+--------+ | 1 | 张三 | 4321.00 | | 2 | 李四 | 10000.00 | | 3 | 王五 | 5432.00 | +----+------+--------+ 3 rows in set (0.00 sec)1.5.4串行化(serializable)
最高的隔离级别,强制所有事务串行执行,完全解决了脏读、不可重复读、幻读,但并发性能极差,生产环境几乎不使用。
--对所有操作全部加锁,进行串行化,不会有问题,但是只要串行化,效率很低,几乎完全不会被采用 --终端A mysql> set global transaction isolation level serializable; Query OK, 0 rows affected (0.00 sec) mysql> select @@tx_isolation; +----------------+ | @@tx_isolation | +----------------+ | SERIALIZABLE | +----------------+ 1 row in set, 1 warning (0.00 sec) mysql> begin; --开启事务,终端B同步开启 Query OK, 0 rows affected (0.00 sec) mysql> select * from account; --两个读取不会串行化,共享锁 +----+------+--------+ | id | name | blance | +----+------+--------+ | 1 | 张三 | 4321.00| | 2 | 李四 |10000.00| | 3 | 王五 | 5432.00| +----+------+--------+ 3 rows in set (0.00 sec) mysql> update account set blance=1.00 where id=1; --终端A中有更新或者其他操作,会阻塞。直到终端B事务提交。 Query OK, 1 row affected (18.19 sec) Rows matched: 1 Changed: 1 Warnings: 0 --终端B mysql> begin; Query OK, 0 rows affected (0.00 sec) mysql> select * from account; --两个读取不会串行化 +----+------+--------+ | id | name | blance | +----+------+--------+ | 1 | 张三 | 4321.00| | 2 | 李四 |10000.00| | 3 | 王五 | 5432.00| +----+------+--------+ 3 rows in set (0.00 sec) mysql> commit; --提交之后,终端A中的update才会提交。 Query OK, 0 rows affected (0.00 sec)核心特点:所有读写操作都会加锁,事务只能串行执行,安全性最高,但并发性能最低。
1.5.5 隔离级别实操总结
- 其中隔离级别越严格,安全性越高,但数据库的并发性能也就越低,往往需要在两者之间找一个平衡点。
- 不可重复读的重点是修改和删除:同样的条件,你读取过的数据,再次读取出来发现值不一样了;幻读的重点在于新增:同样的条件,第 1 次和第 2 次读出来的记录数不一样
- 说明:mysql 默认的隔离级别是可重复读,一般情况下不要修改
- 上面的例子可以看出,事务也有长短事务这样的概念。事务间互相影响,指的是事务在并行执行的时候,即都没有 commit 的时候,影响会比较大。
二、一致性:事务的最终归宿
我们前面讲了原子性、隔离性、持久性,最终都是为了保证一致性。
一致性分为两个层面:
- 数据库层面的一致性:事务执行前后,数据库的完整性约束(主键、外键、唯一索引、非空约束等)不会被破坏,比如主键不会重复、库存不会出现负数。
- 业务层面的一致性:这是由我们的业务代码决定的,比如转账前后两个账户的总金额不变、订单创建后库存必须扣减、用户下单后优惠券必须标记为已使用。
MySQL 的事务机制,为我们提供了保证一致性的技术基础(原子性、隔离性、持久性),但最终的业务一致性,还是需要我们的业务代码来保证。一句话总结:通过 AID(原子性、隔离性、持久性)来保证 C(一致性)。
三、进阶原理:MVCC 多版本并发控制
很多同学会好奇:MySQL 的可重复读隔离级别,没有加锁,为什么还能解决不可重复读和幻读?为什么多个事务同时读写数据,不会互相阻塞?
答案就是:MVCC(Multi-Version Concurrency Control)多版本并发控制,它是 MySQL 用来解决「读 - 写冲突」的无锁并发控制机制,也是 InnoDB 高性能的核心。
3.1 MVCC 解决了什么问题?
数据库的并发场景分为三种:
- 读 - 读:不存在任何问题,不需要并发控制;
- 写 - 写:有线程安全问题,需要加锁控制,会出现更新丢失;
- 读 - 写:有线程安全问题,会出现脏读、不可重复读、幻读,传统方案是加锁,会导致性能大幅下降。
而 MVCC 就是为了解决「读 - 写冲突」,实现了:读操作不会阻塞写操作,写操作也不会阻塞读操作,大幅提升了数据库的并发读写性能,同时解决了脏读、不可重复读、幻读等隔离性问题。
3.2 MVCC 三大基础前置知识点:隐式字段、undo log、read view
要理解 MVCC,必须先搞懂三个核心基础:3 个隐藏字段、undo 日志、Read View。
3.2.1InnoDB 行记录的 3 个隐藏字段
之前我们学习创建的每一行记录,除了我们定义的字段,InnoDB 会自动添加 3 个隐藏字段:
| 隐藏字段名 | 长度 | 作用说明 |
|---|---|---|
| DB_TRX_ID | 6 字节 | 最近一次修改(插入 / 更新)这条记录的事务 ID,事务 ID 是单向递增的,事务开启时分配。 |
| DB_ROLL_PTR | 7 字节 | 回滚指针,指向这条记录的上一个历史版本,所有历史版本都保存在 undo 日志中,通过这个指针形成版本链。 |
| DB_ROW_ID | 6 字节 | 隐藏的自增主键,如果我们的表没有定义主键,InnoDB 会自动以这个字段生成聚簇索引;如果表有主键,这个字段就不会存在。 |
除此之外,还有一个隐藏的删除标记字段 flag:记录被更新或删除时,并不是真的从磁盘删除,而是把这个标记置为删除,后续由 purge 线程清理。
假设测试表结构是:
mysql> create table if not exists student( name varchar(11) not null, age int not null ); mysql> insert into student (name, age) values ('张三', 28); Query OK, 1 row affected (0.05 sec) mysql> select * from student; +------+-----+ | name | age | +------+-----+ | 张三 | 28 | +------+-----+ 1 row in set (0.00 sec)如果带上三个隐藏字段显示:
| name | age | DB_TRX_ID (创建该记录的事务 ID) | DB_ROW_ID (隐式主键) | DB_ROLL_PTR (回滚指针) |
|---|---|---|---|---|
| 张三 | 28 | null | 1 | null |
我们目前并不知道创建该记录的事务 ID;隐式主键,我们就默认设置成 null;第一条记录也没有其他版本,我们设置回滚指针为 null。
3.2.2undo 日志
undo 日志简单理解就是MySQL 的历史数据快照缓冲区。
当我们对一条记录执行update/delete时,InnoDB 会先把这条记录的原始数据拷贝到 undo 日志中,然后再修改当前记录,同时把当前记录的DB_ROLL_PTR指向 undo 日志中的历史版本。
多次修改后,undo 日志中就会形成一条基于链表的历史版本链,事务的回滚、MVCC 的快照读,都是基于这个版本链实现的。
举个例子:
- 插入一条记录 insert into student (name,age) values ('张三',28);,此时事务 ID 为 10,undo 日志中没有历史版本;
- 事务 11 执行 update student set name='李四' where id=1;,先把原始数据拷贝到 undo 日志,修改当前记录为李四,DB_TRX_ID设为 11,DB_ROLL_PTR指向 undo 日志中的张三版本;
- 事务 12 执行 update student set age=38 where id=1;,再次拷贝当前记录到 undo 日志,修改 age 为 38,DB_TRX_ID设为 12,DB_ROLL_PTR指向李四的版本;
- 最终形成「张三→李四→最新记录」的版本链。
这样,我们就有了一个基于链表记录的历史版本链。所谓的回滚,无非就是用历史数据,覆盖当前数据。
上面的一个一个版本,我们可以称之为一个一个的快照。
3.2.3Read View(读视图)
Read View 是事务执行快照读时,生成的一个读视图,它记录了生成 Read View 的那一刻,MySQL 中当前活跃的(已开启但未提交)事务 ID 列表。
简单理解:Read View 就是事务快照读的那一刻,给数据库的活跃事务拍了一张照片,后续通过这张照片,判断版本链中的哪个数据版本对当前事务是可见的。
Read View在MySQL 源码中,就是一个类,本质是用来进行可见性判断的。即当我们某个事务执行快照读的时候,对该记录创建一个 Read view 读视图,把它比作条件,用来判断当前事务能够看到哪个版本的数据,既可能是当前最新的数据,也有可能是该行记录的 undo log 里面的某个版本的数据。
下面是 Readview 结构,但为了减少同学们负担,我们简化一下:
class ReadView { // 省略... private: /** 高水位:大于等于这个ID的事务均不可见*/ trx_id_t m_low_limit_id /** 低水位:小于这个ID的事务均可见 */ trx_id_t m_up_limit_id; /** 创建该 Read View 的事务ID*/ trx_id_t m_creator_trx_id; /** 创建视图时的活跃事务id列表*/ ids_t m_ids; /** 配合purge,标识该视图不需要小于m_low_limit_no的UNDO LOG, * 如果其他视图也不需要,则可以删除小于m_low_limit_no的UNDO LOG*/ trx_id_t m_low_limit_no; /** 标记视图是否被关闭*/ bool m_closed; // 省略... };Read View 的 4 个核心字段:
| 字段名 | 作用说明 |
|---|---|
m_ids | 生成 Read View 时,系统中所有活跃的(未提交)事务 ID 列表 |
m_up_limit_id | 低水位,m_ids中最小的事务 ID,小于这个 ID 的事务,都是已提交的,对当前事务可见 |
m_low_limit_id | 高水位,生成 Read View 时,系统尚未分配的下一个事务 ID,大于等于这个 ID 的事务,对当前事务都不可见 |
m_creator_trx_id | 创建这个 Read View 的当前事务 ID |
我们在实际读取数据版本链的时候,是能读取到每一个版本对应的事务 ID 的,即:当前记录的 DB_TRX_ID。
那么,我们现在手里面有的东西就有,当前快照读的 Readview 和 版本链中的某一个记录的 DB_TRX_ID。
所以现在的问题就是,当前快照读,应不应该读到当前版本记录。一张图,解决所有问题!
整体流程
假设当前有条记录:
| name | age | DB_TRX_ID (创建该记录的事务 ID) | DB_ROW_ID (隐式主键) | DB_ROLL_PTR (回滚指针) |
|---|---|---|---|---|
| 张三 | 28 | null | 1 | null |
事务操作:
| 事务 1 [id=1] | 事务 2 [id=2] | 事务 3 [id=3] | 事务 4 [id=4] |
|---|---|---|---|
| 事务开始 | 事务开始 | 事务开始 | 事务开始 |
| … | … | … | 修改且已提交 |
| 进行中 | 快照读 | 进行中 | |
| … | … | … |
- 事务 4:修改 name (张三) 变成 name (李四)
- 当事务 2 对某行数据执行了快照读,数据库为该行数据生成一个 Read View 读视图
此时版本链是:
- 只有事务4修改过该行记录,并在事务2执行快照读前,就提交了事务。
- 我们的事务 2 在快照读该行记录的时候,就会拿该行记录的 DB_TRX_ID 去跟 up_limit_id,low_limit_id 和活跃事务 ID 列表 (trx_list) 进行比较,判断当前事务 2 能看到该记录的版本。
结论:故,事务4的更改,应该看到。 所以事务2能读到的最新数据记录是事务4所提交的版本,而事务4提交的版本也是全局角度上最新的版本。
3.3 版本可见性判断规则
有了版本链和 Read View,MySQL 就能判断哪个历史版本对当前事务是可见的,规则如下(按优先级判断):
- 如果版本的DB_TRX_ID == m_creator_trx_id:说明是当前事务自己修改的,可见;
- 如果版本的DB_TRX_ID < m_up_limit_id:说明这个版本的事务在生成 Read View 前就已经提交了,可见;
- 如果版本的DB_TRX_ID >= m_low_limit_id:说明这个版本的事务是生成 Read View 后才开启的,不可见;
- 如果版本的 DB_TRX_ID在 m_up_limit_id 和 m_low_limit_id 之间:
- 如果 DB_TRX_ID 在 m_ids 列表中:说明事务还未提交,不可见;
- 如果不在 m_ids 列表中:说明事务已经提交,可见。
如果当前版本不可见,就顺着 DB_ROLL_PTR 找到下一个历史版本,重复上面的判断,直到找到可见的版本;如果所有版本都不可见,就查询不到这条记录。
3.4 当前读 vs 快照读
MVCC 中,把 select 查询分为两种类型,这是理解 MVCC 的关键:
- 快照读:读取的是记录的历史版本(快照),不加锁,普通的select * from table就是快照读。MVCC 的无锁并发,就是基于快照读实现的。
- 当前读:读取的是记录的最新版本,会加锁。insert/update/delete,以及select ... lock in share mode(共享锁)、select ... for update(排他锁),都是当前读。
3.5 RR 与 RC 的本质区别
为什么 RC 级别会出现不可重复读,而 RR 级别不会?核心原因就是Read View 的生成时机不同。
- 读已提交(RC)级别:事务中,每一次快照读,都会生成一个全新的 Read View。
- 所以每次 select,都会拿到最新的已提交事务的修改,同一个事务内两次 select,Read View 不一样,读到的结果就不一样,出现不可重复读。
- 可重复读(RR)级别:事务中,只有第一次快照读,才会生成 Read View,后续所有的快照读,都复用这个 Read View。
所以整个事务生命周期内,看到的都是同一个快照,无论其他事务怎么修改、提交,当前事务读到的结果都一致,完美解决了不可重复读,同时也解决了幻读。
四、面试高频考点总结
- ACID 四大特性:原子性、一致性、隔离性、持久性,必须能清晰解释每个特性的含义;
- 事务的基础操作:begin/commit/rollback/savepoint 的使用,autocommit 的作用;
- 并发事务的三个问题:脏读、不可重复读、幻读,必须能说清定义和区别;
- 四种隔离级别:每个级别能解决什么问题、不能解决什么问题,MySQL 的默认级别是什么;
- MVCC 底层原理:三个隐藏字段、undo 日志、Read View、可见性规则、RC 与 RR 的本质区别;
- InnoDB 与 MyISAM 的区别:核心就是 InnoDB 支持事务、行锁、外键,MyISAM 不支持。
结束语
到这里,我们完整走完了 MySQL 事务进阶知识体系:从脏读、不可重复读、幻读三类并发问题入手,理解四种隔离级别的设计目标,再下沉到 MVCC 多版本并发控制底层机制,理清快照读、当前读以及 RC 与 RR 隔离级别最核心的区别。
很多面试中关于事务的深度考题,根源都来自 MVCC 与隔离级别之间的关联逻辑。在实际开发中,也需要结合业务数据一致性要求,合理选择隔离级别,规避并发读写带来的数据风险。 建议大家动手搭建实验环境,复现文中案例,加深对整套机制的理解。