写在文章开头
在当今的数据库世界中,MySQL 以其强大的功能和广泛的应用备受瞩目。而其中的 MVCC(多版本并发控制)和事务隔离级别更是关键且核心的概念,它们犹如数据库运行的精密齿轮,协同作用确保着数据的完整性、一致性和高效的并发处理。
当我们深入探究 MySQL
的内部机制时,MVCC
展现出其独特的魅力,它巧妙地解决了并发操作中可能产生的诸多问题。与此同时,事务隔离级别则为不同场景下的数据处理提供了灵活而精准的规则框架。理解这两者,不仅是对 MySQL 技术精髓的把握,更是开启高效数据库应用和系统开发的关键钥匙。在接下来的篇章中,我们将一同踏上这场解析 MySQL MVCC
和事务隔离级别的精彩之旅,逐步揭开它们神秘的面纱,探寻其背后蕴含的深刻原理和实际应用价值。

Hi,我是 sharkChili ,是个不断在硬核技术上作死的技术人,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili 。
同时也非常欢迎你star我的开源项目mini-redis:github.com/shark-ctrl/...
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 "加群" 即可和笔者和笔者的朋友们进行深入交流。
详解事务的基本概念
什么是事务
现在我们开发的一个功能需要进行操作多张表,假如我们遇到以下几种情况:
- 某个逻辑报错
- 数据库连接中断
- 某台服务器突然宕机
- .......
这时候我们数据库执行的操作可能才到一半,所以为了避免这种一半一半的情况,我们就需要事务来保证数据一致性。 所以事务就是当作一个原子的逻辑组操作,要么全都成功执行,要么全部都失败。事务有分分布式事务和数据库事务,如果没有特指,我们平时所说的事务都是数据库事务,也就是本文探讨的话题。

事务的四大特性
原子性(Atomicity)
原子可以看作事务的最小单位,而原子性(Atomicity)
的概念即要求一组复合操作要构成一个原子,不可在进行分割了,要么都执行成功,要么都不执行直接回滚。

隔离性(Isolation)
隔离性(Isolation)
要求在并发场景下,每个事务之间的操作互不干扰,即我们事务的操作,不会影响到其它是事务的操作结果。
持久性(Durability)
持久性(Durability)
:存储到数据库中的数据永不丢失,及时数据库发生故障,当然机器被破坏了那就另说了。
一致性(Consistency)
一致性是一个比较特殊的概念,和AID不同的是,它并非数据库的特性,按照权威的说法:
ensuring the consistency is the responsibility of user, not DBMS.", "DBMS assumes that consistency holds for each transaction
即一致性要求,从一个正确的状态转换为另一个正确的状态,它并不是DBMS
负责的范畴,而是通过DB
的AID
特定来做到这个C。
我们以转账业务为例说明一下转账操作在系统中的过程:
- 转账方余额扣除转账的金额。
- 收款方加上转账的金额。
假设我们手里又90元,希望通过系统转账100到另一个账户上,如果这个操作成功,那么我们的账户就会变为-10元,而另一个账户多了100元。
很明显这种操作并不符合上述所说的从一个正确的状态转为另一个正确的状态,我们必须做到在业务发现转账方余额小于转账额度时,将所有事务中的操作回滚,避免出现上述那种账户余额负数的非正确状态的情况。

这也就是我们上文所说的,通过MySQL的AID来保证C,C是目的,AID是手段,由此保证应用层面业务能够从正确的状态转为另一个正确的状态,以保证业务的约束,从而做到一致性。
并发事务带来那些问题
这里笔者先说一个概念,具体会在后文示例中详尽介绍
脏读:我们举个例子:
- 我们开启一个事务A,准备读取
user
表的数据。 - 此时,事务B将事务A要读取的数据修改了,但事务还没提交.
- A却能看到这个未提交的结果即sex为1
(而且这个结果后续还不一定提交)
。
这种其他事务还没提交的结果能被另一个事务看到的情况就属于脏读
。

幻读:我们再举个例子:
事务A
查询user
表,此时表中有10
条数据。- 在此期间,
事务B
插入5
条数据。 事务A
再次查发现有15
条事务。
这种同一次事务两次查询结果不一致的情况是幻读:

不可重复读,仍然举一个例子:
- 事务A读取id为1的数据,
name
为xiaoming
。 - 事务B在此期间更新id为1的数据并提交这个事务
- 结果事务A再次读取时发现
name
变了。 这就是不可重复读。

