
小叶-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 uncommitted1.5.2 读已提交read committed1.5.2.1 不可重复读现象是否是问题1.5.3 可重复读repeatable read1.5.4 串行化serializable1.5.5 隔离级别实操总结二、一致性事务的最终归宿三、进阶原理MVCC 多版本并发控制3.1 MVCC 解决了什么问题3.2 MVCC 三大基础前置知识点隐式字段、undo log、read view3.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 blance123.0 where id1; --更新指定行 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的数据[insertdelete同样] | 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 blance321.0 where id1; --更新张三数据 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 readMySQL 默认隔离级别解决了脏读和不可重复读同时通过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 blance4321.0 where id1; --更新数据 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 blance1.00 where id1; --终端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 的可重复读隔离级别没有加锁为什么还能解决不可重复读和幻读为什么多个事务同时读写数据不会互相阻塞答案就是MVCCMulti-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_ID6 字节最近一次修改插入 / 更新这条记录的事务 ID事务 ID 是单向递增的事务开启时分配。DB_ROLL_PTR7 字节回滚指针指向这条记录的上一个历史版本所有历史版本都保存在 undo 日志中通过这个指针形成版本链。DB_ROW_ID6 字节隐藏的自增主键如果我们的表没有定义主键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)如果带上三个隐藏字段显示nameageDB_TRX_ID (创建该记录的事务 ID)DB_ROW_ID (隐式主键)DB_ROLL_PTR (回滚指针)张三28null1null我们目前并不知道创建该记录的事务 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 为 10undo 日志中没有历史版本事务 11 执行 update student set name李四 where id1;先把原始数据拷贝到 undo 日志修改当前记录为李四DB_TRX_ID设为 11DB_ROLL_PTR指向 undo 日志中的张三版本事务 12 执行 update student set age38 where id1;再次拷贝当前记录到 undo 日志修改 age 为 38DB_TRX_ID设为 12DB_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。所以现在的问题就是当前快照读应不应该读到当前版本记录。一张图解决所有问题整体流程假设当前有条记录nameageDB_TRX_ID (创建该记录的事务 ID)DB_ROW_ID (隐式主键)DB_ROLL_PTR (回滚指针)张三28null1null事务操作事务 1 [id1]事务 2 [id2]事务 3 [id3]事务 4 [id4]事务开始事务开始事务开始事务开始………修改且已提交进行中快照读进行中………事务 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 ViewMySQL 就能判断哪个历史版本对当前事务是可见的规则如下按优先级判断如果版本的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都会拿到最新的已提交事务的修改同一个事务内两次 selectRead 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 与隔离级别之间的关联逻辑。在实际开发中也需要结合业务数据一致性要求合理选择隔离级别规避并发读写带来的数据风险。 建议大家动手搭建实验环境复现文中案例加深对整套机制的理解。