你可能会问了,这和幻读听起来是一个概念啊,他俩有什么区别? 幻读说是针对插入或者删除操作后导致数据前后不一致的情况,而不可重复读是针对两次相同查询操作出现数据不一致。也就是说幻读更多是强调前后数据集的不一致和不可重复读更多是强调数据行上的前后不一致。
数据丢失:这个就很好理解了,高并发场景下,事务A修改id为1的money+100
,事务B修改id为1的money+200
,他们统一时间读取,先后写入,这就导致如果事务A后写入,那么money
最后只加了100
,如果事务B后写入,那么money
就少了100
。
详解事务的隔离级别
读未提交(READ UNCOMMITTED)
在这个级别下,任何事务的修改操作即使没有提交,其他事务也能看到,造成我们上述所说的脏读,对此我们不妨用下面这段SQL
来验证一下:
首先我们先建个测试表:
sql
create table test2 (id int,name varchar(10),money int);
insert into test2 values(1,'xiaoming',100);
insert into test2 values(2,'xiaowang',100);
事务A开启事务,进行test2 的更新操作,不提交
sql
start transaction;
-- 小明+100元
update test2 set money = money +100 where name ='xiaoming';
-- 小王减100元
update test2 set money =money -100 where name ='xiaowang';
事务B
设置为读未提交的隔离级别:
sql
SET SESSION TRANSACTION ISOLATION LEVEL READ committed;
select * from test2 t ;
查询结果是事务B
看到了事务A
的更新操作,造成脏读。

对应结果如下:
bash
id|name |money|
--+--------+-----+
1|xiaoming| 200|
2|xiaowang| 0|
同理这个读未提交,也会造成:
- 幻读(同一个事务同一次查询记录数不一样)
- 不可重复读(同一个事务下查询记录的值不一样)
读已提交(READ COMMITTED)
这个概念也很好理解,每个事务只能看到其他事务提交后的数据。避免了脏读,但是无法避免幻读和不可重复读。 我们就以幻读为例,如下图,事务B首先查询到数据表中没有id为1的用户,在这个查询结束后,事务A进行一次插入操作但是事务还未提交。

然后事务A将数据提交,事务B再次查询就发现了数据,出现幻读:

了解流程之后,我们拿SQL印证一下,首先创建数据表
sql
drop table if exists account1;
CREATE TABLE `account1` (
`id` int(11) NOT NULL AUTO_INCREMENT,
`name` varchar(50) DEFAULT NULL,
`balance` int(11) DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `account1_un` (`name`)
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=UTF8MB4;
事务B查询,没数据
sql
SET SESSION TRANSACTION ISOLATION LEVEL READ committed;
START TRANSACTION;
-- 查询表,此时没有数据
SELECT * from account1;
事务A在此期间插入,事务不提交
sql
SET SESSION TRANSACTION ISOLATION LEVEL READ committed;
START TRANSACTION;
-- 在上一个事务查询后,插入一条事务但是不提交
insert into account1(id,name,balance) values(1,'zhangsan',1000);
此时事务B还是没看到数据,然后我们将上述的事务A数据commit,事务B看到这条数据出现幻读:

可重复读(REPEATABLE READ)
这个隔离级别,也很好理解,同一个事务内,多次查询的数据都是一样的。我们不妨基于上面的例子实验一下
首先事务B查询,没有任何数据:
ini
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
select * from account1 a where id=3;
此时xiaoming的数据为300:
bash
id|name |balance|
--+--------+-------+
3|xiaoming| 100|
事务A执行更新并提交:
ini
SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;
START TRANSACTION;
update account1 set balance=0 where id=3;
commit;
事务B再查数据还是不变,还是300:
bash
id|name |balance|
--+--------+-------+
3|xiaoming| 100|
总的来说可重复读避免了脏读和不可重复读,但是幻读还是无法避免:

串行化(SERIALIZABLE)
事务隔离最高级别,通过锁的方式控制并发流程,解决上述一切问题。
详解MVCC(多版本并发控制)
当前读和快照读
快照读:即读取数据是从快照中获取的,事务在进行事务读取时不上锁,这就是mysql
并发读写性能高的原因之一。
而当前读反之,读取数据时会上锁,这也就意味着即使你的隔离级别是可重复读,你用当前读也能读取到其他事务的最新结果,造成不可重复读。
我们举个例子,首先事务A读取数据,假设数据值是100:
sql
begin;
-- 读取到a的money为100
select * from account1 a ;
事务B更新事务并提交:
bash
update account1 set money=1000 where id=1;
事务A使用快照读,数据还是100:
sql
select * from account1 a ; --快照读 旧数据
一旦使用当前读,就是其他事务提交的新数据了:
sql
--两个都是当前读,得到最新结果
select * from account1 a for update;
select * from account1 a lock in share mode;
undo.log概念扫盲
首先说说undo log
,在innoDB
的聚簇索引中,每一条记录除了我们表中的数据以外,还会额外记录名为事务id(transaction id)
的隐藏列。每当用户对当前数据进行修改操作后,新值的数据的事务id
就会递增。 同时每行数据还有一个回滚指针(roll_pointer)
,如下图所示,每当用户对索引进行更新之后,旧的数据就会被存放到undo log
中,新的数据的回滚指针指向这条最新的旧数据(就是刚刚存到undo log中的数据,通俗的说是最新的垃圾)
,用于后续可能需要的回滚操作:

readView概念扫盲
接下来就说说readView
,readView
就是真正用到undo log
的东西,如下图所示,它由三个部分组成,分别是:
已提交事务
:已提交事务中记录的则是已经被提交的事务id
集合。活跃事务:
这个则记录那些还能活动且还没被提交的事务,其中min_trx_id
指向活跃事务的最小值。未开始事务
:这里面则是存放待使用的事务id
值,其中max_trx_id
就是记录这一块的最小值。

基于可重复读版本理解SQL的MVCC工作机制
了解了undo.log
和readView
,我们就可以了解mvcc
的工作机制了。就先以可重复读RR为例,我们来了解一下如何结合undo.log
和readView
实现可重复读的。
可重复读这个级别的readView
只会在事务刚刚开始时创建,这也就意味着后续数据无论怎么变化,readView
都以第一次创建的为主:
假设我们现在account
表数据存在一条id为1的数据xiaoming
,然后事务trx_id
为100
的事务基于RR
级别将name
先更新为xiaoming_50
然后再更新为xiaoming_100
,但是事务还没提交,此时对应的版本链如下所示:

需要注意的是,只有进行SQL
修改操作即insert
、update
、delete
才会分配一个事务id,所以我们本在进行查询之前执行一些无关紧要的update
操作,生成一个事务200
开始查询执行下面这条sql
查询,即查询id为1的数据:
sql
-- 执行一些无关紧要的update
select * from account1 a where id=1;
然后事务启动创建readView
,结合版本链记录来看,活跃但是未提交事务值为100,即min_trx_id
为100,而我们的事务为200
,这也就意味着max_trx_id
为201
,由此可得活跃未提交的读写事务m_ids
列表有100、200
之间。
所以事务200
生成readView
如下,然后顺着版本链开始获取数据首先看到xiaoming_100
事务id为100处于活跃事务列表不符合要求继续顺着指针往下走,看到xiaoming_50
也不符合要求,继续顺着指针往下走,看到xiaoming
事务id值为80小于min_trx_id
即已提交的事务中的值,所以我们事务id为200查询结果就是xiaoming
:

此时事务100
将更新结果提交,因为可重复读生成readView
永远是以第一次创建时候为主,这也就意味着查询的思路还是和上述步骤一样,查询结果仍然是trx_id
为80的xiaoming
,这里就不多做赘述了。
基于读已提交版本readView理解SQL的MVCC工作机制
读已提交版本会在每次执行查询时生成一个readView
,我们还是以上面的例子进行演示,还是事务100
触发修改但是还没提交,对应生成的版本链如下:

还是同理,执行一些无关紧要的修改操作生成本次的事务id为200
然后开始查询,因为事务100没有提交,所以活跃的事务列表数据为100
、200
生成readView
如下:

所以顺着版本链查询到结果也是小于min_trx_id
最大值为80,最终查询结果为xiaoming
。
然后事务100将结果提交,此时我们的事务200再次进行查询,由读已提交生成readView
为每次查询时可得,事务100
已提交所以该事务处于已提交事务范围,然后我们的事务200
还未提交,所以处于活跃事务列表中,所以活跃事务列表只有我们的事务200
:

由此顺着版本链定位到小于min_trx_id
的最大值为100
,顺着版本链定位到的第一个trx_id
为100
的结果是xiaoming_100
,所以事务200
查询结果就是xiaoming_100
。
关于MySQL事务一些常见问题
MySQL 的隔离级别是基于锁实现的吗
是基于锁和mvcc
共同实现的,SERIALIZABLE
这个隔离级别就是基于锁实现的,其他隔离级别都是基于mvcc
,需要补充的是REPEATABLE-READ
如果使用当前读也是基于锁实现。
MySQL 的默认隔离级别是什么
以笔者使用的MySQL8
来说使用如下命令可以看到默认级别为可重复读
:
scss
select @@transaction_isolation;
对应输出结果如下:
sql
@@transaction_isolation|
-----------------------+
REPEATABLE-READ |
小结
MySQL 的 MVCC(多版本并发控制)是其实现高效并发处理的关键机制。
通过 MVCC,在并发读写操作时,读操作不会阻塞写操作,写操作也不会阻塞读操作,极大地提高了数据库的并发性和性能。
它允许事务读取到特定版本的数据,实现了事务隔离级别的灵活控制。使得不同的事务可以看到符合其隔离级别要求的数据视图。
在 MVCC 中,每行数据都有多个版本,记录了不同事务对其的修改历史。这种方式有效地避免了锁竞争带来的性能开销和潜在的死锁问题。
对于理解和优化数据库的并发操作,MVCC 是一个至关重要的概念。深入研究和掌握它,有助于更好地设计和管理数据库系统,确保数据的一致性和高效性。
我是 sharkchili ,CSDN Java 领域博客专家 ,mini-redis 的作者,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。
同时也非常欢迎你star我的开源项目mini-redis:github.com/shark-ctrl/...
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 "加群" 即可和笔者和笔者的朋友们进行深入交流。
参考
看完这篇还不懂MySQL的MVCC机制算我输:juejin.cn/post/717023...
MVCC 水略深,但是弄懂了真的好爽!:juejin.cn/post/704404...
《MySQL是怎样运行的:从根儿上理解MySQL》
如何理解数据库事务中的一致性的概念?:www.zhihu.com/question/31...
本文使用 markdown.com.cn 排版